Skip to content

feat(fusion): a stalled review must delegate or reply; a request-named input is not replaced without overwrite (F41, F51) - #459

Merged
plombeer31 merged 5 commits into
mainfrom
harness/17-review-stall-inputs
Sep 17, 2026
Merged

plombeer31 merged 5 commits into
mainfrom
harness/17-review-stall-inputs

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

What

Two fixes for the two failure classes that survived the bench-2 matrix on both local models (findings 29, 34, 35):

  • F41 — a stalled Fusion review is told to delegate, then can only delegate or reply. With one failing check line, Gemma (bugfix) and Qwen (site) as Fusion planners read for 90 minutes each — zero fusion.delegate calls — because the orchestrator gate refuses every write and a local model answers a refusal by reading again. Now the orchestrator turn counts consecutive read-only steps since the last fan-out (a refused write counts as one). At llm.runMode.fusion.reviewStallSteps (default 6, 0 disables; halved when the message is a repair request with failing check lines) the model gets one notice: "6 steps of reading and no fan-out. In Fusion you cannot edit; a fix means fusion.delegate with the change spelled out, or reply with what stands." At twice that, the step's tool set is fusion.delegate, reply, finish only — the grammar for local models, the descriptor list for cloud models, and a dispatch refusal for the text-JSON fallback — so a read cannot be emitted. A fan-out resets the count. step_finished carries reviewStall: {steps, phase}; the TUI step line says "review stalled: delegate or reply" on a cut step. Config 68 → 69.
  • F51 — a file the request names as an input is not replaced without overwrite: true. Five first attempts across both local models failed by regenerating a provided file from memory (projects.json, sales.csv); F36/F43 warned and saved, the write landed. Three layers: (1) os.fs.write on a file that existed before the turn, was not created by the agent, and whose name appears in the pinned original request is refused — refused: sales.csv is an input the request names (2,402 lines → 10); edit it in place (os.fs.edit / os.fs.patch), or pass overwrite: true if replacing it is really what the user asked for — unless the request's own words asked for a rewrite (rewrite / replace / regenerate / overwrite / recreate / reset within the same sentence, negations respected); os.fs.edit and os.fs.patch are never refused by this rule. (2) One persona line: "Files that existed before this turn are the user's: edit them in place and write new files beside them; do not regenerate a provided file from memory. If the user asked for a rewrite, say so and pass overwrite: true." (3) The fusion contract gains inputs: declared inputs are listed in the worker brief as edit-in-place, and a worker's write that would replace one is refused (the orchestrator redeclares if a replacement is really wanted).

Why

These are the last two ways a Fusion or local run in the matrix failed without a defect in the task itself: the planner never handing the fix to a worker, and the builder treating the user's data as scratch. Both fixes act on the runtime's own signals (the gate's refusals, the pinned request, the F36 store), not on prompt hope; the notice comes first, the hard cut second, and every refusal names the one thing to do next.

How it was verified

  • npm run lint clean
  • npx vitest run src/agent src/config src/tracing src/llm — 154 files / 2,434 tests green; src/tools src/prompt src/agent src/runtime — 1,305 + 773 tests green
  • Full suite on this head: 928/929 files, 10,815 passed, 3 skipped, 1 failed — the pre-existing send-message-concurrency baseline (the whole stack rebased onto main at v0.6.2, 2026-09-17)
  • F41: counter/notice/cut unit tests; the grammar and the native descriptor payload both narrow to the three names on the cut step; a repair request halves N; 0 disables; a turn that delegates on step 2 never sees the notice; with the loop wiring stashed the seams tests fail.
  • F51: the refusal fires only for request-named, pre-existing, not-agent-created files; verb exemptions and negations; overwrite: true passes; worker refusal on a declared input; the persona line pinned; with the write wiring stashed 4 of 11 refusal tests fail.
  • Live: re-run of the two local-planner failures (Gemma bugfix, Qwen site) and a local site first attempt on this exact build is in progress; results land as a comment on this PR

Stacked on #453 (harness/16-local-turn-shaping); merge in order.

…t overwrite: true

F36 announced a replacement and saved the previous content; live, five
first attempts across two local models still rewrote the file the request
named as the input (projects.json, sales.csv) from memory, read the
warning, and went on. A warning is advice; the write had landed.

New `fs-input-guard.ts`, run by `os.fs.write` (mode replace) before the
approval prompt: a non-empty file that existed, was not created by this
session (the F36 store's created set) and whose basename appears as a
whole name in the turn's pinned request is refused with the counts and
the way onward — `refused: sales.csv is an input the request names (2,401
lines → 10); edit it in place (os.fs.edit / os.fs.patch), or pass
overwrite: true if replacing it is really what the user asked for` —
as an error result with `details.refused: "input"`. `overwrite: true`
(declared in the args schema, so F40 admits it) lets the write through
to the approval and the F36 note. The request's own words lift the rule:
the basename within six words after rewrite / replace / regenerate /
overwrite / recreate / reset (any inflection, same sentence, not
negated). Edits and patches are never refused; every other pre-existing
file keeps the warn-and-save path.

The request reaches the fs tools as `resolveOriginalRequest`, the
bootstrap's per-turn record the workers' briefs quote (declared before
`registerOsTools`). A worker's record is now its brief, pinned by the
worker branch of `executeTurn`; only its ORIGINAL REQUEST block counts
(`quotedRequestText`, the brief's markers shared through
`request-section.ts`), so a file the task names as an output is not
mistaken for one the operator provided.

Tests: name matching (whole name, path prefix, case, longer names), the
verb window and its negations, the worker-brief block, F40 acceptance of
`overwrite`, and the write tool end to end — refusal before any prompt
with the file untouched and no copy taken, overwrite landing with the F36
note, the lift on a rewrite request, created / edit / append / new-file
exemptions, no request / no store / empty file / unnamed file, the
spelled path and the too-large size, and a worker guarded by its brief's
request block rather than its task.
… edit in place

The refusal in `fs-input-guard.ts` catches the replacement; this is the
model told the rule before it reaches for the tool. Both personas —
grammar and native-tools — carry `SYSTEM_PERSONA_INPUTS_LINE` right after
the bias-toward-action line: "Files that existed before this turn are the
user's: edit them in place and write new files beside them; do not
regenerate a provided file from memory. If the user asked for a rewrite,
say so and pass overwrite: true." One constant, so the two variants
cannot drift; a one-time stable-prefix byte change, like every persona
edit before it.

Tests (`stable-prefix.test.ts`): the exact sentence, its position (line 3
of both personas, between "Bias toward action" and "Terminals"), once in
the built prefix on either transport and ahead of `### rules`, and a
ceiling on the persona's token cost (~1,310 today, under 1,400) with the
line itself under 70.
…ver replaced by a worker

The request-name refusal gives a plain session `overwrite: true` as the
way past it. A worker is the wrong party to decide that: it sees the
request only as context, and the orchestrator read the whole job. So the
`fusion.delegate` contract gains `inputs?: string[]` — paths relative to
the working directory or absolute, at most 32, trimmed and deduplicated,
a bad entry named by index like every other shape problem, an empty
list dropped (`contract-inputs.ts`, read by `parseDelegateArgs`, counted
against the rendered cap). The shared block every worker reads lists
them first: `INPUTS (the operator's own files — read and edit in place,
never replace; os.fs.write on one is refused):`.

The worker runner resolves them as the worker's tools would (globs guard
nothing) and declares them for the worker's session in a new
`DeclaredInputsRegistry` (`fs-declared-inputs.ts`, the shape of
`FanoutScopeRegistry`, cleared in the same `finally` as the refuse
policy). `os.fs.write` on a declared input is refused for that session
with no `overwrite` exemption, without a store or a request — the
orchestrator declared it, and it can redeclare. The refusal's head is
the request rule's; its tail says what a worker can do, because telling
a worker to pass a key that is ignored for it would loop: `refused:
sales.csv is an input this fan-out declared (2,401 lines → 10); edit it
in place (os.fs.edit / os.fs.patch) — a worker cannot replace a declared
input; if the task needs it replaced, say so in your reply so the
orchestrator can redeclare it`. The registry is wired in the bootstrap
next to the request record; the descriptor and the args schema of
`fusion.delegate` carry `contract.inputs`.

Tests: parsing (carried alone or with the rest, JSON text, every bad
entry named, non-array, cap, empty dropped, rendered cap), the INPUTS
section first in the block and absent otherwise, resolution without
globs or repeats, the brief, the runner's declare-before / clear-after
with and without inputs, the tool end to end for every worker, and the
write tool: refused whatever `overwrite` says with the file untouched and
no prompt, an edit in place fine, another session unbound, the worker
unbound after `clear`.
@plombeer31
plombeer31 changed the base branch from harness/16-local-turn-shaping to main September 17, 2026 10:30
@plombeer31
plombeer31 merged commit 30e7972 into main Sep 17, 2026
@plombeer31
plombeer31 deleted the harness/17-review-stall-inputs branch September 17, 2026 10:31
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