diff --git a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts index 180fc51b5bb..41c45768e73 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-resume.test.ts @@ -16,6 +16,7 @@ import type { RuntimeEnvironmentStatus } from '@/store/slices/runtime-status-typ import { HOST_MIRROR_HANDLE_GAP_DEADLINE_MS, countParkedHostMirrorHandleGapPanesForTests, + hasHostMirrorHandleWaitExpired, resetHostMirrorHandleGapWaitsForTests } from './host-mirror-handle-gap-wait' @@ -89,23 +90,7 @@ function seedMirroredWorkspace(worktree: ReturnType ): void { @@ -289,12 +274,7 @@ describe('resume across the mirror handle gap', () => { expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1) }) - // The counterweight to the park, and the reason the hydration short-circuit could not simply be - // dropped: a pane with nothing outstanding must still resume. Here no leaf of the published row - // binds a PTY this environment minted, so there is no handle on its way and no wait to arm — - // parking would be the latch-that-never-releases defect, since mirror settlement has already run - // and will not replay the sweep a second time. - it('still resumes a published row no leaf of which binds this environment', () => { + it('does not resume a published host row without a PTY binding', () => { const worktree = makeRuntimeOwnedWorktree() seedMirroredWorkspace(worktree) seedActiveSleepingRecord(worktree.id) @@ -312,7 +292,7 @@ describe('resume across the mirror handle gap', () => { } as never) markHostSessionMirrorHydrated(RUNTIME_ENV_ID) - expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(1) + expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) }) @@ -337,60 +317,47 @@ describe('resume across the mirror handle gap', () => { expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - // KNOWN RESIDUAL, pinned as current behaviour rather than as desired behaviour. The gate this - // wait sits behind is tab-granular (`host-mirrored-pane-liveness.ts`: any published handle for - // the tab makes the pane decidable), while everything below it is leaf-aware. A split tab whose - // sibling surface is `ready` while this one is still `pending-handle` therefore reads decidable, - // no wait is armed at all, and the sweep resumes a pane the host has not answered for — #19735's - // own shape, narrowed to a split tab's first frame. - // - // It is not closable inside this module: such a leaf has NO binding, and the binding is what - // names the pane in a verdict, so a leaf-keyed wait has nothing to key on. It needs the - // per-surface `pending-handle` status the host publishes (runtime-mobile-session-projection.ts) - // and the client consumes without retaining per leaf. Tracked separately; this case exists so - // the residual cannot be mistaken for a covered one. - it('resumes a pending leaf when a sibling leaf of the same tab holds the only handle', () => { + it('does not resume a pending leaf merely because its sibling holds a handle', () => { const worktree = makeRuntimeOwnedWorktree() seedSplitTabWithOnlySiblingReady(worktree) const paneKey = seedActiveSleepingRecord(worktree.id) markHostSessionMirrorHydrated(RUNTIME_ENV_ID) - expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(1) - // The residual in one assertion: nothing was ever parked for this pane. + expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) + // A sibling handle does not authorize a second writer for this leaf. expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) const after = useAppStore.getState() const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? []) .map((tab) => tab.id) .filter((id) => id !== WEB_TAB_ID) - expect(resumeTabIds).toHaveLength(1) - expect(after.automaticAgentResumeClaimsByTabId[resumeTabIds[0]!]?.providerSession).toEqual({ - key: 'session_id', - id: 'handle-gap-session' - }) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(resumeTabIds).toHaveLength(0) + expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('releases when the host retracts the row and resumes into a fresh tab', () => { + it.each([true, false])('does not resume a retracted host row (parked=%s)', (parked) => { const worktree = makeRuntimeOwnedWorktree() seedMirroredWorkspace(worktree) const paneKey = seedActiveSleepingRecord(worktree.id) markHostSessionMirrorHydrated(RUNTIME_ENV_ID) - expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) + if (parked) { + expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) + } useAppStore.setState({ tabsByWorktree: { [worktree.id]: [] } }) + expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) + vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS * 2) const after = useAppStore.getState() - const tabs = after.tabsByWorktree[worktree.id] ?? [] - expect(tabs).toHaveLength(1) - expect(after.automaticAgentResumeClaimsByTabId[tabs[0]!.id]?.providerSession).toEqual({ - key: 'session_id', - id: 'handle-gap-session' - }) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(after.tabsByWorktree[worktree.id]).toEqual([]) + expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) + expect(vi.getTimerCount()).toBe(0) }) - it('releases at the deadline and resumes rather than holding the pane forever', () => { + it('releases the handle wait at its deadline without authorizing a resume', () => { const worktree = makeRuntimeOwnedWorktree() seedMirroredWorkspace(worktree) const paneKey = seedActiveSleepingRecord(worktree.id) @@ -405,12 +372,11 @@ describe('resume across the mirror handle gap', () => { const resumeTabIds = (after.tabsByWorktree[worktree.id] ?? []) .map((tab) => tab.id) .filter((id) => id !== WEB_TAB_ID) - expect(resumeTabIds).toHaveLength(1) - expect(after.automaticAgentResumeClaimsByTabId[resumeTabIds[0]!]?.providerSession).toEqual({ - key: 'session_id', - id: 'handle-gap-session' - }) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(resumeTabIds).toHaveLength(0) + expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostMirrorHandleWaitExpired(RUNTIME_ENV_ID, WEB_TAB_ID)).toBe(true) + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) }) it('keeps the original deadline when a second sweep re-parks the same pane', () => { @@ -426,8 +392,9 @@ describe('resume across the mirror handle gap', () => { expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1) vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1) + expect(hasHostMirrorHandleWaitExpired(RUNTIME_ENV_ID, WEB_TAB_ID)).toBe(true) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0) }) it('re-arms the wait after a reconnect instead of inheriting the expired verdict', () => { @@ -437,7 +404,7 @@ describe('resume across the mirror handle gap', () => { markHostSessionMirrorHydrated(RUNTIME_ENV_ID) expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS) - expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1) + expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0) // A host restart: the same row, a new connection, its handle unknown again. seedMirroredWorkspace(worktree) @@ -470,8 +437,8 @@ describe('resume across the mirror handle gap', () => { // Re-armed, not held: the new connection gets its own budget and then decides. expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(1) vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)).toHaveLength(0) }) // The journey: the network drops mid-turn on a paired runtime. Nothing is unpaired and no @@ -505,8 +472,8 @@ describe('resume across the mirror handle gap', () => { vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS) const after = useAppStore.getState() - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(1) + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) }) // The same outage as the shape main actually publishes: a status snapshot with the transport @@ -554,8 +521,8 @@ describe('resume across the mirror handle gap', () => { vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS) const after = useAppStore.getState() - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(1) + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) }) it('releases only the pane whose handle landed when two panes share the environment', () => { @@ -587,7 +554,8 @@ describe('resume across the mirror handle gap', () => { // would look identical right now and silently restart its budget. Its own deadline still has // to land on the original schedule. vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[secondPaneKey]).toBeUndefined() + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[secondPaneKey]).toBeDefined() }) it('does not release or reschedule a park because another environment published a handle', () => { @@ -606,7 +574,9 @@ describe('resume across the mirror handle gap', () => { expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() // The unrelated handle must not have restarted this pane's budget. vi.advanceTimersByTime(HOST_MIRROR_HANDLE_GAP_DEADLINE_MS / 2) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(hasHostMirrorHandleWaitExpired(RUNTIME_ENV_ID, WEB_TAB_ID)).toBe(true) + expect(countParkedHostMirrorHandleGapPanesForTests()).toBe(0) }) it('leaves no waiter or timer behind when the environment tears its rows down mid-park', () => { diff --git a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts index b5523902d33..d4ae47bedc2 100644 --- a/src/renderer/src/lib/host-mirror-handle-gap-wait.ts +++ b/src/renderer/src/lib/host-mirror-handle-gap-wait.ts @@ -19,11 +19,10 @@ import { * The wait is bounded because mirror settlement has already happened and will * not replay a parked sweep again. Three exits, each replaying the sweep: * - the pane's own handle lands (`ptyIdsByTabId[tabId]` non-empty); - * - the row is retracted (the host has spoken: the pane is gone); + * - the row is retracted; * - the deadline expires. A handle that has not landed within the RPC budget - * is not coming on this connection, so the pane is released to ordinary - * recovery: a resume after a bounded wait is defensible, an indefinite hold - * is the latch-that-never-releases defect. A reconnect bumps the connection + * ends this wait without authorizing a client-side resume; the execution host owns recovery. + * A reconnect bumps the connection * generation and arms a fresh wait. A deadline that fires while contact is * lost, or after contact was lost and regained mid-budget, releases WITHOUT a * verdict: it measured the outage, not the host, and the re-park arms a diff --git a/src/renderer/src/lib/host-mirrored-pane-liveness.ts b/src/renderer/src/lib/host-mirrored-pane-liveness.ts index 17e252de349..0d5586f6895 100644 --- a/src/renderer/src/lib/host-mirrored-pane-liveness.ts +++ b/src/renderer/src/lib/host-mirrored-pane-liveness.ts @@ -1,7 +1,7 @@ import type { useAppStore } from '@/store' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' -import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' import { hasHostSessionMirrorHydrated } from '@/runtime/host-session-mirror-hydration' import { hasHostMirrorHandleWaitExpired } from './host-mirror-handle-gap-wait' @@ -19,6 +19,14 @@ export type UnhydratedHostMirror = /** The rows arrived but this pane's PTY handle has not; a bounded per-pane wait replays. */ | { kind: 'handle'; environmentId: string; tabId: string } +export function isHostMirroredSleepingRecord(record: SleepingAgentSessionRecord): boolean { + const tabId = + record.tabId ?? + parsePaneKey(record.paneKey)?.tabId ?? + parseLegacyNumericPaneKey(record.paneKey)?.tabId + return Boolean(tabId && isWebTerminalSurfaceTabId(tabId)) +} + /** The layout still binds a leaf of this tab to a PTY the environment minted. */ function tabHoldsEnvironmentPtyBinding( state: AppStoreState, @@ -33,7 +41,7 @@ function tabHoldsEnvironmentPtyBinding( /** * Reports the mirror a pane is still waiting on, or null when the pane's - * remote liveness is already decidable. + * mirror has no pending handle wait. * * Why: a `web-terminal-*` tab exists only because a host published it, and its * PTY handle arrives one relay round trip later. An empty local handle map is @@ -50,30 +58,12 @@ export function findUnhydratedHostMirrorForPane( if (!tabId || !isWebTerminalSurfaceTabId(tabId)) { return null } - // Why: once the mirror retracts the tab the host has spoken — the pane is - // gone, and ordinary recovery owns it again. + // Missing rows cannot arm a handle wait or authorize a replacement launch. const worktreeTabs = state.tabsByWorktree[record.worktreeId] ?? [] if (!worktreeTabs.some((tab) => tab.id === tabId)) { return null } - // Why: a published PTY handle for the tab is the mirror having spoken for it, - // whatever the individual leaf's fate. - // - // TAB-GRANULAR, and everything below this line is leaf-aware — the asymmetry is a known residual, - // not an oversight. For a single-leaf tab (every agent tab Orca creates) it is exact: the mirror - // builds `ptyIdsByTabId[tab]` out of the same map it writes to the layout's `ptyIdsByLeafId` - // (web-session-tabs-sync/terminal-build.ts), so a non-empty entry means this leaf is bound and - // live. For a SPLIT mirrored tab it is not. A leaf that has ever been bound keeps its binding - // across the gap — `retainPendingTerminalBindings` carries it — so the residual needs a leaf that - // was NEVER bound, i.e. a cold start or a re-pair with no layout to retain from. There, a sibling - // surface that reaches `ready` first publishes a handle for the tab while this leaf has none, the - // pane reads decidable, and the resume fires: #19735 narrowed to a split tab's first frame. - // It cannot be closed here, because such a leaf holds no binding and the binding is what names a - // pane in a handle-gap verdict. Closing it means keeping each surface's `pending-handle` status - // per leaf, which the host already publishes - // (main/runtime/runtime-mobile-session-projection.ts) and the client consumes but does not retain. - // Pinned as current behaviour by "resumes a pending leaf when a sibling leaf of the same tab - // holds the only handle" in host-mirror-handle-gap-resume.test.ts. + // A sibling handle ends the tab-level wait without authorizing a replacement launch. if ((state.ptyIdsByTabId[tabId]?.length ?? 0) > 0) { return null } diff --git a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts index d5fca82d19b..8be9580b11a 100644 --- a/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts +++ b/src/renderer/src/lib/host-mirrored-pane-resume-replay.test.ts @@ -155,7 +155,7 @@ afterEach(() => { }) describe('parked mirrored-pane resume replay', () => { - it('replays the deferred sweep and relaunches once the mirror retracts the pane', () => { + it('releases the deferred sweep without relaunching a retracted host pane', () => { const worktree = makeRuntimeOwnedWorktree() seedState(worktree) const paneKey = seedSleepingRecord(worktree.id, 'codex-session-replay-1') @@ -163,18 +163,14 @@ describe('parked mirrored-pane resume replay', () => { expect(resumeSleepingAgentSessionsForWorktree(worktree.id)).toBe(0) expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() - // The host answered: this pane is gone, so recovery is finally justified. + // Retraction settles the mirror; it does not authorize a client to restart the agent. retractMirroredTab(worktree.id) markHostSessionMirrorHydrated(RUNTIME_ENV_ID) const after = useAppStore.getState() - const tabs = after.tabsByWorktree[worktree.id] ?? [] - expect(tabs).toHaveLength(1) - expect(after.automaticAgentResumeClaimsByTabId[tabs[0]!.id]?.providerSession).toEqual({ - key: 'session_id', - id: 'codex-session-replay-1' - }) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(after.tabsByWorktree[worktree.id]).toEqual([]) + expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) it('replays without relaunching when the mirror reports the host PTY live', () => { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 0db30d02d62..bb9a84a912a 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -19,6 +19,7 @@ import { import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' import { findUnhydratedHostMirrorForPane, + isHostMirroredSleepingRecord, type UnhydratedHostMirror } from './host-mirrored-pane-liveness' import { parkUntilHostMirrorHandleLands } from './host-mirror-handle-gap-wait' @@ -318,6 +319,10 @@ export function resumeSleepingAgentSessionsForWorktree( if (isPaneOwned) { continue } + // Mirrored rows cannot authorize a writer: closes, re-keys and pending handles look alike here. + if (isHostMirroredSleepingRecord(record)) { + continue + } if (launchSleepingAgentSession(record, options)) { launched += 1 freshlyLaunchedClaimKeys.add(claimKey) diff --git a/src/renderer/src/lib/retracted-host-pane-resume.test.ts b/src/renderer/src/lib/retracted-host-pane-resume.test.ts new file mode 100644 index 00000000000..0908dceb0aa --- /dev/null +++ b/src/renderer/src/lib/retracted-host-pane-resume.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialState = useAppStore.getState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const HOST_TAB_ID = 'web-terminal-host-tab' + +afterEach(() => useAppStore.setState(initialState, true)) + +function makeRecord( + worktreeId: string, + paneKey: string, + tabId?: string +): SleepingAgentSessionRecord { + return { + paneKey, + ...(tabId ? { tabId } : {}), + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: 'host-conversation' }, + origin: 'live', + state: 'working', + prompt: 'continue', + capturedAt: 1, + updatedAt: 1 + } +} + +describe.each(['repo-1::/workspace/repo', 'folder:workspace'])('%s', (worktreeId) => { + it.each([ + { paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID), tabId: HOST_TAB_ID }, + { paneKey: makePaneKey(HOST_TAB_ID, LEAF_ID), tabId: undefined }, + { paneKey: `${HOST_TAB_ID}:0`, tabId: undefined } + ])('preserves an absent host record $paneKey without a catalog owner', ({ paneKey, tabId }) => { + const record = makeRecord(worktreeId, paneKey, tabId) + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record }, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {} + }) + + expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(0) + expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(0) + + const state = useAppStore.getState() + expect(state.tabsByWorktree[worktreeId]).toEqual([]) + expect(state.sleepingAgentSessionsByPaneKey[paneKey]).toBe(record) + expect(state.automaticAgentResumeClaimsByTabId).toEqual({}) + expect(state.pendingStartupByTabId).toEqual({}) + }) + + it('still recovers an ordinary terminal record with no preserved tab', () => { + const record = makeRecord(worktreeId, makePaneKey('local-tab', LEAF_ID), 'local-tab') + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {} + }) + + expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(1) + const state = useAppStore.getState() + expect(state.tabsByWorktree[worktreeId]).toHaveLength(1) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + expect(Object.keys(state.automaticAgentResumeClaimsByTabId)).toHaveLength(1) + }) + + it('still clears completed mirror history whose pane is gone', () => { + const record = { + ...makeRecord(worktreeId, makePaneKey(HOST_TAB_ID, LEAF_ID), HOST_TAB_ID), + state: 'done' as const + } + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {} + }) + + expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(0) + const state = useAppStore.getState() + expect(state.sleepingAgentSessionsByPaneKey).toEqual({}) + expect(state.tabsByWorktree[worktreeId]).toEqual([]) + expect(state.pendingStartupByTabId).toEqual({}) + }) + + it('still recovers the newer native record and clears its older mirror duplicate', () => { + const mirrored = makeRecord(worktreeId, makePaneKey(HOST_TAB_ID, LEAF_ID), HOST_TAB_ID) + const native = { + ...makeRecord(worktreeId, makePaneKey('native-tab', LEAF_ID), 'native-tab'), + capturedAt: 2, + updatedAt: 2 + } + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [] }, + sleepingAgentSessionsByPaneKey: { + [mirrored.paneKey]: mirrored, + [native.paneKey]: native + }, + automaticAgentResumeClaimsByTabId: {}, + pendingStartupByTabId: {} + }) + + expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(1) + expect(resumeSleepingAgentSessionsForWorktree(worktreeId)).toBe(0) + const state = useAppStore.getState() + expect(state.sleepingAgentSessionsByPaneKey).toEqual({}) + expect(state.tabsByWorktree[worktreeId]).toHaveLength(1) + expect(Object.values(state.automaticAgentResumeClaimsByTabId)).toMatchObject([ + { providerSession: native.providerSession } + ]) + expect(Object.values(state.pendingStartupByTabId)).toMatchObject([ + { resumeProviderSession: native.providerSession } + ]) + }) +}) diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index 75be4ea395e..fba6d4f08d0 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -265,7 +265,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { // the replacement it used to append relaunched `codex resume` against a // session the host still held (-32600 "already has an active writer"), // stranding a bare shell while the live agent lost its tab. - it('parks the resume for a still-published web-mirror tab and replays it on the verdict', async () => { + it('does not replace a still-published web-mirror tab after hydration settles', async () => { const webTabId = 'web-terminal-host-tab' // The workspace is owned by a paired runtime — without that the deferral // takes its no-execution-host early return and pins nothing. @@ -336,24 +336,20 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() expect(Object.keys(after.automaticAgentResumeClaimsByTabId)).toHaveLength(0) - // The mirror answers and still publishes no PTY for the pane, so the parked - // sweep replays with evidence and the replacement is finally justified. + // Hydration settles inventory delivery, not whether the host's process exited. markHostSessionMirrorHydrated(RUNTIME_ENV_ID) const replayed = useAppStore.getState() const replacementIds = (replayed.tabsByWorktree[worktree.id] ?? []) .map((tab) => tab.id) .filter((id) => id !== webTabId) - expect(replacementIds).toHaveLength(1) - expect(replayed.automaticAgentResumeClaimsByTabId[replacementIds[0]!]).toMatchObject({ - launchAgent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-E' } - }) + expect(replacementIds).toHaveLength(0) + expect(replayed.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(replayed.automaticAgentResumeClaimsByTabId).toEqual({}) + expect(replayed.pendingStartupByTabId).toEqual({}) }) - // Why: retraction is the mirror's verdict that the host pane is gone, which - // is what re-arms the one-replacement-per-session contract above. - it('appends the replacement once the mirror has retracted the web-mirror tab', async () => { + it('keeps recovery evidence without replacing a retracted web-mirror tab', async () => { const webTabId = 'web-terminal-host-tab' const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) @@ -392,15 +388,9 @@ describe('preserved-pane replacement contract on workspace activation', () => { const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] expect(tabs).toHaveLength(1) - expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - const replacement = tabs[0]! - expect(after.automaticAgentResumeClaimsByTabId[replacement.id]?.providerSession).toEqual({ - key: 'session_id', - id: 'codex-session-F' - }) - expect(after.consumeTabStartupCommand(replacement.id)?.resumeProviderSession).toEqual({ - key: 'session_id', - id: 'codex-session-F' - }) + // Activation may seed its ordinary shell; it must not queue an agent resume. + expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + expect(after.automaticAgentResumeClaimsByTabId).toEqual({}) + expect(after.consumeTabStartupCommand(tabs[0]!.id)?.resumeProviderSession).toBeUndefined() }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts index 472761778b0..54f23698e7b 100644 --- a/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts +++ b/src/renderer/src/runtime/host-session-mirror-frame-ordering-harness.ts @@ -22,7 +22,10 @@ import { REVISION, WT } from './host-session-mirror-frame-fixtures' -import { resetHostSessionMirrorHydrationForTests } from './host-session-mirror-hydration' +import { + hasHostSessionMirrorHydrated, + resetHostSessionMirrorHydrationForTests +} from './host-session-mirror-hydration' import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' import { resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync/tracking-lifecycle' @@ -159,20 +162,19 @@ export function tabIds(worktreeId: string): string[] { return (useAppStore.getState().tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) } -/** A drained waiter has to land a claiming replacement tab; a replay that only - * consumed the record launched nothing. */ -export function expectReplayedResume(paneKey: string, worktreeId: string, sessionId: string): void { +export function expectHostOwnedRecordNotResumed( + paneKey: string, + worktreeId: string, + sessionId: string +): void { const state = useAppStore.getState() - expect(state.sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() - const claimedTabIds = Object.keys(state.automaticAgentResumeClaimsByTabId) - expect(claimedTabIds).toHaveLength(1) - const replacementTabId = claimedTabIds[0]! - expect(replacementTabId).not.toBe(MIRROR_TAB_ID) - expect(tabIds(worktreeId)).toContain(replacementTabId) - expect(state.automaticAgentResumeClaimsByTabId[replacementTabId]).toMatchObject({ - launchAgent: 'codex', + expect(hasHostSessionMirrorHydrated(ENV, worktreeId)).toBe(true) + expect(state.sleepingAgentSessionsByPaneKey[paneKey]).toMatchObject({ + worktreeId, providerSession: { key: 'session_id', id: sessionId } }) + expect(Object.keys(state.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + expect(Object.keys(state.pendingStartupByTabId)).toHaveLength(0) } /** Registers the shared harness reset for the receipt-era describe blocks. */ diff --git a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx index 4fc867c6c37..4e2379811aa 100644 --- a/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-hydration-frame-ordering.test.tsx @@ -60,7 +60,7 @@ import { WT } from './host-session-mirror-frame-fixtures' import { - expectReplayedResume, + expectHostOwnedRecordNotResumed, findSubscription, installFrameOrderingHarness, publish, @@ -289,19 +289,8 @@ describe('mirrored-pane resume deferral against real stream frames', () => { snapshots: [makeHostSnapshot(WT, HOST_SURFACE_ID, 'host-tab-1')] }) - // The background mirror tab was retracted by the inventory, so recovery is - // finally justified: the record is consumed INTO a replacement tab that - // claims the session. Asserting only the record would pass for a replay - // that cleared it and launched nothing. - const replayed = useAppStore.getState() - expect(replayed.sleepingAgentSessionsByPaneKey[backgroundPaneKey]).toBeUndefined() - const backgroundTabIds = tabIds(BG_WT) - expect(backgroundTabIds).toHaveLength(1) - expect(backgroundTabIds[0]).not.toBe(BG_MIRROR_TAB_ID) - expect(replayed.automaticAgentResumeClaimsByTabId[backgroundTabIds[0]!]).toMatchObject({ - launchAgent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-bg-1' } - }) + expect(tabIds(BG_WT)).toEqual([BG_MIRROR_TAB_ID]) + expectHostOwnedRecordNotResumed(backgroundPaneKey, BG_WT, 'codex-session-bg-1') }) // The three tests below pin the remaining release sites one frame at a time: @@ -321,8 +310,7 @@ describe('mirrored-pane resume deferral against real stream frames', () => { const paneKey = seedSleepingRecord(MIRROR_TAB_ID, WT, 'codex-session-listall-release') expect(resumeSleepingAgentSessionsForWorktree(WT)).toBe(0) - // The inventory retracts the mirror tab, so the host HAS spoken: this pane - // is gone and the resume it parked is finally justified. + // A settled inventory can retract a closed tab without authorizing another resume. await act(async () => { resolveListAll({ id: 'listall', @@ -336,7 +324,7 @@ describe('mirrored-pane resume deferral against real stream frames', () => { }) expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-listall-release') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-listall-release') }) it('a single frame on the global stream releases the pane it parked', async () => { @@ -351,7 +339,7 @@ describe('mirrored-pane resume deferral against real stream frames', () => { }) expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-global-frame') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-global-frame') }) it('the active-worktree scoped frame releases the pane it parked', async () => { @@ -366,7 +354,7 @@ describe('mirrored-pane resume deferral against real stream frames', () => { }) expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-scoped-frame') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-scoped-frame') }) it('does not let a scoped frame from before tracking reset release its pane', async () => { @@ -454,7 +442,7 @@ describe('mirror latch verdicts against real stream failures', () => { expect(mocks.createTerminal).toHaveBeenCalled() expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-spawn-reject') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-spawn-reject') }) it('a rejection before the patch settles nothing', async () => { @@ -506,7 +494,7 @@ describe('a parked wake replay keeps the mount contract of its caller', () => { beforeEach(takeAllPendingBackgroundTerminalWorktreeMounts) afterEach(takeAllPendingBackgroundTerminalWorktreeMounts) - it('background-mounts the resume tab a parked sweep replayed', async () => { + it('does not resume or mount a retracted host tab after a phone opens the workspace', async () => { renderHook(() => useWebSessionTabsSync()) await act(settle) const paneKey = seedSleepingRecord(BG_MIRROR_TAB_ID, BG_WT, 'codex-session-parked-wake') @@ -522,11 +510,9 @@ describe('a parked wake replay keeps the mount contract of its caller', () => { snapshots: [makeHostSnapshot(WT, HOST_SURFACE_ID, HOST_PARENT_TAB_ID)] }) - expectReplayedResume(paneKey, BG_WT, 'codex-session-parked-wake') - const launchedTabId = Object.keys(useAppStore.getState().automaticAgentResumeClaimsByTabId)[0]! - // Why: the replayed tab is created activate:false, so nothing else mounts it - // and its queued `--resume` never reaches a PTY. - expect(takePendingBackgroundTerminalWorktreeMount(BG_WT)?.tabIds).toEqual([launchedTabId]) + expectHostOwnedRecordNotResumed(paneKey, BG_WT, 'codex-session-parked-wake') + expect(tabIds(BG_WT)).toEqual([BG_MIRROR_TAB_ID]) + expect(takePendingBackgroundTerminalWorktreeMount(BG_WT)).toBeNull() }) }) @@ -576,6 +562,6 @@ describe('an inventory whose post-patch bookkeeping throws', () => { ENV ) expect(tabIds(BG_WT)).not.toContain(BG_MIRROR_TAB_ID) - expectReplayedResume(paneKey, BG_WT, 'codex-session-bookkeeping-throw') + expectHostOwnedRecordNotResumed(paneKey, BG_WT, 'codex-session-bookkeeping-throw') }) }) diff --git a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx index ea9aa7e7e43..16223f05ab5 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx +++ b/src/renderer/src/runtime/host-session-mirror-settle-receipt-frames.test.tsx @@ -56,7 +56,7 @@ import { WT } from './host-session-mirror-frame-fixtures' import { - expectReplayedResume, + expectHostOwnedRecordNotResumed, findSubscription, installFrameOrderingHarness, publish, @@ -114,7 +114,7 @@ describe('a global singular frame whose patch never lands', () => { }) expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-patch-throw') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-patch-throw') }) it('settles nothing when the patch itself throws mid-apply', async () => { @@ -147,7 +147,7 @@ describe('a global singular frame whose patch never lands', () => { }) expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-apply-throw') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-apply-throw') }) }) @@ -177,7 +177,7 @@ describe('a stale frame against a newer accepted view', () => { }) expect(hasHostSessionMirrorHydrated(ENV, WT)).toBe(true) - expectReplayedResume(paneKey, WT, 'codex-session-stale-settle') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-stale-settle') }) }) @@ -223,10 +223,9 @@ describe('a deferred visibility-resume repair patch', () => { authoritative: true }) - // The tombstone repair DID reach the store: the background mirror retracted, - // which is the host answering — the parked resume is finally justified. + // The repair settles hydration; a visibility retraction does not authorize a resume. expect(tabIds(BG_WT)).not.toContain(BG_MIRROR_TAB_ID) - expectReplayedResume(paneKey, BG_WT, 'codex-session-tombstone') + expectHostOwnedRecordNotResumed(paneKey, BG_WT, 'codex-session-tombstone') }) }) @@ -252,10 +251,9 @@ describe('the eager post-create list answers for its worktree', () => { await settle() }) - // The list retracted the mirror tab and its patch landed, so this worktree - // has its verdict: the parked resume must drain, not wait for a stream. + // The list settled hydration, but the retracted tab must stay absent. expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-eager-refresh') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-eager-refresh') }) it('does not resurrect a worktree the stream retracted while the list was in flight', async () => { @@ -476,7 +474,7 @@ describe('a host frame for a workspace the mirror never writes', () => { }) expect(hasHostSessionMirrorHydrated(ENV, BG_WT)).toBe(true) - expectReplayedResume(paneKey, BG_WT, 'codex-session-floating-member') + expectHostOwnedRecordNotResumed(paneKey, BG_WT, 'codex-session-floating-member') }) }) @@ -523,7 +521,7 @@ describe('a store subscriber that throws after the commit', () => { ) expect(tabIds(WT)).not.toContain(MIRROR_TAB_ID) - expectReplayedResume(paneKey, WT, 'codex-session-subscriber-throw') + expectHostOwnedRecordNotResumed(paneKey, WT, 'codex-session-subscriber-throw') warn.mockRestore() }) })