Skip to content

feat(acp): lag-reopen + task-scoped completion in the run loop (C-c/C-d, ENG-9402) - #413

Open
luizParreira wants to merge 1 commit into
mainfrom
eng-9402-run-loop-hardening
Open

feat(acp): lag-reopen + task-scoped completion in the run loop (C-c/C-d, ENG-9402)#413
luizParreira wants to merge 1 commit into
mainfrom
eng-9402-run-loop-hardening

Conversation

@luizParreira

Copy link
Copy Markdown
Member

Summary

Contracts C-c and C-d harden in the agent-acp run loop (ENG-9402 / M1.4): a Lagged stream now reopens gapless from the last yielded sequence instead of failing the prompt; the new RunStreamItem::RetentionGap is fatal (pruned events cannot be reopened around); and completion becomes task-scoped — a two-phase machine (AwaitingStartStreaming) in which only a terminal attributed to OUR task may close the prompt, unattributed terminals are reconciled against the backend's durable task status, and a bounded stall poll (new AcpBackend::task_status) resolves turns whose task died without ever committing a terminal event.

Key decisions

  • Recovery policy lives in the loop, never in backends (review finding feat: add agent loop for LLM call orchestration #9): backends report Lagged/RetentionGap; the reopen/fail decision is loop-owned, so every embedder inherits identical correctness.
  • Attribution is the gate, phase is the defense. emitter_task_id == our task resolves in either phase — the deliberate asymmetry from the card's literal "no terminals in AwaitingStart": a queued turn cancelled before it ever starts emits Cancelled with no Start, and per V4's salvage-attribution rules an event naming our task can never be a predecessor's. The AwaitingStart prohibition applies to unattributed terminals (categorically stale) and foreign ones (never ours to act on).
  • Content is phase-gated because it can't be attributed. TextDelta/Text carry no emitter; suppressing them until OUR Start is what keeps a late predecessor's text out of the prompt — the exact live failure observed in satoshi's scenario-5 QA, where a follow-up prompt streamed an orphaned predecessor's "1 2 3 …" output.
  • Status is the only authority allowed to close without an attributed event. Both the mid-stream reconcile (unattributed terminal) and the stall poll resolve from task_status, with two consecutive terminal readings as grace for an in-flight journal commit. Failed { error } surfaces the task's recorded error to the client — a task can reach Failed with no journal Error event at all.
  • Single resolution by construction: the prompt's outcome IS run_prompt's return value. No out-of-band resolution channel, callback, or shared cell exists — every terminal path is a return. (This is the "reviewer can point to the mechanism" answer.)

Review guide

Part 1 (review first — crates/agent-acp/src/run.rs, the whole card).
Entry point: run_prompt (called per session/prompt by BackendPromptHandler). Reach: the select loop's three arms (cancel, stream item, stall tick) → map_event/reconcile_unattributed/resolve_from_status — and stops at the AcpBackend trait boundary; the wire server, session store, and envelope encoding are untouched. State machine: the turn-completion machine is newAwaitingStart --(our Start)--> Streaming --(our terminal | status-terminal)--> return. Attack the edges: a foreign Start (ignored — only ours advances), duplicate sequences across a reopen (suppressed by last_yielded), a terminal-status reading followed by stream progress (streak resets), resolve_from_status(Running) (unreachable-by-guard, returns loop-bug error).

Part 2 (crates/agent-acp/src/backend.rs).
RunStreamItem::RetentionGap (new variant), BackendTaskStatus (new enum; Running deliberately covers pending/queued/parked), and AcpBackend::task_status (new required method — breaking for backend impls, by design; satoshi's impl lands with its pin bump). No state machine; contract surface only.

Part 3 (tests/backend_run.rs).
The scripted MockBackend now serves a queue of streams (lag-reopen pops the next), a scripted durable status, and counts status polls. Existing scripts became C-d-conformant (attributed Start + terminals — the contract changed; assertions did not). New race tests map 1:1 to the acceptance criteria: stale predecessor terminals before AND after our Start never close (and predecessor text never streams); Failed with no journal Error resolves with the task's error via ≥2 status readings; forced Lagged reopens at exactly Some(last_yielded) and the overlapping duplicate is dropped; RetentionGap fails loudly; queued-cancel-before-Start resolves via its own attributed Cancelled.

Test plan

  • cargo nextest run -p agent-acp — 30/30 (13 loop/wire race tests + goldens).
  • cargo clippy --workspace --all-targets --all-features -- -D warnings clean (one too_many_lines allow on the linear select loop, same call as the provider impls); cargo check --workspace --all-targets --all-features clean; fmt clean.
  • Live QA rides the satoshi pin-bump PR (satoshi must implement task_status to compile): full five-scenario buzz e2e on the durable host, including the scenario that previously demonstrated the pre-C-d bug (a follow-up prompt completing with an orphaned predecessor's output).

Linear: ENG-9402. Unblocks ENG-9403 (crash/duplicate e2e matrix) with its M1 siblings.

Two races could corrupt an ACP turn: a Lagged stream failed the whole
prompt, and ANY Done on the thread closed it — including a stale
predecessor's (the durable host's grpc layer warns about exactly this).
Both die here, once, for every backend:

- Lagged now reopens from the last yielded sequence (gapless by the
  strictly-greater stream contract, duplicate-free by suppression) with
  a small backoff; the new RunStreamItem::RetentionGap is fatal instead
  — pruned events cannot be reopened around.
- Completion is a two-phase machine: AwaitingStart (nothing streams,
  unattributed terminals are categorically stale) advances only on OUR
  task's Start; while Streaming, terminals resolve only when attributed
  to OUR task, foreign ones are ignored, and unattributed ones are
  reconciled against the backend's durable task status before they may
  close anything. One asymmetry: an OUR-attributed terminal resolves in
  either phase — a queued turn cancelled before Start emits Cancelled
  with no Start, and an event naming our task can never be a
  predecessor's.
- A bounded stall poll (new AcpBackend::task_status) resolves turns
  whose task reached a terminal STATUS without a terminal EVENT (crash
  between status write and journal commit), including Failed with the
  task's recorded error. Two consecutive terminal readings are required
  as grace for an in-flight commit; stream progress resets the streak.
- The prompt still resolves exactly once BY CONSTRUCTION: resolution is
  run_prompt's return value; no out-of-band channel exists.
@luizParreira luizParreira self-assigned this Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant