mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
fix(native-chat): mark a chat whose start failed on its tab and workspace row (#24904)
* fix(native-chat): mark a chat whose start failed on its tab and workspace row A chat whose start the host refused said so only inside its own tab. Its row now carries the failed verdict the host publishes for a refused start, so the tab and the workspace row show it from elsewhere until a retry starts it or the tab closes. The + menu no longer shows a failed start as still starting, so launching the agent again retries it. * fix(native-chat): date the failed-start mark by the failure; keep the + menu change for a separate PR The failed-start mark on a chat's tab and workspace row is now dated by when the start failed, the way a failure the host reports is dated: it ages out on the same schedule, a restart does not bring an old mark back, and a failure the user already saw stays read. The failure time is saved with the launch record so a restart keeps it; records saved without it fall back to the tab's creation time. The + menu change (letting a failed agent be picked again) moves to its own PR: its correct form makes a new chat instead of retrying the failed one, which changes how launches join. * test: name the local host on the saved failed-launch record * fix(native-chat): re-check an unconfirmed chat start when its host is reachable again A chat create whose reply was lost, or that a reload interrupted, stayed unknown until the user pressed Retry in its tab. Each host subscription opens with one full inventory; on it, every launch that host owns whose outcome is unknown (including ones restored from the saved launch record) is re-checked through the existing Retry path, so it settles to started or failed. One still unknown waits for the host's next return; cancelled launches are skipped.
This commit is contained in:
@@ -432,6 +432,7 @@ writers:
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| Command Code output seeds, parked-pane seeds, pty-exit removal | delete; main already emits the same facts |
|
||||
| structured bridge status writes | delete; main now publishes the row |
|
||||
| structured bridge failed-start row (the host refused the create) | keep; a refused create leaves the host no session, so the bridge writes it from the launch record |
|
||||
| launch placeholder seeds (a user launched an agent with a prompt) | keep for now; main holds the launch config and can seed later |
|
||||
| dismissal, acknowledgement, unmount | keep; user facts and component lifecycle |
|
||||
| remote-runtime OSC parse (bytes never transit local main) | keep, fenced behind the host's published row once the host is new enough; rule 3 of the wire doc applies |
|
||||
|
||||
@@ -28,6 +28,9 @@ import {
|
||||
structuredAgentSessionDatedMainAgent,
|
||||
structuredAgentSessionRowStateStartedAt
|
||||
} from '../../../../shared/structured-agent-session-status-started-at'
|
||||
import { agentMainAgentVerdict } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { useStructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch-registry'
|
||||
import { useStructuredAgentSessionLaunchFailedAt } from '@/lib/structured-agent-session-launch-failed-at'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
@@ -93,16 +96,60 @@ function childWorkFor(summary: AgentSessionStatusSummary): {
|
||||
return subagents ? { subagents } : {}
|
||||
}
|
||||
|
||||
/** A start the host refused leaves it no session to publish, so the launch's own failure is the
|
||||
* row: the same failed verdict the host publishes for a send the agent's start refused. */
|
||||
function projectFailedStart(tab: StructuredTab, paneKey: string, failedAt: number): void {
|
||||
const store = useAppStore.getState()
|
||||
const current = store.agentStatusByPaneKey?.[paneKey]
|
||||
if (
|
||||
current?.state === 'done' &&
|
||||
agentMainAgentVerdict(current) === 'failure' &&
|
||||
current.updatedAt === failedAt &&
|
||||
current.stateStartedAt === failedAt &&
|
||||
current.agentType === tab.agentSessionAgent &&
|
||||
current.terminalTitle === tab.label &&
|
||||
current.tabId === tab.id &&
|
||||
current.worktreeId === tab.worktreeId
|
||||
) {
|
||||
return
|
||||
}
|
||||
const { state, mainAgent } = structuredAgentSessionAgentStatus({
|
||||
status: 'idle',
|
||||
turnOutcome: 'failure'
|
||||
})
|
||||
store.setAgentStatus(
|
||||
paneKey,
|
||||
{
|
||||
state,
|
||||
mainAgent: { ...mainAgent, stateStartedAt: failedAt },
|
||||
interrupted: false,
|
||||
prompt: '',
|
||||
agentType: tab.agentSessionAgent,
|
||||
sessionBoundary: false
|
||||
},
|
||||
tab.label,
|
||||
// Dated by the failure, as a host row is by its journal: it ages the same, a restart does not
|
||||
// refresh it, and it replaces whatever newer-dated row the pane key held.
|
||||
{ updatedAt: failedAt, allowOlderTimestamp: true, stateStartedAt: failedAt },
|
||||
{ tabId: tab.id, worktreeId: tab.worktreeId },
|
||||
{ terminalResumeEligible: false }
|
||||
)
|
||||
}
|
||||
|
||||
function projectStatus(
|
||||
tab: StructuredTab,
|
||||
summary: AgentSessionStatusSummary | null,
|
||||
observation: 'live' | 'unverifiable'
|
||||
observation: 'live' | 'unverifiable',
|
||||
/** When the launch failed; null while it has not. */
|
||||
launchFailedAt: number | null
|
||||
): void {
|
||||
const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId)
|
||||
const store = useAppStore.getState()
|
||||
// No persisted turn yet (or nothing known): the row shows no agent status at all.
|
||||
if (!summary?.status) {
|
||||
if (store.agentStatusByPaneKey?.[paneKey]) {
|
||||
if (launchFailedAt !== null) {
|
||||
projectFailedStart(tab, paneKey, launchFailedAt)
|
||||
} else if (store.agentStatusByPaneKey?.[paneKey]) {
|
||||
store.removeAgentStatus(paneKey)
|
||||
}
|
||||
return
|
||||
@@ -219,9 +266,15 @@ function StructuredAgentSessionOwnedStatusProjection({
|
||||
target: RuntimeClientTarget
|
||||
}): null {
|
||||
const { summary, observation } = useStructuredAgentSessionStatusSummary(tab.entityId, target)
|
||||
const launchFailed =
|
||||
useStructuredAgentSessionLaunchLifecycle(tab.worktreeId, tab.entityId) === 'failed'
|
||||
const failedAt = useStructuredAgentSessionLaunchFailedAt(tab.entityId)
|
||||
// Only records saved by older builds lack the time; the tab's creation precedes any
|
||||
// acknowledgement of it, so a failure seen before then stays read.
|
||||
const launchFailedAt = launchFailed ? (failedAt ?? tab.createdAt) : null
|
||||
useEffect(() => {
|
||||
projectStatus(tab, summary, observation)
|
||||
}, [summary, observation, tab])
|
||||
projectStatus(tab, summary, observation, launchFailedAt)
|
||||
}, [summary, observation, tab, launchFailedAt])
|
||||
useEffect(
|
||||
() => () =>
|
||||
useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)),
|
||||
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A chat whose start the host refused has no host session, so nothing publishes its row. The
|
||||
// bridge marks it failed from the launch's own outcome, so the tab and the workspace row show it
|
||||
// to a user looking elsewhere, until a retry starts it or the tab closes.
|
||||
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { AgentSessionStatusEvent } from '../../../../shared/agent-session-wire'
|
||||
import { agentVerdictDisplayMark } from '../../../../shared/agent-main-agent-verdict'
|
||||
import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-freshness'
|
||||
import { structuredAgentSessionAgentStatus } from '../../../../shared/structured-agent-session-agent-status'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
type TestStore = {
|
||||
getState: () => AppState
|
||||
setState: (state: Partial<AppState> & { testRuntimeOwner?: string | null }) => void
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const hoisted: {
|
||||
store: TestStore | null
|
||||
subscribeStatus: Mock
|
||||
unsubscribe: Mock
|
||||
createIntent: Mock
|
||||
retryIntent: Mock
|
||||
restoreIntent: Mock
|
||||
launch: Mock
|
||||
} = {
|
||||
store: null,
|
||||
subscribeStatus: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
retryIntent: vi.fn(),
|
||||
restoreIntent: vi.fn(),
|
||||
launch: vi.fn()
|
||||
}
|
||||
return hoisted
|
||||
})
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { createTestStore } = await import('@/store/slices/store-test-helpers')
|
||||
const useAppStore = createTestStore()
|
||||
mocks.store = useAppStore
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null,
|
||||
getExecutionHostIdForWorktree: () => 'local'
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: vi.fn(),
|
||||
subscribeStructuredAgentSession: vi.fn(),
|
||||
subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: vi.fn(async () => [])
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: mocks.retryIntent,
|
||||
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
|
||||
abandonStructuredAgentSessionLaunchIntent: vi.fn(),
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
}
|
||||
})
|
||||
|
||||
import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge'
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import {
|
||||
retryStructuredAgentSessionLaunch,
|
||||
startStructuredAgentLaunch
|
||||
} from '@/lib/structured-agent-session-launch'
|
||||
import { resetStructuredAgentLaunchRegistryForTests } from '@/lib/structured-agent-session-launch-registry'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence'
|
||||
import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed'
|
||||
import {
|
||||
resetTerminalTabActivityFlagsCacheForTest,
|
||||
resolveTerminalTabActivityStatus
|
||||
} from '../tab-bar/terminal-tab-activity-status'
|
||||
import { selectWorktreeAgentActivitySummary } from '../sidebar/worktree-agent-activity-summary'
|
||||
import { countActivityUnread } from '../activity/useActivityUnreadCount'
|
||||
|
||||
const WORKTREE_ID = 'wt-1'
|
||||
const SESSION_ID = 'session-1'
|
||||
const TAB_CREATED_AT = 1_000
|
||||
const FAILED_AT = 10 * 60 * 60_000
|
||||
const PANE_KEY = structuredAgentSessionPaneKey(`structured-agent-session-${SESSION_ID}`, SESSION_ID)
|
||||
|
||||
const structuredTab = {
|
||||
id: `structured-agent-session-${SESSION_ID}`,
|
||||
worktreeId: WORKTREE_ID,
|
||||
groupId: 'group-1',
|
||||
contentType: 'agent-session',
|
||||
entityId: SESSION_ID,
|
||||
label: 'Claude Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: TAB_CREATED_AT,
|
||||
isPinned: false,
|
||||
agentSessionAgent: 'claude'
|
||||
} satisfies Tab
|
||||
|
||||
const intent: StructuredAgentSessionLaunchIntent = {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId: SESSION_ID,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent: 'claude',
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId: SESSION_ID,
|
||||
clientOperationId: 'operation-1',
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: 'fingerprint-1'
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent: 'claude'
|
||||
}
|
||||
}
|
||||
|
||||
function store(): TestStore {
|
||||
if (!mocks.store) {
|
||||
throw new Error('store missing')
|
||||
}
|
||||
return mocks.store
|
||||
}
|
||||
|
||||
function rows(): AgentStatusEntry[] {
|
||||
return Object.values(store().getState().agentStatusByPaneKey)
|
||||
}
|
||||
|
||||
/** What the chat's own tab shows, resolved as the tab bar resolves a structured tab. */
|
||||
function tabStatus(): string {
|
||||
const state = store().getState()
|
||||
return resolveTerminalTabActivityStatus({
|
||||
tab: { id: structuredTab.id, title: structuredTab.label, launchAgent: 'claude' },
|
||||
agentStatusByPaneKey: state.agentStatusByPaneKey,
|
||||
agentStatusEpoch: state.agentStatusEpoch
|
||||
})
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<(event: AgentSessionStatusEvent) => void> {
|
||||
render(<StructuredAgentSessionStatusBridge />)
|
||||
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce())
|
||||
const emit: (event: AgentSessionStatusEvent) => void = mocks.subscribeStatus.mock.calls[0]?.[1]
|
||||
// The host never created the session, so it publishes nothing for it.
|
||||
act(() => emit({ type: 'snapshot', sessions: [] }))
|
||||
return (event) => act(() => emit(event))
|
||||
}
|
||||
|
||||
/** A restart keeps the saved launch and the acknowledgements, but no rows and no launch in memory. */
|
||||
async function restart(): Promise<void> {
|
||||
const { acknowledgedAgentsByPaneKey } = store().getState()
|
||||
cleanup()
|
||||
store().setState({ agentStatusByPaneKey: {}, acknowledgedAgentsByPaneKey })
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
resetTerminalTabActivityFlagsCacheForTest()
|
||||
mocks.subscribeStatus.mockClear()
|
||||
await connect()
|
||||
}
|
||||
|
||||
function setClock(now: number): void {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
}
|
||||
|
||||
function acknowledge(at: number): void {
|
||||
store().setState({ acknowledgedAgentsByPaneKey: { [PANE_KEY]: at } })
|
||||
}
|
||||
|
||||
function unread(): number {
|
||||
return countActivityUnread(store().getState())
|
||||
}
|
||||
|
||||
let failedStarts = 0
|
||||
|
||||
async function failStart(): Promise<void> {
|
||||
mocks.createIntent.mockReturnValueOnce(intent)
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
|
||||
failedStarts += 1
|
||||
// Each failed start stands for its own user action.
|
||||
const launch = startStructuredAgentLaunch(WORKTREE_ID, 'claude', {
|
||||
requestId: `request-${failedStarts}`
|
||||
})
|
||||
await expect(launch.launchResult).rejects.toBeInstanceOf(StructuredAgentSessionCreateRefusalError)
|
||||
await flush()
|
||||
}
|
||||
|
||||
describe('a chat whose start failed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
resetTerminalTabActivityFlagsCacheForTest()
|
||||
mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe })
|
||||
mocks.retryIntent.mockImplementation((prior: StructuredAgentSessionLaunchIntent) => prior)
|
||||
mocks.restoreIntent.mockReturnValue(intent)
|
||||
store().setState({
|
||||
agentStatusByPaneKey: {},
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
unifiedTabsByWorktree: { [WORKTREE_ID]: [structuredTab] }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('marks its tab and its workspace row failed', async () => {
|
||||
await connect()
|
||||
expect(rows()).toEqual([])
|
||||
|
||||
await failStart()
|
||||
|
||||
const [row] = rows()
|
||||
expect(row).toMatchObject({
|
||||
state: 'done',
|
||||
mainAgent: { state: 'done', outcome: 'failure' },
|
||||
tabId: structuredTab.id,
|
||||
worktreeId: WORKTREE_ID,
|
||||
agentType: 'claude'
|
||||
})
|
||||
expect(row && agentVerdictDisplayMark(row)).toBe('failed')
|
||||
expect(tabStatus()).toBe('failed')
|
||||
expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasFailed).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the mark while a retry starts it, and leaves the started chat to the host', async () => {
|
||||
const emit = await connect()
|
||||
await failStart()
|
||||
expect(rows()).toHaveLength(1)
|
||||
|
||||
let publish: (receipt: { sessionId: string; fence: number }) => void = () => {}
|
||||
mocks.launch.mockReturnValueOnce(new Promise((resolve) => (publish = resolve)))
|
||||
act(() => {
|
||||
retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID)
|
||||
})
|
||||
await flush()
|
||||
expect(rows()).toEqual([])
|
||||
|
||||
publish({ sessionId: SESSION_ID, fence: 1 })
|
||||
await flush()
|
||||
expect(rows()).toEqual([])
|
||||
emit({
|
||||
type: 'status',
|
||||
session: {
|
||||
sessionId: SESSION_ID,
|
||||
workspaceId: WORKTREE_ID,
|
||||
agent: 'claude',
|
||||
status: 'working',
|
||||
latestPrompt: 'hello',
|
||||
updatedAt: 50_000
|
||||
}
|
||||
})
|
||||
expect(rows()).toEqual([expect.objectContaining({ state: 'working' })])
|
||||
})
|
||||
|
||||
it('drops the mark when its tab closes', async () => {
|
||||
await connect()
|
||||
await failStart()
|
||||
expect(rows()).toHaveLength(1)
|
||||
|
||||
act(() => store().setState({ unifiedTabsByWorktree: { [WORKTREE_ID]: [] } }))
|
||||
await flush()
|
||||
|
||||
expect(rows()).toEqual([])
|
||||
})
|
||||
|
||||
it('stays read across a restart once seen, and a later failure is news again', async () => {
|
||||
setClock(FAILED_AT)
|
||||
await connect()
|
||||
await failStart()
|
||||
expect(rows()).toEqual([
|
||||
expect.objectContaining({ updatedAt: FAILED_AT, stateStartedAt: FAILED_AT })
|
||||
])
|
||||
expect(unread()).toBe(1)
|
||||
setClock(FAILED_AT + 60_000)
|
||||
acknowledge(FAILED_AT + 60_000)
|
||||
expect(unread()).toBe(0)
|
||||
|
||||
await restart()
|
||||
expect(rows()).toEqual([
|
||||
expect.objectContaining({ state: 'done', updatedAt: FAILED_AT, stateStartedAt: FAILED_AT })
|
||||
])
|
||||
expect(unread()).toBe(0)
|
||||
expect(tabStatus()).toBe('failed')
|
||||
|
||||
setClock(FAILED_AT + 120_000)
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
|
||||
act(() => {
|
||||
retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID)
|
||||
})
|
||||
await flush()
|
||||
expect(rows()).toEqual([
|
||||
expect.objectContaining({
|
||||
stateStartedAt: FAILED_AT + 120_000,
|
||||
mainAgent: expect.objectContaining({ outcome: 'failure' })
|
||||
})
|
||||
])
|
||||
expect(unread()).toBe(1)
|
||||
})
|
||||
|
||||
it('is unread after a restart when it failed after the chat was last viewed', async () => {
|
||||
setClock(FAILED_AT - 60_000)
|
||||
await connect()
|
||||
// Viewed while it was starting; it failed after the user looked away.
|
||||
acknowledge(FAILED_AT - 60_000)
|
||||
setClock(FAILED_AT)
|
||||
await failStart()
|
||||
expect(unread()).toBe(1)
|
||||
|
||||
setClock(FAILED_AT + 60_000)
|
||||
await restart()
|
||||
expect(rows()).toEqual([expect.objectContaining({ stateStartedAt: FAILED_AT })])
|
||||
expect(unread()).toBe(1)
|
||||
})
|
||||
|
||||
it('ages like a host-reported failure: a restart does not bring back an old mark', async () => {
|
||||
setClock(FAILED_AT)
|
||||
await connect()
|
||||
await failStart()
|
||||
expect(tabStatus()).toBe('failed')
|
||||
|
||||
setClock(FAILED_AT + AGENT_STATUS_STALE_AFTER_MS + 1)
|
||||
await restart()
|
||||
expect(rows()).toEqual([expect.objectContaining({ updatedAt: FAILED_AT })])
|
||||
expect(tabStatus()).not.toBe('failed')
|
||||
expect(selectWorktreeAgentActivitySummary(store().getState(), WORKTREE_ID).hasFailed).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('dates a retry that fails again by the new failure', async () => {
|
||||
setClock(FAILED_AT)
|
||||
await connect()
|
||||
await failStart()
|
||||
acknowledge(FAILED_AT + 1)
|
||||
expect(unread()).toBe(0)
|
||||
|
||||
setClock(FAILED_AT + 60_000)
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
|
||||
act(() => {
|
||||
retryStructuredAgentSessionLaunch(WORKTREE_ID, SESSION_ID)
|
||||
})
|
||||
await flush()
|
||||
expect(rows()).toEqual([
|
||||
expect.objectContaining({ updatedAt: FAILED_AT + 60_000, stateStartedAt: FAILED_AT + 60_000 })
|
||||
])
|
||||
expect(unread()).toBe(1)
|
||||
})
|
||||
|
||||
it('re-dates a failed row the pane key already held by its own failure', async () => {
|
||||
setClock(FAILED_AT - 60_000)
|
||||
await connect()
|
||||
const { state, mainAgent } = structuredAgentSessionAgentStatus({
|
||||
status: 'idle',
|
||||
turnOutcome: 'failure'
|
||||
})
|
||||
act(() =>
|
||||
store()
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
PANE_KEY,
|
||||
{
|
||||
state,
|
||||
mainAgent: { ...mainAgent, stateStartedAt: FAILED_AT - 60_000 },
|
||||
interrupted: false,
|
||||
prompt: '',
|
||||
agentType: 'claude',
|
||||
sessionBoundary: false
|
||||
},
|
||||
structuredTab.label,
|
||||
undefined,
|
||||
{ tabId: structuredTab.id, worktreeId: WORKTREE_ID }
|
||||
)
|
||||
)
|
||||
expect(rows()).toEqual([expect.objectContaining({ updatedAt: FAILED_AT - 60_000 })])
|
||||
|
||||
setClock(FAILED_AT)
|
||||
await failStart()
|
||||
expect(rows()).toEqual([
|
||||
expect.objectContaining({ updatedAt: FAILED_AT, stateStartedAt: FAILED_AT })
|
||||
])
|
||||
})
|
||||
})
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
// A chat whose create got no answer (a lost reply, or a reload mid-start) may or may not exist on
|
||||
// its host. It shows no mark while that is unknown; when the host is reachable again the launch is
|
||||
// re-checked without the user, and settles to started (no mark) or failed (marked).
|
||||
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session'
|
||||
import type { AppState } from '@/store/types'
|
||||
|
||||
type TestStore = {
|
||||
getState: () => AppState
|
||||
setState: (state: Partial<AppState>) => void
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const hoisted: {
|
||||
store: TestStore | null
|
||||
subscribeStatus: Mock
|
||||
createIntent: Mock
|
||||
restoreIntent: Mock
|
||||
launch: Mock<
|
||||
(
|
||||
intent: StructuredAgentSessionLaunchIntent,
|
||||
onHostSeed?: unknown
|
||||
) => Promise<{ sessionId: string; fence: number }>
|
||||
>
|
||||
refresh: Mock
|
||||
history: Mock
|
||||
} = {
|
||||
store: null,
|
||||
subscribeStatus: vi.fn(),
|
||||
createIntent: vi.fn(),
|
||||
restoreIntent: vi.fn(),
|
||||
launch: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
history: vi.fn()
|
||||
}
|
||||
return hoisted
|
||||
})
|
||||
|
||||
vi.mock('@/store', async () => {
|
||||
const { createTestStore } = await import('@/store/slices/store-test-helpers')
|
||||
const useAppStore = createTestStore()
|
||||
mocks.store = useAppStore
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), message: vi.fn() } }))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null,
|
||||
getExecutionHostIdForWorktree: () => 'local'
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: mocks.history,
|
||||
subscribeStructuredAgentSession: vi.fn(),
|
||||
subscribeStructuredAgentSessionStatus: mocks.subscribeStatus
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
|
||||
refreshLocalStructuredSessionTabs: mocks.refresh
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-structured-agent-session', () => {
|
||||
class StructuredAgentSessionCreateRefusalError extends Error {}
|
||||
return {
|
||||
createStructuredAgentSessionLaunchIntent: mocks.createIntent,
|
||||
retryStructuredAgentSessionLaunchIntent: vi.fn((prior: unknown) => prior),
|
||||
restoreStructuredAgentSessionLaunchIntent: mocks.restoreIntent,
|
||||
abandonStructuredAgentSessionLaunchIntent: vi.fn(),
|
||||
launchStructuredAgentSession: mocks.launch,
|
||||
StructuredAgentSessionCreateRefusalError
|
||||
}
|
||||
})
|
||||
|
||||
import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge'
|
||||
import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session'
|
||||
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
|
||||
import {
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
markStructuredAgentSessionLaunchCancelled,
|
||||
resetStructuredAgentLaunchRegistryForTests
|
||||
} from '@/lib/structured-agent-session-launch-registry'
|
||||
import { resetStructuredAgentLaunchPersistenceForTests } from '@/lib/structured-agent-session-launch-persistence'
|
||||
import { recheckUnconfirmedStructuredAgentLaunches } from '@/lib/structured-agent-session-launch-unconfirmed-recheck'
|
||||
import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed'
|
||||
import {
|
||||
resetTerminalTabActivityFlagsCacheForTest,
|
||||
resolveTerminalTabActivityStatus
|
||||
} from '../tab-bar/terminal-tab-activity-status'
|
||||
|
||||
const WORKTREE_ID = 'wt-1'
|
||||
const CLAUDE_SESSION = 'session-claude'
|
||||
const CODEX_SESSION = 'session-codex'
|
||||
|
||||
function chatTab(sessionId: string, agent: 'claude' | 'codex'): Tab {
|
||||
return {
|
||||
id: `structured-agent-session-${sessionId}`,
|
||||
worktreeId: WORKTREE_ID,
|
||||
groupId: 'group-1',
|
||||
contentType: 'agent-session',
|
||||
entityId: sessionId,
|
||||
label: 'Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1_000,
|
||||
isPinned: false,
|
||||
agentSessionAgent: agent
|
||||
}
|
||||
}
|
||||
|
||||
function launchIntent(
|
||||
sessionId: string,
|
||||
agent: 'claude' | 'codex' = 'claude'
|
||||
): StructuredAgentSessionLaunchIntent {
|
||||
return {
|
||||
worktreeId: WORKTREE_ID,
|
||||
sessionId,
|
||||
executionHostId: 'local',
|
||||
target: { kind: 'local' },
|
||||
agent,
|
||||
params: {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId: `operation-${sessionId}`,
|
||||
expectedRuntimeFence: null,
|
||||
payloadFingerprint: `fingerprint-${sessionId}`
|
||||
},
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
agent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function listing(...sessionIds: string[]): RuntimeMobileSessionTabsResult[] {
|
||||
return [
|
||||
{
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: sessionIds.map((sessionId) => ({
|
||||
type: 'agent-session' as const,
|
||||
id: `agent-session:${sessionId}`,
|
||||
title: 'Chat',
|
||||
sessionId,
|
||||
agent: 'claude' as const,
|
||||
isActive: false
|
||||
}))
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function store(): TestStore {
|
||||
if (!mocks.store) {
|
||||
throw new Error('store missing')
|
||||
}
|
||||
return mocks.store
|
||||
}
|
||||
|
||||
function rows(): AgentStatusEntry[] {
|
||||
return Object.values(store().getState().agentStatusByPaneKey)
|
||||
}
|
||||
|
||||
function tabStatus(sessionId = CLAUDE_SESSION): string {
|
||||
const state = store().getState()
|
||||
return resolveTerminalTabActivityStatus({
|
||||
tab: { id: `structured-agent-session-${sessionId}`, title: 'Chat', launchAgent: 'claude' },
|
||||
agentStatusByPaneKey: state.agentStatusByPaneKey,
|
||||
agentStatusEpoch: state.agentStatusEpoch
|
||||
})
|
||||
}
|
||||
|
||||
function lifecycle(sessionId = CLAUDE_SESSION): string | null {
|
||||
return getStructuredAgentSessionLaunchLifecycle(WORKTREE_ID, sessionId)
|
||||
}
|
||||
|
||||
function createCalls(): number {
|
||||
return mocks.launch.mock.calls.length
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
render(<StructuredAgentSessionStatusBridge />)
|
||||
await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalled())
|
||||
for (const [, emit] of mocks.subscribeStatus.mock.calls) {
|
||||
act(() => emit({ type: 'snapshot', sessions: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
/** A reload keeps the saved launch record and the tab, but nothing in memory. */
|
||||
async function reload(): Promise<void> {
|
||||
cleanup()
|
||||
store().setState({ agentStatusByPaneKey: {} })
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
resetTerminalTabActivityFlagsCacheForTest()
|
||||
mocks.subscribeStatus.mockClear()
|
||||
await connect()
|
||||
}
|
||||
|
||||
/** The create's reply never arrives and the host lists nothing, so the outcome stays unknown. */
|
||||
async function loseStart(
|
||||
sessionId = CLAUDE_SESSION,
|
||||
agent: 'claude' | 'codex' = 'claude'
|
||||
): Promise<void> {
|
||||
mocks.createIntent.mockReturnValueOnce(launchIntent(sessionId, agent))
|
||||
const launch = startStructuredAgentLaunch(WORKTREE_ID, agent, {
|
||||
requestId: `request-${sessionId}`
|
||||
})
|
||||
await expect(launch.launchResult).rejects.toThrow('reply lost')
|
||||
await flush()
|
||||
expect(lifecycle(sessionId)).toBe('visibility-unknown')
|
||||
}
|
||||
|
||||
function hostIsReachableAgain(executionHostId: 'local' | `runtime:${string}` = 'local'): void {
|
||||
act(() => recheckUnconfirmedStructuredAgentLaunches(executionHostId))
|
||||
}
|
||||
|
||||
describe('a chat whose start was never confirmed', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Queued one-shot answers an earlier case left unconsumed must not leak into the next.
|
||||
for (const mock of [mocks.createIntent, mocks.launch, mocks.refresh, mocks.restoreIntent]) {
|
||||
mock.mockReset()
|
||||
}
|
||||
localStorage.clear()
|
||||
resetStructuredAgentLaunchRegistryForTests()
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
resetTerminalTabActivityFlagsCacheForTest()
|
||||
mocks.subscribeStatus.mockResolvedValue({ unsubscribe: vi.fn() })
|
||||
mocks.launch.mockRejectedValue(new Error('reply lost'))
|
||||
mocks.refresh.mockResolvedValue([])
|
||||
mocks.history.mockResolvedValue({ ok: true, page: { fence: 1 } })
|
||||
mocks.restoreIntent.mockImplementation((args: { sessionId: string }) =>
|
||||
launchIntent(args.sessionId)
|
||||
)
|
||||
store().setState({
|
||||
agentStatusByPaneKey: {},
|
||||
acknowledgedAgentsByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKTREE_ID]: [chatTab(CLAUDE_SESSION, 'claude'), chatTab(CODEX_SESSION, 'codex')]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetStructuredAgentSessionStatusFeedsForTests()
|
||||
})
|
||||
|
||||
it('shows no mark while unconfirmed, and none once the host shows the create had landed', async () => {
|
||||
await connect()
|
||||
await loseStart()
|
||||
expect(rows()).toEqual([])
|
||||
expect(tabStatus()).not.toBe('failed')
|
||||
const creates = createCalls()
|
||||
|
||||
mocks.refresh.mockResolvedValueOnce(listing(CLAUDE_SESSION))
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
|
||||
expect(lifecycle()).toBeNull()
|
||||
// The host already held the chat, so nothing is created twice.
|
||||
expect(createCalls()).toBe(creates)
|
||||
expect(rows()).toEqual([])
|
||||
})
|
||||
|
||||
it('marks it failed when the host, asked again, refuses the create', async () => {
|
||||
await connect()
|
||||
await loseStart()
|
||||
expect(rows()).toEqual([])
|
||||
|
||||
mocks.launch.mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('refused'))
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
|
||||
expect(lifecycle()).toBe('failed')
|
||||
expect(tabStatus()).toBe('failed')
|
||||
|
||||
// A refusal is an answer: only the user's Retry asks again.
|
||||
const creates = createCalls()
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
expect(createCalls()).toBe(creates)
|
||||
})
|
||||
|
||||
it('re-checks a start a reload interrupted, without the user pressing Retry', async () => {
|
||||
await connect()
|
||||
mocks.createIntent.mockReturnValueOnce(launchIntent(CLAUDE_SESSION))
|
||||
mocks.launch.mockReturnValueOnce(new Promise(() => {}))
|
||||
startStructuredAgentLaunch(WORKTREE_ID, 'claude', { requestId: `request-${CLAUDE_SESSION}` })
|
||||
await flush()
|
||||
|
||||
await reload()
|
||||
expect(lifecycle()).toBe('visibility-unknown')
|
||||
expect(rows()).toEqual([])
|
||||
expect(tabStatus()).not.toBe('failed')
|
||||
|
||||
mocks.launch.mockResolvedValueOnce({ sessionId: CLAUDE_SESSION, fence: 1 })
|
||||
mocks.refresh.mockResolvedValueOnce([]).mockResolvedValueOnce(listing(CLAUDE_SESSION))
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
|
||||
expect(mocks.launch).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: CLAUDE_SESSION,
|
||||
params: expect.objectContaining({
|
||||
envelope: expect.objectContaining({ clientOperationId: `operation-${CLAUDE_SESSION}` })
|
||||
})
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(lifecycle()).toBeNull()
|
||||
expect(rows()).toEqual([])
|
||||
})
|
||||
|
||||
it('never re-creates a chat the user closed, before or after a reload', async () => {
|
||||
await connect()
|
||||
await loseStart(CLAUDE_SESSION, 'claude')
|
||||
await loseStart(CODEX_SESSION, 'codex')
|
||||
act(() => {
|
||||
markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, CLAUDE_SESSION, 'local')
|
||||
})
|
||||
const creates = createCalls()
|
||||
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
const recreated = (sessionId: string): number =>
|
||||
mocks.launch.mock.calls.slice(creates).filter(([intent]) => intent.sessionId === sessionId)
|
||||
.length
|
||||
expect(recreated(CODEX_SESSION)).toBe(1)
|
||||
expect(recreated(CLAUDE_SESSION)).toBe(0)
|
||||
|
||||
await reload()
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
expect(recreated(CLAUDE_SESSION)).toBe(0)
|
||||
expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('re-checks once per return of its own host: one still unknown waits for the next', async () => {
|
||||
await connect()
|
||||
await loseStart()
|
||||
const creates = createCalls()
|
||||
|
||||
hostIsReachableAgain('runtime:other-host')
|
||||
await flush()
|
||||
expect(createCalls()).toBe(creates)
|
||||
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
expect(createCalls()).toBe(creates + 1)
|
||||
expect(lifecycle()).toBe('visibility-unknown')
|
||||
expect(rows()).toEqual([])
|
||||
|
||||
await flush()
|
||||
expect(createCalls()).toBe(creates + 1)
|
||||
|
||||
hostIsReachableAgain()
|
||||
await flush()
|
||||
expect(createCalls()).toBe(creates + 2)
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,8 @@ export type StructuredLaunchCaller = {
|
||||
export type StructuredLaunchCallerGroup = {
|
||||
outcome: 'pending' | 'published' | 'failed' | 'unknown' | 'cancelled'
|
||||
attempt: StructuredLaunchAttempt
|
||||
/** When this attempt failed; a retry starts a new group, so it never outlives the failure. */
|
||||
failedAt?: number
|
||||
entries: Set<StructuredLaunchCaller>
|
||||
promptDeliveryResults: Set<Promise<StructuredPromptDeliveryResult>>
|
||||
onSettled: () => void
|
||||
@@ -90,6 +92,9 @@ export function settleStructuredLaunchCallers(
|
||||
outcome: 'published' | 'failed' | 'cancelled'
|
||||
): void {
|
||||
group.outcome = outcome
|
||||
if (outcome === 'failed') {
|
||||
group.failedAt = Date.now()
|
||||
}
|
||||
group.onSettled()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import {
|
||||
getPersistedStructuredAgentLaunchRecord,
|
||||
getStructuredLaunchStateBySessionId,
|
||||
subscribeStructuredAgentLaunchStatus
|
||||
} from './structured-agent-session-launch-registry'
|
||||
|
||||
/** When the launch's last attempt failed: in memory, else as saved before a reload. Undefined
|
||||
* while it has not failed, and for records saved by builds that did not keep the time. */
|
||||
export function getStructuredAgentSessionLaunchFailedAt(sessionId: string): number | undefined {
|
||||
return (
|
||||
getStructuredLaunchStateBySessionId(sessionId)?.callers.failedAt ??
|
||||
getPersistedStructuredAgentLaunchRecord(sessionId)?.failedAt
|
||||
)
|
||||
}
|
||||
|
||||
export function useStructuredAgentSessionLaunchFailedAt(sessionId: string): number | undefined {
|
||||
return useSyncExternalStore(
|
||||
subscribeStructuredAgentLaunchStatus,
|
||||
() => getStructuredAgentSessionLaunchFailedAt(sessionId),
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
@@ -149,6 +149,38 @@ describe('structured agent launch persistence', () => {
|
||||
expect(readStructuredAgentLaunchRecord('claude_session')?.clientOperationId).toBe('operation-2')
|
||||
})
|
||||
|
||||
it('keeps when a failed launch failed across a reload, and still loads records without it', () => {
|
||||
writeStructuredAgentLaunchRecord({
|
||||
sessionId: 'claude_session',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
lifecycle: 'failed',
|
||||
clientOperationId: 'operation-3',
|
||||
payloadFingerprint: 'fingerprint-3',
|
||||
expectedRuntimeFence: null,
|
||||
failedAt: 42_000
|
||||
})
|
||||
const stored = JSON.parse(localStorage.getItem('orca:structuredAgentLaunches:v1') ?? '[]')
|
||||
localStorage.setItem(
|
||||
'orca:structuredAgentLaunches:v1',
|
||||
JSON.stringify([
|
||||
...stored,
|
||||
// Written by a build that did not save the failure time.
|
||||
{ ...stored[0], sessionId: 'older_session', failedAt: undefined },
|
||||
{ ...stored[0], sessionId: 'corrupt_session', failedAt: 'yesterday' }
|
||||
])
|
||||
)
|
||||
resetStructuredAgentLaunchPersistenceForTests()
|
||||
|
||||
expect(readStructuredAgentLaunchRecord('claude_session')).toMatchObject({
|
||||
lifecycle: 'failed',
|
||||
failedAt: 42_000
|
||||
})
|
||||
expect(readStructuredAgentLaunchRecord('older_session')).toMatchObject({ lifecycle: 'failed' })
|
||||
expect(readStructuredAgentLaunchRecord('older_session')?.failedAt).toBeUndefined()
|
||||
expect(readStructuredAgentLaunchRecord('corrupt_session')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists cancellation tombstones by session id and retires them', () => {
|
||||
markStructuredAgentLaunchCancelledPersisted('codex_session', 'local')
|
||||
expect(hasStructuredAgentLaunchCancellationTombstonePersisted('codex_session')).toBe(true)
|
||||
|
||||
@@ -23,6 +23,8 @@ export type StructuredAgentLaunchPersistedRecord = {
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
/** A paired server's reported seed, which this machine cannot re-derive after a reload. */
|
||||
seedOptions?: Readonly<Record<string, string>>
|
||||
/** When a failed launch failed; records written by older builds lack it. */
|
||||
failedAt?: number
|
||||
}
|
||||
|
||||
/** What survives a reload of an unpublished launch. */
|
||||
@@ -85,6 +87,7 @@ function validRecord(value: unknown): value is Omit<
|
||||
} = value
|
||||
const resumeFrom = 'resumeFrom' in value ? value.resumeFrom : undefined
|
||||
const executionHostId = 'executionHostId' in value ? value.executionHostId : undefined
|
||||
const failedAt = 'failedAt' in value ? value.failedAt : undefined
|
||||
return (
|
||||
(executionHostId === undefined ||
|
||||
(typeof executionHostId === 'string' && parseExecutionHostId(executionHostId) !== null)) &&
|
||||
@@ -95,6 +98,7 @@ function validRecord(value: unknown): value is Omit<
|
||||
typeof clientOperationId === 'string' &&
|
||||
typeof payloadFingerprint === 'string' &&
|
||||
(expectedRuntimeFence === null || typeof expectedRuntimeFence === 'number') &&
|
||||
(failedAt === undefined || Number.isFinite(failedAt)) &&
|
||||
(resumeFrom === undefined ||
|
||||
(typeof resumeFrom === 'object' &&
|
||||
resumeFrom !== null &&
|
||||
|
||||
@@ -104,7 +104,10 @@ function persistStructuredLaunchState(state: StructuredLaunchState): void {
|
||||
deleteStructuredAgentLaunchRecord(state.intent.sessionId)
|
||||
return
|
||||
}
|
||||
writeStructuredAgentLaunchRecord(structuredAgentLaunchRecordFor(state.intent, lifecycle))
|
||||
writeStructuredAgentLaunchRecord({
|
||||
...structuredAgentLaunchRecordFor(state.intent, lifecycle),
|
||||
...(lifecycle === 'failed' ? { failedAt: state.callers.failedAt } : {})
|
||||
})
|
||||
}
|
||||
|
||||
export function getPersistedStructuredAgentLaunchRecord(
|
||||
|
||||
@@ -56,6 +56,7 @@ export function restorePersistedStructuredLaunchState(
|
||||
selection: { seed: intent.seedOptions, held: {} }
|
||||
}
|
||||
callers.outcome = record.lifecycle === 'failed' ? 'failed' : 'unknown'
|
||||
callers.failedAt = record.failedAt
|
||||
setStructuredLaunchState(state)
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
import { retryStructuredAgentSessionLaunch } from './structured-agent-session-launch'
|
||||
import {
|
||||
getStructuredAgentSessionLaunchLifecycle,
|
||||
getStructuredAgentSessionLaunchOwner,
|
||||
structuredLaunchStates
|
||||
} from './structured-agent-session-launch-registry'
|
||||
|
||||
/**
|
||||
* `executionHostId` is reachable again: its full inventory just arrived on a new subscription.
|
||||
* Re-checks each launch it owns whose create was never confirmed, as Retry would, so it settles to
|
||||
* published or failed without the user. One still unconfirmed waits for the host's next return;
|
||||
* a cancelled launch reads as cancelled and is skipped.
|
||||
*/
|
||||
export function recheckUnconfirmedStructuredAgentLaunches(executionHostId: ExecutionHostId): void {
|
||||
const launches = [...structuredLaunchStates()].map(({ intent }) => ({
|
||||
worktreeId: intent.worktreeId,
|
||||
sessionId: intent.sessionId
|
||||
}))
|
||||
// A launch from before a reload is known only by its saved record and the tab still showing it.
|
||||
for (const [worktreeId, tabs] of Object.entries(useAppStore.getState().unifiedTabsByWorktree)) {
|
||||
for (const tab of tabs) {
|
||||
if (tab.contentType === 'agent-session') {
|
||||
launches.push({ worktreeId, sessionId: tab.entityId })
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { worktreeId, sessionId } of launches) {
|
||||
if (
|
||||
getStructuredAgentSessionLaunchLifecycle(worktreeId, sessionId) !== 'visibility-unknown' ||
|
||||
getStructuredAgentSessionLaunchOwner(sessionId) !== executionHostId
|
||||
) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
retryStructuredAgentSessionLaunch(worktreeId, sessionId)
|
||||
} catch (error) {
|
||||
// Why: recovery bookkeeping must not break the inventory that reported the host back.
|
||||
console.warn('[structured-agent-launch] unconfirmed launch re-check failed', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
resetWebSessionFocusIntentForTests
|
||||
} from './web-session-focus-intent'
|
||||
|
||||
const mocks = vi.hoisted(() => ({ recheckUnconfirmedLaunches: vi.fn() }))
|
||||
|
||||
vi.mock('../lib/structured-agent-session-launch-unconfirmed-recheck', () => ({
|
||||
recheckUnconfirmedStructuredAgentLaunches: mocks.recheckUnconfirmedLaunches
|
||||
}))
|
||||
|
||||
const WORKTREE_ID = 'repo-1::worktree-1'
|
||||
const TERMINAL_ID = 'terminal-1'
|
||||
const STRUCTURED_ID = 'structured-agent-session-codex-1'
|
||||
@@ -244,6 +250,54 @@ describe('local structured session tab projection', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('re-checks unconfirmed chat starts each time the host stream reopens, not on each frame', async () => {
|
||||
vi.useFakeTimers()
|
||||
mocks.recheckUnconfirmedLaunches.mockClear()
|
||||
const priorApi = window.api
|
||||
const callbacks: ((response: unknown) => void)[] = []
|
||||
const subscribe = vi.fn(async (_args: unknown, callback: (response: unknown) => void) => {
|
||||
callbacks.push(callback)
|
||||
return { unsubscribe: vi.fn(), sendBinary: vi.fn() }
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtime: {
|
||||
getStatus: vi.fn().mockResolvedValue({
|
||||
capabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
|
||||
}),
|
||||
call: vi.fn().mockResolvedValue({ ok: true, result: { snapshots: [] } }),
|
||||
subscribe
|
||||
}
|
||||
}
|
||||
})
|
||||
const census = { ok: true, result: { type: 'snapshots', snapshots: [], authoritative: true } }
|
||||
try {
|
||||
await startLocalStructuredSessionTabsSync({
|
||||
isDisposed: () => false,
|
||||
setUnsubscribe: () => undefined
|
||||
})
|
||||
// The startup inventory alone is not the stream reopening.
|
||||
expect(mocks.recheckUnconfirmedLaunches).not.toHaveBeenCalled()
|
||||
|
||||
callbacks[0]?.(census)
|
||||
expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledOnce()
|
||||
expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledWith('local')
|
||||
callbacks[0]?.({ ok: true, result: { type: 'updated', ...structuredInventory('e', 3, 'c') } })
|
||||
expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledOnce()
|
||||
|
||||
// The runtime restarted: the stream ends and the resubscribe opens with a new census.
|
||||
callbacks[0]?.({ ok: true, result: { type: 'end' } })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
await Promise.resolve()
|
||||
expect(subscribe).toHaveBeenCalledTimes(2)
|
||||
callbacks[1]?.(census)
|
||||
expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: priorApi })
|
||||
}
|
||||
})
|
||||
|
||||
it('starts the session-tabs inventory without waiting for the capability refresh', async () => {
|
||||
const priorApi = window.api
|
||||
let releaseStatus = (): void => undefined
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities'
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
refreshLocalStructuredSessionTabs,
|
||||
restoreLocalStructuredSessionTabsOnce
|
||||
} from './inventory-refresh'
|
||||
import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck'
|
||||
import { scheduleRetiredEpochRepair } from './retired-epoch-repair'
|
||||
import {
|
||||
applyStructuredSessionTabSnapshots,
|
||||
@@ -101,6 +103,8 @@ export async function startLocalStructuredSessionTabsSync(args: {
|
||||
...REPAIR_DROPPED_EPOCHS,
|
||||
authoritative: event.authoritative === true
|
||||
})
|
||||
// Each subscription opens with one census: the host is reachable again.
|
||||
recheckUnconfirmedStructuredAgentLaunches(LOCAL_EXECUTION_HOST_ID)
|
||||
} else if (event.type === 'snapshot' || event.type === 'updated') {
|
||||
applyStructuredSessionTabSnapshots([event], undefined, REPAIR_DROPPED_EPOCHS)
|
||||
} else if (event.type === 'end' && generation === subscriptionGeneration) {
|
||||
|
||||
@@ -10,7 +10,14 @@ import type * as WorktreeRuntimeOwnerModule from '@/lib/worktree-runtime-owner'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } }))
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getExplicitRuntimeEnvironmentIdForWorktree: vi.fn() }))
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getExplicitRuntimeEnvironmentIdForWorktree: vi.fn(),
|
||||
recheckUnconfirmedLaunches: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/structured-agent-session-launch-unconfirmed-recheck', () => ({
|
||||
recheckUnconfirmedStructuredAgentLaunches: mocks.recheckUnconfirmedLaunches
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof WorktreeRuntimeOwnerModule>()
|
||||
@@ -144,6 +151,7 @@ describe('session-tabs mirror across an outage and its recovery', () => {
|
||||
runtimeCall.mockClear()
|
||||
runtimeSubscribe.mockClear()
|
||||
mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReset().mockReturnValue(ENV_A)
|
||||
mocks.recheckUnconfirmedLaunches.mockClear()
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } }
|
||||
@@ -195,6 +203,32 @@ describe('session-tabs mirror across an outage and its recovery', () => {
|
||||
expect(mirroredSubscriptions('session.tabs.subscribe')).toHaveLength(stranded.active + 1)
|
||||
})
|
||||
|
||||
// A create whose reply was lost may have landed: the host's census on the new stream is when
|
||||
// this client can ask it again, once per return.
|
||||
it('re-checks unconfirmed chat starts on that host when its reinstalled stream opens', async () => {
|
||||
await connectThenLoseContact()
|
||||
const census = async (): Promise<void> => {
|
||||
const [reopened] = mirroredSubscriptions('session.tabs.subscribeAll').slice(-1)
|
||||
await act(async () => {
|
||||
reopened?.callbacks.onResponse({
|
||||
id: 'census',
|
||||
ok: true,
|
||||
result: { type: 'snapshots', snapshots: [], authoritative: true },
|
||||
_meta: { runtimeId: 'runtime-a' }
|
||||
})
|
||||
await settle()
|
||||
})
|
||||
}
|
||||
mocks.recheckUnconfirmedLaunches.mockClear()
|
||||
|
||||
await regainContact()
|
||||
expect(mocks.recheckUnconfirmedLaunches).not.toHaveBeenCalled()
|
||||
await census()
|
||||
|
||||
expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledOnce()
|
||||
expect(mocks.recheckUnconfirmedLaunches).toHaveBeenCalledWith(`runtime:${ENV_A}`)
|
||||
})
|
||||
|
||||
// Direction 2: the mirror's cache key. #19647 -- recovery is not a second connection, so every
|
||||
// retained-state stamp cut from this key stays valid and the mirror is never rebuilt.
|
||||
it('holds the mirror environment key across the outage and the recovery', async () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { toRuntimeExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { recheckUnconfirmedStructuredAgentLaunches } from '../../lib/structured-agent-session-launch-unconfirmed-recheck'
|
||||
import { useAppStore } from '../../store'
|
||||
import { recoverWebSessionTerminalOrphansBeforeApply } from '../web-session-terminal-orphan-recovery'
|
||||
import { queueAcceptedWebSessionTerminalSnapshot } from '../web-session-terminal-handle-events'
|
||||
@@ -173,4 +175,7 @@ export function handleGlobalSessionInventoryEvent({
|
||||
settleHydration?.()
|
||||
}
|
||||
})
|
||||
// Each subscription opens with one census: the host is reachable again. Chats it lists were
|
||||
// already settled as published above, before any recovery await.
|
||||
recheckUnconfirmedStructuredAgentLaunches(toRuntimeExecutionHostId(environmentId))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user