fix: retain fence identity through final admission and retirement

This commit is contained in:
Jinwoo-H
2026-09-07 22:55:08 -04:00
parent 2120f095f2
commit dc6390b004
22 changed files with 642 additions and 123 deletions
@@ -1,3 +1,8 @@
import { localProvider, sshProviders } from './pty/provider/registry'
vi.mock('../project-groups/folder-workspace-path-status', () => ({
getFolderWorkspacePathStatus: vi.fn(async () => ({ status: 'available' })),
assertFolderWorkspacePathUsable: vi.fn()
}))
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { TERMINAL_LIFECYCLE_METHODS } from '../runtime/rpc/methods/terminal/terminal-lifecycle-methods'
import { TERMINAL_FENCED_CREATE_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
@@ -72,6 +77,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
const ptyId = `pty-${name}`
const paneKey = makePaneKey(tabId, leafId)
let session = {
legacyWorkerResumeFencesByPaneKey: undefined as Record<string, true> | undefined,
tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] },
terminalLayoutsByTabId: {
[tabId]: {
@@ -539,6 +545,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
: { ...session, sleepingAgentSessionsByPaneKey: {} }
)
const spawn = installDaemonTestProvider()
sshProviders.set('ssh-host', localProvider)
registerPtyHandlers(
mainWindow as never,
runtime as never,
@@ -567,7 +574,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
entry === 'ipc'
? await handlers.get('pty:spawn')!(null, request)
: await controller.spawn(request)
expect(result).toEqual({ id: '', reattachUnverifiable: true })
expect(result).toEqual({ id: expect.any(String), reattachUnverifiable: true })
expect(spawn).not.toHaveBeenCalled()
expect(runtime.registerPty).not.toHaveBeenCalled()
expect(store.setWorkspaceSession).not.toHaveBeenCalled()
@@ -600,6 +607,7 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
: { ...session, sleepingAgentSessionsByPaneKey: {} }
)
const spawn = installDaemonTestProvider()
sshProviders.set('ssh-host', localProvider)
registerPtyHandlers(
mainWindow as never,
runtime as never,
@@ -699,4 +707,51 @@ describe('pty:spawn under a persisted main-owned resume fence', () => {
expect(store.persistPtyBinding).not.toHaveBeenCalled()
expect(session.sleepingAgentSessionsByPaneKey[sourcePaneKey]).toBe(record)
})
it.each(['ipc', 'runtime'] as const)(
'review: %s rechecks source fence after target attach',
async (entry) => {
const { store, runtime, spawnArgs } = buildFencedPaneContext('source-race')
const session = store.getWorkspaceSession()
const sourceKey = makePaneKey('source-tab', spawnArgs.leafId)
const targetKey = makePaneKey(spawnArgs.tabId, spawnArgs.leafId)
session.legacyWorkerResumeFencesByPaneKey = {}
session.sleepingAgentSessionsByPaneKey[targetKey].automaticResumeBlockedBy = ''
session.sleepingAgentSessionsByPaneKey[sourceKey] = {
worktreeId: spawnArgs.worktreeId,
agent: 'claude',
providerSession: { key: 'session_id', id: 'resume-source' },
automaticResumeBlockedBy: ''
} as never
const spawn = vi.fn(async (options) => {
if (options.attachOnly) {
session.legacyWorkerResumeFencesByPaneKey = { [sourceKey]: true }
throw new SessionNotFoundError(spawnArgs.sessionId)
}
return { id: 'replacement-resuming-fenced-source' }
})
installDaemonTestProvider({ spawn })
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store as never
)
const controller = runtime.setPtyController.mock.calls[0]![0] as {
spawn: (a: unknown) => Promise<unknown>
}
const args = {
...spawnArgs,
command: 'claude --resume resume-source',
launchAgent: 'claude',
resumeProviderSession: { key: 'session_id', id: 'resume-source' }
}
const result = await (entry === 'ipc'
? handlers.get('pty:spawn')!(null, args)
: controller.spawn(args))
expect(result).toMatchObject({ reattachUnverifiable: true })
expect(spawn.mock.calls.filter(([o]) => !o.attachOnly)).toHaveLength(0)
}
)
})
@@ -1,3 +1,12 @@
import { Store } from '../persistence/loading-store/store'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
import { getDefaultWorkspaceSession } from '../../shared/constants'
vi.mock('../project-groups/folder-workspace-path-status', () => ({
getFolderWorkspacePathStatus: vi.fn(async () => ({ status: 'available' })),
assertFolderWorkspacePathUsable: vi.fn()
}))
import { describe, expect, it, vi } from 'vitest'
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
import { registerPtyHandlers } from './pty'
@@ -17,7 +26,12 @@ vi.mock('../mimo/hook-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
)
vi.mock('../agent-hooks/server', () =>
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
import('./pty-ipc-mock-registry').then((m) => ({
agentHookServer: {
...m.agentHookServerModuleMock().agentHookServer,
setPaneKeyAliasPersistenceListener: vi.fn()
}
}))
)
vi.mock('../pi/titlebar-extension-service', () =>
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
@@ -39,7 +53,10 @@ vi.mock('../memory/pty-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
)
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
import('./pty-ipc-mock-registry').then((m) => ({
...m.migrationUnsupportedPtyModuleMock(),
setMigrationUnsupportedPtyPersistenceListener: vi.fn()
}))
)
vi.mock('../codex/codex-pane-account-registry', () =>
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
@@ -49,7 +66,7 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () =>
)
describe('paired ensure resumes are admitted against the persisted source', () => {
const { mainWindow, installDaemonTestProvider } = setupPtyIpcSuite()
const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite()
it('refuses a new-pane resume before provider spawn or publication with the source intact', async () => {
const worktreeId = 'folder:ensure-source'
@@ -66,6 +83,7 @@ describe('paired ensure resumes are admitted against the persisted source', () =
sleepingAgentSessionsByPaneKey: { [paneKey]: record }
}
const store = {
getFolderWorkspace: vi.fn(() => ({ folderPath: '/folder' })),
getWorkspaceSession: vi.fn(() => session),
getSettings: () => ({ agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {} }),
setWorkspaceSession: vi.fn(),
@@ -111,4 +129,83 @@ describe('paired ensure resumes are admitted against the persisted source', () =
expect(session.sleepingAgentSessionsByPaneKey[paneKey]).toBe(record)
expect(session.tabsByWorktree).toEqual({})
})
it.each(['ipc', 'runtime'])(
'%s refuses a duplicate after the real Store retains its fenced source',
async (entry) => {
const store = new Store({
dataFile: join(tmpdir(), `orca-unify-${randomUUID()}`, 'data.json')
})
const worktreeId = 'folder-worker',
leafId = '11111111-1111-4111-8111-111111111111',
paneKey = `source:${leafId}`
const record = {
paneKey,
tabId: 'source',
worktreeId,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'duplicate-worker' },
state: 'working' as const,
prompt: 'continue',
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
legacyWorkerResumeFencesByPaneKey: { [paneKey]: true }
})
store.setWorkspaceSession({
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: {
[`newer:${leafId}`]: {
...record,
paneKey: `newer:${leafId}`,
tabId: 'newer',
updatedAt: 2
}
}
})
const spawn = installDaemonTestProvider()
const runtime = {
createPreAllocatedTerminalHandle: vi.fn(),
setPtyController: vi.fn(),
resolveTerminalPane: () => {
throw new Error('terminal_not_found')
}
}
registerPtyHandlers(
mainWindow as never,
runtime as never,
undefined,
undefined,
undefined,
store
)
const controller = runtime.setPtyController.mock.calls[0][0] as {
spawn: (args: unknown) => Promise<unknown>
}
const args = {
cols: 80,
rows: 24,
worktreeId,
tabId: 'new-resume',
leafId,
launchAgent: 'codex',
resumeProviderSession: record.providerSession
}
try {
const result = await (entry === 'ipc'
? handlers.get('pty:spawn')!(null, args)
: controller.spawn(args))
expect(result).toMatchObject({ reattachUnverifiable: true })
expect(spawn).not.toHaveBeenCalled()
expect(
store.getWorkspaceSession().sleepingAgentSessionsByPaneKey?.[paneKey]?.providerSession
).toEqual(record.providerSession)
} finally {
store.flush()
}
}
)
})
@@ -452,4 +452,46 @@ describe('undelivered SSH stops', () => {
deletePtyOwnership('ssh:ssh-1@@pty-8')
}
})
it.each(['ipc', 'runtime'])(
'%s cannot hibernate a live fenced SSH pane with a stale persisted binding',
async (entry) => {
const leaf = '11111111-1111-4111-8111-111111111111',
wt = 'folder-worker',
paneKey = makePaneKey('worker', leaf)
const session = {
tabsByWorktree: { [wt]: [{ id: 'worker', worktreeId: wt }] },
terminalLayoutsByTabId: { worker: { ptyIdsByLeafId: { [leaf]: 'ssh:ssh-1@@stale' } } },
sleepingAgentSessionsByPaneKey: {},
legacyWorkerResumeFencesByPaneKey: { [paneKey]: true }
}
const store = {
...createKillStore(),
getWorkspaceSession: vi.fn((host) => (host === 'ssh:ssh-1' ? session : {}))
}
const shutdown = vi.fn(async () => {})
registerSshPtyProvider('ssh-1', sshProviderStub(shutdown))
setPtyOwnership(SCOPED_PTY_ID, 'ssh-1')
const { runtime, stopAndWait } = install(store)
Object.assign(runtime, {
resolveTerminalPane: () => ({
ptyId: SCOPED_PTY_ID,
tabId: 'worker',
leafId: leaf,
worktreeId: wt,
connected: true
})
})
try {
await expect(
entry === 'ipc'
? handlers.get('pty:kill')!(null, { id: SCOPED_PTY_ID, keepHistory: true })
: stopAndWait(SCOPED_PTY_ID, { keepHistory: true })
).rejects.toThrow('agent_hibernation_automatic_resume_blocked')
expect(shutdown).not.toHaveBeenCalled()
} finally {
unregisterSshPtyProvider('ssh-1')
deletePtyOwnership(SCOPED_PTY_ID)
}
}
)
})
+1 -1
View File
@@ -55,7 +55,7 @@ export function installPtyKillIpcHandler(deps: PtyKillIpcDeps): void {
// hibernating pane would destroy it on the next handshake.
const reversible = args.keepHistory === true
if (reversible) {
assertPtyHibernationAllowed(store, args.id, connectionId)
assertPtyHibernationAllowed(runtime, store, args.id, connectionId)
}
runtime?.markPtyStopRequested?.(args.id)
const provider = connectionId ? sshProviders.get(connectionId) : tryGetProviderForPty(args.id)
+2 -17
View File
@@ -12,10 +12,7 @@ import {
paneSpawnReservationsByOwnerKey,
pendingRuntimePaneCreatesByOwnerKey
} from '../pane/spawn-reservation'
import {
isSleepingAgentResumeBlocked,
isStablePaneResumeBlocked
} from '../pane/stable-pane-resume-fence'
import { isStablePaneResumeBlocked } from '../pane/stable-pane-resume-fence'
import { resolveStablePaneOwner } from '../pane/stable-owner'
import type { PtyIpcSpawnState } from './spawn-state'
@@ -23,9 +20,6 @@ export async function beginPtyIpcSpawn(
ctx: PtyIpcSpawnState
): Promise<PtySpawnResult | { isReattach: true } | null> {
const args = ctx.args
if (isSleepingAgentResumeBlocked(ctx.deps.store, args)) {
return { id: args.sessionId ?? '', reattachUnverifiable: true as const }
}
ctx.codexHomeLaunchStartedAt = !args.connectionId ? new Date() : undefined
ctx.codexHomeLaunchStartedSequence = !args.connectionId
? allocatePtyLifecycleSequence()
@@ -66,16 +60,7 @@ export async function beginPtyIpcSpawn(
ctx.spawnTiming = createPtySpawnTiming()
ctx.cwd = ctx.deps.resolvePtySpawnStartupCwd(args.worktreeId, args.cwd)
const earlyLeafId =
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
const earlyPaneKey =
typeof args.worktreeId === 'string' &&
typeof args.tabId === 'string' &&
isValidTerminalTabId(args.tabId) &&
args.tabId.length <= 512 &&
earlyLeafId
? makePaneKey(args.tabId, earlyLeafId)
: null
const earlyPaneKey = initialPaneKey
const earlyReservationKey = makePaneSpawnReservationKey(
args.worktreeId,
args.connectionId,
+2 -5
View File
@@ -1,4 +1,3 @@
import { isSleepingAgentResumeBlocked } from '../pane/stable-pane-resume-fence'
import { ensureWslHookRelayForReattach } from '../../../agent-hooks/wsl-hook-relay-reattach'
import {
SSH_SESSION_EXPIRED_ERROR,
@@ -20,10 +19,6 @@ import type { PtyIpcSpawnState } from './spawn-state'
export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
const args = ctx.args
try {
if (isSleepingAgentResumeBlocked(ctx.deps.store, args)) {
ctx.result = { id: args.sessionId ?? '', reattachUnverifiable: true }
return
}
if (ctx.preAllocatedHandle) {
ctx.deps.trustedTerminalHandleEnv.add(ctx.preAllocatedHandle)
}
@@ -58,6 +53,8 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<void> {
store: ctx.deps.store,
provider: ctx.provider,
spawnOptions: ctx.spawnOptions,
launchAgent: args.launchAgent,
resumeProviderSession: args.resumeProviderSession,
owner: stablePaneOwnerCandidate,
worktreeId: args.worktreeId,
connectionId: args.connectionId,
+24 -17
View File
@@ -1,10 +1,13 @@
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { readWorkspaceSessionResumeFences } from '../../../../shared/workspace-session-resume-fences'
import type { Store } from '../../../persistence'
import { resolvePersistedStablePaneOwner } from './stable-owner'
import { isStablePaneResumeBlocked, StablePaneResumeBlockedError } from './stable-pane-resume-fence'
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
import { resolvePersistedStablePaneOwner, resolveStablePaneOwner } from './stable-owner'
import { StablePaneResumeBlockedError } from './stable-pane-resume-fence'
export function assertPtyHibernationAllowed(
runtime: OrcaRuntimeService | undefined,
store: Store | undefined,
ptyId: string,
connectionId: string | null | undefined
@@ -12,20 +15,24 @@ export function assertPtyHibernationAllowed(
const session = store?.getWorkspaceSession?.(
connectionId ? toSshExecutionHostId(connectionId) : undefined
)
for (const tabs of Object.values(session?.tabsByWorktree ?? {})) {
for (const tab of tabs) {
for (const leafId of Object.keys(
session?.terminalLayoutsByTabId?.[tab.id]?.ptyIdsByLeafId ?? {}
)) {
const paneKey = makePaneKey(tab.id, leafId)
if (
resolvePersistedStablePaneOwner(store, paneKey, tab.worktreeId, connectionId)?.ptyId ===
ptyId &&
isStablePaneResumeBlocked(store, paneKey, tab.worktreeId, connectionId)
) {
throw new StablePaneResumeBlockedError('agent_hibernation_automatic_resume_blocked')
}
}
if (!session) {
return
}
for (const paneKey of Object.keys(readWorkspaceSessionResumeFences(session))) {
const tabId = parsePaneKey(paneKey)?.tabId
const worktreeId =
session?.sleepingAgentSessionsByPaneKey?.[paneKey]?.worktreeId ??
Object.entries(session?.tabsByWorktree ?? {}).find(([, tabs]) =>
tabs.some((tab) => tab.id === tabId)
)?.[0]
if (!worktreeId) {
continue
}
// Resolve independently: a stale persisted binding must not hide the live owner.
const current = resolveStablePaneOwner(runtime, undefined, paneKey, worktreeId, connectionId)
const persisted = resolvePersistedStablePaneOwner(store, paneKey, worktreeId, connectionId)
if (current?.ptyId === ptyId || persisted?.ptyId === ptyId) {
throw new StablePaneResumeBlockedError('agent_hibernation_automatic_resume_blocked')
}
}
}
+4 -12
View File
@@ -1,4 +1,5 @@
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
import type { isSleepingAgentResumeBlocked } from './stable-pane-resume-fence'
import { isFreshPaneResumeBlocked, isStablePaneResumeBlocked } from './stable-pane-resume-fence'
import { toSshExecutionHostId } from '../../../../shared/execution-host'
import { makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
@@ -121,14 +122,12 @@ export function resolveStablePaneOwner(
}
}
export type StablePaneSpawnContext = {
export type StablePaneSpawnContext = Parameters<typeof isSleepingAgentResumeBlocked>[1] & {
runtime: OrcaRuntimeService | undefined
store?: Store
provider: IPtyProvider
spawnOptions: PtySpawnOptions
owner: StablePaneOwner | null
worktreeId?: string
connectionId?: string | null
resolveOwner?: () => StablePaneOwner | null
onFreshSpawn?: (result: PtySpawnResult) => void
}
@@ -280,14 +279,7 @@ export async function spawnForStablePane(
return attached
}
}
if (
isStablePaneResumeBlocked(
args.store,
args.spawnOptions.paneKey,
args.worktreeId,
args.connectionId
)
) {
if (isFreshPaneResumeBlocked(args.store, args.spawnOptions.paneKey, args)) {
return {
result: { id: args.spawnOptions.sessionId ?? '', reattachUnverifiable: true },
owner: null
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { isStablePaneResumeBlocked } from './stable-pane-resume-fence'
import { isStablePaneResumeBlocked, isSleepingAgentResumeBlocked } from './stable-pane-resume-fence'
describe('review host-scoped fence', () => {
it('reads SSH policy when the identical local pane is not fenced', () => {
@@ -15,3 +15,60 @@ describe('review host-scoped fence', () => {
expect(isStablePaneResumeBlocked(store as never, paneKey, worktreeId, 'host')).toBe(true)
})
})
const leaf = '11111111-1111-4111-8111-111111111111',
key = `source:${leaf}`,
wt = 'folder-worker',
ps = { key: 'session_id' as const, id: 'same' }
function fixture() {
const session = {
sleepingAgentSessionsByPaneKey: {
[key]: { worktreeId: wt, agent: 'claude', providerSession: ps }
},
legacyWorkerResumeFencesByPaneKey: { [key]: true },
tabsByWorktree: { [wt]: [{ id: 'source', worktreeId: wt }] },
terminalLayoutsByTabId: { source: { ptyIdsByLeafId: { [leaf]: 'pty' } } }
}
const store = {
getWorkspaceSession: vi.fn((host?: string) =>
host === 'ssh:host'
? session
: { ...session, sleepingAgentSessionsByPaneKey: {}, legacyWorkerResumeFencesByPaneKey: {} }
)
}
return { session, store }
}
it('review: resume identity stays in its workspace, agent and host partition', () => {
const { store } = fixture()
const a = {
worktreeId: wt,
connectionId: 'host',
launchAgent: 'claude' as const,
resumeProviderSession: ps
}
expect(isSleepingAgentResumeBlocked(store as never, a)).toBe(true)
for (const patch of [
{ worktreeId: 'other' },
{ launchAgent: 'codex' },
{ connectionId: null },
{ resumeProviderSession: undefined },
{ resumeProviderSession: { key: 'session_id', id: 'other' } }
]) {
expect(isSleepingAgentResumeBlocked(store as never, { ...a, ...patch } as never)).toBe(false)
}
})
it('review: a matching fenced duplicate wins regardless of unfenced record order', () => {
const { session, store } = fixture()
const record = session.sleepingAgentSessionsByPaneKey[key]
session.sleepingAgentSessionsByPaneKey = {
[`other:${leaf}`]: { ...record },
[key]: record
} as never
expect(
isSleepingAgentResumeBlocked(store as never, {
worktreeId: wt,
connectionId: 'host',
resumeProviderSession: ps
})
).toBe(true)
})
@@ -32,7 +32,8 @@ export function isSleepingAgentResumeBlocked(
resumeProviderSession?: AgentProviderSessionMetadata
}
): boolean {
if (!store?.getWorkspaceSession || !args.worktreeId || !args.resumeProviderSession) {
const worktreeId = args.worktreeId
if (!store?.getWorkspaceSession || !worktreeId || !args.resumeProviderSession) {
return false
}
const session = store.getWorkspaceSession(
@@ -40,14 +41,24 @@ export function isSleepingAgentResumeBlocked(
)
return Object.entries(session?.sleepingAgentSessionsByPaneKey ?? {}).some(
([paneKey, record]) =>
record.worktreeId === args.worktreeId &&
(!args.launchAgent || record.agent === args.launchAgent) &&
agentProviderSessionsEqual(
record.agent,
record.providerSession,
args.resumeProviderSession
) &&
isPaneAutomaticResumeBlocked(session, paneKey, args.worktreeId)
isPaneAutomaticResumeBlocked(session, paneKey, worktreeId)
)
}
export function isFreshPaneResumeBlocked(
store: Store | undefined,
paneKey: string | null | undefined,
args: Parameters<typeof isSleepingAgentResumeBlocked>[1]
): boolean {
return (
isSleepingAgentResumeBlocked(store, args) ||
isStablePaneResumeBlocked(store, paneKey, args.worktreeId, args.connectionId)
)
}
+1 -1
View File
@@ -244,7 +244,7 @@ export async function stopAndWaitPtyFromRuntimeController(
}
}
if (opts?.keepHistory) {
assertPtyHibernationAllowed(store, ptyId, connectionId)
assertPtyHibernationAllowed(runtime, store, ptyId, connectionId)
}
runtime?.markPtyStopRequested?.(ptyId)
let provider: IPtyProvider
+4 -13
View File
@@ -1,6 +1,6 @@
import {
isStablePaneResumeBlocked,
isSleepingAgentResumeBlocked,
isFreshPaneResumeBlocked,
StablePaneResumeBlockedError
} from '../pane/stable-pane-resume-fence'
import type { PtySpawnResult } from '../../../providers/types'
@@ -31,9 +31,6 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
? await acquireWorktreeSpawn.call(runtime, args.worktreeId)
: undefined
try {
if (isSleepingAgentResumeBlocked(ctx.deps.store, args)) {
throw new StablePaneResumeBlockedError()
}
if (args.preAllocatedHandle) {
ctx.deps.trustedTerminalHandleEnv.add(args.preAllocatedHandle)
}
@@ -96,15 +93,7 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
surface: args.agentSessionEnsure.surface,
spawn: async () => {
assertClientStillConnected()
if (
isSleepingAgentResumeBlocked(ctx.deps.store, args) ||
isStablePaneResumeBlocked(
ctx.deps.store,
ctx.spawnIdentityPaneKey,
args.worktreeId,
args.connectionId
)
) {
if (isFreshPaneResumeBlocked(ctx.deps.store, ctx.spawnIdentityPaneKey, args)) {
throw new StablePaneResumeBlockedError()
}
providerResult = await ctx.provider.spawn(ctx.spawnOptions)
@@ -158,6 +147,8 @@ export async function executeRuntimePtySpawn(ctx: RuntimePtySpawnState): Promise
store: ctx.deps.store,
provider: ctx.provider,
spawnOptions: ctx.spawnOptions,
launchAgent: args.launchAgent,
resumeProviderSession: args.resumeProviderSession,
owner: stablePaneOwnerCandidate,
worktreeId: args.worktreeId,
connectionId: args.connectionId,
+1 -7
View File
@@ -8,10 +8,7 @@ import {
reservePaneSpawn,
resolvePaneSpawnReservation
} from '../pane/spawn-reservation'
import {
isSleepingAgentResumeBlocked,
isStablePaneResumeBlocked
} from '../pane/stable-pane-resume-fence'
import { isStablePaneResumeBlocked } from '../pane/stable-pane-resume-fence'
import { resolveStablePaneOwner } from '../pane/stable-owner'
import { ptySizes } from '../delivery/visibility-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
@@ -59,9 +56,6 @@ export async function spawnPtyFromRuntimeController(
args: RuntimePtySpawnArgs
) {
const ctx = createRuntimePtySpawnState(deps, args)
if (isSleepingAgentResumeBlocked(deps.store, args)) {
return { id: args.sessionId ?? '', reattachUnverifiable: true as const }
}
if (!args.adoptedStablePane) {
const leafId =
typeof args.leafId === 'string' && isTerminalLeafId(args.leafId) ? args.leafId : null
@@ -1,3 +1,5 @@
import { retireTerminalSurfaceFromPersistence } from '../../runtime/mobile-session-terminal-persistence-retirement'
import { isSleepingAgentResumeBlocked } from '../../ipc/pty/pane/stable-pane-resume-fence'
/**
* Drives the real `Store`, not the helper and not a fake.
*
@@ -33,7 +35,7 @@ vi.mock('electron', () => ({
const { Store } = await import('./store')
const HOST_ID = 'ssh:user@host'
const HOST_ID = 'ssh:host'
const WT = 'repo-1::/tmp/worktree-a'
const stores: InstanceType<typeof Store>[] = []
@@ -247,3 +249,103 @@ it.each([undefined, HOST_ID])(
).toBeUndefined()
}
)
it.each([undefined, HOST_ID])(
'retains duplicate cleanup identity until runtime retires the fence on %s',
(hostId) => {
const store = createStore()
const wt = 'folder-worker',
key = 'fenced:11111111-1111-4111-8111-111111111111',
other = 'newer:22222222-2222-4222-8222-222222222222'
const record = {
paneKey: key,
tabId: 'fenced',
worktreeId: wt,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'session-worker' },
state: 'working' as const,
prompt: 'continue',
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
const records = {
[key]: record,
[other]: { ...record, paneKey: other, tabId: 'newer', capturedAt: 2, updatedAt: 2 }
}
store.setWorkspaceSession(
{
...rendererSession('seed'),
sleepingAgentSessionsByPaneKey: records,
legacyWorkerResumeFencesByPaneKey: { [key]: true }
},
hostId
)
const payload = {
...rendererSession('after-cleanup'),
sleepingAgentSessionsByPaneKey: { [other]: records[other] }
}
expect(payload.sleepingAgentSessionsByPaneKey?.[key]).toBeUndefined()
store.setWorkspaceSession(payload, hostId)
expect(store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey?.[key]).toEqual({
...record,
automaticResumeBlockedBy: 'legacy-orchestration-worker'
})
expect(
isSleepingAgentResumeBlocked(store, {
worktreeId: wt,
connectionId: hostId?.slice(4),
launchAgent: 'codex',
resumeProviderSession: record.providerSession
})
).toBe(true)
store.setWorkspaceSession(
{ ...store.getWorkspaceSession(hostId), legacyWorkerResumeFencesByPaneKey: {} },
hostId
)
store.setWorkspaceSession(payload, hostId)
expect(store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey?.[key]).toBeUndefined()
}
)
it.each([undefined, HOST_ID])(
'runtime retirement ends fenced identity retention on %s',
(hostId) => {
const store = createStore(),
paneKey = 'worker:11111111-1111-4111-8111-111111111111'
const record = {
paneKey,
tabId: 'worker',
worktreeId: WT,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'worker-session' },
state: 'done' as const,
prompt: '',
capturedAt: 1,
updatedAt: 1
}
store.setWorkspaceSession(
{
...rendererSession('worker'),
sleepingAgentSessionsByPaneKey: { [paneKey]: record },
legacyWorkerResumeFencesByPaneKey: { [paneKey]: true }
},
hostId
)
const retired = retireTerminalSurfaceFromPersistence(store.getWorkspaceSession(hostId), {
worktreeId: WT,
parentTabId: 'worker',
leafId: '11111111-1111-4111-8111-111111111111',
ptyId: 'worker-pty'
})
store.setWorkspaceSession(retired, hostId)
expect(
store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey?.[paneKey]
).toMatchObject(record)
expect(store.getWorkspaceSession(hostId).legacyWorkerResumeFencesByPaneKey).toEqual({})
store.stageWorkspaceSessionBeforeUnload(rendererSession('after-close'), hostId)
expect(
store.getWorkspaceSession(hostId).sleepingAgentSessionsByPaneKey?.[paneKey]
).toBeUndefined()
}
)
@@ -6,33 +6,28 @@ const RUNTIME_AUTHORED_FIELDS = [
'legacyWorkerResumeFencesByPaneKey'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
/**
* Keeps runtime-authored session state alive across a renderer's full write.
*
* A session write replaces the stored object, and the renderer builds its payload from Zustand --
* which has no idea the runtime authority sharing this profile also persists the client-hosted
* pages and settled-worker resume fences it owns. Without this, every ordinary desktop session
* write erases them, and the loss only shows up a restart later when there is nothing left to
* rehydrate.
*
* Callers do not opt in: the Store applies this inside setLocalWorkspaceSession and
* setHostWorkspaceSession, so the before-unload stage path inherits it too. Guarding the individual
* writers instead is what let the quit write -- the most common one there is -- erase the field.
*
* A runtime clearing its own rows writes an empty map, not `undefined`, so this can never pin a
* stale set: only an author that never mentioned the field inherits the previous one.
*/
// Renderer replacement writes cannot erase runtime policy or its retained identity.
export function preserveRuntimeAuthoredWorkspaceSessionFields(
next: WorkspaceSessionState,
prior: WorkspaceSessionState | null | undefined
): WorkspaceSessionState {
let preserved: WorkspaceSessionState | undefined
for (const field of RUNTIME_AUTHORED_FIELDS) {
if (next[field] !== undefined || prior?.[field] === undefined) {
continue
if (next[field] === undefined && prior?.[field] !== undefined) {
preserved ??= { ...next }
preserved[field] = prior[field] as never
}
}
const fences = (preserved ?? next).legacyWorkerResumeFencesByPaneKey
for (const paneKey of Object.keys(fences ?? {})) {
const record = prior?.sleepingAgentSessionsByPaneKey?.[paneKey]
if (record && !next.sleepingAgentSessionsByPaneKey?.[paneKey]) {
preserved ??= { ...next }
preserved.sleepingAgentSessionsByPaneKey = {
...preserved.sleepingAgentSessionsByPaneKey,
[paneKey]: record
}
}
preserved ??= { ...next }
preserved[field] = prior[field] as never
}
return preserved ?? next
}
@@ -43,22 +43,22 @@ function recordTerminalSurfaceRetirement(
surface: RetiredTerminalSurface,
paneKey: string
): WorkspaceSessionState {
const terminalPtyIncarnationsByPaneKey = {
...session.terminalPtyIncarnationsByPaneKey
const next = {
...session,
terminalPtyIncarnationsByPaneKey: session.terminalPtyIncarnationsByPaneKey ?? {},
terminalSurfaceTombstonesByPaneKey: session.terminalSurfaceTombstonesByPaneKey ?? {}
}
delete terminalPtyIncarnationsByPaneKey[paneKey]
const terminalSurfaceTombstonesByPaneKey = {
...session.terminalSurfaceTombstonesByPaneKey
for (const field of [
'terminalPtyIncarnationsByPaneKey',
'terminalSurfaceTombstonesByPaneKey',
'legacyWorkerResumeFencesByPaneKey'
] as const) {
if (next[field]) {
next[field] = { ...next[field] } as never
delete next[field]?.[paneKey]
}
}
delete terminalSurfaceTombstonesByPaneKey[paneKey]
return advanceTerminalTopologyRevision(
{
...session,
terminalPtyIncarnationsByPaneKey,
terminalSurfaceTombstonesByPaneKey
},
surface.worktreeId
)
return advanceTerminalTopologyRevision(next, surface.worktreeId)
}
export function retireTerminalSurfaceFromPersistence(
@@ -1,4 +1,7 @@
import { settleAutomaticResumeSpawn } from '@/lib/automatic-resume-spawn-settlement'
import {
clearAutomaticAgentResumeClaim,
settleAutomaticResumeSpawn
} from '@/lib/automatic-resume-spawn-settlement'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import type { PtyBufferSnapshot, PtyConnectResult } from '../pty-transport'
import { warnTerminalLifecycleAnomaly } from '../terminal-lifecycle-diagnostics'
@@ -40,6 +43,7 @@ type ReattachResultSession = ReattachPayloadSession &
| 'getSshMainModelSnapshotProbe'
| 'handleReattachResult'
| 'followsDirectSshReconnect'
| 'lastTerminalInputAt'
| 'mountFollowsTerminalPark'
| 'registerEffectiveLaunchConfig'
| 'registerPaneSerializerFor'
@@ -94,11 +98,12 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi
session.remotePtyIncarnationId = null
}
if (
(connectResult?.reattachUnverifiable || connectResult?.exitedBeforeAttach) &&
settleAutomaticResumeSpawn(session.deps.tabId, false)
) {
return false
if (connectResult?.reattachUnverifiable || connectResult?.exitedBeforeAttach) {
if (Number.isFinite(session.lastTerminalInputAt) || session.deps.preconnectInput) {
clearAutomaticAgentResumeClaim(session.deps.tabId)
} else if (settleAutomaticResumeSpawn(session.deps.tabId, false)) {
return false
}
}
if (connectResult?.exitedBeforeAttach) {
@@ -1,3 +1,6 @@
vi.mock('@/components/terminal-pane/terminal-pane-recovery', () => ({
requestTerminalPaneRecovery: vi.fn()
}))
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
@@ -69,5 +72,93 @@ describe('automatic resume host admission settlement with empty renderer fence h
expect(settleAutomaticResumeSpawn(tab.id, true)).toBe(true)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
expect(useAppStore.getState().tabsByWorktree[record.worktreeId]).toHaveLength(1)
expect(useAppStore.getState().automaticAgentResumeClaimsByTabId[tab.id]).toBeUndefined()
useAppStore.setState({ sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } })
expect(resumeSleepingAgentSessionsForWorktree(record.worktreeId)).toBe(0)
expect(resumeSleepingAgentSessionsForWorktree(record.worktreeId)).toBe(0)
})
it.each(['accepted', 'preconnect'])(
'a refusal after %s input preserves the new tab',
async (input) => {
const { tab, record } = queueResume()
const transport = { getPtyId: () => null }
const session = {
rejectObsoleteDirectSshReattach: () => false,
terminalRecoveryInstance: { id: 1 },
transport,
pane: { id: 1 },
lastTerminalInputAt: input === 'accepted' ? performance.now() : Number.NEGATIVE_INFINITY,
transportStreamGeneration: 1,
authoritativeReattachGeneration: 0,
deps: {
tabId: tab.id,
worktreeId: record.worktreeId,
preconnectInput: input === 'preconnect' ? 'user typed a new request' : undefined,
paneTransportsRef: { current: new Map([[1, transport]]) }
},
handleReattachResult: vi.fn()
}
bindHandleReattachResult(session as never)
await session.handleReattachResult({ id: '', reattachUnverifiable: true })
expect(useAppStore.getState().automaticAgentResumeClaimsByTabId[tab.id]).toBeUndefined()
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
expect(
useAppStore.getState().tabsByWorktree[record.worktreeId].some((t) => t.id === tab.id)
).toBe(true)
}
)
it('review: later refusal cannot close an already admitted live tab', async () => {
const { tab, record } = queueResume()
settleAutomaticResumeSpawn(tab.id, true)
useAppStore.getState().updateTabPtyId(tab.id, 'admitted-live-pty')
const transport = { getPtyId: () => 'admitted-live-pty' }
const session = {
rejectObsoleteDirectSshReattach: () => false,
terminalRecoveryInstance: { id: 1 },
transport,
pane: { id: 1 },
lastTerminalInputAt: Number.NEGATIVE_INFINITY,
transportStreamGeneration: 2,
authoritativeReattachGeneration: 0,
deps: {
tabId: tab.id,
worktreeId: record.worktreeId,
paneTransportsRef: { current: new Map([[1, transport]]) }
},
handleReattachResult: vi.fn()
}
bindHandleReattachResult(session as never)
await session.handleReattachResult({ id: 'admitted-live-pty', reattachUnverifiable: true })
expect(
useAppStore.getState().tabsByWorktree[record.worktreeId].some((t) => t.id === tab.id)
).toBe(true)
})
it('review: successful settlement preserves other identities and workspaces', () => {
const { tab, record } = queueResume()
const unrelated = {
...record,
paneKey: 'other:22222222-2222-4222-8222-222222222222',
tabId: 'other',
providerSession: { key: 'session_id' as const, id: 'different' }
}
const sibling = {
...record,
paneKey: 'sibling:33333333-3333-4333-8333-333333333333',
tabId: 'sibling',
worktreeId: 'other-folder'
}
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
[record.paneKey]: record,
[unrelated.paneKey]: unrelated,
[sibling.paneKey]: sibling
}
})
settleAutomaticResumeSpawn(tab.id, true)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[unrelated.paneKey]).toEqual(
unrelated
)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[sibling.paneKey]).toEqual(sibling)
})
})
@@ -1,6 +1,15 @@
import { omitRecordKeys } from '@/store/slices/worktrees/teardown/record-key-omission'
import { useAppStore } from '@/store'
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'
export function clearAutomaticAgentResumeClaim(tabId: string): void {
useAppStore.setState((state) => ({
automaticAgentResumeClaimsByTabId: omitRecordKeys(state.automaticAgentResumeClaimsByTabId, [
tabId
])
}))
}
export function settleAutomaticResumeSpawn(tabId: string, admitted: boolean): boolean {
const state = useAppStore.getState()
const claim = state.automaticAgentResumeClaimsByTabId?.[tabId]
@@ -17,6 +26,7 @@ export function settleAutomaticResumeSpawn(tabId: string, admitted: boolean): bo
})
return true
}
clearAutomaticAgentResumeClaim(tabId)
for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) {
if (
record.worktreeId === claim.worktreeId &&
@@ -1,3 +1,5 @@
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import { refreshLegacyWorkerResumeFences } from './legacy-worker-resume-fence-refresh'
@@ -58,3 +60,79 @@ describe('re-reading the fenced-pane set after main invalidates it', () => {
expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true })
})
})
it.each(
['full', 'scoped', 'two-scopes'].flatMap((scope) =>
[true, false].map((stale) => ({ scope, stale }))
)
)(
'review: stale acquired hint after %s hydration cannot block retirement',
async ({ scope, stale }) => {
const key = 'historical:11111111-1111-4111-8111-111111111111',
wt = 'folder:worker'
const record = {
paneKey: key,
tabId: 'historical',
worktreeId: wt,
agent: 'codex' as const,
providerSession: { key: 'session_id' as const, id: 'session-worker' },
state: 'working' as const,
prompt: 'continue',
capturedAt: 1,
updatedAt: 1,
origin: 'live' as const
}
let reply!: (v: Record<string, true>) => void
vi.stubGlobal('window', {
api: {
app: {
getLegacyWorkerResumeFences: () => new Promise<Record<string, true>>((r) => (reply = r))
}
}
})
const pending = refreshLegacyWorkerResumeFences()
useAppStore.getState().hydrateWorkspaceSession(
{
...getDefaultWorkspaceSession(),
sleepingAgentSessionsByPaneKey: { [key]: record },
legacyWorkerResumeFencesByPaneKey: {}
},
{
additionalValidWorkspaceKeys: [wt],
...(scope === 'full' ? {} : { replaceWorkspaceKeys: [wt] })
}
)
if (scope === 'two-scopes') {
useAppStore.getState().hydrateWorkspaceSession(
{ ...getDefaultWorkspaceSession(), legacyWorkerResumeFencesByPaneKey: {} },
{
additionalValidWorkspaceKeys: ['folder:sibling'],
replaceWorkspaceKeys: ['folder:sibling']
}
)
}
reply(stale ? { [key]: true } : {})
await pending
const count = resumeSleepingAgentSessionsForWorktree(wt)
expect(count).toBe(1)
}
)
it('drops an older refresh reply after a newer refresh retires the hint', async () => {
let reply!: (value: Record<string, true>) => void
const get = stubFences({})
get
.mockReset()
.mockResolvedValue({})
.mockImplementationOnce(
() =>
new Promise<Record<string, true>>((resolve) => {
reply = resolve
})
)
const pending = refreshLegacyWorkerResumeFences()
await refreshLegacyWorkerResumeFences()
reply({ [PANE_KEY]: true })
await pending
expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({})
})
@@ -1,8 +1,16 @@
import { useAppStore } from '@/store'
let generation = 0
export function markLegacyWorkerResumeFencesHydrated(): void {
generation++
}
export async function refreshLegacyWorkerResumeFences(): Promise<void> {
const requestGeneration = ++generation
try {
const fences = await window.api.app.getLegacyWorkerResumeFences()
const { useAppStore } = await import('@/store')
if (requestGeneration !== generation) {
return
}
useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: fences })
} catch (error) {
console.warn('[orchestration] failed to read legacy worker resume fences', error)
@@ -1,3 +1,4 @@
import { markLegacyWorkerResumeFencesHydrated } from '@/lib/legacy-worker-resume-fence-refresh'
import { readWorkspaceSessionResumeFences } from '../../../../shared/workspace-session-resume-fences'
import type { WorkspaceKey } from '../../../../shared/folder-workspace-types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
@@ -41,6 +42,7 @@ export function createWorkspaceTerminalHydrationActions(
: null
const ownershipTransfersByTabId = new Map<string, TerminalLayoutPtyOwnershipTransfer[]>()
set((s) => {
markLegacyWorkerResumeFencesHydrated()
const runtimeSessionPlaceholders = buildRuntimeSessionPlaceholders({
repos: s.repos,
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},