mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user