Skip to content

feat(dispatch)!: steer the agent run already in flight on work-item updates - #6959

Open
waynesun09 wants to merge 57 commits into
preserve-runsfrom
steer-followup-runs
Open

feat(dispatch)!: steer the agent run already in flight on work-item updates#6959
waynesun09 wants to merge 57 commits into
preserve-runsfrom
steer-followup-runs

Conversation

@waynesun09

@waynesun09 waynesun09 commented Sep 3, 2026

Copy link
Copy Markdown
Member

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 Steerer capability 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), the steerwatch package that finds and authorises follow-up runs, the receipt that lets the queued run skip work already absorbed, the /fs-steer route 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

  • Runtime Steerer contract (internal/runtime/steer.go): Steer + Settle on a live session, SteerMessage, SteerResult, RunParams.Steerable, RunMetrics.SessionID/Steers. Session ids are now captured for all three runtimes.
  • Claude Code: live steer through an in-sandbox mailbox feeder (tail -f … | claude -p --input-format stream-json), delivery acked by the --replay-user-messages echo, feeder killed only when settled, every steer acked, and not mid-turn. N results per session: usage and num_turns summed per turn, total_cost_usd taken from the last result (it is session-cumulative; regression test on the probe figures).
  • Codex: interrupt (stray-process sweep) then codex exec … resume <thread_id> - on the same thread; flags stay before resume (-C/-c are not global on 0.152.1).
  • pi: --mode rpc with streamingBehavior: steer; under Steerable pi now emits one result per prompt instead of holding a single result until EOF.
  • Envelope: authority is stated where it is, in the actor the follow-up run's route job authorized; framing the update as untrusted content, forbidding scope changes, or laundering its origin all made real agents refuse it (regression tests). The opening line 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).
  • Follow-up run watcher (internal/steerwatch): lists shim runs since run start with the job token, accepts one only when: shim path + event allowlist; referenced_workflows equal to my own run's by path and ref; the Route job concluded success (not the run conclusion, since queue: single cancels superseded pending stage jobs); my stage job not skipped; bound to my work item (pull_requests[] or the shim run-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.
  • Marker + skip check: <!-- fullsend:steer consumed=… head=… --> on the terminal status comment; a queued run whose id is listed exits before starting the agent.
  • Dispatch: the concurrency change moved to the base PR feat(dispatch): let a repository preserve the agent run already in flight #7007, which owns 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 the gh auth token chain.
  • Env: FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT exported from bootstrapEnv for the agents' end-of-run re-check.
  • Docs: ADR 0101, harness reference (steer: block), runtime support matrix row.

Why path+ref and not sha for the chain check: path already carries owner/repo and the @ref suffix, 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 @main shim (this repository's) resolves to a new sha whenever main advances, so comparing it would have silently dropped every steer here.

Testing

  • make lint passes (pre-commit over the full range, exit 0)
  • Tests added/updated for new or modified logic (patch coverage 89.8% on the watcher side, steer files ~84% mean on the runtime side, settle state machine 100%)

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). --agent still 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=true on the repository, merge fullsend-ai/agents#1163 so the definitions recognise a runner update, then enable steer: 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-name on the per-repo shim reaches consumers through scaffold sync; until then issue_comment follow-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, and TestEnsureProvider_RetryCancelledByContext now passes.

Checklist

  • PR title follows Conventional Commits (correct type, ! for breaking changes)
  • Commits are signed off (DCO) — human and human-directed agent sessions only
  • I wrote this contribution myself and can explain all changes in it

Review notes

  • Deprecated per-org mode (ADR 0044): this change touches no per-org behaviour. ADR 0101 and the watcher comments mention the per-org dispatch path only to state that it is deprecated and out of scope; the design covers per-repo installs and this repository's own shim, both of which call reusable-dispatch.yml directly.
  • Runtime implementation guide: docs/contributing/runtime-implementation.md was consulted. The Runtime interface is unchanged; Steerer is a new optional capability interface (documented alongside DebugLogNamer in the follow-up commits), RunParams.Steerable is additive and false by default, and every non-steerable code path is byte-for-byte today's (pinned by TestBuildRunCommand_NotSteerableUnchanged).
  • Relation to ADR 0098 (docs(adr): serialize agent runs and coalesce subsequent events #6909) and docs(adr): adopt entity-first harness evaluation #6956: ADR 0098 is open and was written in parallel with this work, against the same feedback rather than against each other. This PR is not an implementation of it and does not claim to be; where they overlap they should be reconciled before either lands. What this PR does: the run in flight is no longer cancelled, the event queued behind it tells that run what changed, the running agent handles it in the session that already has the context, and the queued run then exits instead of repeating the work. Without steering the queued run simply does the work itself with current state, which is the behaviour ADR 0098 also proposes.
  • functional-tests is red on main independently 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)

  • Attribution (high): the delta used to fold every non-bot comment since the baseline into one block that the envelope attributed to the accepted run's actor. Now each item carries its own author, and the text has two sections: Amendments (items by the actors whose follow-up runs the route job authorized, including a /fs-steer instruction 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. f30987c3b carries a BREAKING CHANGE trailer for the envelope shape ([work-item-context] plus an Amendments section replaces the single [work-item-update] block).
  • Receipts (high): a follow-up run is receipted as consumed only when its amendment was actually included (truncation now applies to the context block only; the message id names an included run), only on a successful run (no marker on failure, cancel, or failed validation), and receipts accumulate across validation iterations. Freshness is keyed on the run's server-side created_at (same-second ties broken by run id, which assumes monotonic ids), and the jobs listing paginates.
  • Codex (high): the thread id is published the moment thread.started arrives, 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.
  • Comment listing uses the API's since (an updated_at filter, so a bandwidth cut; the created_at check still decides).
  • Dropped as unfounded: a claim that ADRs must stay under 80 lines; docs/contributing/adrs.md has 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)

  • Codex interrupted when no turn was running (regression, fixed). enqueue gated 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 because Steer runs 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 injectable sweep: seam.
  • Turn observation differs per runtime, which the design text now states: Claude emits one result per turn, pi under --mode rpc emits one per prompt, codex one per process. The turn-end channel is buffered and its non-blocking send coalesces rather than drops work.
  • "Prompt on stdin, never argv" was imprecise. It is true of the agent CLI's own command line, which is the property worth having, but the printf that feeds it is part of the command string the exec runs as sh -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 harness steer: block and /fs-steer are 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)

  • A mailbox line could receipt a steer that was never delivered (high, in this PR's own code). The feeder matched echoes by counting positions, so a line written into the mailbox by anything other than the watcher shifted the count and let an unconsumed steer be marked consumed — and the settle condition then waited on an echo that would never arrive. Echoes are now matched by the steer's own identity, so an extra line cannot displace one.
  • Steering now requires the run to be preserved, in code. steerEligible refuses to steer when FULLSEND_PRESERVE_RUNS is not true, 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-name was added to this repository's own shim alongside the scaffold change, with a drift test, so the two do not diverge.
  • The sticky marker is stripped before neutralizing at both call sites, not just one.

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-tests was red here for a reason outside this change: main allowlists **/claude.exe on 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 ResultEvent carries the session total for ReasoningTokens while every sibling field on that event comes from the per-result usage block. 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. The max() hedge could not catch it either, because TokensEvent carries 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 as TotalCostUSD already 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.aliases field (#6882) landing where RunParams.Steerable goes, the same at the rt.Run call site, and the /fs-steer arm sitting beside the /fs-retro arm that #6738 stripped the /fullsend alias 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.yml rather than the live reusable-dispatch.yml, so /fs-steer is 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 / Review job went red with validation 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: renderSteerEnvelope writes it, then appends the watcher's buildText output, 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 and buildText drops 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 RunMetrics the 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

#7007 had gone DIRTY, 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.denv.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:

steerBudget is now the single source: steerDeadline turns it into the instant the run stops at, and steerAwareTimeout returns 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 — steerAwareTimeout returns 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.

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 3, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 12:56 PM UTC · Completed 1:33 PM UTC

Commit: 4d175a6 · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $21.98

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Steer in-flight agent runs on work-item updates

✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Absorbs authorized work-item updates into running agent sessions instead of restarting.
• Verifies follow-up workflow provenance and skips queued work already consumed.
• Adds default-off steering for Claude, Codex, and pi runtimes.
Diagram

sequenceDiagram
    actor User as Maintainer
    participant Shim as Dispatch Shim
    participant Actions as GitHub Actions
    participant Runner as Agent Runner
    participant Watcher as Steer Watcher
    participant Forge as Forge API
    participant Runtime as Agent Runtime

    User->>Shim: Update work item
    Shim->>Actions: Authorize and route
    Actions-->>Runner: Queue follow-up run
    Runner->>Watcher: Start with job token
    loop Until settled
        Watcher->>Actions: Poll follow-up runs
        Actions-->>Watcher: Runs and jobs
        Watcher->>Watcher: Verify provenance
        Watcher->>Forge: Read current item
        Forge-->>Watcher: Work-item delta
        Watcher->>Runtime: Steer live session
        Runtime-->>Watcher: Delivery acknowledgement
    end
    Watcher->>Runtime: Settle session
    Runner->>Forge: Publish consumed marker
    Actions->>Runner: Start queued run
    Runner->>Forge: Read consumed marker
    Runner-->>Actions: Exit when handled
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Cancel and restart every run
  • ➕ Retains the existing simple concurrency model
  • ➕ Always starts from a fresh checkout and session
  • ➖ Repeats expensive context gathering and model work
  • ➖ Discards useful in-flight progress
  • ➖ Can repeatedly restart during bursty updates
2. External push relay
  • ➕ Could deliver updates immediately without polling
  • ➕ Could support multiple forge providers through one transport
  • ➖ Introduces a new public service and authentication boundary
  • ➖ Requires durable delivery, replay protection, and operational ownership
  • ➖ Duplicates authorization already recorded by workflow runs
3. Runner-managed checkout refresh
  • ➕ Read-only agents would immediately see the latest repository state
  • ➕ Reduces reliance on agents following fetch instructions
  • ➖ Can overwrite uncommitted work in mutation stages
  • ➖ Requires stage-specific workspace policy and conflict handling
  • ➖ Does not address comments, labels, reviews, or task amendments alone

Recommendation: The PR's follow-up-run watcher is the best fit for GitHub Actions because it reuses existing dispatch authorization and requires only outbound runner access. Keep the implementation default-off and roll it out runtime-by-runtime; cancellation remains the safest fallback where steering or provenance checks are unavailable.

Files changed (46) +7537 / -100

Enhancement (22) +3539 / -79
root.goRegister the steer subcommand +1/-0

Register the steer subcommand

• Adds the new 'fullsend steer' command to the root CLI.

internal/cli/root.go

run.goIntegrate steering into agent execution +104/-31

Integrate steering into agent execution

• Starts the follow-up watcher, marks runtime runs steerable, exports run baseline facts, and skips follow-up runs already consumed. It also attaches consumed markers to completion comments and centralizes prompt-text sanitization.

internal/cli/run.go

steer.goCoordinate watcher lifecycle and skip checks +404/-0

Coordinate watcher lifecycle and skip checks

• Adds steering eligibility, deadlines, sandbox-locked delivery and settlement, turn-end signaling, run baseline exports, and App-authored marker checks.

internal/cli/steer.go

steercmd.goAdd the fullsend steer command +186/-0

Add the fullsend steer command

• Parses GitHub work-item URLs and posts authorized '/fs-steer' comments with optional review, fix, or triage targeting. GitLab is recognized but explicitly unsupported.

internal/cli/steercmd.go

claude.goKeep Claude sessions open for live steering +106/-7

Keep Claude sessions open for live steering

• Launches steerable Claude sessions through a mailbox feeder, captures session IDs and acknowledgements, and aggregates metrics across multiple results. Non-steerable command behavior remains unchanged.

internal/runtime/claude.go

claude_progress.goParse Claude session and replay events +24/-2

Parse Claude session and replay events

• Surfaces Claude session IDs and replayed user-message acknowledgements. Resets result tracking when a steered turn begins so partial metrics remain recoverable.

internal/runtime/claude_progress.go

claude_steer.goImplement Claude mailbox steering +175/-0

Implement Claude mailbox steering

• Encodes stream-JSON user messages, appends steers to the live mailbox, settles the feeder safely, and aggregates per-turn usage with cumulative cost semantics.

internal/runtime/claude_steer.go

codex_run.goRun steerable Codex resume loops +199/-28

Run steerable Codex resume loops

• Refactors Codex execution into per-process turns and resumes the same thread after an interrupt. Preserves artifacts and aggregates metrics across resumed processes.

internal/runtime/codex_run.go

codex_steer.goImplement Codex interrupt-and-resume steering +291/-0

Implement Codex interrupt-and-resume steering

• Queues steers, interrupts active Codex processes through the stray-process sweep, resumes the captured thread, and records delivered updates. Settlement leaves the current turn running.

internal/runtime/codex_steer.go

event.goAdd session and delivery events +22/-2

Add session and delivery events

• Extends initialization events with session IDs and introduces replay events representing confirmed user-message delivery.

internal/runtime/event.go

pi_progress.goEmit per-prompt results in pi RPC mode +57/-0

Emit per-prompt results in pi RPC mode

• Adds steering-aware parsing that emits results after each settled prompt and recognizes successful RPC prompt acknowledgements without duplicating results at EOF.

internal/runtime/pi_progress.go

pi_run.goRun steerable pi sessions in RPC mode +114/-9

Run steerable pi sessions in RPC mode

• Launches pi with a runner-generated session ID and mailbox-fed RPC input. Captures acknowledgements, session metrics, and one cumulative result per prompt.

internal/runtime/pi_run.go

pi_steer.goImplement live pi RPC steering +93/-0

Implement live pi RPC steering

• Encodes mailbox prompts with 'streamingBehavior: steer', appends them to active sessions, and safely closes settled feeders.

internal/runtime/pi_steer.go

runtime.goExpose steerable runs and session metrics +12/-0

Expose steerable runs and session metrics

• Adds 'RunParams.Steerable' plus runtime session IDs and delivered steer records to run metrics.

internal/runtime/runtime.go

steer.goDefine the runtime steering contract +81/-0

Define the runtime steering contract

• Introduces 'Steerer', steer messages, results, unsupported errors, and caller locking requirements shared by runtime adapters.

internal/runtime/steer.go

steer_session.goManage live runtime steer feeds +370/-0

Manage live runtime steer feeds

• Implements the shared mailbox feeder registry and acknowledgement-based settlement state machine. It also renders the provenance-aware runner update envelope.

internal/runtime/steer_session.go

statuscomment.goAttach steering markers to completion comments +18/-0

Attach steering markers to completion comments

• Stores consumed steering metadata in terminal status comments while removing the marker from visible status text.

internal/statuscomment/statuscomment.go

steermarker.goEncode and locate consumed-run markers +125/-0

Encode and locate consumed-run markers

• Builds, parses, validates, and retrieves App-authored markers containing consumed follow-up run IDs and settled head SHAs.

internal/statuscomment/steermarker.go

actions.goAdd the follow-up Actions API client +172/-0

Add the follow-up Actions API client

• Reads workflow runs and jobs with bounded requests, authenticated by the captured job token, and returns candidates in creation order.

internal/steerwatch/actions.go

delta.goBuild sanitized work-item update deltas +255/-0

Build sanitized work-item update deltas

• Detects PR heads, comments, reviews, and issue metadata changes while excluding bot activity. It sanitizes and bounds the agent-facing update text.

internal/steerwatch/delta.go

provenance.goValidate follow-up run provenance +169/-0

Validate follow-up run provenance

• Requires the expected shim, allowed event, matching reusable-workflow chain, successful Route job, selected stage, freshness, and work-item binding.

internal/steerwatch/provenance.go

watcher.goImplement the follow-up run watcher +561/-0

Implement the follow-up run watcher

• Polls and verifies follow-up runs, folds simultaneous updates, computes deltas, enforces time and cost ceilings, delivers steers, and always settles sessions. Tracks seen and consumed runs separately for safe skipping.

internal/steerwatch/watcher.go

Refactor (1) +38 / -0
unicode.goShare agent prompt sanitization +38/-0

Share agent prompt sanitization

• Extracts Unicode sanitization for validation feedback and steering text while preserving benign compatibility characters.

internal/security/unicode.go

Tests (15) +3513 / -11
run_test.goAdapt bootstrap tests for run facts +4/-4

Adapt bootstrap tests for run facts

• Updates existing bootstrap environment tests for the new run-baseline argument.

internal/cli/run_test.go

steer_test.goTest CLI steering orchestration +494/-0

Test CLI steering orchestration

• Covers eligibility, deadlines, watcher startup and settlement, run facts, stage ambiguity, and fail-open consumed-marker checks.

internal/cli/steer_test.go

steercmd_test.goTest steer command parsing and posting +204/-0

Test steer command parsing and posting

• Covers work-item URL forms, stage validation, generated comment bodies, authentication failures, and comment-posting errors.

internal/cli/steercmd_test.go

harness_test.goTest steering configuration defaults +76/-0

Test steering configuration defaults

• Verifies YAML loading, opt-in behavior, defaults, explicit values, and validation bounds.

internal/harness/harness_test.go

claude_progress_test.goTest Claude steering stream events +94/-0

Test Claude steering stream events

• Covers session headers, replay acknowledgements, tool-result discrimination, and token salvage after earlier results.

internal/runtime/claude_progress_test.go

claude_steer_test.goTest Claude steering lifecycle and metrics +499/-0

Test Claude steering lifecycle and metrics

• Exercises command construction, envelope semantics, mailbox races, delivery acknowledgements, feeder shutdown, and multi-turn metric aggregation.

internal/runtime/claude_steer_test.go

codex_steer_test.goTest Codex steering queues and resumes +327/-0

Test Codex steering queues and resumes

• Covers early-steer handling, FIFO delivery, failed interrupts, settlement, resume command ordering, and cross-process metric aggregation.

internal/runtime/codex_steer_test.go

pi_steer_test.goTest pi RPC steering behavior +281/-0

Test pi RPC steering behavior

• Covers hardened command construction, prompt encoding, acknowledgements, per-prompt results, session IDs, and settlement.

internal/runtime/pi_steer_test.go

workflow_call_alignment_test.goValidate steering workflow contracts +82/-7

Validate steering workflow contracts

• Updates concurrency parsing for expression values and verifies gated cancellation, '/fs-steer' authorization, instruction stripping, and shim run names.

internal/scaffold/workflow_call_alignment_test.go

unicode_test.goTest shared prompt sanitization +30/-0

Test shared prompt sanitization

• Verifies clean text preservation, compatibility-character handling, and removal of non-rendering characters.

internal/security/unicode_test.go

steermarker_test.goTest steering marker safety and selection +193/-0

Test steering marker safety and selection

• Covers canonical rendering, malformed data, forged authors, latest-marker selection, completion integration, and visible-body filtering.

internal/statuscomment/steermarker_test.go

delta_test.goTest work-item delta construction +255/-0

Test work-item delta construction

• Covers PR and issue baselines, bot filtering, label changes, head movement, Unicode sanitization, truncation, and forge failures.

internal/steerwatch/delta_test.go

provenance_test.goTest provenance rejection boundaries +270/-0

Test provenance rejection boundaries

• Exercises workflow-chain equality, route and stage selection, work-item binding, replay rejection, foreign workflows, and ambiguous stage resolution.

internal/steerwatch/provenance_test.go

steerwatch_test.goProvide steer watcher integration fixtures +297/-0

Provide steer watcher integration fixtures

• Adds fake Actions endpoints, forge readers, delivery recorders, and shared fixtures for watcher and provenance tests.

internal/steerwatch/steerwatch_test.go

watcher_test.goTest watcher delivery and settlement +407/-0

Test watcher delivery and settlement

• Covers successful and failed delivery, candidate deduplication, steer caps, ticker and turn-end polling, deadlines, cancellation, API failures, and settlement guarantees.

internal/steerwatch/watcher_test.go

Documentation (5) +310 / -0
0101-steer-the-running-agent-on-work-item-updates.mdDocument the in-flight steering architecture +295/-0

Document the in-flight steering architecture

• Records the steering decision, provenance checks, runtime contract, limits, skip-marker protocol, security implications, and default-off rollout plan.

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md

README.mdList the steer CLI command +1/-0

List the steer CLI command

• Adds 'fullsend steer' to the CLI command reference with a link to ADR 0101.

docs/cli/README.md

harness-fields.mdClassify steering as an overlay field +1/-0

Classify steering as an overlay field

• Documents 'steer' as an operational harness field eligible for overlay configuration.

docs/contributing/harness-fields.md

harness-reference.mdDocument steering harness settings +12/-0

Document steering harness settings

• Describes the default-off 'steer' block, runtime and repository prerequisites, steer cap, and polling interval.

docs/reference/harness-reference.md

runtimes.mdAdd runtime steering support matrix +1/-0

Add runtime steering support matrix

• Documents live steering for Claude and pi and interrupt-and-resume steering for Codex.

docs/runtimes.md

Other (3) +137 / -10
reusable-dispatch.ymlRoute steering commands and gate cancellation +64/-10

Route steering commands and gate cancellation

• Adds authorized '/fs-steer' routing with stage-specific permission floors. Stage concurrency stops cancelling in-flight runs only when 'FULLSEND_STEER=true', and fix instructions strip steering prefixes.

.github/workflows/reusable-dispatch.yml

harness.goAdd steering harness configuration +65/-0

Add steering harness configuration

• Introduces default-off steering configuration, default limits, poll interval resolution, and validation for invalid values.

internal/harness/harness.go

shim-per-repo.yamlBind shim runs to work items +8/-0

Bind shim runs to work items

• Adds a server-visible run name containing the repository and work-item number for provenance matching.

internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml

@qodo-code-review

qodo-code-review Bot commented Sep 3, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📜 Skill insights (1)

Grey Divider


Action required

1. Accepted ADR lacks architecture update ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
ADR 0101 is introduced with status: Accepted, but this PR contains no corresponding
docs/architecture.md change linking the decision under the relevant component. An accepted
architectural decision must update the living architecture overview in the same PR.
Code

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[R2-3]

+title: "101. Steer the running agent on work-item updates instead of cancelling the run"
+status: Accepted
Relevance

●●● Strong

Recent ADR precedent accepts findings requiring related architecture or problem-document updates.

PR-#6926
PR-#5016

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new ADR is explicitly Accepted at lines 2–3 and 18–20. PR Compliance IDs 1062062 and 1062100
require a same-PR architecture overview update, but docs/architecture.md is absent from the change
set.

Rule 1062062: Update architecture overview and problem docs when ADR is accepted
docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[1-20]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR adds accepted ADR 0101 without updating `docs/architecture.md`.

## Issue Context
Add a surgical `Decided:` entry or short paragraph under the dispatch/runtime component, linking to ADR 0101. Also annotate any architecture open question resolved by this decision.

## Fix Focus Areas
- docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[1-20]
- docs/architecture.md[1-1]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Runs consumed before delivery ✓ Resolved 🐞 Bug ☼ Reliability
Description
pollAndSteer marks a follow-up run consumed as soon as Steer returns, but Claude and pi only
append to a mailbox and Codex only queues the message; their actual delivery acknowledgements occur
later. If the runtime fails or times out before the echo or resume starts, the terminal marker still
causes the queued follow-up run to skip, silently losing the update.
Code

internal/steerwatch/watcher.go[474]

+	w.markSteered(accepted)
Relevance

●●● Strong

Accepted reliability precedents address state advancement after partial operations and preventing
lost or duplicated effects.

PR-#6711
PR-#6861

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The watcher calls markSteered immediately after Steer returns. Claude's implementation returns
after appendLine, while live delivery is only recorded by noteEcho; Codex returns after
enqueueing and records delivery later in nextCodexTurn, yet the CLI builds the skip marker from
the watcher's consumed IDs.

internal/steerwatch/watcher.go[454-479]
internal/runtime/claude_steer.go[62-71]
internal/runtime/steer_session.go[201-226]
internal/runtime/codex_steer.go[198-207]
internal/runtime/codex_run.go[584-592]
internal/cli/run.go[2214-2218]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Do not mark a follow-up workflow run consumed until the runtime confirms that its message reached the agent. Add an acknowledgement path for live mailbox echoes and Codex resume startup, and leave unacknowledged runs for the queued follow-up job.

## Issue Context
The watcher currently treats a successful queue or mailbox write as delivery. Runtime `SteerResult` records already distinguish the later point at which a live message is echoed or a Codex resume begins, but the marker is built directly from the watcher's earlier consumed set.

## Fix Focus Areas
- internal/steerwatch/watcher.go[454-479]
- internal/runtime/claude_steer.go[62-71]
- internal/runtime/steer_session.go[201-226]
- internal/runtime/codex_steer.go[198-207]
- internal/runtime/codex_run.go[584-592]
- internal/cli/run.go[2214-2218]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Issue snapshot never advances ✓ Resolved 🐞 Bug ≡ Correctness
Description
After a successful issue steer, buildDelta continues comparing title, body, and labels with the
run-start snapshot instead of the last delivered state. Later updates therefore repeat old changes
or omit a revert to the original value; if another visible change makes that steer non-empty, the
follow-up run is consumed and skipped despite the omitted metadata update.
Code

internal/steerwatch/delta.go[R124-127]

+	if issue.Title != w.cfg.Item.Title {
+		d.lines = append(d.lines, fmt.Sprintf("Title is now: %s", issue.Title))
+	}
+	if issue.Body != w.cfg.Item.Body {
Relevance

●●● Strong

Accepted bug precedents favor correcting stale cumulative snapshots and preserving accurate later
deltas.

PR-#6924

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The issue delta compares current metadata with w.cfg.Item, while successful delivery advances only
w.baseline; only PR head state receives a corresponding update. Thus the comparison baseline
remains the initial issue state across every steer.

internal/steerwatch/delta.go[120-140]
internal/steerwatch/watcher.go[430-479]
internal/steerwatch/watcher.go[495-512]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Issue title, body, and label deltas are always calculated against the run-start snapshot. Persist the latest successfully delivered issue state so subsequent steers describe only new changes, including reversions to an earlier value.

## Issue Context
`markSteered` advances the timestamp baseline and PR head only; it does not advance the issue metadata snapshot used by `buildDelta`. Update the snapshot only after successful delivery so failed steers remain retryable.

## Fix Focus Areas
- internal/steerwatch/delta.go[120-140]
- internal/steerwatch/watcher.go[430-479]
- internal/steerwatch/watcher.go[495-512]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Consequences lack required bullets ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The ADR's Consequences section consists of eight multi-sentence prose blocks rather than 3–5
one-sentence bullet points. This violates the required ADR consequence format.
Code

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[R241-244]

+## Consequences
+
+**What each stage's contract becomes.** Review produces one review per *settled* head rather than
+per dispatched head; the steered turn re-diffs A..B in-session, which is the incremental review
Relevance

●●● Strong

ADR formatting and concise consequence structure are regularly enforced in accepted review feedback.

PR-#2743
PR-#6083

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1062091 requires 3–5 one-sentence bullets, but the new section uses long prose
paragraphs beginning at line 243 and continuing through the end of the ADR.

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[241-295]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ADR 0101's Consequences section does not use the required 3–5 one-sentence bullets.

## Issue Context
Summarize only the most important positive and negative consequences; move detailed operational analysis to a linked document.

## Fix Focus Areas
- docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[241-295]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. ADR 0041 lacks link ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The Context states that ADR 0101 follows ADR 0041 but mentions it as plain text rather than linking
to the existing ADR file. Related ADRs must be explicitly cross-referenced with a link in Context.
Code

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[R33-36]

+[#6573](https://github.com/fullsend-ai/fullsend/issues/6573)), and as an agent missing an update
+that arrived while it worked ([#1207](https://github.com/fullsend-ai/fullsend/issues/1207)).
+[#1637](https://github.com/fullsend-ai/fullsend/issues/1637) asked for the concurrency and cancel
+semantics to be written down after ADR 0041; this ADR is that content.
Relevance

●●● Strong

Recent ADR reviews explicitly require linked cross-references in Context.

PR-#5798
PR-#6926
PR-#1549

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Line 36 explicitly says this decision follows ADR 0041, establishing a relationship, while PR
Compliance ID 1062094 requires an explicit cross-reference link in Context.

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[33-36]
docs/ADRs/0041-synchronous-workflow-call-event-dispatch.md[1-20]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ADR 0101 references related ADR 0041 in Context without a Markdown link.

## Issue Context
Link the reference to the existing `0041-synchronous-workflow-call-event-dispatch.md` ADR.

## Fix Focus Areas
- docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[33-36]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. steerCommentPoster bypasses forge.Client ✓ Resolved 📘 Rule violation ⌂ Architecture
Description
The CLI introduces a single-use local interface whose only production implementation is constructed
directly with gh.New(token). The comment operation should use an injected forge.Client rather
than a parallel abstraction created solely for this command.
Code

internal/cli/steercmd.go[R109-112]

+// steerCommentPoster is the write the command needs. github.LiveClient
+// satisfies it.
+type steerCommentPoster interface {
+	CreateIssueComment(ctx context.Context, owner, repo string, number int, body string) (*forge.IssueComment, error)
Relevance

●●● Strong

Historical architecture reviews accept findings that isolate runtime-specific abstractions and
preserve shared contracts.

PR-#1780
PR-#5643

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The added interface has one production implementation, gh.New(token), and one production call
path. This violates both the shared forge abstraction requirement and the prohibition on
abstractions without multiple production usages or implementors.

Rule 1062052: Route all git forge operations through forge.Client
Rule 1062065: Avoid single-use abstractions; require reuse across multiple call paths
internal/cli/steercmd.go[109-116]
internal/cli/steercmd.go[166-170]
internal/forge/forge.go[435-437]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`steerCommentPoster` is a single-use abstraction that bypasses the repository's shared `forge.Client` contract.

## Issue Context
The only production factory returns `github.LiveClient`; alternative implementations exist only in tests. Inject a `forge.Client` through the CLI composition path and use its comment operation directly.

## Fix Focus Areas
- internal/cli/steercmd.go[109-116]
- internal/cli/steercmd.go[166-170]
- internal/forge/forge.go[435-437]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (5)
7. Actions API bypasses forge layer ✓ Resolved 📘 Rule violation ⌂ Architecture
Description
internal/steerwatch adds a raw HTTP client with a hardcoded GitHub API root and GitHub-specific
headers outside internal/forge/github. This bypasses forge.Client and violates the required
location for direct GitHub API calls.
Code

internal/steerwatch/actions.go[R26-28]

+// defaultAPIBase is the GitHub REST root. Tests point this at an httptest
+// server instead.
+const defaultAPIBase = "https://api.github.com"
Relevance

●● Moderate

The architecture rule is explicit, but no close historical finding exists for this new package and
API boundary.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new package hardcodes https://api.github.com, constructs requests with GitHub-specific
headers, and calls http.Client.Do directly. Rules 1062052 and 1062054 require these operations to
flow through forge.Client and reside under internal/forge/github.

Rule 1062052: Route all git forge operations through forge.Client
Rule 1062054: Restrict direct GitHub API calls to internal/forge/github
internal/steerwatch/actions.go[26-28]
internal/steerwatch/actions.go[103-129]
internal/forge/forge.go[435-437]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The steering watcher performs direct GitHub Actions REST requests outside the approved forge adapter.

## Issue Context
Add the required workflow-run operations to the forge abstraction and implement GitHub-specific HTTP behavior under `internal/forge/github`. Inject that abstraction into the watcher rather than constructing an HTTP client there.

## Fix Focus Areas
- internal/steerwatch/actions.go[26-28]
- internal/steerwatch/actions.go[81-117]
- internal/steerwatch/watcher.go[128-154]
- internal/forge/forge.go[435-437]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Context has excess paragraphs ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The ADR Context contains six substantive paragraphs, exceeding the required 1–3 short paragraphs.
The extended operational explanation should be summarized and linked from a shorter Context section.
Code

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[R24-27]

+Every stage job in `reusable-dispatch.yml` declares `cancel-in-progress: true`. When a second
+event lands on the same work item while an agent is running — a push during a review, a comment
+during triage — the run in flight is killed and a fresh one starts from nothing. Everything the
+first run read is thrown away, and the same diff is read again from scratch.
Relevance

●● Moderate

ADR structure is reviewed closely, but no decisive paragraph-count precedent was found.

PR-#2743
PR-#6083

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The Context section spans lines 24–51 and contains six paragraphs, while PR Compliance ID 1062090
permits at most three short paragraphs.

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[22-51]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ADR 0101's Context exceeds the required 1–3 short paragraphs.

## Issue Context
Retain a brief statement of the cancellation problem and link to the relevant problem documents or detailed design material rather than reproducing the full analysis.

## Fix Focus Areas
- docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[22-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Per-org change lacks ADR callout 📘 Rule violation § Compliance
Description
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.
Code

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[R161-164]

+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
Relevance

●● Moderate

The deprecated-path disclosure requirement is specific and compliance-oriented, but lacks a close
historical match.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The added ADR says PR_HEAD_SHA is set only on the deprecated per-org dispatch path, and the
watcher adds the same per-org-specific reasoning. PR Compliance ID 2795055 requires the PR
description to identify such changes and reference ADR 0044, which the supplied description does not
do.

Rule 2795055: Flag and avoid changes to deprecated per-org installation mode content (ADR 0044)
docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[161-168]
internal/steerwatch/watcher.go[239-241]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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


10. Runtime guide consultation undocumented ✓ Resolved 📘 Rule violation ⛨ Security
Description
The PR adds steering behavior to the Claude, Codex, and Pi runtime backends, but neither the PR
description nor the diff references docs/contributing/runtime-implementation.md. Behavioral
backend changes require explicit evidence that the runtime implementation guide was consulted and
any affected contracts were reviewed.
Code

internal/runtime/claude_steer.go[R174-175]

+// Ensure ClaudeRuntime implements Steerer.
+var _ Steerer = ClaudeRuntime{}
Relevance

●● Moderate

Runtime contract changes receive review, but guide-consultation documentation lacks a close
precedent.

PR-#1780
PR-#6926

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The added compile-time assertions show that all three production runtime backends now implement the
new Steerer behavior. PR Compliance ID 2889480 requires the PR description or diff to show
consultation of the implementation guide, but the guide is neither referenced nor modified.

Rule 2889480: Consult runtime implementation guide when modifying runtime.Runtime backends
internal/runtime/claude_steer.go[174-175]
internal/runtime/codex_steer.go[290-291]
internal/runtime/pi_steer.go[92-93]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Significant runtime backend behavior was added without documenting consultation of the runtime implementation guide.

## Issue Context
State in the PR description that `docs/contributing/runtime-implementation.md` was consulted and identify whether steering affects its security matrix, runtime interfaces, sandbox hooks, wire protocol, or workspace layout. Update that guide where necessary.

## Fix Focus Areas
- internal/runtime/claude_steer.go[174-175]
- internal/runtime/codex_steer.go[290-291]
- internal/runtime/pi_steer.go[92-93]
- docs/contributing/runtime-implementation.md[1-1]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


11. Concurrency comments changed alone ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The workflow's concurrency documentation is rewritten in a comment-only hunk with no related
non-comment code changed in that hunk. The checklist prohibits standalone comment modifications
outside a hunk containing the associated code change.
Code

.github/workflows/reusable-dispatch.yml[R17-20]

+# Concurrency: each stage job declares a per-role group (fullsend-{stage}-...).
+# Roles operate independently — review dispatches do not cancel triage, code,
+# fix, etc. Within a role, cancel-in-progress is gated on the FULLSEND_STEER
+# repository variable (ADR 0101): unset, or anything but "true", keeps today's
Relevance

●● Moderate

Comment-only changes are sometimes accepted, but this repository also rejects stylistic comment-only
edits.

PR-#3079
PR-#5244

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Lines 17–24 modify only YAML comments; the actual cancel-in-progress expression is changed in a
separate hunk beginning near line 641. PR Compliance ID 1062072 requires a related non-comment code
change in the same comment hunk.

Rule 1062072: Do not add or modify comments outside code lines changed for the issue
.github/workflows/reusable-dispatch.yml[17-24]
.github/workflows/reusable-dispatch.yml[641-644]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The concurrency explanation is modified in a comment-only diff hunk.

## Issue Context
Place the documentation adjacent to the relevant changed concurrency expression so the comment and functional change appear together, or avoid modifying the standalone header comment.

## Fix Focus Areas
- .github/workflows/reusable-dispatch.yml[17-24]
- .github/workflows/reusable-dispatch.yml[641-644]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

12. ADR exceeds 100 lines 📜 Skill insight ⚙ Maintainability
Description
The new ADR contains 283 content lines after its frontmatter, substantially exceeding the 100-line
maximum. This makes the decision record too large and indicates that detailed design material should
be moved elsewhere.
Code

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[295]

+the same one a local resume needs.
Relevance

● Weak

A closely matching ADR length reduction finding was explicitly rejected in a recent ADR review.

PR-#2582

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1062092 limits ADR content excluding frontmatter to 100 lines, while this newly
added ADR runs from line 14 through line 295.

docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[1-295]
Skill: writing-adrs

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
ADR 0101 exceeds the permitted 100 lines of content.

## Issue Context
Keep the point-in-time architectural decision concise and move detailed runtime, protocol, rollout, and implementation contracts into linked design or normative documents.

## Fix Focus Areas
- docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md[14-295]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 72 rules
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md Outdated
Comment thread docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md Outdated
Comment thread docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md
Comment thread docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md Outdated
Comment on lines +161 to +164
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

Comment thread internal/steerwatch/actions.go Outdated
Comment thread internal/cli/steercmd.go Outdated
Comment thread .github/workflows/reusable-dispatch.yml Outdated
Comment thread internal/steerwatch/delta.go
Comment thread internal/steerwatch/watcher.go Outdated
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Site preview

Preview: https://b9a48ef6-site.fullsend-ai.workers.dev

Commit: 84f3b9ca27d2ce664ea18f80f61341b48cb643a3

@fullsend-ai-review fullsend-ai-review Bot added the risk/high PR risk: high label Sep 3, 2026
@fullsend-ai-review

fullsend-ai-review Bot commented Sep 3, 2026

Copy link
Copy Markdown

Risk Assessment: high (4/5)

Details

Score 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 run

Risk Assessment: high (4/5)

Details

High 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)

Details

High 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)

Details

High 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.

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review

Findings

Medium

  • [edge-case] internal/steerwatch/watcher.go:525 — 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. The steer counter increments and the cap can be reached without receipting anything.
    Remediation: After building included and dropped, check len(included) == 0. If true, mark all accepted runs as seen and return false without delivering.

  • [design-adherence] docs/reference/harness-reference.md:189 — The steer block reference documents three prerequisites (enabled:true, a Steerer runtime, FULLSEND_PRESERVE_RUNS=true) but omits the fourth prerequisite that ADR 0101's Rollout Order section declares binding: steering may not be enabled anywhere until receipts are authenticated. An operator reading only the reference doc could enable steer: {enabled: true} before that channel exists.
    Remediation: Add a warning paragraph to the steer block noting the receipt authentication prerequisite and linking to fullsend#7006.

  • [breaking-api] internal/runtime/steer_session.go:421 — The steer envelope's opening line (Runner update: your task inputs changed after this run started.) is a cross-repo wire interface. Agent definitions in fullsend-ai/agents match on this exact string. The line is pinned by TestSteerEnvelopeOpeningLineIsStable in this repo but fullsend-ai/agents carries no equivalent pin; a rename or reword here silently breaks steering on unupdated agent definitions.
    Remediation: Add a cross-repo stability test in fullsend-ai/agents. Do not change the opening line without a coordinated agents release.

  • [missing-doc] docs/guides/getting-started/operations.md:22 — The GitHub variables table does not include FULLSEND_PRESERVE_RUNS, which operators must set to true to enable preserve-and-coalesce scheduling — the prerequisite for the steer feature.
    Remediation: Add a FULLSEND_PRESERVE_RUNS row to the GitHub variables table.

  • [protected-path] .github/workflows/fullsend.yaml, .github/workflows/reusable-dispatch.yml — This PR modifies governance files under .github/. The changes (adding run-name, gating cancel-in-progress on FULLSEND_PRESERVE_RUNS, adding the /fs-steer route arm) are justified by linked issue Steer the in-flight agent run on work-item updates instead of cancelling it #6957 and documented in ADR 0101. Human approval is always required for protected-path changes.

Low

  • [receipt-authenticity-gap] internal/statuscomment/steermarker.go:118LatestSteerMarker trusts two public strings (status marker prefix + terminal tag + App author) as receipt authentication, which the code itself documents as necessary but not sufficient (lines 118–141). Tracked as KNOWN GAP in fullsend#7006, gated by ADR 0101's rollout order, and feature ships disabled by default. See also: [breaking-api] finding at steer_session.go:421.
    Remediation: Land fullsend#7006 before enabling steer in any repository.

  • [logic-error] internal/steerwatch/delta.go:216steerInstruction detects the /fs-steer command case-insensitively (line 213: strings.ToLower) but removes the prefix case-sensitively (line 216: strings.TrimPrefix). If a user types /FS-STEER do the thing, TrimPrefix fails to strip the uppercase variant, returning the raw command name as part of the instruction text.
    Remediation: Use case-insensitive prefix removal.

  • [edge-case] internal/steerwatch/watcher.go:627 — When a candidate run's job checks fail with a persistent API error (e.g., 403), the run is not marked seen and is re-fetched on every poll with no circuit breaker, creating noise for the entire watch duration.

  • [prompt-injection-surface] internal/steerwatch/delta.go:375 — Steer text is built from untrusted PR bodies, comments, and reviews. Mitigated by amendment/context split, SanitizeAgentText, envelope framing, and NeutralizeMarkers. Residual risk is inherent to any system passing user content to an LLM.

  • [design-adherence] docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md:7 — ADR header comment reads 'link it as 0098-... once that PR merges' but no mechanism exists to remind maintainers to fulfill the forward-link.
    Remediation: File a tracking issue or add a TODO comment.

  • [doc-comment-format] internal/steerwatch/provenance.go:12 — Doc comment for allowedEvents (lines 12–15) is separated from its var declaration at line 59 by the amendmentEvents block. Go's godoc convention requires the comment to immediately precede its declaration.
    Remediation: Move comment to immediately precede var allowedEvents.

  • [doc-comment-format] internal/cli/steer.go:179steerBudget's doc comment names steerAwareTimeout as the timeout-detection path but steerAwareBudget is the actual production path; steerAwareTimeout is only exercised from tests.
    Remediation: Update comment to name steerAwareBudget or factor through steerAwareTimeout.

  • [api-shape-patterns] internal/runtime/codex_run.go:642runCodexTurn takes an emittedInit *bool pointer for cross-iteration state mutation. Non-idiomatic but functional.
    Remediation: Return the state as a second bool return value or add to codexTurnOutcome.

  • [naming-conventions] internal/runtime/pi_progress.go:316parsePiStreamMode vs parseCodexStreamWith use asymmetric suffixes for the same structural pattern.

  • [api-shape-patterns] internal/cli/steer.go:111steerEligible returns "" for eligible and a reason string for ineligible, inverting Go's typical pattern for clarity.
    Remediation: Rename to steerBlockReason or change signature to (ok bool, reason string).

  • [missing-doc] docs/cli/run.md:132 — The Budget and deadline section's env var table is missing FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT, two new unconditionally-injected sandbox variables.
    Remediation: Add rows for both env vars.

  • [missing-doc] docs/guides/user/building-custom-agents.md:71 — The environment variables bullet list is missing FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT.
    Remediation: Add two bullets.

  • [missing-doc] docs/cli/run.md:87 — The metrics.json fields table is missing steers, the new field written by RunMetrics.Steers when at least one steer was acknowledged.
    Remediation: Add a steers row to the metrics.json fields table.

  • [missing-doc] docs/guides/user/bring-your-own-agent.md:162 — The agent sandbox environment description is missing FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT.
    Remediation: Extend the sentence to mention both vars.

  • [breaking-api] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:26 — Adding run-name changes display_title. Consumer repos without re-scaffold lack this, causing issue_comment steers to silently degrade (not break) to queued-run behavior.

  • [breaking-api] .github/workflows/reusable-dispatch.yml:81FULLSEND_PRESERVE_RUNS string "true" is the only accepted value. The YAML expression comparison is case-sensitive while the Go side uses strings.EqualFold, creating an invisible asymmetry.
    Remediation: Document in harness-reference.md that only lowercase "true" is recognized.

  • [api-addition] internal/runtime/runtime.go:118RunParams.Steerable bool is a new exported field. Safe for conforming implementations (zero value = no steering).

  • [api-addition] internal/runtime/runtime.go:35RunMetrics.Steers []SteerResult is a new exported field with omitempty JSON tag. Non-steered runs produce no change in JSON output.

  • [api-addition] internal/statuscomment/steermarker.go — New exports (SteerMarker, BuildSteerMarker, ParseSteerMarker, LatestSteerMarker, NeutralizeMarkers) define the marker wire format. No version field; future extension requires synchronized binary upgrades.
    Remediation: Consider adding a v=1 format key now.

  • [api-addition] internal/security/unicode.go:302SanitizeAgentText is a new exported function promoted from inline logic. Additive, no existing callers broken.

  • [api-addition] .github/workflows/reusable-dispatch.yml:26/fs-steer is a new slash command with stage-inherited authorization floor.

  • [api-addition] internal/forge/forge.go:233ReferencedWorkflow is a new exported struct. Six new fields on WorkflowRun. All additive, no interface break.


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR
Previous run

Review

Findings

High

  • [nil-deref] internal/steerwatch/delta.go:188 — Index-out-of-range panic in steerInstruction when a comment body starts with a newline or whitespace-only first line. strings.Fields(first + " ") returns an empty slice when first is empty or all whitespace, so [0] panics. The comment body comes from the forge API, so a collaborator comment starting with a blank line crashes the runner.
    Remediation: Guard the index: fields := strings.Fields(first); if len(fields) == 0 { return "" } before checking fields[0].

Medium

  • [test-inadequate] internal/steerwatch/delta_test.go:433TestSteerInstruction does not cover the case where the comment body starts with a newline, a blank first line, or a whitespace-only first line — inputs that trigger the panic at delta.go:188.
    Remediation: Add test cases for empty/whitespace-only first lines.

  • [edge-case] internal/steerwatch/watcher.go:519 — When buildText excludes ALL accepted runs' amendments (all exceeded the text budget), included is empty but a steer is still delivered, consuming a budget unit against MaxSteers. markSteered records a DeliveredSteer with an empty RunIDs slice, so no follow-up run is receipted. The steer counter increments and the cap can be reached without receipting anything.
    Remediation: After computing included and dropped, check if len(included) == 0 and skip delivering a steer that would receipt nothing.

  • [breaking-api] internal/statuscomment/steermarker.go:142 — Receipt authentication gap in LatestSteerMarker: trusts two public strings as proof of runner authorship. Documented as KNOWN GAP (lines 117–141), tracked in fullsend#7006, gated behind SteerEnabled(). ADR 0101 makes authenticated receipts a precondition for ENABLING steering, not for merging this code. See also: [prompt-injection-surface] at delta.go:342.
    Remediation: Before enabling steer on any repository, fullsend#7006 must ship: status-only credential or runner-signed receipts.

  • [breaking-api] internal/runtime/steer_session.go — The steer envelope's opening line (Runner update: your task inputs changed after this run started.) is a cross-repo interface pinned by TestSteerEnvelopeOpeningLineIsStable. Changing it without coordinating with fullsend-ai/agents breaks steering.

  • [missing-doc] docs/guides/getting-started/operations.md:22 — The GitHub variables table does not include FULLSEND_PRESERVE_RUNS, which operators must set to enable preserve-and-coalesce scheduling (prerequisite for steering).
    Remediation: Add a FULLSEND_PRESERVE_RUNS row to the GitHub variables table.

  • [protected-path] .github/workflows/fullsend.yaml, .github/workflows/reusable-dispatch.yml — This PR modifies governance files under .github/. The changes (gating cancel-in-progress on FULLSEND_PRESERVE_RUNS, adding the /fs-steer route arm, adding run-name) are justified by linked issue Steer the in-flight agent run on work-item updates instead of cancelling it #6957 and documented in ADR 0101. Human approval is always required for protected-path changes.

Low

  • [edge-case] internal/steerwatch/watcher.go:608 — When a candidate run's job checks fail with a persistent API error (e.g., 403), the run is not marked seen and is re-fetched on every poll with no circuit breaker, creating noise for the entire watch duration.

  • [edge-case] internal/runtime/steer_session.gonoteEcho with empty id and content would never match a pending message with empty key (explicitly skipped in matchLocked), leaving allAckedLocked permanently false. In practice, callers always pass non-empty keys, so this is a latent defect.

  • [prompt-injection-surface] internal/steerwatch/delta.go:342 — Steer text is built from untrusted PR bodies, comments, and reviews. Mitigated by amendment/context split, SanitizeAgentText, envelope framing, and NeutralizeMarkers. Residual risk is inherent to any system passing user content to an LLM.

  • [design-adherence] docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md — ADR 0101 and ADR 0098 address overlapping problem space. The ADR has been updated to remove the blocking precondition and now explicitly names the boundary rather than implying a precondition it does not meet.

  • [doc-comment-format] internal/statuscomment/steermarker.go:13 — Doc comment for steerMarkerPrefix opens with The steer marker records... rather than steerMarkerPrefix is... per Go convention and the in-package pattern.
    Remediation: Rewrite opening to steerMarkerPrefix is the constant opening of the steer marker HTML comment....

  • [API-shape-patterns] internal/runtime/codex_run.go:642runCodexTurn takes an emittedInit *bool pointer for cross-iteration state mutation. Non-idiomatic but functional.
    Remediation: Return the state as a second bool return value.

  • [naming-conventions] internal/runtime/pi_progress.go:316parsePiStreamMode vs parseCodexStreamWith use asymmetric suffixes for the same structural pattern.

  • [API-shape-patterns] internal/cli/steer.go:111steerEligible returns "" for eligible and a reason string for ineligible, inverting Go's zero-value-means-ok idiom.
    Remediation: Rename to steerBlockReason or change signature to (ok bool, reason string).

  • [doc-comment-format] internal/steerwatch/provenance.go:12 — Doc comment for allowedEvents (lines 12–15) is separated from its var declaration at line 59 by the amendmentEvents block.
    Remediation: Move comment to immediately precede var allowedEvents.

  • [missing-doc] docs/cli/run.md:132 — The Budget and deadline section's env var table is missing FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT, two new unconditionally-injected sandbox variables.
    Remediation: Add rows for both env vars.

  • [missing-doc] docs/guides/user/building-custom-agents.md:71 — The environment variables bullet list is missing FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT.
    Remediation: Add two bullets.

  • [breaking-api] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:26 — Adding run-name changes display_title. Consumer repos without re-scaffold lack this, causing issue_comment steers to silently degrade (not break) to queued-run behavior.

  • [api-addition] internal/runtime/runtime.go:118RunParams.Steerable bool is a new exported field. Safe for conforming implementations (zero value = no steering).

  • [api-addition] internal/runtime/runtime.go:35RunMetrics.Steers []SteerResult is a new exported field with omitempty JSON tag. Non-steered runs produce no change in JSON output.

  • [breaking-api] .github/workflows/reusable-dispatch.ymlFULLSEND_PRESERVE_RUNS appears in both YAML and Go without a shared constant. A typo in any location silently degrades to cancel-in-progress behavior.

  • [api-addition] internal/statuscomment/steermarker.go — New exports BuildSteerMarker, ParseSteerMarker, LatestSteerMarker, NeutralizeMarkers define the marker wire format. No version field; mismatch degrades safely to "not consumed."

  • [api-addition] internal/security/unicode.goSanitizeAgentText is a new exported function promoted from inline logic. Additive, no existing callers broken.


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR
Previous run (2)

Review

Findings

Medium

  • [breaking-api] internal/statuscomment/steermarker.go — The skip check (checkSteerAlreadyHandled) is gated behind SteerEnabled() (verified at steer.go:448), so it does not run for non-steered runs. However, the receipt authentication gap — a post-script shelling out to gh api can forge a receipt — is real and documented in the code (lines 117–134) and ADR 0101, with authenticated receipts tracked as an enablement precondition in fullsend#7006. See also: [receipt-auth-gap] at this file and [intent-adherence] at steer.go:448.

  • [breaking-api] internal/runtime/steer_session.go — The steer envelope's opening line (Runner update: your task inputs changed after this run started.) is a cross-repo interface pinned by TestSteerEnvelopeOpeningLineIsStable. Enablement depends on feat(agents): re-check the work item once and act on runner mid-run updates agents#1163 having the recognition logic, enforced by policy (ADR 0101 rollout order), not by a runtime check.
    Remediation: Ensure feat(agents): re-check the work item once and act on runner mid-run updates agents#1163 has the recognition logic before any repo enables steer. Consider adding a runner startup check.

  • [design-adherence] docs/ADRs/0101-steer-the-running-agent-on-work-item-updates.md — ADR 0101's Context section states that ADRs 0098 and 0101 "should be reconciled before either merges." ADR 0101 is marked Accepted without this reconciliation being resolved.
    Remediation: Resolve the reconciliation with ADR 0098 before merging, or update ADR 0101 to document why shipping independently is safe.

  • [stale-doc] docs/guides/user/bugfix-workflow.md:61 — The slash command reference table lists /fs-triage, /fs-code, /fs-review, /fs-fix, /fs-fix-stop, and /fs-retro but does not include the new /fs-steer command added in this PR.
    Remediation: Add a /fs-steer row to the slash command table.

  • [protected-path] .github/workflows/reusable-dispatch.yml, .github/workflows/fullsend.yaml — This PR modifies governance files under .github/. The changes (gating cancel-in-progress on FULLSEND_PRESERVE_RUNS, adding the /fs-steer route arm, adding run-name) are justified by linked issue Steer the in-flight agent run on work-item updates instead of cancelling it #6957 and documented in ADR 0101. Human approval is always required for protected-path changes.

Low

  • [receipt-auth-gap] internal/statuscomment/steermarker.go:135 — The receipt authentication gap is documented in the code (lines 117–134), in ADR 0101, and tracked as an enablement precondition in fullsend#7006. Steer is off by default.

  • [breaking-api] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml:26 — Adding run-name changes display_title. Consumer repos without re-scaffold lack this, causing boundToItem to skip issue_comment candidates — steering silently degrades to queued-run behavior (the pre-steer default), not to a broken state. PR events still work via pull_requests[].
    Remediation: Document the re-scaffold requirement as an explicit migration step in the rollout order.

  • [prompt-injection-surface] internal/steerwatch/delta.go:342 — Steer text is built from untrusted PR bodies, comments, and reviews. Mitigated by amendment/context split, SanitizeAgentText, envelope framing, and NeutralizeMarkers. Residual risk is inherent to any system passing user content to an LLM.

  • [intent-adherence] internal/cli/steer.go:448 — The skip check ships before the authenticated receipt (fullsend#7006). Risk bounded by steer being off by default and gated behind SteerEnabled().
    Remediation: Link the authentication gap to a filed issue so precondition closure is tracked.

  • [design-adherence] internal/cli/steer.gonewSteerGitHubClient returns *github.LiveClient directly. The steer code uses narrow interfaces (steerwatch.ItemReader, steerwatch.ActionsReader) — idiomatic Go ("accept interfaces, return structs").

  • [error-handling-idioms] internal/cli/steercmd.go:82 — Error message ends with dangling preposition: "the segment after issues/pull is not a valid item number in".
    Remediation: Drop trailing in from the message.

  • [API-shape-patterns] internal/runtime/codex_run.go:642runCodexTurn takes an emittedInit *bool pointer for cross-iteration state mutation. Non-idiomatic but functional; avoids duplicate InitEvent emissions when Codex process restarts on steer.
    Remediation: Return the state as a second bool return value instead of a pointer parameter.

  • [naming-conventions] internal/runtime/pi_progress.go:316parsePiStreamMode vs parseCodexStreamWith use asymmetric suffixes for the same structural pattern.
    Remediation: Standardize to a consistent suffix.

  • [API-shape-patterns] internal/cli/steer.go:111steerEligible returns "" for eligible and a reason string for ineligible, inverting Go's zero-value-means-ok idiom.
    Remediation: Rename to steerBlockReason or change signature to (ok bool, reason string).

  • [naming-conventions] internal/steerwatch/delta.go:89 — Field context []deltaItem shadows the imported context standard library package. No compilation issue but cognitive friction in a security-sensitive codebase.
    Remediation: Rename to background, unattributed, or surrounding.

  • [doc-comment-format] internal/steerwatch/provenance.go:12 — Doc comment for allowedEvents (lines 12–16) is 47 lines away from its var declaration at line 59, separated by the amendmentEvents block.
    Remediation: Move comment to immediately precede var allowedEvents at line 59.

  • [api-addition] internal/runtime/runtime.go:118RunParams.Steerable bool is a new exported field. Safe for conforming implementations (zero value = no steering).

  • [api-addition] internal/runtime/runtime.go:35RunMetrics.Steers []SteerResult is a new exported field. Standard JSON decoders ignore unknown fields.

  • [api-addition] .github/workflows/reusable-dispatch.ymlFULLSEND_PRESERVE_RUNS appears in both YAML and Go without a shared constant. Other FULLSEND_* variables use constants in forge.go.
    Remediation: Add VarPreserveRuns constant to forge.go.

  • [missing-doc] docs/guides/getting-started/operations.md:24 — The GitHub variables table does not include FULLSEND_PRESERVE_RUNS, which operators must set to enable preserve-and-coalesce scheduling.
    Remediation: Add FULLSEND_PRESERVE_RUNS to the GitHub variables table.


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR
Previous run (3)

Review

Findings

Medium

  • [composition-gap] internal/harness/compose.go — The new Steer *SteerConfig field on Harness has no merge rule in mergeBaseIntoChild. Every other pointer-to-struct field (ValidationLoop, Security, Trace, OpenShell) has a if child.X == nil { child.X = base.X } rule so a base harness can set a default that a child inherits. Without it, a fleet base harness that sets steer: enabled: true will silently lose that config when a child harness extends it via base: — which is the rollout path ADR 0101 describes ("enable steer: in the fleet harnesses"). Three independent review dimensions flagged this.
    Remediation: Add if child.Steer == nil { child.Steer = base.Steer } in mergeBaseIntoChild, alongside the similar block for Security and ValidationLoop.

  • [protected-path] .github/workflows/reusable-dispatch.yml — This PR modifies a governance file under .github/. The changes (gating cancel-in-progress on FULLSEND_STEER, adding the /fs-steer route arm) are justified by the linked issue Steer the in-flight agent run on work-item updates instead of cancelling it #6957 and documented in ADR 0101. Human approval is always required for protected-path changes.

  • [stale-doc] docs/contributing/runtime-implementation.md — The Runtime interface contract table lists all optional capability interfaces (DebugLogNamer, ContextBridger, etc.) but does not include the new runtime.Steerer interface. Per AGENTS.md: "When extending a Go interface with new methods, see Go Code § Interface documentation for sync requirements."
    Remediation: Add a Steerer row to the interface contract table: "Optional — deliver a mid-run message into a running session; caller must hold sandboxMu. See ADR 0101."

  • [stale-doc] docs/guides/dev/cli-internals.md — The CLI command tree does not include the new fullsend steer command. Per docs/contributing/documentation.md, cli-internals.md is the most comprehensive single reference for all commands.
    Remediation: Add a steer entry to the command tree between run and lock, showing the positional args (<work-item-url> <text>) and the --stage flag.

  • [breaking-api] internal/runtime/steer_session.go — The steer envelope's opening line ("Runner update: your task inputs changed after this run started.") is a cross-repo interface: agent definitions in fullsend-ai/agents match on it to recognise a runner amendment. The test TestSteerEnvelopeOpeningLineIsStable pins it. The ADR explicitly documents this dependency and the rollout order, but no code enforces that the agents repo has matching changes before a consumer enables steering.
    Remediation: Ensure fullsend-ai/agents has the corresponding recognition logic before any repository sets FULLSEND_STEER=true. Consider adding a pre-flight check in the runner that warns when the agent definition does not contain the expected match.

  • [breaking-api] internal/scaffold/fullsend-repo/templates/shim-per-repo.yaml — Adding run-name changes display_title for every workflow run. The steer watcher's boundToItem check for issue_comment events relies on this field, but consumer repos only get it after re-scaffolding. On older shims without run-name, issue_comment steers will silently fail the binding check (no pull_requests[] and no matching display_title). The breaking change is properly marked with !.
    Remediation: Document the re-scaffold requirement as an explicit migration step in the rollout order, not just implied.

Low

  • [off-by-one] internal/steerwatch/watcher.goNew() defaults MaxSteers to 1 when <= 0, but ADR 0101 says the default is 2 and harness.DefaultSteerMaxSteers is also 2. The runner always passes the harness value so this is not a runtime bug, but a direct New() caller without a harness gets 1 steer instead of the documented 2.
    Remediation: Change the watcher's internal fallback from 1 to 2 so the two defaults agree.

  • [pattern-inconsistency] internal/runtime/claude_steer.go, internal/runtime/pi_run.go — The closeFeedIf helper method is duplicated verbatim on ClaudeRuntime and PiRuntime with identical signatures and logic. Divergence between the copies is a maintenance risk.
    Remediation: Extract into a shared free function (e.g., steerCloseFeedIf) that both runtimes call.

  • [naming-convention] internal/cli/steercmd.goworkItemFromSegments returns fmt.Errorf("could not read the item number from") — the sentence ends with a dangling preposition and no argument. Other error messages in the same function are complete sentences.
    Remediation: Change to a self-contained message like fmt.Errorf("the segment after issues/pull is not a valid number").

  • [stale-doc] docs/contributing/documentation.md — The documentation cross-reference guide lists touchpoints for every major CLI command group but does not include a steer entry.
    Remediation: Add a steer entry listing docs/cli/README.md, ADR 0101, and internal/cli/steercmd.go.

  • [missing-doc] docs/contributing/harness-fields.md — The steer field is added to the top-level classification table but not to the merge rules table. Its merge behavior should be documented.
    Remediation: Add steer to the merge rules table with 'Child replaces entirely' semantics.


Labels: PR introduces a new runtime capability (steering) spanning dispatch workflows, runner CLI, and all three runtime backends


Next steps:

  • /fs-fix — agent addresses review findings automatically
  • /fs-fix <your instruction> — agent fixes with your specific guidance
  • Push commits directly — review re-runs automatically on push
  • /fs-fix-stop — disable automatic fix runs for this PR

fullsend-ai-review[bot]

This comment was marked as outdated.

@fullsend-ai-review fullsend-ai-review Bot added component/runner Agent runner behavior and lifecycle component/dispatch Workflow dispatch and triggers labels Sep 3, 2026
@waynesun09

Copy link
Copy Markdown
Member Author

functional-tests is red on main itself, not from this PR: a workflow_dispatch control on main (run 33763731846) fails identically (4/4 policy_denied on the first Vertex call), and the last run that actually executed the step and passed was 33704432608 (01:38Z). Every green functional run on other PRs since then skipped the step for lack of relevant changes. Tracked in #6962; bisect runs on this branch's runtime-only and pre-wiring refs failed the same way and those refs are deleted.

waynesun09 added a commit that referenced this pull request Sep 3, 2026
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>
@fullsend-ai-review

fullsend-ai-review Bot commented Sep 3, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 3:30 PM UTC · Ended 4:01 PM UTC

Commit: 748dd20 · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 3, 2026

Copy link
Copy Markdown

🤖 Finished Review · ❌ Failure (validation failed after 2 iteration(s)) · Started 4:04 PM UTC · Completed 4:46 PM UTC

Commit: 0dbfade · View workflow run →

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 "&lt;!--" 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>
@fullsend-ai-review

fullsend-ai-review Bot commented Sep 6, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 4:40 PM UTC · Ended 4:40 PM UTC

Commit: 3345aeb · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 6, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 4:42 PM UTC · Completed 5:19 PM UTC

Commit: 84f3b9c · View workflow run →

Runtime: claude · Model: opus → claude-opus-4-6 · Effort: high · Cost: $19.29

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@fullsend-ai-review fullsend-ai-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the review comment for full details.

// 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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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"`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] api-addition

SanitizeAgentText is a new exported function. Additive, no existing callers broken.

Comment thread internal/forge/forge.go
// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] api-addition

ReferencedWorkflow is a new exported struct. Six new fields on WorkflowRun. All additive, no interface break.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/dispatch Workflow dispatch and triggers component/runner Agent runner behavior and lifecycle risk/high PR risk: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants