Certified Agent Harness Engineer
Course sections

M6.6

Lifecycle and control of running children

881 words · about 6 min

Concept

A child is either a one-shot run — one prompt, one result, no resume — or a continuable child: a durable session you can send further turns to. The distinction is not a convenience feature; it changes what must persist.

A one-shot run needs a promise and a disposal path. A continuable child needs a durable session record, a policy for what happens when a message arrives while it is busy, a policy for what happens when it is not running at all, and a way to enumerate live children so an operator or a model can see what exists.

The concept that makes this tractable is the Activation: a durable session may have at most one process-local running instance. That gives a three-state routing problem for an incoming message — running (queue it), idle but activated (wake it), no activation (cold-resume the session first) — and each state has a different cost.

Mechanism: the FIFO inbox and the routing table

Mechanism

dsh's followup() routes by activation state: a running child gets the message enqueued; a waiting child is woken; a child with no Activation triggers a cold resume. The critical semantic, stated plainly in packages/subagent/tool-subagent-control/README.md: "if the child is still working, the message waits until its current turn finishes, so it cannot redirect work already underway."

Read that as a hard constraint on design. send_message cannot cancel or redirect. If a parent notices a child heading the wrong way, messaging it does nothing until the wrong way is fully travelled. Redirecting requires interruption.

interrupt() is fire-and-return, implemented over Agent.cancel(cause, { keepInbox: true }). The keepInbox flag matters: interrupting stops the current turn without discarding queued messages, so "stop what you're doing and read the queue" is expressible. Authority is typed rather than checked ad hoc:

type SubagentInterruptAuthority =
  | { readonly kind: 'user'; readonly parentSessionId: SessionId }
  | { readonly kind: 'ancestor'; readonly agent: Agent }

A user acting through a parent session, or an ancestor agent. Not a sibling; not an arbitrary caller.

Worked example — the wrong-direction child

Worked example

A continuable child is refactoring a module and, four turns in, its report reveals it has started rewriting the test suite too — not what was asked.

Wrong response: send_message("please don't touch the tests"). Queued behind the current turn. The child finishes rewriting the tests, then reads the message, then must undo work it just did — with the undo now competing for its context budget against the original task.

Right response: interrupt_agent, then send_message with the corrected scope. The interrupt lands during the turn; the queued message defines the next one. The keepInbox: true semantics make this two-step sequence safe: the correction is not lost by the interrupt.

The general rule: interrupt to stop, message to steer, and never confuse the two.

Tradeoffs

One-shot runs are simple and cheap and cannot be corrected. Continuable children are correctable and cost durable state, activation bookkeeping, and a genuinely harder failure model — a child with no live Activation is not gone, it is cold, and cold resume has latency and can fail on its own. Interruption is coarse: it stops the turn, not the specific bad decision, and the child resumes with a transcript containing a half-finished action it must interpret.

How `dsh` does it

How dsh does it

packages/subagent/tool-subagent-control registers send_message(subagent_id, message) and interrupt_agent(agent_id); a separately loadable ./list-agents plugin adds list_agents(scope?) with scope children or descendants. The split is a dependency decision — the first two need only subagents, while listing also needs agents — and it means a deployment can grant messaging without granting enumeration.

Acceptance strings are deliberately non-committal: message queued as the next turn for subagent <subagent_id>, interrupt requested for agent <agent_id>. Queued, not delivered. Requested, not performed. The wording refuses to promise more than the mechanism guarantees — a habit worth copying.

Live status vocabulary is running / idle / ready. Enumeration failures are typed (SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE, SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE, CANCELLED) rather than returning an empty list — because "no children" and "cannot determine children" must never look alike.

On the tool side, packages/subagent/tool-subagent selects the mode with backgroundMode: one-shot (default) or continuable, with enableRunInBackground defaulting to true. Three return envelopes correspond: { kind: 'foreground', runId, output }, { kind: 'background', jobId }, and { kind: 'continuable', subagentId }. Note that a background child is a job id while a continuable child is a subagent id — different identifier spaces because they are different things.

A shipped-configuration detail is a lesson in itself: every shipped cordis.yml sets backgroundMode: one-shot on the fork delegation tool, because the report tool's schema and section are inserted before the inherited history and therefore invalidate all of it. Prompt-cache economics, encoded as a configuration default.

How others differ

Claude Code exposes lifecycle through hooks — SubagentStart, SubagentStop, TeammateIdle — which is an observation surface rather than a control surface, and a good one for building supervision on top. dsh's experimental packages/experimental/agent-team goes further toward peer coordination: TeamMemberSnapshot records context: 'fresh' | 'fork' per member; tasks carry a compare-and-set revision, an acyclic blockedBy list, and writeScopes that the docs describe as "normalized advisory path prefixes rather than locks." Advisory is the honest word. Two teammates writing the same file will still conflict; the scopes make the conflict visible, not impossible.


Check yourself

2 questions on M6.6

Start drill