retry claude prompt lifecycle admission

This commit is contained in:
Brennan Benson
2026-09-14 13:00:19 -07:00
parent 4c18f7eb73
commit a5feb00b2f
4 changed files with 200 additions and 1 deletions
@@ -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<string>
} {
const staged = new Map<string, AgentJournalItemBody>()
const durable = new Map<string, AgentJournalItemBody>()
const appliedSettlements = new Set<string>()
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)
}
)
})
@@ -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<string, ClaudeJournalPrompt[]>()
private readonly pendingCancellations = new Set<string>()
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()
}
}
@@ -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.
@@ -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)
})
})