Terret Subagents, Jobs, and the Tool Barrier (v1)
M7 gave an agent hands (docs/exec.md). M8 gives it help: children it can delegate a whole conversation to, work that outlives the turn that started it, a plan it can write down, and finally the ability to do more than one thing at a time. This is that primer, written before any of it is code, the way docs/exec.md and docs/security.md were.
Four mechanisms, one theme. Every one of them adds a way for work to happen somewhere other than inline in the current step, and every one of them stays inside the contract that made the first seven milestones tractable: model-visible means logged, deny-by-default fails closed, registration is reversible, and the log’s order is the same on every replay.
1. The ctx[:subagents] seam
ctx[:subagents] is a sole-provider seam, the same shape
ctx[:session_store] and ctx[:summarizer] already use (docs/lifecycle.md,
“Compaction”). Exactly one provider may claim the key. A second registration
raises; it does not silently win. That much is true of every service key in
the kernel, which answers a duplicate claim with a ContractError. What is a
design choice here is that nothing sits behind the key dispatching between
providers. “What is a subagent in this deployment” is one answer for the
whole process, decided in a config row. The Task tool does not pick a
provider from a roster per call.
The seam is small:
ctx[:subagents].run(prompt:, ctx:) # => Result(text:, session_id:, usage:, status:)
prompt: is the child’s instruction. ctx: is the calling agent’s
context, not the root. That distinction is the whole of §3 below, and it is
why the argument is explicit: run receives it instead of reading it off a
service ivar.
Plan §6.4 names three providers for this seam. M8 builds one: the fork
provider. The other two are recorded in plan §14 as deferred, not sketched
here: a delegated turn to an external agent over ACP (docs/acp.md), and a
pooled worker. A seam with one provider is still a seam; what makes it one is
that the Task tool talks to run and knows nothing else.
2. The fork provider’s lifecycle
The default provider spawns a fresh child agent inside a fork of the calling agent’s context and runs it to completion. Exactly:
- Fork the calling agent’s context (
ctx.fork, plan §4.1). Every registration the child makes is recorded on that fork. - Spawn a child agent on it with a fresh session id: a new durable
session, not a copy of the parent’s. This is the one M8 change to the
registry.
spawn_agentgains aparent:keyword whose default is the loop service’s own@ctx, so it forks from the root exactly as it does today, and every existing caller (the socket, ACP, a test harness) stays untouched. The subagent provider is the caller that passes something else: the calling agent’s context. - Append the prompt as a
user/messageon the child’s session, the same append every other input goes through. - Run the turn to completion through the ordinary
Loop: same steps, same MAX_STEPS ceiling, same pipeline, same approvals gate, same allow list. - Read the result off the log: the final assistant text from the child
session’s projection, and
Sessions#usage(child_session_id)for what it cost. - Dispose the child agent, unconditionally, including when the turn raised. The fork is disposed with it, so every tool, listener, and effect the child installed dies at the same moment.
The value that comes back is Subagents::Result = Data.define(:text,
:session_id, :usage, :status). status is the child’s ordinary turn status:
:completed, :cancelled, :rejected, or :empty. A failure raises
instead. It is there because “had nothing to say” and “was stopped part-way”
are different facts about a delegation, and a caller shown only the child’s
last sentence would report the second as if it were an answer.
Two properties of that sequence are load-bearing.
The child’s session is fresh, not forked. Sessions#fork exists: it
copies events up to a boundary under a new id and records session/forked
lineage (plan §6.1). This is deliberately not that. A subagent inherits its
parent’s capabilities, not its parent’s transcript. The child sees the
prompt it was given and nothing else. That is most of why delegating is worth
doing: a long, expensive parent context does not have to be re-read to answer
a bounded question.
The parent’s log gains no new durable event. No subagent/started, no
lineage record: just the ordinary tool/call and tool/result bookends
every tool call already writes. That keeps “model-visible means logged”
exactly true with nothing added. The only thing the parent’s model ever sees
of the child is the tool result, and the tool result is in the log. It costs
something: nothing in the parent’s log links it to the child’s session
except the ledger line inside that tool result (§4). That is why the ledger
line names the session id. It is not cosmetic; it is the only thread from a
parent transcript to a child’s.
A cost rollup inherits the same shape. Sessions#usage(session_id) is a pure
projection of one log (docs/lifecycle.md, “Cost accounting”), so a parent’s
rollup is the parent’s own steps and nothing more. What a turn spent is the
parent’s rollup plus one per child session it named. The seam returns
usage: so a caller that wants to account for a child inline can, and the
number stays recoverable from the child’s log forever anyway. That is why the
Task tool spends its ledger line on the session id, not on tokens the model
does not need in its context.
Failure inside the child renders to the parent as an ordinary
Terret::Tools::Failure naming the child’s session id. The message never
embeds the child’s stack. A stack trace in a tool result is context the
parent’s model cannot act on and pays for on every subsequent request; the
session id is the pointer to where the whole story is.
An approval that would park inside a child is denied instead. This is the
one place the child’s pipeline does not behave exactly like its parent’s. It
closes a deadlock; no one wanted this rule for its own sake. Nothing can
reach a child’s session to decide anything: the parent’s log never names it
(that is the paragraph above), so no socket is bound to it and no operator
can answer a request they were never shown. A parked call there waits on a
verdict that cannot arrive, and it waits holding the fiber that runs the
parent’s turn. One delegated call would take the parent down with it. So the
approvals gate refuses: the call comes back as an ordinary denial reading no
approver can reach a subagent session, the child’s model sees it in a tool
result and can say so, and no approval/requested is ever written, because
nobody is going to be asked.
The order inside the gate is what keeps this from swallowing real decisions. A verdict already in the log settles a call however it got there, so a child that crashed and resumed still honors the answer a human gave. Only a call with no recorded answer at all reaches this refusal. The allow list still runs first, as it does everywhere, and a top-level agent is untouched. The flag is set by the subagent provider on the children it spawns, so an interface-driven agent parks exactly as it always did.
What this costs is real: a subagent cannot do anything that needs a human’s
consent. A deployment that wants a child to run Bash in a profile where
Bash demands approval has to grant that at the policy level. It cannot
grant it at the moment of the call. Routing a child’s request to the parent’s
operator (a delegated approval) is the design that would lift this. It is a
plan §14 item, not something to bolt on: it needs a link from a parent
transcript to a child’s request that §2 deliberately does not create.
Two limits fall straight out of the existing machinery. The registry’s agent
cap (config[:max_agents], default 128, docs/lifecycle.md) counts children
like anything else, and a child holds its slot for the length of its run, so
a wide parallel fan-out of Task calls is bounded by the same cap as the
fleet. AgentCapExceeded is what a caller sees when it is not. A child that
itself calls Task nests: nothing forbids it, the cap is the only ceiling,
and a deployment that does not want recursive delegation expresses that in
its allow list, not in this seam.
3. Why the child inherits the parent’s roster and policy floor
The provider forks the calling agent’s context, so the child starts with
the tools that agent can see and the tools/pre_execute listeners that
govern it, including its Terret::Tools::AllowList. Deny-by-default carries
down. A subagent is not an escalation path, and the design says so
structurally, not by a check someone has to remember to write: run takes
the caller’s ctx as an argument and passes it as parent:, so no path
the subagent provider can take builds a child from the root context. (The
registry’s own default is still a root fork: that is what an interface
spawning a top-level agent wants, and §2’s parent: keyword is what keeps
the two cases from having to share one answer.)
Where the caller’s context comes from matters. Task is a std tool
registered on the root context like the rest of the roster, so its
handler’s closure captures the root, not any agent. What it does at call time
is look the caller up: the handler receives the session it is running in
(M7’s merge-ordered session_id, which a model-forged argument cannot
override), and ctx[:loop].agent_for_session(session_id).ctx is the calling
agent’s forked context. That lookup is the load-bearing line in the tool, and
the whole guarantee in this section rests on it.
That matters because the alternative is a familiar hole. If a child were
forked from the root, an agent restricted to Read and Grep could ask a
child to run Bash, and the restriction would be worth nothing. Delegation
would be a privilege-escalation primitive dressed as a convenience.
The AllowList’s own design has a consequence that is easy to miss. The active
pattern set is a projection of the call’s session: the last
policy/updated event in that session, falling back to the patterns
install was called with (docs/lifecycle.md, “Hot-reloadable permissions”).
A child’s session is fresh, so it holds no policy/updated. That means the
child runs at the install-time floor, not at whatever the parent’s live,
hot-updated set happens to be. In the direction that matters, that is safe:
the floor is a ceiling the child can never exceed, so a hot update that
widened a parent does not widen its children. In the other direction it is
a real edge: a policy hot-narrowed mid-session does not narrow the children
spawned after it. A deployment that needs a live narrowing to propagate
should narrow the floor (a config row); narrowing only the running session
will not reach children spawned after it.
4. The Task tool
| Tool | mutating | approval | concurrency |
|---|---|---|---|
Task |
false |
:never |
:parallel |
Claude Code’s name verbatim, per the M7 rule (docs/exec.md §5): allow lists
in the wild are already written against Task, and inventing a Terret-native
name would buy a translation layer that does nothing but rename, forever.
Parameters are two strings:
description: a short label for the delegation, the kind of thing that reads well in a log or a UI line.prompt: the child’s instruction, the whole of what the child will see.
The result is the child’s final text followed by the ledger:
<the child's final assistant message>
--- terret ---
child session <session id>
--- terret --- is the same literal Bash and WebFetch separate their
output with, and it carries the same caveats those tools already document. It
is a readability device, not a security boundary: a child could print the
line itself. What it delivers is that the genuine remarks are always last,
and they are advisory data that nothing downstream acts on.
approval: :never on a tool that can obviously mutate the world is the
one entry here that looks wrong and is not. The metadata describes what
this call does directly, and this call starts a conversation. Everything
the child then does passes the child’s own pipeline: its own allow list, its
own approvals gate, one decision per actual effect. Gating Task itself
would ask a human to approve a call whose effects are not knowable until
after the approval is granted, the worst possible moment to ask, and would
then ask again, correctly, for each real effect inside. The calls that need a
human get one, in the places where a human can see what they are. Where no
human can see them at all, §2’s fail-closed rule denies them; it does not
park a turn nobody can unstick.
concurrency: :parallel because a Task call is a whole turn of
latency, which is exactly the case the barrier was declared for.
5. The tool barrier
M7 declared concurrency: on every tool Definition and honored it nowhere:
the loop executed a step’s tool calls one after another and the field was
documented as metadata waiting for a consumer (docs/exec.md §5, §8). M8 is
the consumer.
Within one assistant message, the loop partitions the calls into maximal
runs of :parallel-declared definitions. Each run executes under an
Async barrier on the one reactor (plan §8); a :serial call (the default)
runs alone, exactly as every call did through M7. That maximal-run
partitioning does not gather every parallel call in the message into one
batch, and the distinction is what preserves a serial call’s meaning: a
:serial call is a barrier of one, and nothing reorders across it.
Results append in call order regardless of completion order.
derive_messages projects the model’s history from the log, and
assert_log_invariant! digests each outbound request against that
projection. A log whose result order depended on which child finished first
would replay differently than it ran. Resume, which re-executes exactly the
calls the last assistant message still owes (docs/lifecycle.md, “Resuming an
open turn”), would then rebuild a different history than the one that was
live. Concurrency changes when work happens, not what the log says
happened.
Three consequences follow:
- A failing call does not abandon its siblings. Each call’s result is its
own
Result, error or not, and the barrier waits for the run. A tool error has always surfaced to the model as an ordinary tool result. It has never ended a turn, and that does not change here. Loop::MAX_STEPSstill bounds the turn. Twenty-five steps is twenty-five steps; parallelism makes a step wider, never longer.- Cancellation still lands at step boundaries, and now between runs, not
between calls. A barrier is not interruptible from outside once it
starts, so a cancel requested while five calls are in flight settles after
that run. It does not tear fibers out of the middle of it. That visibly
changes one behavior the socket documents: a cancelled batch truncates the
calls that have not started, each getting a
tool/resultcarryingcancelled before execution(docs/protocol.md), and with maximal runs there are fewer such results than before, because a run already in flight finishes. Every call in the batch still ends with a result either way. The projection never holds a call without one. The:stoppingstatus (§8) exists to make that window visible instead of leaving a cancelled-but-still-working agent looking:running.
6. ctx[:jobs] and the job_* tools
A job is a subprocess that outlives the tool call that started it. The seam
lives in terret-exec, because it needs ctx[:subprocess] and therefore
ctx[:sandbox]: a job’s argv goes through ctx[:sandbox].wrap on the way to
Process.spawn like every other spawn in the system (docs/exec.md §2), so a
job in a sandboxed profile runs inside the container with everything else.
There is no spawn path in Terret that skips the sandbox seam, and jobs do not
become the first one.
The seam a job does not use is ctx[:shell]. A shell command from
job_start becomes a fresh bash -lc <command> argv handed to
ctx[:subprocess], not a run against the agent’s persistent bash. The two
would be actively wrong together: ctx[:shell] is one long-lived process per
agent driven by a sentinel protocol that reads until it sees its marker
(docs/exec.md §2), so a job parked in it would hold that shell for its entire
lifetime and every subsequent Bash call in the session would block behind
it. The comparison to draw with Bash is therefore about wrapping, not
about the seam: Bash wraps once when its session opens, while a job wraps
per spawn.
ctx[:jobs].start(command, session:, cwd: nil) # => opaque job id
ctx[:jobs].collect(id, session:) # => {status:, exit_status:, output:, truncated:}
ctx[:jobs].stop(id, session:) # SIGTERM, escalating to SIGKILL
start takes the command as a string and turns it into the bash -lc argv
above itself, so the one place a shell line becomes a process is the seam,
not each of its callers.
collect drains the buffer: what it returns is what has accumulated since
the last call, with status: either :running or :exited. Buffers are
byte-capped, the same cap discipline M7 applied to command output, and
truncated: reports it instead of staying silent. A per-session job cap
(config, default 8) refuses the next start instead of letting an agent fill
the process table. A job is forgotten once it has exited and its last output
has been handed over. Everything it will ever say has been said by then, and
letting the row go is what frees its slot against that cap. An unknown id, an
id already collected out, and an id belonging to another session all fail
closed, and all with the same answer: which of the three is true is not
something one session should learn about another’s jobs.
Under a reactor a fiber per job drains its pipe as the job fills it, and with
no reactor mounted the same calls return the same bytes in the same order.
Two things stop happening, though, and both look like the seam misbehaving,
not the deployment: a job with more than a pipe buffer (about 64KB) to write
between two collects is parked in write until the next one, its side
effects stopped with it, and a job that finishes while nobody is collecting
stays an unreaped zombie until a collect, a stop, or its agent’s disposal
notices that it went.
Jobs are reaped on agent/disposed, the event M7 declared for exactly
this class of state: per-agent runtime that no registration owns and
reversibility alone cannot collect (docs/exec.md §2, shells and terminals).
An agent’s jobs go down with the agent, the same way its shells and its
terminals do.
| Tool | mutating | approval | concurrency |
|---|---|---|---|
job_start |
true |
:always unsandboxed / :policy sandboxed |
:serial |
job_collect |
false |
:never |
:parallel |
job_stop |
true |
:policy |
:serial |
job_start takes a command string, matching Bash’s parameter convention,
not inventing a second one, and hands it to the seam exactly as written. Job
tools are snake_case because they have no Claude Code equivalent to be
verbatim with. The same rule produced terminal_open in M7.
job_start’s approval is derived from sandbox isolation exactly as Bash’s
is (docs/exec.md §5): it runs bash -lc <cmd> in a fresh shell, so
unsandboxed it is arbitrary shell execution and is :always, and inside a
sandbox the container is the backstop so it is :policy like any other
mutating tool. The verdict is captured at registration and re-derived on a
hot sandbox swap through the config/updated listener, the same mechanism
Bash uses. job_stop is :policy. Either way both need an approver, which
means a subagent cannot start or stop a job in a profile where approvals
are mounted: §2’s fail-closed rule denies the call; it never parks it. A
child cannot read its parent’s either. A job’s ledger is keyed by the session
that started it, a child’s session is fresh (§2), and an id from another
session fails closed like any other stranger’s.
Jobs and children therefore do not mix in v1. A parent both starts a job and collects it, and hands the child whatever of the output the child needs in the prompt it delegates with, which is the only channel the two share anyway. Nothing in the runtime links a child’s session to its parent’s, the same gap §2 already used to justify the approvals rule. Until that lineage exists, “my parent’s job” and “another agent’s live process” are the same shape to the seam, and fail closed wins. Lineage-scoped job visibility is recorded in plan §14 as the design that would lift it.
Session-scoped, and not durable
A job is a live pid plus a buffer in one process’s memory. Nothing in the log says it exists. It survives its turn, and it does not survive a restart: the process that held the pid is gone, and the log, the only thing that crosses a restart, was never told. Restart-surviving jobs are a recorded non-goal for 0.1, not an oversight; making them durable means answering what a job id means after the process that owned it died, and that is a design, not a patch.
Where at-least-once bites
Crash recovery is at-least-once for tool calls (docs/lifecycle.md,
docs/security.md): a call whose tool/result never reached the log may have
already run, and resume runs it again, because the log cannot tell “ran and
didn’t log” from “never ran.”
For Write that is harmlessly idempotent. For Bash it means a command runs
twice. For job_start it means something slightly worse: resume starts a
second job. The first one may still be running, holding the first process’s
buffer that nobody will ever collect; the second gets a new id and is the one
the model is told about. The harness does not detect this and does not claim
to. The honest contract is that Terret never loses a call and never invents a
result for one that has not run, and it stops there. job_collect is how the
model finds out: two ids where it expected one, or output that does not match
what it thinks it started. Harness-level idempotency keys remain a plan §14
item.
One limit belongs to the seam itself. A stop ends the job’s whole process
group (SIGTERM, then SIGKILL after the grace), and that signal goes out while
the leader is still alive or an unreaped zombie, which is what proves the
process group is still ours to signal, not a pgid the kernel has since
recycled. A job whose leader exited on its own and was already reaped (a
collect noticed it, or the drain fiber did) has no such proof left, so
nothing is signalled for it at all: a background child that job started can
outlive the job’s disposal. Killing some other process that inherited the
recycled pgid is the worse of the two failures. Bounding the wait without
collecting the leader is what would lift it, and it is recorded in plan §14,
not patched here.
One inherited limit from the Docker provider applies here more than anywhere.
Signals go to the host-side docker exec CLI, not to the process inside the
container (docs/exec.md §4), so job_stop in a sandboxed profile abandons
the CLI and leaves the job running behind the container boundary. Stopping
the container is what ends it. That is a property of the docker exec model,
not of this seam, and a job, the longest-lived thing that model ever wraps,
is where it shows.
7. TodoWrite
| Tool | mutating | approval | concurrency |
|---|---|---|---|
TodoWrite |
false |
:never |
:serial |
Parameters match Claude Code’s shape exactly:
{"todos": [{"content": "…", "status": "pending|in_progress|completed", "activeForm": "…"}]}
The handler validates the statuses, renders the list back as the tool result, and holds no state at all. That echo is its only storage.
This is the smallest illustration in the codebase of what “model-visible
means logged” buys. The list is durable because the tool result is durable;
derive_messages projects it into the next request the same way it projects
every other result; a restart replays it for free; and resume_turn
re-derives it with no special case, because there is no special case. A todo
service would have been a second source of truth that has to be reconciled
with the log after every crash, and it would disagree with it eventually.
There is no new event to declare, nothing to migrate, and nothing that can
drift.
One honest limit: compaction can erase it. A session/compacted boundary
that swallows the last TodoWrite result replaces it with a summary
(docs/lifecycle.md), and whether the plan survives depends on whether the
summarizer kept it. The model writes a new list; nothing is corrupted.
concurrency: :serial because the semantics are order-dependent: two writes
in one message mean the last one wins, and “last” should be a property of the
message, not of which fiber returned first. An invalid status fails closed
naming the offending value, not coercing it to something plausible.
8. Two new statuses, and one that stays reserved
Plan §6.4 specifies idle → running → waiting_approval | waiting_input →
stopping → done/failed. M6 built the first three and said so
(docs/lifecycle.md, “The status machine”). M8 adds two more, not the sixth:
failed is a turn status, not an agent status. It is what turn/end
{status} carries when an exception left the turn, alongside completed,
cancelled, rejected, and empty; the agent that ran that turn goes back
to :idle and takes the next one. There is nothing for an agent-side
:failed slot to mean, so none is minted.
-
:stopping: set when a cancel is requested, cleared when the turn ends. Cancellation is cooperative and honored at step boundaries (plan §8), so between the request and the boundary there is a real interval where the agent is still working and is no longer going to finish. Through M7 that interval was indistinguishable from:running. It is a status because that window is observable and lying about it costs an operator real time; the turn that closes inside it still appends the ordinary durableturn/end {status: "cancelled"}, and the agent returns to:idle.It interacts with
:waiting_approval, which is the one other sub-state of a running turn, and the interaction is decided, not left to whichever assignment runs last. The approvals gate flips an agent to:waiting_approvalwhile a call is parked and restores it when a verdict lands (docs/lifecycle.md, “Durable approvals”). That restore returns the agent to:stoppingwhen a cancel is standing, not to:running. A cancel requested while a call was parked has not stopped being true just because the human answered. Cancelling a parked turn denies every standing request durably (deny_pending!), so the parked fiber unparks into a turn that already knows it is stopping, and the status now says the same thing the turn does. -
:done: set bydispose_agent, terminal. A disposed agent’s context is gone along with every effect it owned, so a laterrun_turnagainst that handle refuses; it does not half-work against a dead fork.
:waiting_input stays vocabulary and nothing more. Plan §6.4 names it,
no consumer exists for it, and a status nothing ever sets is worse than an
absent one: it invites a client to write a branch that never runs. It is
recorded in plan §14 as reserved. The day something parks a turn on human
input instead of human approval, it gets implemented then.