Skip to content

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.

This page is the contributor reference for the mechanics. ADR 0113 is the decision to steer at all, and ADR 0117 decides the interface described here, and ADR 0119 decides how a run learns there is an update to absorb. The byte-level envelope the agent receives is a versioned contract of its own, in normative/steer-envelope/v1.

On this page:

Concurrency ​

Two independent switches, and steering needs both. Whether the run in flight survives a newer event is decided elsewhere: every reusable-dispatch.yml stage job carries

yaml
concurrency:
  group: fullsend-<stage>-${{ github.repository }}-<item>
  cancel-in-progress: false

so 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 0106). Whether that surviving run is steered is decided here, by the harness steer: block. Preserving is useful on its own; 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 proves an authorized principal caused something on the item, not that every comment since the baseline is theirs. So the delta is split. An amendment — an instruction the agent acts on, over its original task — is the one comment the Route job evaluated. Everything else is context: data the agent may read and must not obey.

The run record names the actor and the run's creation instant, not the comment, and the comment precedes that instant (a real /fs-retro at 14:06:16Z produced its run at 14:06:19Z). The triggering comment is therefore the newest comment by that actor created at or before the run, and only that one; everything earlier by the same login stays context, and a burst of two commands binds each run to its own comment, newest first. The text must be unchanged since: a comment edited after the run was created is context, and its run is unbound — not counted as consumed, because nothing reached the agent under its authority. The actor is the run's actor; triggering_actor names whoever caused the latest attempt, so a re-run (run_attempt > 1) confers nothing. A review is never an amendment.

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:

EventVerdict
issue_commentevery slash-command arm checks the comment author, who is the run's sender. Eligible.
issuesopened 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_targetopened, 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_reviewchecks the PR author while the actor is the review submitter, which the arm requires to be the review App. Excluded: a review reaches the agent as context at most.
pull_request_review_commenthas 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, not instructions — the same reason an issue's title, body and label edits are context. A head move still arrives as context: the new SHA, and as the first item of the context block what changed, read from the forge's compare API because the agent may not fetch in the sandbox. The change is shown only when the comparison is complete (the new head is simply ahead and fewer than 300 files came back); a force-push, a listing at the cap or a forge error is reported as a move that could not be read, and the agent goes to the pull request itself. Patches are bounded, whole files at a time, and the cut is stated.

Whose text is context ​

Everything on the work item is context except fullsend's own output, and that exclusion never reads the shape of a login: a user account can be named fullsend-ai-review or end in -bot, and the old [bot] rule also threw away the reviews of Apps the repository installed. ownOutput in delta.go applies two rules:

  • Exact login — the primary rule. Config.SelfLogins holds the logins the runner resolved at start: its own App login from the forge token, and the review App logins as reusable-dispatch.yml builds REVIEW_BOT and SHARED_REVIEW_BOT (steerwatch.ReviewBotLogins, pinned to the workflow file by a test). Start refuses an empty list, because the post-fix and post-code comments carry no marker and only the login keeps them out.
  • App plus marker — the supplement. A body carrying a <!-- fullsend: marker whose author the forge reports as an App (user.type == "Bot", forge.IssueComment.AuthorIsApp) is the runner's own under any login. This keeps out the processing receipt, posted as github-actions[bot].

A human is never own output, so an authorized /fs-fix that quotes a status comment is still an amendment. A miss leaves an App's text in context, and no rule moves an author toward amendments; the one gap is a fullsend App the runner did not name posting without a marker, which enters context like any other App's comment.

The steer contract ​

runtime.Steerer is an optional capability on a runtime:

go
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 ​

The runner reaches the sandbox only through exec and upload, and neither gives it a handle on the stdin of a process already running inside — so there is nothing to write a message to from outside. The live runtimes therefore start with stdin connected to an in-sandbox feeder tailing a mailbox file, and Steer appends one line to that mailbox. Claude Code reads it as stream-json input, pi over its rpc channel. Codex exec has no live channel: it interrupts the current turn and resumes the same session with the message as the next prompt.

A steer counts as delivered only when the runtime observes the agent echo that specific message, matched by the message's own identity rather than by counting — the mailbox lives in the runtime's config directory, which the agent can write to, so an echo that matches nothing outstanding is ignored.

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, never on which words the comment opened with. 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 ​

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. The run record is a server-side, unforgeable statement of what ran and when — not of who was authorized: its actor is the principal the Route job checked only for issue_comment (see Amendments and context). So the runner re-implements no routing predicate and calls no permission API. It verifies provenance, entirely from records the sender cannot write:

#CheckRejects
1Same repositoryimplicit in the API path
2path 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
3referenced_workflows (path and ref) equals my own run'sa 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
4The candidate's Route job concluded success, and the run was created after mine starteda run whose Route job authorized nobody; a replayed old run. It does not establish that the run's reported actor is the authorized one
5My stage's job has conclusion != "skipped"a fork author's stage command, whose run has every stage job skipped
6Bound to my work item: pull_requests[], else the shim's run-name as display_titleanother item's run
7Not judged before, by run ida replay; a second look at the same listing

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 they bind only through the run's display_title, which is the shim's run-name. The contract this package expects is run-name: ${{ github.repository }}#${{ github.event.issue.number || github.event.pull_request.number }} (for a comment on a PR, github.event.issue.number is the PR number, so the pair covers every event the shim listens for); the shim gains it with the runner wiring that starts the watcher, and a shim without it binds pull-request events only. 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 reached the agent is recorded as consumed: the consumed set is what tells a later run its event was handled, so a dropped candidate — an empty delta, a failed delivery, a runtime that cannot steer — must not look handled. Every accepted candidate in one batch 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, 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:

text
Runner update: your task inputs changed after this run started.

In this repository it is the exported constant runtime.SteerEnvelopeOpeningLine, written once and pinned by a test. That line is deliberately not defanged inside work-item content: its appearing there is the injection signal the agent definitions act on, and rewriting it would delete the evidence. The rest of the envelope's structure — the two section headings, the amendment prefix, and the fence around the untrusted block — carries only authority when a stranger writes it, so a context body carrying any of them is defanged before it is wrapped — including spellings that only become those tokens under NFKC folding, and nothing is scanned again once the block is assembled, since a folding pass over the whole envelope is exactly what would turn such a spelling into a live token.

The delta text goes through the same Unicode sanitizer buildFeedbackPrompt uses (security.SanitizeAgentText, shared so the two cannot drift) and is 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 that sandbox exec runs as sh -c, so the text is visible in that shell's argv inside the sandbox and in OpenShell's host-side command preview; plumbing the exec request's stdin through the sandbox package would remove that and is tracked separately. Fullsend's own output is excluded before any of this (whose text is context), so a run never steers itself.

The work item's baseline ​

The watcher asks the forge what the work item is at startup rather than reading 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 a way to tell a pull request from an issue. Guessing wrong is not cosmetic — an issue-shaped baseline of empty title, body and labels reports the whole body as edited and every label as added on every delta, so the run never settles. A head SHA the environment does supply still wins: it is the head at run start, which is what a head move is 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. It settles only when that poll finds nothing conclusively new: a poll that reached no verdict — a listing that failed, or a candidate whose Route or stage job has not finished — leaves the session open for the next tick, because those runs are deliberately still judgeable and the deadline and remaining-time floor already bound the wait. 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/runtime knows nothing about forge token life.
  • Cost. A steered turn on a large diff can cost as much as a fresh run. steer.max_steers defaults 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 and carries the count into each, so a three-iteration run still absorbs max_steers updates in total.
  • 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 --settings hooks 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:

text
<!-- 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 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 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 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:

  • Minting must actually have happened. The swap is what puts the job token out of reach: it replaces both GH_TOKEN and GITHUB_TOKEN before the sandbox exists, and a child script's environment drops any remaining entry whose value is the swapped-away token, so a caller cannot keep it reachable under a third name. 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 to gh could sign a receipt. The receipt credential is the job token only when a role token was minted; otherwise it is empty and both the writer and the check turn off.
  • The two identities must differ. The action's github_token input defaults to ${{ github.token }} but is an input, so a caller can pass an App installation token. If that resolves to the 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, nothing inside the sandbox can post as the receipt's author.

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 from fullsend. Every job's default GITHUB_TOKEN in a repository posts under the same login, so any other workflow there could write a comment carrying the marker syntax and it would be honoured. That is a maintainer-controlled boundary, and it is deliberately not narrowed: 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 identity the agent's own output goes out under, so a marker there authenticates two public strings rather than the code path that wrote them — an injection can induce the agent to write a well-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 is a credential leaked out of the runner's own process, which is a compromise of the runner rather than of the sandbox. Reading the Actions API is deliberately not held to the same bar: those reads prove no authorship, so the watcher uses the job token whether or not minting swapped it.

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, skipped or unvalidated run writes none, because a receipt claims the work is done. A run that absorbed nothing writes none either: the status comment still records the head it settled on, but a 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.

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, 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 run's server-side created_at, so the two halves reference different instants: the watcher compares server-side timestamps, the agent's re-check this host-side one. The gap is the setup before runAgent — checkout, sandbox create, bootstrap — and it runs one way, because the exported instant is later than the run's true start. The re-check can miss an update that landed during setup; it cannot invent one.

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:

yaml
steer:
  enabled: false         # default: true — this is the opt-out
  max_steers: 2          # default: 2
  poll_interval_seconds: 30   # default: 30

enabled 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. A child harness that says nothing inherits its base's whole block, so a base opt-out is inherited rather than overwritten by the default.

The runner exports FULLSEND_STEER_ACTIVE=1 into the sandbox for an iteration whose watcher is running, and nothing at all otherwise. The agent definitions treat the envelope's opening line as an injection attempt unless it is set, so a watcher that declines, or whose first API calls fail, leaves the variable unset. See fullsend run § Run baseline.

The runner sets RunParams.Steerable only when all three of: the harness has not opted out, the runtime implements Steerer, and the run is a GitHub Actions job with a work item to watch — the same three harness-reference.md states. 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.

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.

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 carries what changed as context; the agent does not fetch it.

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 ​

Steering arrives with the release that carries it rather than one harness at a time, and the harness steer: block is the only switch. A harness that opts out with enabled: false sits in the state ADR 0106 describes, where the run in flight finishes and the queued run does the work. Nothing is half-enabled, so a harness can sit there indefinitely.

The receipt the queued run exits on (ADR 0120) is the precondition for enabling steering anywhere: without it steering costs more than preserving alone. It ships in the change below this one.

What remains is judged by the people cutting the release; nothing in the binary enforces it. The fleet agent definitions must teach the envelope, an end-to-end steer must have been observed inside the sandbox from a real workflow run, the receipt's identity match must have been seen on a live run, and every stage whose definition ignores the envelope must opt out before the default reaches it — an eligible run on such a definition still acknowledges the delivery and still posts a receipt, so the queued run skips and the update is dropped silently.

Until a release carries both the FULLSEND_STEER_ACTIVE export and definitions that read it, the default changes nothing at the prompt level: the definitions treat the envelope's opening line as an injection attempt while the variable is unset. A run that started no watcher is not the silent drop above — with no watcher there are no consumed runs, and a receipt requires one, so such a run claims nothing.

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.