From 5d0d2bc8baf6f6a441bc2c3f1aa4c7c5ea173dcc Mon Sep 17 00:00:00 2001 From: Bambo Date: Sat, 8 Aug 2026 20:14:36 +0800 Subject: [PATCH] fix(cli): user-initiated abort no longer kills the ACP runner process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AcpBackend.cancel() emits status 'stopped' after a successful session/cancel, which runAcp treated as fatal backend death — so a user tapping stop in the app killed the whole session process instead of just cancelling the turn. Track user-initiated aborts (userAbortInFlight) and treat the matching stopped status as a normal turn end (marked 'cancelled'). The flag is cleared at every new turn start and when cancel() throws, so a stale flag can never mask a genuine backend failure. --- packages/happy-cli/src/agent/acp/runAcp.ts | 29 ++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/happy-cli/src/agent/acp/runAcp.ts b/packages/happy-cli/src/agent/acp/runAcp.ts index 9ba0b0c928..73e201ad1e 100644 --- a/packages/happy-cli/src/agent/acp/runAcp.ts +++ b/packages/happy-cli/src/agent/acp/runAcp.ts @@ -552,6 +552,11 @@ export async function runAcp(opts: { let shouldExit = false; let abortController = new AbortController(); let pendingTurn: PendingTurn | null = null; + // A user-initiated abort makes AcpBackend.cancel() emit status 'stopped', + // which is indistinguishable from backend death. Track it so the runner + // survives user aborts and only exits on genuine backend failure. + let userAbortInFlight = false; + let turnCancelledByAbort = false; const clearPendingTurn = (error?: Error) => { if (!pendingTurn) { @@ -815,7 +820,17 @@ export async function runAcp(opts: { if (msg.status === 'idle') { clearPendingTurn(); } - if (msg.status === 'error' || msg.status === 'stopped') { + if (msg.status === 'stopped' && userAbortInFlight) { + // User-initiated abort: end the current turn as cancelled, keep the + // runner alive instead of treating it as backend death. + userAbortInFlight = false; + thinking = false; + session.keepAlive(false, 'remote'); + if (pendingTurn) { + turnCancelledByAbort = true; + clearPendingTurn(); + } + } else if (msg.status === 'error' || msg.status === 'stopped') { stopRunnerFromBackendStatus(msg.status, msg.detail); } } @@ -859,11 +874,15 @@ export async function runAcp(opts: { async function handleAbort() { try { if (acpSessionId) { + userAbortInFlight = true; await backend.cancel(acpSessionId); } permissionHandler.reset(); abortController.abort(); } catch (error) { + // Cancel never reached the agent — don't let the flag mark a later + // genuine backend failure as user-initiated. + userAbortInFlight = false; logger.debug(`[${opts.agentName}] Abort failed:`, error); } finally { abortController = new AbortController(); @@ -912,6 +931,11 @@ export async function runAcp(opts: { logAcp('incoming', `Incoming prompt: ${formatUnknownForConsole(batch.message, ACP_EVENT_PREVIEW_CHARS)}`); sendEnvelopes(sessionManager.startTurn()); + // Guard against a stale flag: an abort that arrives while idle (e.g. just + // after a turn ended naturally, or when cancel() throws) must not cause a + // later genuine backend failure to be misread as a user abort. + userAbortInFlight = false; + turnCancelledByAbort = false; const turnEnded = waitForTurnEnd(); try { if (typeof batch.mode.permissionMode === 'string' && batch.mode.permissionMode.length > 0) { @@ -922,7 +946,8 @@ export async function runAcp(opts: { } await backend.sendPrompt(acpSessionId, batch.message); await turnEnded; - sendEnvelopes(sessionManager.endTurn('completed')); + sendEnvelopes(sessionManager.endTurn(turnCancelledByAbort ? 'cancelled' : 'completed')); + turnCancelledByAbort = false; session.sendSessionEvent({ type: 'ready' }); if (verbose) { logAcp('muted', `Outgoing prompt completion from ${opts.agentName}`);