Repository navigation
fix: a subagent must not run its own 0→1 phase machine - #48
Merged
Merged
Conversation
`createPolicy` builds one policy per agent, and a policy with a pipeline gets
a fresh `startPipeline()` — the phase machine, starting at `research`, with
the research phase's share of the step ceiling. That is right for the session
that owns the goal and wrong for every helper the model delegates to.
Measured on a live headless run (2026-10-07). The model spawned four research
subagents; each got its own phase machine and was told "you are in phase 1 of
5: RESEARCH". Each then ran the research phase's full ceiling and was rejected
by `pipelinePreCallGuard`:
research step ceiling reached (24 steps)
All four ended `stopReason: 'refusal'`, so the parent was told "Background
subagent … declined the task. It left no closing message", and the run blocked
in `research` with no research note written, at 2% of its budget and 24 of 240
steps. The parent's own session shows the same ceiling in its reject.
A session that is a subagent child — `origin: 'subagent'` or a non-zero
`delegationDepth`, both read structurally — now runs under the same spec,
budget, ladder and review gate with ONLY `policy.pipeline` cleared. The gate
stays on: a subagent's `write` and `bash` are exactly as irreversible as the
parent's, and the gate is the thing that asks a human.
The predicate is its own import-free module (`src/subagent.ts`) so the
CI job that installs nothing can test it, the same reason `watcher-ttl.ts` is.
Malformed or absent signals fail closed to "not a subagent": an unclassified
session keeps the phase machine it would have had before this existed.
Verified: `test/subagent.test.ts` fails with the `policyFor` branch reverted
(both the predicate wiring and the "only the pipeline is cleared" assertion),
passes with it; full suite 827 pass / 0 fail / 1 skipped; `tsc --noEmit` clean;
pre-commit scan clean. `src/subagent.ts` joins the typecheck list and its test
joins the no-install job in ci.yml.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A subagent the model spawns is not the run. It was getting its own 0→1 phase machine, burning the research phase's step ceiling, and being rejected — which blocked the whole run in
research.The measurement
Live headless run, 2026-10-07 (harness 0.2.0-rc.2,
fl-liveprofile). The model spawned four research subagents. Every one of them has its own run record and its own phase table:Four subagent sessions, each stopped at exactly 24 steps — the research phase's ceiling (
maxSteps: 240 × research share 0.10 = 24). Each subagent's own session log confirms it:session.origin: 'subagent',delegationDepth: 1,parentSession: <the run>turn/end {"reason":{"kind":"blocked"}}after its 24th stepThe parent then received, four times:
declinedisstopReason: 'refusal'(packages/subagent/tool-subagent/src/index.ts). The parent's own reject is the same string:research step ceiling reached (24 steps).Result: the run blocked in
research, 24 of 240 steps, $0.0471 of $2.00 (2%), with no research note written.Why
policyForbuilds one policy per agent (policies = new WeakMap<Agent, FeatureLoopPolicy>()), andcreatePolicybuilds a pipeline for each:startPipeline()returns{ state: 'research', … }. So every helper the model delegates to inherits the run's goal, its phase machine, and — viaphaseJustEntered— a phase-1-of-5 briefing, then dies at that phase's ceiling.The fix
A session that is a subagent child —
origin: 'subagent'or a non-zerodelegationDepth, both read structurally fromsession.header— now runs under the same spec, budget, ladder and review gate, with onlypolicy.pipelinecleared:Three deliberate choices:
writeandbashare exactly as irreversible as the parent's, and the gate is the thing that asks a human. Only the phase machine — meaningless without a goal — is switched off.originanddelegationDepthare set by different parts of the harness, and a release that moves one should not silently re-enable the phase machine on every helper.The predicate is its own import-free module (
src/subagent.ts), so the CI job that installs nothing can test it — the same reasonwatcher-ttl.tsis.Verification
test/subagent.test.tsfails with thepolicyForbranch reverted — bothnot ok 6(the predicate is wired) andnot ok 7(the branch clears only the pipeline) — and passes with it. Confirmed, then restored.gate/spec/budget/worktreeRootfails rather than quietly disarming a subagent's gate.tsc --noEmitclean; pre-commit scan clean.src/subagent.tsadded to the typecheck list andtest/subagent.test.tsto the no-install test job inci.yml— with both lists re-checked so every path they name exists.Context
Third bug from the same headless run, and the second one that only became observable after the previous was fixed:
b4c3f44) — an open dashboard tab was a watcher only 60% of the time, so the ask hung.18a94e3) —/productand/loopproduced a command row and no turn.Each was found by driving the real thing and reading what it actually did, not by reading the code.