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:
@@ -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' });
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user