feat(dispatch)!: steer the agent run already in flight on work-item updates - #6959
feat(dispatch)!: steer the agent run already in flight on work-item updates#6959waynesun09 wants to merge 57 commits into
Conversation
|
🤖 Finished Review · ✅ Success · Started 12:56 PM UTC · Completed 1:33 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $21.98 |
PR Summary by QodoSteer in-flight agent runs on work-item updates
AI Description
Diagram
High-Level Assessment
Files changed (46)
|
Code Review by Qodo
1.
|
| The watcher asks the forge what the work item is, at startup, rather than reading it from the | ||
| job's environment. `PR_HEAD_SHA` is set only on the deprecated per-org dispatch path, so a | ||
| per-repo run has neither a head SHA nor any way to tell a pull request from an issue. Guessing | ||
| wrong is not cosmetic: an issue-shaped baseline of empty title, body and labels makes every delta |
There was a problem hiding this comment.
6. Per-org change lacks adr callout 📘 Rule violation § Compliance
The new ADR and watcher comments discuss the deprecated per-org dispatch path, but the PR description does not disclose that deprecated functionality is being touched or reference ADR 0044. Any such change must be explicitly called out even when it does not add per-org behavior.
Agent Prompt
## Issue description
The changes reference deprecated per-org installation behavior without the required PR-description callout.
## Issue Context
Update the PR description to state that deprecated per-org dispatch behavior is referenced or affected and link to ADR 0044. Confirm that no new per-org-only capability is introduced.
## Fix Focus Areas
- docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[161-164]
- internal/steerwatch/watcher.go[239-241]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Site previewPreview: https://b9a48ef6-site.fullsend-ai.workers.dev Commit: |
|
Risk Assessment: high (4/5) DetailsScore anchored at 4 (high) per re-review anchoring policy. Tier 1 signals essentially unchanged: FILES_CHANGED remains 65, LINES_CHANGED increased marginally from 10936 to 11414, PROTECTED_PATH_COUNT at maximum threshold, CI_WORKFLOW_CHANGED true. Tier 2 churn identical to prior (internal/cli 291, internal/runtime 158 commits in 30d). Breaking behavioral change to in-flight agent run control with the issue still open. Previous runRisk Assessment: high (4/5) DetailsHigh risk anchored from prior assessment: Tier 1 signals essentially unchanged (65 files, 10936 lines, PROTECTED_PATH_COUNT at maximum threshold), Tier 2 churn near-identical (internal/cli 291 and internal/runtime 158 commits in 30d), breaking-change designation, and no mitigating factors justify lowering the score. Previous run (2)Risk Assessment: high (4/5) DetailsHigh risk maintained from prior assessment: PR grew from 46 to 64 files (10920 lines), PROTECTED_PATH_COUNT at maximum threshold, Tier 2 churn signals near-identical (internal/cli 295 and internal/runtime 159 commits in 30d), and no mitigating factors emerged to justify a lower score. Previous run (3)Risk Assessment: high (4/5) DetailsHigh risk due to a breaking change, very large blast radius (46 files, 7637 lines across 9 packages including a new internal/steerwatch/ package), active modification of high-churn areas (internal/cli/ with 298 commits in 30 days, internal/runtime/ with 118), CI workflow changes, and cross-component scope spanning runner and dispatch. The 0.33 test file ratio provides partial mitigation but is modest for a change of this magnitude. |
ReviewFindingsMedium
Low
Next steps:
Previous runReviewFindingsHigh
Medium
Low
Next steps:
Previous run (2)ReviewFindingsMedium
Low
Next steps:
Previous run (3)ReviewFindingsMedium
Low
Labels: PR introduces a new runtime capability (steering) spanning dispatch workflows, runner CLI, and all three runtime backends Next steps:
|
|
functional-tests is red on |
Two review findings on #6959, one cosmetic and one that would have lost updates. 1. closeFeedIf was duplicated verbatim on ClaudeRuntime and PiRuntime. Extracted as steerCloseFeedIf in steer_session.go, next to the state machine whose decision it acts on. Both runtimes call it; behaviour is unchanged, and it now has direct tests (the two copies had none, being reachable only from Run's stream handler). 2. Confirming steer-2's Qodo fix surfaced a real defect on the codex path. recordDelivery was called from nextCodexTurn, BEFORE the resumed process launched, so a resume that failed to start — or one codex refused because the thread was gone — still appended a SteerResult. With the marker now rebuilt from RunMetrics.Steers, that would mark the follow-up run consumed, the queued run would skip it, and the update would be lost outright rather than merely delayed. Delivery is now two-phase: nextCodexTurn stakes the message with the resume's start time, and confirmDelivery records it only once that process reported a thread of its own. codex emits thread.started on a resume — verified live, the resumed process repeats the original thread_id — so an empty thread id is proof the resume never took. It is confirmed on the error path too, which is precisely where the resume did not happen. DeliveredAt is still the resume's start, not its end, because that is when the message enters the thread. Verified for the reviewer's question, all three runtimes append a SteerResult only after the delivery signal and never on a failed one, and FollowUpRunID is carried from the SteerMessage in every case: - Claude: appended in noteEcho, driven by the --replay-user-messages echo (isReplay). appendLine increments nothing and queues nothing unless the mailbox write returned exit 0, so a failed write can never acquire a result. Already covered; the failed-write test now also asserts steerResults() stays empty. - pi: same shared machinery, its ack being the rpc `response` with command=prompt and success=true (a success=false response is deliberately not an ack, already tested). Added two pi-level tests — nothing recorded at append time, recorded on the steer's own ack with the right run id, and nothing recorded when the write fails — because the reviewer asked per runtime and pi had none of its own. - Codex: as above, plus tests that an unconfirmed resume records nothing, that a discarded delivery cannot be resurrected by a later confirm, and that confirming with nothing staked (every run's first turn) is a no-op. steerCloseFeedIf, stakeDelivery and confirmDelivery are at 100%. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
|
🤖 Review · Commit: |
|
🤖 Finished Review · ❌ Failure (validation failed after 2 iteration(s)) · Started 4:04 PM UTC · Completed 4:46 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high |
…omment The marker was honoured on any comment by the App login, anywhere on the work item. An agent can be induced to write one into its own output — an injection in a PR body asking it to include a receipt naming a specific run id is enough — and the App posts that output, so the comment is genuinely App-authored. The queued run named in the forged receipt would then find its own id, exit, and the update would be lost silently. A stronger identity check does not help: performed_via_github_app and the App's client id both say yes, because the comment really was posted by the App. Scope is the control, not identity. Only the run's own status comment is written by the runner rather than by the agent, so a receipt now counts only in a body carrying both the per-run status marker and the terminal tag, which only buildCompletionBody writes together. Second layer: agent output is defanged before it is posted, so the forged text never reaches the timeline. Both paths that post agent output as the App are covered — sticky.Post and postTrackerStickyComment, the tracker-based path behind `fullsend issues post-comment`. The pattern is deliberately looser than the marker parser — any case, any whitespace including newlines and CR between `<!--` and `fullsend:` — because neutralization must cover everything a marker parser could ever match, not just what one matches today. Only the "<" is escaped, so an attempt stays visible as text rather than being silently deleted, and prose that already reads "<!--" carries no "<" and is untouched. Applied to the agent's content before the sticky marker is prepended, so the comment's own marker is unaffected. A post-script that shells out to `gh` from inside the sandbox still bypasses both, which is why the scoping fix and not the neutralization is the control: a marker planted that way is inert, it is merely visible. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
ADR 0098 (#6909) is open and under review, and it and this change were written independently against the same feedback. Saying the dispatch half here is 0098 implemented claims an alignment nobody has established. State the shared first move, that the two were not designed against each other, and that they should be reconciled where they overlap. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Three findings from an adversarial review of the security fix, plus a correction to what the previous commit claimed. **A route job's verdict is about one comment, not a standing grant.** authorizedActors credited a login for the whole poll batch, and any later item from that login became an amendment. A collaborator could post an authorized slash command, lose repo permission, comment again, and have the second comment promoted on the strength of the first. An authorization now carries the instant it covers — a comment necessarily predates the run it triggered, so that run's creation is the cutoff — and an item qualifies only against an authorization that covers its own timestamp. A second authorized comment brings its own run and is covered by that. **Collapsed history was re-posted unneutralized.** Both sticky paths defang the new body but then copy the old one into their `<details>` history verbatim, so anything smuggled in before this defence existed survived every later edit. The old body now goes through the same filter. **issue_comment's eligibility rests on a filter in another file.** The route arm never inspects the action, so "the login checked is the login the run reports" holds only while the shim delivers `created` events; on `edited` the sender is the editor, who need not be the author. Both shims are now pinned by a test. CORRECTION to the previous commit's message, which said a marker planted by a post-script is "inert, merely visible". That is wrong. The scoping check authenticates two public strings, not the code path that wrote them, so an agent holding the same App token can post a comment carrying both status tags and a marker and it passes. Scoping raises the bar; it does not close the hole. Closing it needs authenticity the agent cannot mint — a status-only credential withheld from the sandbox, or a runner-signed receipt — and until then the skip check must be treated as advisory. Recorded on LatestSteerMarker as a known gap. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
…eering The adversarial review established that scoping the steer receipt to a body carrying the status markers does not make it unforgeable: it authenticates two public strings rather than the writer, and the runner's status comments and the agent's own output are posted under the same App identity. An agent induced to emit those strings through a post-script reaches none of the runner's sanitizing paths, and the resulting receipt passes — so the queued run exits without doing its work and the update is dropped silently rather than merely wasted. That belongs in the ADR rather than only in a commit message, since the ADR is where a reviewer looks for what must be true before this is switched on. The rollout section also now says why the receipt is load-bearing rather than an optimization: without one, steering costs more than cancelling does today, because the active run reviews the new head and the queued run reviews it again — two reviews where cancel-and-restart produces one. The first Consequences bullet claimed that saving unconditionally and is qualified to match. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
…claims The ADR's security model was a revision behind the fix, in the place the fix corrected. It called the run record a statement of who asked for what, when it states what ran and when: the reported actor is the authorized principal only for issue_comment. Check 4's Rejects cell claimed it turns away an unauthorized actor, but a successful Route job proves only that someone was authorized, which on a pull_request_target synchronize is the PR author while the actor is the pusher. And the amendment/context split, which is the authority boundary of the whole design, was undocumented, so a reader implementing from this ADR would have rebuilt the escalation the issue_comment-only rule exists to close. Also restores the blank line between two doc comments so godoc stops attributing allowedEvents' text to amendmentEvents. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The base change owns whether a run in flight survives a newer event, with FULLSEND_PRESERVE_RUNS; this change owns whether that surviving run is steered, with the harness steer: block. ADR 0101 and the harness reference described one variable doing both jobs. The dependency runs one way and the ADR now says so: steering a run that is about to be cancelled is pointless, so a repository wanting steering must set FULLSEND_PRESERVE_RUNS too, while preserving runs is useful on its own and is the base change's whole subject. That also retires a claim the split makes false. The rollout order warned that setting the variable without enabling the harness block was "worse than today" — stale output plus a full re-run. With the split, that intermediate state is not a mixed state at all: it is exactly the base change, where the run in flight finishes and the queued run works from the item's current state. Nothing is half-enabled, so a repository can sit there indefinitely, which is where every repository starts. The order is therefore the variable first, then the harness block, with steering's own preconditions — the fleet definitions, one real steer per runtime, and the authenticated receipt — attached to the second step where they belong. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The mailbox lives in the runtime's config directory, which the agent can write to. I had documented that and argued it granted nothing, reasoning only about the content: a line the agent appends injects a user message into its own session, and it already controls its own output. That was incomplete. The agent's line is echoed back exactly as the runner's are, and the runner was counting echoes, so an injected line reached the runner's own bookkeeping. Two defects followed. Attribution was positional — the nth echo after the opening prompt credited the nth queued steer. An injected line arriving before a real steer's echo shifted the index, stamping that steer's SteerResult on somebody else's echo. Since the receipt is built from acked SteerResults, this could receipt a steer the agent had not consumed, and the queued run would then skip work nobody did — the one failure the receipt exists to prevent. The settle condition was an equality between lines written and echoes seen. Any injected line made echoes exceed writes permanently, so the equality could never hold again, the feeder was never stopped, and the run burned its whole timeout. A prompt-injected agent could hang its own run. Each echo is now matched to a runner-written message by identity, and an echo matching nothing outstanding is ignored — not counted, not credited, and with no effect on the settle condition. The key is what each runtime can actually echo back: pi's rpc `response.id`, which the runner already generated and the parser was discarding, and for Claude Code the message content, which --replay-user-messages returns verbatim. Using the content means the envelope the agent reads is unchanged, so the wording that took six probes to get right is untouched. An agent that copies a key out of the mailbox cannot get ahead of the message it copies: the copy must be appended after it, the feeder delivers in order, and each outstanding message is acked at most once — so the original claims its own echo and the copy matches nothing. There is a test for that too. Tests cover both attacker cases named in review: an injected line ahead of a real steer does not receipt it, and a run carrying injected lines still settles instead of running to its timeout. The mailbox comment now says what agent-writability actually grants and what constrains it, rather than that it grants nothing. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
harness-reference.md documents steering as taking effect only when three things line up — `steer.enabled`, a runtime that can take a message into a running session, and `FULLSEND_PRESERVE_RUNS` set on the repository — and promises that missing any one leaves the run behaving as it does today, with the runner printing why it declined. steerEligible checked the first two and never the third, so the runner steered with the variable unset. That is the mixed state ADR 0101 calls worse than today: the stage job still cancels in progress whenever the variable is not "true", so the run absorbs an update, is cancelled anyway, and the run queued behind it repeats the work with no receipt to skip on. Enforcing the documented condition is the fix; weakening the documentation to match would have left the hazard in place. The variable was not reachable where the check runs. `FULLSEND_REPO_VARS` carries `toJSON(vars)` into the "Setup agent environment" step, whose script writes the sandbox environment — the runner's own step has an explicit env block listing individual `vars` entries and did not include this one. So the workflow now passes `FULLSEND_PRESERVE_RUNS` to the agent step of all seven stage jobs in reusable-dispatch.yml, next to the concurrency expression that reads the same variable. The deprecated per-org `reusable-<stage>.yml` workflows are deliberately untouched (ADR 0044). Note for the integrator: this edits reusable-dispatch.yml, which the base PR also modifies, so it may conflict on rebase. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The per-repo shim template gained a `run-name` so an in-flight run can bind a follow-up run to its own work item: issue_comment and issues runs carry no pull_requests[], and display_title is the only server-side alternative. This repository's own shim never got it. The PR notes that consumers receive the line through scaffold sync, but that does not cover this file. .github/workflows/fullsend.yaml is reached by no sync at all — its "managed by fullsend" header names an upstream at internal/scaffold/fullsend-repo/.github/workflows/fullsend.yaml, and no such file exists; that directory holds the deprecated per-org stage workflows. So steering on fullsend itself would have silently skipped every issue_comment follow-up for want of a binding, with nothing on the way to fix it. Adds the line by hand, and a test asserting the two shims declare the same run-name, since nothing else will reconcile them. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
BuildUpdatedBody removes the runner's own marker from an old body by exact prefix. Post and the tracker path both neutralized that body first, which rewrites the "<" of every fullsend marker opener — including the runner's — so the prefix no longer matched, the marker was not stripped, and an escaped copy landed in the visible history. One more accumulated every time the comment was updated. NeutralizeHistory splits the old body first: the runner's marker and footer are held aside intact, only the agent-authored remainder is defanged, and the pieces are rejoined. The smuggled-marker defence is unchanged — a receipt hidden in agent output is still escaped — and the strip that follows works again. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Two wordings that told a reader something the code does not do. The steer-marker comment said the skip check "must be treated as advisory rather than trusted". Nothing treats it as advisory: checkSteerAlreadyHandled trusts it outright and run.go returns before the start comment or the pre-script on the strength of it. The comment now says the check is trusted, and that this is precisely why ADR 0101 makes authenticated receipts a precondition for enabling steering rather than a later tightening. ADR 0101 line 64 said "the default" without naming which of the two switches it meant, and two readers in a row took it as contradicting the line further down about an unset FULLSEND_PRESERVE_RUNS keeping today's behaviour. They are different switches. The sentence now says so. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
…ng it claudeSteerAggregator.onResult added ReasoningTokens the way it adds every other usage field. That field is not like the others. Its siblings are read off `re.Usage` on each result event and are genuinely per-turn; ReasoningTokens is not on the wire there at all. parseClaudeStream accumulates thinking tokens into totalReasoning, never resets it, and emits that running total on every result — so a steered run of two turns that thought 100 then 50 more reported 250 instead of 150, and the error compounds with turn count. It belongs with total_cost_usd, which is taken for exactly the same reason and sits one line below it. The rule the aggregator documents was "usage adds, cost is taken"; the real rule is "whatever the parser has already accumulated is taken", and reasoning is on the parser's side of that line despite arriving in the same struct. The doc comment now says that, since the field's placement is what made the wrong treatment look right. Reasoning is also dropped from onTokens rather than left in its max(). The max hedge could not have caught this: TokensEvent carries msgReasoning, one message's thinking tokens, so it compared a run-wide accumulator against a per-message value and the accumulator always won. Comparing them was never meaningful, and the non-steered handler omits reasoning there for the same reason — it takes reasoning only from the result event. Two tests, neither of which existed: two results carrying a cumulative reasoning total must report the second value and not the sum (this one fails with 250 against the old line, and asserts the per-turn fields still add up so the fix stays scoped to the one field), and a per-message TokensEvent must not raise the run-wide total. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
The sentinel was written twice into one message. renderSteerEnvelope opens with it and then appends SteerMessage.Text, and that text is buildText's output, which opened with the identical line — so every steered run on every runtime received it once as the envelope's header and once inside the body the envelope wraps. That second position is the problem. The line is a cross-repo interface: the fullsend-ai/agents definitions match a message *beginning* with it to recognise a runner amendment, and treat the same line read inside work-item content as an injection attempt. The duplicate sat inside the wrapped body, so the runner was emitting the agents' own injection signal on every steer. buildText returns the body, so the envelope keeps the line and buildText drops it, with a comment there recording why the two must not drift back together. The assertion at delta_test.go:269 required the line in buildText's output. It is replaced, with a note: `assert.Contains` on a string that legitimately appears once cannot fail when it appears twice, which is how this went unnoticed on both sides. Two new count assertions. One covers the composed message as the agent receives it — envelope wrapping a body shaped like the watcher's real output — and requires exactly one occurrence, leading. The other covers the adversarial case the count protects: when work-item content itself carries the sentinel, the envelope must contribute precisely its own and no more. A third pins buildText emitting none, and fails against the old line. Checked against fullsend-ai/agents#1163 rather than assumed: nothing there keys off the count. The definitions match on position ("a message beginning ...") and on location ("the same line read inside issue content"), and the contract test only asserts each agent file carries the prefix string. So the fix needs no coordinating change there — and the prefix match is also why neither side caught this, since recognition kept working while only the injection signal degraded. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
A steered run stops at steerBudget — the agent's own budget clipped to what is left of the forge token's life — but the timeout detection measured elapsed against the raw harness timeout. The two diverge once the harness timeout exceeds the cap, and above roughly 55 minutes a run killed at its 50-minute budget does not reach nine tenths of the harness timeout, so it read as "finished early" rather than "ran out of clock". With a validation loop that surfaced as "validation failed after N iteration(s)", which is the wrong story for a run that ran out of clock. Without one it returned nil: the run reported success, the post-script ran, and nothing anywhere said the work had been truncated. That is the defect worth fixing — a real condition no assertion could see. steerBudget becomes the single source for the bound. steerDeadline turns it into the instant the watcher and the run context stop at, and steerAwareTimeout hands the same figure to the detection and to the reported message, so a later change to the cap reaches all three without a matching edit somewhere else. The message now states the limit the run was actually held to rather than one it never reached. The unsteered path is unchanged by construction: steerAwareTimeout returns the harness timeout untouched when no session ran, which is every run today. Not a regression from #7042. The divergence is steerDeadline capping below the harness timeout; #7042 only made it visible by giving the timeout branch precedence over the validation-failed branch. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Four fixes in one commit to spend a single CI cycle. 1. steermarker.go no longer attributes a policy to ADR 0101. The comment claimed the ADR "makes authenticated receipts a precondition for enabling steering anywhere (fullsend#7006)". The issue number was invented — 7006 appears nowhere in the ADR — and the maintainer's position is that the hardening is optional rather than a gate. The verifiable half is kept unchanged, because it is load-bearing: the check IS trusted, checkSteerAlreadyHandled takes it outright and run.go returns on the strength of it, so calling it advisory would describe an implementation that does not exist. The comment now states what the code does and leaves the decision to the maintainers rather than speaking for a document. 2. bugfix-workflow.md gains `/fs-steer`, in the command table and in the authorization sentence. The tier is read off the route arm rather than guessed: the command is floored by the stage it targets, so the default and the `review:`/`triage:` prefixes take `is_authorized triage` while `fix:` takes the write floor, exactly like `/fs-fix`. 3. steercmd.go's item-number error ended on a dangling "in". The caller wraps it as `%w: %s` with the URL, so dropping the preposition makes the surfaced message read as one sentence: "...is not a valid item number: https://...". 4. ADR 0101 no longer reads as gating itself. An Accepted ADR saying its overlap with 0098 "should be reconciled before either merges" is a contradiction; the substance is unchanged and only that clause moves, to say maintainers should reconcile the overlap. The style findings are deliberately not taken: steerEligible's inverted return, the `context` field name, the emittedInit pointer, the parsePiStreamMode suffix, newSteerGitHubClient's return type, the allowedEvents comment distance, and a VarPreserveRuns constant. Renames on a 10.7k-line change spend review attention on churn. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Reverses the finding-1 change in b6c6903. That finding rested on a grep for `7006` in ADR 0101, which finds nothing — but the policy is there in bold at line 362, "steering may not be enabled anywhere until receipts are authenticated by a channel that agents and post-scripts cannot mint", and again in the ship-together sentence, the steering preconditions, and Consequences. The search was for the issue number rather than the claim. The timeline settles the rest. ede3648, "make authenticated receipts a precondition for enabling steering", has an author date of 2026-09-04 13:12 UTC; the maintainer's "not blocked by 7006" and "current adr is right" came at 13:52 and 14:26. The precondition was already in the file when the ADR was endorsed, so it never drifted from the decision. The two statements are about different gates and both hold. "Not blocked by 7006" is about this change MERGING, which it does, because steering ships off by default. The ADR's precondition is about ENABLING steering in a repository. The comment now says which gate it means, since conflating them is what produced the wrong finding, and names both wrong readings — merge gate, and optional hardening — because the second is the dangerous one: it reads as permission to turn steering on without the receipt work. Keeps the one accurate part of the reverted edit: the policy is the ADR's and the issue carries the work, so the comment no longer implies the ADR cites fullsend#7006 by number. The other three fixes in b6c6903 stand. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Both are in the delta builder, both reachable from ordinary input, and a panic there runs in the watcher goroutine and kills the run. steerInstruction indexed `strings.Fields(first + " ")[0]`. The trailing space reads as a guard and is not one: strings.Fields returns an EMPTY slice for whitespace-only input, so `[0]` panics whenever the first line is blank — which is what a body opening with a newline produces, and that is ordinary human formatting, not a crafted input. It now checks the slice. truncate tested `len(s) <= max` and fell through to `s[:cut]` for any negative max, slicing with a negative bound. buildText derives the context budget by subtracting the amendments and the delimiters from maxDeltaBytes, so the value it passes is not guaranteed positive. A non-positive budget now yields the empty string, which is what a budget with no room means. Whether amendments can actually reach that size under the per-item caps is untested and left open; a helper that panics instead of returning empty is worth fixing either way. Tests cover a leading newline, a leading CRLF, whitespace-only and empty bodies, and negative and zero budgets, plus that a first-line command is still recognised and one pushed to a later line still is not. Both were mutation-checked: with each defect reintroduced the matching test panics, and passes again once restored. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
An authorization is a verdict on what the route job saw. The delta filtered on a comment's creation time and then placed its CURRENT body, so an edit made after the authorizing run kept the standing the original wording earned: an actor could comment while authorized, lose permission, edit the comment before the next poll, and have the replacement delivered as an amendment — presented to the agent as an instruction from someone the route job verified. The window is narrow, and worth stating so the fix is not mistaken for something broader: a comment created before the baseline is filtered out whatever its edit time, so this needs one created after the baseline and edited before the poll that reads it. The binding time is now the later of created_at and updated_at, which required carrying updated_at through forge.IssueComment — GitHub returns it on the same payload and it was simply not decoded. An unedited comment is unaffected: GitHub reports updated_at equal to created_at, and a forge that reports nothing leaves it empty, which falls back to creation. The baseline filter deliberately still keys on created_at. That decides whether a comment is new to this run, which an edit does not change; keying it on the edit would pull an old comment into the delta the moment somebody fixed a typo. Not covered here: reviews carry no edit time on forge.PullRequestReview, so a review body has the same shape of gap. It needs an issue_comment run by the same actor to be an amendment at all, so it is narrower again — flagged rather than fixed. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
renderAmendment caps a body or an instruction at maxAmendmentBytes, but the caller could not see that it had, so only amendments dropped WHOLE were excluded from the receipt. An instruction whose actionable sentence sits past 4096 bytes was therefore delivered without it and still receipted, and the queued run skipped work nobody did — the same class as receipting a steer the agent never consumed. truncate and renderAmendment now report whether they cut anything, and a clipped amendment excludes its runs. It is still delivered and still attributed: the agent acts on the part that arrived, and the queued run covers the part that did not, which is strictly better than dropping it. An existing test asserted the defective behaviour — "clipping inside an amendment keeps it attributed and delivered, where dropping it whole would cost its run's receipt", with assert.Empty on the exclusions. That is why this survived. Its contract is corrected in place with a note saying so. New tests: a clipped instruction is delivered, its tail is provably absent, and its run is not receipted; a whole instruction still is, so the fix does not simply stop receipting. Mutation-checked — dropping the exclusion makes the first fail on exactly that assertion. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
Two ways the watcher discarded work it should have kept. The baseline advanced to the moment delivery FINISHED, not to the snapshot the delta was built from. Delivery takes time, so a comment landing during it was already behind the new baseline: its text was filtered out of the next delta while its own follow-up run could still be accepted and receipted, which is a dropped update rather than a delayed one. The boundary is now captured before the delta is built and used as the new baseline, so the window the delta covered and the window it advances past are the same window. The job checks treated "not yet" as "no". A Route job that has not concluded, or a stage job the API has not listed, produced a rejection, and every rejection marked the run seen — permanently. Polling a moment early therefore discarded a legitimate update, and on a busy item that is the common case rather than the rare one. routeVerdict now reports pending separately from failed, an unlisted stage job is pending too, and only a final verdict marks a run seen. A skipped stage stays final, so a settled question still is not re-fetched every poll. Tests: a pending Route and an unlisted stage job are both re-judged and accepted on the next poll, and a skipped stage stays rejected without re-reading its jobs. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
…d for SteerMessage.Actor is rendered by the envelope as an authorization claim — "activity by X, whose authorization the route job verified ... the same permission check that authorized this run". The watcher set it from the newest accepted run whatever its event. Accepted runs are not restricted to issue_comment; only amendment AUTHORITY is. So a batch carrying a pull_request_target — a push — named that run's actor, while the route arm for that event checks the PR AUTHOR. On a fork PR those are different people and the pusher needs no permission on this repository at all, so the envelope asserted an authorization that was never checked for them. This is the laundering the amendment split already removed from the body, surviving in the header because the header reads a different field: the body derives its claim from the amendment authors and correctly says nothing when there are none, while the envelope derived its claim from the run. A pure-context batch could therefore open by naming an authorized party and then state that nothing below is addressed to the agent. Actor is now set only when the run's actor is the principal the route job checked, which is the amendmentEvents rule. Empty, the envelope falls back to asserting only that the update arrived through an authorized follow-up run — true for every accepted event — and the Source line still carries the run id and the event, so no provenance is lost. TestPollAndSteer_DeliversAndConsumes asserted Actor was the pusher on a pull_request_target, which is the defective behaviour stated as the contract; corrected in place with a note, since that assertion is why the header kept laundering after the body stopped. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
A steered run is killed at steerDeadline(runStartedAt, timeout), a whole-run bound anchored at the top of runAgent because it comes from the forge token's life and kills the run rather than an iteration. The timeout detection compared lastIterElapsed, which is time.Since(agentStart) and anchored per iteration. Two clocks, and the gap between them is the setup: with a 90 minute timeout, a 50 minute budget and 10 minutes of harness resolution, minting and env expansion, the agent is killed having itself run 40 minutes, 40 does not reach nine tenths of 50, and the run reports success with the work unfinished. Later iterations are worse, their anchor being later still. steerAwareBudget returns the elapsed and the budget as a pair, so the two can no longer be given different clocks: steered measures from runStartedAt against steerBudget, unsteered keeps per-iteration elapsed against the harness timeout, which is every run in production. The detection and the terminal error consume the same stored pair, so the reported figure is the one the run was judged on rather than a clock that has moved on since. An earlier commit corrected the threshold and left this anchor wrong, and the reason it shipped is worth recording: its mutation check fed synthetic elapsed values into the helper and asserted the helper's arithmetic, so it could not fail on an anchor mismatch. Under the mutation that catches this one — restoring the per-iteration anchor — that older test still passes. The new test drives the scenario instead: setup consumes part of the budget, the run is killed at the whole-run deadline, and it asserts both the timed-out reading and that the no-loop path returns an error rather than nil. Assisted-by: Claude Signed-off-by: Wayne Sun <gsun@redhat.com>
3345aeb to
84f3b9c
Compare
|
🤖 Review · Commit: |
|
🤖 Finished Review · ✅ Success · Started 4:42 PM UTC · Completed 5:19 PM UTC Commit: Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $19.29 |
There was a problem hiding this comment.
Note: The following review comments could not be posted on the diff (GitHub returned 422) and are included here instead:
.github/workflows/reusable-dispatch.yml(file-level): Line 81 · [low] breaking-api
FULLSEND_PRESERVE_RUNS comparison semantics differ between YAML (case-sensitive) and Go (EqualFold), creating an invisible asymmetry.
Suggested fix: Document that only lowercase 'true' is recognized or align the Go side.
.github/workflows/reusable-dispatch.yml(file-level): Line 26 · [low] api-addition
/fs-steer is a new slash command with stage-inherited authorization floor.
| // The message id is the key the runtime acknowledges and the marker | ||
| // intersects on, so it must name a run that is actually being | ||
| // receipted; an excluded run's id would strand the whole batch. | ||
| newest := accepted[len(accepted)-1] |
There was a problem hiding this comment.
[medium] edge-case
When buildText excludes ALL accepted runs' amendments (all exceeded the text budget), included is empty but a steer is still delivered, consuming one MaxSteers budget unit. The FollowUpRunID is taken from a dropped run whose receipt will not be checked, and no follow-up run is receipted.
Suggested fix: After building included and dropped, check len(included) == 0. If true, mark all accepted runs as seen and return false without delivering.
|
|
||
| **`steer`** — Lets a run already in flight absorb updates to its work item — a push, a comment, a `/fs-steer` — instead of being cancelled and restarted from nothing ([ADR 0101](../ADRs/0101-steer-the-running-agent-on-work-item-updates.md)). Off by default: enabling it means a run holds its sandbox until it settles rather than ending at its first result. | ||
|
|
||
| It takes effect only when three things line up: `enabled: true` here, a runtime that can take a message into a running session (`claude` and `pi` live, `codex` by interrupt-and-resume — see the [runtime support matrix](../runtimes.md#choosing-a-runtime)), and a repository that has set `FULLSEND_PRESERVE_RUNS` to `true`, since a run that is about to be cancelled cannot usefully be steered. Miss any one and the run behaves exactly as it does today; the runner prints why it declined. |
There was a problem hiding this comment.
[medium] design-adherence
The steer block reference documents three prerequisites but omits the fourth prerequisite that ADR 0101's Rollout Order section declares binding: steering may not be enabled until receipts are authenticated. An operator reading only the reference doc could enable steer before the channel exists.
Suggested fix: Add a warning paragraph to the steer block noting the receipt authentication prerequisite and linking to fullsend#7006.
| // fullsend-ai/agents match on it to recognise a runner amendment, and | ||
| // also to flag the same line appearing INSIDE work-item content as an | ||
| // injection attempt. It must stay byte-identical. | ||
| b.WriteString("Runner update: your task inputs changed after this run started.\n\n") |
There was a problem hiding this comment.
[medium] breaking-api
The steer envelope's opening line is a cross-repo wire interface pinned by TestSteerEnvelopeOpeningLineIsStable but fullsend-ai/agents carries no equivalent pin.
Suggested fix: Add a cross-repo stability test in fullsend-ai/agents. Do not change the opening line without a coordinated agents release.
| // genuinely App-authored. A stronger identity check (performed_via_github_app, | ||
| // the App's client id) does not help for the same reason. | ||
| // | ||
| // KNOWN GAP, tracked before this ships: this check is necessary but NOT |
There was a problem hiding this comment.
[low] receipt-authenticity-gap
LatestSteerMarker trusts two public strings as receipt authentication. Documented as KNOWN GAP, tracked in fullsend#7006, gated by ADR 0101's rollout order, feature ships disabled by default.
Suggested fix: Land fullsend#7006 before enabling steer in any repository.
| if len(fields) == 0 || strings.ToLower(fields[0]) != steerCommand { | ||
| return "" | ||
| } | ||
| rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(item.Body), steerCommand)) |
There was a problem hiding this comment.
[low] logic-error
steerInstruction detects /fs-steer case-insensitively but removes the prefix case-sensitively. Uppercase variants retain the raw command name in the instruction text.
Suggested fix: Use case-insensitive prefix removal.
| relates_to: | ||
| - security-threat-model | ||
| - operational-observability | ||
| - flapping-convergence |
There was a problem hiding this comment.
[low] design-adherence
ADR header comment reads 'link it as 0098-... once that PR merges' but no mechanism exists to remind maintainers.
Suggested fix: File a tracking issue or add a TODO comment.
| // runner can deliver SteerMessages while Run executes; Run then returns | ||
| // only after Settle and the agent's current turn. Runtimes that do not | ||
| // implement Steerer ignore it. False keeps today's single-turn Run. | ||
| Steerable bool |
There was a problem hiding this comment.
[low] api-addition
RunParams.Steerable bool is a new exported field. Safe for conforming implementations (zero value = no steering).
| // summary and the steer marker; set by each runtime's Run. | ||
| SessionID string `json:"session_id,omitempty"` | ||
| // Steers records every mid-run update delivered through Steerer. | ||
| Steers []SteerResult `json:"steers,omitempty"` |
There was a problem hiding this comment.
[low] api-addition
RunMetrics.Steers []SteerResult is a new exported field with omitempty JSON tag.
| // | ||
| // Both the validation-feedback prompt and the steer envelope (ADR 0101) go | ||
| // through this one function so the two treatments cannot drift. | ||
| func SanitizeAgentText(text string) (string, int) { |
There was a problem hiding this comment.
[low] api-addition
SanitizeAgentText is a new exported function. Additive, no existing callers broken.
| // and resolved sha. Comparing two runs' sets is how a caller establishes | ||
| // that both came through the same dispatch chain without knowing which | ||
| // version that chain is on (ADR 0101). | ||
| type ReferencedWorkflow struct { |
There was a problem hiding this comment.
[low] api-addition
ReferencedWorkflow is a new exported struct. Six new fields on WorkflowRun. All additive, no interface break.
Scope
This is the steering half of the split, stacked on #7007. That PR stops the cancelling; this one hands the update to the agent already working.
In: the runtime
Steerercapability with its three implementations (Claude Code and pi take a message at the next tool boundary; Codex is interrupted and resumed on the same thread), thesteerwatchpackage that finds and authorises follow-up runs, the receipt that lets the queued run skip work already absorbed, the/fs-steerroute arm,fullsend steer, and ADR 0101.Not in: anything in #7007. Read that one first; it is 187 lines and this PR is meaningless without it.
Also required before any repository enables this: fullsend-ai/agents#1163, since the agent definitions have to recognise a runner update, and one observed real steer per runtime on OpenShell.
Summary
When a PR or issue changes while an agent run is in flight, every stage job cancels the run and a fresh job repeats all the work. This PR lets the run in flight absorb the update instead: the runner watches for the follow-up shim run that the update already produced, verifies its provenance from server-side records, and steers the running agent session; the run queued behind it reads a marker and exits. Ships default-off; nothing changes until a repository opts in.
Related Issue
Refs #6957 (validation criteria need a real steer observed after rollout, so not
Closes).Changes
Steerercontract (internal/runtime/steer.go):Steer+Settleon a live session,SteerMessage,SteerResult,RunParams.Steerable,RunMetrics.SessionID/Steers. Session ids are now captured for all three runtimes.tail -f … | claude -p --input-format stream-json), delivery acked by the--replay-user-messagesecho, feeder killed only when settled, every steer acked, and not mid-turn. N results per session:usageandnum_turnssummed per turn,total_cost_usdtaken from the last result (it is session-cumulative; regression test on the probe figures).codex exec … resume <thread_id> -on the same thread; flags stay beforeresume(-C/-care not global on 0.152.1).--mode rpcwithstreamingBehavior: steer; underSteerablepi now emits one result per prompt instead of holding a single result until EOF.Runner update: your task inputs changed after this run started.is an interface the fleet agent definitions match on (fullsend-ai/agents PR to follow).internal/steerwatch): lists shim runs since run start with the job token, accepts one only when: shim path + event allowlist;referenced_workflowsequal to my own run's by path and ref; theRoutejob concludedsuccess(not the run conclusion, sincequeue: singlecancels superseded pending stage jobs); my stage job notskipped; bound to my work item (pull_requests[]or the shimrun-name); not consumed before. Resolves the work item from the forge, not the environment. Settles when the run budget runs low (MinRemaining, default 5 min) or the cap is reached.<!-- fullsend:steer consumed=… head=… -->on the terminal status comment; a queued run whose id is listed exits before starting the agent.FULLSEND_PRESERVE_RUNS; here it is/fs-steer [stage:] <text>route arm under the existing authorization guard;fullsend steer <url> "<text>"posts that comment with thegh authtoken chain.FULLSEND_RUN_HEAD_SHAandFULLSEND_RUN_STARTED_ATexported frombootstrapEnvfor the agents' end-of-run re-check.steer:block), runtime support matrix row.Why path+ref and not sha for the chain check: path already carries owner/repo and the
@refsuffix, so path+ref names the trusted workflow completely. The sha only added version identity between the two runs, never trust: anyone who could put different code behind the same path+ref needs write access to that ref, in which case my own run is executing the same code. A@mainshim (this repository's) resolves to a new sha whenever main advances, so comparing it would have silently dropped every steer here.Testing
make lintpasses (pre-commit over the full range, exit 0)Verified live, at the pinned versions unless noted: Claude full loop (2.1.259; both flags confirmed present on the 2.1.258 pin), pi full loop (0.84.4), Codex interrupt→resume (0.152.1, same thread, context intact), and the stray-process sweep against a real OpenShell 0.0.116 sandbox (victim tree killed, runner exec channel survived).
--agentstill applies with the prompt on stdin.Not yet verified: an end-to-end steer inside OpenShell from a real workflow run (one Codex with a real model turn, one Claude). That is the gate before any repository enables steering.
Rollout order matters: merge #7007 and set
FULLSEND_PRESERVE_RUNS=trueon the repository, merge fullsend-ai/agents#1163 so the definitions recognise a runner update, then enablesteer:in the fleet harnesses. Setting the variable without the harness block is simply #7007 doing its job, which is where every repository starts: the run in flight posts stale output and the queued run redoes the work with no marker to skip on. ADR 0101 records this.run-nameon the per-repo shim reaches consumers through scaffold sync; until thenissue_commentfollow-ups carry no binding and are skipped (no steer, no regression).Pre-existing failure on macOS, identical on the base branch, which does not touch that package:
TestListTriggeredHarnesses_BaseComposition. The other three previously listed here no longer fail and the claim has been withdrawn:TestDummy*Runtime_{Bootstrap,ClearIterationArtifacts}were fixed upstream by #7002, which the rebase picked up, andTestEnsureProvider_RetryCancelledByContextnow passes.Checklist
!for breaking changes)Review notes
reusable-dispatch.ymldirectly.docs/contributing/runtime-implementation.mdwas consulted. TheRuntimeinterface is unchanged;Steereris a new optional capability interface (documented alongsideDebugLogNamerin the follow-up commits),RunParams.Steerableis additive and false by default, and every non-steerable code path is byte-for-byte today's (pinned byTestBuildRunCommand_NotSteerableUnchanged).mainindependently of this PR (functional-tests: every triage case fails with API Error policy_denied on main since 2026-09-03 #6962).Review round 2 (Codex gpt-5.6-sol review, findings verified in code)
/fs-steerinstruction extracted into its own field) and Work-item context (everything else, explicitly data that cannot amend the task). Issue title/body/label changes are context: the API attributes them to nobody and they are state to reconcile, per ADR 0098. The envelope header states the authority ("activity by @x, whose authorization the route job verified") without claiming authorship of the body. Probed end to end against the same harness, task and agent that previously ignored a steer: the amendment was applied and a planted injection in the context section was refused, the agent citing the runner's framing.f30987c3bcarries a BREAKING CHANGE trailer for the envelope shape ([work-item-context]plus an Amendments section replaces the single[work-item-update]block).created_at(same-second ties broken by run id, which assumes monotonic ids), and the jobs listing paginates.thread.startedarrives, so a steer during the first turn interrupts instead of waiting for the turn to end; the interrupt sweep gets a 10 s TERM grace with the sweep exec timeout scaled to outlast it (a flat 15 s would have fired during the wait and skipped the KILL pass); the between-iteration sweep is byte-for-byte unchanged.since(anupdated_atfilter, so a bandwidth cut; thecreated_atcheck still decides).docs/contributing/adrs.mdhas no such rule.Ordering dependency: fullsend-ai/agents#1163 (agent definitions that recognise the envelope and re-check at end of run) must land before any repository enables steering, in addition to the rollout order above.
Review round 3 (gpt-5.6-sol audit of the published design, findings verified in code)
enqueuegated the interrupt on knowing the thread id but not on a process being alive. Because the codex parser emits its single result at stream end, the runner's turn-end signal for codex is process exit, so every steer after the first turn fired a full stray-process sweep on an idle sandbox — and becauseSteerruns under the runner's sandbox lock, each one also held that lock for the whole TERM grace, blocking the OIDC refresher and the OpenAI re-seeder. Lengthening the grace to 10 s made it worse. The queue now tracks whether a turn is live and interrupts only then; an idle steer is delivered by the resume with no sweep at all, covered by tests at the injectablesweep:seam.--mode rpcemits one per prompt, codex one per process. The turn-end channel is buffered and its non-blocking send coalesces rather than drops work.printfthat feeds it is part of the command string the exec runs assh -c, so the text transits that shell's argv inside the sandbox and OpenShell's command preview. Inside the sandbox the reader is the agent that is about to receive the text, so this is a log disclosure rather than a privilege boundary. ADR 0101 and the runtime comments now say both, and Plumb the exec request's stdin through sandbox.ExecContext so agent prompts leave the shell's argv #6983 tracks plumbing the exec request's stdin field so the flat claim becomes true.BREAKING CHANGE: the per-repo shim now sets
run-name: <owner/repo>#<number>, so Actions runs are retitled on the next scaffold sync; dashboards and saved filters that match on the run name need updating. The harnesssteer:block and/fs-steerare opt-in and change nothing by default; the concurrency change lives in the base PR #7007.Review round 4 (gpt-5.6-sol pass on the split, one reviewer rather than the full squad)
steerEligiblerefuses to steer whenFULLSEND_PRESERVE_RUNSis nottrue, since without it the run this PR steers is the run the concurrency group cancels. The comparison is case-insensitive to agree with GitHub's, and the variable is plumbed into the agent step of all seven stage jobs. The dependency runs one way only: preserving runs is useful on its own, which is why it is the base PR.run-namewas added to this repository's own shim alongside the scaffold change, with a drift test, so the two do not diverge.Rebased onto the base PR at
e56312ef9; the only conflict was the run-start capture, resolved so the exported run fact and the watcher's baseline are computed once and shared.Rebased onto current main, together with the base PR, because
functional-testswas red here for a reason outside this change: main allowlists**/claude.exeon the Vertex egress profile and the branch predated that, so OPA denied the agent binary and every triage eval case failed at zero cost.The rebase surfaced a defect that neither change causes alone. Upstream's thinking-token parser (#6904) accumulates into a counter it never resets, so every
ResultEventcarries the session total forReasoningTokenswhile every sibling field on that event comes from the per-resultusageblock. This PR's steer aggregator sums each field, which double-counts that one: two turns of 100 and 50 reported 250 instead of 150, growing with turn count. Themax()hedge could not catch it either, becauseTokensEventcarries per-message reasoning rather than a cumulative figure, so the over-counted accumulator always won the comparison. That field is now taken rather than summed, exactly asTotalCostUSDalready was, and reasoning is dropped from the token-snapshot path to match what the non-steered handler does. Two tests cover it, and the fix was mutation-checked: restoring the+=reproduces 250.Three conflicts came out of the rebase, all resolved keep-both-sides: the new
models.aliasesfield (#6882) landing whereRunParams.Steerablegoes, the same at thert.Runcall site, and the/fs-steerarm sitting beside the/fs-retroarm that #6738 stripped the/fullsendalias from. Every commit builds and vets individually on the new base.Known gap, not addressed here: the onboarding catalog tests read the deprecated per-org
dispatch.ymlrather than the livereusable-dispatch.yml, so/fs-steeris absent from the catalog users read and any command added only to the live workflow is invisible to that check. Steering is gated off and the command is documented in the CLI reference and ADR 0101.Recovered review findings, and the defect they contained
The
dispatch / Reviewjob went red withvalidation failed after 2 iteration(s). That is the agent exiting −1 on a diff this size rather than a finding: it had already produced 41 sanitized findings, which the skipped post-script never posted. Re-triggering a review on a 10k-line diff reliably fails the same way, so the findings were recovered from the run transcript instead — 62 unique, 2 high, 10 medium.One was a real defect, now fixed. The steer envelope's opening sentinel was written twice into the same message:
renderSteerEnvelopewrites it, then appends the watcher'sbuildTextoutput, which opened with the identical line. Every steered run on all three runtimes saw it twice. This is worse than cosmetic, because that line is a cross-repo interface — the agent definitions match on it to recognise a runner amendment and flag the same line appearing inside work-item content as an injection attempt. The duplicate sat inside the body the envelope wraps, so the runner was emitting its own injection signal in the position reserved for untrusted content. The envelope now owns the line andbuildTextdrops it.Neither side caught it because the agent definitions match on the line being first, so recognition kept working and only the injection signal degraded, silently. The existing tests used presence assertions, which cannot fail on a duplicate; the replacements assert the composed message carries it exactly once, including when work-item content itself contains the sentinel. The guard was mutation-checked: duplicating the envelope's write fails both.
The two highs are the known receipt-forgery gap (#7006), reported from two angles. They are the documented limitation the ADR already records, not new information, and the reviewer's proposed remediation — a runtime gate blocking steering until receipts are authenticated — is deliberately not taken; #7006 is optional hardening rather than a precondition.
One medium is dismissed on inspection. The claim that the amendment time-binding could be gamed under clock skew reads
authorization.covers()in isolation. It is a secondary check on top of an author-identity match: a comment becomes an amendment only if its author is the actor of an authorized run, so the same-second edge can only re-credit a comment by someone already authorized. No escalation.Rebased again onto current main after #6756 (pi Agent tool) landed, since it folds sub-agent usage into the same
RunMetricsthe steer aggregator writes. That seam was checked rather than assumed: the fold is pi-only and runs once after the process exits, and pi's result handler assigns rather than sums because its counters are already cumulative — the same rule this PR now applies to reasoning. The two cannot double-count each other.A third defect, found by rebasing onto #7042
#7007had goneDIRTY, so both halves were rebased onto current main. The collision was #7042, which exports its own runner-owned values into the sandbox and reasons about ordering the same way the base half does. Two of its three conflicts were unions; the third was that #7042's iteration-env line and this stack's run-facts export both claimed to be last, for the same stated reason. They export disjoint variables, so neither shadows the other, but only #7042's sources a file rewritten before every iteration — its "must be last" is load-bearing, ours only ever needed to be after.env.d. Order is now.env.d→env.sandbox→ run facts → iteration env source, with each comment stating which invariant it holds.Probing #7042's new terminal-error precedence then surfaced a defect in this stack, latent until now:
steerDeadlineatmin(timeout, 50m)— the forge token's life less a margin — but the "did it run out of clock" test measured elapsed against the raw harnesstimeoutat 90%. The condition is thereforemin(timeout, 50m) >= 0.9 × timeout, which holds up to ~55.5 minutes and fails above it.validation failed after N iteration(s)with a validation loop and — the outcome that matters —nil, success, without one. A run cut off at 50 minutes mid-work looked clean, and the post-script ran.steerDeadlinecapping below the harness timeout. run: a timed-out validation-loop iteration must not retry from scratch; export the budget and send a wrap-up signal #7042 only made it visible by giving the timeout branch precedence it did not previously have. Its precedence change is an improvement for this stack, since a steered run's elapsed time legitimately spans several turns and "validation failed" was the wrong story for a run that ran out of clock.steerBudgetis now the single source:steerDeadlineturns it into the instant the run stops at, andsteerAwareTimeoutreturns the same figure for the detection and for the reported message, so the bound the run is held to and the figure it is judged against cannot drift. The reported message now cites the effective budget rather than a limit the run never reached. The unsteered path is unchanged by construction —steerAwareTimeoutreturns its argument untouched when steering is off, which is every run today — and a separate test pins that.Bounded before the fix by steering being off everywhere and by no shipped harness setting a timeout above 55 minutes (the documented examples are 45 and below; review's is 20). It was fixed rather than filed because thresholds are configuration, and a run that reports success after being truncated is the same class as the two defects already fixed here: a real condition no assertion could see. Guard mutation-checked — restoring the raw timeout reproduces the failure verbatim, including
An error is expected but got nil.