From 2ed493317e1257cc8a328c5d05d37dcaf74a7d87 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 00:42:00 -0700 Subject: [PATCH] fix(claude): settle retained exit before reacquire --- .../claude-structured-session-acquisition.ts | 8 +- .../claude-structured-session-adapter.ts | 3 +- ...claude-structured-session-recovery.test.ts | 108 ++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index e09f52c35b9..6e7af93519a 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -56,6 +56,7 @@ type AcquireCallbacks = { event: ClaudeStructuredSessionEvent ) => void handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void + settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise } export async function acquireClaudeSession({ @@ -141,9 +142,10 @@ export async function acquireClaudeSession({ if (!proven) { throw claudeAcquisitionCleanupError(retainedExit.connection, retainedExit.error) } - // The old child is superseded by this acquisition. It has a true proof, - // so discard its lifecycle evidence without publishing a stale recovery. - exits.delete(sessionId) + // The old child is superseded by this acquisition. Settle its lifecycle + // before discarding the retained proof so its cursor and callbacks are + // cleaned up exactly once. + await callbacks.settleExit(sessionId, retainedExit) } acquisitions.assertCurrent(sessionId, attempt) // Closing persists the prior connection's final leaf, so launch validates that durable head. diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index e133bd3d788..316433d929c 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -57,7 +57,8 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda callbacks: { deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event), emit: (session, events, event) => this.emit(session, events, event), - handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error) + handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error), + settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit) } }) diff --git a/src/main/claude/claude-structured-session-recovery.test.ts b/src/main/claude/claude-structured-session-recovery.test.ts index a3797dc4eae..87d2da383e2 100644 --- a/src/main/claude/claude-structured-session-recovery.test.ts +++ b/src/main/claude/claude-structured-session-recovery.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-adapter' import { ClaudeTranscriptPreviousCursorMissingError } from './claude-transcript-branch-proof' import { @@ -345,4 +346,111 @@ describe('ClaudeStructuredSessionAdapter transcript-derived recovery', () => { expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) }) + + it('settles a retained exit before reacquisition replaces its provider session', async () => { + const claude = fakeClaude() + const events: ClaudeStructuredSessionEvent[] = [] + const persistedHandles: unknown[] = [] + const journalSink: StructuredAgentSessionEventSink = { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {} + } + const readTranscriptLeaf = vi + .fn() + .mockRejectedValue(new Error('latest marker is on a sibling branch')) + let persisted = false + const adapter = adapterFor( + claude, + { resumed: true, resumeLeafUuid: 'replacement-leaf' }, + events, + persistedHandles, + undefined, + readTranscriptLeaf, + async (handle) => { + persisted = true + persistedHandles.push(handle) + } + ) + const firstAcquisition = await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: journalSink + }) + const first = claude.connections[0] + const oldPrompt = invokeCanUseTool(first, 'Bash', 'permission-retained', 'tool-retained') + const oldSession = ( + adapter as unknown as { + sessions: Map void } | null }> + } + ).sessions.get('session-1') + expect(oldSession?.translator).not.toBeNull() + const disposeTranslator = vi.spyOn(oldSession!.translator!, 'dispose') + first.handlers.onMessage?.({ + type: 'assistant', + session_id: PROVIDER_SESSION_ID, + uuid: 'observed-retained-leaf' + }) + first.close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) as unknown as (typeof first)['close'] + first.handlers.onExit?.(new Error('crashed before replacement')) + await tick() + + expect(oldPrompt.settled()).toBe(false) + expect(events.filter((event) => event.type === 'ended')).toEqual([]) + + const replacement = await adapter.acquire({ + identity: { + ...identityFor(), + providerHandle: { + kind: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'observed-retained-leaf' + } + }, + fence: 8, + spawnToken: 'spawn-10', + events: journalSink + }) + + expect(persisted).toBe(true) + expect(disposeTranslator).toHaveBeenCalledOnce() + expect(persistedHandles).toEqual([ + { + sessionId: 'session-1', + providerSessionId: PROVIDER_SESSION_ID, + leafUuid: 'observed-retained-leaf', + fence: 7 + } + ]) + expect(readTranscriptLeaf).toHaveBeenCalledWith({ + providerSessionId: PROVIDER_SESSION_ID, + previousLeafUuid: 'observed-retained-leaf', + claudeConfigDir: '/accounts/claude' + }) + expect(oldPrompt.settled()).toBe(true) + expect(events.filter((event) => event.type === 'ended')).toEqual([ + { + type: 'ended', + sessionId: 'session-1', + reason: 'crashed before replacement', + cause: 'unexpected-exit', + fence: 7, + acquisitionGeneration: firstAcquisition.acquisitionGeneration + } + ]) + expect(replacement.link).toMatchObject({ + handle: { + provider: 'claude', + sessionId: PROVIDER_SESSION_ID, + leafUuid: 'replacement-leaf' + }, + origin: 'resumed', + mintedAtFence: 8 + }) + expect(claude.connections).toHaveLength(2) + }) })