Skip to content

feat(dispatch): let a repository preserve the agent run already in flight - #7007

Open
waynesun09 wants to merge 8 commits into
mainfrom
preserve-runs
Open

feat(dispatch): let a repository preserve the agent run already in flight#7007
waynesun09 wants to merge 8 commits into
mainfrom
preserve-runs

Conversation

@waynesun09

@waynesun09 waynesun09 commented Sep 4, 2026

Copy link
Copy Markdown
Member

Summary

Every agent stage job cancels the run working on a work item as soon as a second event arrives for it. This lets a repository choose otherwise: the run in flight finishes, and the newer event waits as the single pending run GitHub Actions allows, which then works from the item's current state.

Default behaviour is unchanged. FULLSEND_PRESERVE_RUNS unset, which is everywhere today, still cancels.

Scope

In: the concurrency expression on the seven reusable-dispatch.yml stage jobs, and the two run facts an agent needs to tell what changed underneath it (FULLSEND_RUN_HEAD_SHA, FULLSEND_RUN_STARTED_AT), exported through bootstrapEnv after .env.d is sourced so a harness env file cannot replace them. Note they are not fully protected: reservedSandboxKeys guards env.sandbox only, and an unvalidated host_files destination can still overwrite the runner's env file. That gap is pre-existing and repo-wide — FULLSEND_ROLE and FULLSEND_SLUG sit above the same line — and is tracked separately in #7010 rather than fixed here.

Not in, deliberately: anything that talks to a running agent. No watcher, no receipt, no /fs-steer, no runtime changes. When nothing steers, the run queued behind simply does the work, so there is nothing to skip and no receipt to get wrong. That half is #6959, which is stacked on this branch and is not a prerequisite for this one.

The other half of the bargain is fullsend-ai/agents#1163: once the run in flight is preserved, it can outlive the state it was dispatched on, so the agent re-checks the work item once before writing its result. It skips that check when these variables are empty, so the two merge in either order.

Why

A restart is not only tokens. It pays sandbox provisioning and bootstrap before the model reads anything, then re-reads the whole item from cold. On #6513, six force-pushes produced five completed reviews of commits that were superseded within minutes, none of which led to a change.

Relation to ADR 0098 (#6909)

That ADR proposes preserve-and-coalesce as policy. It is open, and it and this change were written in parallel against the same feedback rather than against each other, so this is not offered as its implementation. If it is accepted, the natural follow-up is to drop the variable and make the behaviour unconditional.

Testing

  • make lint passes (pre-commit over the full range)
  • Tests added: the alignment test now asserts the exact expression every stage job carries, and runfacts_test.go covers the exports, UTC normalisation, quote escaping, the empty head on an issue run, and that GITHUB_SHA is never used as a fallback

Every commit builds and vets on its own.

Checklist

  • PR title follows Conventional Commits
  • Commits are signed off (DCO)
  • I wrote this contribution myself and can explain all changes in it

Refs #6957

Review round

A Codex gpt-5.6-sol pass (one reviewer, not the full squad) produced five findings, all confirmed and addressed:

  • Run facts were overrideable through .env.d; they now export below that line, beside the env.sandbox block that already sits there for the same reason. The general reserved-key gap is reservedSandboxKeys only guards env.sandbox, not .env.d or host_files dest #7010.
  • Opting in gives something up, and the PR now says so in three places. Preserving the run in flight removes the per-stage cancel-in-progress duplicate-dispatch mitigation that accepted ADR 0063 relies on, leaving the Jira lock and agent idempotency. The duplicate-poll case is where preserving is least defensible, because two duplicate dispatches are the same work rather than a newer state superseding an older one. All seven jobs stay consistent; an exemption for harness-run was considered and rejected, since a job quietly ignoring the variable is harder to reason about than the trade.
  • The alignment test pinned six of the seven jobs; it now pins all seven, and asserts scalar types so a literal boolean cannot silently become a quoted string, or the gate a bare boolean.
  • FULLSEND_PRESERVE_RUNS is documented for operators in the repo-variable table, including that the comparison is case-insensitive: TRUE and True preserve, while 1 and yes cancel.
  • The run start is captured before the setup work that precedes it. The honest baseline is the workflow run's server-side created_at rather than any host clock inside the process, which is noted in the code.

The export ordering is now load-bearing, so bootstrapEnv's script assembly was split into a testable function and pinned by a test, rather than being asserted only by a comment.

Rebased onto current main. functional-tests was red here for a reason outside this change: main allowlists **/claude.exe on the Vertex egress profile and this branch predated that, so OPA denied the agent binary and every triage eval case failed at zero cost. The rebase picks that fix up. The e2e red is separate and unrelated, a pool-repo race in TestAdminInstallUninstall (422 expected head sha didn't match current head ref).

@waynesun09

Copy link
Copy Markdown
Member Author

Stacked: this is the base. #6959 sits on top of it and carries the steering half. Reviewing this one alone is meaningful; reviewing #6959 without this one is not.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Optionally preserve in-flight agent stage runs

✨ Enhancement ⚙️ Configuration changes 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Lets repositories preserve active stage runs while retaining cancellation as the default.
• Exports run head and start time for detecting intervening work-item changes.
• Adds concurrency, run-fact, escaping, and forge-specific baseline coverage.
Diagram

graph TD
  A["Repository event"] --> B{"Preserve runs?"}
  B -- "No" --> C["Cancel active run"] --> D["Newest stage run"] --> G["CLI run baseline"] --> H["Agent sandbox"]
  B -- "Yes" --> E["Finish active run"] --> F["Pending stage run"] --> G
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Make preservation unconditional
  • ➕ Provides one consistent concurrency policy across repositories
  • ➕ Eliminates a long-lived configuration toggle
  • ➖ Changes existing behavior before ADR 0098 is accepted
  • ➖ Requires consumers to support state reconciliation safely
2. Add runtime steering
  • ➕ Could redirect active agents instead of waiting for a subsequent run
  • ➕ May reduce duplicated work after rapid events
  • ➖ Requires watcher, receipt, and runtime protocol changes
  • ➖ Introduces substantially more failure modes than queue coalescing
3. Retain cancel-and-restart behavior
  • ➕ Preserves established semantics without agent-side reconciliation
  • ➕ Always starts from the newest dispatch context
  • ➖ Discards completed work during event bursts
  • ➖ Repeats sandbox provisioning, bootstrap, and item analysis

Recommendation: The opt-in preserve-and-coalesce approach is best while policy and agent reconciliation support are still rolling out. It preserves compatibility, relies on GitHub Actions' existing single-pending-run semantics, and exports only the baseline facts agents need; preservation can become unconditional later if ADR 0098 is accepted.

Files changed (6) +187 / -23

Enhancement (1) +56 / -2
run.goExport immutable run baseline facts to sandboxes +56/-2

Export immutable run baseline facts to sandboxes

• Captures the run start time and resolves pull or merge request head SHAs for GitHub and GitLab. It exports both values safely into the sandbox and reserves their keys against harness overrides, deliberately excluding GITHUB_SHA as a fallback.

internal/cli/run.go

Tests (3) +106 / -11
run_test.goPass run facts through existing bootstrap tests +4/-4

Pass run facts through existing bootstrap tests

• Updates existing bootstrapEnv test calls for the new runFacts argument without changing their original assertions.

internal/cli/run_test.go

runfacts_test.goCover run baseline extraction and environment exports +85/-0

Cover run baseline extraction and environment exports

• Tests UTC normalization, empty issue heads, shell-quote escaping, GitHub and GitLab SHA sources, and exclusion of the base GITHUB_SHA. It also verifies that both exported keys remain reserved.

internal/cli/runfacts_test.go

workflow_call_alignment_test.goEnforce exact stage preservation expressions +17/-7

Enforce exact stage preservation expressions

• Changes concurrency decoding from bool to yaml.Node so tests can handle both expressions and literals. It asserts the exact preservation-gated expression on dispatch stages while retaining literal checks for reusable workflows, thin callers, and shims.

internal/scaffold/workflow_call_alignment_test.go

Documentation (1) +1 / -0
ci-workflows.mdDocument agent-stage concurrency policy +1/-0

Document agent-stage concurrency policy

• Documents the repository-level preservation option, its default behavior, and the requirement that every dispatch stage use the identical expression.

docs/contributing/ci-workflows.md

Other (1) +24 / -10
reusable-dispatch.ymlGate stage cancellation on repository preservation policy +24/-10

Gate stage cancellation on repository preservation policy

• Replaces hard-coded cancellation on all seven stage jobs with FULLSEND_PRESERVE_RUNS-based expressions. The default continues cancelling active runs, while an explicit true value preserves the active run and queues the newest event.

.github/workflows/reusable-dispatch.yml

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 4, 2026

Copy link
Copy Markdown

🤖 Review · ❌ Terminated · Started 2:21 PM UTC · Ended 3:03 PM UTC

Commit: c45f2ad · View workflow run →

@qodo-code-review

qodo-code-review Bot commented Sep 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Run timestamp starts too late ✓ Resolved 🐞 Bug ≡ Correctness
Description
FULLSEND_RUN_STARTED_AT is captured only after harness resolution, token minting, environment
expansion, and validation, so work-item activity during that setup is incorrectly classified as
predating the run. A preserved run can therefore miss an update that occurred after runAgent began
and write a stale result.
Code

internal/cli/run.go[R1125-1127]

+	// The instant this run started, exported into the sandbox so the agent
+	// can tell which activity on the work item postdates it.
+	runStartedAt := time.Now().UTC()
Relevance

●●● Strong

Recent run.go correctness findings about lifecycle boundaries and preserving run state are accepted.

PR-#1682
PR-#1780

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
runAgent begins at line 483 and performs environment loading and harness resolution from lines 496
onward; token minting and environment validation also occur at lines 865-920. The new timestamp is
not captured until lines 1125-1127, despite being documented and exported as the instant the run
started.

internal/cli/run.go[483-524]
internal/cli/run.go[865-920]
internal/cli/run.go[1125-1127]
internal/cli/run.go[1811-1812]

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

## Issue description
Capture `runStartedAt` at the beginning of `runAgent`, before setup work that may take significant time, so the exported baseline covers the complete run lifecycle.

## Issue Context
The current assignment occurs after harness resolution, token minting, environment expansion, and validation. Updates arriving during those operations can be excluded from the agent's final reconciliation window.

## Fix Focus Areas
- internal/cli/run.go[483-500]
- internal/cli/run.go[1125-1127]
- internal/cli/run.go[1811-1812]

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



Remediation recommended

2. Run facts remain shadowable ✓ Resolved 🐞 Bug ≡ Correctness
Description
bootstrapEnv emits the reserved run facts before sourcing .env.d/*.env, allowing any expanded
harness host_files environment file to overwrite them. The agent can consequently receive
fabricated or stale baselines despite these keys being reserved against env.sandbox shadowing.
Code

internal/cli/run.go[R2770-2772]

+	// Expose this run's baseline so the agent can re-check, once, whether the
+	// work item moved under it before it writes its result.
+	lines = append(lines, buildRunFactsEnvLines(facts)...)
Relevance

●●● Strong

The repository accepted protections against environment shadowing and explicitly recognized .env.d
precedence risks.

PR-#2582
PR-#5837

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The reservation check only filters env.sandbox keys in buildSandboxEnvLines. Run facts are
appended at lines 2770-2772, but .env.d files are sourced afterward at lines 2812-2813; those
files are populated from harness host_files, whose contents may contain assignments or exports.

internal/cli/run.go[2645-2705]
internal/cli/run.go[2770-2772]
internal/cli/run.go[2812-2818]
internal/cli/run.go[2838-2865]

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

## Issue description
Ensure runner-owned run facts take precedence over both `.env.d` files and `env.sandbox` values by exporting them after all user-configurable environment sources.

## Issue Context
The reserved-key validation applies only to `env.sandbox`. Expanded `host_files` are sourced later from `.env.d`, so they can currently redefine either run-fact variable.

## Fix Focus Areas
- internal/cli/run.go[2645-2669]
- internal/cli/run.go[2770-2772]
- internal/cli/run.go[2812-2818]
- internal/cli/run.go[2838-2865]

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


Grey Divider

Context sources
✅ Compliance rules (platform): 67 rules
Review mode: ⚖️ Balanced: This changes GitHub Actions concurrency behavior and runtime environment exports across multiple paths, creating genuine CI and agent-state risks, but the scope remains cohesive enough for one careful review.

Grey Divider

Tip of the day
💡 Did you know, you can route each action level your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread internal/cli/run.go Outdated
Comment thread internal/cli/run.go Outdated
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Site preview

Preview: https://9fc4712a-site.fullsend-ai.workers.dev

Commit: c6c05f81ffe309785dc02aea942d11159c62c477

@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@fullsend-ai-review

Copy link
Copy Markdown

🤖 Finished Review · ❌ Failure (validation failed after 2 iteration(s)) · Started 2:21 PM UTC · Completed 3:03 PM UTC

Commit: c45f2ad · View workflow run →

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

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 4, 2026

Copy link
Copy Markdown

🤖 Review · ⚠️ Cancelled · Started 3:15 PM UTC · Ended 3:28 PM UTC

Commit: e56312e · View workflow run →

@fullsend-ai-review

fullsend-ai-review Bot commented Sep 4, 2026

Copy link
Copy Markdown

🤖 Finished Review · ✅ Success · Started 3:30 PM UTC · Completed 3:52 PM UTC

Commit: 9085c4d · View workflow run →

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

@fullsend-ai-review

Copy link
Copy Markdown

Risk Assessment: elevated (3/5)

Details

PR modifies critical workflow infrastructure (reusable-dispatch.yml) and core CLI logic (run.go) with CI workflow changes and one protected path file. Elevated by active development on run.go and the behavioral nature of the concurrency toggle change.

@fullsend-ai-review

Copy link
Copy Markdown

Review

Findings

Medium

  • [protected-path] .github/workflows/reusable-dispatch.yml — This PR modifies a file under the .github/ protected path. The PR links to issue Steer the in-flight agent run on work-item updates instead of cancelling it #6957 and the description explains the rationale (making cancel-in-progress conditional on FULLSEND_PRESERVE_RUNS to let repositories preserve in-flight agent runs). Human approval is always required for protected-path changes, regardless of context.

Low

  • [naming-convention] internal/cli/run.gorunFacts struct and buildRunFactsEnvLines function follow the established Go naming conventions and the build*EnvLines pattern in the file. No change needed.
  • [api-shape] internal/cli/run.gobootstrapEnv signature correctly places the required runFacts parameter before the variadic fetchEnv. No change needed.

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

Comment thread internal/cli/run.go
@@ -2787,8 +2804,49 @@ func buildRoleSlugEnvLines(h *harness.Harness) []string {
return lines
}

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] naming-convention

runFacts struct and buildRunFactsEnvLines function follow the established Go naming conventions and the build*EnvLines pattern in the file. No change needed.

Comment thread internal/cli/run.go
// startedAt is when the run started, in UTC.
startedAt time.Time
}

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

bootstrapEnv signature correctly places the required runFacts parameter before the variadic fetchEnv. No change needed.

@fullsend-ai-review fullsend-ai-review Bot added the requires-manual-review Review requires human judgment label Sep 4, 2026
Every stage job cancels the run working on a work item as soon as a
second event arrives for it. The replacement pays sandbox provisioning
and bootstrap before the model reads anything, then re-reads the whole
item from cold, so a burst of pushes discards finished work and buys
nothing: on #6513 six force-pushes produced five completed reviews of
commits that were superseded within minutes.

FULLSEND_PRESERVE_RUNS lets a repository choose otherwise. Unset — the
default everywhere — is exactly today's behaviour. Set to "true", the
run in flight finishes and the newer event waits as the single pending
run, which then works from the item's current state.

The agent's side of that bargain, reconciling current state rather than
the state that dispatched it, is fullsend-ai/agents#1163, and is inert
until the run facts in the next commit reach the sandbox.

Refs #6957
Assisted-by: Claude

Signed-off-by: Wayne Sun <gsun@redhat.com>

The alignment test decoded cancel-in-progress as a Go bool, which an
expression string cannot unmarshal into, so it moves to a yaml.Node and
asserts the exact expression every stage job must carry.

Signed-off-by: Wayne Sun <gsun@redhat.com>
Once a repository preserves the run in flight, that run can outlive the
state it was dispatched on, so an agent has to be able to tell what
changed underneath it before it writes its result. Export the two facts
it needs: the work item's head at run start, and the instant the run
started.

They go through bootstrapEnv rather than env.sandbox or an env/*.env
file, because .env.d files are sourced afterwards and would expand ${VAR}
host-side to an empty string, and a ${VAR} in harness env.sandbox
hard-fails ValidateRunnerEnvWith for every consumer that does not define
it. Both are reserved so a harness cannot shadow them.

An issue run exports an empty head rather than omitting the variable: the
agent side skips its re-check on an empty value, and absence and emptiness
would otherwise be indistinguishable. The consumer is
fullsend-ai/agents#1163.

Refs #6957
Assisted-by: Claude

Signed-off-by: Wayne Sun <gsun@redhat.com>
The concurrency rules describe workflow-level choices; the stage jobs in
reusable-dispatch.yml now defer that one to the repository. Say so where a
contributor reads the rules, and say that the expression must stay
identical across stage jobs, since a mixed setting would let one role
cancel while another queues on the same work item.

Refs #6957
Assisted-by: Claude

Signed-off-by: Wayne Sun <gsun@redhat.com>
reservedSandboxKeys stops an env.sandbox entry shadowing
FULLSEND_RUN_HEAD_SHA and FULLSEND_RUN_STARTED_AT, but it says nothing about
.env.d, which bootstrapEnv sources after writing them — so a harness
host_files env file could overwrite either value and hand the agent a
fabricated baseline. Position in the generated script is what actually
protects them, which is why the ADR 0055 env.sandbox block already sits
after that line for the same reason. The run facts now sit beside it.

Since the ordering is behaviour rather than formatting, the line assembly
moves into buildEnvScriptLines so a test can pin it; bootstrapEnv keeps the
file write and upload, and host_files copying moves to uploadHostFiles. No
behaviour change beyond the ordering.

This does not close every route: a host_files entry whose dest is the
runner's own .env replaces the file wholesale, and the same exposure applies
to FULLSEND_ROLE and FULLSEND_SLUG, which are written four lines above.
That gap is repo-wide and predates this change, so it is tracked separately
rather than widened into this one.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
ADR 0063 names per-stage cancel-in-progress as the second of two layers
protecting poll dispatch from duplicate side effects. Setting
FULLSEND_PRESERVE_RUNS removes that layer, leaving the poller's own lock and
whatever idempotency the agent has — and the duplicate-poll case is where the
trade is least favourable, because two duplicate dispatches are the same work
rather than a newer state superseding an older one, so both run to completion
instead of one replacing the other.

Recorded where someone deciding will read it: the workflow's concurrency
comment, and a cross-reference note on ADR 0063 itself, which its rules allow
without rewriting an accepted decision. The default is unchanged, so that
ADR's assumption still holds wherever the variable is unset.

Deliberately documentation and not a code guard, and deliberately not an
exemption for harness-run: all seven stage jobs stay consistent, because a
job that quietly ignored the variable would be harder to reason about than
the trade itself. The repository owner makes this call; our part is to make
sure they make it knowing what they give up.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
The concurrency test iterated six built-in stages while the workflow has
seven jobs carrying the expression, so harness-run — the matrix fan-out —
was unpinned and a change to it would not have been caught. It is in the map
now, keyed by matrix identity rather than the event payload because a poller
supplies its work item, and the failure message says that a new stage job
has to be added there or its concurrency is unguarded.

Converting CancelInProgress from bool to yaml.Node also lost a property
nobody asked to give up: Node.Value is "true" for both a YAML boolean and
the quoted string "true", and only Tag separates them, so the three literal
sites would have accepted a string and the expression site would have
accepted a bare boolean. Each now asserts Tag alongside Value.

Both were checked against tampered fixtures rather than assumed: setting
harness-run back to a literal true fails on harness-run, and quoting a
reusable workflow's cancel-in-progress fails on the type.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
The variable was described only in the contributor CI guide. A repository
administrator deciding whether to set it reads the operations guide, which
already carries the repo-variable table it belongs in.

The value semantics are worth stating rather than leaving to be discovered:
GitHub compares strings case-insensitively, so TRUE and True also preserve,
while any other value — including 1 and yes, which someone setting a boolean
flag might reasonably try — cancels. The row points at the CI guide for what
preserving gives up.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
runStartedAt was taken after harness resolution, base composition, token
minting and env expansion — a mint call retries over the network — so
activity on the work item during setup was exported to the agent as
predating the run, and a preserved run could miss an update it was supposed
to reconcile against.

Moving the capture to the top of runAgent shrinks that window to nothing
within this process, which is the whole fix available here. It is not the
honest baseline and the comment says so: the run was dispatched before the
process started, so the true start is the workflow run's server-side
created_at, which no host clock can reach and which costs an API call to
read. That is deliberately not added here, but it is recorded so the two
halves do not end up disagreeing about what "run start" means — the
follow-up run watcher reached the same conclusion and already uses the run
record's created_at rather than its own clock.

Assisted-by: Claude
Signed-off-by: Wayne Sun <gsun@redhat.com>
@fullsend-ai-review

Copy link
Copy Markdown

🤖 Review · Started 7:17 PM UTC

Commit: c6c05f8 · View workflow run →

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

Labels

requires-manual-review Review requires human judgment risk/elevated PR risk: elevated

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant