Parallel Workers — Fan-Out / Fan-In (TypeScript)
Overview
Golem agents process invocations sequentially. Async I/O can overlap cooperatively within one invocation, but it does not provide CPU-thread parallelism. Distribute work across multiple agent instances for parallel invocation execution. This skill covers two approaches:
- Child agents via
AgentDef.client.newPhantom(id).client— create separate agent instances, dispatch work, and collect results fork()— clone the current agent at the current execution point for lightweight parallel execution
Approach 1: Child Agent Fan-Out
Spawn child agents, call them concurrently with Promise.all, and aggregate results.
Basic Pattern
import { z } from 'zod';
import { defineAgent, method } from '@golemcloud/golem-ts-sdk';
export const Worker = defineAgent({
name: 'Worker',
id: { id: z.number() },
methods: {
process: method({ input: { data: z.string() }, returns: z.string() }),
},
});
export const WorkerImpl = Worker.implement({
init: ({ id }) => ({ id: id.id }),
methods: {
process({ data }) {
return `processed-${data}`;
},
},
});
export const Coordinator = defineAgent({
name: 'Coordinator',
id: { name: z.string() },
methods: {
fanOut: method({ input: { items: z.array(z.string()) }, returns: z.array(z.string()) }),
},
});
export const CoordinatorImpl = Coordinator.implement({
init: () => ({}),
methods: {
async fanOut({ items }) {
// Use a fresh phantom identity for each child, even across coordinators.
const promises = items.map((item, i) => Worker.client.newPhantom({ id: i }).client.process({ data: item }));
// Wait for all children to finish.
return await Promise.all(promises);
},
},
});Chunked Fan-Out
When spawning many children, batch them to limit concurrency:
async fanOutChunked({ items }) {
const chunks = arrayChunks(items, 5); // Process 5 at a time
const results: string[] = [];
for (const chunk of chunks) {
const promises = chunk.map((item, i) => Worker.client.newPhantom({ id: i }).client.process({ data: item }));
results.push(...await Promise.all(promises));
}
return results;
}
function arrayChunks<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}Fire-and-Forget with Promise Collection
For long-running work, trigger children with .trigger() and collect results via
Golem promises. A PromiseId is a nested record that holds bigints (the agent ID, the fingerprint
of the agent incarnation that created it, and the oplog index), so ship it across the
agent boundary as a bigint-aware JSON string:
import { z } from 'zod';
import {
defineAgent, method,
createPromise, awaitPromise, completePromise, PromiseId,
} from '@golemcloud/golem-ts-sdk';
export function encodePromiseId(id: PromiseId): string {
return JSON.stringify(id, (_k, v) => (typeof v === 'bigint' ? { '#bigint': v.toString() } : v));
}
export function decodePromiseId(text: string): PromiseId {
return JSON.parse(text, (_k, v) =>
v && typeof v === 'object' && '#bigint' in v ? BigInt(v['#bigint']) : v,
) as PromiseId;
}
export const RegionWorker = defineAgent({
name: 'RegionWorker',
id: { region: z.string() },
methods: {
runReport: method({ input: { promiseId: z.string() }, returns: z.void() }),
},
});
export const RegionWorkerImpl = RegionWorker.implement({
init: ({ id }) => ({ region: id.region }),
methods: {
runReport({ promiseId }) {
const report = `Report for ${this.region}: OK`;
completePromise(decodePromiseId(promiseId), new TextEncoder().encode(report));
},
},
});
// Inside a coordinator method handler:
async dispatchAndCollect({ regions }) {
// Create one promise per child.
const promiseIds = regions.map(() => createPromise());
// Fire-and-forget: trigger each child with its (encoded) promise ID.
regions.forEach((region, i) => {
RegionWorker.client.newPhantom({ region }).client.runReport.trigger({ promiseId: encodePromiseId(promiseIds[i]) });
});
// Collect all results (the agent suspends until each promise completes).
return await Promise.all(
promiseIds.map(async (pid) => new TextDecoder().decode(await awaitPromise(pid))),
);
}Error Handling
Use Promise.allSettled to handle partial failures:
async fanOutWithErrors({ items }) {
const promises = items.map((item, i) => Worker.client.newPhantom({ id: i }).client.process({ data: item }));
const settled = await Promise.allSettled(promises);
const successes: string[] = [];
const failures: string[] = [];
settled.forEach((result, i) => {
if (result.status === 'fulfilled') {
successes.push(result.value);
} else {
failures.push(`Item ${items[i]} failed: ${result.reason}`);
}
});
return { successes, failures };
}Approach 2: fork()
fork() clones the current agent at the current execution point, creating a new agent instance with the same state but a unique phantom ID. Only a durable agent can fork, and the forked agent is durable too. Use Golem promises to synchronize between the original and forked agents.
Basic Fork Pattern
import { z } from 'zod';
import {
defineAgent, method,
fork, createPromise, awaitPromise, completePromise,
} from '@golemcloud/golem-ts-sdk';
export const ForkAgent = defineAgent({
name: 'ForkAgent',
id: { name: z.string() },
methods: {
parallelCompute: method({ input: {}, returns: z.string() }),
},
});
export const ForkAgentImpl = ForkAgent.implement({
init: () => ({}),
methods: {
async parallelCompute() {
const promiseId = createPromise();
const result = fork();
switch (result.tag) {
case 'original': {
// Wait for the forked agent to complete the promise.
const bytes = await awaitPromise(promiseId);
const forkedResult = new TextDecoder().decode(bytes);
return `Combined: original + ${forkedResult}`;
}
case 'forked': {
// Do work in the forked copy.
const computed = 'forked-result';
completePromise(promiseId, new TextEncoder().encode(computed));
return 'forked done'; // This return is only seen by the forked agent.
}
}
},
},
});Multi-Fork Fan-Out
Fork multiple times for N-way parallelism:
async multiFork({ n }) {
const promiseIds = Array.from({ length: n }, () => createPromise());
for (let i = 0; i < n; i++) {
const result = fork();
if (result.tag === 'forked') {
// Each forked agent does its slice of work.
const output = `result-from-fork-${i}`;
completePromise(promiseIds[i], new TextEncoder().encode(output));
return []; // Forked agent exits here.
}
}
// Original agent collects all results.
return await Promise.all(
promiseIds.map(async (pid) => new TextDecoder().decode(await awaitPromise(pid))),
);
}When to Use Which Approach
| Criteria | Child Agents | fork() |
|---|---|---|
| Work is independent and stateless | ✅ Best fit | Works but overkill |
| Need to share current state with workers | ❌ Must pass via args | ✅ Forked copy inherits state |
| Need a caller-selected identity | ✅ Use client.get(id) with a coordinator/job namespace | Generated phantom ID; durable and addressable with client.getPhantom(id, phantomId) |
| Number of parallel tasks is dynamic | ✅ Spawn as many as needed | ✅ Fork in a loop |
| Need simple error isolation | ✅ Child failure doesn’t crash parent | ⚠️ Forked agent shares oplog lineage |
Key Points
- No threads: Golem is single-threaded per agent — parallelism is achieved by distributing across agent instances
- Durability: All RPC calls, promises, and fork operations are durably recorded — work survives crashes
- Deadlock avoidance: Never have two agents awaiting each other synchronously — use
.trigger()to break cycles - Identity: These examples create fresh durable phantoms with
newPhantom(...).client. Retain the returned phantom ID to address one later withgetPhantom. If usingget(id)instead, namespace IDs by coordinator and job to avoid sharing children unintentionally. - Cleanup: Durable children and forks persist after the coordinator finishes. Delete only instances the coordinator owns and no caller still needs; never delete shared workers merely because one job finished.