From 98064b2608fe8db2e9fe52820ae9129e879b3092 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:00:24 -0700 Subject: [PATCH] fix(ssh): a managed server restart no longer marks still-running agents done (#29604) * fix(runtime): keep a restored agent turn unverifiable instead of done after a server restart * fix(runtime): retire restored turns by the synced leaf's title clock; no Stop for unverifiable mobile turns * fix(runtime): capability-gate restored agent rows so older clients keep the legacy done projection * fix(runtime): retire restored turns only on shell titles; restored rows never pierce live client status * test(web): the browser client advertises the restored agent status capability --- ...se-mobile-native-chat-active-resolution.ts | 5 +- .../mobile-runtime-client-capabilities.ts | 5 +- ...ntime-hook-agent-status-projection.test.ts | 46 +++++++- ...ession-tab-agent-status-projection.test.ts | 50 ++++++++ .../session-tab-agent-status-projection.ts | 24 ++++ ...untime-mobile-agent-status-builder.test.ts | 110 ++++++++++++++++++ .../runtime-mobile-agent-status-builder.ts | 40 ++++++- .../runtime-mobile-agent-status-projection.ts | 39 +++++-- .../prune-mobile-session-tab-group-layout.ts | 4 +- ...tabs-sync-remote-status-title-flap.test.ts | 24 ++++ .../agent-status-primitives.ts | 6 +- .../web/web-runtime-client-capabilities.ts | 5 +- .../src/web/web-runtime-client.test.ts | 4 +- ...tron-remote-runtime-client-capabilities.ts | 5 +- src/shared/runtime-capability-constants.ts | 4 + 15 files changed, 350 insertions(+), 21 deletions(-) diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts index ea7f923de47..e7c69818edd 100644 --- a/mobile/src/session/use-mobile-native-chat-active-resolution.ts +++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts @@ -56,8 +56,11 @@ export function useMobileNativeChatActiveResolution(args: { const activeChatStructured = activeChatResolution != null && activeSessionTab?.type === 'agent-session' const activeTabStatus = activeSessionTab?.agentStatus + // Why: a turn restored across a host restart is unverifiable, so it must not offer Stop. const activeTabAgentWorking = - activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring' + activeTabStatus?.state === 'working' && + activeTabStatus.workingMode !== 'monitoring' && + activeTabStatus.restoredUnconfirmed !== true const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` diff --git a/mobile/src/transport/mobile-runtime-client-capabilities.ts b/mobile/src/transport/mobile-runtime-client-capabilities.ts index 9fcf92727ec..336616ec58d 100644 --- a/mobile/src/transport/mobile-runtime-client-capabilities.ts +++ b/mobile/src/transport/mobile-runtime-client-capabilities.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, @@ -37,7 +38,9 @@ export const MOBILE_RUNTIME_CLIENT_CAPABILITIES = remoteRuntimeClientCapabilitie // Reads a listed launch tab with no terminal yet as not started, so the host may show it early. AGENT_LAUNCH_UNSTARTED_TAB_CLIENT_CAPABILITY, // Reads `agent_launch_tab_closed` (a user closed its tab, which stopped it) as a definite answer. - AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY + AGENT_LAUNCH_TAB_CLOSED_CLIENT_CAPABILITY, + // Native chat offers no Stop for a turn the host restored but could not confirm. + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY ]) export const MOBILE_RUNTIME_CLIENT_CAPABILITY_UPDATE_METHOD = diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 9fab2267d25..7ac50f58fbd 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -229,13 +229,55 @@ describe('headless hook agent-status projection (#11761)', () => { // #12346: a row hydrated from last-status.json may describe a turn that ended while // no receiver was up, so its recent `receivedAt` proves nothing. Publishing it would // resurrect the question card on every restart, with no agent left to answer it. - it('refuses a hydrated unconfirmed row while keeping its resume identity', async () => { + it('refuses a hydrated unconfirmed row as live while keeping its resume identity', async () => { const agentStatus = await projectAgentStatus([hookRow({ restoredUnconfirmed: true })]) expect(agentStatus).toEqual( - expect.objectContaining({ state: 'done', prompt: '', providerSession: PROVIDER_SESSION }) + expect.objectContaining({ + state: 'waiting', + restoredUnconfirmed: true, + providerSession: PROVIDER_SESSION + }) ) expect(agentStatus).not.toHaveProperty('interactivePrompt') + expect(agentStatus).not.toHaveProperty('toolName') + }) + + // A managed server restart re-hydrates last-status.json while the daemon keeps the agent + // running: the turn is unverifiable, so it must not be published as finished. + it('keeps a restored working turn unverifiable instead of done', async () => { + const receivedAt = Date.now() - 60_000 + const agentStatus = await projectAgentStatus([ + hookRow({ + state: 'working', + prompt: 'refactor the parser', + toolName: undefined, + interactivePrompt: undefined, + receivedAt, + stateStartedAt: receivedAt - 5_000, + restoredUnconfirmed: true + }) + ]) + + expect(agentStatus).toEqual( + expect.objectContaining({ + state: 'working', + prompt: 'refactor the parser', + restoredUnconfirmed: true, + updatedAt: receivedAt, + stateStartedAt: receivedAt - 5_000 + }) + ) + }) + + it('lets a shell title retire a restored turn as done', async () => { + const agentStatus = await projectAgentStatus( + [hookRow({ state: 'working', interactivePrompt: undefined, restoredUnconfirmed: true })], + (runtime) => observePaneTitle(runtime, 'bash') + ) + + expect(agentStatus === undefined || agentStatus.state === 'done').toBe(true) + expect(agentStatus).not.toHaveProperty('restoredUnconfirmed') }) // #7970: a retained OSC 9999 row is the pane's own report and keeps precedence. diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index d06f5819e7d..ca4ba28d75a 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' @@ -41,7 +42,56 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho } } +function makeRestoredSnapshot(): RuntimeMobileSessionTabsSnapshot { + const snapshot = makeSnapshot(false) + const tab = snapshot.tabs[0]! + return { + ...snapshot, + tabs: [ + tab.type === 'terminal' + ? { + ...tab, + agentStatus: { + ...tab.agentStatus!, + state: 'working', + prompt: 'refactor the parser', + restoredUnconfirmed: true + } + } + : tab + ] + } +} + describe('projectSessionTabAgentStatus', () => { + // A client that cannot read `restoredUnconfirmed` would take the last known state as live. + it.each(['mobile', 'runtime'] as const)( + 'keeps the legacy done projection for a %s client without the restored capability', + (clientKind) => { + const projected = projectSessionTabAgentStatus(makeRestoredSnapshot(), clientKind, []) + const tab = projected.tabs[0] + + expect(tab?.type === 'terminal' ? tab.agentStatus : null).toEqual( + expect.objectContaining({ state: 'done', prompt: '' }) + ) + expect(tab?.type === 'terminal' ? tab.agentStatus : null).not.toHaveProperty( + 'restoredUnconfirmed' + ) + } + ) + + it('publishes a restored turn as unverifiable to a client that reads it', () => { + const projected = projectSessionTabAgentStatus(makeRestoredSnapshot(), 'runtime', [ + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY + ]) + const tab = projected.tabs[0] + + expect(tab?.type === 'terminal' ? tab.agentStatus : null).toEqual( + expect.objectContaining({ state: 'working', restoredUnconfirmed: true }) + ) + }) + it('projects structured tabs and dangling group focus out of old clients', () => { const snapshot: RuntimeMobileSessionTabsSnapshot = { ...makeSnapshot(false), diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 995c8281d87..1fa3d5569a6 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,5 +1,6 @@ import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, type RuntimeCapability } from '../../../../shared/protocol-version' import type { @@ -72,6 +73,12 @@ export function projectSessionTabAgentStatus( + payload: TPayload +): TPayload { + let changed = false + const tabs = payload.tabs.map((tab) => { + if (tab.type !== 'terminal' || tab.agentStatus?.restoredUnconfirmed !== true) { + return tab + } + changed = true + const { restoredUnconfirmed: _restored, ...legacyStatus } = tab.agentStatus + return { ...tab, agentStatus: { ...legacyStatus, state: 'done' as const, prompt: '' } } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only terminal tabs' agentStatus changed; every other TPayload field is spread through unchanged. + return changed ? ({ ...payload, tabs } as TPayload) : payload +} + function projectUnsupportedAgentSessionTabTitles( payload: TPayload, args: { diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts index f9e21d7e323..acc7b10c97e 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.test.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimeMobileSessionTerminalTab } from '../../shared/runtime-types' import type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection' import { buildRuntimeMobileAgentStatus } from './runtime-mobile-agent-status-builder' +import type { RuntimeLeafRecord } from './runtime-terminal-state-records' const PROVIDER_SESSION = { key: 'session_id' as const, id: 'session-1' } const TAB: RuntimeMobileSessionTerminalTab = { @@ -37,3 +39,111 @@ describe('mobile agent status builder', () => { ) }) }) + +// A synced leaf projects with no PTY record of its own, so a shell title it painted after a +// restored turn must still retire that turn through the leaf's PTY title clock. +const TAB_ID = 'tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PTY_ID = 'pty-1' + +const LEAF_TAB: RuntimeMobileSessionTerminalTab = { + type: 'terminal', + id: `${TAB_ID}::${LEAF_ID}`, + title: 'Terminal 1', + parentTabId: TAB_ID, + leafId: LEAF_ID, + isActive: true +} + +function leafWithTitle(lastOscTitle: string | null): RuntimeLeafRecord { + return { + tabId: TAB_ID, + worktreeId: 'wt-1', + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: null, + ptyGeneration: 1, + connected: true, + writable: true, + lastOutputAt: null, + lastExitCode: null, + lastExitCause: null, + lastAgentStatus: null, + lastAgentStatusObservedLive: false, + lastOscTitle, + lastOscTitleAt: lastOscTitle ? 1 : null, + paneTitleUpdatedAt: null, + tailBuffer: [], + tailTranscriptBuffer: [], + tailTranscriptChars: 0, + tailPartialLine: '', + tailPendingAnsi: '', + tailRedrawCursor: null, + tailTruncated: false, + tailLinesTotal: 0, + preview: '', + waitBlockedAt: null + } +} + +function restoredRow(receivedAt: number): AgentStatusIpcPayload { + return { + paneKey: PANE_KEY, + state: 'working', + prompt: 'refactor the parser', + agentType: 'claude', + connectionId: null, + receivedAt, + stateStartedAt: receivedAt, + tabId: TAB_ID, + worktreeId: 'wt-1', + restoredUnconfirmed: true + } +} + +function project(leaf: RuntimeLeafRecord, titleEpochMs: number | null, receivedAt: number) { + return buildRuntimeMobileAgentStatus( + null, + LEAF_TAB, + null, + null, + () => [restoredRow(receivedAt)], + { + getPaneKey: () => PANE_KEY, + getLeaf: () => leaf, + getTrackedTitle: () => null, + getPtyTitleObservedAtEpochMs: (ptyId) => (ptyId === PTY_ID ? titleEpochMs : null) + } + ) +} + +describe('restored turns on a synced leaf', () => { + it('stays unverifiable when only a seeded (unclocked) shell title is known', () => { + const receivedAt = Date.now() - 60_000 + const result = project(leafWithTitle('bash'), null, receivedAt) + + expect(result.agentStatus).toEqual( + expect.objectContaining({ state: 'working', restoredUnconfirmed: true }) + ) + }) + + it('stays unverifiable under a newer title that is not a shell', () => { + const receivedAt = Date.now() - 60_000 + const result = project(leafWithTitle('vim'), receivedAt + 1_000, receivedAt) + + expect(result.agentStatus).toEqual( + expect.objectContaining({ state: 'working', restoredUnconfirmed: true }) + ) + }) + + it('retires to done once the leaf PTY paints a newer shell title', () => { + const receivedAt = Date.now() - 60_000 + const result = project(leafWithTitle('bash'), receivedAt + 1_000, receivedAt) + + expect(result.agentStatus?.state).toBe('done') + expect(result.agentStatus).not.toHaveProperty('restoredUnconfirmed') + }) +}) diff --git a/src/main/runtime/runtime-mobile-agent-status-builder.ts b/src/main/runtime/runtime-mobile-agent-status-builder.ts index 5ce39d49d6a..fa468cf888a 100644 --- a/src/main/runtime/runtime-mobile-agent-status-builder.ts +++ b/src/main/runtime/runtime-mobile-agent-status-builder.ts @@ -15,13 +15,16 @@ import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import { classifyAgentTitle, getLatestAgentCandidateTitle, - getLatestPtyTitle + getLatestPtyTitle, + terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-status-projection' type RuntimeMobileAgentStatusHost = { getPaneKey(tab: RuntimeMobileSessionTerminalTab): string getLeaf(tab: RuntimeMobileSessionTerminalTab): RuntimeLeafRecord | null getTrackedTitle(ptyId: string | null): string | null + /** Wall clock of the PTY's latest live OSC title; seeded restore titles leave it unset. */ + getPtyTitleObservedAtEpochMs?(ptyId: string | null): number | null } export function buildRuntimeMobileAgentStatus( @@ -127,6 +130,41 @@ export function buildRuntimeMobileAgentStatus( return { agentStatus: renewedStatus } } } + // Why: a turn restored across a server restart is unverifiable, not finished; a synthetic + // `done` told clients a running agent had stopped. Keep its own date (so it cannot outrank a + // client's row) and drop the question fields (#12346). Only a later shell title retires it. + const shellTitleRetiredRestored = + hookRow.restored !== null && + terminalTitleBlocksExplicitAgentStatus(ptyTitle) && + (pty?.lastOscTitleEpochMs ?? host.getPtyTitleObservedAtEpochMs?.(leaf?.ptyId ?? null) ?? 0) >= + hookRow.restored.updatedAt + const restored = !pty?.lastAgentStatus && !shellTitleRetiredRestored ? hookRow.restored : null + if (restored) { + const restoredAgentType = ownerAgent ?? restored.payload.agentType + return { + agentStatus: normalizeCompatibleAgentStatusEntryForOwner( + { + state: restored.payload.state, + prompt: restored.payload.prompt, + updatedAt: restored.updatedAt, + stateStartedAt: restored.stateStartedAt, + restoredUnconfirmed: true, + paneKey, + ...(terminalHandle ? { terminalHandle } : {}), + ...(restoredAgentType ? { agentType: restoredAgentType } : {}), + ...((pty?.worktreeId ?? restored.worktreeId) + ? { worktreeId: pty?.worktreeId ?? restored.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle, + stateHistory: [], + ...providerSession + }, + ownerAgent, + ownerOptions + ) + } + } // Last resort: the pane's hook evidence is identity only (resume rows, stale // rows, or a row the freshness gate rejected). `done` is the honest // projection — and it is what retires the card once the agent exits. diff --git a/src/main/runtime/runtime-mobile-agent-status-projection.ts b/src/main/runtime/runtime-mobile-agent-status-projection.ts index 7482effbabd..f2bcea6a8a4 100644 --- a/src/main/runtime/runtime-mobile-agent-status-projection.ts +++ b/src/main/runtime/runtime-mobile-agent-status-projection.ts @@ -120,6 +120,8 @@ export type RuntimeHookAgentRowLookup = { agentType: string | null agentIsLive: boolean live: HookLiveAgentRow | null + /** Latest fresh nonterminal row hydrated across a host restart: unverifiable, never live. */ + restored: HookLiveAgentRow | null } /** Resume identity stays valid until relaunch; ownership and live status remain freshness-bounded. */ @@ -129,6 +131,7 @@ export function selectRuntimeHookAgentRowForPane( let session: AgentStatusIpcPayload | null = null let agent: AgentStatusIpcPayload | null = null let live: AgentStatusIpcPayload | null = null + let restored: AgentStatusIpcPayload | null = null const freshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS for (const entry of rows) { if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) { @@ -152,6 +155,19 @@ export function selectRuntimeHookAgentRowForPane( ) { live = entry } + if ( + entry.providerSessionOnly !== true && + entry.restoredUnconfirmed === true && + entry.state !== 'done' && + (entry.evidenceObservedAt ?? entry.receivedAt) >= freshAfter && + (!restored || entry.receivedAt > restored.receivedAt) + ) { + restored = entry + } + } + // A confirmed row at least as new as the restored one supersedes it. + if (restored && live && live.receivedAt >= restored.receivedAt) { + restored = null } return { providerSession: session?.providerSession ?? null, @@ -159,17 +175,18 @@ export function selectRuntimeHookAgentRowForPane( providerSessionReceivedAt: session?.receivedAt ?? null, agentType: agent?.agentType ?? null, agentIsLive: agent != null && agent.state !== 'done', - live: live - ? { - payload: pickParsedAgentStatusPayload(live), - updatedAt: live.receivedAt, - ...(live.evidenceObservedAt !== undefined - ? { evidenceObservedAt: live.evidenceObservedAt } - : {}), - stateStartedAt: live.stateStartedAt ?? live.receivedAt, - ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) - } - : null + live: live ? toHookLiveAgentRow(live) : null, + restored: restored ? toHookLiveAgentRow(restored) : null + } +} + +function toHookLiveAgentRow(row: AgentStatusIpcPayload): HookLiveAgentRow { + return { + payload: pickParsedAgentStatusPayload(row), + updatedAt: row.receivedAt, + ...(row.evidenceObservedAt !== undefined ? { evidenceObservedAt: row.evidenceObservedAt } : {}), + stateStartedAt: row.stateStartedAt ?? row.receivedAt, + ...(row.worktreeId ? { worktreeId: row.worktreeId } : {}) } } diff --git a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts index b1f724222a8..ee7d5cf7571 100644 --- a/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/session-lifecycle/projection/prune-mobile-session-tab-group-layout.ts @@ -148,7 +148,9 @@ export abstract class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends Or const leaf = this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) return leaf ? this.getLeafDisplayRecord(leaf) : null }, - getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) + getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId), + getPtyTitleObservedAtEpochMs: (ptyId) => + ptyId ? (this.ptysById.get(ptyId)?.lastOscTitleEpochMs ?? null) : null } ) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 24b15af55e6..5402e3747ff 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -513,6 +513,30 @@ describe('remote-paired pane: host snapshot mirror vs client byte-derived status expect(entry?.interactivePrompt).toBe('Allow Bash(rm -rf)?') }) + it('keeps live client status over a restored, unconfirmed host block', () => { + const store = seedPairedClientStore() + replayClientOscWorking(store, T0 + 500) + + // Host clock ahead of the client: only the unconfirmed flag can keep this row out. + const restoredAt = T0 + 60_000 + applyHostSnapshot( + store, + makeHostSnapshot({ + snapshotVersion: 2, + hostNow: restoredAt, + includeAgentStatus: true, + agentStatusOverrides: { + state: 'blocked', + prompt: 'Allow Bash(rm -rf)?', + restoredUnconfirmed: true + } + }), + T0 + 1_000 + ) + + expect(store.getState().agentStatusByPaneKey[MIRROR_PANE_KEY]?.state).toBe('working') + }) + it('adopts host monitoring metadata without replacing client-owned byte status', () => { const store = seedPairedClientStore() applyHostSnapshot( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index f4dd435535f..733bb083221 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -68,7 +68,11 @@ export function isMirroredAgentPaneKeyForTabs( * interactive question cards reach the host over its HTTP agent hook, never * through PTY bytes, so they must pierce the client-authority fence. */ export function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean { - return entry.state === 'blocked' || entry.interactivePrompt != null + // Why: a row restored across a host restart is unverifiable, so it never outranks live bytes. + return ( + entry.restoredUnconfirmed !== true && + (entry.state === 'blocked' || entry.interactivePrompt != null) + ) } /** True while this renderer's own byte-derived status owns the pane: it claimed diff --git a/src/renderer/src/web/web-runtime-client-capabilities.ts b/src/renderer/src/web/web-runtime-client-capabilities.ts index c59afdff164..2d86b095e7b 100644 --- a/src/renderer/src/web/web-runtime-client-capabilities.ts +++ b/src/renderer/src/web/web-runtime-client-capabilities.ts @@ -1,6 +1,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, @@ -28,5 +29,7 @@ export const WEB_RUNTIME_CLIENT_CAPABILITIES = [ WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, - TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY + TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY, + // Same renderer as the desktop: a restored, unconfirmed agent row shows as identity only. + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY ] as const diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index afc43e46bfa..d2012fa09c0 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -18,6 +18,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY, SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY, SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY, @@ -138,7 +139,8 @@ describe('WebRuntimeClient', () => { WORKTREE_VISIBILITY_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_VISIBILITY_SOURCE_DEFAULTS_RUNTIME_CAPABILITY, WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, - TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY + TERMINAL_QUICK_COMMAND_LONG_PROMPTS_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY ] }) diff --git a/src/shared/electron-remote-runtime-client-capabilities.ts b/src/shared/electron-remote-runtime-client-capabilities.ts index 2875fdc8a93..669cc1467e9 100644 --- a/src/shared/electron-remote-runtime-client-capabilities.ts +++ b/src/shared/electron-remote-runtime-client-capabilities.ts @@ -2,6 +2,7 @@ import { TERMINAL_IMAGE_VIEWER_CAPABILITY } from './terminal-image-viewer-capabi import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -41,5 +42,7 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // Main restores every tab-delta frame before the window or any other reader sees it. SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY, // Only the renderer shows Deleting for a `removing` row; CLI and mobile get those rows omitted. - WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY + WORKTREE_BACKGROUND_REMOVAL_RUNTIME_CAPABILITY, + // The renderer shows a restored, unconfirmed agent row as identity only. + AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/runtime-capability-constants.ts b/src/shared/runtime-capability-constants.ts index 26c0685863a..a2ef9fcf7af 100644 --- a/src/shared/runtime-capability-constants.ts +++ b/src/shared/runtime-capability-constants.ts @@ -97,6 +97,10 @@ export const SESSION_TABS_RETIREMENT_PROOF_DELTA_RUNTIME_CAPABILITY = // Why: a client advertising this rebuilds each `updated` frame's tab list from the previous frame // on the same stream, so one tab's status change no longer re-sends every tab in the worktree. export const SESSION_TABS_TAB_DELTA_RUNTIME_CAPABILITY = 'session-tabs.tab-delta.v1' as const +// Why: a client advertising this reads `restoredUnconfirmed` agent rows as unverifiable; older +// clients would read their last known state as live, so they keep the legacy `done` projection. +export const AGENT_STATUS_RESTORED_UNCONFIRMED_RUNTIME_CAPABILITY = + 'agent-status.restored-unconfirmed.v1' as const export const AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY = 'agent-session.session-boundary.v1' as const export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY =