Separate launch cancellation persistence from delivery settlement

This commit is contained in:
Merge Sim
2026-09-08 00:25:02 -07:00
parent e9c73bd572
commit 9634184fdf
5 changed files with 446 additions and 62 deletions
@@ -0,0 +1,169 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
hasStructuredLaunchCancellation,
persistStructuredLaunchCancellation,
retryStructuredLaunchCancellation,
subscribeStructuredLaunchCancellation
} from './structured-agent-session-launch-cancellation'
import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-launch-outbox'
import * as storage from '@/components/native-chat/structured-agent-session-outbox-storage'
import {
readOutbox,
subscribeOutbox
} from '@/components/native-chat/structured-agent-session-outbox-storage'
import {
transitionOutbox,
transitionOutboxEntry
} from '@/components/native-chat/structured-agent-session-outbox-transitions'
import type { StructuredAgentSessionLaunchIntent } from './launch-structured-agent-session'
const { abandon } = vi.hoisted(() => ({ abandon: vi.fn() }))
vi.mock('./launch-structured-agent-session', () => ({
abandonStructuredAgentSessionLaunchIntent: abandon
}))
function intent(id: string): StructuredAgentSessionLaunchIntent {
return {
sessionId: id,
worktreeId: id,
agent: 'codex',
params: {} as StructuredAgentSessionLaunchIntent['params']
}
}
function failRemoval() {
return vi.spyOn(localStorage, 'removeItem').mockImplementation(() => {
throw new Error('synthetic storage failure')
})
}
afterEach(() => {
vi.restoreAllMocks()
abandon.mockClear()
localStorage.clear()
})
describe('cancellation persistence ownership', () => {
it('retains one obligation through repeated failures and disposes its storage/status subscriptions on success', async () => {
const originalSubscribe = storage.subscribeOutbox
const storageDetach = vi.fn()
vi.spyOn(storage, 'subscribeOutbox').mockImplementation((session, listener) => {
const detach = originalSubscribe(session, listener)
return () => {
storageDetach()
detach()
}
})
const launch = intent('cancel-repeated')
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'old')
const notify = vi.fn()
const detach = subscribeStructuredLaunchCancellation(notify)
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
for (let i = 0; i < 4; i++) {
expect(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId)).toBe(false)
}
expect(failure).toHaveBeenCalledTimes(5)
expect(notify).toHaveBeenCalledTimes(1)
expect(abandon).not.toHaveBeenCalled()
await Promise.resolve()
expect(failure).toHaveBeenCalledTimes(5)
failure.mockRestore()
expect(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId)).toBe(true)
expect(hasStructuredLaunchCancellation(launch.worktreeId, 'codex')).toBe(false)
expect(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId)).toBeUndefined()
detach()
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'later')
await Promise.resolve()
expect(readOutbox(launch.sessionId)).toHaveLength(1)
expect(abandon).toHaveBeenCalledTimes(1)
expect(notify).toHaveBeenCalledTimes(2)
expect(storageDetach).toHaveBeenCalledTimes(1)
})
it('retries after a storage recovery commit without removing an unrelated later operation', async () => {
const launch = intent('cancel-recovery')
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'old')
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
failure.mockRestore()
const later = enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'later')!
await Promise.resolve()
expect(readOutbox(launch.sessionId).map((e) => e.clientMessageId)).toEqual([
later.clientMessageId
])
expect(hasStructuredLaunchCancellation(launch.worktreeId, 'codex')).toBe(false)
expect(abandon).toHaveBeenCalledTimes(1)
})
it('does not remove an explicit Retry incarnation or let stale completion remove it', async () => {
const launch = intent('cancel-incarnation')
const old = enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'old')!
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
failure.mockRestore()
transitionOutboxEntry(old, (current) => ({ ...current, deliveryIncarnation: 1 }))
await Promise.resolve()
expect(readOutbox(launch.sessionId)[0].deliveryIncarnation).toBe(1)
expect(transitionOutboxEntry(old, () => null, true).changed).toBe(false)
expect(hasStructuredLaunchCancellation(launch.worktreeId, 'codex')).toBe(false)
expect(readOutbox(launch.sessionId)).toHaveLength(1)
})
it('defers commit retries and rejects synchronous reentrant retry', async () => {
const launch = intent('cancel-reentrant')
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'old')
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
failure.mockRestore()
const nested: (boolean | undefined)[] = []
const detach = subscribeOutbox(launch.sessionId, () =>
nested.push(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId))
)
expect(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId)).toBe(true)
expect(nested).toEqual([false])
detach()
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'new generation')
await Promise.resolve()
expect(readOutbox(launch.sessionId)).toHaveLength(1)
expect(abandon).toHaveBeenCalledTimes(1)
})
it('bounds a failed commit-triggered retry without a self-scheduling loop', async () => {
const launch = intent('cancel-failed-retry')
const old = enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'old')!
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
transitionOutboxEntry(old, (current) => ({ ...current, state: 'unconfirmed' }))
await Promise.resolve()
await Promise.resolve()
expect(failure).toHaveBeenCalledTimes(2)
expect(hasStructuredLaunchCancellation(launch.worktreeId, 'codex')).toBe(true)
failure.mockRestore()
expect(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId)).toBe(true)
})
it('ignores the wrong worktree and disposes when another owner durably removes the targeted incarnation', async () => {
const launch = intent('cancel-external-retirement')
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'old')
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
expect(retryStructuredLaunchCancellation('other', launch.sessionId)).toBeUndefined()
failure.mockRestore()
transitionOutbox(launch.sessionId, () => [])
await Promise.resolve()
expect(hasStructuredLaunchCancellation(launch.worktreeId, 'codex')).toBe(false)
expect(abandon).toHaveBeenCalledTimes(1)
})
it('does not run a queued retry against a newer cancellation generation for the same session', async () => {
const launch = intent('cancel-record-generation')
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'first')
expect(persistStructuredLaunchCancellation(launch)).toBe(true)
enqueueStructuredAgentSessionLaunchPrompt(launch.sessionId, 'second')
const failure = failRemoval()
expect(persistStructuredLaunchCancellation(launch)).toBe(false)
await Promise.resolve()
expect(failure).toHaveBeenCalledTimes(1)
expect(readOutbox(launch.sessionId)).toHaveLength(1)
failure.mockRestore()
expect(retryStructuredLaunchCancellation(launch.worktreeId, launch.sessionId)).toBe(true)
})
})
@@ -0,0 +1,109 @@
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import {
abandonStructuredAgentSessionLaunchIntent,
type StructuredAgentSessionLaunchIntent
} from './launch-structured-agent-session'
import {
readOutbox,
subscribeOutbox
} from '@/components/native-chat/structured-agent-session-outbox-storage'
import { transitionOutbox } from '@/components/native-chat/structured-agent-session-outbox-transitions'
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
type CancellationObligation = {
intent: StructuredAgentSessionLaunchIntent
incarnations: Set<string>
detach: () => void
retryQueued: boolean
retrying: boolean
}
// Cancellation owns persistence independently of terminal delivery receipts and launch callers.
const cancellations = new Map<string, CancellationObligation>()
const listeners = new Set<() => void>()
function incarnationKey(entry: StructuredAgentSessionOutboxEntry): string {
return JSON.stringify([entry.clientMessageId, entry.deliveryIncarnation ?? 0])
}
function notify(): void {
for (const listener of listeners) {
listener()
}
}
export function subscribeStructuredLaunchCancellation(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function hasStructuredLaunchCancellation(
worktreeId: string,
agent: AgentSessionHandleProvider
): boolean {
return [...cancellations.values()].some(
({ intent }) => intent.worktreeId === worktreeId && intent.agent === agent
)
}
function retryCancellation(obligation: CancellationObligation): boolean {
const { intent, incarnations } = obligation
if (cancellations.get(intent.sessionId) !== obligation || obligation.retrying) {
return false
}
obligation.retrying = true
const result = transitionOutbox(intent.sessionId, (entries) =>
entries.filter((entry) => !incarnations.has(incarnationKey(entry)))
)
obligation.retrying = false
if (!result.ok) {
return false
}
cancellations.delete(intent.sessionId)
obligation.detach()
incarnations.clear()
abandonStructuredAgentSessionLaunchIntent(intent)
notify()
return true
}
export function retryStructuredLaunchCancellation(
worktreeId: string,
sessionId: string
): boolean | undefined {
const obligation = cancellations.get(sessionId)
return obligation?.intent.worktreeId === worktreeId ? retryCancellation(obligation) : undefined
}
export function persistStructuredLaunchCancellation(
intent: StructuredAgentSessionLaunchIntent
): boolean {
const existing = cancellations.get(intent.sessionId)
if (existing) {
return retryCancellation(existing)
}
const obligation: CancellationObligation = {
intent,
incarnations: new Set(readOutbox(intent.sessionId, false).map(incarnationKey)),
detach: () => {},
retryQueued: false,
retrying: false
}
cancellations.set(intent.sessionId, obligation)
obligation.detach = subscribeOutbox(intent.sessionId, () => {
if (obligation.retryQueued) {
return
}
obligation.retryQueued = true
// Retry after publication, never recursively inside another owner's storage commit.
queueMicrotask(() => {
obligation.retryQueued = false
retryCancellation(obligation)
})
})
const persisted = retryCancellation(obligation)
if (!persisted) {
notify()
}
return persisted
}
@@ -0,0 +1,65 @@
import { toast } from 'sonner'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { getAgentCatalog } from './agent-catalog'
import { translate } from '@/i18n/i18n'
import { StructuredAgentSessionCreateRefusalError } from './launch-structured-agent-session'
import {
StructuredAgentSessionLaunchCancelledError,
type StructuredLaunchRecoveryState
} from './structured-agent-session-launch-recovery'
import type { StructuredLaunchCallerGroup } from './structured-agent-session-launch-callers'
export function structuredAgentLabel(agent: AgentSessionHandleProvider): string {
return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent
}
export function trackLaunchFailureToast(
state: Pick<StructuredLaunchRecoveryState, 'intent' | 'promise'> & {
callers: StructuredLaunchCallerGroup
}
): void {
void state.promise.catch(async (error) => {
if (error instanceof StructuredAgentSessionLaunchCancelledError) {
return
}
const agentLabel = structuredAgentLabel(state.intent.agent)
if (
error instanceof StructuredAgentSessionCreateRefusalError &&
(await state.callers.refusalSettlement.promise.catch(() => false))
) {
// Why: the callback proves the fallback was attempted, not that its terminal became visible.
toast.message(
translate(
'components.native-chat.structuredSessionFellBackToTerminal',
"Structured chat isn't available"
),
{
description: translate(
'components.native-chat.structuredSessionFellBackToTerminalDescription',
'Orca tried to open a {{value0}} terminal instead.',
{ value0: agentLabel }
)
}
)
return
}
// Why: the raw error carries errnos and absolute paths; it belongs in the log, not the toast.
console.warn('[native-chat] structured launch failed', error)
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open {{value0}} chat',
{
value0: agentLabel
}
),
{
description: translate(
'components.native-chat.structuredSessionLaunchFailedDescription',
'Orca could not open a structured {{value0}} chat. See the logs for details.',
{ value0: agentLabel }
)
}
)
})
}
@@ -72,6 +72,7 @@ import {
getStructuredAgentLaunchStatus,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
import * as launchCallers from './structured-agent-session-launch-callers'
import { readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage'
function launchIntent(
@@ -718,4 +719,79 @@ describe('startStructuredAgentLaunch', () => {
expect(mocks.abandonIntent).toHaveBeenCalledWith(intent)
expect(toast.error).not.toHaveBeenCalled()
})
it('failed cancellation remains retryable after published unknown delivery settles unavailable', async () => {
const groups = vi.spyOn(launchCallers, 'createStructuredLaunchCallerGroup')
const worktreeId = 'wt-independent-published-cancel'
const intent = launchIntent(worktreeId)
mocks.createIntent.mockReturnValueOnce(intent)
mocks.launch.mockResolvedValueOnce({ sessionId: intent.sessionId, fence: 1 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, intent.sessionId)
])
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
const launch = startStructuredAgentLaunch(worktreeId, 'codex', {
prompt: 'retain failed cancel'
})
await launch.launchResult
await flushLaunchSettlement()
expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('pending')
const before = readOutbox(intent.sessionId, false)
const spy = vi.spyOn(localStorage, 'removeItem').mockImplementation(() => {
throw new Error('synthetic storage failure')
})
expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(false)
spy.mockRestore()
await expect(launch.promptDeliveryResult).resolves.toMatchObject({ delivered: false })
await flushLaunchSettlement()
const group = groups.mock.results.at(-1)!.value
expect(group.entries.size).toBe(0)
expect(group.promptDeliveryResults.size).toBe(0)
groups.mockRestore()
const status = getStructuredAgentLaunchStatus(worktreeId, 'codex')
expect(readOutbox(intent.sessionId, false)).toEqual(before)
const retry = cancelStructuredAgentLaunch(worktreeId, intent.sessionId)
expect(status).toBe('pending')
expect(retry).toBe(true)
expect(readOutbox(intent.sessionId, false)).toEqual([])
})
it('keeps a later launch independent while failed cancellation and stale create settle', async () => {
const worktreeId = 'wt-cancel-generation'
const old = launchIntent(worktreeId, 'cancel-old-session')
const next = launchIntent(worktreeId, 'cancel-new-session')
const pending = Promise.withResolvers<{ sessionId: string; fence: number }>()
mocks.createIntent.mockReturnValueOnce(old).mockReturnValueOnce(next)
mocks.launch
.mockReturnValueOnce(pending.promise)
.mockResolvedValueOnce({ sessionId: next.sessionId, fence: 2 })
vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([
publishedSnapshot(worktreeId, next.sessionId)
])
mocks.callStructuredAgentSession.mockResolvedValue({
ok: true,
value: { submission: { dispatchState: 'unknown' } }
})
const first = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'old' })
const rejected = expect(first.launchResult).rejects.toThrow('cancelled')
const failure = vi.spyOn(localStorage, 'removeItem').mockImplementation(() => {
throw new Error('synthetic failure')
})
expect(cancelStructuredAgentLaunch(worktreeId, old.sessionId)).toBe(false)
const second = startStructuredAgentLaunch(worktreeId, 'codex', { prompt: 'new' })
await second.launchResult
failure.mockRestore()
expect(cancelStructuredAgentLaunch(worktreeId, old.sessionId)).toBe(true)
pending.resolve({ sessionId: old.sessionId, fence: 1 })
await rejected
await flushLaunchSettlement()
expect(mocks.launch).toHaveBeenCalledTimes(2)
expect(readOutbox(next.sessionId)).toHaveLength(1)
expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('pending')
expect(cancelStructuredAgentLaunch(worktreeId, next.sessionId)).toBe(true)
await expect(second.promptDeliveryResult).resolves.toMatchObject({ delivered: false })
expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('idle')
})
})
@@ -1,8 +1,9 @@
import {
structuredAgentLabel,
trackLaunchFailureToast
} from './structured-agent-session-launch-notification'
import { useSyncExternalStore } from 'react'
import { toast } from 'sonner'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import { getAgentCatalog } from '@/lib/agent-catalog'
import { translate } from '@/i18n/i18n'
import {
abandonStructuredAgentSessionLaunchIntent,
createStructuredAgentSessionLaunchIntent,
@@ -15,7 +16,6 @@ import {
import {
launchAndReconcile,
reconcileUnknownLaunch,
StructuredAgentSessionLaunchCancelledError,
type StructuredAgentLaunchReceipt,
type StructuredLaunchRecoveryState
} from '@/lib/structured-agent-session-launch-recovery'
@@ -34,6 +34,12 @@ import {
type StructuredRefusalFallback
} from '@/lib/structured-agent-session-launch-callers'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
import {
hasStructuredLaunchCancellation,
persistStructuredLaunchCancellation,
retryStructuredLaunchCancellation,
subscribeStructuredLaunchCancellation
} from './structured-agent-session-launch-cancellation'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
@@ -58,10 +64,6 @@ export type StructuredAgentLaunchResult = {
export type StructuredAgentLaunchStatus = 'idle' | 'pending' | 'unknown'
function structuredAgentLabel(agent: AgentSessionHandleProvider): string {
return getAgentCatalog().find((entry) => entry.id === agent)?.label ?? agent
}
const pendingStructuredLaunchesByIdentity = new Map<string, StructuredLaunchState>()
const structuredLaunchListeners = new Set<() => void>()
@@ -73,7 +75,11 @@ function notifyStructuredLaunchListeners(): void {
export function subscribeStructuredAgentLaunchStatus(listener: () => void): () => void {
structuredLaunchListeners.add(listener)
return () => structuredLaunchListeners.delete(listener)
const detachCancellation = subscribeStructuredLaunchCancellation(listener)
return () => {
structuredLaunchListeners.delete(listener)
detachCancellation()
}
}
export function getStructuredAgentLaunchStatus(
@@ -88,6 +94,9 @@ export function getStructuredAgentLaunchStatus(
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
.map(([, state]) => state)
].filter((state): state is StructuredLaunchState => Boolean(state))
if (hasStructuredLaunchCancellation(worktreeId, agent)) {
return 'pending'
}
if (states.length === 0) {
return 'idle'
}
@@ -151,7 +160,7 @@ function trackLaunchSettlement(
): void {
void promise.then(
() => {
if (state.promise !== promise) {
if (state.promise !== promise || state.cancelled) {
return
}
settleStructuredLaunchCallersWithoutFallback(state.callers, 'published')
@@ -174,53 +183,6 @@ function trackLaunchSettlement(
)
}
function trackLaunchFailureToast(state: StructuredLaunchState): void {
void state.promise.catch(async (error) => {
if (error instanceof StructuredAgentSessionLaunchCancelledError) {
return
}
const agentLabel = structuredAgentLabel(state.intent.agent)
if (
error instanceof StructuredAgentSessionCreateRefusalError &&
(await state.callers.refusalSettlement.promise.catch(() => false))
) {
// Why: the callback proves the fallback was attempted, not that its terminal became visible.
toast.message(
translate(
'components.native-chat.structuredSessionFellBackToTerminal',
"Structured chat isn't available"
),
{
description: translate(
'components.native-chat.structuredSessionFellBackToTerminalDescription',
'Orca tried to open a {{value0}} terminal instead.',
{ value0: agentLabel }
)
}
)
return
}
// Why: the raw error carries errnos and absolute paths; it belongs in the log, not the toast.
console.warn('[native-chat] structured launch failed', error)
toast.error(
translate(
'components.native-chat.structuredSessionLaunchFailed',
'Could not open {{value0}} chat',
{
value0: agentLabel
}
),
{
description: translate(
'components.native-chat.structuredSessionLaunchFailedDescription',
'Orca could not open a structured {{value0}} chat. See the logs for details.',
{ value0: agentLabel }
)
}
)
})
}
function structuredAgentLaunchState(
worktreeId: string,
agent: AgentSessionHandleProvider,
@@ -298,6 +260,10 @@ function structuredAgentLaunchState(
}
export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: string): boolean {
const retry = retryStructuredLaunchCancellation(worktreeId, sessionId)
if (retry !== undefined) {
return retry
}
const state = [...pendingStructuredLaunchesByIdentity.values()].find(
(candidate) =>
candidate.intent.worktreeId === worktreeId && candidate.intent.sessionId === sessionId
@@ -305,15 +271,14 @@ export function cancelStructuredAgentLaunch(worktreeId: string, sessionId: strin
if (!state) {
return false
}
if (!discardStructuredAgentSessionLaunchOutbox(state.intent.sessionId)) {
return false
}
// Stop create reconciliation now; durable discard has its own retry owner.
state.cancelled = true
cleanupLaunchState(state)
const persisted = persistStructuredLaunchCancellation(state.intent)
settleStructuredLaunchCallersWithoutFallback(state.callers, 'cancelled')
cleanupLaunchState(state)
abandonStructuredAgentSessionLaunchIntent(state.intent)
notifyStructuredLaunchListeners()
return true
return persisted
}
export function startStructuredAgentLaunch(