From 9634184fdfc8ecb9f1a10841dabfc0928df5ea3f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Tue, 8 Sep 2026 00:25:02 -0700 Subject: [PATCH] Separate launch cancellation persistence from delivery settlement --- ...-agent-session-launch-cancellation.test.ts | 169 ++++++++++++++++++ ...tured-agent-session-launch-cancellation.ts | 109 +++++++++++ ...tured-agent-session-launch-notification.ts | 65 +++++++ .../structured-agent-session-launch.test.ts | 76 ++++++++ .../lib/structured-agent-session-launch.ts | 89 +++------ 5 files changed, 446 insertions(+), 62 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-cancellation.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-notification.ts diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts new file mode 100644 index 00000000000..445125b375e --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.ts b/src/renderer/src/lib/structured-agent-session-launch-cancellation.ts new file mode 100644 index 00000000000..55265d25218 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-cancellation.ts @@ -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 + detach: () => void + retryQueued: boolean + retrying: boolean +} + +// Cancellation owns persistence independently of terminal delivery receipts and launch callers. +const cancellations = new Map() +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 +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-notification.ts b/src/renderer/src/lib/structured-agent-session-launch-notification.ts new file mode 100644 index 00000000000..5db6c9a8fc5 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-notification.ts @@ -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 & { + 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 } + ) + } + ) + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts index 2f4278d8803..58bd147c25a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 6c837b5225c..104a2eb5243 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -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() 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(