From 9b89aeeb689facfc7d523238dd321fa77c1c8f19 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 5 Jul 2026 15:52:57 +0800 Subject: [PATCH] fix: preempt in-flight turn when a new prompt arrives without cancel --- .github/workflows/ci.yml | 3 ++- src/handlers/session.ts | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c3651..4bbe1ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,8 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - # version is read from packageManager in package.json via corepack + with: + version: 10 - name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 diff --git a/src/handlers/session.ts b/src/handlers/session.ts index 7869335..78b6c21 100644 --- a/src/handlers/session.ts +++ b/src/handlers/session.ts @@ -281,6 +281,12 @@ export async function prompt( const intercepted = await handleSlashCommand(server, cx, params.sessionId, zcodeSid, text); if (intercepted) return intercepted; + // Preempt: if another turn is still running for this session (client sent a + // new prompt without cancelling), stop it and wait for it to fully exit + // before we subscribe/send. Without this, session/send hits the backend + // prompt-lock and the error path kills the old turn but loses the new msg. + await preemptInFlightTurn(server, zcodeSid, requestId); + // Register the pending turn. This same object is mutated by cancel(); the // turn loop checks `.cancelled` on the SAME reference, so cancel propagates. const turn: PendingTurn = { @@ -464,6 +470,55 @@ async function ensureTurnStopped(server: ZcodeAcpServer, zcodeSid: string): Prom return false; } +/** + * If another prompt() is already running for this zcodeSid, treat the new + * prompt as an implicit cancel: stop the in-flight turn and wait for it to + * fully exit (lock released + listener unregistered + pendingTurns cleaned) + * before the new prompt subscribes and sends. + * + * Why wait for the map entry to disappear (not just the lock): registering + * a second EventStreamListener overwrites the first (Map.set in client.ts), + * so the old turn loop must have run its finally block before we subscribe. + * The map cleanup in that finally block is the synchronization point. + * + * Best-effort: never throws. On timeout, continues anyway — session/send + * will then hit the lock and take the existing error path. + */ +async function preemptInFlightTurn( + server: ZcodeAcpServer, + zcodeSid: string, + selfRequestId: number, +): Promise { + // Find any in-flight turn for this session that isn't this request. + let oldRequestId: number | undefined; + for (const [reqId, turn] of server.pendingTurns) { + if (turn.zcodeSid === zcodeSid && reqId !== selfRequestId) { + oldRequestId = reqId; + turn.cancelled = true; // signal the old turn loop to exit + break; + } + } + if (oldRequestId === undefined) return; // no in-flight turn, proceed + + log(` [preempt] in-flight turn ${oldRequestId} found, stopping it`); + // Stop the backend turn and wait for the prompt lock to release. + await ensureTurnStopped(server, zcodeSid); + + // Wait for the old turn's prompt() to fully exit (its finally block deletes + // the pendingTurns entry). This is the synchronization point that guarantees + // its listener is unregistered before we register ours. + const PREEMPT_TIMEOUT_MS = 35_000; // slightly longer than ensureTurnStopped's 30s + const t0 = Date.now(); + while (server.pendingTurns.has(oldRequestId)) { + if (Date.now() - t0 > PREEMPT_TIMEOUT_MS) { + log(` [preempt] timed out waiting for old turn ${oldRequestId} to exit`); + return; // best-effort: continue anyway, session/send may fail + } + await sleep(200); + } + log(` [preempt] old turn ${oldRequestId} exited, proceeding`); +} + // ---------- internals ---------- /** Concatenate text from ACP ContentBlocks into a prompt string. */