fix(terminal): prevent automatic replacement of host-mirrored agents (#29289)

* fix(terminal): leave mirrored agent recovery to execution host

* fix(terminal): limit mirrored recovery guard to replacement launches
This commit is contained in:
Neil
2026-10-15 23:12:47 -07:00
committed by GitHub
parent 2c44830c68
commit c5a92b1df1
10 changed files with 235 additions and 179 deletions
@@ -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<typeof makeCreatedAgentWorkt
useAppStore.setState(state as AppState)
}
/**
* The first frame of a SPLIT mirrored tab whose sibling surface is already `ready` while the
* record's own surface is still `pending-handle` and has never been bound.
*
* Why "never been bound" and not "went pending": `retainPendingTerminalBindings`
* (web-session-tabs-sync/terminal-build.ts) carries a pending surface's PRIOR binding forward, so a
* leaf that has ever held a handle keeps it across the gap and this shape cannot arise from one. It
* needs a cold start or a re-pair — no existing layout to retain from.
*
* No wait is armed here for a separate reason: `ptyIdsByTabId[tab]` is non-empty, so the pane reads
* decidable at the tab-granular gate before the per-pane wait is ever considered. That is the
* residual, and it is decided on the first frame.
*
* And not "the tab published a handle no leaf is bound to": `ptyIdsByTabId[tab]` is built from the
* very map written to `terminalLayoutsByTabId[tab].ptyIdsByLeafId`, so those two cannot disagree
* about which PTY ids exist.
*/
/** A cold split mirror whose sibling is ready before this leaf has ever held a binding. */
function seedSplitTabWithOnlySiblingReady(
worktree: ReturnType<typeof makeCreatedAgentWorktree>
): 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', () => {
@@ -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
@@ -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
}
@@ -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', () => {
@@ -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)
@@ -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 }
])
})
})
@@ -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()
})
})
@@ -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. */
@@ -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')
})
})
@@ -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()
})
})