Skip to content

Latest commit

 

History

History
418 lines (304 loc) · 70.5 KB

Execution Lifecycle.md

File metadata and controls

418 lines (304 loc) · 70.5 KB
type architecture
last-updated 2026-11-02
updated-by f8ee76a3
sources
src/harness/phases/execution-backend.ts
src/harness/phases/execute-v2.ts
src/harness/phases/release-v2.ts
src/harness/phases/observed-turn-v2.ts
src/harness/phases/v2-server.ts
src/harness/phases/v1-import-wait.ts
src/features/agent/execution-v2.ts
src/features/agent/prompt-sender-v2.ts
src/features/agent/retry-v2.ts
src/features/agent/drain-v2.ts
src/features/agent/streaming-v2.ts
src/harness/config/state-keys.ts
src/harness/config/outputs.ts
src/harness/outcome.ts
src/shared/cache-save-result.ts
src/features/observability/job-summary.ts
src/features/agent/attempt-outcome.ts
src/features/agent/execution.ts
packages/runtime/src/agent/ownership-ledger.ts
packages/runtime/src/agent/ledger-reconcile.ts
packages/runtime/src/agent/error-format/format.ts
packages/runtime/src/agent/error-format/types.ts
src/shared/summary-html.ts
action.yaml
src/harness/run.ts
src/harness/phases/bootstrap.ts
src/harness/phases/routing.ts
src/harness/phases/acquire-lock.ts
src/harness/phases/acknowledge.ts
src/harness/phases/cache-restore.ts
src/harness/phases/session-prep.ts
src/harness/phases/execute.ts
src/features/agent/retry.ts
src/features/agent/session-poll.ts
src/features/agent/streaming.ts
src/features/agent/prompt-sender.ts
src/features/agent/response-file.ts
src/harness/phases/finalize.ts
src/harness/phases/cleanup.ts
src/harness/phases/dedup.ts
src/harness/phases/review-reconciliation.ts
src/harness/post.ts
src/features/triggers/router.ts
src/features/triggers/skip-conditions-pr.ts
src/features/agent/output-mode.ts
src/features/agent/response-post.ts
src/features/delegated/brokered-push.ts
src/features/delegated/brokered-push-gate.ts
src/features/delegated/brokered-push-validation.ts
src/features/delegated/reconstruct-changes.ts
src/features/reviews/review-reconciliation.ts
src/features/reviews/review-guards.ts
src/services/github/context.ts
packages/runtime/src/agent/response-delivery.ts
packages/runtime/src/agent/response-file.ts
packages/runtime/src/coordination/lock.ts
packages/runtime/src/coordination/types.ts
packages/runtime/src/coordination/lease-loop.ts
packages/runtime/src/coordination/lock-adoption.ts
packages/gateway/src/execute/checkout-recovery.ts
packages/gateway/src/execute/recovery.ts
packages/gateway/src/execute/stranded-run-sweep.ts
packages/gateway/src/execute/live-run-registry.ts
packages/gateway/src/execute/run-core.ts
packages/gateway/src/execute/run.ts
RFCs/RFC-005-GitHub-Triggers-Events.md
RFCs/RFC-010-Delegated-Work.md
RFCs/RFC-012-Agent-Execution-Main-Action.md
RFCs/RFC-017-Post-Action-Cache-Hook.md
RFCs/RFC-019-S3-Storage-Backend.md
ARCHITECTURE.md
src/services/cache/types.ts
summary Action phases across OpenCode V1 and V2, from bootstrap and prompt submission through verified release and persistence

Execution Lifecycle

Every Fro Bot run follows the same phase sequence, orchestrated by src/harness/run.ts. This page builds on the [[Architecture Overview]] — specifically the harness layer — and walks through each phase in execution order. Each phase is a standalone module under src/harness/phases/, a deliberate design that keeps the orchestrator thin and each phase independently testable.

Phase Sequence

The phase order is shared by both generations. The resolved executable version selects the server, session reader, execution backend, and release path together; it is not independently inferred at each call. The Action defaults to the recorded stock V2 pin and its -v2.ts implementations. Explicit V1 pins retain the compatibility path.

main.ts
  └─ run()
       ├─  1. Bootstrap
       ├─  2. Routing
       ├─  3. Deduplication
       ├─  4. Acquire Lock
       ├─  5. Acknowledge
       ├─  6. Cache Restore
       ├─  7. Session Prep
       ├─  8. Execute
       ├─  9. Drain
       ├─ 10. Review Reconciliation
       ├─ 11. Finalize
       └─ 12. Cleanup (always, via finally)

post.ts
  └─ runPost()
       └─ Durable Cache Save

1. Bootstrap

Parses action inputs (parseActionInputs), validates credentials, and ensures the OpenCode CLI is available. If the tools aren't already cached, the setup module installs OpenCode and the Systematic plugin (see [[Setup and Configuration]]). On failure, the run exits immediately with code 1.

Bootstrap also resolves the run's response-delivery decision — a two-axis object (resolveResponseDelivery() in packages/runtime/src/agent/response-delivery.ts) that decides both how the agent's answer reaches GitHub and whether the agent is given a GitHub credential at all. Comment and review triggers (issue_comment, pull_request, issues) resolve to file-convention delivery with the credential withheld; schedule and workflow_dispatch resolve to model-gh delivery with the credential provisioned, because those flows legitimately create branches, commits, and PRs on their own. The decision is computed once here from the raw event name, threaded through setup so the credential is (or is not) provisioned, and re-asserted against the routing classification so a mismatch fails loudly rather than silently mis-provisioning. See [[Setup and Configuration]] for how the credential is withheld and [[Prompt Architecture]] for how the delivery mode reshapes the response protocol the agent is told to follow.

2. Routing

This is where the incoming GitHub webhook event gets classified and the run decides what to do.

First, parseGitHubContext() reads the raw Actions context and calls normalizeEvent() to produce a NormalizedEvent — a discriminated union with eight variants (one per supported event type plus unsupported). This normalization layer is the project's central abstraction: no downstream code ever touches raw webhook payloads.

Then routeEvent() applies skip conditions to decide whether to proceed. Skip conditions include: action not supported (e.g., a labeled event), draft PR, locked issue, bot responding to itself, unauthorized author (not OWNER, MEMBER, or COLLABORATOR), missing prompt for schedule/dispatch events, and PR review not requested from the bot. If any condition matches, the run exits cleanly with code 0, emits a core.notice naming the skip reason, and records it in the job summary; it posts no comment or reaction. See [[Troubleshooting]] ("Review Access and the Review-Request Path") for admitting a skipped pull request.

Pull-request events also carry an opt-out label check (src/features/triggers/skip-conditions-pr.ts). When a PR carries the configured review-skip-label (default skip-agent-review, see [[Setup and Configuration]]), the automatic review is suppressed. The suppression is deliberately overridable so it never silences a directed request: a @fro-bot mention in the PR body overrides the label on opened, synchronize, reopened, and edited actions (where the PR author controls the body), and an explicit review request naming the bot overrides it on review_requested. ready_for_review has no override — its trigger is not author-controlled — so the label always wins there. This keeps the label a passive default-off switch rather than a hard gate an authorized human cannot bypass.

3. Deduplication

A lightweight guard against duplicate runs for the same entity within a configurable window (default: 10 minutes). Uses cache-based sentinel markers — not an in-flight lock. This is best-effort suppression; workflow-level concurrency groups provide the stronger guarantee.

4. Acquire Lock

When the S3 object store is enabled, the harness acquires a per-repo Action coordination lock (src/harness/phases/acquire-lock.ts, locks/action.json) so that two Action runs cannot execute concurrently against the same repository's shared S3 session object. The Discord gateway and [[Operator Web Control Surface|operator web surface]] runs lock locks/repo.json instead (they share one checkout) and exclude each other, not the Action; the two lock families are independent, and cross-surface GitHub writes rely on head checks, non-force pushes, and review-delivery receipts. The lock is an S3 object (JSON LockRecord) written with conditional-put semantics (If-None-Match: * for initial acquisition). If S3 is configured and acquisition errors, the Action fails closed (exit 1, invocation-outcome=failed); if another Action holds the lock, the run exits 0 as skipped with a coordination job summary, a warning annotation, and an agent: blocked label on issue/PR targets (skipped under response-mode: none; never a comment or reaction). The decline removes and re-adds the label so every skip emits a fresh labeled event, and a later run for the same item that ends succeeded removes it again (src/harness/phases/coordination-clear.ts) — but only if the label's latest labeled event is strictly earlier than that run's own agent: working labeled event, which acknowledge stamps at run start (both read from the issue events, on GitHub's clock; no extra token permission is needed). A missing working event, a tie, or any API failure keeps the label; an agent: working label left behind by a crashed run yields an older anchor, which can only keep labels, never wrongly clear one. GitHub has no conditional label removal, so a decline landing between that check and the remove (or between run start and acknowledge) could be cleared; the per-target concurrency group prevents same-target overlap. Neither of the declined or errored runs persists session state.

The lock config carries two staleness thresholds. The ordinary stale threshold governs takeover of a lock held by a running surface that stopped heartbeating, while a separate pending stale threshold (pendingStaleThresholdMs) governs runs that were admitted but never progressed. This second threshold exists because the gateway now records queued and failed runs through the same admission path, so a run that is reserved but stuck must eventually become reclaimable without waiting out the full running-lease TTL. Both the acquire and cleanup phases pass this threshold so the two lock holders agree on when a pending lock may be taken over.

The lock result is a discriminated union with four outcomes:

  • acquired — This run holds the lock. The returned lockEtag is carried through to the cleanup phase for release.
  • held-by-other — The lock is held, or it has expired but was held by a non-Action holder (an expired non-Action lease is not reclaimed without corroboration, which the Action cannot supply). The current run exits cleanly with code 0. An expired lease held by another Action run is reclaimed.
  • s3-disabled — S3 is not configured. Coordination is opt-in, so the run proceeds without a lock.
  • error — Lock acquisition failed or threw (store, config, network) while S3 is configured. The run fails closed: exit 1, invocation-outcome=failed, no setup or execution, no session persistence. An orphaned Action lock is freed by the post hook when it can, reclaimed by a later attempt of the same run and job once it has gone unrenewed for about 90 seconds, and otherwise reclaimed by any later run once the 15-minute TTL lapses.

The shared coordination layer (packages/runtime/src/coordination/types.ts) also names a run's lifecycle phases as a closed union — PENDING, ACKNOWLEDGED, EXECUTING, and the three terminal phases COMPLETED, FAILED, and CANCELLED (the last modeled as a TerminalPhase type so that gateway cancellation and the operator cancel route agree on one closed set instead of hand-writing the same literals). The Action harness itself does not expose these phases directly, but they are the vocabulary the [[Operator Web Control Surface]] and Discord gateway use for gateway runs, which share the gateway's per-repo repo.json lock (the Action locks action.json).

The lock has a 15-minute TTL. A cancelled or killed main step can leave it held, so two recovery paths exist besides expiry. The holder id stays action:<runId>:<attempt> — older agent versions recognize an Action lease only by that exact shape, so a different shape would wedge the repo after a version rollback — and the job identity (job plus matrix leg, as in the cache key) is stored in the record's optional holder_job field, so sibling jobs of one run are told apart. A malformed holder_job is dropped on read, making the record look like a legacy one. Holder fields can repeat — two calls of one reusable workflow share run, attempt, job and matrix — so each acquisition also stores a random acquisition_token in the record (dropped on read if malformed). The main step saves its holder id, job and token (lockHolderId, lockHolderJob, lockAcquisitionToken) right after acquisition — if that write fails it releases the lock (bounded by a 10-second deadline) and fails closed — and clears them after a successful release. post.ts then deletes the lock, fail-soft, only while the stored holder_id, holder_job and acquisition_token all match the saved values (read, then delete with the ETag). The release has a 10-second deadline that aborts its requests and body read, and no delete starts after the abort. The lock is not renewed in the post step, so this frees a lock the run still owns rather than protecting the post-step writes. Separately, acquisition replaces an unexpired lease only when it is held by a strictly earlier attempt of the same run and the same job (matching holder_job) and has gone three heartbeat intervals (~90 seconds) without renewal, judged by the record's acquired_at (rewritten on every renewal); a lost runner is reclaimed after about 90 seconds instead of 15 minutes. The takeover uses the ETag it read, so a racing renewal wins, and it is audited as lock-takeover-outcome with confirmationSource: holder-superseded. Everything else — a renewing holder, a record without holder_job, another job, another run, equal or later attempts, malformed holder ids, non-Action surfaces — waits for the 15-minute TTL. runAcquireLock() now pairs an acquired outcome with a LeaseController (src/harness/phases/acquire-lock.ts:239-242) that starts a renewal timer and ticks on an interval for as long as the lock is held (:150-166), rather than relying on the TTL alone to outlive the run — the original v1 no-heartbeat design sized the TTL for the median ~2-minute Action run, and the drain phase (§9 below, and [[Background Subagents and Ownership]]) can push a run's protected interval well past that (:201-205). Each successful renewal captures the lock record's new etag, and cleanup releases with that renewed etag rather than the acquisition etag — a stale etag fails the conditional delete and leaks the lock for the next surface — while stop() clears the interval and waits up to a five-second grace period for any in-flight tick to settle (:50-73, :159-165) — well under the renewal call's own ten-second timeout, so stop() can return with a renewal still unresolved and currentEtag() stale. Release then reads that stale etag. Whether the conditional delete succeeds depends on whether the unresolved renewal goes on to succeed and advance the record's etag before release runs: if it does, release's precondition fails against the newer etag, which is safe — it declines to delete a lock this run may no longer hold — and leaves the lock held until its TTL expires rather than freed for the next surface. If the renewal instead fails or times out without changing the record, the retained etag can still match, and release can succeed normally. A failed renewal fails persistence closed: the run declines to checkpoint and save session state rather than write over what may be a lost lease. It does not fail the invocation itself — the Action still finalizes, publishes, and reports its result on a renewal failure; only the cache save is what's withheld.

5. Acknowledge

Posts visual feedback so the user knows the agent received their request. For comment-triggered events, this means adding an eyes (👀) reaction to the triggering comment and applying an agent: working label to the issue or PR. These are non-fatal — if the GitHub API call fails, execution continues.

6. Cache Restore

Restores the OpenCode storage directory from S3 backup first when configured, then falls back to GitHub Actions cache. The cache key is scoped by repository, branch, and OS to prevent cross-branch contamination. After restore, the module checks for corruption (unreadable directory, version mismatch) and falls back to clean state if needed. Credentials (auth.json) that may have leaked into cache from a prior run are deleted as a security measure.

This phase also bootstraps the OpenCode SDK server and establishes a client connection — the server handle is reused throughout the remaining phases. Bootstrap does not report success on the SDK's own signal alone (a stdout string match satisfied once the HTTP listener binds, before this instance's lazy config/plugin/service init has run); it issues one bounded, instance-scoped session.list probe first, so a blocked instance bootstrap fails here, by name, instead of surfacing later as an unexplained request failure.

That readiness mechanism is V1-specific. V2 uses src/harness/phases/v2-server.ts: prepare owner-only provider-key files, remove stale auth.json, ensure both required plugins are installed, assemble the ordered permission rules, clear held execution claims from restored state, then spawn and verify plugins, effective permissions, and provider authentication. Finally it waits for the V1 history-import endpoint to report completed. A failed gate shuts the server down and disables both main-step and post-hook session saves, avoiding persistence of half-imported or half-gated state. See [[Setup and Configuration]] and [[Session Persistence]].

7. Session Prep

Processes any file attachments from the triggering context, searches prior sessions for relevant context (see [[Session Persistence]]), and builds the agent prompt (see [[Prompt Architecture]]). The prompt is a multi-section XML-tagged document that includes environment metadata, issue/PR context, session history, the task directive, and response protocol rules.

V2 reads context through the native /api/session and session-context routes (src/harness/phases/session-v2.ts), using the same V2 mappers as the plugin's session tools. Logical-key continuation matches the newest eligible root session title; a failed listing is an error rather than evidence that no prior session exists.

8. Execute

V2 submission and observation

execute-v2.ts selects a backend whose session creation, prompt submission, overflow recovery, and prior-work search all use V2. Agent and model are session properties, while a prompt carries text and files. The event subscription must connect before the first send because the stream has no replay.

V2 does not replay a failed admitted turn with the V1 continuation loop. The server handles transient provider retries. The Action retries eligible pre-send connection failures or explicit submission refusals; a transport failure of the prompt POST is instead sendUncertain. The request may already have been admitted, so resending could duplicate external effects. That case fails, stops the directory, and declines persistence and endorsement even if teardown later looks quiet.

The V2 consumer rejects permission requests and cancels forms in CI. Native events are normalized into root freshness, subagent and shell ownership, usage, and artifact observations. Successful execution leaves its observer running through release, since a queued notice may start a later root turn whose output or failure belongs to this same invocation.

V1 execution and continuation

The SDK-specific details and continuation-prompt policy below describe the retained V1 path.

The core phase. Calls executeOpenCode() which creates (or continues) an SDK session, sends the assembled prompt, and streams events back in real time. The SDK lifecycle follows the pattern: spawn server, connect client, create session, send prompt, process event stream, close.

If a turn fails with a retryable error, the system retries up to three times with a continuation prompt. A configurable timeout (default: 30 minutes) bounds execution if the agent runs too long — but that bound is an internal execution deadline, not the whole GitHub Actions job timeout. The distinction matters: the deadline aborts only the agent's own work so that Finalize and Cleanup still get a bounded budget to preserve outcomes and persist state (action.yaml documents this boundary, and the job's own timeout-minutes remains the outer backstop). A run that hits the deadline is reported as a genuine terminal failure rather than a silent hang, and a pre-deadline terminal result is kept distinct from the bounded post-result teardown that follows it, so cleanup can never overwrite the real outcome.

A permission ask raised mid-run no longer blocks until that deadline. OpenCode can emit an interactive permission request during a turn — a shell command, an out-of-tree file write — and in CI there is no operator to answer it, so an unanswered ask would previously pin the run open until the execution deadline elapsed. The streaming consumer (src/features/agent/streaming.ts) denies and logs any such ask as it arrives, and the two native ask defaults that are actually reachable in CI are denied at config time (see [[Setup and Configuration]]).

Two properties of that branch are load-bearing, and both were learned the hard way. First, the ask is answered regardless of which session raised it — the branch checks only that the event names a session at all, and targets the reply at that session rather than the root. Routing asks through the same ownership filter used for activity events meant a background subagent's ask was silently skipped, so the child blocked forever, the root blocked on the child, and the run died at its CI job cap having delivered nothing observable. Second, the reply is not awaited inside the stream loop: awaiting it reproduced the same stall one layer up, where a single slow reply holds every subsequent event behind it. A failed reply is logged and execution continues, because throwing would abort the stream for every other in-flight ask and event. This is safe on the Action specifically because the harness starts its own loopback OpenCode server per run and wires an unconditional-reject responder to it, so every well-formed ask on that subscription belongs to this run's own process tree. The gateway deliberately keeps its ownership check on the same branch — it has a real human-answerable approval path, where answering everything would leak one run's approval into another run's thread. See [[Background Subagents and Ownership]].

Each attempt now resolves to a classified outcome rather than a bare "retry or not" boolean (src/features/agent/retry.ts). The outcome distinguishes a submission that never reached the server (submit_failed) from a turn that actually ran and then failed, and among failed turns it separates retryable from terminal. The retry decision is derived from that outcome — only a retryable turn failure retries — which keeps the classification, not a side-effect flag, as the authoritative signal. The prompt is never sent into an unconnected stream: the SDK's event stream is lazy (no request until the first read) and OpenCode replays nothing to a late connection, so a new prompt waits for the stream's first frame, server.connected (the server registers its listener before it emits it), for at most 10 s or the time the run has left. A stream that ends or stays silent before then fails the attempt as submit_failed with nothing sent. One subtlety this guards against: the event stream is connected before the prompt is submitted, so if submission returns a transport error while the server has already accepted the prompt and begun working, the attempt is reclassified from submit_failed to a turn failure rather than resending the original prompt into a session that is already running it.

Not every provider error is retryable. Classification prefers structured SDK evidence, then HTTP status, and finally a prose pattern when no stable marker is available (packages/runtime/src/agent/error-format/format.ts, with the error vocabulary in types.ts). A provider's isRetryable flag supports retrying an otherwise unclassified API failure, but known terminal classifications take precedence. Quota exhaustion (quota_exceeded) and rejected provider authentication (provider_auth_error) fail fast instead of consuming attempts on an unchanged limit or credential. The activity tracker latches the first terminal signal because provider retry activity, or a later wait() resolution, must not turn an observed failure into success. Polling also reconciles terminal evidence the event stream missed. Unresolvable-model failures follow the same fail-fast policy, described below.

Completion evidence itself is scoped to a generation rather than read as a sticky flag. ActivityTracker.rootFreshness (src/features/agent/streaming.ts) carries a monotonic revision that advances only on renewed root-session activity, and an idle observation is stamped with the revision it was seen at, so evidence from a superseded generation cannot authorize finishing a later turn. This matters concretely because a background subagent's completion injects another parent turn onto the root session, and upstream persists that user message before the runner that will answer it starts running — a plain sticky idle-received flag read during that window could complete a turn that is still in flight. A completion timestamp is not a success certificate either: upstream stamps time.completed during processor cleanup, including failed and intermediate processing, so a qualified candidate additionally needs a finish reason present and not tool-calls/unknown, correlation to the latest root user message once one has been observed (a no-op in the common single-turn case, since latestRootUserMessageId is positively known only after a new root user turn — such as an injected background-task-completion turn — has been seen, and the pending-parent check is the operative guard until then), no non-provider-executed tool part left unless cleanup marked it an interrupted orphan, two consecutive observations, session.status() corroborating inactivity, and a drained ownership ledger. The governing rule underneath all of it is that a provisional observation may record evidence or tighten a guard, but it may never release a guard or authorize completion on its own — barrier resolution and revalidation clearing are committed only at final admission, synchronously with the settlement it protects. The accepted consequence is that a run whose evidence is genuinely unavailable now reaches the watchdog or the deadline rather than reporting an unproven success.

The continuation prompt sent on a retry is no longer a fixed string that asserted a "network error (fetch failed)" cause regardless of what actually happened. It is built from the observed error type (src/features/agent/prompt-sender.ts), so a rate-limit retry says rate limit and a timeout says timeout, and it asks the model to continue the remaining objective rather than to "resume" — a distinction that matters because a credential-provisioned run is additionally told to verify what has already landed (external changes and response artifacts) before acting, so it does not repeat a side effect that already succeeded. Recovery also treats the response file by status rather than mere existence (src/features/agent/response-file.ts): an agent that created the file and then overflowed before writing a body leaves it empty, so the harness inspects the file and proceeds with recovery only when the deliverable is genuinely absent, declining to recover — rather than silently posting nothing — when the file exists but cannot be parsed. Recovery also probes where the write actually landed. An earlier version searched a fallback path (_temp/fro-bot-response/<runId>-<attempt>/<nonce>.md) the write never used, so a stray file went unrecovered. The deny rule that keeps the model out of that fallback directory and the recovery probe that scans it are now derived from a single shared definition (packages/runtime/src/agent/response-file.ts), so the guard and the recovery can no longer drift apart and point at different paths.

When a terminal failure reaches the user, its diagnostics are handled carefully: provider-controlled error text is kept out of the trusted failure summary the action delivers, so a hostile or noisy provider message cannot smuggle content into the run's authoritative outcome. The structured session error is preserved through teardown rather than being swallowed by the cleanup path, which is what lets a failed run deliver an accurate, trustworthy failure notice instead of a generic one.

An unresolvable model is also terminal. OpenCode reports this as a generic UnknownError, so classifyModelNotFoundError (packages/runtime/src/agent/error-format/format.ts) recognizes its model-not-found message and extracts only the model identifier and up to five validated suggestion identifiers. The Action records model_not_found as a latched terminal signal and stops the turn without waiting for the inactivity watchdog or retrying the same configuration. The job summary carries the classifier's suggested action, distinguishing an incorrect model ID or an unavailable model catalog from a transient transport failure. See [[Troubleshooting]].

Usage accounting is per assistant message, not a running total supplied by OpenCode. The stream consumer retains the latest snapshot for each owned session/message pair and sums those snapshots, so repeated updates replace one message's contribution while later root turns and adopted descendants add their own tokens and cost. Final message retrieval uses the same additive interpretation rather than overwriting streamed totals with the last root reply.

Reasoning and response text share the SDK's text-delta shape. The Action records reasoning-part IDs from message.part.updated and excludes their later deltas from response text, matching the gateway. A delta arriving before its part is registered remains indistinguishable and is appended; neither surface infers a part's kind from its content.

9. Drain

V2 directory release

runReleaseV2 (src/harness/phases/release-v2.ts) occupies this phase on V2. A successful execution drains within the remaining budget; a failed run or expired drain stops directory activity. Release requires eleven quiet readings spanning at least five seconds, covering active sessions, running shells, and queued inbox items. A settled ledger or an interrupt receipt alone cannot authorize it. [[Background Subagents and Ownership]] explains why background completion can wake a parent after its earlier idle event.

The observer remains open until the directory decision. Later root turns are merged into success/failure, artifacts, and usage before Finalize. The run summary is written only after that observation ends, followed by pruning while the server is still available. Shutdown then confirms the child process exited. Thus V2 has already shut down before review reconciliation and response delivery, and Cleanup handles persistence rather than repeating that lifecycle.

An exceptional exit before this phase still runs a stop-and-release attempt from run.ts's outer finally. An unconfirmed stop or server exit declines saving; an expired successful-run drain remains incomplete even if cancellation subsequently confirms quiet, because work was cut short. A failed overflow stop also prevents starting a replacement session in the same checkout.

V1 ledger drain

Execute can finish while work it started is still running. OpenCode's background subagent dispatch returns immediately and leaves the child running as a detached fiber, and the parent session reports idle without consulting it — so the root's turn ending is no longer the same event as the run's work ending. The drain phase (runDrain, src/harness/phases/execute.ts) exists to reconcile those two.

Its position in the sequence is the design. Drain sits after Execute returns and strictly before Review Reconciliation, Finalize, and Cleanup, because everything below it either publishes a result, persists state, or releases the coordination lock — and each of those is wrong to do on top of a live writer. Folding drain into Cleanup would have been simpler and would have published a response describing work still changing underneath it.

Drain reconciles the run's ownership ledger once unconditionally, then returns immediately if it is drained; an empty ledger makes no remote calls. Otherwise it polls until the ledger drains or its deadline expires, at which point every entry not confirmed settled is individually aborted and then re-checked, and anything still unconfirmed is recorded as unknown rather than assumed finished. The drain budget is carved out of the same wall-clock budget as execution — the total timeout minus a 30-second teardown reserve minus what execution already spent — so one deadline covers both rather than letting drain extend a finite run indefinitely.

With timeout: 0, drain has no Action deadline and keeps waiting while reconciliation observes owned work running. Three consecutive passes observing no tracked live child while ownership remains unresolved end that wait and enter cancellation, so an unreachable server cannot prevent cleanup forever. Every drain reconciliation pass and child abort is bounded to five seconds with an aborted signal; an abandoned pass cannot later mutate the ledger. [[Background Subagents and Ownership]] details the distinction between observed liveness and an unresolved ledger entry.

When there is no ledger, or when execution never ran, the phase is a complete no-op. [[Background Subagents and Ownership]] covers the ledger's states, the reconciliation rules, and the parallel design on the gateway.

10. Review Reconciliation

Runs after Execute, before Finalize, only for pull_request review triggers on the model-gh posting path (workflow_dispatch/schedule). Calls decideReconciliation() in src/features/reviews/review-reconciliation.ts to inspect the agent's posted review body for a verdict signal. If the verdict warrants a formal GitHub APPROVE, the phase submits one automatically via the GitHub review API through the shared review guards (fork/self/head-SHA/TOCTOU). This removes the manual step of having the agent issue the gh pr review --approve command itself on approve-verdicts.

For comment/review flows that post through the file convention (see Finalize), this phase skips immediately — the model posts no review of its own for reconciliation to reconcile, and Finalize owns the review post instead. The fork/self/head-SHA/TOCTOU guard set both paths share has been extracted into src/features/reviews/review-guards.ts, so a file-driven approve on a fork PR is blocked exactly as a reconciled one would be.

The phase is fail-safe: any error logs a warning and no-ops. It never throws and never fails the run. It also checks the bot login before acting — an empty or null botLogin triggers an immediate no-op.

11. Finalize

Collects metrics and sets action outputs (session ID, cache status, duration), and owns response delivery. On V1 the preceding execution path writes a synthetic summary and Finalize prunes sessions. V2 has already written its deferred summary and pruned during release, before shutting down the server, so these operations are not repeated here.

The Actions job summary is HTML, while the comment footer is Markdown. src/shared/summary-html.ts builds escaped links, emphasis, code spans, and paragraphs for job-summary.ts, because GitHub does not interpret Markdown inside the toolkit's HTML tables and lists. Dynamic diagnostic text is escaped once, and remediation paragraphs stay outside the tables, preserving readable links and line breaks without treating error text as markup.

For pull_request/issue_comment/issues triggers, this phase also posts the agent's response on the model's behalf — the file-convention delivery path. The model wrote its response to a run-scoped file (outside the checkout, under RUNNER_TEMP, nonce-named and created by the action); Finalize reads it, binds the target and surface to the trusted NormalizedEvent (never the file), validates it against a strict allowlist schema, and posts via the Octokit comment/review writers (src/features/agent/response-post.ts) — a PR-review verdict goes through the same shared review guards (src/features/reviews/review-guards.ts) as reconciliation. This path is fail-closed: a missing, malformed, or undeliverable response fails the run (naming the file), and a delivery assertion prevents a green-but-silent run. The dedup marker for these flows is written only after delivery is confirmed, so a failed post followed by a retry cannot dedup-skip into a silent success.

This design exists for a security reason, not just tidiness. Because the model no longer posts its own comment or review, it no longer needs a GitHub credential for those flows — so the credential is withheld from the OpenCode child entirely (see [[Setup and Configuration]]). The response file is treated as untrusted payload: it contributes only body content (and, for a review, an approve/request-changes verdict) within an envelope whose target, surface, and eligibility come from the trusted event. A fork PR cannot preseed a workspace file to redirect a post or forge an approval. workflow_dispatch/schedule runs keep model-gh delivery — they post via the model's own gh and are unchanged.

Brokered push

Finalize also hosts an optional brokered push step for the file-convention path (RFC-010, src/features/delegated/brokered-push.ts). The credential-withholding design above means a comment-triggered agent cannot commit for itself — it has no gh token. Brokered push closes that gap for the narrow, trusted case of a same-repository pull-request comment: after a successful run that produced workspace edits but posted no comment of its own, the harness reconstructs those edits and commits them to the PR head branch on the model's behalf, using the action's own Octokit client rather than any credential the model can reach. This lets an authorized @fro-bot fix request land as a real commit while still keeping the token off the agent process.

The step is a fail-closed state machine where every stage re-derives trust from event-time facts rather than the model's output. An early gate (brokered-push-gate.ts) admits only issue_comment events on a same-repo pull request from an OWNER, MEMBER, or COLLABORATOR, and only when a trusted head SHA was captured before execution (see [[Setup and Configuration]] for the trusted-head-sha input that anchors this). A live permission re-check then confirms the triggering actor still has write or admin on the repository, failing closed on any lookup error so a transient outage suppresses the push rather than allowing it. Reconstruction (reconstruct-changes.ts) diffs the workspace against that trusted SHA — never local HEAD, branches, or remotes — enumerates untracked files, and reads each changed file through an O_NOFOLLOW handle to close a symlink swap race. A path allowlist (brokered-push-validation.ts) restricts delivery to src/, package src/, docs/, and a short set of top-level docs (README.md, ARCHITECTURE.md, STRUCTURE.md), capped at 100 files, so config, scripts, and CI files can never be pushed. A final pre-write gate re-resolves the live PR immediately before the Git Data API write, rejecting the push if the PR closed, the base or head repository changed, the head branch was renamed, or the head SHA moved.

The whole step runs under a 120-second wall-clock ceiling enforced by a Promise.race. The AbortSignal cancels the Octokit calls promptly, but the reconstruction git subprocess cannot observe an abort signal, so the race is the hard bound for a stalled subprocess; the abandoned promise never leaks because the process exits once run() resolves. Delivery is deliberately non-atomic: the commit lands before the response comment is posted, and a timeout firing after updateRef already succeeded reports failure for a commit that may exist. This self-heals — a re-run reconstructs the now-updated branch to nothing-to-deliver rather than double-pushing. On success, Finalize appends a "Brokered push delivered" footer (branch, changed paths, commit SHA) to the delivered comment; on failure it fails the run with a generic error and posts no push.

12. Cleanup (Always)

Runs in a finally block regardless of success or failure. Completes the acknowledgment state machine (replaces 👀 with 🎉 on success or 😕 on failure, removes the agent: working label). Cleans up file attachments. Prunes old sessions. Shuts down the OpenCode server. Shutdown does not itself checkpoint SQLite — it sends the child's kill signal and then waits, bounded and best-effort, for the child to stop answering, so the checkpoint that follows is not racing a writer that is still alive but idle. Merging the write-ahead log into the main database file is checkpointDatabase's job, called inside saveCache. If the S3 object store is enabled, uploads run artifacts and metadata to the store (see [[Session Persistence]]), and saveCache writes session state to S3 before the Actions cache. Saves the cache, then optionally uploads a prompt log artifact for observability.

The save is not unconditional. Cleanup first applies a persistence safety gate that can decline it outright, checking three independently sufficient reasons in a fixed order and reporting only the first match: unresolved background-subagent ownership, an OpenCode server shutdown that never confirmed the writer had quiesced, or a coordination lease whose continuity could not be verified. Declining yields the ownership-declined outcome rather than a failed save — the difference matters downstream, because a declined save must not be retried by a process holding less evidence than the one that declined it. See [[Background Subagents and Ownership]] for the gate and [[Session Persistence]] for the outcome contract.

The cleanup phase has its own finally block for lock release: if a coordination lock was acquired in phase 4, it is released after all S3 sync and cache save operations complete. This ordering ensures the next Action run sees a coherent session state. Lock release is always attempted, even if earlier cleanup steps failed.

Invocation Outcome

After Cleanup returns, run() assesses the invocation once (src/harness/outcome.ts) and publishes the result as the invocation-outcome action output: succeeded, incomplete, failed, or skipped. The assessment reduces delivery success together with four verification facts — whether the event stream was observed without a gap, whether background ownership resolved, whether the server confirmed quiescence, and whether lease continuity held.

incomplete is the outcome that did not previously exist, and it names a case the harness could not previously express: a useful result may well exist, but this invocation cannot certify that it finished. It forces a non-zero exit code rather than introducing a third numeric code — the structured output is what distinguishes "the harness contract was not met" from "the agent was wrong" — and it withholds the reversible certificates, writing no dedup marker and posting no success reaction, so a re-run is not dedup-skipped into a silent success. skipped bypasses the assessment entirely.

The irreversible consumers use a known execution veto rather than assuming that delivery certifies the invocation. Observation gaps and unresolved ownership veto brokered push and formal approval; V2 also includes an unconfirmed server exit and an uncertain prompt acknowledgment, already known before publication. Lease continuity is assessed after cleanup. A known defect can veto an endorsement without the absence of that defect certifying completion.

Post-Action Hook

post.ts runs after the main step completes — even if the main step was cancelled or failed. It exists because GitHub Actions may kill the main step's finally block after a brief grace period, which could interrupt the cache save. The post-action hook provides a second opportunity to save the Actions cache. It reads state written by cleanup to decide what still needs doing. It never publishes the session to the object store: the main step has already released the coordination lease, the store holds one shared session object per repo, and an upload without the lease could overwrite a newer session published by a later run. When an object store is configured, the hook says so in its log and in the job summary (Object Store Publish: withheld (lease released)). Its outer finally also attempts the bounded, ownership-checked Action lock release described under Acquire Lock, even when persistence was skipped or failed. Cache-save eligibility and lock recovery are separate decisions: declining a save does not suppress release of a lease this invocation still owns.

The Cleanup-to-Post Handoff

Two state keys cross the process boundary between the main step and the post hook (src/harness/config/state-keys.ts), and they answer different questions.

CACHE_SAVED carries a five-value enum rather than a boolean — durable, store-only, skipped, declined-for-safety, or not-persisted — derived from cleanup's CacheSaveResult (see [[Session Persistence]]). The boolean it replaced conflated a store-only save with total failure. The enum lets the hook gate on durability actually achieved rather than on cache-write success alone: durable and store-only skip because the database already reached a durable backend, and skipped skips because the operator opted out. declined-for-safety also skips — cleanup could not confirm persistence safety, and the post hook has less information than the step that declined. Only not-persisted retries, including an absent or unrecognized value, because the post hook is the last chance to save the Actions cache. The retry writes the Actions cache only (objectStorePublish: 'withhold'); a session that reached neither backend in the main step is not published to the store afterwards.

skipped-empty is worth calling out as the case that does not fold into skipped. "No cacheable content existed" is a point-in-time filesystem observation, not a configuration constant, so the post hook retries it: the retry re-runs the SQLite checkpoint before re-checking for content, a small cost weighed against losing a session.

CLEANUP_METADATA_WRITTEN answers a question CACHE_SAVED structurally cannot. Cleanup uploads a rich run-metadata payload — token usage, timing, session IDs, PRs and commits, errors — to the object store, and the post hook has only a thin cleanupSkipped: true placeholder to write. But run.ts seeds CACHE_SAVED with not-persisted before cleanup ever executes, so that value is equally consistent with "cleanup ran and its cache write was denied" and "cleanup never ran at all." Using it to gate the metadata write meant the placeholder could clobber the real payload. The dedicated key is set unconditionally by cleanup, independent of the cache outcome, and the post hook writes its placeholder only when the key is absent.

Event Types

The router supports seven event types, each with specific skip conditions and prompt directives:

Event Common Trigger Agent Behavior
issue_comment @fro-bot mention in a comment Respond to the comment
discussion_comment @fro-bot mention in a discussion Respond to the discussion
issues Issue opened or edited with mention Triage (opened) or respond (edited)
pull_request PR opened, synced, or review requested Code review
pull_request_review_comment @fro-bot mention in review thread Respond with file/line context
schedule Cron trigger Execute the configured prompt
workflow_dispatch Manual trigger Execute the provided prompt

For schedule and workflow_dispatch, the custom prompt replaces the default directive entirely. The harness also prepends a ## Delivery Mode preamble inside <task> for these triggers, declaring whether the agent should edit the working directory or deliver via branch+PR (driven by the output-mode action input). See Delivery-mode contract for manual workflow triggers. For all other events, the custom prompt is appended to the event-specific directive.

Security Gating

The routing phase enforces access control before any agent execution occurs. Only users with OWNER, MEMBER, or COLLABORATOR association can trigger the agent. Bot accounts are blocked to prevent infinite loops. Fork PRs are skipped for pull_request events. These checks happen at the NormalizedEvent level, using the author association field that GitHub provides in webhook payloads.

Implementation Reference

Symbol-level reference for mechanisms that ARCHITECTURE.md summarizes under Cross-Cutting Concerns.

S3 Conditional-Write Locks (Gateway repo.json, Action action.json)

Two scoped S3 conditional-write locks (If-None-Match / If-Match) coordinate per-repo execution, both built by getLockKey(config, repo, scope) in packages/runtime/src/coordination/lock.ts (scope defaults to repo): gateway runs exclude each other per repo on locks/repo.json (they share one checkout), and Action runs exclude each other per repo on locks/action.json (they share one S3 session object, sessions/opencode.db, uploaded last-writer-wins). The two surfaces share no disk and no session database, so they no longer exclude each other; their GitHub writes rely on head checks, non-force pushes, and review-delivery receipts. Action acquisition lives in src/harness/phases/acquire-lock.ts (ACTION_LOCK_SCOPE): a 15-minute TTL, renewed by the shared lease loop for the life of the invocation (no RunState record); src/harness/phases/cleanup.ts releases it in a cleanup finally block. The Gateway keeps repo.json and adds a heartbeat that writes run state during execution. When S3 coordination is configured, a lock-acquisition error fails the Action closed (exit 1, invocation-outcome=failed) rather than running unlocked; a contended Action exits 0 as skipped with a coordination job summary, a warning annotation, and the agent: blocked label on issue/PR targets (skipped under response-mode: none; never a comment or reaction), via src/harness/phases/coordination-decline.ts. The decline re-stamps the label (remove, then add; a 404 on the remove is ignored) so every skip emits a fresh labeled event — re-adding a label that is already present emits none. A later run for the same item that ends invocation-outcome=succeeded clears the label (src/harness/phases/coordination-clear.ts, called from the finally block of src/harness/run.ts after cleanup, outputs and the summary): one listLabelsOnIssue read, and only if the label is present, one pass over the issue events (the last page via the Link header, then a look-back bounded to 3 pages) finds the latest labeled event for both agent: blocked and agent: working. The agent: working event is the run-start anchor: acknowledge applies that label at run start (addWorkingLabel) and cleanup removes it (before the clear runs; removal never deletes the past labeled event). Both stamps come from GitHub's clock, need only the label/issue permissions the Action already uses (no actions: read), and never involve payload timestamps (a re-run attempt reuses the original payload) or the runner's clock. The label is removed only when the blocked event is strictly earlier than the working event; a same-second tie keeps it (both stamps have whole-second resolution), as does a missing working or blocked event. If agent: working was already present at acknowledge time (a crashed earlier run never removed it), re-adding it emits no event, so the anchor is that earlier run's — older than this run's start. An older anchor can only keep labels it could have cleared, never clear one it should keep, so that case fails safe (the next run's cleanup removes the stale label and the following run clears normally). The step is best-effort and bounded by a 15 s deadline: any API failure means no removal and a logged reason, and it never fails the run. It is skipped under response-mode: none, for non-issue/PR targets, and for any outcome other than succeeded (so a run whose own lock was declined or errored never clears). Residual race: GitHub has no conditional label removal, so a decline landing between the clear's event read and its remove can be cleared, as can one landing between run start and acknowledge (the anchor is stamped after bootstrap and lock acquisition); the workflow's per-target concurrency group (fro-bot-<issue/pr number> in fro-bot.yaml) prevents same-target overlap. A run that did not acquire the lock while S3 is configured (contended or lock error) never persists session state (skipSessionPersistence → declined-for-safety; the post hook does not retry). Not configured (S3 disabled): no lock, proceed, persistence unchanged.

One lease loop serves both surfaces. createLeaseLoop (packages/runtime/src/coordination/lease-loop.ts) owns the renewal mechanics: the interval, a per-tick timeout, the overlap guard, the stopping flag, one shared stop budget, the freshest confirmed lock ETag, the failure state and failure logging. Each surface injects renew (the Action passes the renewLease it imports from @fro-bot/runtime) and a tick body that renews through ctx.renewLock; takeover policy stays with the surface. Rules a contributor must not break:

  • Every tick is bounded at 10 s on both surfaces. When the timeout fires, the attempt is superseded: it is recorded as failed and logged, the in-flight request is aborted, and a result that arrives afterwards moves neither the lock ETag nor any state the body gates on isCurrent(). stop() does not supersede an attempt: a result that lands after stop() returned but before the attempt's own timeout still applies.
  • stop() never rejects. It sets stopping first, then spends one 5 s budget on the in-flight tick and the optional finalize; when the budget runs out it aborts the still-pending request and reports tickPending with the freshest confirmed lock ETag. The abandoned attempt's tick timer is unref'd, so it records its timeout without holding shutdown open.
  • renewLock returns 'skipped' once stop has begun or the attempt is superseded, and throws when a renewal is not confirmed, so a body's later I/O does not run. A body that catches that failure still ends the tick as failed if its last renewal outcome was a failure.
  • hasFailed() follows the latest attempt: false after a confirmed renewal, true after a failure. continuityUnverified() latches when an attempt ends without a confirmed renewal (or stop() leaves one pending) and never clears.
  • The gateway heartbeat (heartbeat.ts) adds RunState on top: per tick it reads RunState, renews the lock, then writes RunState, and updates its run ETag only while the attempt is current. No tick starts a RunState read or write after stop() begins; stop()'s own budget-bounded snapshot read is the only RunState read it makes.
  • Aborting narrows the late-landing window but cannot close it: a PUT aborted on the client, or one that outlived the tick timeout, can still commit server-side. The loop would then hold a dead ETag, so the surface supplies adopt (createLockAdopter over the exported readLock): after a renewal fails inside a tick, and once at stop() when the latest renewal sent was never confirmed (tracked per renewal, so a tick that confirmed one renewal and lost the next counts), the loop reads the stored lock and takes its ETag if the record is this acquisition's own and the ETag differs. The read runs under the tick timeout and its signal in a tick, and within what is left of the stop budget at stop. An adopted tick has no failure and keeps continuityUnverified latched; at stop the error is still returned, now carrying the adopted lockEtag.
  • Ownership is per surface and strict. Gateway: holder_id, run_id and the per-acquisition acquisition_token (a random UUID minted at acquisition and kept on every heartbeat renewal) must match, and a record without a token is never adopted, so a record an older build renewed falls back to its TTL. Action: holder_id, holder_job (absent on both sides matches) and acquisition_token must match. A heartbeat built without a token never adopts. A tick still pending when the stop budget runs out leaves no budget for the read, so its lockEtag may be stale; the gateway's release then retries once by ownership when its ETag release 412s, deleting the lock only if holder_id, run_id and the acquisition token are its own. That retry has its own 5 s budget: past it the call is aborted and abandoned, a warning is logged, and the lock is left to its TTL, so a stalled store never holds up the run's slot hand-off.

Expired leases are replaced only with corroboration. An expired lease is a clock fact, not proof the old holder stopped writing (a stalled renewal, a quarantined run past its hold window, or a gateway partitioned from S3 but not from the workspace can all outlive their lease). acquireLock therefore never replaces an expired lease on time alone: it reads the record and its ETag, and then either (a) accepts a caller-supplied ConfirmExpiredHolder result of clear, or (b) for holders the caller marks reclaimableWithoutConfirmation, proceeds directly. Confirmation runs under a 5 s deadline enforced by racing, so a hung or signal-ignoring callback cannot stall acquisition; a throw, timeout, or malformed result is unknown and blocks. The replacement acquired_at is stamped after confirmation and written If-Match on the ETag observed before it — a renewal or competing takeover during confirmation yields conflict and the newer record survives. acquireLock reports outcome: 'acquired' | 'active-holder' | 'expired-holder' | 'conflict'; a blocked takeover is a normal ok result, never an error.

  • Gateway (packages/gateway/src/execute/repo-quiescence.ts): one checker, built once in program.ts from the workspace endpoint and bearer token, calls OpenCode session.status scoped to the repo's canonical workspace directory. busy or retry in any entry (including child sessions under an idle root) is busy; an empty or all-idle map is clear; every failure (transport, non-2xx, SDK-rewritten empty body, invalid shape, timeout, bad repo) is unknown. Only clear permits takeover. The check relies on sessions running in the canonical checkout directory (canonicalWorkspaceTarget in packages/gateway/src/workspace-api/client.ts, used for both the run's OpenCode directory and this query; the stored binding.workspacePath is never used), because session.status returns {} for any directory without sessions. The same checker is injected into run acquisition (Discord and web share the engine deps), maintenance runs (recover-checkout, before any preview or recovery mutation), the automatic recovery of a dirty checkout during run preparation (packages/gateway/src/execute/checkout-recovery.ts, which attempts it only on clear), startup recovery, and the operator force-release-lock. The gateway corroborates even expired Action leases.
  • Action: supplies no corroborator. It reclaims an expired lease only when the holder is an Action run (surface: 'github' and an action:{runId}:{attempt} holder id — Action runs never write the shared workspace checkout, so a killed runner must not wedge the repo). An expired gateway-surface lease is skipped like an active one (held-by-other).
  • Operator release (forceReleaseStaleLock): keeps the expired-lease prerequisite and requires the same corroboration; RunState is diagnostic only. A missing, busy, or unknown check yields workspace-unknown / workspace-busy and deletes nothing; the delete is If-Match on the ETag observed before confirmation.
  • Startup recovery (packages/gateway/src/execute/recovery.ts, recoverStaleRuns) never deletes coordination locks. Before terminalizing any stale active run it runs the repo check; busy/unknown leaves the run and lock untouched and logs a blocked recovery; clear re-reads the run, re-checks staleness on that fresh record (a heartbeat during the check means the run is alive and is left untouched), then performs the FAILED transition conditioned on that read's ETag and a best-effort interruption note. Persisted ownership is not consulted, so missing or malformed ownership cannot bypass the check. A leftover lease lapses by TTL and the next acquisition performs the guarded takeover. While the gateway runs, a periodic stranded-run sweep (stranded-run-sweep.ts, every 60 s) applies the same record repair to EXECUTING runs the process no longer owns, without the workspace check and without ever touching a lock, for EXECUTING records and ACKNOWLEDGED records past the pending threshold (a refused preparation whose terminal write failed stays ACKNOWLEDGED). It reads only records modified in the last hour, skips runs in the live-run set (from the ACKNOWLEDGED commit to the end of the run or of its quarantine hold), the abort registry and in-flight maintenance runs, closes a swept run's observers, and its thread note says only that the run's status was reconciled; a hung pass is abandoned at its 4-minute bound.
  • Audit: every expired acquisition and guarded release emits INFO lock-takeover-attempt / lock-takeover-outcome events sharing a correlation id — old and new holder identity, lease age and expiry age, the old run's last-seen phase, heartbeat, quarantine flag/reason/hold deadline and persisted ownership claim (read by the old holder identity under a 2 s bound; Action holders and unreadable records are an explicit unknown, never authorization either way), the decision (taken-over, blocked-busy, blocked-unknown, cas-conflict, lock-vanished, store-error), and the confirmation source, directory, time, and up to 32 busy session ids. Details, prompts, and tokens are never logged.

Scope. This is snapshot corroboration, not fencing. It checks current OpenCode activity at one instant; it does not prevent a stale holder from resuming later, cover detached writers outside OpenCode, prove an old Action finished, or fix ordinary premature-release paths. Hard exclusion (an epoch checked by the workspace on each mutating request) remains future work (#1655, Phase 2).

Stuck busy status. While the workspace status endpoint is unavailable (unknown), expired gateway leases stay blocked — a stopped container cannot be corroborated, so stopping it does not unblock anything. If a repo's workspace reports busy and the lease has expired, first wait for or cancel that repo's running sessions. If it stays busy, pause gateway admissions and restart the workspace service (keep the volume; the restart ends its sessions), wait until its status endpoint is reachable again, then run the guarded force-release-lock or retry acquisition — the check now reports clear. There is no unguarded bypass.

Gateway Event Stream Loss

A gateway run listens to one OpenCode event stream (packages/gateway/src/execute/run-core.ts) and treats losing it as the end of the run. The subscription allows a single attempt (sseMaxRetryAttempts: 1), so a transport failure ends the stream rather than reconnecting past events the run never saw, and the transport error is kept as the failure's cause. The prompt is sent only after the stream's first frame, server.connected, has been read (the server registers its listener before it emits that frame, and replays nothing to a late connection); a stream that does not connect within 10 s, ends first, fails, or opens with another frame fails the run as unreachable before anything is sent. It leaves through the termination barrier like any other early exit: a run that owns nothing sends no abort, while sessions it already owns from an earlier turn are settled first (and the run is quarantined if they cannot be confirmed settled). A second server.connected on one stream counts as a loss. A silence watchdog (streamSilenceTimeoutMs, 30 s, set in packages/gateway/src/execute/run.ts) fails the run when no event at all, heartbeats included, arrives for that long. Unlike the inactivity watchdog it is never paused by a drain or a pending approval or question. Every one of these ends the run as stream-ended through the termination barrier: the root session is aborted and confirmed quiescent before the lock is released. Confirmation needs a terminal reply to the root's latest turn; a stop with background work open is never confirmed, because OpenCode can start a notice turn the message list does not show yet, so that run is quarantined and its root watch aborts every busy observation for the hold. A drain the completion gate admitted in the same tick as the silence timer still completes the run. The /event connection is closed on every exit of the run, so it never outlives the lock and slot. The barrier is also where pending approvals and questions are fail-closed, for every failure it handles, not only these: it rejects them to OpenCode before any root abort, so a button pressed while the barrier is confirming the root is refused (already-claimed, then not-found) and cannot reach OpenCode.

Annotated Action Phase Pipeline

The fully annotated tree that ARCHITECTURE.md condenses in its Data Flow section.

main.ts
  └─→ harness/run.ts (run)
        │
        ├─→ bootstrap phase
        │     parseActionInputs → ensureOpenCodeAvailable
        │
        ├─→ routing phase
        │     parseGitHubContext → normalizeEvent → routeEvent
        │     (produces TriggerResult; skips if no matching trigger)
        │
        ├─→ dedup phase
        │     skip if agent already ran for this PR/issue within dedup window

        ├─→ coordination-lock phase
        │     acquire per-repo Action lock (`action.json`) when S3 is enabled; fail closed on lock error
        │
        ├─→ acknowledge phase
        │     acknowledgeReceipt (reaction + comment stub)
        │
        ├─→ cache-restore phase
        │     restore from S3 first → fall back to Actions cache when needed
        │     → repair the restored SQLite database before the server opens it:
        │       checkpoint its WAL, or clean-slate it if SQLite reports it structurally corrupt
        │     → bootstrapOpenCodeServer probes instance-scoped readiness (session.list) before
        │       reporting the server bootstrapped, not just listening
        │
        ├─→ session-prep phase
        │     processAttachments → buildAgentPrompt (packages/runtime)
        │
        ├─→ execute phase
        │     executeOpenCode → bootstrapOpenCodeServer → sendPromptToSession
        │       → runPromptAttempt → processEventStream (SSE)
        │     (onPermissionAsked auto-denies any permission.asked event immediately —
        │      there is no interactive approval path in CI, so an unanswered ask must
        │      never be left to block the run until the execution deadline)
     (the `question` tool is never offered: bootstrapOpenCodeServer pins
      OPENCODE_CLIENT=fro-bot-action and clears OPENCODE_ENABLE_QUESTION_TOOL, so
      upstream does not register it — nobody can answer in CI and a pending
      question has no server-side timeout; see "OpenCode Question Tool Disabled")
        │     (processEventStream observes owned descendants via a per-invocation
        │      OwnershipLedger: a completed `task` tool call carrying
        │      metadata.background === true adopts the child session id, after which
        │      its deltas/tools/permission-asks route like the root's, its token
        │      totals sum into the run's reported usage, and its session.error marks
        │      the entry unknown rather than ending the run's own turn)
        │
        ├─→ drain phase
        │     runDrain: owned background work settles before anything below
        │       publishes, persists, or releases — reconciles once immediately, then
        │       polls until the ledger drains or the run's deadline
        │       (computeDrainDeadlineMs: total timeout minus a 30s teardown reserve
        │       minus time execution already spent) expires, at which point every
        │       unsettled entry is session.abort-ed (no-op when execution never ran,
        │       e.g. SKIP_AGENT_EXECUTION=true)
        │
        ├─→ review-reconciliation phase
        │     reconcile formal review state after agent execution

        ├─→ finalize phase
        │     set outputs → writeJobSummary (includes a "Background Work" section
        │       naming any ownership-ledger entry that did not settle) → enforce/
        │       deliver response contract (review-required, review-permitted, or
        │       comment-only from trusted routing context)
        │
        └─→ cleanup phase
              prune sessions → shutdown server → sync artifacts and metadata
              → saveCache — declined outright (`ownership-declined`) if the ownership
                ledger, server shutdown quiescence, or the coordination lease cannot
                confirm no writer remains live; otherwise checkpoints the WAL before
                capturing files (S3 before Actions cache)
                → toCacheSaveStateValue derives CACHE_SAVED state + cache-save-result output + job-summary row
              → release lock in finally

post.ts (separate Action step)
  └─→ harness/post.ts (runPost)
        └─→ saveCache retry — skipped when CACHE_SAVED already reports durable/store-only/skipped/
              declined-for-safety; an absent or unrecognized state retries (not-persisted). Writes its own
              job-summary row on retry (no cache-save-result output: post: steps run after every other step)