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:
Brennan Benson
2026-10-05 13:50:18 -07:00
committed by GitHub
parent a42e586878
commit e9ed75a5de
15 changed files with 1065 additions and 6 deletions
+1
View File
@@ -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)),
@@ -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 })
])
})
})
@@ -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))
}