Skip to content

feat(runtime): ask an unattended turn to prove the task's requirements - #67

Merged
mangit955 merged 6 commits into
mainfrom
feat/requirement-finish-gate
Aug 30, 2026
Merged

feat(runtime): ask an unattended turn to prove the task's requirements#67
mangit955 merged 6 commits into
mainfrom
feat/requirement-finish-gate

Conversation

@mangit955

Copy link
Copy Markdown
Owner

Why

A turn ends when the model responds without calling a tool. One thing could overrule that: a turn that had changed files and run nothing to check them was asked, once, to verify. That gate fires on evidence the loop can see. The failure it cannot see is a turn that verified something thoroughly, and verified the wrong thing.

Two of the three failed trials in jobs/tb2-post-1.1 ended that way — early, confident, and wrong, with most of both budgets unspent.

task stopped at of ceiling wall used what it did
overfull-hbox iteration 59 200 26% ran its chosen check three times, declared success
video-processing iteration 83 200 7% confident, wrong

overfull-hbox is the instructive one: it ran pdflatex and a search for overfull boxes three times over, so the unverified-edits gate had nothing to say — the edits were checked. The task also constrained which wording was permitted, and nothing it ran tested that. CLAUDE.md already recorded the pattern from an earlier run: two trials that reported success with accurate self-verification still scored 0, because they verified the wrong property.

Separately, and found while reading that gate: the verification reminder was clock-blind. It guarded only iterations < budget, so injected against a spent wall budget it sent the turn round to a deadline check that throws — turning a turn holding a finished answer into WallBudgetExhaustedError and exit status 2. Reachable on every wall-budgeted run. That fix is the first commit, on its own.

What

A second finish gate. An unattended turn that responds with no tool calls, has ten steps left and no wind-down warning outstanding, is asked once to enumerate every stated requirement — including constraints on what is not allowed — and quote the command output proving each. Recollection is explicitly refused as evidence, since the turn being interrupted is one whose recollection is already wrong.

Ten steps is twice the wind-down threshold. The gate asks for work, and inside that zone the loop is telling the model the opposite: finish what you started, begin nothing new. The windDownWarned flag is read as well as the count, because the count comes from a measured rate that moves — it can recover past the floor while the model is still under a warning issued earlier.

Headless only, by an explicit option. AgentLoopOptions.unattended, set by runHeadless and threaded through AgentController. Deliberately not inferred from a missing onBudgetExhausted callback, which would hand the behaviour to every embedder that happens not to pass one, and not read from WOOPCODE_NON_INTERACTIVE, which only the benchmark harness sets. Interactive turns are excluded because a person is reading the answer and can correct it for the cost of one sentence.

Both gates answer with one message. They are cheap in round trips and expensive in window: each injection costs one of the six conversation turns the window keeps, and losing the window is what this gate exists to correct.

The task statement is pinned into the window. The gate says "go back to the task statement above", and that has to be true. The window counts user messages and the loop pushes its own — the wind-down warning, the finish gates, a truncated-stream resume — so six of them and the question being worked on has left the request. agentLoop captures the turn-initiating message at entry and recentMessages carries it back in when the window has moved past it. Rejected: quoting the task into the gate's message instead, which leaves a long turn still arguing from a question it cannot see; and pinning messages[0], which is a greeting in an interactive session and a trimmed leftover under --resume.

The duplicate threshold is cleared when the gate fires. The two collide head on: the check a turn most needs to re-run is usually the one it has already run twice, where the loop answers that the result is already in the conversation — pointing at output the window dropped long ago.

Observability. TurnSummary gains requirementReminders and requirementGateActedOn, both on run_end and lifted into Harbor trial metadata. They separate three outcomes a score collapses into one: never fired, fired and the model ran commands, fired and the model answered in prose from memory. The last is this mechanism's likeliest failure and is otherwise invisible.

docs/adr/0002-finish-gates-for-an-unattended-turn.md carries the reasoning and the rejected alternatives. CONTEXT.md is new and holds the turn-lifecycle vocabulary as a glossary.

Verified

bun run verify --all on the tree as pushed: docs lint, docs surface, type check and bun test all pass. The reverse-order sweep runs 1965 tests across 120 files with 0 failures, and python3 -m pytest harbor_woopcode/test_agent.py passes 46.

Every mechanism was proved by reverting it and watching its test go red, each revert confirmed applied before the suite ran:

reverted what went red
the clock guard on the verification gate WallBudgetExhaustedError in place of the finished answer
the pin the task absent from every request after the sixth injection
the gate's unattended condition seven tests, while the three negative ones stayed green
the duplicate amnesty two executions of the repeated check instead of three
the composed status line the merged notice naming only the verification gate
the trial metadata keys KeyError in the harness tests

While writing the pin tests, agentController.test.ts was found stubbing recentMessages with an ungated identity function registered for the whole run — the mock.module trap CLAUDE.md documents, and the reason the new tests would otherwise have passed vacuously. It is now gated on stubActive like the session stubs beside it.

What is not verified. bun run replay:baseline is unchanged before and after, and that is not evidence: the corpus holds one conversation turn per fixture, because the loop's injected messages were never written to the event log, so the pin cannot fire there. The harness cannot see this change.

Nothing here has faced a live provider or a real task. Whether the model obeys the gate or answers it in prose is exactly what requirementGateActedOn exists to measure, and only the benchmark answers it — overfull-hbox and video-processing first, then the five-task job to check the three passing tasks did not regress. Draft until those have run.

… answer

The reminder guarded only `iterations < budget`, which cannot see a clock.
Injected against a wall budget with nothing left, the turn goes round, the
deadline check at the top of the next iteration fires, and a turn holding a
finished answer exits as WallBudgetExhaustedError with status 2.

Both budgets are now read through `canAffordAnotherRound`: stepsRemaining
against a floor of three, plus the deadline consulted directly, because the
clock-to-steps conversion is not trusted until MIN_RATE_SAMPLES steps have
gone into it and a short turn would otherwise slip past.
recentMessages counts user messages, and the loop pushes user messages of its
own — the wind-down warning, the verification reminder, a truncated-stream
resume. Six of those and the request no longer contains the task being worked
on, while the turn carries on working on it.

The loop captures the turn-initiating message at entry, as an index because the
array only grows, and recentMessages carries it back in when the window has
moved past it. A window that still holds it is assembled byte-identically.

The replay corpus cannot measure this: its recordings hold one conversation
turn each, because injected messages were never written to the event log, so
the pin never fires there and the baseline is unchanged.

Also gates agentController.test.ts's recentMessages stub on stubActive, like
the session stubs beside it. Ungated, its identity window was installed for the
whole run — under it these tests would have passed vacuously.
Two of three failed trials in tb2-post-1.1 ended early, confident and wrong,
with ~70% of both budgets unspent. The only completion gate looked for edits
that had gone unchecked, and theirs had been checked — overfull-hbox ran its
chosen check three times. It verified a property the task never asked about.

So a second finish gate: an unattended turn that responds with no tool calls,
with ten steps left and no wind-down warning outstanding, is asked once to
enumerate every stated requirement and quote the command output proving each.
Both gates answer with one message when both apply, because a second injection
costs one of the six conversation turns the window keeps.

The duplicate threshold is cleared as the gate fires: the check a turn most
needs to re-run is usually the one it has already run twice, where the loop
would answer that the result is already in a conversation the window dropped.

TurnSummary gains requirementReminders and requirementGateActedOn so a run can
be read for "asked and ignored" rather than only "asked". Reasoning, rejected
alternatives and how this gets judged are in the ADR; CONTEXT.md is new and
holds the turn-lifecycle vocabulary.
run_end already carried the two fields; this lifts them beside
woopcode_unverified_edits so a job can be read without opening a trajectory.
Kept as two keys because they answer different questions: how often a trial
tried to stop early, and how often being asked sent it back to run something.
A gate that fires and is answered in prose changes nothing, and in the score
alone that is indistinguishable from a gate that never fired.
Review findings from the two-axis pass over this branch.

Standards: both new test files hand-rolled the deadline incantation the
bun-test skill says to take from packages/tests/shared — so the movable-clock
case joins budgetWith there as budgetDrivenBy, summaryOf moves to shared as
turnSummaryOf instead of being copied per file, and the four inline stream
generators become createStreamingProvider. canAffordAnotherRound's ceiling
parameter is named for what it is.

Spec: the merged injection reported only the verification gate on the status
channel, which is what a headless operator watches and what the event log
records — the notice now names every gate that fired. canAffordAnotherRound
guards deadlineReached on whether this turn set a wall budget at all, mirroring
the loop's own check, so two readers of one piece of module state cannot
disagree.

The ADR gains the revert-to-red evidence per mechanism, which was run but
never written down.
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
woop-code Ready Ready Preview Aug 30, 2026 1:34pm

@mangit955
mangit955 marked this pull request as ready for review August 30, 2026 13:27
…'s bound

Review follow-ups.

The pin makes MAX_TURNS a bound on the tail rather than on the window: a pinned
request carries one conversation turn more than the constant names. That is the
only exception in a budget every other context decision treats as absolute, so
it is now stated at recentMessages, in the ADR and in CONTEXT.md, and pinned by
a test that asserts the arithmetic in both directions.

The amnesty is wholesale — every exhausted call may run again, not only the one
the gate asks about — and what bounds it is that suppression resumes at once.
There was a test for the intended repeat running again and none for the
downside; now a third identical call after the gate is asserted to be refused,
which fails if the threshold is disabled rather than reset.

No change to windDownWarned, which is not latched for the turn: shouldWarnWindDown
clears it above twice the threshold and requirementGate.test.ts covers the gate
firing after it clears. The gate's comment now says so, since a reader of
`!state.windDownWarned` could reasonably assume otherwise.
@mangit955
mangit955 merged commit e50bb87 into main Aug 30, 2026
6 checks passed
@mangit955
mangit955 deleted the feat/requirement-finish-gate branch August 30, 2026 13:38
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