mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Separate launch cancellation persistence from delivery settlement
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user