mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16:02:38 +00:00
retry claude prompt lifecycle admission
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user