mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix(claude): fence cancellation after unknown dispatch
This commit is contained in:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user