mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
fix: retain fence identity through final admission and retirement
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
+11
-6
@@ -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 ?? {},
|
||||
|
||||
Reference in New Issue
Block a user