Stop OpenCode cancels from killing the next turn (#2662)
* fix(opencode): prevent late aborts from canceling replacement turns OpenCode aborts are session-scoped, so provider idle alone does not make a session safe to reuse. Keep abort retries owned by the stopping turn and require both abort settlement and provider idle before dispatching the next runner operation. * fix(opencode): retain the stop barrier through provider idle Keep the stopping operation authoritative until both its session-scoped abort and provider-idle evidence settle, including stale idle-session interrupts. Also keep the real drift fixture credential-allowlisted and wait for command output pipes to drain. * test(opencode): launch Windows fixture shims safely * fix(opencode): recover stop observation safely * fix(opencode): preserve autonomous wake through stop * fix(opencode): retain deferred wake completion * fix(opencode): serialize stop boundary replay * test(opencode): use shared real e2e lane * fix(opencode): own stops started during replay * fix(opencode): retry stop observation on demand * fix(opencode): follow replacement stop boundaries * fix(opencode): retain wakes during stop recovery * fix(opencode): hold replay turns behind boundary * fix(opencode): filter canceled reconnect output * fix(opencode): bind status evidence to stop * fix(opencode): acknowledge owned cancellation * fix(opencode): reconcile stale busy evidence * fix(opencode): bind replay terminals to wakes * refactor(opencode): own the stop boundary in one explicit state The run boundary had grown into a mutable flag bag plus a raw-event buffer that was replayed and re-classified after the fact. Each new interleaving needed another predicate, and the classification kept losing autonomous wakes. Restore stopping as a turn-state variant that owns exactly two things: the session-scoped abort it issued (with its single owned retry) and the canceled run's terminal. Runner operations wait on both; a failed abort fails closed. Provider status reconciliation now ends the stop through the same transition an SSE terminal takes. The canceled run's residue is suppressed only up to that terminal. Events published afterwards are a new run by construction and take the normal live path, so a wake no longer waits for abort settlement and cannot be dropped by a retention heuristic. * fix(opencode): emit cancellation before adopting the next run The acknowledgement ran off a promise combinator, so a wake adopted from the event right after the terminal could reach subscribers before the canceled turn's turn_canceled. Acknowledge synchronously from whichever proof lands first instead. * docs(opencode): attach the stop doc comment to its interface * fix(opencode): retry the stop in progress instead of opening a second Pressing Stop again after an abort failed built a detached boundary: its fresh abort could succeed while the canceled turn kept waiting for an acknowledgement, so the daemon sat out the full cancel timeout before force-canceling. A repeated Stop now replaces the abort owned by the stop already in progress, which is what carries the acknowledgement and what lifts the fail-closed gate. * fix(opencode): accumulate every abort a stop has issued A repeated Stop replaced the stop's abort settlement instead of adding to it, so a first request still in flight — the slow-teardown case the cancel cap exists for — no longer held the gate. The replacement could start while that request was live and get cancelled by it, which is the race this boundary exists to close. Reuse now waits for every abort the stop issued, while only the newest can hold the gate closed. * fix(opencode): scope abort settlement to the session, not the stop Abort is session-scoped, so its settlement has to be too. Tracking it on the stop meant a new stop boundary silently dropped an older request that was still in flight, and that request could still cancel whatever run started next. The session now accumulates every abort it has issued; the stop is left owning only the run it is stopping. * fix(opencode): recheck the abort gate after waiting on it A waiting prompt read the settlement once, so a Stop issued while it waited was invisible to it: the older abort would settle, the prompt would start, and the newer one could still cancel it. Drain until the settlement observed is the current one.
This commit is contained in:
@@ -458,3 +458,11 @@ Tests use `isProviderAvailable(provider)` to skip when the binary or credentials
|
||||
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
||||
|
||||
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
||||
|
||||
**Session-scoped cancellation needs a stop boundary inside the provider.** Some agents cancel the whole session rather than one turn — OpenCode's `session.abort` is the example. A cancel that is still in flight when the next run starts will kill that replacement run, which is what makes "stop, then immediately prompt again" (`replaceRunning`, `notifyOnFinish` wakes, schedules) flaky. Own this in the provider session, not in `AgentManager`:
|
||||
|
||||
- Model the stop as an explicit `stopping` turn-state variant carrying the canceled run's terminal and the cancellation the caller is still owed. Pressing Stop again retries the stop already in progress rather than opening a second one; never fire a detached retry, it will outlive its boundary.
|
||||
- **Scope the cancel settlement the way the provider scopes the cancel.** If cancellation is session-scoped, so is its settlement: track it on the session, accumulating every request issued, and let it outlive the stop that issued it. A request still in flight lands on the runner whenever the server gets to it — however many stops have come and gone since. Scoping it to the current stop looks right and quietly drops older requests from the gate. Only the newest may hold the gate closed, since recovering from a failed cancel is what pressing Stop again is for.
|
||||
- Gate the operations **the daemon issues** (prompt, slash command, summarize) on both the terminal and cancel settlement. Permission and question responses are not runner operations and must stay outside the gate, or an auto-approve deadlocks the stop. Runs the _provider_ starts on its own — plugin or autonomous wakes — are observed, not gated: the daemon does not choose when they begin, and holding their events back does not protect them from a cancel already in flight, it only hides a run that may already be dead.
|
||||
- Fail closed: if the cancel never succeeded you never proved the run stopped, so refuse new runs until the next Stop issues a fresh cancel. `AgentManager` already turns a rejected `interrupt()` into a refused cancel.
|
||||
- Suppress the canceled run's residue only until its authoritative terminal. Anything the provider publishes after that terminal is a new run by construction and must take the normal live path — buffering it and replaying it later is how autonomous/plugin wakes get lost.
|
||||
|
||||
Reference in New Issue
Block a user