Steering a run in flight
How a fullsend run already working on a work item absorbs an update to that item — a push, a comment, a stage command — rather than leaving it for the run queued behind it, and how that queued run learns the work is done.
This page is the contributor reference for the mechanics. ADR 0113 is the decision record: it says what was decided and why, and links here for how. The byte-level envelope the agent receives is a versioned contract of its own, in normative/steer-envelope/v1.
On this page:
- Concurrency — the two switches, and why steering needs both
- Amendments and context — which half of a delta may instruct the agent
- The steer contract —
runtime.Steerer, and the caller's lock obligation - Transport: follow-up runs are the requests
- Provenance: what the runner verifies — the seven checks
- The work item's baseline
- Settle — how a steered run ends
- Ceilings — token life, cost, and the per-process guards
- The skip check — the receipt the queued run reads
- The fleet-agent backstop
- Configuration
- What changes for each stage
- Known limits
- Rollout order
Concurrency
Two independent switches, and steering needs both. Whether the run in flight survives a newer event is decided by the base change, not by this one: every reusable-dispatch.yml stage job carries
concurrency:
group: fullsend-<stage>-${{ github.repository }}-<item>
cancel-in-progress: falseso the active run always finishes while one run waits behind it as the pending run — normally but not necessarily the newest event — and works from the item's current state (ADR 0101). Whether that surviving run is steered is decided here, by the harness steer: block. Preserving is useful on its own and is the base change's whole subject; steering builds on it.
queue: max is deliberately unused: it is incompatible with cancel-in-progress: true, and N pending full runs is the failure mode preserving the active run removes.
Amendments and context
Provenance authorizes runs, not the text they carry. An accepted run establishes that an authorized principal caused something on this work item; it does not establish that every comment since the baseline came from that principal. So the delta is split. An item is an amendment — an instruction the agent acts on, taking precedence over its original task — only when its author is the principal the Route job checked. Everything else is context: data the agent may read and must not obey.
That is decidable only for events where the run's actor is by construction the login the arm checked. The run record carries the event but not the action, so an event qualifies only if every arm handling it checks the login the run reports. Auditing reusable-dispatch.yml leaves exactly one:
| Event | Verdict |
|---|---|
issue_comment | every slash-command arm checks the comment author, who is the run's sender. Eligible. |
issues | opened and edited check the reported actor, but labeled with ready-for-triage or ready-for-review checks nobody and still selects a stage, and the action is invisible in the run record, so the authorized arms cannot be told from the unauthorized ones. Excluded. |
pull_request_target | opened, synchronize and ready_for_review check the PR author while the run's actor is whoever pushed — on a fork PR a different person, who needs no upstream permission at all. labeled and closed check nobody. Excluded. |
pull_request_review | checks the PR author while the actor is the review submitter, which the arm requires to be the review App. Excluded, and bot actors are filtered regardless. |
pull_request_review_comment | has no arm at all, so every stage job is skipped and check 5 already rejects it. Excluded. |
A push, a label and a closure are state changes rather than instructions, which is the same reason an issue's title, body and label edits are context. Excluding them costs the agent nothing it needs: a head move still arrives as context, carrying the new SHA. An authorization also covers only items that predate the run it came from, so a login that was authorized once does not promote whatever it writes later.
The steer contract
runtime.Steerer is an optional capability on a runtime:
type Steerer interface {
Steer(ctx context.Context, sandboxName string, msg SteerMessage) error
Settle(ctx context.Context, sandboxName string) error
}RunParams.Steerable asks a Steerer runtime to keep the session open; Run then returns only after Settle and the agent's current turn. A runtime that does not implement Steerer ignores the field, and its command line is unchanged.
Both methods are called with sandboxMu held. They write into the sandbox — a mailbox append, or on Codex the stray-process sweep that interrupts the turn — and would otherwise race the credential refreshers the runner already serializes through that lock. The lock lives in internal/cli, so the runtime cannot take it itself; this is a caller obligation, documented on the interface.
A steer is content, never capability. It cannot widen tools, role, model, scope, or the L7 network policy. Runtimes render it as a user message.
Transport: follow-up runs are the requests
Every legitimate update to the work item already fires the repository's shim, and that run's Route job already applied ADR 0054's authorization. That run record is a server-side, unforgeable statement of what ran and when. It is not a statement of who was authorized: the actor it reports is the principal the Route job checked only for issue_comment (see Amendments and context above). So the runner needs no mailbox, no relay, and no re-implementation of the routing predicate: it polls GET /repos/{repo}/actions/workflows/{shim}/runs?created>=<my start> with the job token — the GH_TOKEN the action passed in, which every stage job already grants actions: write — and turns the runs that pass provenance into steers.
A human on a workstation reaches the same transport with fullsend steer <url> "<text>", which posts the stage's own slash command — /fs-review for a pull request, /fs-triage for an issue, or --stage to choose — and the comment fires the shim like any other event. There is no steer-specific command: the watcher accepts follow-up runs on provenance alone and never looks at which words the comment opened with, so a run in flight absorbs the run an ordinary /fs-fix produced exactly as it would any other. The existing arms already carry the right floor for each stage, /fs-fix keeping the write floor that makes it a mutation stage.
Dispatch is never suppressed while a run is in flight. A route arm that skipped whenever something was running would lose a steer that lands after the in-flight run's last check.
Provenance: what the runner verifies
Authorization is not the runner's job — it already happened, once, in the follow-up run's route job. What the runner verifies is provenance, entirely from server-side records the sender cannot write:
| # | Check | Rejects |
|---|---|---|
| 1 | Same repository | implicit in the API path |
| 2 | path is the shim and event is a work-item update (issue_comment, issues, pull_request_target, pull_request_review, pull_request_review_comment) | push, pull_request, workflow_dispatch, and any other workflow |
| 3 | referenced_workflows (path and ref) equals my own run's | a foreign or renamed reusable workflow, or one at another ref, by inequality — no version knowledge needed. The sha is not compared: a branch-pinned shim (@main, as on this repository) resolves to a new sha whenever the branch advances, which would drop every steer there |
| 4 | The candidate's Route job concluded success, and the run was created after mine started | a run whose Route job authorized nobody; a replayed old run. It does not establish that the run's reported actor is the authorized one |
| 5 | My stage's job has conclusion != "skipped" | a fork author's stage command, whose run has every stage job skipped |
| 6 | Bound to my work item: pull_requests[], else the shim's run-name as display_title | another item's run |
| 7 | Not judged before, by run id | a replay; a re-poll |
Check 4 deliberately ignores the run's own conclusion. Under queue: single, a later event cancels the earlier pending stage job and that run concludes cancelled — while the authorization its Route job established still stands. Check 5 counts a null conclusion as selected: that is the run queued behind me, which is the common case.
issue_comment and issues runs carry no pull_requests[], so the per-repo shim declares run-name: ${{ github.repository }}#${{ github.event.issue.number || github.event.pull_request.number }}, which the API returns as display_title. For a comment on a PR, github.event.issue.number is the PR number, so the pair covers every event the shim listens for. A candidate that matches neither is skipped rather than guessed at: a wrong binding steers one work item's agent with another's content.
A run the watcher has judged is never re-examined, but only a run whose content actually reached the agent is recorded as consumed. The marker is what the queued run reads to decide whether to skip its own work, so a candidate that was dropped — an empty delta, a failed delivery, a runtime that cannot steer — must not look handled.
Every accepted candidate in one poll folds into a single steer — the delta is the item's current state against a baseline, so two comments that arrive together cost one turn, not two, and both run ids are recorded as consumed.
The envelope's first line is a cross-repo interface with fullsend-ai/agents, which matches on it twice: to recognise a runner amendment, and to flag the same line appearing inside work-item content as an injection attempt. It is, byte for byte:
Runner update: your task inputs changed after this run started.In this repository it is the exported constant runtime.SteerEnvelopeOpeningLine, written in one place and pinned by a test, so the agent definitions have a single string to match and this one cannot drift from it silently. That line is deliberately not defanged when it appears inside work-item content — its appearing there is the injection signal the agent definitions are told to act on, and rewriting it would delete the evidence. The rest of the envelope's structure is the opposite case: the two section headings, the amendment prefix, and the fence around the untrusted block carry no signal when a stranger writes them, only authority, so a context body carrying any of them is defanged before it is wrapped.
The delta text is a runner-authored envelope through the same Unicode sanitizer buildFeedbackPrompt uses (now security.SanitizeAgentText, shared so the two cannot drift), delivered through the mailbox, so it never reaches the agent CLI's own argv. It is not out of argv entirely: the mailbox write is a printf ... >> mailbox command string that sandbox exec runs as sh -c, so the text is visible in that shell's argv inside the sandbox (to the sandbox user, which is the agent that is about to read it) and in OpenShell's host-side command preview. Plumbing the exec request's stdin field through the sandbox package would remove even that; it is tracked separately. Only non-bot activity counts, so a run never steers itself with its own start comment.
The work item's baseline
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 report the whole body as edited and every label as added, forever, so the run never settles and the agent is handed the same "update" on each steer. A head SHA the environment does supply still wins, because it is the head at run start and that is what a head move must be measured against.
Settle
On a turn end — runtime.ResultEvent, which Claude's result, pi's agent_settled (on a steerable run) and Codex's turn.completed all normalize to — the watcher polls once. If something new arrived it steers and the agent takes another turn; otherwise it settles and the run ends. A steer consumed mid-turn produces no turn end of its own, so turn ends are a settle signal and are never counted against the steer budget.
The watcher settles on every exit path, including a cancelled context, on a context of its own — otherwise Run would hold a session open for a watcher that has stopped watching.
Ceilings
- Forge token life. The stage mints a GitHub App installation token at job start; those live one hour and the runner has no refresher for them. The budget is
min(agent timeout, token life − margin), owned by the runner:internal/runtimeknows nothing about forge token life, so deciding it there would put a policy in the wrong layer. - Cost. A steered turn on a large diff can cost as much as a fresh run.
steer.max_steersdefaults to 2, which covers the burst patterns in #6573 and #4960; beyond the cap the run settles and the queued run does the work. The cap counts the run, not the iteration: a validation loop builds one watcher per iteration, so the count spent so far is carried into each new one, and a three-iteration run absorbsmax_steersupdates in total rather than three times that. - Session files are agent-writable. A resume reads a session store the agent controls, so a poisoned session is a prompt-injection vector into the next turn. It is not a credential leak, and the hooks still gate tools (ADR 0090). This is documented, not signed.
- Per-process guards. pi's config-dir guard, Codex's hook-digest re-assert and Claude's
--settingshooks run once per process. A live steer keeps the process, so they have already run and the hooks stay loaded; interrupt-and-resume re-runs them. Neither weakens ADR 0090.
The skip check
After a successful run that absorbed at least one steer, the runner posts a receipt as its own comment on the work item:
<!-- fullsend:steer consumed=<run_id,...> head=<sha> -->
_The run already working on this item absorbed follow-up run(s) 101, 102, so a run queued for
those events exits without repeating the work._It is a processing receipt in the sense of the entity-first evaluation ADR (fullsend#6956): a durable record on the subject of what a run actually handled, which is what lets a later run decide whether its own trigger is already covered. In fullsend run's pre-flight — before the start comment and before the pre-script, whose side effects are not free — a queued run reads the latest receipt on the work item and exits 0 without starting the agent when its own GITHUB_RUN_ID is listed.
Who may write a receipt
The author is the whole of the authentication, and it must be the job token's.
The runner captures the GitHub Actions job token — GH_TOKEN as the action passed it in — before minting swaps in the role token, and posts the receipt under that identity. The reader resolves that login from the token itself rather than hardcoding it, because it differs between github.com and GitHub Enterprise Server.
Two conditions have to hold for that identity to mean anything, and both are enforced in code rather than assumed:
- Minting must actually have happened. The swap is what puts the job token out of reach: it replaces
GH_TOKENbefore the sandbox exists, leaving the captured credential to the runner's process alone. With no mint URL or no role there is no swap, the same credential stays in the environment a post-script inherits, and a post-script shelling out toghcould sign a receipt. The receipt credential is therefore the job token only when a role token was minted; otherwise it is empty and both the writer and the skip check turn off. - The two identities must differ. The action's
github_tokeninput defaults to${{ github.token }}but is an input, so a caller can pass an App installation token — and if that resolves to the same login the role token posts under, the agent's own comments carry the trusted author and the check is inverted. The reader resolves both logins and refuses to skip when they match, warning as it goes.
With both holding, the receipt's author is one nothing inside the sandbox can post as: not the agent, and not a post-script.
What the author check proves, exactly: that the comment came from a workflow job token of this repository — not that it came from this run, or even from fullsend. Every job's default GITHUB_TOKEN in a repository posts under the same login, so any other workflow in the same repository could write a comment carrying the marker syntax and it would be honoured. That is a maintainer-controlled boundary, since a repository's own workflows can already do anything to it, and it is deliberately not narrowed further: checking the named run id against the Actions API would add nothing, because that id is public.
This is why the receipt is a separate comment rather than the marker on the terminal status comment. That comment is posted by the App — the same identity the agent's own output goes out under. A marker there authenticates two public strings, not the code path that wrote them: an injection in the work item can induce the agent to write a perfectly formed marker into its review body, and the App then posts it. The runner still writes a copy of the marker into the status comment so a reader can see what a run absorbed, but the skip check does not honour it, and no App-authored marker of any shape is a receipt.
The boundary this leaves: a credential that leaked out of the runner's own process could post a receipt the check would honour. That is a compromise of the runner rather than of the agent sandbox, and it is the same credential that already reads the Actions API to accept steers in the first place.
Reading the Actions API is deliberately not held to the same bar. Those reads prove no authorship, so the watcher keeps using the job token whether or not minting swapped it; only the receipt's identity has to be unreachable.
Failure directions
The check fails open in every direction: no receipt, an unreadable timeline, an unresolvable login, a malformed run id. A false "already handled" silently drops the work; a false "not handled" costs one short run. Writing the receipt is best-effort for the same reason — a failed post costs one queued run that redoes finished work, where failing the run would throw away work that succeeded.
Only an outright success leaves a receipt, and only one that absorbed something. A failed, cancelled or skipped run writes none, and a run whose validation never passed writes none, because a receipt claims the work is done. A run that absorbed nothing writes none either: the marker on the status comment still records the head such a run settled on, but a standalone receipt asserting that a queued run may skip has nothing to assert. One receipt per run, not per steer, and it names only what the iteration whose output shipped absorbed — an update absorbed by an iteration that then failed validation never reached that output, so receipting it would tell the queued run to skip work nobody published.
Worst case is one short redundant run — the same window Actions has today, minus the wasted in-flight tokens.
On GitLab there is no receipt. Its job token cannot post or read notes, and fullsend steer refuses GitLab already, so the skip check stays fail-open exactly as it is today: the queued pipeline does the work.
The fleet-agent backstop
The runner exports FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT into the sandbox unconditionally, so an agent definition can re-read the work item once before writing its result. This is a backstop under the harness steer, not an alternative: the steer is deterministic and lands during the run, while the re-check depends on the model following the instruction and lands only at the end. It costs one or two API calls when nothing changed.
Both are written from bootstrapEnv, not from env.sandbox or an env/*.env file: .env.d files are sourced later and would expand the references host-side to empty, and a ${VAR} in harness env.sandbox hard-fails ValidateRunnerEnvWith for consumers that do not define it.
FULLSEND_RUN_STARTED_AT is the runner's own clock at the top of runAgent, not the workflow run's server-side created_at, so the two halves of preserve-and-reconcile do reference different absolute instants: the watcher accepts a follow-up run by comparing server-side timestamps, while the agent's end-of-run re-check compares against this host-side one. The gap is the setup that precedes runAgent — checkout, sandbox create, bootstrap — and it is one-directional: the exported instant is later than the run's true start, so the re-check can only ever look at a slightly narrower window than the run actually spans, never a wider one. It can therefore miss an update that landed during setup; it cannot invent one. That is the conservative direction, and the steer itself does not depend on this value — only the backstop does. Making it exact would mean reading the run's created_at back from the Actions API at startup, which buys a bounded improvement to a backstop at the cost of an API call on every run; if the backstop ever becomes load-bearing, that is the change to make.
Configuration
Per-agent, on by default. A harness that says nothing about steer: steers; so does one that sets only a cap or a cadence. Turning it off takes the key by name:
steer:
enabled: false # default: true — this is the opt-out
max_steers: 2 # default: 2
poll_interval_seconds: 30 # default: 30enabled is a pointer internally for exactly this reason: absent and false have to mean different things, so steer: {max_steers: 3} cannot silently opt a harness out while it is trying to raise the cap.
The runner sets RunParams.Steerable only when all of: the harness has not opted out, the runtime implements Steerer, and the job is a GitHub Actions run. Otherwise Steerable stays false and Run is single-turn exactly as before. The runner announces a declined watch only when the harness set enabled: true itself — a block that sets only a cap or a cadence stays quiet, since with the default on most declines are ordinary conditions rather than misconfiguration. A missing job token or GITHUB_RUN_ID is always announced.
What changes for each stage
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 at zero re-read cost, and the existing prior_sha output becomes the skip key. Fix counts its iteration once at start and treats a steered continuation as the same iteration, so the queued run's skip check replaces its reliance on the concurrency group for TOCTOU. Triage receives the new comment or title mid-run, which is #1207 closed — its needs-info flips must be idempotent, which ADR 0063 already asks for. Code stops watching once the branch is pushed, since after the PR exists the update belongs to fix or review.
Known limits
Parsers see N results per run. A steered run emits one ResultEvent per turn, so anything that assumed one result per iteration — the Claude parser's seenResult (#6932), RunMetrics, the agent span, eval-measure — is now 1:N. RunMetrics.Steers records every acknowledged steer, written by Run alone so the watcher's goroutine never races it.
The prompt-injection surface grows. The steer text is built from PR bodies, comments and commit messages, and under a stage command from an authorized human — the same trust dispatch already places in that person. The sanitizer and the sandbox hooks remain the controls; an authorized human pasting attacker-supplied text is still an injection, and this design does not change that.
The sandbox checkout is not refreshed. It stays a snapshot of the head the run started on, because refreshing it from the runner would clobber uncommitted work for the fix and code stages, which write to that tree. On a head move the envelope names the new SHA and tells the agent to fetch it with the forge token it already holds; a runner-side refresh for read-only stages is a possible follow-up, not part of this decision.
GitLab is not wired. GitLab pipelines already queue rather than cancel, and the provenance join is different — GET /pipelines/:id/variables exposes the poller-set STAGE and RESOURCE_KEY, already covered by the HMAC dispatch signature. The watcher is GitHub-only for now, and on GitLab it declines quietly unless the harness set enabled: true itself.
A steer needs time left. The exec hosting a live session cannot be extended once running, so the watcher settles rather than steering when less than MinRemaining (default five minutes) of the run budget remains, and the update falls to the queued run.
Rollout order
Precondition, now satisfied: steering may not be enabled anywhere until receipts are authenticated by a channel that agents and post-scripts cannot mint. Scoping the receipt to a body carrying the status markers was not that channel: it authenticated 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, so an agent induced to emit those strings — through a post-script shelling out to gh, which reaches none of the runner's sanitizing paths — produced a receipt that passed. A forged receipt makes the queued run exit without doing its work, so the failure is a silently dropped update rather than a wasted one.
The channel is the GitHub Actions job token, described under Who may write a receipt: the runner holds it, minting removes it from the environment before the sandbox exists, and both writing and reading the receipt happen under its identity.
This precondition, like the rest of the rollout order, is judged by the people who enable steering; nothing in the binary enforces it.
The receipt is load-bearing rather than an optimization. Without one, steering costs more than preserving alone: the active run absorbs the push and reviews head B, then the queued run reviews head B again — two reviews of the same head where preserving alone produces one. So the skip check and the authenticity it depends on ship together, or neither ships.
Once that holds, the harness steer: block is the only switch, and it is now on by default: steering arrives with the release that carries it rather than one harness at a time. A harness that opts out with enabled: false sits in exactly the base change's state, where the run in flight finishes and the queued run does the work from the item's current state. Nothing is half-enabled, so a harness can sit there indefinitely.
The preconditions this ordering existed to impose are not waived by moving the switch; they are what the people cutting the release check by hand, since nothing in the binary enforces them. The authenticated receipt ships in the change below this one. Still open at the time of writing: the fleet agent definitions (fullsend-ai/agents#1163) are unmerged, so the agents have not yet been taught the envelope; each runtime's transport has been driven in isolation, but no end-to-end steer inside OpenShell from a real workflow run has been observed; the receipt's identity match has not been seen on a live run; and that same PR teaches the envelope to code, fix, review and triage only, so prioritize, retro and scribe must either learn the contract or set steer: {enabled: false} before the default reaches them — an eligible run on a definition that ignores the envelope still acks the delivery and still posts a receipt, so the queued run skips and the update is dropped silently, which is the one failure mode this design may not have. All four belong before the default reaches a release. Agents that fullsend agent new scaffolds from here on carry the contract in their generated body; ones scaffolded earlier must be regenerated to gain it, and neither changes the fleet definitions above.
