ADR 0022 Child workflows as runs of their own - agbossjs/agboss GitHub Wiki
ADR-0022 — Child workflows as runs of their own
- Status: Accepted (#333 S3, S4) — reverses #263 S24; implemented in #333 PR 1–4
- Issue: #333 (the epic); #360 (cross-instance cancel, its PR 0)
Context
A child workflow (#263) — .toWorkflow(W), route(W), .toParallelWorkflows(W, …), ThisWorkflow
(#264) — runs today as a subgraph on its parent's checkpoint thread (#263 S24): the parent's node
streams the child's compiled graph under the launching task's checkpoint_ns; the child's state,
identity and pauses live in the parent's checkpoints, and one lease (#265) covers the whole tree.
So a whole tree runs on the one instance that holds its lease. A fan-out of heavy children — or a deep recursion — cannot use the other instances that share the same stores, however idle they are. A child cannot be claimed elsewhere because it has no place of its own: no thread, no identity outside its parent's checkpoint, nothing another instance can continue.
Decision
Every child execution is a run of its own.
- Its own checkpoint thread on the application's checkpointer:
<parentThreadId>/child/<childExecutionId>(S4 D3). The child's graph runs as a root graph — outside its parent's LangGraph run (its namespace, its task, its resume map) — so its checkpoints, interrupts and resumes are its own. A child thread is never a thread of the API: it is not listed,execute/cancel/ history never address it, andrecords exportkeeps children nested under their root. - Its identity is found by its launching task: the parent's task (
checkpoint_ns) maps to the child's identity once, on its first entry; a re-entry after a pause or a crash reads it back — the same id, no secondonExecutionStart. - Waiting is the drain's (Q3, #267 S35 / Q58), only when it must (S4 D2, the hybrid). A child
placed on its parent's instance (
ChildPlacement.ParentInstance, the default) is awaited inside its parent's step, as today: it ends there — its value, or its failure — and the parent goes on in the same pass, with the same budget tally, tree slots,FailFastaborts, undo boundary (#340) and events. Only a child that cannot end within the step makes the parent's node interrupt with a waiting marker naming the child: a child that rests paused (PR 1), or — from PR 4 — one placed on another instance (ChildPlacement.AnyInstance). The tree rests as it does for any pause; its pending pauses are read through the markers (result.pendingunchanged); a resume is still one command for the tree, which the parent's node forwards to the child's thread; the child's end lets the parent go on. - Studio is unchanged (S4 D5): the child's compiled graph stays registered as its node's subgraph, for drawing only; no execution runs under it.
- Placement is per launch (Q2):
placement: ChildPlacement.AnyInstanceon.toWorkflow(W)/.toParallelWorkflows(W, …)/route(W)(PR 2); never automatic, never application-wide. - A remote child is awaited inside its parent's step, yielding the lease while it runs (S6, S9):
the parent's node saves the child to the queue (once — a re-entry finds its row) and waits on its
row — woken in-process at once, else read every second on the
Clock— under the step's own signal, temporarily yielding its lease inExecutionLeaseRepositorywhile waiting and re-acquiring it before continuing, soFailFast,app.cancel, a lost lease, budgets and failure records take today's paths without holding the root's lease idle. When a remote child rests at a pause, the worker completes its pass atPausedand the parent's node interrupts with a{ waitingForChild: { …, remote: true } }marker so the pause reachesresult.pending; onapp.resume, the parent's node forwards the child's resume map through the queue so any instance continues the child on<parentThreadId>/child/<id>. - Tree-wide guarantees hold across instances (PR 2–4): queued children are claimed atomically
(
ChildExecutionQueueRepository, in-memory + SQLite) by instances with free step slots (#332); the tree'smaxConcurrentExecutionslives inRateLimitRepository(slots as leases); depth and the root's budgets are checked where the child runs; spend reaches the ledger at once; PII tokens go back to the parent with the child's end. - Recovery (PR 4, S7, S9): a worker holds a 30 s lease in
ExecutionLeaseRepositoryunder(tenantId, childExecutionId). On a crash or a graceful stop pastshutdownTimeout(S7), the queue row staysRunning, and once the child's lease is free a live worker reclaims only that child and continues it from its last checkpoint on<parentThreadId>/child/<id>without re-running finished siblings. - Upgrade (S4 D6): for one version, a child found at its old place — a subgraph checkpoint under
the launching task's
checkpoint_nson its parent's thread — is continued the old way to its end.
Consequences
- A tree can spread over instances, launch by launch, without changing what a workflow means: a child still inherits its parent's thread owner, tenant, contexts and budgets, and gives back its value, its PII tokens and its record.
- Children have state of their own that other instances can read: the checkpoint of a child is no longer found under its parent's namespace — code that reads children's checkpoints (pending pauses, undo records, recovery) follows the child's thread instead.
- A local child that pauses costs one more interrupt (its parent's marker); one that ends in its step costs nothing more than today.
- The mechanism of a remote child is the one of a pause (Q3): no new scheduler; the drain already takes what was resolved meanwhile before a tree rests.
- Watch: the waiting marker must never reach a channel,
result.pendingor the pause history; a child thread must never be addressable as a thread.