fix(claude): fence cancellation after unknown dispatch

This commit is contained in:
Merge Sim
2026-09-02 17:58:09 -07:00
parent e7dc379778
commit 78bd25aca6
7 changed files with 59 additions and 9 deletions
@@ -15,6 +15,7 @@ function sessionFor(send = vi.fn().mockResolvedValue(undefined)): ClaudeSession
acquisitionGeneration: 'generation-1',
prompts: {} as ClaudeSession['prompts'],
dispatchWaiters: [],
dispatchSequence: 0,
options: new Map(),
reportedOptions: {},
capabilities: [],
@@ -169,6 +169,9 @@ export async function dispatchClaudeTurn(
} catch (error) {
return { state: 'rejected', reason: (error as Error).message }
}
// Advance before writing so an unknown/late replay still fences cancellation
// of the previously acknowledged turn on this session-scoped connection.
const dispatchSequence = ++session.dispatchSequence
const acceptsResult = input.body.blocks.some(
(block) => block.type === 'text' && block.text.trimStart().startsWith('/')
)
@@ -193,6 +196,7 @@ export async function dispatchClaudeTurn(
// Claude's interrupt API is session-scoped, so retain the provider turn
// identity and let cancellation reject a stale request for an older turn.
session.activeTurnId = uuid
session.activeTurnSequence = dispatchSequence
}
return uuid
? {
@@ -358,6 +358,39 @@ describe('ClaudeStructuredSessionAdapter turns and controls', () => {
)
})
it('does not cancel an acknowledged turn after a later dispatch returns unknown', async () => {
const claude = fakeClaude({ replayUuids: ['turn-T', null] })
const adapter = await acquired(claude)
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-T',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({
state: 'accepted',
providerIdentity: { uuid: 'turn-T' }
})
await expect(
adapter.dispatch({
sessionId: 'session-1',
clientMessageId: 'client-U',
body: USER_MESSAGE,
fence: 7
})
).resolves.toMatchObject({ state: 'unknown' })
expect(claude.connections[0].sent).toHaveLength(2)
await expect(
adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-T', fence: 7 })
).resolves.toEqual({ cancelled: false })
expect(claude.connections[0].calls.filter((call) => call.subtype === 'interrupt')).toHaveLength(
0
)
})
it('classifies provider-declined options without treating timeouts as settled', async () => {
const claude = fakeClaude({
routes: {
@@ -155,7 +155,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
this.sessions.get(input.sessionId) === session &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration &&
(session.activeTurnId === undefined || session.activeTurnId === input.turnId)
(session.activeTurnId === undefined
? session.dispatchSequence === 0
: session.activeTurnId === input.turnId &&
session.activeTurnSequence === session.dispatchSequence)
)
})
}
@@ -44,6 +44,7 @@ export function createClaudeSessionPublication(input: {
acquisitionGeneration: input.acquisitionGeneration,
prompts: input.prompts,
dispatchWaiters: [],
dispatchSequence: 0,
options: new Map(input.options),
capabilities: input.capabilities,
reportedOptions: {
@@ -95,6 +95,10 @@ export type ClaudeSession = {
capabilities: readonly string[]
/** Provider uuid of the most recently admitted turn, if one is active. */
activeTurnId?: string
/** Monotonic fence advanced when a dispatch starts, including unresolved dispatches. */
dispatchSequence: number
/** Dispatch sequence that admitted activeTurnId. */
activeTurnSequence?: number
translator: ClaudeJournalTranslator | null
events: StructuredAgentSessionEventSink | undefined
}
@@ -56,7 +56,7 @@ export function fakeClaude(
exitBeforeInit?: string
settings?: unknown
replayUuid?: string | null
replayUuids?: string[]
replayUuids?: (string | null)[]
capabilities?: string[]
unprovenCloseVerdict?: ClaudeStreamJsonConnection['exitVerdict']
routes?: Record<string, Route>
@@ -149,13 +149,17 @@ export function fakeClaude(
send: async (message) => {
connection.sent.push(message)
if (message.type === 'user' && options.replayUuid !== null) {
const replayUuid = options.replayUuids
? (options.replayUuids[replayIndex++] ?? `user-uuid-${replayIndex}`)
: (options.replayUuid ?? 'user-uuid')
handlers.onMessage?.({
...message,
uuid: replayUuid
})
const configuredReplayUuid = options.replayUuids
? options.replayUuids[replayIndex++]
: options.replayUuid
const replayUuid =
configuredReplayUuid === undefined ? `user-uuid-${replayIndex}` : configuredReplayUuid
if (replayUuid !== null) {
handlers.onMessage?.({
...message,
uuid: replayUuid
})
}
}
},
exitVerdict: options.unprovenCloseVerdict ?? { root: 'live', tree: 'unverifiable' },