mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(claude): settle retained exit before reacquire
This commit is contained in:
@@ -56,6 +56,7 @@ type AcquireCallbacks = {
|
||||
event: ClaudeStructuredSessionEvent
|
||||
) => void
|
||||
handleExit: (sessionId: string, attempt: ClaudeAcquisitionAttempt, error: Error) => void
|
||||
settleExit: (sessionId: string, exit: ClaudeSessionExit) => Promise<void>
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, { translator: { dispose: () => 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<boolean>>()
|
||||
.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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user