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
Open
feat(acp): lag-reopen + task-scoped completion in the run loop (C-c/C-d, ENG-9402)#413luizParreira wants to merge 1 commit into
luizParreira wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Contracts C-c and C-d harden in the
agent-acprun loop (ENG-9402 / M1.4): aLaggedstream now reopens gapless from the last yielded sequence instead of failing the prompt; the newRunStreamItem::RetentionGapis fatal (pruned events cannot be reopened around); and completion becomes task-scoped — a two-phase machine (AwaitingStart→Streaming) 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 (newAcpBackend::task_status) resolves turns whose task died without ever committing a terminal event.Key decisions
Lagged/RetentionGap; the reopen/fail decision is loop-owned, so every embedder inherits identical correctness.emitter_task_id == our taskresolves in either phase — the deliberate asymmetry from the card's literal "no terminals in AwaitingStart": a queued turn cancelled before it ever starts emitsCancelledwith noStart, 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).TextDelta/Textcarry no emitter; suppressing them until OURStartis 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.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 reachFailedwith no journalErrorevent at all.run_prompt's return value. No out-of-band resolution channel, callback, or shared cell exists — every terminal path is areturn. (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 persession/promptbyBackendPromptHandler). Reach: the select loop's three arms (cancel, stream item, stall tick) →map_event/reconcile_unattributed/resolve_from_status— and stops at theAcpBackendtrait boundary; the wire server, session store, and envelope encoding are untouched. State machine: the turn-completion machine is new —AwaitingStart--(ourStart)-->Streaming--(our terminal | status-terminal)--> return. Attack the edges: a foreignStart(ignored — only ours advances), duplicate sequences across a reopen (suppressed bylast_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;Runningdeliberately covers pending/queued/parked), andAcpBackend::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
MockBackendnow serves a queue of streams (lag-reopen pops the next), a scripted durable status, and counts status polls. Existing scripts became C-d-conformant (attributedStart+ terminals — the contract changed; assertions did not). New race tests map 1:1 to the acceptance criteria: stale predecessor terminals before AND after ourStartnever close (and predecessor text never streams);Failedwith no journalErrorresolves with the task's error via ≥2 status readings; forcedLaggedreopens at exactlySome(last_yielded)and the overlapping duplicate is dropped;RetentionGapfails loudly; queued-cancel-before-Startresolves via its own attributedCancelled.Test plan
cargo nextest run -p agent-acp— 30/30 (13 loop/wire race tests + goldens).cargo clippy --workspace --all-targets --all-features -- -D warningsclean (onetoo_many_linesallow on the linear select loop, same call as the provider impls);cargo check --workspace --all-targets --all-featuresclean; fmt clean.task_statusto 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.