Skip to content

fix: a subagent must not run its own 0→1 phase machine - #48

Merged
linhdmn merged 1 commit into
mainfrom
fix/subagent-no-pipeline
Oct 7, 2026
Merged

linhdmn merged 1 commit into
mainfrom
fix/subagent-no-pipeline

Conversation

@linhdmn

@linhdmn linhdmn commented Oct 7, 2026

Copy link
Copy Markdown
Member

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-live profile). The model spawned four research subagents. Every one of them has its own run record and its own phase table:

67770243-…  Verdict: blocked   research | current | 24 | $0.0638 | $0.2000
3b8306ef-…  Verdict: blocked   research | current | 24 | $0.0531 | $0.2000
f3ab6708-…  Verdict: blocked   research | current | 24 | $0.0700 | $0.2000
120412bc-…  Verdict: blocked   research | current | 24 | $0.1093 | $0.2000

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>
  • the subagent was told "0→1 PIPELINE — phase 1 of 5: RESEARCH" — the parent's pipeline notice, in a session that has no goal
  • and each ended turn/end {"reason":{"kind":"blocked"}} after its 24th step

The parent then received, four times:

Background subagent 67770243-… declined the task.It left no closing message.

declined is stopReason: '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

policyFor builds one policy per agent (policies = new WeakMap<Agent, FeatureLoopPolicy>()), and createPolicy builds a pipeline for each:

const pipeline = spec === undefined || options.pipeline?.enabled !== true
  ? undefined
  : buildPipelineRuntime(spec, options.pipeline, options)   // → startPipeline()

startPipeline() returns { state: 'research', … }. So every helper the model delegates to inherits the run's goal, its phase machine, and — via phaseJustEntered — 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-zero delegationDepth, both read structurally from session.header — now runs under the same spec, budget, ladder and review gate, with only policy.pipeline cleared:

if (isSubagentSession(sessionHeaderOf(agent))) policy.pipeline = undefined

Three deliberate choices:

  • 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. Only the phase machine — meaningless without a goal — is switched off.
  • Both signals are read. origin and delegationDepth are set by different parts of the harness, and a release that moves one should not silently re-enable the phase machine on every helper.
  • Malformed signals fail closed to "not a subagent." An unclassified session keeps the phase machine it would have had before this existed.

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.

Verification

  • test/subagent.test.ts fails with the policyFor branch reverted — both not ok 6 (the predicate is wired) and not ok 7 (the branch clears only the pipeline) — and passes with it. Confirmed, then restored.
  • Test 7 asserts the whole branch body is that single assignment, so a future edit that also clears gate/spec/budget/worktreeRoot fails rather than quietly disarming a subagent's gate.
  • Full suite 829 pass / 0 fail / 1 skipped (830 tests); tsc --noEmit clean; pre-commit scan clean.
  • src/subagent.ts added to the typecheck list and test/subagent.test.ts to the no-install test job in ci.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:

  1. fix: an open dashboard tab must stay a watcher, or a headless run hangs #46 (merged, b4c3f44) — an open dashboard tab was a watcher only 60% of the time, so the ask hung.
  2. fix: /product and /loop must start a turn, not just return text #47 (merged, 18a94e3) — /product and /loop produced a command row and no turn.
  3. this PR — a subagent ran its own phase machine.

Each was found by driving the real thing and reading what it actually did, not by reading the code.

`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.
@linhdmn
linhdmn merged commit 808f398 into main Oct 7, 2026
3 checks passed
@linhdmn
linhdmn deleted the fix/subagent-no-pipeline branch October 7, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant