Merge remote-tracking branch 'origin/main' into fix-mobile-takeover-r3

# Conflicts:
#	src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts
This commit is contained in:
Jinwoo-H
2026-09-08 05:21:36 -04:00
75 changed files with 1011 additions and 1616 deletions
+1 -1
View File
@@ -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
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 42m">
<title>downloads: 42m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 43m">
<title>downloads: 43m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">42m</text>
<text x="90" y="14">42m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">43m</text>
<text x="90" y="14">43m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

@@ -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`)
@@ -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
}
@@ -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
@@ -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'
)
@@ -334,10 +334,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'],
@@ -451,9 +448,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([])
@@ -483,7 +478,6 @@ describe('OrcaRuntimeService', () => {
try {
const runtime = new OrcaRuntimeService(store)
const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({
blockedPaneCount: 1,
adoptedDispatchIds: [],
exitedDispatchIds: [],
deferredDispatchIds: []
@@ -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}`)
})
})
@@ -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,
@@ -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({ runId: 'run_legacy_local', 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 { recordWorkerTerminalUserTakeover } from '../../worker-terminal-user-takeover'
export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [
defineMethod({
@@ -155,7 +154,10 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [
(params.sessionId
? runtime.getStructuredWorkerPaneKeyForSession(params.sessionId)
: runtime.getTerminalPaneKey(params.terminal!))
return { changed: recordWorkerTerminalUserTakeover(runtime, paneKey) }
const changed = paneKey
? runtime.getOrchestrationDb().markWorkerTerminalUserOwned(paneKey)
: 0
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
}
}
}
@@ -1,17 +0,0 @@
import type { OrcaRuntimeService } from '../../orca-runtime'
import { sweepSettledWorkerResumeFences } from './settled-worker-resume-fence-sweep'
// Client reports own throttling; the host must observe every resource ownership population.
export function recordWorkerTerminalUserTakeover(
runtime: OrcaRuntimeService,
paneKey: string | null | undefined
): number {
if (!paneKey) {
return 0
}
const changed = runtime.getOrchestrationDb().markWorkerTerminalUserOwned(paneKey)
if (changed > 0) {
sweepSettledWorkerResumeFences(runtime)
}
return changed
}
@@ -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({ runId: 'run_legacy_local', 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,
-4
View File
@@ -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
-10
View File
@@ -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. */
@@ -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')
@@ -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,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}`
)
@@ -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
@@ -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.
@@ -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',
-9
View File
@@ -8627,15 +8627,6 @@
"fastDescription": "Extra multiplier while scrolling with a modifier key.",
"tui": "TUI",
"tuiDescription": "Discrete wheel reports for full-screen terminal apps."
},
"minimumContrast": {
"title": "Minimum Contrast Ratio",
"description": "Lifts terminal foreground colors that sit too close to the background. Leave blank for automatic, or set 1 to render program colors exactly as sent.",
"automatic": "Automatic: {{light}} on light backgrounds, {{dark}} on dark.",
"disabled": "Correction off. Programs that rely on low contrast, like Powerline separators, render as sent.",
"pinned": "Targets {{ratio}}:1 contrast for foreground colors, where possible.",
"placeholder": "Auto",
"suffix": "blank = automatic, 1 = off"
}
},
"TerminalSettingsPreview": {
@@ -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: () => {},
-3
View File
@@ -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(
@@ -434,17 +434,11 @@ describe('completed background-worker retirement resume matrix', () => {
expect(retiredRestart.tabsByWorktree[WORKTREE_ID]).toEqual([])
expect(retiredRestart.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeUndefined()
// Case 4: legacy rollback preserves a fenced record; exited resolution clears it.
// Case 4: legacy rollback preserves the settled worker's record as an ordinary sleeping
// record; with its tab gone it is passive completed evidence that wake clears, and an exited
// resolution clears it too. No fence: a finished worker follows the same rule as any agent pane.
seedWorkspace()
const legacyRecord = recordCompletedWorker()
useAppStore.setState({
sleepingAgentSessionsByPaneKey: {
[ORIGINAL_PANE_KEY]: {
...legacyRecord,
automaticResumeBlockedBy: 'legacy-orchestration-worker'
}
}
})
recordCompletedWorker()
const legacyAction = resolveLegacyWorkerTerminalRecoveryAction({
paneKey: ORIGINAL_PANE_KEY,
resolution: 'rolled_back',
@@ -456,17 +450,19 @@ describe('completed background-worker retirement resume matrix', () => {
rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), legacyAction.detail)
).toBe('removed')
}
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({
state: 'done'
})
expect(
useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]
?.automaticResumeBlockedBy
).toBe('legacy-orchestration-worker')
).not.toHaveProperty('automaticResumeBlockedBy')
expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0)
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined()
const exitedAction = resolveLegacyWorkerTerminalRecoveryAction({
paneKey: ORIGINAL_PANE_KEY,
resolution: 'exited'
})
expect(exitedAction).toEqual({ kind: 'clear-sleeping', paneKey: ORIGINAL_PANE_KEY })
useAppStore.getState().clearSleepingAgentSession(ORIGINAL_PANE_KEY)
// Case 5: coordinator manual close is the same safe exact-tab retirement boundary.
seedWorkspace()
@@ -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()
}
})
}