fix(coder): F.1 post-interrupt stale-terminal guard (opencode warm server)

opencode emits one trailing session.idle/error for a turn cancelled via client.session.abort(), carrying only a sessionID (no turn id). The warm-server backend settled activeTurn on that event, so after Stop + an immediate new message the orphan idle settled the NEXT turn early as success (one-click reachable since v2.6.5's Send->Stop composer).

Adds a pure per-session guard (backends/turn-guard.ts: armAbortGuard / noteTurnActivity / consumeTerminal over swallowNextTerminal) wired into opencode-server.ts: abort arms it, the next terminal is swallowed once, and a new turn's first delta self-heals so a never-arriving orphan can't strand a real turn. Test-first; 3 regression tests in turn-guard.test.ts. Paseo parallel: 1d38aac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 21:31:35 +00:00
parent d66948c925
commit 372651bcb1
3 changed files with 91 additions and 2 deletions

View File

@@ -37,6 +37,7 @@ import {
import type { ToolCallStatus } from '@agentclientprotocol/sdk';
import type { Sql } from '../../db.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
import { armAbortGuard, noteTurnActivity, consumeTerminal } from './turn-guard.js';
import type {
AgentBackend,
AgentEvent,
@@ -78,6 +79,9 @@ interface SessionState {
/** Per-session SSE subscription handle. Non-null while the loop is running;
* aborting it tears down the underlying fetch and exits the loop. */
sseAbort: AbortController | null;
/** F.1 post-abort orphan-terminal guard: swallow the one session.idle/error
* opencode emits for an aborted turn so it can't settle the next turn. */
swallowNextTerminal: boolean;
}
export interface OpenCodeServerBackendDeps {
@@ -305,13 +309,19 @@ export class OpenCodeServerBackend implements AgentBackend {
}
// ─── lifecycle ─────────────────────────────────────────────────────────
case 'session.idle': {
this.byOpencodeId.get(ev.properties.sessionID)?.activeTurn?.settle({ ok: true });
const st = this.byOpencodeId.get(ev.properties.sessionID);
if (!st) return;
if (consumeTerminal(st) === 'swallow') return; // F.1: drop the post-abort orphan
st.activeTurn?.settle({ ok: true });
return;
}
case 'session.error': {
const sid = ev.properties.sessionID;
if (!sid) return;
this.byOpencodeId.get(sid)?.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
const st = this.byOpencodeId.get(sid);
if (!st) return;
if (consumeTerminal(st) === 'swallow') return; // F.1: drop the post-abort orphan
st.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
return;
}
default:
@@ -358,6 +368,8 @@ export class OpenCodeServerBackend implements AgentBackend {
/** Reset the inactivity backstop on any event routed to a session's active turn. */
private bumpActivity(st: SessionState): void {
if (!st.activeTurn) return;
// A live turn is producing → the post-abort orphan-terminal window is over.
noteTurnActivity(st);
if (st.watchdog) clearTimeout(st.watchdog);
st.watchdog = setTimeout(() => {
void this.onTurnStall(st);
@@ -490,6 +502,7 @@ export class OpenCodeServerBackend implements AgentBackend {
activeTurn: null,
watchdog: null,
sseAbort: null,
swallowNextTerminal: false,
};
this.byOpencodeId.set(ocSessionId, state);
}
@@ -528,6 +541,7 @@ export class OpenCodeServerBackend implements AgentBackend {
activeTurn: null,
watchdog: null,
sseAbort: null,
swallowNextTerminal: false,
};
this.byOpencodeId.set(oc, state);
}
@@ -561,6 +575,9 @@ export class OpenCodeServerBackend implements AgentBackend {
const onAbort = () => {
// Abort the turn only — never the server.
client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {});
// F.1: opencode emits one trailing session.idle/error for the cancelled
// turn — arm the guard so it's swallowed, not used to settle the next turn.
armAbortGuard(session);
settle({ ok: false, error: 'aborted' });
};