diff --git a/src/main/claude/claude-structured-journal-prompt-retry.test.ts b/src/main/claude/claude-structured-journal-prompt-retry.test.ts new file mode 100644 index 00000000000..9c7941f88bd --- /dev/null +++ b/src/main/claude/claude-structured-journal-prompt-retry.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' +import { createClaudeJournalTranslator } from './claude-structured-journal-translation' + +function approval(promptKey: string): ClaudePendingPrompt { + return { + requestId: promptKey, + promptKey, + toolUseId: 'tool-retry', + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: () => {} + } +} + +function transientBackpressureSink(refusedAt: 'append' | 'publish'): { + sink: StructuredAgentSessionEventSink + durableApproval: () => AgentJournalItemBody | undefined + appendAttempts: () => number + publishAttempts: () => number + appliedSettlements: Set +} { + const staged = new Map() + const durable = new Map() + const appliedSettlements = new Set() + let lifecycleAppendAttempts = 0 + let lifecyclePublishAttempts = 0 + const persist = (): void => { + durable.clear() + for (const [key, body] of staged) { + durable.set(key, body) + } + } + const applyItem = (identity: AgentJournalItemIdentity, body: AgentJournalItemBody): void => { + staged.set(agentJournalItemKey(identity), body) + } + return { + sink: { + appendItem: applyItem, + appendTombstone: (identity) => staged.delete(agentJournalItemKey(identity)), + publish: persist, + tryAppendLifecycleBatch: (settlementId, mutations) => { + lifecycleAppendAttempts += 1 + if (refusedAt === 'append' && lifecycleAppendAttempts === 1) { + return { accepted: false, reason: 'backpressure' } + } + if (!appliedSettlements.has(settlementId)) { + for (const mutation of mutations) { + if (mutation.kind === 'item') { + applyItem(mutation.identity, mutation.body) + } else { + staged.delete(agentJournalItemKey(mutation.identity)) + } + } + appliedSettlements.add(settlementId) + } + return { accepted: true } + }, + tryPublish: () => { + lifecyclePublishAttempts += 1 + if (refusedAt === 'publish' && lifecyclePublishAttempts === 1) { + return { accepted: false, reason: 'backpressure' } + } + persist() + return { accepted: true } + } + }, + durableApproval: () => [...durable.values()].find((body) => body.kind === 'approval'), + appendAttempts: () => lifecycleAppendAttempts, + publishAttempts: () => lifecyclePublishAttempts, + appliedSettlements + } +} + +describe('Claude journal prompt cancellation retry', () => { + it.each(['append', 'publish'] as const)( + 'retries after transient lifecycle %s backpressure', + (refusedAt) => { + const state = transientBackpressureSink(refusedAt) + const translator = createClaudeJournalTranslator({ sink: state.sink }) + const prompt = approval('permission-retry') + + translator.handle({ type: 'prompt', sessionId: 'orca-session', prompt }) + translator.handle({ + type: 'prompt-cancelled', + sessionId: 'orca-session', + promptKey: prompt.promptKey + }) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'pending' } }) + + translator.handle({ + type: 'provider-frame', + sessionId: 'orca-session', + kind: 'control_request:retry-boundary', + payload: {} + }) + expect(state.durableApproval()).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + expect(state.appliedSettlements).toEqual(new Set(['prompt-cancelled:permission-retry'])) + + translator.handle({ + type: 'provider-frame', + sessionId: 'orca-session', + kind: 'control_request:after-retry', + payload: {} + }) + expect(state.appendAttempts()).toBe(2) + expect(state.publishAttempts()).toBe(refusedAt === 'publish' ? 2 : 1) + } + ) +}) diff --git a/src/main/claude/claude-structured-journal-prompts.ts b/src/main/claude/claude-structured-journal-prompts.ts index 1860df0afe6..8d9d2c115a4 100644 --- a/src/main/claude/claude-structured-journal-prompts.ts +++ b/src/main/claude/claude-structured-journal-prompts.ts @@ -18,6 +18,7 @@ import { import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' const ADMITTED = { accepted: true } as const +const MAX_PENDING_PROMPT_CANCELLATIONS = 64 type ClaudeJournalPrompt = { identity: AgentJournalItemIdentity @@ -36,11 +37,16 @@ function cancelledPromptBody( export class ClaudeJournalPrompts { private readonly items = new Map() + private readonly pendingCancellations = new Set() get size(): number { return this.items.size } + get pendingCancellationCount(): number { + return this.pendingCancellations.size + } + constructor( private readonly deps: { sink: StructuredAgentSessionEventSink @@ -77,7 +83,7 @@ export class ClaudeJournalPrompts { this.deps.sink.publish() } - cancel(promptKey: string): StructuredAgentSessionSinkAdmission { + private admitCancellation(promptKey: string): StructuredAgentSessionSinkAdmission { const items = this.items.get(promptKey) ?? [] if (items.length === 0) { return ADMITTED @@ -125,11 +131,39 @@ export class ClaudeJournalPrompts { return published } + cancel(promptKey: string): StructuredAgentSessionSinkAdmission { + const admission = this.admitCancellation(promptKey) + if (admission.accepted || admission.reason !== 'backpressure') { + this.pendingCancellations.delete(promptKey) + } else if ( + this.pendingCancellations.has(promptKey) || + this.pendingCancellations.size < MAX_PENDING_PROMPT_CANCELLATIONS + ) { + this.pendingCancellations.add(promptKey) + } + return admission + } + + retryPendingCancellations(): void { + if (this.pendingCancellations.size === 0) { + return + } + for (const promptKey of this.pendingCancellations) { + const admission = this.admitCancellation(promptKey) + if (!admission.accepted && admission.reason === 'backpressure') { + return + } + this.pendingCancellations.delete(promptKey) + } + } + resolve(promptKey: string): void { this.items.delete(promptKey) + this.pendingCancellations.delete(promptKey) } clear(): void { this.items.clear() + this.pendingCancellations.clear() } } diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index c43ca316a50..b8a3c9fec49 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -255,6 +255,7 @@ export function createClaudeJournalTranslator( return { handle: (event) => { + prompts.retryPendingCancellations() if (event.type === 'ended') { streamedText.flush() // No event will ever settle a child once the provider is gone. diff --git a/src/main/claude/claude-structured-prompt-ownership.test.ts b/src/main/claude/claude-structured-prompt-ownership.test.ts index 1dd362ee856..f717a5063a0 100644 --- a/src/main/claude/claude-structured-prompt-ownership.test.ts +++ b/src/main/claude/claude-structured-prompt-ownership.test.ts @@ -702,4 +702,46 @@ describe('Claude live prompt ownership', () => { expect(tryAppendTombstone).not.toHaveBeenCalled() expect(tombstones).toEqual([]) }) + + it('dedupes, bounds, and clears prompt cancellation retries', () => { + const prompts = new ClaudeJournalPrompts({ + sink: { + appendItem: () => {}, + appendTombstone: () => {}, + publish: () => {}, + tryAppendLifecycleBatch: () => ({ accepted: false, reason: 'backpressure' }) + } + }) + const registerCancellation = (index: number): void => { + const promptKey = `permission-${index}` + const prompt: ClaudePendingPrompt = { + requestId: promptKey, + promptKey, + toolUseId: `tool-${index}`, + toolName: 'Bash', + kind: 'approval', + input: { command: 'git status' }, + suggestions: [], + questionIds: [], + answers: new Map(), + settle: vi.fn() + } + prompts.handle({ type: 'prompt', sessionId: 'session-1', prompt }) + prompts.cancel(promptKey) + } + + registerCancellation(0) + prompts.cancel('permission-0') + expect(prompts.pendingCancellationCount).toBe(1) + for (let index = 1; index < 65; index += 1) { + registerCancellation(index) + } + expect(prompts.pendingCancellationCount).toBe(64) + + prompts.resolve('permission-0') + expect(prompts.pendingCancellationCount).toBe(63) + prompts.clear() + expect(prompts.pendingCancellationCount).toBe(0) + expect(prompts.size).toBe(0) + }) })