A worker claims the inventory task and disappears. Another worker cannot run the same unit at the same time, but it should not wait forever either. If the only copy of the state lives in the supervisor's conversation, the workflow stopped with the first process.
In this tutorial, you will build multi-agent orchestration in TypeScript that survives this failure. PostgreSQL stores tasks, dependencies, attempts, leases, and events. Two workers compete for work without duplicating it, a transient failure gets retried, and review starts only after inventory and testing finish.
Tutorial outcome
- A runnable workflow with
inventory,test, andreview.- Durable state outside agent context.
- Atomic task claims using leases.
- Recovery from an interrupted worker and bounded retries.
- An interface for replacing the simulated agent with Codex or Claude Code.

When is a multi-agent architecture worth using?
Use several agents when the work has independent boundaries, requires different tools, or needs permissions you want to separate. Microsoft's 2026 documentation, "AI agent orchestration patterns", recommends using the least complex design that can solve the problem. One agent with tools remains the easiest default to debug.
Fan-out starts to earn its keep when a task supports parallel investigation or genuine risk separation. One worker can map the repository without write access. Another can run tests in an isolated worktree. A third reviews the artifacts without permission to change the patch.
Do not multiply agents when all of them depend on the same file, must follow a short sequence, or carry the same context. OpenAI's "Subagents" documentation recommends parallelism first for exploration, tests, triage, and synthesis. It also warns that parallel writing raises conflict and coordination costs.
| Situation | Initial choice |
|---|---|
| A short task with known tools | One agent |
| Parallel reading with central synthesis | Subagents |
| Independent modules that need to communicate | Agent team |
| Long workflow with resumption and dependencies | Durable orchestrator |
This tutorial goes deeper into subagent fan-out for code migrations. That article focuses on partitioning a change. This one focuses on the mechanism that claims, resumes, and verifies each unit.
What architecture will you implement?
The supervisor sits outside the agents. It decides which tasks are ready, recovers expired leases, and closes the workflow. Workers receive a typed task, call an AgentAdapter, and return a small artifact. PostgreSQL is the source of truth for state and dependencies.
Microsoft recommends persisting progress and intermediate results in durable storage when orchestration spans interactions or long-running executions. The same document treats node failure, lost messages, and cascading errors as classic distributed systems problems. A prompt is not a substitute for a timeout, retry policy, or transaction.
The workflow follows this order:
inventoryidentifies the scope and produces evidence.testwaits forinventory, runs a check, and may be retried.reviewwaits fortestand treats earlier results as claims that need verification.- The supervisor stops when every task reaches a terminal state.
The outputs are not complete transcripts. They contain summary and evidence. When a workflow must span more sessions and subagents without resending raw logs, I use RemoteCode to take Codex and Claude Code further with less repeated context as a tool built by the author of this site. It belongs here because token savings depend on the handoff contract, not on hiding state in a prompt.
Prerequisites
You need Docker with Compose, Git, and Node.js 24 LTS. The official "Node.js Releases" page lists version 24 as LTS in July 2026. The example uses PostgreSQL 18, TypeScript 7.0.2, pg 8.22.0, and tsx 4.23.1.
Check your environment:
node --version
npm --version
docker version
docker compose version
The code runs on Linux, macOS, and Windows with Docker Desktop. Port 54339 must be free. If another process already uses it, change the left side of the mapping in compose.yaml and update DATABASE_URL.
Prepare the TypeScript project and PostgreSQL
Create an empty directory and install the pinned dependencies. The versions below were available on npm on July 25, 2026. Pinning makes the tutorial reproducible, while package-lock.json records the resolved dependency tree.
mkdir coding-agent-orchestrator
cd coding-agent-orchestrator
npm init -y
npm install pg@8.22.0
npm install --save-dev typescript@7.0.2 tsx@4.23.1 \
@types/node@24.13.3 @types/pg@8.20.0
mkdir src
Replace package.json with:
{
"name": "coding-agent-orchestrator",
"private": true,
"type": "module",
"scripts": {
"demo": "tsx src/index.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"pg": "8.22.0"
},
"devDependencies": {
"@types/node": "24.13.3",
"@types/pg": "8.20.0",
"tsx": "4.23.1",
"typescript": "7.0.2"
}
}
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
Create compose.yaml:
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_USER: agents
POSTGRES_PASSWORD: agents
POSTGRES_DB: agents
ports:
- "54339:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agents -d agents"]
interval: 2s
timeout: 2s
retries: 15
Start the database and wait for the health check:
docker compose up -d --wait
The command should finish with a healthy container. If your Compose version does not accept --wait, run docker compose up -d, then check docker compose ps before continuing.
Persist tasks, dependencies, and events
The minimum state must show who claimed a task, when its lease expires, how many attempts it has used, and what evidence it produced. The task_dependencies table prevents review from starting before the test. task_events keeps a transition log without turning the transcript into a database.
Create src/index.ts with the imports, types, connection, and bootstrap code:
import { Pool, type PoolClient } from "pg";
type TaskStatus = "ready" | "running" | "succeeded" | "failed";
type TaskKind = "inventory" | "test" | "review";
type Task = {
id: string;
kind: TaskKind;
input: Record<string, unknown>;
status: TaskStatus;
attempts: number;
max_attempts: number;
lease_owner: string | null;
};
type AgentOutput = { summary: string; evidence: string[] };
type AgentAdapter = (task: Task) => Promise<AgentOutput>;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL ??
"postgres://agents:agents@127.0.0.1:54339/agents",
});
const sleep = (milliseconds: number) =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function inTransaction<T>(
operation: (client: PoolClient) => Promise<T>,
): Promise<T> {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await operation(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async function bootstrap() {
await pool.query(`
CREATE TABLE IF NOT EXISTS tasks (
id text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('inventory', 'test', 'review')),
input jsonb NOT NULL DEFAULT '{}'::jsonb,
status text NOT NULL CHECK (
status IN ('ready', 'running', 'succeeded', 'failed')
),
attempts integer NOT NULL DEFAULT 0,
max_attempts integer NOT NULL DEFAULT 3,
available_at timestamptz NOT NULL DEFAULT now(),
lease_owner text,
lease_until timestamptz,
output jsonb,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS task_dependencies (
task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
depends_on text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, depends_on)
);
CREATE TABLE IF NOT EXISTS task_events (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
task_id text NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
event text NOT NULL,
worker text,
detail jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
`);
}
async function seedWorkflow() {
await pool.query(
"TRUNCATE task_events, task_dependencies, tasks RESTART IDENTITY CASCADE",
);
await pool.query(`
INSERT INTO tasks (id, kind, input, status)
VALUES
('inventory', 'inventory', '{"scope":"src"}', 'ready'),
('test', 'test', '{"command":"npm test"}', 'ready'),
('review', 'review', '{"policy":"require-evidence"}', 'ready');
INSERT INTO task_dependencies (task_id, depends_on)
VALUES ('test', 'inventory'), ('review', 'test');
`);
}
async function appendEvent(
client: PoolClient | Pool,
taskId: string,
event: string,
worker: string | null,
detail: Record<string, unknown> = {},
) {
await client.query(
`INSERT INTO task_events (task_id, event, worker, detail)
VALUES ($1, $2, $3, $4)`,
[taskId, event, worker, JSON.stringify(detail)],
);
}
seedWorkflow clears only the tables in the demonstration database. Do not copy this TRUNCATE into a shared service. In production, each run needs a workflow_id, and task creation must be idempotent.
Claim work with a transaction and lease
Two workers may query the queue at the same time. The claim must happen in the same transaction that selects the task. The PostgreSQL 18 "SELECT" documentation explains that SKIP LOCKED avoids waiting on rows another consumer has already locked. It suits queue-like tables with multiple consumers, though it does not provide a consistent general view of the data.
Add this function to the same file:
async function claimTask(
worker: string,
leaseMilliseconds = 2_000,
): Promise<Task | null> {
return inTransaction(async (client) => {
const result = await client.query<Task>(
`
SELECT t.id, t.kind, t.input, t.status, t.attempts,
t.max_attempts, t.lease_owner
FROM tasks t
WHERE t.status = 'ready'
AND t.available_at <= now()
AND NOT EXISTS (
SELECT 1
FROM task_dependencies d
JOIN tasks prerequisite ON prerequisite.id = d.depends_on
WHERE d.task_id = t.id
AND prerequisite.status <> 'succeeded'
)
ORDER BY t.created_at, t.id
FOR UPDATE SKIP LOCKED
LIMIT 1
`,
);
const task = result.rows[0];
if (!task) return null;
const claimed = await client.query<Task>(
`
UPDATE tasks
SET status = 'running',
attempts = attempts + 1,
lease_owner = $2,
lease_until = now() + ($3 * interval '1 millisecond'),
updated_at = now()
WHERE id = $1
RETURNING id, kind, input, status, attempts,
max_attempts, lease_owner
`,
[task.id, worker, leaseMilliseconds],
);
await appendEvent(client, task.id, "claimed", worker, {
leaseMilliseconds,
});
return claimed.rows[0] ?? null;
});
}
The NOT EXISTS condition releases a task only after every dependency reaches succeeded. ORDER BY keeps the order predictable among candidates. PostgreSQL's own documentation warns that LIMIT without ordering may return different subsets between executions.
The lease is not a long database lock. The transaction ends as soon as the claim is stored. After that, lease_until represents the worker's temporary right to that unit. If the process dies, another worker can recover it.
Recover an interrupted worker without duplicating the task
An expired lease returns to ready. The event must preserve the previous owner before clearing the columns. This transition must be atomic because several supervisors may scan for expirations at the same time.

Add:
async function recoverExpiredLeases() {
return inTransaction(async (client) => {
const expired = await client.query<{
id: string;
old_owner: string | null;
}>(
`
WITH expired AS (
SELECT id, lease_owner AS old_owner
FROM tasks
WHERE status = 'running' AND lease_until < now()
FOR UPDATE
)
UPDATE tasks AS task
SET status = 'ready',
lease_owner = NULL,
lease_until = NULL,
last_error = 'lease expired before completion',
updated_at = now()
FROM expired
WHERE task.id = expired.id
RETURNING task.id, expired.old_owner
`,
);
for (const task of expired.rows) {
await appendEvent(
client,
task.id,
"lease-expired",
task.old_owner,
);
}
return expired.rowCount ?? 0;
});
}
A recovered task may run again. The operation performed by the agent must therefore be idempotent or run in a disposable environment. For code, use an isolated worktree, a dedicated branch, or a patch as the artifact. Do not let two workers publish to the same branch or apply the same migration.
The example uses a short lease so the failure appears within seconds. In production, renew leases with a heartbeat and choose the duration from actual task runtimes. An arbitrarily large duration only turns a fast failure into a long wait.
Separate success, retry, and terminal failure
The worker may complete a task only while it still owns the lease. A late response cannot overwrite the result from the worker that recovered the work. Transient failures return to ready after a delay. The final attempt changes the state to failed.
Add:
async function completeTask(
task: Task,
worker: string,
output: AgentOutput,
) {
const result = await pool.query(
`
UPDATE tasks
SET status = 'succeeded',
output = $3,
lease_owner = NULL,
lease_until = NULL,
updated_at = now()
WHERE id = $1 AND status = 'running' AND lease_owner = $2
`,
[task.id, worker, JSON.stringify(output)],
);
if (result.rowCount !== 1) throw new Error(`lease lost for ${task.id}`);
await appendEvent(pool, task.id, "succeeded", worker, output);
}
async function failTask(task: Task, worker: string, error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const terminal = task.attempts >= task.max_attempts;
const result = await pool.query(
`
UPDATE tasks
SET status = $3,
available_at = CASE
WHEN $3 = 'ready'
THEN now() + ($4 * interval '1 millisecond')
ELSE available_at
END,
lease_owner = NULL,
lease_until = NULL,
last_error = $5,
updated_at = now()
WHERE id = $1 AND status = 'running' AND lease_owner = $2
`,
[
task.id,
worker,
terminal ? "failed" : "ready",
250 * task.attempts,
message,
],
);
if (result.rowCount !== 1) throw new Error(`lease lost for ${task.id}`);
await appendEvent(
pool,
task.id,
terminal ? "failed" : "retry",
worker,
{ message },
);
}
The delay grows with attempts, but the example omits jitter to keep its output predictable. In a service with many workers, add jitter and a maximum delay. Classify errors too: an invalid prompt or denied permission will not improve with a retry, while a network outage might.
Put Codex or Claude Code behind the same adapter
The orchestrator should not know which model handles a task. AgentAdapter receives a contract and returns evidence. The simulated adapter lets you test the queue, failures, and dependencies without an API call. You can later replace it with Codex, Claude Code, or your own service.
Add the deterministic adapter:
const mockAgent: AgentAdapter = async (task) => {
await sleep(120);
if (task.kind === "test" && task.attempts === 1) {
throw new Error("transient test runner failure");
}
const evidence: Record<TaskKind, string[]> = {
inventory: ["src/index.ts", "package.json"],
test: ["npm test: passed"],
review: ["dependencies satisfied", "evidence attached"],
};
return {
summary: `${task.kind} completed`,
evidence: evidence[task.kind],
};
};
For a real adapter, build the prompt from task.input, run the CLI in its own worktree, and transform JSONL into AgentOutput. The article on coding agent observability in CI explains why a complete transcript belongs in a restricted artifact while the queue receives only a summary and evidence.
Keep permissions specific to each task type. inventory can be read-only. test runs a known list of commands. review reads the diff and results but cannot publish or edit. If every adapter receives full access, splitting the work among agents only divides the prompts.
Run the workers and verify the complete workflow
The loop tries to recover leases, claims a ready task, and calls the adapter. If it finds no work, it checks whether any tasks remain in ready or running. Two instances can run this loop at the same time because the claim uses FOR UPDATE SKIP LOCKED.
Add the rest of the file:
async function workerLoop(worker: string, adapter: AgentAdapter) {
for (;;) {
await recoverExpiredLeases();
const task = await claimTask(worker);
if (!task) {
const unfinished = await pool.query<{ count: string }>(
`SELECT count(*) FROM tasks
WHERE status IN ('ready', 'running')`,
);
if (Number(unfinished.rows[0]?.count ?? 0) === 0) return;
await sleep(100);
continue;
}
try {
const output = await adapter(task);
await completeTask(task, worker, output);
} catch (error) {
await failTask(task, worker, error);
}
}
}
async function printReport() {
const tasks = await pool.query(
`SELECT id, status, attempts, output, last_error
FROM tasks ORDER BY created_at, id`,
);
console.table(tasks.rows);
const events = await pool.query(
`SELECT task_id, event, worker
FROM task_events ORDER BY id`,
);
console.table(events.rows);
}
async function main() {
await bootstrap();
await seedWorkflow();
const abandoned = await claimTask("worker-interrompido", 500);
console.log(`Lease abandonado: ${abandoned?.id}`);
await sleep(650);
await Promise.all([
workerLoop("worker-a", mockAgent),
workerLoop("worker-b", mockAgent),
]);
await printReport();
}
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => pool.end());
Run the type check and the demonstration:
npm run check
npm run demo
The final table should show inventory, test, and review in succeeded. inventory will have two attempts because the first worker abandoned its lease. test will also have two because the adapter simulates a transient failure. review will have one attempt and will appear only after test succeeds.

In the event table, check this order:
inventory claimed worker-interrompido
inventory lease-expired worker-interrompido
inventory claimed worker-a
inventory succeeded worker-a
test claimed worker-a
test retry worker-a
test claimed worker-b
test succeeded worker-b
review claimed worker-b
review succeeded worker-b
This tests the plumbing, not the model's intelligence. Replacing mockAgent with a CLI adds another set of evals: patch quality, evidence validity, tool use, and adherence to the spec. Regression tests for agents in CI should remain a separate gate.
Common errors and how to diagnose them
Orchestration failures need to appear as state, not silence. Microsoft recommends exposing errors so the supervisor can respond and validate an output before passing it to the next agent. A persuasive message should not release dependencies unless it satisfies the accepted contract.
| Symptom | Likely cause | Fix |
|---|---|---|
address already in use |
Port 54339 is already in use. |
Change the port in compose.yaml and DATABASE_URL. |
ECONNREFUSED |
PostgreSQL has not become healthy yet. | Run docker compose ps and check the logs. |
| The same task runs on two workers | The selection and UPDATE do not share a transaction. |
Keep FOR UPDATE SKIP LOCKED inside inTransaction. |
A task remains in running |
The workflow has no recovery or heartbeat. | Run recoverExpiredLeases and monitor lease_until. |
review starts too early |
The query ignored task_dependencies. |
Keep the NOT EXISTS check for unfinished prerequisites. |
| Retries never stop | max_attempts does not affect the transition. |
Make the failure terminal when it reaches the limit. |
| A late result wins | Completion does not check the lease owner. | Update only while lease_owner still matches the worker. |
If a dependency reaches failed, the example leaves the following tasks unexecuted. In production, add a blocked state, record the cause, and finish the workflow instead of polling the queue indefinitely. You also need a policy for cancellation initiated by a person.
During validation, the first startup failed because port 54329 was already in use. I changed the example to 54339, repeated the type check, and only then ran the workflow. It is a mundane detail, but it makes a useful test: infrastructure failure needs a concrete cause instead of turning into "no work available."
What is missing before production?
The example proves task claims, dependencies, recovery, and retries, but it is not a complete service. The first improvement is to split the supervisor and workers into separate processes. The second is to renew leases during long tasks. Cancellation, a dead-letter path, idempotency, authentication, metrics, and artifact retention come next.
The "Orchestrate teams of Claude Code sessions" documentation describes a lead, teammates, a shared task list, and a mailbox. It also marks agent teams as experimental and notes limitations around resumption and coordination. Use the feature when it fits an interactive workflow, but do not confuse a tool's local state with your product's durable contract.
For parallel writing, isolate the file system. Each task gets its own worktree, branch, or sandbox. The supervisor accepts a patch or commit instead of concurrent edits in a shared checkout. The verifier applies the artifact in a clean environment and runs deterministic checks before allowing a merge.
Access control and context reduction are also missing. A worker should receive only its task, dependency artifacts, and required tools. The article on context budgets for coding agents helps decide what to persist, summarize, or discard between handoffs.
Frequently asked questions
Do I need several agents for every large task?
No. Microsoft recommends the least complex design that solves the problem, and OpenAI points to subagents first for independent, reading-heavy work. If the parts write to the same file or follow a short sequence, one agent with checkpoints is usually more predictable.
Why use PostgreSQL instead of supervisor memory?
External state lets the workflow resume tasks after an interruption and coordinate more than one process. Microsoft recommends durable storage for the progress and results of long-running orchestration. PostgreSQL also supplies transactions and SKIP LOCKED, which suits concurrent consumers of a queue-like table.
Does a lease guarantee exactly-once execution?
No. A lease reduces concurrent execution, but a worker can complete an external effect and die before recording success. Treat execution as at least once. Use idempotency keys, disposable worktrees, and artifact validation before publishing a change.
Can I mix Codex and Claude Code in the same workflow?
Yes, as long as both implement the same AgentAdapter. The contract must normalize input, permissions, timeout, output, and evidence. Do not force different transcripts into one table. Store raw output in a restricted artifact and persist only what the next decision needs.
When should I use an agent team instead of subagents?
Use subagents when the supervisor only needs the results of focused tasks. Claude Code reserves agent teams for workers that need to share findings and coordinate with one another. That communication uses more context and adds states you must also observe and recover.
Your next architecture decision
Start with mockAgent and induce failures. Kill a worker, occupy the port, force a timeout, and make an adapter return invalid evidence. Once the orchestrator exposes each case without duplicating work or releasing dependencies early, replace just one adapter with a real agent.
Then connect the result to pull request evals for coding agents. The supervisor decides when a task may run. The eval decides whether its output should advance. Keeping those responsibilities separate makes the workflow recoverable even when the model makes a convincing mistake.
Sources
- Microsoft Azure Architecture Center, "AI agent orchestration patterns", accessed July 25, 2026, https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/ai-agent-design-patterns
- Microsoft Learn, "Orchestrator and subagent multi-agent patterns", accessed July 25, 2026, https://learn.microsoft.com/en-us/agents/architecture/multi-agent-orchestrator-sub-agent
- OpenAI Developers, "Subagents", accessed July 25, 2026, https://developers.openai.com/codex/subagents
- Claude Code Docs, "Orchestrate teams of Claude Code sessions", accessed July 25, 2026, https://code.claude.com/docs/en/agent-teams
- Claude Code Docs, "Create custom subagents", accessed July 25, 2026, https://code.claude.com/docs/en/sub-agents
- PostgreSQL, "SELECT", accessed July 25, 2026, https://www.postgresql.org/docs/current/sql-select.html
- Node.js, "Node.js Releases", accessed July 25, 2026, https://nodejs.org/en/about/previous-releases
- npm, "pg", accessed July 25, 2026, https://www.npmjs.com/package/pg
- npm, "TypeScript", accessed July 25, 2026, https://www.npmjs.com/package/typescript
- npm, "tsx", accessed July 25, 2026, https://www.npmjs.com/package/tsx
- npm, "@types/node", accessed July 25, 2026, https://www.npmjs.com/package/@types/node
- npm, "@types/pg", accessed July 25, 2026, https://www.npmjs.com/package/@types/pg