mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Remove settled-worker automatic resume and hibernation fences
This commit is contained in:
@@ -34,7 +34,7 @@ src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector.
|
||||
src/main/runtime/orca-runtime-create-terminal.ts
|
||||
src/main/runtime/orca-runtime-deliver-pending-messages.ts
|
||||
src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts
|
||||
src/main/runtime/orca-runtime-fence-automation-owner.ts
|
||||
src/main/runtime/orca-runtime-automation-operations.ts
|
||||
src/main/runtime/orca-runtime-file-commands.ts
|
||||
src/main/runtime/orca-runtime-fit-override-listeners.ts
|
||||
src/main/runtime/orca-runtime-focus-terminal.ts
|
||||
|
||||
@@ -81,6 +81,49 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe
|
||||
}
|
||||
|
||||
describe('stable pane adoption after the relay reports the PTY absent', () => {
|
||||
it.each([false, true])(
|
||||
'reattaches a live pane without launching a provider process (settled worker: %s)',
|
||||
async (settledWorker) => {
|
||||
const { store, read } = sessionStore([LEAF])
|
||||
const paneKey = `${OWNER.tabId}:${LEAF}`
|
||||
const record = {
|
||||
paneKey,
|
||||
tabId: OWNER.tabId,
|
||||
worktreeId: WORKTREE,
|
||||
agent: 'claude' as const,
|
||||
providerSession: { key: 'session_id' as const, id: 'provider-session' },
|
||||
prompt: '',
|
||||
state: 'done' as const,
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
...(settledWorker ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } : {})
|
||||
}
|
||||
store.setWorkspaceSession({
|
||||
...read(),
|
||||
sleepingAgentSessionsByPaneKey: { [paneKey]: record }
|
||||
})
|
||||
const spawn = vi.fn().mockResolvedValue({ id: OWNER.ptyId, isReattach: true })
|
||||
const onFreshSpawn = vi.fn()
|
||||
const result = await spawnForStablePane({
|
||||
runtime: undefined,
|
||||
store,
|
||||
worktreeId: WORKTREE,
|
||||
provider: { spawn } as unknown as IPtyProvider,
|
||||
spawnOptions: { cols: 80, rows: 24, command: 'claude --resume provider-session' },
|
||||
owner: OWNER,
|
||||
connectionId: 'conn-1',
|
||||
resolveOwner: () => OWNER,
|
||||
onFreshSpawn
|
||||
})
|
||||
expect(result.owner).toBe(OWNER)
|
||||
expect(spawn).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.objectContaining({ sessionId: OWNER.ptyId, attachOnly: true, command: undefined })
|
||||
)
|
||||
expect(onFreshSpawn).not.toHaveBeenCalled()
|
||||
expect(read().tabsByWorktree[WORKTREE]).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('spawns fresh once the relay has positively answered for that id', async () => {
|
||||
const { run, spawn } = spawnAfterAttachRejection(
|
||||
new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`)
|
||||
|
||||
+1
-5
@@ -23,7 +23,7 @@ import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
|
||||
|
||||
export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForegroundProcessReads {
|
||||
export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForegroundProcessReads {
|
||||
protected fenceAutomationOwner(
|
||||
id: string,
|
||||
expectedOwner: AutomationOwnerPrecondition | undefined,
|
||||
@@ -167,10 +167,6 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg
|
||||
this.scheduleRestoredMessageRepoints()
|
||||
}
|
||||
|
||||
prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan {
|
||||
return this.legacyWorkerRecovery.prepare()
|
||||
}
|
||||
|
||||
protected async flushWorkspaceSessionOrThrowAsync(): Promise<void> {
|
||||
const store = this.store
|
||||
if (store?.flushPendingOrThrowAsync) {
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithFenceAutomationOwner } from './orca-runtime-fence-automation-owner'
|
||||
import { OrcaRuntimeWithAutomationOperations } from './orca-runtime-automation-operations'
|
||||
import {
|
||||
resolveTerminalSessionWorktreeId,
|
||||
runtimeWorktreeIdsEqual
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
ArtifactWriteRequest
|
||||
} from '../../shared/artifacts'
|
||||
|
||||
export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithFenceAutomationOwner {
|
||||
export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations {
|
||||
protected hasExactPersistedTerminalSurfaceIdentity(expected: {
|
||||
worktreeId: string
|
||||
tabId: string
|
||||
|
||||
@@ -136,8 +136,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
|
||||
new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => this.store,
|
||||
() => this.getOrchestrationDb(),
|
||||
(worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId),
|
||||
(paneKey, blocked) => this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked)
|
||||
(worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId)
|
||||
)
|
||||
|
||||
protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({
|
||||
|
||||
@@ -80,6 +80,15 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand
|
||||
...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}),
|
||||
...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {})
|
||||
})
|
||||
const hostScope = this.getOrchestrationCompatibilityHostScope(pty)
|
||||
if (paneKey && binding?.incarnationId && hostScope) {
|
||||
this._orchestrationDb?.retainReplacedWorkerTerminalResources({
|
||||
paneKey,
|
||||
worktreeId,
|
||||
hostScope: JSON.stringify(hostScope),
|
||||
processIncarnation: `${ptyId}:${binding.incarnationId}`
|
||||
})
|
||||
}
|
||||
const agentLaunchAuthority = binding?.agentLaunchAuthority
|
||||
if (
|
||||
agentLaunchAuthority &&
|
||||
|
||||
@@ -54,16 +54,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
// dispatch contexts immediately, rather than waiting for the coordinator's
|
||||
// next poll cycle. This catches agent crashes and unexpected exits within
|
||||
// milliseconds. The task is set back to 'pending' so it can be re-dispatched.
|
||||
/** A worker settled by its own process exit makes its pane fenceable now, not at the next app
|
||||
* start; a fence sweep must never fail the exit path behind it. */
|
||||
private sweepSettledWorkerResumeFencesAfterExit(): void {
|
||||
try {
|
||||
this.prepareLegacyWorkerTerminalRecovery()
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] settled worker resume fence sweep failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
protected failActiveDispatchOnExit(
|
||||
handle: string,
|
||||
paneKey: string | null,
|
||||
@@ -90,7 +80,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
const stopping = this._orchestrationDb.getWorkerDispatch?.(dispatch.id)
|
||||
if (stopping?.state === 'stopping' && stopping.runtime_epoch === this.getRuntimeId()) {
|
||||
this._orchestrationDb.settleWorkerStop(dispatch.id)
|
||||
this.sweepSettledWorkerResumeFencesAfterExit()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +88,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp
|
||||
workerProcessExited: true,
|
||||
terminationReason: cause.kind
|
||||
})
|
||||
this.sweepSettledWorkerResumeFencesAfterExit()
|
||||
if (isDeliberateTerminalExit(cause)) {
|
||||
return
|
||||
}
|
||||
|
||||
+2
-5
@@ -390,7 +390,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1)
|
||||
})
|
||||
|
||||
it('fences provider resume and reveals one exact live legacy worker without stealing focus', async () => {
|
||||
it('reveals one exact live legacy worker without stealing focus', async () => {
|
||||
const workerLeafId = HEADLESS_LEAF_ID
|
||||
const coordinatorLeafId = HEADLESS_SECOND_LEAF_ID
|
||||
const workerPaneKey = `legacy-worker:${workerLeafId}`
|
||||
@@ -526,10 +526,7 @@ describe('OrcaRuntimeService', () => {
|
||||
resolveLegacyWorkerTerminalRecovery
|
||||
} as never)
|
||||
|
||||
runtime.prepareLegacyWorkerTerminalRecovery()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
|
||||
const recovered = await runtime.reconcileLegacyWorkerTerminals({
|
||||
materializeRenderer: true
|
||||
|
||||
+5
-7
@@ -17,7 +17,7 @@ import {
|
||||
} from '../orca-runtime-test-scenario-builders.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('retries renderer reveal before clearing an adopted legacy worker resume fence', async () => {
|
||||
it('retries renderer reveal before clearing an adopted legacy worker sleeping record', async () => {
|
||||
const workerPaneKey = `legacy-worker:${HEADLESS_LEAF_ID}`
|
||||
const incarnationId = '44444444-4444-4444-8444-444444444444'
|
||||
const session: WorkspaceSessionState = {
|
||||
@@ -106,7 +106,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith(workerPaneKey, 'adopted')
|
||||
})
|
||||
|
||||
it('keeps a revealed worker fenced until its exact renderer graph is published', async () => {
|
||||
it('defers a revealed worker until its exact renderer graph is published', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const harness = makePostRevealWorkerRecoveryHarness(() => true)
|
||||
@@ -288,7 +288,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps recovery fenced when the renderer omits the exact reveal identity', async () => {
|
||||
it('defers recovery when the renderer omits the exact reveal identity', async () => {
|
||||
const harness = makePostRevealWorkerRecoveryHarness(() => false)
|
||||
harness.revealTerminalSession.mockResolvedValue({ tabId: 'legacy-post-reveal' })
|
||||
|
||||
@@ -417,7 +417,7 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the legacy worker resume fence in memory when persistence fails', async () => {
|
||||
it('keeps the legacy worker sleeping record in memory when persistence fails', async () => {
|
||||
const workerPaneKey = `legacy-worker:${HEADLESS_LEAF_ID}`
|
||||
const incarnationId = '99999999-9999-4999-8999-999999999999'
|
||||
const session: WorkspaceSessionState = {
|
||||
@@ -526,9 +526,7 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2)
|
||||
expect(revealTerminalSession).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe(
|
||||
'concurrent-tab'
|
||||
)
|
||||
|
||||
+2
-8
@@ -328,10 +328,7 @@ describe('OrcaRuntimeService', () => {
|
||||
resolveLegacyWorkerTerminalRecovery
|
||||
} as never)
|
||||
|
||||
runtime.prepareLegacyWorkerTerminalRecovery()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
|
||||
await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({
|
||||
adoptedDispatchIds: ['dispatch-exited-two'],
|
||||
@@ -445,9 +442,7 @@ describe('OrcaRuntimeService', () => {
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: ['dispatch-inventory-unavailable']
|
||||
})
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled()
|
||||
expect(listProcesses).toHaveBeenCalledOnce()
|
||||
expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([])
|
||||
@@ -477,7 +472,6 @@ describe('OrcaRuntimeService', () => {
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({
|
||||
blockedPaneCount: 1,
|
||||
adoptedDispatchIds: [],
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: []
|
||||
|
||||
+4
-81
@@ -18,13 +18,12 @@ import {
|
||||
TEST_WORKTREE_PATH,
|
||||
makeFolderProjectGroup,
|
||||
makeFolderWorkspace,
|
||||
makeRuntimeStoreWithWorkspaceSession,
|
||||
store
|
||||
makeRuntimeStoreWithWorkspaceSession
|
||||
} from '../orca-runtime-test-fixtures.spec'
|
||||
import { publishLegacyWorkerReveal } from '../orca-runtime-test-scenario-builders.spec'
|
||||
|
||||
describe('OrcaRuntimeService', () => {
|
||||
it('keeps live workers fenced without exact controller identity evidence', async () => {
|
||||
it('defers live workers without exact controller identity evidence', async () => {
|
||||
const incarnationId = '56565656-5656-4656-8656-565656565656'
|
||||
const cases = [
|
||||
{
|
||||
@@ -152,8 +151,7 @@ describe('OrcaRuntimeService', () => {
|
||||
for (const { name, leafId } of cases.slice(0, 2)) {
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[`legacy-${name}:${leafId}`]
|
||||
?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
).toBeDefined()
|
||||
}
|
||||
for (const { name, leafId } of cases.slice(2)) {
|
||||
expect(
|
||||
@@ -374,12 +372,7 @@ describe('OrcaRuntimeService', () => {
|
||||
} as never)
|
||||
|
||||
try {
|
||||
expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({
|
||||
blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey })]
|
||||
})
|
||||
expect(
|
||||
sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
await expect(
|
||||
runtime.reconcileLegacyWorkerTerminals({
|
||||
@@ -422,74 +415,4 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('fences an unresolved folder legacy worker in its exact retained session partition', () => {
|
||||
const connectionId = 'ssh-unresolved-folder'
|
||||
const worktreeId = 'folder:missing-folder'
|
||||
const workerPaneKey = `legacy-unresolved-folder-worker:${HEADLESS_LEAF_ID}`
|
||||
const remoteInitialSession: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: { [worktreeId]: [] },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[workerPaneKey]: {
|
||||
paneKey: workerPaneKey,
|
||||
tabId: 'legacy-unresolved-folder-worker',
|
||||
worktreeId,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'legacy-unresolved-folder-session' },
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live',
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
}
|
||||
const localSession = getDefaultWorkspaceSession()
|
||||
let remoteSession = remoteInitialSession
|
||||
const getWorkspaceSession = vi.fn((hostId?: string | null) =>
|
||||
hostId === `ssh:${connectionId}` ? remoteSession : localSession
|
||||
)
|
||||
const setWorkspaceSession = vi.fn((next: WorkspaceSessionState, hostId?: string | null) => {
|
||||
if (hostId !== `ssh:${connectionId}`) {
|
||||
throw new Error(`unexpected workspace-session host ${hostId ?? 'default'}`)
|
||||
}
|
||||
remoteSession = next
|
||||
})
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getFolderWorkspaces: () => [],
|
||||
getWorkspaceSession,
|
||||
getWorkspaceSessionHostIds: () => ['local', `ssh:${connectionId}`],
|
||||
setWorkspaceSession,
|
||||
flushOrThrow: vi.fn()
|
||||
} as never)
|
||||
runtime.setOrchestrationDb({
|
||||
listLegacyWorkerTerminalRecoveryRows: () => [
|
||||
{
|
||||
dispatch_id: 'dispatch-unresolved-folder',
|
||||
task_id: 'task-unresolved-folder',
|
||||
dispatch_status: 'completed',
|
||||
contract_version: 0,
|
||||
assignee_handle: 'term_unresolved_folder',
|
||||
assignee_pane_key: workerPaneKey,
|
||||
process_incarnation: 'pty-unresolved-folder:68686868-6868-4868-8868-686868686868',
|
||||
worker_state: 'ready',
|
||||
worktree_id: worktreeId,
|
||||
agent_terminal_handle: 'term_unresolved_folder'
|
||||
}
|
||||
]
|
||||
} as unknown as OrchestrationDb)
|
||||
|
||||
expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({
|
||||
blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey, worktreeId })]
|
||||
})
|
||||
expect(
|
||||
remoteSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(setWorkspaceSession).toHaveBeenCalledOnce()
|
||||
expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`)
|
||||
})
|
||||
})
|
||||
|
||||
+3
-7
@@ -153,11 +153,8 @@ describe('OrcaRuntimeService', () => {
|
||||
deferredDispatchIds: ['dispatch-ssh']
|
||||
})
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`)
|
||||
|
||||
await expect(
|
||||
runtime.reconcileLegacyWorkerTerminals({
|
||||
@@ -175,6 +172,7 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined()
|
||||
expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`)
|
||||
expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`)
|
||||
expect(listProcesses).toHaveBeenCalledTimes(3)
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, {
|
||||
@@ -297,9 +295,7 @@ describe('OrcaRuntimeService', () => {
|
||||
exitedDispatchIds: [],
|
||||
deferredDispatchIds: ['dispatch-wsl']
|
||||
})
|
||||
expect(
|
||||
getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined()
|
||||
expect(revealTerminalSession).not.toHaveBeenCalled()
|
||||
|
||||
observedDistro = 'Ubuntu'
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DISPATCH_CIRCUIT_BREAK_FAILURES } from '../dispatch-context/dispatch-ci
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation'
|
||||
import { transitionLifecycleWithDb } from '../lifecycle-transition'
|
||||
import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership'
|
||||
|
||||
export function listLegacyWorkerTerminalRecoveryRows(
|
||||
this: OrchestrationDb
|
||||
@@ -23,18 +22,9 @@ export function listLegacyWorkerTerminalRecoveryRows(
|
||||
FROM dispatch_contexts dc
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = dc.id
|
||||
WHERE wd.state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown')
|
||||
-- A settled worker whose terminal orchestration still owns keeps a resumable agent
|
||||
-- session; it needs the resume fence until release or retain retires the pane.
|
||||
OR (wd.state IN (${WORKER_SETTLED_STATES.map(() => '?').join(', ')})
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM worker_terminal_resources wtr
|
||||
WHERE wtr.owner_dispatch_id = dc.id
|
||||
AND wtr.ownership_state = 'owned'
|
||||
AND wtr.release_state NOT IN ('released', 'retained')
|
||||
))
|
||||
ORDER BY dc.rowid`
|
||||
)
|
||||
.all(...WORKER_SETTLED_STATES) as LegacyWorkerTerminalRecoveryRow[]
|
||||
.all() as LegacyWorkerTerminalRecoveryRow[]
|
||||
}
|
||||
|
||||
export function reconcileMissingWorkerTerminal(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
WorkerTerminalResourceRow,
|
||||
WorkerTerminalOwnershipState
|
||||
} from '../../worker-terminal-ownership'
|
||||
import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
@@ -199,9 +200,40 @@ export function transferWorkerTerminalResourceStatement(
|
||||
return this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow
|
||||
}
|
||||
|
||||
// A new process in the same pane is ordinary user work, not the settled Dispatch's resource.
|
||||
export function retainReplacedWorkerTerminalResources(
|
||||
this: OrchestrationDb,
|
||||
params: { paneKey: string; worktreeId: string; hostScope: string; processIncarnation: string }
|
||||
): number {
|
||||
return Number(
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_terminal_resources
|
||||
SET release_state = 'retained', retained_reason = 'identity_unproven',
|
||||
updated_at = datetime('now')
|
||||
WHERE pane_key = ? AND worktree_id = ? AND host_scope = ?
|
||||
AND process_incarnation IS NOT NULL AND process_incarnation != ?
|
||||
AND ownership_state = 'owned' AND release_state = 'not_requested'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM worker_dispatches w
|
||||
WHERE w.dispatch_id = worker_terminal_resources.owner_dispatch_id
|
||||
AND w.state IN (${WORKER_SETTLED_STATES.map(() => '?').join(', ')})
|
||||
)`
|
||||
)
|
||||
.run(
|
||||
params.paneKey,
|
||||
params.worktreeId,
|
||||
params.hostScope,
|
||||
params.processIncarnation,
|
||||
...WORKER_SETTLED_STATES
|
||||
).changes
|
||||
)
|
||||
}
|
||||
|
||||
// Finds an owned, settled, exact-match resource for an explicitly reused terminal.
|
||||
|
||||
export type WorkerTerminalResourceStoreMethods = {
|
||||
retainReplacedWorkerTerminalResources: typeof retainReplacedWorkerTerminalResources
|
||||
backfillWorkerTerminalResources: typeof backfillWorkerTerminalResources
|
||||
createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement
|
||||
getWorkerTerminalResource: typeof getWorkerTerminalResource
|
||||
@@ -214,6 +246,7 @@ export type WorkerTerminalResourceStoreMethods = {
|
||||
|
||||
export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
retainReplacedWorkerTerminalResources,
|
||||
backfillWorkerTerminalResources,
|
||||
createWorkerTerminalResourceStatement,
|
||||
getWorkerTerminalResource,
|
||||
|
||||
+1
-21
@@ -26,14 +26,6 @@ function recoveryRow(
|
||||
describe('legacy worker terminal recovery planning', () => {
|
||||
it('retains completed Dispatches when the worker process row is still live', () => {
|
||||
expect(planLegacyWorkerTerminalRecovery([recoveryRow()])).toEqual({
|
||||
blockedPanes: [
|
||||
{
|
||||
worktreeId: 'repo::/workspace',
|
||||
paneKey: `tab-worker:${LEAF_ID}`,
|
||||
contractVersion: 0,
|
||||
settled: false
|
||||
}
|
||||
],
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
dispatchId: 'dispatch-1',
|
||||
@@ -45,18 +37,10 @@ describe('legacy worker terminal recovery planning', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks resume but refuses recovery when durable handles disagree', () => {
|
||||
it('refuses recovery when durable handles disagree', () => {
|
||||
expect(
|
||||
planLegacyWorkerTerminalRecovery([recoveryRow({ agent_terminal_handle: 'term-replacement' })])
|
||||
).toEqual({
|
||||
blockedPanes: [
|
||||
{
|
||||
worktreeId: 'repo::/workspace',
|
||||
paneKey: `tab-worker:${LEAF_ID}`,
|
||||
contractVersion: 0,
|
||||
settled: false
|
||||
}
|
||||
],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
@@ -70,8 +54,6 @@ describe('legacy worker terminal recovery planning', () => {
|
||||
|
||||
expect(plan.candidates).toEqual([expect.objectContaining({ dispatchId: 'dispatch-live' })])
|
||||
expect(plan.ambiguousDispatchIds).toEqual([])
|
||||
// A live dispatch still holds this pane, so it must not be reported as a settled fence.
|
||||
expect(plan.blockedPanes).toEqual([expect.objectContaining({ settled: false })])
|
||||
})
|
||||
|
||||
it('fails closed when two Dispatches claim one terminal identity', () => {
|
||||
@@ -82,7 +64,6 @@ describe('legacy worker terminal recovery planning', () => {
|
||||
|
||||
expect(plan.candidates).toEqual([])
|
||||
expect(plan.ambiguousDispatchIds).toEqual(['dispatch-1', 'dispatch-2'])
|
||||
expect(plan.blockedPanes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not trust malformed pane or process identities', () => {
|
||||
@@ -94,7 +75,6 @@ describe('legacy worker terminal recovery planning', () => {
|
||||
])
|
||||
|
||||
expect(plan).toEqual({
|
||||
blockedPanes: [],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
|
||||
@@ -19,16 +19,7 @@ export type LegacyWorkerTerminalRecoveryCandidate = {
|
||||
incarnationId: PtyIncarnationId
|
||||
}
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryBlockedPane = {
|
||||
worktreeId: string
|
||||
paneKey: string
|
||||
contractVersion: number
|
||||
/** The dispatch reported an outcome; its pane needs the fence but owns no process to recover. */
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryPlan = {
|
||||
blockedPanes: LegacyWorkerTerminalRecoveryBlockedPane[]
|
||||
candidates: LegacyWorkerTerminalRecoveryCandidate[]
|
||||
ambiguousDispatchIds: string[]
|
||||
}
|
||||
@@ -65,26 +56,13 @@ function countCandidateKeys(
|
||||
export function planLegacyWorkerTerminalRecovery(
|
||||
rows: readonly LegacyWorkerTerminalRecoveryRow[]
|
||||
): LegacyWorkerTerminalRecoveryPlan {
|
||||
const blockedPanes = new Map<string, LegacyWorkerTerminalRecoveryBlockedPane>()
|
||||
const parsedCandidates: LegacyWorkerTerminalRecoveryCandidate[] = []
|
||||
for (const row of rows) {
|
||||
const worktreeId = row.worktree_id?.trim()
|
||||
const paneKey = row.assignee_pane_key?.trim()
|
||||
const pane = paneKey ? parsePaneKey(paneKey) : null
|
||||
const settled = WORKER_SETTLED_STATES.includes(row.worker_state)
|
||||
if (worktreeId && paneKey && pane) {
|
||||
const blockedKey = `${worktreeId}\0${paneKey}`
|
||||
const alreadySettled = blockedPanes.get(blockedKey)?.settled
|
||||
blockedPanes.set(blockedKey, {
|
||||
worktreeId,
|
||||
paneKey,
|
||||
contractVersion: row.contract_version,
|
||||
// A pane reused across dispatches is settled only once every dispatch holding it is.
|
||||
settled: (alreadySettled ?? true) && settled
|
||||
})
|
||||
}
|
||||
// A settled worker owns no live process to adopt or roll back, so its identity must never
|
||||
// compete with a running worker's in the ambiguity count below.
|
||||
// Settled dispatches need no adoption and must not make an active worker's identity ambiguous.
|
||||
if (settled) {
|
||||
continue
|
||||
}
|
||||
@@ -134,7 +112,6 @@ export function planLegacyWorkerTerminalRecovery(
|
||||
return !ambiguous
|
||||
})
|
||||
return {
|
||||
blockedPanes: [...blockedPanes.values()],
|
||||
candidates,
|
||||
ambiguousDispatchIds: [...ambiguousDispatchIds]
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { OrchestrationDb } from './db'
|
||||
import { planLegacyWorkerTerminalRecovery } from './orchestration-legacy-worker-terminal-recovery'
|
||||
import type { WorkerTerminalResourceRow } from './worker-terminal-ownership'
|
||||
|
||||
const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
|
||||
describe('settled worker terminal resume fence rows', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
function createReadyWorker(): { db: OrchestrationDb; taskId: string; dispatchId: string } {
|
||||
const d = new OrchestrationDb(':memory:')
|
||||
db = d
|
||||
const task = d.createTask({ spec: 'settled worker' })
|
||||
const started = d.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: task.id,
|
||||
startOptions: {}
|
||||
})
|
||||
d.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: 'term_worker',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'runtime:pty:1',
|
||||
worktreeId: 'repo::worktree',
|
||||
setupState: 'not_applicable',
|
||||
effects: [],
|
||||
terminalOwnership: 'created'
|
||||
})
|
||||
d.markWorkerDispatchReady(started.dispatch.id)
|
||||
return { db: d, taskId: task.id, dispatchId: started.dispatch.id }
|
||||
}
|
||||
|
||||
/** Asserts the `requested` arm so the resource row is non-null for the caller. */
|
||||
function requestRelease(d: OrchestrationDb, dispatchId: string): WorkerTerminalResourceRow {
|
||||
const requested = d.requestWorkerTerminalRelease(dispatchId)
|
||||
if (requested.disposition !== 'requested') {
|
||||
throw new Error(`expected a release request, got ${requested.disposition}`)
|
||||
}
|
||||
return requested.resource
|
||||
}
|
||||
|
||||
function settle(d: OrchestrationDb, taskId: string, dispatchId: string): void {
|
||||
expect(
|
||||
d.settleWorkerReport({
|
||||
taskId,
|
||||
dispatchId,
|
||||
outcome: 'succeeded',
|
||||
result: 'worker succeeded'
|
||||
}).action
|
||||
).toBe('settled')
|
||||
}
|
||||
|
||||
it('keeps a settled-but-unreleased worker terminal in the recovery rows', () => {
|
||||
const { db: d, taskId, dispatchId } = createReadyWorker()
|
||||
settle(d, taskId, dispatchId)
|
||||
|
||||
expect(d.getWorkerDispatch(dispatchId)?.state).toBe('succeeded')
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([
|
||||
expect.objectContaining({
|
||||
dispatch_id: dispatchId,
|
||||
worker_state: 'succeeded',
|
||||
assignee_pane_key: PANE_KEY
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
// A settled worker owns no live process, so it must only fence — never be offered for adoption.
|
||||
it('plans a settled pane as a fence with no adoption candidate', () => {
|
||||
const { db: d, taskId, dispatchId } = createReadyWorker()
|
||||
settle(d, taskId, dispatchId)
|
||||
|
||||
const plan = planLegacyWorkerTerminalRecovery(d.listLegacyWorkerTerminalRecoveryRows())
|
||||
|
||||
expect(plan.blockedPanes).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE_KEY, settled: true })
|
||||
])
|
||||
expect(plan.candidates).toEqual([])
|
||||
expect(plan.ambiguousDispatchIds).toEqual([])
|
||||
})
|
||||
|
||||
// `release_unknown` is the ticket's own repro: release could not be proven, the pane keeps a
|
||||
// resumable provider session, and dropping it here would re-open the auto-resume.
|
||||
it('keeps a settled worker terminal whose release could not be proven', () => {
|
||||
const { db: d, taskId, dispatchId } = createReadyWorker()
|
||||
settle(d, taskId, dispatchId)
|
||||
const resource = requestRelease(d, dispatchId)
|
||||
expect(
|
||||
d.markWorkerTerminalReleaseUnknown(resource.id, 'terminal no longer resolves').release_state
|
||||
).toBe('unknown')
|
||||
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([
|
||||
expect.objectContaining({ dispatch_id: dispatchId, assignee_pane_key: PANE_KEY })
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a settled worker terminal once its resource is released', () => {
|
||||
const { db: d, taskId, dispatchId } = createReadyWorker()
|
||||
settle(d, taskId, dispatchId)
|
||||
const resource = requestRelease(d, dispatchId)
|
||||
expect(d.settleWorkerTerminalRelease(resource.id).release_state).toBe('released')
|
||||
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a settled worker terminal the user chose to retain', () => {
|
||||
const { db: d, taskId, dispatchId } = createReadyWorker()
|
||||
d.retainWorkerTerminalResource(dispatchId)
|
||||
settle(d, taskId, dispatchId)
|
||||
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops a settled worker terminal the user took over', () => {
|
||||
const { db: d, taskId, dispatchId } = createReadyWorker()
|
||||
settle(d, taskId, dispatchId)
|
||||
expect(d.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1)
|
||||
|
||||
expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { RpcMethod } from '../core'
|
||||
import { sweepingSettledWorkerResumeFences } from './settled-worker-resume-fence-sweep'
|
||||
import { ORCHESTRATION_RUN_METHODS } from './orchestration/runs/runs'
|
||||
import { ORCHESTRATION_WORKER_METHODS } from './orchestration/worker/worker-methods'
|
||||
import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration/federation/federation-methods'
|
||||
@@ -24,4 +23,4 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
...ORCHESTRATION_ASK_METHODS,
|
||||
...ORCHESTRATION_GATE_METHODS,
|
||||
...ORCHESTRATION_RESET_METHODS
|
||||
].map(sweepingSettledWorkerResumeFences)
|
||||
]
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { SendParams } from '../schemas'
|
||||
import { legacyWorkerDeliveryContract } from '../routing'
|
||||
import { exposeMessage } from './mailbox-message-receipt'
|
||||
import { recordReceiptForPostCommitNudge } from './mutation-replay-nudge'
|
||||
import { sweepSettledWorkerResumeFences } from '../../settled-worker-resume-fence-sweep'
|
||||
import type { SendRecipientWarning } from './recipient-routing'
|
||||
import type { z } from 'zod'
|
||||
|
||||
@@ -150,11 +149,6 @@ export function sendPointToPointMessage(args: {
|
||||
? db.commitWorkerDoneMessageMutation(commitMessage)
|
||||
: commitMessage()
|
||||
committed.nudge()
|
||||
if (messageType === 'worker_done') {
|
||||
// Settlement is what makes the pane fenceable; without this the fence only appeared at the
|
||||
// next app start and reopening the pane in the same session respawned the agent.
|
||||
sweepSettledWorkerResumeFences(runtime)
|
||||
}
|
||||
return committed.receipt
|
||||
}
|
||||
|
||||
|
||||
@@ -322,18 +322,36 @@ describe('orchestration worker release', () => {
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned')
|
||||
})
|
||||
|
||||
it('retains when the exact process identity changed instead of closing', async () => {
|
||||
it('keeps a resumed settled worker retained in worker-list without re-dispatch or release', async () => {
|
||||
h.setup()
|
||||
const { dispatchId } = await h.startSettledWorker()
|
||||
const { dispatchId, taskId } = await h.startSettledWorker()
|
||||
const dispatch = h.db.getDispatchContextById(dispatchId)
|
||||
const task = h.db.getTask(taskId)
|
||||
vi.mocked(h.runtime.createTerminal).mockClear()
|
||||
vi.mocked(h.runtime.sendTerminalAgentPrompt).mockClear()
|
||||
vi.mocked(h.runtime.getTerminalProcessIncarnation).mockImplementation((handle) =>
|
||||
handle === 'term_worker' ? 'runtime_test:term_worker:2' : null
|
||||
)
|
||||
const receipt = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as {
|
||||
state: string
|
||||
reason?: string
|
||||
|
||||
await expect(
|
||||
h.call('orchestration.workerRelease', { dispatch: dispatchId })
|
||||
).resolves.toMatchObject({
|
||||
state: 'retained',
|
||||
reason: 'identity_unproven',
|
||||
processAction: 'none'
|
||||
})
|
||||
const listed = (await h.call('orchestration.workerList', { run: h.activeRunId })) as {
|
||||
workers: { dispatchId: string; terminalState: string; workerState: string }[]
|
||||
}
|
||||
expect(receipt).toMatchObject({ state: 'retained', reason: 'identity_unproven' })
|
||||
expect(listed.workers).toEqual([
|
||||
expect.objectContaining({ dispatchId, terminalState: 'retained', workerState: 'succeeded' })
|
||||
])
|
||||
expect(h.db.getTask(taskId)).toEqual(task)
|
||||
expect(h.db.getDispatchContextById(dispatchId)).toEqual(dispatch)
|
||||
expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained')
|
||||
expect(h.runtime.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(h.runtime.createTerminal).not.toHaveBeenCalled()
|
||||
expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains when the terminal host scope changed instead of closing', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type WorkerReleaseReceipt
|
||||
} from './worker-release-completion'
|
||||
import { WorkerDispatchParams, WorkerRetainParams } from './worker-release-schemas'
|
||||
import { sweepSettledWorkerResumeFences } from '../../settled-worker-resume-fence-sweep'
|
||||
|
||||
export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
@@ -148,11 +147,6 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [
|
||||
const changed = paneKey
|
||||
? runtime.getOrchestrationDb().markWorkerTerminalUserOwned(paneKey)
|
||||
: 0
|
||||
if (changed > 0) {
|
||||
// Only a real takeover retires the resource; ordinary panes report here too and must not
|
||||
// pay for a plan read on every keystroke window.
|
||||
sweepSettledWorkerResumeFences(runtime)
|
||||
}
|
||||
return { changed }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { RpcMethod } from '../core'
|
||||
|
||||
/**
|
||||
* One pass both stamps the automatic-resume fence on every settled worker pane and lifts it from
|
||||
* every pane the recovery plan no longer claims. A fenced pane refuses a fresh spawn, so any path
|
||||
* that drops a worker's row from that plan — release, user retain, user takeover — has to run the
|
||||
* sweep in the same call, or the fence outlives its dispatch and the pane stays unspawnable until
|
||||
* the next app start. Failures are swallowed: a fence sweep must never fail the RPC behind it.
|
||||
*/
|
||||
export function sweepSettledWorkerResumeFences(runtime: OrcaRuntimeService): void {
|
||||
try {
|
||||
runtime.prepareLegacyWorkerTerminalRecovery()
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] settled worker resume fence sweep failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Settling a worker is what makes its pane fenceable, and release/retain/takeover are what make it
|
||||
* unfenceable again — so every one of those has to sweep in the same call. Without the settlement
|
||||
* half the fence only appeared at the next app start, and reopening the pane in the same session
|
||||
* respawned the agent. */
|
||||
const FENCE_SWEEPING_METHOD_NAMES = new Set([
|
||||
'orchestration.workerRelease',
|
||||
'orchestration.workerRetain',
|
||||
'orchestration.workerStop',
|
||||
'orchestration.workerAbandon',
|
||||
// Reusing a settled worker's pane for a new Dispatch drops the old row from the plan; without
|
||||
// this the stale fence stays on the pane it just relaunched into.
|
||||
'orchestration.workerStart'
|
||||
])
|
||||
|
||||
export function sweepingSettledWorkerResumeFences(method: RpcMethod): RpcMethod {
|
||||
if (!FENCE_SWEEPING_METHOD_NAMES.has(method.name)) {
|
||||
return method
|
||||
}
|
||||
return {
|
||||
...method,
|
||||
handler: async (params, ctx) => {
|
||||
const result = await method.handler(params, ctx)
|
||||
sweepSettledWorkerResumeFences(ctx.runtime)
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController {
|
||||
|
||||
constructor(private readonly ports: LegacyWorkerRecoveryPorts) {}
|
||||
|
||||
prepare(): LegacyWorkerTerminalRecoveryPlan {
|
||||
return this.ports.preparePlan()
|
||||
}
|
||||
|
||||
reconcile(
|
||||
options: LegacyWorkerRecoveryOptions = {}
|
||||
): Promise<LegacyWorkerTerminalRecoveryResult> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement'
|
||||
import type { OrchestrationDb } from './orchestration/db'
|
||||
@@ -18,144 +18,11 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence {
|
||||
constructor(
|
||||
private readonly getStore: () => RuntimeStore | null,
|
||||
private readonly getDb: () => OrchestrationDb,
|
||||
private readonly getHostId: (worktreeId: string) => ExecutionHostId | null,
|
||||
/** The store write only reaches the next app start; a live renderer holds its own copy. */
|
||||
private readonly notifyFenceChanged?: (paneKey: string, blocked: boolean) => void
|
||||
private readonly getHostId: (worktreeId: string) => ExecutionHostId | null
|
||||
) {}
|
||||
|
||||
/** Panes announced as fenced before any sleeping record existed; the only place a lift for one
|
||||
* can come from, because `liftRetiredFences` can only see panes that already have a record. */
|
||||
private readonly announcedBlockedPaneKeys = new Set<string>()
|
||||
|
||||
prepare(): LegacyWorkerTerminalRecoveryPlan {
|
||||
const plan = this.getPlan()
|
||||
if (!plan) {
|
||||
// An unreadable plan is not evidence that any pane stopped needing its fence: stamp
|
||||
// nothing, lift nothing, retry on the next pass.
|
||||
return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] }
|
||||
}
|
||||
const store = this.getStore()
|
||||
if (
|
||||
!store?.getWorkspaceSession ||
|
||||
!store.setWorkspaceSession ||
|
||||
(!store.flushPendingOrThrowAsync && !store.flushOrThrow)
|
||||
) {
|
||||
return plan
|
||||
}
|
||||
const sessions = new Map<
|
||||
ExecutionHostId,
|
||||
{ current: WorkspaceSessionState; next: WorkspaceSessionState }
|
||||
>()
|
||||
const changedHostIds = new Set<ExecutionHostId>()
|
||||
const fenceChanges: [string, boolean][] = []
|
||||
for (const blocked of plan.blockedPanes) {
|
||||
// A worker can settle while its tab is still open, so there is no sleeping record to stamp
|
||||
// yet. Tell the live renderer anyway: it mints the record on close and must fence it there.
|
||||
if (!this.announcedBlockedPaneKeys.has(blocked.paneKey)) {
|
||||
this.announcedBlockedPaneKeys.add(blocked.paneKey)
|
||||
fenceChanges.push([blocked.paneKey, true])
|
||||
}
|
||||
let hostIds: ExecutionHostId[]
|
||||
try {
|
||||
const hostId = this.getHostId(blocked.worktreeId)
|
||||
if (!hostId) {
|
||||
throw new Error('folder_workspace_not_found')
|
||||
}
|
||||
hostIds = [hostId]
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] legacy worker resume fence owner is unavailable', {
|
||||
worktreeId: blocked.worktreeId,
|
||||
error
|
||||
})
|
||||
hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID]
|
||||
}
|
||||
for (const hostId of hostIds) {
|
||||
let state = sessions.get(hostId)
|
||||
if (!state) {
|
||||
const current = store.getWorkspaceSession(hostId)
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
state = { current, next: structuredClone(current) }
|
||||
sessions.set(hostId, state)
|
||||
}
|
||||
const record = state.next.sleepingAgentSessionsByPaneKey?.[blocked.paneKey]
|
||||
if (
|
||||
!record ||
|
||||
!runtimeWorktreeIdsEqual(record.worktreeId, blocked.worktreeId) ||
|
||||
record.automaticResumeBlockedBy === 'legacy-orchestration-worker'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
state.next.sleepingAgentSessionsByPaneKey = {
|
||||
...state.next.sleepingAgentSessionsByPaneKey,
|
||||
[blocked.paneKey]: { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
}
|
||||
changedHostIds.add(hostId)
|
||||
}
|
||||
}
|
||||
this.liftRetiredFences(store, plan, sessions, changedHostIds, fenceChanges)
|
||||
const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId))
|
||||
try {
|
||||
for (const [hostId, state] of changed) {
|
||||
store.setWorkspaceSession(state.next, hostId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[orchestration] failed to stage legacy worker resume fence', error)
|
||||
return plan
|
||||
}
|
||||
for (const [paneKey, blocked] of fenceChanges) {
|
||||
this.notifyFenceChanged?.(paneKey, blocked)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
/** A fence that outlives its dispatch leaves a pane that can never spawn again, so release,
|
||||
* retain, user takeover and dispatch pruning — each of which drops the row from the plan —
|
||||
* retire it here. An unreadable plan yields no blocked panes, so callers must not sweep. */
|
||||
private liftRetiredFences(
|
||||
store: RuntimeStore,
|
||||
plan: LegacyWorkerTerminalRecoveryPlan,
|
||||
sessions: Map<ExecutionHostId, { current: WorkspaceSessionState; next: WorkspaceSessionState }>,
|
||||
changedHostIds: Set<ExecutionHostId>,
|
||||
fenceChanges: [string, boolean][]
|
||||
): void {
|
||||
const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey))
|
||||
for (const paneKey of this.announcedBlockedPaneKeys) {
|
||||
if (!blockedPaneKeys.has(paneKey)) {
|
||||
this.announcedBlockedPaneKeys.delete(paneKey)
|
||||
fenceChanges.push([paneKey, false])
|
||||
}
|
||||
}
|
||||
for (const hostId of store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID]) {
|
||||
const staged = sessions.get(hostId)
|
||||
const session = staged?.next ?? store.getWorkspaceSession?.(hostId)
|
||||
const retired = Object.entries(session?.sleepingAgentSessionsByPaneKey ?? {}).filter(
|
||||
([paneKey, record]) =>
|
||||
record.automaticResumeBlockedBy === 'legacy-orchestration-worker' &&
|
||||
!blockedPaneKeys.has(paneKey)
|
||||
)
|
||||
if (retired.length === 0) {
|
||||
continue
|
||||
}
|
||||
let state = staged
|
||||
if (!state) {
|
||||
const current = store.getWorkspaceSession?.(hostId)
|
||||
if (!current) {
|
||||
continue
|
||||
}
|
||||
state = { current, next: structuredClone(current) }
|
||||
sessions.set(hostId, state)
|
||||
}
|
||||
const next = { ...state.next.sleepingAgentSessionsByPaneKey }
|
||||
for (const [paneKey, record] of retired) {
|
||||
const { automaticResumeBlockedBy: _retired, ...unfenced } = record
|
||||
next[paneKey] = unfenced
|
||||
fenceChanges.push([paneKey, false])
|
||||
}
|
||||
state.next.sleepingAgentSessionsByPaneKey = next
|
||||
changedHostIds.add(hostId)
|
||||
}
|
||||
return this.getPlan() ?? { candidates: [], ambiguousDispatchIds: [] }
|
||||
}
|
||||
|
||||
async persist(
|
||||
|
||||
@@ -104,7 +104,6 @@ export async function runLegacyWorkerTerminalRecovery(
|
||||
exitedDispatchIds.push(candidate.dispatchId)
|
||||
}
|
||||
const result = {
|
||||
blockedPaneCount: plan.blockedPanes.length,
|
||||
adoptedDispatchIds,
|
||||
exitedDispatchIds,
|
||||
deferredDispatchIds: [...deferredDispatchIds]
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PtyControllerInventory } from './runtime-pty-controller-contract'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
|
||||
export type LegacyWorkerTerminalRecoveryResult = {
|
||||
blockedPaneCount: number
|
||||
adoptedDispatchIds: string[]
|
||||
exitedDispatchIds: string[]
|
||||
deferredDispatchIds: string[]
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../shared/constants'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration'
|
||||
import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
|
||||
const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333'
|
||||
const WORKTREE_ID = 'repo::worktree'
|
||||
|
||||
function sessionWithSleepingWorker(): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[PANE_KEY]: {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab_worker',
|
||||
worktreeId: WORKTREE_ID,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: '',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live'
|
||||
}
|
||||
}
|
||||
} as WorkspaceSessionState
|
||||
}
|
||||
|
||||
describe('settled worker automatic-resume fence persistence', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
function harness(
|
||||
onFenceChanged?: (paneKey: string, blocked: boolean) => void,
|
||||
/** False models a worker that settles while its tab is still open: no record to stamp yet. */
|
||||
withSleepingRecord = true
|
||||
): {
|
||||
db: OrchestrationDb
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
persistence: RuntimeLegacyWorkerTerminalRecoveryPersistence
|
||||
fence: () => string | undefined
|
||||
} {
|
||||
const orchestrationDb = new OrchestrationDb(':memory:')
|
||||
db = orchestrationDb
|
||||
let session = withSleepingRecord
|
||||
? sessionWithSleepingWorker()
|
||||
: (getDefaultWorkspaceSession() as WorkspaceSessionState)
|
||||
const store = {
|
||||
getWorkspaceSession: () => session,
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID],
|
||||
flushOrThrow: vi.fn()
|
||||
} as unknown as RuntimeStore
|
||||
const task = orchestrationDb.createTask({ spec: 'fence me' })
|
||||
const started = orchestrationDb.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: task.id,
|
||||
startOptions: {}
|
||||
})
|
||||
orchestrationDb.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: 'term_worker',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'runtime:pty:1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
setupState: 'not_applicable',
|
||||
effects: [],
|
||||
terminalOwnership: 'created'
|
||||
})
|
||||
orchestrationDb.markWorkerDispatchReady(started.dispatch.id)
|
||||
return {
|
||||
db: orchestrationDb,
|
||||
taskId: task.id,
|
||||
dispatchId: started.dispatch.id,
|
||||
persistence: new RuntimeLegacyWorkerTerminalRecoveryPersistence(
|
||||
() => store,
|
||||
() => orchestrationDb,
|
||||
() => LOCAL_EXECUTION_HOST_ID,
|
||||
onFenceChanged
|
||||
),
|
||||
fence: () => session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy
|
||||
}
|
||||
}
|
||||
|
||||
function settle(d: OrchestrationDb, taskId: string, dispatchId: string): void {
|
||||
expect(
|
||||
d.settleWorkerReport({ taskId, dispatchId, outcome: 'succeeded', result: 'done' }).action
|
||||
).toBe('settled')
|
||||
}
|
||||
|
||||
it('pushes the fence to the live renderer instead of waiting for the next app start', () => {
|
||||
const fenceChanges: [string, boolean][] = []
|
||||
const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]))
|
||||
settle(h.db, h.taskId, h.dispatchId)
|
||||
|
||||
h.persistence.prepare()
|
||||
|
||||
expect(fenceChanges).toEqual([[PANE_KEY, true]])
|
||||
})
|
||||
|
||||
it('announces the fence for a pane that has no sleeping record to stamp yet', () => {
|
||||
const fenceChanges: [string, boolean][] = []
|
||||
const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false)
|
||||
settle(h.db, h.taskId, h.dispatchId)
|
||||
|
||||
h.persistence.prepare()
|
||||
expect(fenceChanges).toEqual([[PANE_KEY, true]])
|
||||
|
||||
const requested = h.db.requestWorkerTerminalRelease(h.dispatchId)
|
||||
h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id)
|
||||
h.persistence.prepare()
|
||||
|
||||
// A fence the plan no longer claims must be lifted even with no record to read it from.
|
||||
expect(fenceChanges).toEqual([
|
||||
[PANE_KEY, true],
|
||||
[PANE_KEY, false]
|
||||
])
|
||||
})
|
||||
|
||||
// The STA-4577 repro: worker_done, no release, restart, open the worktree — the pane still
|
||||
// holds a resumable provider session and must not respawn `codex resume`.
|
||||
it('fences a settled worker pane whose terminal was never released', () => {
|
||||
const h = harness()
|
||||
settle(h.db, h.taskId, h.dispatchId)
|
||||
|
||||
h.persistence.prepare()
|
||||
|
||||
expect(h.fence()).toBe('legacy-orchestration-worker')
|
||||
})
|
||||
|
||||
it('lifts the fence once release retires the terminal resource', () => {
|
||||
const h = harness()
|
||||
settle(h.db, h.taskId, h.dispatchId)
|
||||
h.persistence.prepare()
|
||||
expect(h.fence()).toBe('legacy-orchestration-worker')
|
||||
|
||||
const requested = h.db.requestWorkerTerminalRelease(h.dispatchId)
|
||||
expect(requested.disposition).toBe('requested')
|
||||
h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id)
|
||||
h.persistence.prepare()
|
||||
|
||||
expect(h.fence()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lifts the fence when the user takes the pane over', () => {
|
||||
const h = harness()
|
||||
settle(h.db, h.taskId, h.dispatchId)
|
||||
h.persistence.prepare()
|
||||
expect(h.fence()).toBe('legacy-orchestration-worker')
|
||||
|
||||
expect(h.db.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1)
|
||||
h.persistence.prepare()
|
||||
|
||||
expect(h.fence()).toBeUndefined()
|
||||
})
|
||||
|
||||
// An unreadable plan is not evidence a pane stopped needing its fence.
|
||||
it('keeps the fence when the recovery plan cannot be read', () => {
|
||||
const h = harness()
|
||||
settle(h.db, h.taskId, h.dispatchId)
|
||||
h.persistence.prepare()
|
||||
expect(h.fence()).toBe('legacy-orchestration-worker')
|
||||
|
||||
vi.spyOn(h.db, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => {
|
||||
throw new Error('orchestration_db_unavailable')
|
||||
})
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
expect(h.persistence.prepare()).toEqual({
|
||||
blockedPanes: [],
|
||||
candidates: [],
|
||||
ambiguousDispatchIds: []
|
||||
})
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
|
||||
expect(h.fence()).toBe('legacy-orchestration-worker')
|
||||
})
|
||||
|
||||
// A live worker's pane was already fenced while main reconciles it against PTY inventory; the
|
||||
// settled arm must not disturb that, and the plan must still name it as unsettled.
|
||||
it('keeps a live worker pane fenced and marked unsettled', () => {
|
||||
const h = harness()
|
||||
|
||||
const plan = h.persistence.prepare()
|
||||
|
||||
expect(h.fence()).toBe('legacy-orchestration-worker')
|
||||
expect(plan.blockedPanes).toEqual([
|
||||
expect.objectContaining({ paneKey: PANE_KEY, settled: false })
|
||||
])
|
||||
expect(plan.candidates).toEqual([expect.objectContaining({ dispatchId: h.dispatchId })])
|
||||
})
|
||||
})
|
||||
|
||||
// STA-4577's other half: settlement with no release and no restart. The stamp only ran at startup
|
||||
// and after release/retain/takeover, so reopening the pane in the same session respawned the agent.
|
||||
describe('worker_done without a release', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
afterEach(() => db?.close())
|
||||
|
||||
it('fences the pane in the same session', async () => {
|
||||
const orchestrationDb = new OrchestrationDb(':memory:')
|
||||
db = orchestrationDb
|
||||
let session = sessionWithSleepingWorker()
|
||||
const store = {
|
||||
getWorkspaceSession: () => session,
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
},
|
||||
getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID],
|
||||
flushOrThrow: vi.fn()
|
||||
} as unknown as RuntimeStore
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setOrchestrationDb(orchestrationDb)
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
|
||||
handle === 'term_worker' ? PANE_KEY : 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
)
|
||||
vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1')
|
||||
vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {})
|
||||
|
||||
const run = orchestrationDb.createRun({
|
||||
objective: 'settle without release',
|
||||
coordinatorHandle: 'term_coord',
|
||||
coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
})
|
||||
const task = orchestrationDb.createTask({ spec: 'settle without release', runId: run.id })
|
||||
const started = orchestrationDb.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskId: task.id,
|
||||
startOptions: {}
|
||||
})
|
||||
orchestrationDb.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: 'term_worker',
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'runtime:pty:1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
setupState: 'not_applicable',
|
||||
effects: [],
|
||||
terminalOwnership: 'created'
|
||||
})
|
||||
orchestrationDb.markWorkerDispatchReady(started.dispatch.id)
|
||||
const capability = orchestrationDb.mintDispatchCapability({
|
||||
dispatchId: started.dispatch.id,
|
||||
paneKey: PANE_KEY,
|
||||
processIncarnation: 'runtime:pty:1'
|
||||
})
|
||||
expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe(
|
||||
undefined
|
||||
)
|
||||
|
||||
const send = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.send')!
|
||||
await send.handler(
|
||||
send.params!.parse({
|
||||
from: 'term_worker',
|
||||
to: 'term_coord',
|
||||
subject: 'Done',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({
|
||||
taskId: task.id,
|
||||
dispatchId: started.dispatch.id,
|
||||
outcome: 'succeeded'
|
||||
})
|
||||
}),
|
||||
{ runtime, orchestrationCapability: capability }
|
||||
)
|
||||
|
||||
expect(orchestrationDb.getWorkerDispatch(started.dispatch.id)?.state).toBe('succeeded')
|
||||
expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe(
|
||||
'legacy-orchestration-worker'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -80,7 +80,6 @@ export type RuntimeNotifier = {
|
||||
ptyId?: string
|
||||
): void
|
||||
/** The fence lives in the workspace session, which a live renderer only re-reads at startup. */
|
||||
setLegacyWorkerTerminalResumeFence?(paneKey: string, blocked: boolean): void
|
||||
splitTerminal(
|
||||
tabId: string,
|
||||
paneRuntimeId: number,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrchestrationDb } from './orchestration/db'
|
||||
|
||||
const TAB = 'worker-tab'
|
||||
const LEAF = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE = `${TAB}:${LEAF}`
|
||||
const WORKSPACE = '/folder-workspace'
|
||||
const LOCAL_HOST = JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
const SSH_HOST = JSON.stringify({ kind: 'ssh', targetId: 'remote-host' })
|
||||
let db: OrchestrationDb
|
||||
let runtime: OrcaRuntimeService
|
||||
|
||||
afterEach(() => {
|
||||
db?.close()
|
||||
})
|
||||
|
||||
function seedWorker(hostScope: string, settled = true) {
|
||||
db = new OrchestrationDb(':memory:')
|
||||
runtime = new OrcaRuntimeService(null)
|
||||
runtime.setOrchestrationDb(db)
|
||||
const started = db.createStartingWorkerDispatch({
|
||||
creator: { kind: 'system' },
|
||||
maxDepth: Number.MAX_SAFE_INTEGER,
|
||||
taskSpec: 'Ordinary pane after worker completion',
|
||||
taskRunId: 'run_legacy_local',
|
||||
startOptions: {}
|
||||
})
|
||||
db.prepareStartingWorkerAuthority({
|
||||
dispatchId: started.dispatch.id,
|
||||
handle: 'term_original',
|
||||
paneKey: PANE,
|
||||
processIncarnation: 'pty-original:inc-original',
|
||||
hostScope,
|
||||
worktreeId: WORKSPACE,
|
||||
setupState: 'not_applicable',
|
||||
effects: [],
|
||||
terminalOwnership: 'created'
|
||||
})
|
||||
db.markWorkerDispatchReady(started.dispatch.id)
|
||||
if (settled) {
|
||||
db.settleWorkerReport({
|
||||
taskId: started.task.id,
|
||||
dispatchId: started.dispatch.id,
|
||||
outcome: 'succeeded',
|
||||
result: '{}'
|
||||
})
|
||||
}
|
||||
return {
|
||||
dispatchId: started.dispatch.id,
|
||||
task: db.getTask(started.task.id),
|
||||
dispatch: db.getDispatchContextById(started.dispatch.id)
|
||||
}
|
||||
}
|
||||
|
||||
function register(ptyId: string, incarnationId?: string, connectionId: string | null = null) {
|
||||
runtime.registerPty(ptyId, WORKSPACE, connectionId, {
|
||||
tabId: TAB,
|
||||
leafId: LEAF,
|
||||
...(incarnationId ? { incarnationId } : {})
|
||||
})
|
||||
}
|
||||
|
||||
describe('settled worker process replacement accounting', () => {
|
||||
it.each([null, 'remote-host'])(
|
||||
'retains the replaced resource on owning host %s',
|
||||
(connectionId) => {
|
||||
const worker = seedWorker(connectionId ? SSH_HOST : LOCAL_HOST)
|
||||
register('pty-resumed', 'inc-resumed', connectionId)
|
||||
register('pty-resumed', 'inc-resumed', connectionId)
|
||||
expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toMatchObject({
|
||||
release_state: 'retained',
|
||||
retained_reason: 'identity_unproven',
|
||||
process_incarnation: 'pty-original:inc-original'
|
||||
})
|
||||
expect(db.getTask(worker.task!.id)).toEqual(worker.task)
|
||||
expect(db.getDispatchContextById(worker.dispatchId)).toEqual(worker.dispatch)
|
||||
expect(db.listWorkerTerminalResources({})).toEqual([
|
||||
expect.objectContaining({ dispatchId: worker.dispatchId, terminalState: 'retained' })
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps the original live resource unchanged across reattach', () => {
|
||||
const worker = seedWorker(LOCAL_HOST)
|
||||
const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId)
|
||||
register('pty-original', 'inc-original')
|
||||
expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original)
|
||||
})
|
||||
|
||||
it('does not use missing incarnation evidence as proof of replacement', () => {
|
||||
const worker = seedWorker(LOCAL_HOST)
|
||||
const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId)
|
||||
register('pty-unverifiable')
|
||||
expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original)
|
||||
})
|
||||
|
||||
it('does not change another execution host with the same pane and folder', () => {
|
||||
const worker = seedWorker(SSH_HOST)
|
||||
const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId)
|
||||
register('pty-resumed', 'inc-resumed')
|
||||
expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original)
|
||||
})
|
||||
|
||||
it('does not change an active Dispatch resource', () => {
|
||||
const worker = seedWorker(LOCAL_HOST, false)
|
||||
const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId)
|
||||
register('pty-resumed', 'inc-resumed')
|
||||
expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original)
|
||||
})
|
||||
})
|
||||
@@ -129,7 +129,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
|
||||
agentHookServer.subscribeEnrichedStatus((enriched) =>
|
||||
recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)
|
||||
)
|
||||
runtime.prepareLegacyWorkerTerminalRecovery()
|
||||
// Why before anything can attach: a client host that reattaches to a restarted runtime is only
|
||||
// handed its pages back if the runtime found them first.
|
||||
runtime.rehydrateClientHostedBrowserPages()
|
||||
|
||||
@@ -149,8 +149,6 @@ export function registerRuntimeWindowLifecycle(
|
||||
resolution,
|
||||
...(ptyId ? { ptyId } : {})
|
||||
}),
|
||||
setLegacyWorkerTerminalResumeFence: (paneKey, blocked) =>
|
||||
send('agentStatus:legacyWorkerTerminalResumeFence', { paneKey, blocked }),
|
||||
splitTerminal: (tabId, paneRuntimeId, opts) => {
|
||||
send('ui:splitTerminal', {
|
||||
tabId,
|
||||
|
||||
@@ -28,10 +28,6 @@ export type AgentStatusApi = {
|
||||
ptyId?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
/** Listen for the automatic-resume fence a settled worker's pane gains or loses mid-session. */
|
||||
onLegacyWorkerTerminalResumeFence: (
|
||||
callback: (data: { paneKey: string; blocked: boolean }) => void
|
||||
) => () => void
|
||||
getMigrationUnsupportedSnapshot: () => Promise<MigrationUnsupportedPtyEntry[]>
|
||||
/** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */
|
||||
drop: (paneKey: string) => void
|
||||
|
||||
@@ -61,16 +61,6 @@ export const agentStatusApi = {
|
||||
ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener)
|
||||
return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener)
|
||||
},
|
||||
onLegacyWorkerTerminalResumeFence: (
|
||||
callback: (data: { paneKey: string; blocked: boolean }) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
data: { paneKey: string; blocked: boolean }
|
||||
) => callback(data)
|
||||
ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFence', listener)
|
||||
return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFence', listener)
|
||||
},
|
||||
getMigrationUnsupportedSnapshot: (): Promise<MigrationUnsupportedPtyEntry[]> =>
|
||||
ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'),
|
||||
/** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */
|
||||
|
||||
+72
-169
@@ -1,7 +1,6 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
|
||||
import { flushAsyncTicks } from './pty-connection-test-async'
|
||||
import { UUID_RE } from './pty-connection-test-constants'
|
||||
import {
|
||||
@@ -406,177 +405,81 @@ describe('connectPanePty', () => {
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resume a live provider session while legacy worker recovery owns the pane', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = 'wt-1@@lost-pty'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
sessionId
|
||||
? {
|
||||
id: 'fresh-pty',
|
||||
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
|
||||
it.each(['ordinary', 'settled-worker'])(
|
||||
'restores %s through main with one resume command',
|
||||
async (kind) => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = 'wt-1@@lost-pty'
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
sessionId
|
||||
? {
|
||||
id: 'fresh-pty',
|
||||
coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' }
|
||||
}
|
||||
: 'fresh-pty'
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'claude-session-1' }
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'claude-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
...(kind === 'settled-worker'
|
||||
? { automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
: {})
|
||||
}
|
||||
: 'fresh-pty'
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }]
|
||||
},
|
||||
settings: {
|
||||
...mockStoreState.settings,
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' }
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.connect).toHaveBeenCalledTimes(1)
|
||||
expect(transport.attach).not.toHaveBeenCalled()
|
||||
const options = transport.connect.mock.calls[0]?.[0] as {
|
||||
sessionId?: string
|
||||
command?: string
|
||||
}
|
||||
} as StoreState
|
||||
|
||||
connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
const attachOptions = transport.attach.mock.calls[0]?.[0] as Record<string, unknown>
|
||||
expect(attachOptions).not.toHaveProperty('cols')
|
||||
expect(attachOptions).not.toHaveProperty('rows')
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not replace a missing retained legacy worker over direct SSH', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker')
|
||||
const transport = createMockTransport()
|
||||
transport.getConnectionId.mockReturnValue('ssh-a')
|
||||
transport.attach.mockImplementation(() => {
|
||||
throw new Error('remote PTY missing')
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }]
|
||||
},
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-a' }],
|
||||
sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]),
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId }
|
||||
})
|
||||
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a missing retained legacy worker through direct SSH reconnect', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker')
|
||||
const transport = createMockTransport()
|
||||
transport.getConnectionId.mockReturnValue('ssh-a')
|
||||
transport.attach.mockImplementation(() => {
|
||||
throw new Error('remote PTY missing')
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }]
|
||||
},
|
||||
repos: [{ id: 'repo1', connectionId: 'ssh-a' }],
|
||||
sshConnectionStates: new Map([['ssh-a', { status: 'disconnected' }]]),
|
||||
deferredSshReconnectTargets: ['ssh-a'],
|
||||
deferredSshSessionIdsByTabId: { 'tab-1': retainedPtyId },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'finish the task',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
}
|
||||
}
|
||||
} as StoreState
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId }
|
||||
})
|
||||
|
||||
connectPanePty(createPane(1) as never, createManager(1) as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
await new Promise((resolve) => setTimeout(resolve, 70))
|
||||
|
||||
expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'ssh-a' })
|
||||
expect(transport.attach).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ existingPtyId: retainedPtyId })
|
||||
)
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled()
|
||||
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(options.sessionId).toBe(retainedPtyId)
|
||||
expect(options.command).toContain('claude-session-1')
|
||||
expect(options.command?.match(/--resume/g)).toHaveLength(1)
|
||||
expect(mockStoreState.tabsByWorktree['wt-1']).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores stale live launch config when cold restore identity lookup rejects it', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
+1
-3
@@ -25,9 +25,7 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe
|
||||
const entry = state.agentStatusByPaneKey[session.cacheKey]
|
||||
const sleepingRecordEntry = session.getSleepingRecordForPane(state)
|
||||
const sleepingRecord = sleepingRecordEntry?.record
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const useLiveEntry = entry && entry.state !== 'done'
|
||||
const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent
|
||||
if (!agent || !isResumableTuiAgent(agent)) {
|
||||
|
||||
+1
-10
@@ -1,4 +1,3 @@
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host'
|
||||
import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate'
|
||||
@@ -61,8 +60,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
console.warn(
|
||||
`[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}`
|
||||
)
|
||||
const legacyWorkerOwnsPane = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) {
|
||||
if (gate.enterDeferredFlow) {
|
||||
// Paint main's parked model while SSH recovery continues off the render path.
|
||||
session.prepaintParkedSshSnapshot(pendingSessionId)
|
||||
void (async () => {
|
||||
@@ -115,13 +113,6 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void {
|
||||
}
|
||||
useAppStore.getState().removeDeferredSshReconnectTarget(session.connectionId)
|
||||
if (pendingSessionId) {
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
if (session.attachRetainedLegacyPty(pendingSessionId)) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
scheduleRuntimeGraphSync()
|
||||
}
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}`
|
||||
)
|
||||
|
||||
+34
-50
@@ -89,9 +89,6 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
: null
|
||||
// Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree.
|
||||
// Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh.
|
||||
const legacyAttachOnlyPtyId = session.isLegacyWorkerAutomaticResumeBlocked()
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const pairedParkedReattachSessionId =
|
||||
session.mountFollowsTerminalPark &&
|
||||
candidateReattachSessionId &&
|
||||
@@ -99,64 +96,51 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
canRestorePairedParkedTerminal(candidateReattachSessionId)
|
||||
? candidateReattachSessionId
|
||||
: null
|
||||
const deferredReattachSessionId = legacyAttachOnlyPtyId
|
||||
? null
|
||||
: (runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null))
|
||||
const deferredReattachSessionId =
|
||||
runtimeHostPtyWakeHint ??
|
||||
pairedParkedReattachSessionId ??
|
||||
(candidateReattachSessionId &&
|
||||
!isRemoteRuntimePtyId(candidateReattachSessionId) &&
|
||||
!candidateHasEagerBuffer &&
|
||||
isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId)
|
||||
? candidateReattachSessionId
|
||||
: null)
|
||||
recordPtyConnectDiagnostic(
|
||||
`pane=${session.pane.id} tab=${session.deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${session.hadExistingPaneTransportAtConnect} pendingKey=${session.pendingSpawnKey}`
|
||||
)
|
||||
|
||||
if (deferredReattachSessionId) {
|
||||
startDeferredSessionReattach(session, deferredReattachSessionId)
|
||||
} else if (
|
||||
legacyAttachOnlyPtyId ||
|
||||
detachedRemoteLeafPtyId ||
|
||||
detachedLivePtyId ||
|
||||
eagerLivePtyId
|
||||
) {
|
||||
} else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) {
|
||||
// Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab.
|
||||
// eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt.
|
||||
const attachPtyId =
|
||||
legacyAttachOnlyPtyId ?? detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
|
||||
const attachPtyId = detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId!
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`)
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
if (legacyAttachOnlyPtyId) {
|
||||
if (session.attachRetainedLegacyPty(legacyAttachOnlyPtyId) && session.connectionId) {
|
||||
useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId)
|
||||
}
|
||||
} else {
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: attachPtyId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
|
||||
session.startFreshSpawn()
|
||||
// Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface.
|
||||
// On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here.
|
||||
try {
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: attachPtyId,
|
||||
cols: session.cols,
|
||||
rows: session.rows,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? attachPtyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
session.deps.clearTabPtyId(session.deps.tabId, attachPtyId)
|
||||
session.startFreshSpawn()
|
||||
}
|
||||
} else {
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
|
||||
@@ -34,10 +34,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (session.isLegacyWorkerAutomaticResumeBlocked()) {
|
||||
releaseDeferredCwdFence()
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) {
|
||||
// Why: the worktree is being deleted; its PTYs were just killed for the
|
||||
// filesystem teardown. A fresh shell must not spawn into a directory the
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void {
|
||||
session.attachRetainedLegacyPty = (ptyId: string): boolean => {
|
||||
try {
|
||||
session.authoritativeReattachGeneration += 1
|
||||
session.clearPaneMode2031State()
|
||||
session.clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null)
|
||||
session.transport.attach({
|
||||
existingPtyId: ptyId,
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
const attachedPtyId = session.transport.getPtyId() ?? ptyId
|
||||
session.bindActivePanePty(attachedPtyId, {
|
||||
updateTabPtyId: 'if-missing',
|
||||
sampleVisibleForegroundAgent: true
|
||||
})
|
||||
if (isRemoteRuntimePtyId(attachedPtyId)) {
|
||||
session.registerPaneSerializerFor(attachedPtyId)
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
session.reportError(err instanceof Error ? err.message : String(err))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint'
|
||||
import { bindForegroundOutputRefresh } from './foreground-output-refresh'
|
||||
import { bindRegisterPaneSerializer } from './pane-serializer-register'
|
||||
import { bindHandleReattachResult } from './reattach-result-handler'
|
||||
import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach'
|
||||
import { runDeferredSessionAttach } from './deferred-session-attach'
|
||||
|
||||
import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize'
|
||||
@@ -154,7 +153,6 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
|
||||
|
||||
bindPrepaintParkedSshSnapshot(session)
|
||||
bindHandleReattachResult(session)
|
||||
bindAttachRetainedLegacyPty(session)
|
||||
runDeferredSessionAttach(session)
|
||||
}
|
||||
|
||||
|
||||
@@ -62,9 +62,6 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi
|
||||
const [paneKey, record] = selectedLegacyMatch
|
||||
return { paneKey, record }
|
||||
}
|
||||
session.isLegacyWorkerAutomaticResumeBlocked = (): boolean =>
|
||||
session.getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy ===
|
||||
'legacy-orchestration-worker'
|
||||
session.clearSleepingRecordProviderDuplicates = (
|
||||
state: ReturnType<typeof useAppStore.getState>,
|
||||
consumed: { paneKey: string; record: SleepingAgentSessionRecord }
|
||||
|
||||
@@ -43,20 +43,4 @@ describe('selectSleepingRecordParkExemptTabIds', () => {
|
||||
|
||||
expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([])
|
||||
})
|
||||
|
||||
it('skips records that cannot resume in this worktree', () => {
|
||||
const records = {
|
||||
[`tab-other:${LEAF_ID}`]: sleepingRecord({
|
||||
paneKey: `tab-other:${LEAF_ID}`,
|
||||
worktreeId: 'wt-2'
|
||||
}),
|
||||
[`tab-done:${LEAF_ID}`]: sleepingRecord({ paneKey: `tab-done:${LEAF_ID}`, state: 'done' }),
|
||||
[`tab-blocked:${LEAF_ID}`]: sleepingRecord({
|
||||
paneKey: `tab-blocked:${LEAF_ID}`,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
})
|
||||
}
|
||||
|
||||
expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ const EMPTY_TAB_IDS: ReadonlySet<string> = new Set()
|
||||
|
||||
/** Tab ids whose panes own a sleeping record a mount can actually consume.
|
||||
* Why: a parked pane can never cold-restore, so per-tab parks must exempt
|
||||
* these — but only these: blocked and passive-completed records never resume,
|
||||
* these — but only these: passive-completed records never resume,
|
||||
* and exempting them would pin a hidden pane mounted indefinitely.
|
||||
* Callers subscribe through `useShallow`, which compares the set structurally,
|
||||
* so a write for another worktree cannot re-render this one. Iterates in place —
|
||||
@@ -24,7 +24,7 @@ export function selectSleepingRecordParkExemptTabIds(
|
||||
if (!record || record.worktreeId !== worktreeId) {
|
||||
continue
|
||||
}
|
||||
if (record.automaticResumeBlockedBy || isPassiveCompletedHibernationEvidence(record)) {
|
||||
if (isPassiveCompletedHibernationEvidence(record)) {
|
||||
continue
|
||||
}
|
||||
// Why: malformed pane keys must yield no owner instead of a truncated tab id.
|
||||
|
||||
-15
@@ -111,21 +111,6 @@ describe('cold-park store subscription narrowing', () => {
|
||||
expect(harness.renders).toBe(0)
|
||||
})
|
||||
|
||||
// Why: a blocked record never resumes, so it leaves the exempt set — and the
|
||||
// narrowed subscription's compared value — unchanged.
|
||||
it('ignores a sleeping-session write this worktree can never resume', () => {
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-1:1': sleepingRecord('tab-1:1', WORKTREE_ID, {
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(harness.renders).toBe(0)
|
||||
})
|
||||
|
||||
it('still re-renders when this worktree gains a pending startup', () => {
|
||||
act(() => {
|
||||
useAppStore.setState({
|
||||
|
||||
@@ -528,43 +528,6 @@ describe('useTerminalTabColdParking measure-clock contract', () => {
|
||||
expect(result.current).toEqual(new Set(['tab-2']))
|
||||
})
|
||||
|
||||
// Why: blocked and passive-completed records never auto-resume, so exempting
|
||||
// Why: passive-completed records never auto-resume, so exempting
|
||||
// them would pin a hidden pane mounted indefinitely for nothing.
|
||||
it('keeps parking panes whose records cannot be consumed', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
(args: ReturnType<typeof hookArgs>) => useTerminalTabColdParking(args),
|
||||
{ initialProps: hookArgs(false) }
|
||||
)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1)
|
||||
})
|
||||
expect(result.current).toEqual(new Set(['tab-2']))
|
||||
|
||||
mocks.storeState.sleepingAgentSessionsByPaneKey = {
|
||||
'tab-2:22222222-2222-4222-8222-222222222222': {
|
||||
paneKey: 'tab-2:22222222-2222-4222-8222-222222222222',
|
||||
tabId: 'tab-2',
|
||||
worktreeId: WORKTREE_ID,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
} as never
|
||||
}
|
||||
act(() => {
|
||||
rerender(hookArgs(false))
|
||||
})
|
||||
expect(result.current).toEqual(new Set(['tab-2']))
|
||||
|
||||
mocks.storeState.sleepingAgentSessionsByPaneKey = {
|
||||
'tab-2:22222222-2222-4222-8222-222222222222': {
|
||||
paneKey: 'tab-2:22222222-2222-4222-8222-222222222222',
|
||||
tabId: 'tab-2',
|
||||
worktreeId: WORKTREE_ID,
|
||||
origin: 'worktree-sleep',
|
||||
state: 'done'
|
||||
} as never
|
||||
}
|
||||
act(() => {
|
||||
rerender(hookArgs(false))
|
||||
})
|
||||
expect(result.current).toEqual(new Set(['tab-2']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -126,12 +126,4 @@ export function registerAgentStatusListeners(args: {
|
||||
if (unsubscribeLegacyWorkerTerminalRecovery) {
|
||||
unsubs.push(unsubscribeLegacyWorkerTerminalRecovery)
|
||||
}
|
||||
const unsubscribeResumeFence = window.api.agentStatus.onLegacyWorkerTerminalResumeFence?.(
|
||||
({ paneKey, blocked }) => {
|
||||
useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked)
|
||||
}
|
||||
)
|
||||
if (unsubscribeResumeFence) {
|
||||
unsubs.push(unsubscribeResumeFence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,11 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
|
||||
it('retires the exact sleeping record after adopted or exited legacy worker recovery', async () => {
|
||||
const clearSleepingAgentSession = vi.fn()
|
||||
const setSleepingAgentAutomaticResumeBlocked = vi.fn()
|
||||
let listener:
|
||||
| ((data: { paneKey: string; resolution: 'adopted' | 'exited' }) => void)
|
||||
| undefined
|
||||
const storeState = buildStoreState({
|
||||
clearSleepingAgentSession,
|
||||
setSleepingAgentAutomaticResumeBlocked
|
||||
clearSleepingAgentSession
|
||||
})
|
||||
|
||||
stubReactSyncEffect()
|
||||
@@ -54,12 +52,10 @@ describe('useIpcEvents agent status snapshot integration', () => {
|
||||
|
||||
listener?.({ paneKey: 'tab-adopted:leaf-adopted', resolution: 'adopted' })
|
||||
expect(clearSleepingAgentSession).toHaveBeenCalledWith('tab-adopted:leaf-adopted')
|
||||
expect(setSleepingAgentAutomaticResumeBlocked).not.toHaveBeenCalled()
|
||||
|
||||
clearSleepingAgentSession.mockClear()
|
||||
listener?.({ paneKey: 'tab-exited:leaf-exited', resolution: 'exited' })
|
||||
expect(clearSleepingAgentSession).toHaveBeenCalledWith('tab-exited:leaf-exited')
|
||||
expect(setSleepingAgentAutomaticResumeBlocked).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createHarnessStoreState } from './ipc-events-test-harness'
|
||||
const EXPECTED_DIRECT_CALLBACK_METHODS = [
|
||||
'agentStatus.onClear',
|
||||
'agentStatus.onLegacyWorkerTerminalRecovery',
|
||||
'agentStatus.onLegacyWorkerTerminalResumeFence',
|
||||
'agentStatus.onMigrationUnsupported',
|
||||
'agentStatus.onMigrationUnsupportedClear',
|
||||
'agentStatus.onSet',
|
||||
@@ -199,7 +198,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [
|
||||
'agentStatus.onMigrationUnsupported',
|
||||
'agentStatus.onMigrationUnsupportedClear',
|
||||
'agentStatus.onLegacyWorkerTerminalRecovery',
|
||||
'agentStatus.onLegacyWorkerTerminalResumeFence',
|
||||
'runtime.onTerminalFitOverrideChanged',
|
||||
'runtime.onTerminalDriverChanged',
|
||||
'runtime.onNativeChatLaunchDraftResolved',
|
||||
|
||||
@@ -4,10 +4,7 @@ import { parsePaneKey } from '../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream'
|
||||
import { lastInputBlocksHibernation } from './agent-hibernation-input-guard'
|
||||
import {
|
||||
isAutomaticHibernationAllowed,
|
||||
isLiveResumeAnchorForCompletedAgent
|
||||
} from './live-resume-anchor-record'
|
||||
import { isLiveResumeAnchorForCompletedAgent } from './live-resume-anchor-record'
|
||||
import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot'
|
||||
|
||||
export type EligiblePane = {
|
||||
@@ -95,10 +92,7 @@ export function getEligiblePane(args: {
|
||||
entry.interrupted === true ||
|
||||
Boolean(entry.subagents?.length) ||
|
||||
hasUnsettledOrUnknownDispatch(entry) ||
|
||||
(sleepingRecord && !hasOnlyLiveResumeAnchor) ||
|
||||
// Why: a fenced worker must never be auto-relaunched; killing it would also
|
||||
// erase the fence, since the capture does not copy it.
|
||||
!isAutomaticHibernationAllowed(sleepingRecord)
|
||||
(sleepingRecord && !hasOnlyLiveResumeAnchor)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -753,37 +753,6 @@ describe('live resume anchors do not block hibernation (#10238 regression)', ()
|
||||
)
|
||||
).toEqual([agentEntry.paneKey])
|
||||
})
|
||||
|
||||
it('still refuses a pane fenced against automatic resume', () => {
|
||||
const providerSession = { key: 'session_id' as const, id: 'claude-session-1' }
|
||||
const agentEntry = entry({ agentType: 'claude', providerSession })
|
||||
const fenced = {
|
||||
...liveAnchor('claude', providerSession),
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
}
|
||||
expect(
|
||||
plannedPaneKeys(
|
||||
snapshot({
|
||||
agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry },
|
||||
sleepingAgentSessionsByPaneKey: { [agentEntry.paneKey]: fenced as never },
|
||||
ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD }
|
||||
})
|
||||
)
|
||||
).toEqual([])
|
||||
// Control: the identical pane IS planned once the fence is gone, so the rejection
|
||||
// above isolates the fence rather than some other guard.
|
||||
expect(
|
||||
plannedPaneKeys(
|
||||
snapshot({
|
||||
agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[agentEntry.paneKey]: liveAnchor('claude', providerSession) as never
|
||||
},
|
||||
ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD }
|
||||
})
|
||||
)
|
||||
).toEqual([agentEntry.paneKey])
|
||||
})
|
||||
})
|
||||
|
||||
describe('idle clock anchors on stateStartedAt, not updatedAt', () => {
|
||||
|
||||
@@ -50,14 +50,3 @@ export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord(
|
||||
isLiveResumeAnchorForCompletedAgent(entry, record, worktreeId)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable orchestration fence against automatic provider relaunch. Hibernating
|
||||
* a fenced pane would strand it or — since `sleepingRecordFromEntry` does not copy
|
||||
* the flag — erase the fence and later auto-resume prohibited work.
|
||||
*/
|
||||
export function isAutomaticHibernationAllowed(
|
||||
record: SleepingAgentSessionRecord | undefined
|
||||
): boolean {
|
||||
return !record?.automaticResumeBlockedBy
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
describe('legacy worker sleeping-session recovery', () => {
|
||||
it('never resumes a proven-exited legacy worker on workspace activation', () => {
|
||||
const record: SleepingAgentSessionRecord = {
|
||||
paneKey: 'tab-legacy:leaf-legacy',
|
||||
tabId: 'tab-legacy',
|
||||
worktreeId: 'wt-legacy',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-legacy' },
|
||||
prompt: 'continue legacy work',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live',
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
}
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
'wt-legacy': [
|
||||
{
|
||||
id: 'tab-legacy',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-legacy',
|
||||
title: 'Legacy worker',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
} as never)
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-legacy')).toBe(0)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record)
|
||||
|
||||
useAppStore.getState().clearSleepingAgentSession(record.paneKey)
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-legacy')).toBe(0)
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -210,9 +210,6 @@ export function resumeSleepingAgentSessionsForWorktree(
|
||||
if (options?.skipClaimKeys?.has(claimKey)) {
|
||||
continue
|
||||
}
|
||||
if (record.automaticResumeBlockedBy === 'legacy-orchestration-worker') {
|
||||
continue
|
||||
}
|
||||
if (isInvalidWorktreeActivationRecord(record)) {
|
||||
state.clearSleepingAgentSession(record.paneKey)
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session'
|
||||
|
||||
const initialState = useAppStore.getState()
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
it('resumes a settled worker and an ordinary agent once each in the same wake sweep', () => {
|
||||
const records = ['settled-worker', 'ordinary-agent'].map((id) => ({
|
||||
paneKey: `${id}:leaf`,
|
||||
tabId: id,
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'claude' as const,
|
||||
providerSession: { key: 'session_id' as const, id },
|
||||
prompt: 'continue the session',
|
||||
state: 'working' as const,
|
||||
capturedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
origin: 'worktree-sleep' as const,
|
||||
// Old clients can still publish the withdrawn policy field.
|
||||
...(id === 'settled-worker' ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } : {})
|
||||
}))
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
sleepingAgentSessionsByPaneKey: Object.fromEntries(records.map((r) => [r.paneKey, r]))
|
||||
})
|
||||
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(2)
|
||||
expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0)
|
||||
const state = useAppStore.getState()
|
||||
const tabs = state.tabsByWorktree['wt-1']
|
||||
expect(tabs).toHaveLength(2)
|
||||
const commands = tabs.map((tab) => state.pendingStartupByTabId[tab.id]?.command ?? '')
|
||||
for (const record of records) {
|
||||
expect(commands.filter((command) => command.includes(record.providerSession.id))).toHaveLength(
|
||||
1
|
||||
)
|
||||
}
|
||||
for (const command of commands) {
|
||||
expect(command.match(/--resume/g)).toHaveLength(1)
|
||||
}
|
||||
expect(state.sleepingAgentSessionsByPaneKey).toEqual({})
|
||||
})
|
||||
@@ -156,7 +156,7 @@ describe('agent pane authority', () => {
|
||||
expect(store.getState().agentStatusByPaneKey[SIBLING]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('can retire live pane authority while retaining a migration recovery fence', () => {
|
||||
it('can retire live pane authority while retaining its sleeping session', () => {
|
||||
const store = createTestStore()
|
||||
store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' })
|
||||
store.getState().registerAgentLaunchConfig(TARGET, { agentArgs: '', agentEnv: {} })
|
||||
@@ -171,8 +171,7 @@ describe('agent pane authority', () => {
|
||||
prompt: 'continue',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -183,7 +182,7 @@ describe('agent pane authority', () => {
|
||||
expect(state.agentStatusByPaneKey[TARGET]).toBeUndefined()
|
||||
expect(state.agentLaunchConfigByPaneKey[TARGET]).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[TARGET]).toMatchObject({
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
})
|
||||
expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBe(true)
|
||||
expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET)
|
||||
|
||||
@@ -141,36 +141,6 @@ describe('manual sleep agent session capture', () => {
|
||||
expect(records['tab-1:working'].restoreOnTabOpenOnly).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a blocked legacy-orchestration-worker flag onto the replacement record', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
const store = createTestStore()
|
||||
seedTabs(store)
|
||||
store.setState({
|
||||
agentStatusByPaneKey: {
|
||||
'tab-1:leaf-1': makeAgentEntry(),
|
||||
'tab-1:leaf-2': makeAgentEntry({ paneKey: 'tab-1:leaf-2' })
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-1:leaf-1': makeSleepingRecord({
|
||||
providerSession: { key: 'session_id', id: 'session-tab-1:leaf-1' },
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
}),
|
||||
'tab-1:leaf-2': makeSleepingRecord({
|
||||
paneKey: 'tab-1:leaf-2',
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
})
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().captureSleepingAgentSessionsByWorktree('wt-1')
|
||||
|
||||
const records = store.getState().sleepingAgentSessionsByPaneKey
|
||||
expect(records['tab-1:leaf-1'].automaticResumeBlockedBy).toBe('legacy-orchestration-worker')
|
||||
// Different provider session: the block belonged to a session that is no longer running here.
|
||||
expect(records['tab-1:leaf-2'].automaticResumeBlockedBy).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves retained completed sessions as intentional sleep records', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
@@ -231,38 +201,6 @@ describe('manual sleep agent session capture', () => {
|
||||
expect(record.interrupted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a blocked legacy-orchestration-worker flag onto a retained replacement record', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
const store = createTestStore()
|
||||
seedTabs(store)
|
||||
const entry = makeAgentEntry({ paneKey: 'tab-1:retained', state: 'done' })
|
||||
store.setState({
|
||||
retainedAgentsByPaneKey: {
|
||||
'tab-1:retained': {
|
||||
entry,
|
||||
tab: makeTab({ id: 'tab-1', worktreeId: 'wt-1' }),
|
||||
worktreeId: 'wt-1',
|
||||
agentType: 'codex',
|
||||
startedAt: entry.stateStartedAt
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-1:retained': makeSleepingRecord({
|
||||
paneKey: 'tab-1:retained',
|
||||
providerSession: { key: 'session_id', id: 'session-tab-1:retained' },
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
})
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().captureSleepingAgentSessionsByWorktree('wt-1')
|
||||
|
||||
expect(
|
||||
store.getState().sleepingAgentSessionsByPaneKey['tab-1:retained'].automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
})
|
||||
|
||||
// Why: the promoted checkpoint owns the pane's recovery identity (connection, transcript); the
|
||||
// retained pass must not re-derive over it any more than the live pass may.
|
||||
it('keeps a promoted live checkpoint that also has a retained row', () => {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { AppState } from '../types'
|
||||
import { createTestStore, makeTab } from './store-test-helpers'
|
||||
|
||||
const NOW = 1_800_000_000_000
|
||||
const PANE_KEY = 'tab-1:leaf-1'
|
||||
|
||||
function liveWorkerEntry(): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
updatedAt: NOW,
|
||||
stateStartedAt: NOW,
|
||||
stateHistory: [],
|
||||
agentType: 'codex',
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'session-1' }
|
||||
}
|
||||
}
|
||||
|
||||
// The worker settles while its tab is still open, so there is no sleeping record to stamp; the
|
||||
// record is minted on close and used to arrive unfenced, respawning settled work on reopen.
|
||||
describe('a resume fence that arrives before the sleeping record exists', () => {
|
||||
it('carries the block onto the record minted after the tab closes', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] },
|
||||
agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() }
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true)
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined()
|
||||
|
||||
store.getState().captureAllSleepingAgentSessions('quit')
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject({
|
||||
paneKey: PANE_KEY,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker'
|
||||
})
|
||||
})
|
||||
|
||||
it('mints an unfenced record once the runtime lifts the block', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] },
|
||||
agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() }
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true)
|
||||
store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false)
|
||||
store.getState().captureAllSleepingAgentSessions('quit')
|
||||
|
||||
expect(
|
||||
store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -113,10 +113,6 @@ export function createAgentStatusProviderSessionActions(
|
||||
? { connectionId: existingRecord.connectionId }
|
||||
: {}),
|
||||
...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}),
|
||||
...(existingRecordMatchesProviderSession &&
|
||||
existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker'
|
||||
? { automaticResumeBlockedBy: 'legacy-orchestration-worker' }
|
||||
: {}),
|
||||
...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined
|
||||
? { interrupted: existingRecord.interrupted }
|
||||
: {}),
|
||||
|
||||
@@ -327,64 +327,6 @@ describe('recordAgentProviderSession', () => {
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves the legacy resume fence only for the same Pi session identity', () => {
|
||||
const store = createTestStore()
|
||||
const makeRecord = (transcriptPath: string): SleepingAgentSessionRecord => ({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'pi',
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'pi-session-1',
|
||||
transcriptPath
|
||||
},
|
||||
prompt: '',
|
||||
state: 'working',
|
||||
capturedAt: 10,
|
||||
updatedAt: 10,
|
||||
automaticResumeBlockedBy: 'legacy-orchestration-worker',
|
||||
origin: 'live'
|
||||
})
|
||||
store.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-1:leaf-1': makeRecord('/tmp/pi-session-1.jsonl')
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().recordAgentProviderSession(
|
||||
'tab-1:leaf-1',
|
||||
'pi',
|
||||
{
|
||||
key: 'session_id',
|
||||
id: 'pi-session-1',
|
||||
transcriptPath: '/tmp/pi-session-1.jsonl'
|
||||
},
|
||||
{ updatedAt: 20 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.automaticResumeBlockedBy
|
||||
).toBe('legacy-orchestration-worker')
|
||||
|
||||
store.getState().recordAgentProviderSession(
|
||||
'tab-1:leaf-1',
|
||||
'pi',
|
||||
{
|
||||
key: 'session_id',
|
||||
id: 'pi-session-1',
|
||||
transcriptPath: '/tmp/pi-session-2.jsonl'
|
||||
},
|
||||
{ updatedAt: 30 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' }
|
||||
)
|
||||
|
||||
expect(
|
||||
store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.automaticResumeBlockedBy
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(PI_COMPATIBLE_CASES)(
|
||||
'keeps a completed $label session resumable through manual worktree sleep',
|
||||
async ({ agent, label }) => {
|
||||
|
||||
@@ -22,7 +22,6 @@ export function createAgentStatusRecoveryActions(
|
||||
| 'captureAllSleepingAgentSessions'
|
||||
| 'clearSleepingAgentSession'
|
||||
| 'clearSleepingAgentSessionsByPaneKey'
|
||||
| 'setSleepingAgentAutomaticResumeBlocked'
|
||||
| 'clearSleepingAgentSessionsByWorktree'
|
||||
| 'pruneSleepingAgentSessions'
|
||||
> {
|
||||
@@ -109,47 +108,6 @@ export function createAgentStatusRecoveryActions(
|
||||
clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]),
|
||||
clearSleepingAgentSessionsByPaneKey,
|
||||
|
||||
setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => {
|
||||
set((s) => {
|
||||
// The pane key is tracked even with no record: a worker settled while its tab was open
|
||||
// is fenced before the record exists, and the record is only minted on close.
|
||||
const wasBlocked = s.automaticResumeBlockedPaneKeys[paneKey] === true
|
||||
let paneKeys = s.automaticResumeBlockedPaneKeys
|
||||
if (blocked !== wasBlocked) {
|
||||
paneKeys = { ...s.automaticResumeBlockedPaneKeys }
|
||||
if (blocked) {
|
||||
paneKeys[paneKey] = true
|
||||
} else {
|
||||
delete paneKeys[paneKey]
|
||||
}
|
||||
}
|
||||
const current = s.sleepingAgentSessionsByPaneKey[paneKey]
|
||||
if (
|
||||
!current ||
|
||||
(blocked
|
||||
? current.automaticResumeBlockedBy === 'legacy-orchestration-worker'
|
||||
: current.automaticResumeBlockedBy === undefined)
|
||||
) {
|
||||
return paneKeys === s.automaticResumeBlockedPaneKeys
|
||||
? s
|
||||
: { automaticResumeBlockedPaneKeys: paneKeys }
|
||||
}
|
||||
const next = { ...current }
|
||||
if (blocked) {
|
||||
next.automaticResumeBlockedBy = 'legacy-orchestration-worker'
|
||||
} else {
|
||||
delete next.automaticResumeBlockedBy
|
||||
}
|
||||
return {
|
||||
automaticResumeBlockedPaneKeys: paneKeys,
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...s.sleepingAgentSessionsByPaneKey,
|
||||
[paneKey]: next
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
clearSleepingAgentSessionsByWorktree: (worktreeId) => {
|
||||
set((s) => {
|
||||
let changed = false
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
retainedAgentEntryFromLive
|
||||
} from './agent-status-pane-key-tab-binding'
|
||||
import {
|
||||
carryOverAutomaticResumeBlock,
|
||||
isValidCompletedAgentHibernationEntry,
|
||||
manualSleepCaptureEntry,
|
||||
markManualSleepLazyRestore,
|
||||
@@ -96,10 +95,6 @@ export function collectSleepingAgentSessionRecordsForWorktree(
|
||||
if (record) {
|
||||
if (isManualWorktreeSleep) {
|
||||
markManualSleepLazyRestore(record)
|
||||
carryOverAutomaticResumeBlock(
|
||||
record,
|
||||
state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey]
|
||||
)
|
||||
}
|
||||
records[record.paneKey] = record
|
||||
}
|
||||
@@ -133,7 +128,6 @@ export function collectSleepingAgentSessionRecordsForWorktree(
|
||||
if (record) {
|
||||
if (isManualWorktreeSleep) {
|
||||
markManualSleepLazyRestore(record)
|
||||
carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey])
|
||||
}
|
||||
records[record.paneKey] = record
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AppState } from '../types'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
agentProviderSessionsEqual,
|
||||
getAgentResumeArgv,
|
||||
isResumableTuiAgent,
|
||||
type SleepingAgentLaunchConfig,
|
||||
@@ -59,11 +58,7 @@ export function sleepingRecordFromEntry(args: {
|
||||
: {}),
|
||||
...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}),
|
||||
...(args.entry.interrupted ? { interrupted: true } : {}),
|
||||
...(args.origin ? { origin: args.origin } : {}),
|
||||
// The worker can settle while the tab is open, so the fence arrives before this record exists.
|
||||
...(args.state.automaticResumeBlockedPaneKeys?.[args.entry.paneKey]
|
||||
? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const }
|
||||
: {})
|
||||
...(args.origin ? { origin: args.origin } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,21 +108,6 @@ export function manualSleepCaptureEntry(
|
||||
return { ...entry, updatedAt: capturedAt, interrupted: false }
|
||||
}
|
||||
|
||||
// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately
|
||||
// blocked worker must not become auto-resumable at wake.
|
||||
export function carryOverAutomaticResumeBlock(
|
||||
record: SleepingAgentSessionRecord,
|
||||
previous: SleepingAgentSessionRecord | undefined
|
||||
): void {
|
||||
if (
|
||||
previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' &&
|
||||
previous.agent === record.agent &&
|
||||
agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession)
|
||||
) {
|
||||
record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSleepingRecordsReplacedByManualWorktreeSleep(
|
||||
records: Record<string, SleepingAgentSessionRecord>,
|
||||
worktreeId: string,
|
||||
|
||||
@@ -51,10 +51,6 @@ export type AgentStatusSlice = {
|
||||
/** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */
|
||||
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord>
|
||||
|
||||
/** Panes the runtime fenced against automatic resume. Held separately because a worker can
|
||||
* settle while its tab is open, before the sleeping record the fence belongs on exists. */
|
||||
automaticResumeBlockedPaneKeys: Record<string, true>
|
||||
|
||||
/** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */
|
||||
agentLaunchConfigByPaneKey: Record<string, AgentLaunchConfigRegistryEntry>
|
||||
|
||||
@@ -162,7 +158,6 @@ export type AgentStatusSlice = {
|
||||
captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void
|
||||
clearSleepingAgentSession: (paneKey: string) => void
|
||||
clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void
|
||||
setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void
|
||||
clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void
|
||||
pruneSleepingAgentSessions: (validWorktreeIds: Set<string>) => void
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
transientClearedAgentStatusConnectionIds: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
automaticResumeBlockedPaneKeys: {},
|
||||
agentLaunchConfigByPaneKey: {},
|
||||
retentionSuppressedPaneKeys: {},
|
||||
recentlyClosedAgentStatusTabIds: {},
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
unregisterPtyDataHandlers
|
||||
} from '@/components/terminal-pane/pty-transport'
|
||||
import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures'
|
||||
import { isAutomaticHibernationAllowed } from '@/lib/live-resume-anchor-record'
|
||||
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import {
|
||||
@@ -63,17 +62,6 @@ export function createTerminalPaneHibernationActions(
|
||||
// Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably.
|
||||
throw new Error('agent_hibernation_capture_missing')
|
||||
}
|
||||
// Why: the planner's fence check happened before the coordinator's async re-plan;
|
||||
// the fence can be set in that window. Capture below OVERWRITES the record, and
|
||||
// sleepingRecordFromEntry does not copy the flag, so losing this race erases the
|
||||
// fence and later auto-resumes work whose relaunch was explicitly prohibited.
|
||||
const assertAutomaticHibernationStillAllowed = (): void => {
|
||||
const record = get().sleepingAgentSessionsByPaneKey[opts.paneKey]
|
||||
if (!isAutomaticHibernationAllowed(record)) {
|
||||
throw new Error('agent_hibernation_automatic_resume_blocked')
|
||||
}
|
||||
}
|
||||
assertAutomaticHibernationStillAllowed()
|
||||
const capture = shutdownBufferCaptures.get(opts.tabId)
|
||||
if (capture) {
|
||||
try {
|
||||
@@ -82,8 +70,6 @@ export function createTerminalPaneHibernationActions(
|
||||
// Don't let one tab's capture failure block the pane hibernation.
|
||||
}
|
||||
}
|
||||
// Why: the capture callback runs synchronously above and can itself fence the pane.
|
||||
assertAutomaticHibernationStillAllowed()
|
||||
// Why: store sleeping records before kill, since pty:exit can arrive first.
|
||||
const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords)
|
||||
const replacedSleepingRecords: Record<string, (typeof sleepingAgentSessionRecords)[string]> =
|
||||
|
||||
@@ -12,7 +12,6 @@ export function createWebAgentStatusApi(): Partial<PreloadApi> {
|
||||
onMigrationUnsupported: () => noopUnsubscribe,
|
||||
onMigrationUnsupportedClear: () => noopUnsubscribe,
|
||||
onLegacyWorkerTerminalRecovery: () => noopUnsubscribe,
|
||||
onLegacyWorkerTerminalResumeFence: () => noopUnsubscribe,
|
||||
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
|
||||
drop: () => {},
|
||||
dropPersisted: () => {},
|
||||
|
||||
@@ -63,9 +63,6 @@ export type SleepingAgentSessionRecord = {
|
||||
* so only the pane's own cold-restore path may consume them — activation
|
||||
* launching a tab too would duplicate a warm-reattached session (#5232). */
|
||||
origin?: 'worktree-sleep' | 'quit' | 'live'
|
||||
/** Prevents provider-session relaunch while main reconciles a durable
|
||||
* orchestration assignment against authoritative PTY inventory. */
|
||||
automaticResumeBlockedBy?: 'legacy-orchestration-worker'
|
||||
/** Set on a finished pane captured by an explicit workspace sleep. Its
|
||||
* `--resume` is issued by the pane's own cold restore when its tab is
|
||||
* opened, so a mobile wake must not background-mount every such tab and
|
||||
|
||||
@@ -41,6 +41,38 @@ describe('parseWorkspaceSession sleeping agents', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, 'legacy-orchestration-worker'])(
|
||||
'new host ignores an old client resume fence (%s)',
|
||||
(automaticResumeBlockedBy) => {
|
||||
const record = {
|
||||
paneKey: 'tab1:pane-1',
|
||||
tabId: 'tab1',
|
||||
worktreeId: 'wt',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session' },
|
||||
prompt: 'continue',
|
||||
state: 'done',
|
||||
capturedAt: 10,
|
||||
updatedAt: 10,
|
||||
origin: 'worktree-sleep'
|
||||
}
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
[record.paneKey]: { ...record, automaticResumeBlockedBy }
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.sleepingAgentSessionsByPaneKey?.[record.paneKey]).toEqual(record)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('hydrates a persisted Kimi sleeping agent record', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
|
||||
@@ -99,7 +99,6 @@ const sleepingAgentSessionRecordSchema = z
|
||||
connectionId: z.string().nullable().optional(),
|
||||
launchConfig: sleepingAgentLaunchConfigSchema.optional(),
|
||||
origin: z.enum(['worktree-sleep', 'quit', 'live']).optional(),
|
||||
automaticResumeBlockedBy: z.enum(['legacy-orchestration-worker']).optional(),
|
||||
restoreOnTabOpenOnly: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
|
||||
@@ -60,18 +60,23 @@ process.stdin.resume()
|
||||
setInterval(() => {}, 60_000)
|
||||
`
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource)
|
||||
writeFileSync(
|
||||
path.join(fakeCliDir, 'codex.cmd'),
|
||||
'@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n'
|
||||
)
|
||||
} else {
|
||||
const executable = path.join(fakeCliDir, 'codex')
|
||||
writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`)
|
||||
chmodSync(executable, 0o755)
|
||||
function installCompletedWorkerFakeCodex(): void {
|
||||
mkdirSync(fakeCliDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource)
|
||||
writeFileSync(
|
||||
path.join(fakeCliDir, 'codex.cmd'),
|
||||
'@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n'
|
||||
)
|
||||
} else {
|
||||
const executable = path.join(fakeCliDir, 'codex')
|
||||
writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`)
|
||||
chmodSync(executable, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
installCompletedWorkerFakeCodex()
|
||||
|
||||
export const completedWorkerLaunchEnv = {
|
||||
PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
||||
ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath
|
||||
@@ -91,6 +96,8 @@ export type TerminalIdentity = Pick<
|
||||
>
|
||||
|
||||
export function clearCompletedWorkerLedger(): void {
|
||||
// Another spec can clean up this cached fixture before the next test uses it.
|
||||
installCompletedWorkerFakeCodex()
|
||||
rmSync(lifecycleLedgerPath, { force: true })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { DaemonClient } from '../../src/main/daemon/client'
|
||||
import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner'
|
||||
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { TEST_REPO_PATH_FILE } from './global-setup'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
waitForActivePaneHookDescriptor,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override'
|
||||
import {
|
||||
clearCompletedWorkerLedger,
|
||||
completedWorkerFakeCodexCommand,
|
||||
completedWorkerLaunchEnv,
|
||||
listRuntimeTerminals,
|
||||
readCompletedWorkerDispatchCapability,
|
||||
readCompletedWorkerLedger,
|
||||
seedCurrentCodexTranscript
|
||||
} from './helpers/completed-worker-retirement-fixture'
|
||||
import { RuntimeClient } from '../../src/cli/runtime-client'
|
||||
import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types'
|
||||
import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree/id'
|
||||
|
||||
const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dd'
|
||||
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
async function findSecondaryWorktree(
|
||||
page: Page,
|
||||
client: RuntimeClient,
|
||||
coordinatorWorktreeId: string
|
||||
): Promise<string> {
|
||||
let targetWorktreeId: string | null = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {})
|
||||
// The restart fixture only waits for the primary; refetch until the seeded secondary lands.
|
||||
const rendererWorktreeIds = await page.evaluate(async () => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
return []
|
||||
}
|
||||
await Promise.all(
|
||||
store.getState().repos.map((repo) => store.getState().fetchWorktrees(repo.id))
|
||||
)
|
||||
return Object.values(store.getState().worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
})
|
||||
targetWorktreeId =
|
||||
listed.result.worktrees.find(
|
||||
(worktree) =>
|
||||
worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id)
|
||||
)?.id ?? null
|
||||
return targetWorktreeId
|
||||
},
|
||||
{ timeout: 60_000, message: 'runtime never registered the secondary worktree' }
|
||||
)
|
||||
.not.toBeNull()
|
||||
if (!targetWorktreeId) {
|
||||
throw new Error('The seeded repository did not expose its secondary worktree')
|
||||
}
|
||||
return targetWorktreeId
|
||||
}
|
||||
|
||||
async function backgroundMountTab(page: Page, worktreeId: string, tabId: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
({ tabId, worktreeId }) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('orca-background-mount-terminal-worktree', {
|
||||
detail: { worktreeId, tabIds: [tabId] }
|
||||
})
|
||||
)
|
||||
},
|
||||
{ tabId, worktreeId }
|
||||
)
|
||||
await expect
|
||||
.poll(() => page.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), tabId))
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
function readPersistedSession(userDataDir: string) {
|
||||
return JSON.parse(
|
||||
readFileSync(
|
||||
path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'),
|
||||
'utf8'
|
||||
)
|
||||
).workspaceSession
|
||||
}
|
||||
|
||||
function expectNoPersistedWorkerFence(userDataDir: string, paneKey: string): void {
|
||||
const persisted = readPersistedSession(userDataDir)
|
||||
// Keep the baseline running through reveal even when it still writes the withdrawn policy.
|
||||
expect
|
||||
.soft(persisted.sleepingAgentSessionsByPaneKey?.[paneKey] ?? {})
|
||||
.not.toHaveProperty('automaticResumeBlockedBy')
|
||||
expect.soft(persisted.legacyWorkerResumeFencesByPaneKey ?? {}).not.toHaveProperty(paneKey)
|
||||
}
|
||||
|
||||
// A restored worker must attach through main so revealing it never fabricates a missing PTY.
|
||||
for (const daemonSessionGone of [false, true]) {
|
||||
test(`a settled worker tab survives restart with daemon session ${daemonSessionGone ? 'exited' : 'live'}`, async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
|
||||
{}, testInfo) => {
|
||||
test.setTimeout(300_000)
|
||||
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
|
||||
if (!repoPath || !existsSync(repoPath)) {
|
||||
test.skip(true, 'Global setup did not produce a seeded test repo')
|
||||
return
|
||||
}
|
||||
clearCompletedWorkerLedger()
|
||||
|
||||
const session = createRestartSession(testInfo, completedWorkerLaunchEnv)
|
||||
let firstApp: ElectronApplication | null = null
|
||||
let secondApp: ElectronApplication | null = null
|
||||
try {
|
||||
const first = await session.launch()
|
||||
firstApp = first.app
|
||||
const coordinatorWorktreeId = await attachRepoAndOpenTerminal(first.page, repoPath)
|
||||
await waitForSessionReady(first.page)
|
||||
await waitForActiveWorktree(first.page)
|
||||
await ensureTerminalVisible(first.page)
|
||||
await waitForActiveTerminalManager(first.page)
|
||||
await waitForActivePanePtyId(first.page)
|
||||
await first.page.evaluate(
|
||||
async ({ agentCommand, terminalWindowsShell }) => {
|
||||
await window.__store?.getState().updateSettings({
|
||||
agentCmdOverrides: { codex: agentCommand },
|
||||
terminalWindowsShell,
|
||||
disabledTuiAgents: [],
|
||||
terminalHiddenViewParking: false
|
||||
})
|
||||
},
|
||||
{
|
||||
agentCommand: completedWorkerFakeCodexCommand,
|
||||
terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL
|
||||
}
|
||||
)
|
||||
const isolatedHome = await firstApp.evaluate(({ app }) => app.getPath('home'))
|
||||
const client = new RuntimeClient(session.userDataDir, 30_000, null, null)
|
||||
const coordinatorPane = await waitForActivePaneHookDescriptor(first.page)
|
||||
const coordinatorHandle = (
|
||||
await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', {
|
||||
paneKey: coordinatorPane.paneKey
|
||||
})
|
||||
).result.terminal.handle
|
||||
const targetWorktreeId = await findSecondaryWorktree(
|
||||
first.page,
|
||||
client,
|
||||
coordinatorWorktreeId
|
||||
)
|
||||
const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath
|
||||
if (!targetWorktreePath) {
|
||||
throw new Error('The secondary worktree did not expose a filesystem path')
|
||||
}
|
||||
|
||||
const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', {
|
||||
objective: 'Keep one settled worker tab across restart',
|
||||
from: coordinatorHandle
|
||||
})
|
||||
const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', {
|
||||
spec: 'Report completion and stay open',
|
||||
run: run.result.run.id,
|
||||
callerTerminalHandle: coordinatorHandle
|
||||
})
|
||||
const started = await client.call<{
|
||||
dispatchId: string
|
||||
state: string
|
||||
effects: { kind: string; role?: string; id?: string }[]
|
||||
}>('orchestration.workerStart', {
|
||||
task: task.result.task.id,
|
||||
from: coordinatorHandle,
|
||||
worktree: `id:${targetWorktreeId}`,
|
||||
agent: 'codex',
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
expect(started.result.state).toBe('ready')
|
||||
const workerHandle = started.result.effects.find(
|
||||
(effect) => effect.kind === 'terminal' && effect.role === 'agent'
|
||||
)?.id
|
||||
if (!workerHandle) {
|
||||
throw new Error('worker-start did not return its agent terminal')
|
||||
}
|
||||
let worker: RuntimeTerminalSummary | undefined
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
worker = (await listRuntimeTerminals(client)).find(
|
||||
(terminal) => terminal.handle === workerHandle
|
||||
)
|
||||
return worker?.ptyId ?? null
|
||||
},
|
||||
{ timeout: 30_000, message: 'background worker never published its PTY identity' }
|
||||
)
|
||||
.not.toBeNull()
|
||||
if (!worker?.ptyId) {
|
||||
throw new Error('Background worker did not publish its PTY')
|
||||
}
|
||||
const workerPtyId = worker.ptyId
|
||||
const workerTabId = worker.tabId
|
||||
const workerPaneKey = `${worker.tabId}:${worker.leafId}`
|
||||
await backgroundMountTab(first.page, targetWorktreeId, workerTabId)
|
||||
let dispatchCapability: string | null = null
|
||||
await expect
|
||||
.poll(() => {
|
||||
dispatchCapability = readCompletedWorkerDispatchCapability()
|
||||
return dispatchCapability
|
||||
})
|
||||
.not.toBeNull()
|
||||
if (!dispatchCapability) {
|
||||
throw new Error('Background worker did not receive its dispatch capability')
|
||||
}
|
||||
const transcriptPath = seedCurrentCodexTranscript(
|
||||
isolatedHome,
|
||||
PROVIDER_SESSION_ID,
|
||||
targetWorktreePath
|
||||
)
|
||||
await first.page.evaluate(
|
||||
({
|
||||
agentCommand,
|
||||
paneKey,
|
||||
providerSessionId,
|
||||
tabId,
|
||||
terminalHandle,
|
||||
transcriptPath,
|
||||
worktreeId
|
||||
}) => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('Renderer store unavailable')
|
||||
}
|
||||
const metadata = { tabId, worktreeId, terminalHandle }
|
||||
const recovery = {
|
||||
providerSession: { key: 'session_id' as const, id: providerSessionId, transcriptPath },
|
||||
launchConfig: {
|
||||
agentCommand,
|
||||
agentArgs: '--dangerously-bypass-approvals-and-sandbox',
|
||||
agentEnv: {}
|
||||
}
|
||||
}
|
||||
for (const agentState of ['working', 'done'] as const) {
|
||||
state.setAgentStatus(
|
||||
paneKey,
|
||||
{ state: agentState, prompt: 'Report completion and stay open', agentType: 'codex' },
|
||||
'Settled background worker',
|
||||
undefined,
|
||||
metadata,
|
||||
recovery
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
agentCommand: completedWorkerFakeCodexCommand,
|
||||
paneKey: workerPaneKey,
|
||||
providerSessionId: PROVIDER_SESSION_ID,
|
||||
tabId: workerTabId,
|
||||
terminalHandle: workerHandle,
|
||||
transcriptPath,
|
||||
worktreeId: targetWorktreeId
|
||||
}
|
||||
)
|
||||
const completed = await client.call<{ message: { type: string } }>(
|
||||
'orchestration.send',
|
||||
{
|
||||
from: workerHandle,
|
||||
subject: 'Completed',
|
||||
body: 'The fixture completed and stays open for inspection.',
|
||||
type: 'worker_done',
|
||||
payload: JSON.stringify({
|
||||
taskId: task.result.task.id,
|
||||
dispatchId: started.result.dispatchId,
|
||||
outcome: 'succeeded'
|
||||
})
|
||||
},
|
||||
{ orchestrationCapability: dispatchCapability }
|
||||
)
|
||||
expect(completed.result.message.type).toBe('worker_done')
|
||||
const taskBeforeRestart = (
|
||||
await client.call('orchestration.taskList', { run: run.result.run.id })
|
||||
).result
|
||||
const dispatchBeforeRestart = (
|
||||
await client.call('orchestration.dispatchShow', { task: task.result.task.id })
|
||||
).result
|
||||
|
||||
await session.close(firstApp)
|
||||
firstApp = null
|
||||
expectNoPersistedWorkerFence(session.userDataDir, workerPaneKey)
|
||||
expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
const launchesBeforeRestart = readCompletedWorkerLedger().filter(
|
||||
(event) => event.event === 'spawn'
|
||||
)
|
||||
if (daemonSessionGone) {
|
||||
const daemonDir = path.join(session.userDataDir, 'daemon')
|
||||
const daemon = new DaemonClient({
|
||||
socketPath: getDaemonSocketPath(daemonDir),
|
||||
tokenPath: getDaemonTokenPath(daemonDir)
|
||||
})
|
||||
try {
|
||||
await daemon.ensureConnected()
|
||||
await daemon.request('kill', { sessionId: workerPtyId, immediate: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const result = await daemon.request<{ sessions: { sessionId: string }[] }>(
|
||||
'listSessions',
|
||||
undefined
|
||||
)
|
||||
return result.sessions.some((entry) => entry.sessionId === workerPtyId)
|
||||
})
|
||||
.toBe(false)
|
||||
} finally {
|
||||
daemon.disconnect()
|
||||
}
|
||||
}
|
||||
const second = await session.launch()
|
||||
secondApp = second.app
|
||||
await waitForSessionReady(second.page)
|
||||
if (!daemonSessionGone) {
|
||||
// The restarted runtime must rediscover the daemon-owned worker before reveal.
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await listRuntimeTerminals(client)).find(
|
||||
(terminal) => terminal.ptyId === workerPtyId
|
||||
)?.connected ?? null,
|
||||
{ timeout: 60_000, message: 'restarted runtime never rediscovered the worker PTY' }
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
expect(
|
||||
await second.page.evaluate(
|
||||
({ tabId, worktreeId }) =>
|
||||
Boolean(
|
||||
window.__store?.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)
|
||||
),
|
||||
{ tabId: workerTabId, worktreeId: targetWorktreeId }
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
// Hidden mount, then click to reveal: reveal runs the missing-session reconciler.
|
||||
await backgroundMountTab(second.page, targetWorktreeId, workerTabId)
|
||||
// Poll, don't sample: main's cache learns the session when the pane's deferred reattach lands,
|
||||
// and backgroundMountTab only waits for the pane manager to exist. A restarted main that never
|
||||
// attaches stays false for the whole window, which is the regression this guards.
|
||||
if (!daemonSessionGone) {
|
||||
await expect
|
||||
.configure({ soft: true })
|
||||
.poll(() => second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), workerPtyId), {
|
||||
timeout: 20_000,
|
||||
message: 'liveness before reveal'
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
await second.page.evaluate(
|
||||
({ tabId, worktreeId }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Renderer store unavailable')
|
||||
}
|
||||
type Transition = {
|
||||
activeWorktreeId: string | null
|
||||
tabPresent: boolean
|
||||
leafPtyIds: string[]
|
||||
activeTabId: string | null
|
||||
}
|
||||
const snapshot = (state: ReturnType<typeof store.getState>): Transition => ({
|
||||
activeWorktreeId: state.activeWorktreeId ?? null,
|
||||
tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)),
|
||||
leafPtyIds: Object.values(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}),
|
||||
activeTabId: state.activeTabIdByWorktree[worktreeId] ?? null
|
||||
})
|
||||
const transitions: Transition[] = [snapshot(store.getState())]
|
||||
const e2eWindow = window as typeof window & { __orcaRevealTransitions?: Transition[] }
|
||||
e2eWindow.__orcaRevealTransitions = transitions
|
||||
store.subscribe((state) => {
|
||||
const next = snapshot(state)
|
||||
if (JSON.stringify(next) !== JSON.stringify(transitions.at(-1))) {
|
||||
transitions.push(next)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ tabId: workerTabId, worktreeId: targetWorktreeId }
|
||||
)
|
||||
await second.page
|
||||
.locator(`[role="option"][data-worktree-id="${targetWorktreeId}"]`)
|
||||
.first()
|
||||
.click()
|
||||
const visibleTab = second.page
|
||||
.locator(`[data-testid="sortable-tab"][data-tab-id="${workerTabId}"]`)
|
||||
.first()
|
||||
await visibleTab.click({ timeout: 10_000 })
|
||||
await expect(visibleTab).toBeVisible()
|
||||
await ensureTerminalVisible(second.page)
|
||||
// Give the reconciler's async verdict time to land; the tab must never have left.
|
||||
await second.page.waitForTimeout(3_000)
|
||||
const transitions = await second.page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__orcaRevealTransitions?: {
|
||||
activeWorktreeId: string | null
|
||||
tabPresent: boolean
|
||||
leafPtyIds: string[]
|
||||
}[]
|
||||
}
|
||||
).__orcaRevealTransitions ?? []
|
||||
)
|
||||
// Pre-fix this read: leaf binding cleared -> tab removed -> worktree deselected -> tab re-added by graph sync.
|
||||
expect(
|
||||
transitions.filter(
|
||||
(step) => !step.tabPresent || (!daemonSessionGone && step.leafPtyIds.length === 0)
|
||||
),
|
||||
'reveal must not tear the settled worker tab down'
|
||||
).toEqual([])
|
||||
expect(transitions.at(-1)?.activeWorktreeId).toBe(targetWorktreeId)
|
||||
expect(
|
||||
await second.page.evaluate(
|
||||
(tabId) => Boolean(window.__paneManagers?.get(tabId)),
|
||||
workerTabId
|
||||
)
|
||||
).toBe(true)
|
||||
if (!daemonSessionGone) {
|
||||
expect(
|
||||
(await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId)
|
||||
?.connected
|
||||
).toBe(true)
|
||||
expect(
|
||||
readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')
|
||||
).toEqual([])
|
||||
}
|
||||
const newLaunches = readCompletedWorkerLedger()
|
||||
.filter((event) => event.event === 'spawn')
|
||||
.slice(launchesBeforeRestart.length)
|
||||
if (daemonSessionGone) {
|
||||
expect(newLaunches.length).toBeLessThanOrEqual(1)
|
||||
for (const launch of newLaunches) {
|
||||
// Codex's --resume equivalent is the `resume <session-id>` subcommand.
|
||||
expect(launch.args).toContain('resume')
|
||||
expect(launch.args).toContain(PROVIDER_SESSION_ID)
|
||||
}
|
||||
const listed = await client.call<{
|
||||
workers: { dispatchId: string; terminalState: string; workerState: string }[]
|
||||
}>('orchestration.workerList', { run: run.result.run.id })
|
||||
await testInfo.attach('resumed-worker-accounting', {
|
||||
body: JSON.stringify({ newLaunches, workers: listed.result.workers }),
|
||||
contentType: 'application/json'
|
||||
})
|
||||
expect(listed.result.workers).toEqual([
|
||||
expect.objectContaining({
|
||||
dispatchId: started.result.dispatchId,
|
||||
terminalState: 'retained',
|
||||
workerState: 'succeeded'
|
||||
})
|
||||
])
|
||||
} else {
|
||||
expect(newLaunches).toEqual([])
|
||||
expect(
|
||||
await second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), workerPtyId)
|
||||
).toBe(true)
|
||||
}
|
||||
expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(
|
||||
(await client.call('orchestration.taskList', { run: run.result.run.id })).result
|
||||
).toEqual(taskBeforeRestart)
|
||||
expect(
|
||||
(await client.call('orchestration.dispatchShow', { task: task.result.task.id })).result
|
||||
).toEqual(dispatchBeforeRestart)
|
||||
await expect(visibleTab).toBeVisible()
|
||||
const paneKeys = await second.page.evaluate((tabId) => {
|
||||
const layout = window.__store?.getState().terminalLayoutsByTabId[tabId]
|
||||
const leaves: string[] = []
|
||||
const visit = (node: NonNullable<typeof layout>['root']) => {
|
||||
if (node.type === 'leaf') {
|
||||
leaves.push(`${tabId}:${node.leafId}`)
|
||||
} else {
|
||||
visit(node.first)
|
||||
visit(node.second)
|
||||
}
|
||||
}
|
||||
if (layout?.root) {
|
||||
visit(layout.root)
|
||||
}
|
||||
return leaves
|
||||
}, workerTabId)
|
||||
expect(paneKeys).toContain(workerPaneKey)
|
||||
expect(
|
||||
await secondApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows().map((window) => ({
|
||||
visible: window.isVisible(),
|
||||
focused: window.isFocused()
|
||||
}))
|
||||
)
|
||||
).toEqual([{ visible: false, focused: false }])
|
||||
await second.page.screenshot({ path: testInfo.outputPath('settled-worker-revealed.png') })
|
||||
await session.close(secondApp)
|
||||
secondApp = null
|
||||
const persisted = readPersistedSession(session.userDataDir)
|
||||
expectNoPersistedWorkerFence(session.userDataDir, workerPaneKey)
|
||||
expect(
|
||||
persisted.tabsByWorktree[targetWorktreeId].some(
|
||||
(tab: { id: string }) => tab.id === workerTabId
|
||||
)
|
||||
).toBe(true)
|
||||
expect(persisted.terminalLayoutsByTabId[workerTabId]).toBeDefined()
|
||||
if (!daemonSessionGone) {
|
||||
expect(
|
||||
Object.values(persisted.terminalLayoutsByTabId[workerTabId].ptyIdsByLeafId)
|
||||
).toContain(workerPtyId)
|
||||
}
|
||||
} finally {
|
||||
if (secondApp) {
|
||||
await session.close(secondApp)
|
||||
}
|
||||
if (firstApp) {
|
||||
await session.close(firstApp)
|
||||
}
|
||||
await session.dispose()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user