diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index e1831ff3443..1abef2d7e6b 100644 --- a/src/main/orca-profiles/profile-project-session-field-disposition.ts +++ b/src/main/orca-profiles/profile-project-session-field-disposition.ts @@ -109,6 +109,13 @@ export const WORKSPACE_SESSION_FIELD_DISPOSITION = { // the record's worktreeId on worktree and project removal. Moving a project between profiles runs // removeSourceRepo, which has no owner scan, so these records leak there. sleepingAgentSessionsByPaneKey: { onRepoRemoval: 'notRepoScoped', onTransfer: 'notTransferred' }, + // Why not transferred: the fence is a projection of the source profile's orchestration DB, and a + // transferred project carries no dispatch rows to re-derive it from. The destination runtime + // re-stamps whatever its own DB still claims on its first recovery pass. + legacyWorkerResumeFencesByPaneKey: { + onRepoRemoval: 'prunedByBespokeRule', + onTransfer: 'notTransferred' + }, terminalPtyIncarnationsByPaneKey: { onRepoRemoval: 'prunedByBespokeRule', onTransfer: 'copiedByBespokeRule' diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index 5c54af92975..e3571c872af 100644 --- a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts +++ b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts @@ -49,6 +49,7 @@ export const WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND = { lastVisitedAtByWorktreeId: 'owner-keyed', defaultTerminalTabsAppliedByWorktreeId: 'owner-keyed', sleepingAgentSessionsByPaneKey: 'row-record', + legacyWorkerResumeFencesByPaneKey: 'none', terminalPtyIncarnationsByPaneKey: 'none', terminalTopologyRevisionByRepoId: 'none', terminalSurfaceTombstonesByPaneKey: 'row-record', diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.test.ts b/src/main/persistence/runtime-authored-workspace-session-fields.test.ts index 4badf344d41..57d613a24c9 100644 --- a/src/main/persistence/runtime-authored-workspace-session-fields.test.ts +++ b/src/main/persistence/runtime-authored-workspace-session-fields.test.ts @@ -57,6 +57,50 @@ describe('preserving runtime-authored workspace session fields', () => { expect(next.clientHostedBrowserPagesByWorktree).toEqual({ 'repo-1::wt-a': [row] }) }) + // The settled-worker resume fence is the second runtime-authored field, and this is the property + // the whole design rests on: `sleepingAgentSessionsByPaneKey` is a field the renderer co-authors, + // so storing the fence there let an ordinary session write erase it on disk. Here the renderer + // cannot name the field at all, and a write that omits it inherits the runtime's set. + it('carries the settled-worker resume fences across a renderer write', () => { + const fences = { 'tab-1:leaf-1': true } as const + const prior: WorkspaceSessionState = { + ...session(), + legacyWorkerResumeFencesByPaneKey: { ...fences } + } + + const next = preserveRuntimeAuthoredWorkspaceSessionFields(session(), prior) + + expect(next.legacyWorkerResumeFencesByPaneKey).toEqual(fences) + }) + + it('lets the runtime retire a fence by writing an empty set', () => { + const prior: WorkspaceSessionState = { + ...session(), + legacyWorkerResumeFencesByPaneKey: { 'tab-1:leaf-1': true } + } + const cleared: WorkspaceSessionState = { + ...session(), + legacyWorkerResumeFencesByPaneKey: {} + } + + expect( + preserveRuntimeAuthoredWorkspaceSessionFields(cleared, prior) + .legacyWorkerResumeFencesByPaneKey + ).toEqual({}) + }) + + it('preserves both runtime-authored fields in one write', () => { + const prior: WorkspaceSessionState = { + ...session({ 'repo-1::wt-a': [row] }), + legacyWorkerResumeFencesByPaneKey: { 'tab-1:leaf-1': true } + } + + const next = preserveRuntimeAuthoredWorkspaceSessionFields(session(), prior) + + expect(next.clientHostedBrowserPagesByWorktree).toEqual({ 'repo-1::wt-a': [row] }) + expect(next.legacyWorkerResumeFencesByPaneKey).toEqual({ 'tab-1:leaf-1': true }) + }) + it('leaves an untouched write alone rather than inventing a field', () => { const next = session() diff --git a/src/main/persistence/runtime-authored-workspace-session-fields.ts b/src/main/persistence/runtime-authored-workspace-session-fields.ts index a07454776cd..6deaf57009e 100644 --- a/src/main/persistence/runtime-authored-workspace-session-fields.ts +++ b/src/main/persistence/runtime-authored-workspace-session-fields.ts @@ -1,12 +1,19 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +/** Session fields written by the runtime authority and never by a renderer. */ +const RUNTIME_AUTHORED_FIELDS = [ + 'clientHostedBrowserPagesByWorktree', + 'legacyWorkerResumeFencesByPaneKey' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + /** * Keeps runtime-authored session state alive across a renderer's full write. * * A session write replaces the stored object, and the renderer builds its payload from Zustand -- * which has no idea the runtime authority sharing this profile also persists the client-hosted - * pages it owns. Without this, every ordinary desktop session write erases them, and the loss only - * shows up a restart later when there is nothing left to rehydrate. + * pages and settled-worker resume fences it owns. Without this, every ordinary desktop session + * write erases them, and the loss only shows up a restart later when there is nothing left to + * rehydrate. * * Callers do not opt in: the Store applies this inside setLocalWorkspaceSession and * setHostWorkspaceSession, so the before-unload stage path inherits it too. Guarding the individual @@ -19,14 +26,13 @@ export function preserveRuntimeAuthoredWorkspaceSessionFields( next: WorkspaceSessionState, prior: WorkspaceSessionState | null | undefined ): WorkspaceSessionState { - if ( - next.clientHostedBrowserPagesByWorktree !== undefined || - prior?.clientHostedBrowserPagesByWorktree === undefined - ) { - return next - } - return { - ...next, - clientHostedBrowserPagesByWorktree: prior.clientHostedBrowserPagesByWorktree + let preserved: WorkspaceSessionState | undefined + for (const field of RUNTIME_AUTHORED_FIELDS) { + if (next[field] !== undefined || prior?.[field] === undefined) { + continue + } + preserved ??= { ...next } + preserved[field] = prior[field] as never } + return preserved ?? next } diff --git a/src/main/runtime/orca-runtime-fence-automation-owner.ts b/src/main/runtime/orca-runtime-fence-automation-owner.ts index a90730c7886..6142ef98640 100644 --- a/src/main/runtime/orca-runtime-fence-automation-owner.ts +++ b/src/main/runtime/orca-runtime-fence-automation-owner.ts @@ -20,6 +20,7 @@ import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { makePaneKey } from '../../shared/stable-pane-id' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' @@ -171,6 +172,18 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg return this.legacyWorkerRecovery.prepare() } + /** The fenced-pane set across every host, unioned: pane keys are tab-scoped UUIDs, so they + * cannot collide between hosts, and the renderer asks about a pane without knowing its host. */ + getLegacyWorkerResumeFences(): Record { + const store = this.store + const hostIds = store?.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + const fences: Record = {} + for (const hostId of hostIds) { + Object.assign(fences, store?.getWorkspaceSession?.(hostId)?.legacyWorkerResumeFencesByPaneKey) + } + return fences + } + protected async flushWorkspaceSessionOrThrowAsync(): Promise { const store = this.store if (store?.flushPendingOrThrowAsync) { diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index b0ce7d966b3..4dd1620bed5 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -137,13 +137,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin () => this.store, () => this.getOrchestrationDb(), (worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId), - (paneKey, blocked, generation) => - this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked, generation) + () => this.notifier?.legacyWorkerTerminalResumeFencesChanged?.() ) protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({ preparePlan: () => this.legacyWorkerRecoveryPersistence.prepare(), - committedFenceSnapshot: () => this.legacyWorkerRecoveryPersistence.committedFenceSnapshot(), resolveWorkspace: async (candidate) => { const scope = await this.resolveTerminalWorkspaceLaunchScope(`id:${candidate.worktreeId}`) const resolved = scope.folderWorkspace diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 3c61985e597..c6923694972 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -527,9 +527,7 @@ describe('OrcaRuntimeService', () => { } as never) runtime.prepareLegacyWorkerTerminalRecovery() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) const recovered = await runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts index 44edd9aa371..caa020f0e6e 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts @@ -526,9 +526,7 @@ describe('OrcaRuntimeService', () => { }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2) expect(revealTerminalSession).toHaveBeenCalledOnce() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe( 'concurrent-tab' ) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts index 747a2e1357a..d16f82be26c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts @@ -329,9 +329,7 @@ describe('OrcaRuntimeService', () => { } as never) runtime.prepareLegacyWorkerTerminalRecovery() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ adoptedDispatchIds: ['dispatch-exited-two'], @@ -445,9 +443,7 @@ describe('OrcaRuntimeService', () => { exitedDispatchIds: [], deferredDispatchIds: ['dispatch-inventory-unavailable'] }) - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled() expect(listProcesses).toHaveBeenCalledOnce() expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) @@ -478,7 +474,6 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store) const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({ blockedPaneCount: 1, - fenceSnapshot: { generation: 0, blockedPaneKeys: [] }, adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: [] diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 5798916570e..3b1b6762fb8 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -150,10 +150,9 @@ describe('OrcaRuntimeService', () => { expect(listProcesses).toHaveBeenCalledOnce() expect(listProcesses).toHaveBeenCalledWith(null, LIST_PROVIDER_DEADLINE) for (const { name, leafId } of cases.slice(0, 2)) { - expect( - getSession().sleepingAgentSessionsByPaneKey?.[`legacy-${name}:${leafId}`] - ?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[`legacy-${name}:${leafId}`]).toBe( + true + ) } for (const { name, leafId } of cases.slice(2)) { expect( @@ -377,9 +376,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({ blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey })] }) - expect( - sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(sshSession.legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() await expect( runtime.reconcileLegacyWorkerTerminals({ @@ -485,9 +482,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({ blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey, worktreeId })] }) - expect( - remoteSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(remoteSession.legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() expect(setWorkspaceSession).toHaveBeenCalledOnce() expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 4078a291ab5..6fb536dd8bf 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -153,9 +153,7 @@ describe('OrcaRuntimeService', () => { deferredDispatchIds: ['dispatch-ssh'] }) expect(listProcesses).not.toHaveBeenCalled() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) @@ -297,9 +295,7 @@ describe('OrcaRuntimeService', () => { exitedDispatchIds: [], deferredDispatchIds: ['dispatch-wsl'] }) - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().legacyWorkerResumeFencesByPaneKey?.[workerPaneKey]).toBe(true) expect(revealTerminalSession).not.toHaveBeenCalled() observedDistro = 'Ubuntu' diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index 4c62d2c5f78..e89e7f9eaf2 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' @@ -20,160 +19,89 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { 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, - generation: number - ) => void + /** Invalidation only. The state itself lives in the session field; a push that carried it is + * what made the fence lossy across renderer reloads. */ + private readonly notifyFenceChanged?: () => void ) {} - /** The blocked set this object has actually committed and published, which is what a startup - * reply must report — the raw plan is not, because a pass whose session write threw published - * nothing. It also supplies the lift edge for a pane with no sleeping record, which - * `liftRetiredFences` cannot sweep. */ - private lastPlanBlockedPaneKeys: ReadonlySet = new Set() - - /** Counts commits so a startup reply can be ordered against the live pushes. */ - private fenceGeneration = 0 - - committedFenceSnapshot(): LegacyWorkerResumeFenceSnapshot { - return { generation: this.fenceGeneration, blockedPaneKeys: [...this.lastPlanBlockedPaneKeys] } - } - + /** + * Writes the whole fenced-pane set for every host on every pass. Level-triggered on purpose: + * there is no edge to miss, nothing to announce once, and no bookkeeping to keep in step with + * the store, so a renderer that reloads simply reads the field again. + * + * `legacyWorkerResumeFencesByPaneKey` is runtime-authored and absent from the renderer's patch + * builder, so this write is the only author and a renderer session write cannot erase it. + */ 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. + // An unreadable plan is not evidence that any pane stopped needing its fence: write nothing + // and retry on the next pass, leaving the previously written set in place. return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } } const store = this.getStore() - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { + if (!store?.getWorkspaceSession || !store.setWorkspaceSession) { return plan } - const sessions = new Map< - ExecutionHostId, - { current: WorkspaceSessionState; next: WorkspaceSessionState } - >() - const changedHostIds = new Set() - const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey)) - const fenceChanges = new Map() + const hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + // Why seeded from the listed hosts: a host that no longer owns any fenced pane still has to be + // written with an empty set, or its last set would stay pinned forever. + const fencedByHost = new Map>( + hostIds.map((hostId) => [hostId, {}]) + ) 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. - // Announced every pass because the renderer's map is volatile — a once-per-process push - // dies on reload and never reaches a client that pairs later. The renderer is idempotent. - fenceChanges.set(blocked.paneKey, true) - let hostIds: ExecutionHostId[] + let owners: ExecutionHostId[] try { const hostId = this.getHostId(blocked.worktreeId) if (!hostId) { throw new Error('folder_workspace_not_found') } - hostIds = [hostId] + owners = [hostId] } catch (error) { + // An owner this store cannot name is written to whichever partition already retains this + // pane, and to every host only when none does. Losing the fence relaunches a worker that + // is still running, so the fallback widens rather than skipping. console.warn('[orchestration] legacy worker resume fence owner is unavailable', { worktreeId: blocked.worktreeId, error }) - hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + const retaining = hostIds.filter((hostId) => { + const session = store.getWorkspaceSession?.(hostId) + return ( + session?.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] !== undefined || + session?.legacyWorkerResumeFencesByPaneKey?.[blocked.paneKey] === true + ) + }) + owners = retaining.length > 0 ? retaining : hostIds } - 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) + for (const hostId of owners) { + // An owner outside the listed hosts still gets its own entry; the list is a floor. + const fenced = fencedByHost.get(hostId) ?? {} + fenced[blocked.paneKey] = true + fencedByHost.set(hostId, fenced) } } - this.liftRetiredFences(store, blockedPaneKeys, sessions, changedHostIds, fenceChanges) - const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) + let changed = false try { - for (const [hostId, state] of changed) { - store.setWorkspaceSession(state.next, hostId) + for (const [hostId, fenced] of fencedByHost) { + const current = store.getWorkspaceSession(hostId) + if (!current || sameFenceSet(current.legacyWorkerResumeFencesByPaneKey, fenced)) { + continue + } + store.setWorkspaceSession({ ...current, legacyWorkerResumeFencesByPaneKey: fenced }, hostId) + changed = true } } catch (error) { - // Why after the write: a staging failure must not consume the announce or the lift edge, - // or a record-less pane would keep a fence no later pass could ever deliver or retire. - console.warn('[orchestration] failed to stage legacy worker resume fence', error) + // A failed write publishes nothing, and the next pass rewrites the same level. + console.warn('[orchestration] failed to write legacy worker resume fences', error) return plan } - this.lastPlanBlockedPaneKeys = blockedPaneKeys - this.fenceGeneration += 1 - for (const [paneKey, blocked] of fenceChanges) { - this.notifyFenceChanged?.(paneKey, blocked, this.fenceGeneration) + if (changed) { + this.notifyFenceChanged?.() } 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, - blockedPaneKeys: ReadonlySet, - sessions: Map, - changedHostIds: Set, - fenceChanges: Map - ): void { - for (const paneKey of this.lastPlanBlockedPaneKeys) { - if (!blockedPaneKeys.has(paneKey)) { - fenceChanges.set(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.set(paneKey, false) - } - state.next.sleepingAgentSessionsByPaneKey = next - changedHostIds.add(hostId) - } - } - async persist( resolutions: readonly LegacyWorkerRecoveryResolution[] ): Promise> { @@ -286,3 +214,17 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { throw new Error('workspace_session_persistence_unavailable') } } + +/** Identity is not enough: `prepare` rebuilds the set every pass, so compare by content or every + * pass would rewrite the session and wake every session subscriber. */ +function sameFenceSet( + current: Record | undefined, + next: Record +): boolean { + const currentKeys = Object.keys(current ?? {}) + const nextKeys = Object.keys(next) + return ( + currentKeys.length === nextKeys.length && + nextKeys.every((paneKey) => current?.[paneKey] === true) + ) +} diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index aeb5574d060..bd15abc7d4d 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -105,9 +105,6 @@ export async function runLegacyWorkerTerminalRecovery( } const result = { blockedPaneCount: plan.blockedPanes.length, - // Why re-read: `plan` predates the awaits above, and a release/retain/takeover sweep can retire - // a fence inside that window. Reporting the stale plan would re-fence a retired pane. - fenceSnapshot: ports.committedFenceSnapshot(), adoptedDispatchIds, exitedDispatchIds, deferredDispatchIds: [...deferredDispatchIds] diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts index 422e659bfd5..c65afd73952 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' @@ -7,9 +6,6 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' export type LegacyWorkerTerminalRecoveryResult = { blockedPaneCount: number - /** Main's committed fence state as of the END of this pass, so a renderer seeding from it cannot - * reapply a fence a release/takeover retired while the pass was awaiting its terminal work. */ - fenceSnapshot: LegacyWorkerResumeFenceSnapshot adoptedDispatchIds: string[] exitedDispatchIds: string[] deferredDispatchIds: string[] @@ -44,8 +40,6 @@ export type LegacyWorkerRecoveryInventory = PtyControllerInventory export type LegacyWorkerRecoveryPorts = { preparePlan: () => LegacyWorkerTerminalRecoveryPlan - /** Read after the pass finishes; never derived from the plan the pass started with. */ - committedFenceSnapshot: () => LegacyWorkerResumeFenceSnapshot resolveWorkspace: ( candidate: LegacyWorkerRecoveryCandidate ) => Promise diff --git a/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts b/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts index b364cd4a9c8..f5907e0334e 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts @@ -6,8 +6,6 @@ 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 { runLegacyWorkerTerminalRecovery } from './runtime-legacy-worker-terminal-recovery-runner' -import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' import type { RuntimeStore } from './runtime-store-contract' const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333' @@ -39,15 +37,16 @@ describe('settled worker automatic-resume fence persistence', () => { 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. */ + onFencesChanged?: () => void, + /** False models a worker that settles while its tab is still open: no sleeping record. */ withSleepingRecord = true ): { db: OrchestrationDb taskId: string dispatchId: string persistence: RuntimeLegacyWorkerTerminalRecoveryPersistence - fence: () => string | undefined + fences: () => Record + recordFlag: () => string | undefined } { const orchestrationDb = new OrchestrationDb(':memory:') db = orchestrationDb @@ -88,9 +87,10 @@ describe('settled worker automatic-resume fence persistence', () => { () => store, () => orchestrationDb, () => LOCAL_EXECUTION_HOST_ID, - onFenceChanged + onFencesChanged ), - fence: () => session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy + fences: () => session.legacyWorkerResumeFencesByPaneKey ?? {}, + recordFlag: () => session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy } } @@ -100,402 +100,88 @@ describe('settled worker automatic-resume fence persistence', () => { ).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] - ]) - }) - - // A renderer's blocked-pane map starts empty on every boot (reload, crash restart, asar swap, - // a new window). A once-per-process announcement left the reloaded renderer unfenced, so it - // minted an unfenced record on close and worktree activation respawned the settled worker. - it('re-announces the fence on every pass so a reloaded renderer is fenced again', () => { - 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]]) - - fenceChanges.length = 0 - const plan = h.persistence.prepare() - - expect(plan.blockedPanes).toEqual([expect.objectContaining({ paneKey: PANE_KEY })]) - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - }) - - // Headless `orca serve` / orcad runs the same recovery with no window notifier. Burning the - // announcement there meant a client that paired afterwards never learned of the fence. - it('still announces to a client that attaches after a headless recovery pass', () => { - let notifierAttached = false - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => { - if (notifierAttached) { - fenceChanges.push([paneKey, blocked]) - } - }, false) - settle(h.db, h.taskId, h.dispatchId) - - h.persistence.prepare() - expect(fenceChanges).toEqual([]) - - notifierAttached = true - h.persistence.prepare() - - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - }) - - // A staging failure must not consume the announcement: the pane stays fenced in the plan, so - // the next pass has to deliver it rather than leave a live renderer permanently unfenced. - it('redelivers the announcement after a failed session write', () => { - const fenceChanges: [string, boolean][] = [] - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID, - (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - persistence.prepare() - } finally { - warn.mockRestore() - } - expect(fenceChanges).toEqual([]) - - failWrite = false - persistence.prepare() - - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - 'legacy-orchestration-worker' - ) - }) - - // A pass reads its plan up front and then awaits workspace resolution, inventory and persistence. - // A release/takeover sweep can retire a fence inside that window, so reporting the plan the pass - // started with would hand a starting renderer a fence the authority had already retired. - it('reports the fence state committed at the end of the pass, not the plan it started with', async () => { - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false) - settle(h.db, h.taskId, h.dispatchId) - let releasePersist!: () => void - const ports = { - preparePlan: () => h.persistence.prepare(), - committedFenceSnapshot: () => h.persistence.committedFenceSnapshot(), - persist: () => - new Promise>((resolve) => { - releasePersist = () => resolve(new Set()) - }), - updateRetry: () => {}, - reconcileRequestedReleases: async () => {} - } as unknown as LegacyWorkerRecoveryPorts - const pass = runLegacyWorkerTerminalRecovery({} as never, ports, {}) - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - - // The release lands while the pass is still awaiting its terminal work. - const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) - h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) - h.persistence.prepare() - releasePersist() - - const result = await pass - expect(result.fenceSnapshot.blockedPaneKeys).toEqual([]) - expect(fenceChanges).toEqual([ - [PANE_KEY, true], - [PANE_KEY, false] - ]) - }) - - // A pass whose session write threw published nothing, so it must report the previous committed - // state. Reporting the uncommitted plan would fence a pane through the reply that the push - // channel never announced — and that no later lift could retire. - it('reports nothing new when the session write failed', () => { - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - persistence.prepare() - } finally { - warn.mockRestore() - } - expect(persistence.committedFenceSnapshot()).toEqual({ generation: 0, blockedPaneKeys: [] }) - - failWrite = false - persistence.prepare() - - expect(persistence.committedFenceSnapshot()).toEqual({ - generation: 1, - blockedPaneKeys: [PANE_KEY] - }) - }) - - // The lift edge for a pane with no record lives in the committed set, so a failed write must not - // consume it: the pane must still be retired once the plan drops it after a successful pass. - it('still lifts a record-less fence retired after a failed then successful write', () => { - const fenceChanges: [string, boolean][] = [] - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID, - (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - persistence.prepare() - } finally { - warn.mockRestore() - } - expect(fenceChanges).toEqual([]) - - failWrite = false - persistence.prepare() - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - - const requested = orchestrationDb.requestWorkerTerminalRelease(started.dispatch.id) - orchestrationDb.settleWorkerTerminalRelease( - (requested as { resource: { id: string } }).resource.id - ) - persistence.prepare() - - expect(fenceChanges).toEqual([ - [PANE_KEY, true], - [PANE_KEY, false] - ]) - expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) - }) - - // The damaging order: the fence is retired while the write is still failing. A pass that booked - // its blocked set before staging would carry that uncommitted fence into the retry and publish a - // lift for a pane no renderer was ever told about — or, through the reply, the fence itself. - it('publishes nothing for a pane retired while the session write was failing', () => { - const fenceChanges: [string, boolean][] = [] - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID, - (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - persistence.prepare() - } finally { - warn.mockRestore() - } - expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) - - const requested = orchestrationDb.requestWorkerTerminalRelease(started.dispatch.id) - orchestrationDb.settleWorkerTerminalRelease( - (requested as { resource: { id: string } }).resource.id - ) - failWrite = false - persistence.prepare() - - expect(fenceChanges).toEqual([]) - expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) - }) - - // 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`. + // 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') + expect(h.fences()).toEqual({ [PANE_KEY]: true }) + }) + + // A pane with no sleeping record is the whole reason the fence cannot live on the record. + it('fences a pane that has no sleeping record to hang a flag on', () => { + const h = harness(undefined, false) + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + + expect(h.fences()).toEqual({ [PANE_KEY]: true }) + }) + + // Main never writes the record flag: it is the renderer's outbound projection for old clients. + it('leaves the sleeping record untouched', () => { + const h = harness() + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + + expect(h.recordFlag()).toBeUndefined() + }) + + // Level-triggered: every pass writes the whole set, so there is no announcement to consume and + // no bookkeeping to keep in step. A renderer that reloads simply reads the field again. + it('rewrites the same set on every pass', () => { + const h = harness(undefined, false) + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + h.persistence.prepare() + + expect(h.fences()).toEqual({ [PANE_KEY]: true }) }) 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') + expect(h.fences()).toEqual({ [PANE_KEY]: true }) 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() + expect(h.fences()).toEqual({}) + }) + + // A pane with no record is retired the same way, because the set is rewritten whole rather than + // swept out of the records that happen to exist. + it('lifts a record-less fence on release', () => { + const h = harness(undefined, false) + settle(h.db, h.taskId, h.dispatchId) + h.persistence.prepare() + expect(h.fences()).toEqual({ [PANE_KEY]: true }) + + const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) + h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) + h.persistence.prepare() + + expect(h.fences()).toEqual({}) }) 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.fences()).toEqual({ [PANE_KEY]: true }) expect(h.db.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1) h.persistence.prepare() - expect(h.fence()).toBeUndefined() + expect(h.fences()).toEqual({}) }) // An unreadable plan is not evidence a pane stopped needing its fence. @@ -503,7 +189,7 @@ describe('settled worker automatic-resume fence persistence', () => { const h = harness() settle(h.db, h.taskId, h.dispatchId) h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') + expect(h.fences()).toEqual({ [PANE_KEY]: true }) vi.spyOn(h.db, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => { throw new Error('orchestration_db_unavailable') @@ -519,17 +205,89 @@ describe('settled worker automatic-resume fence persistence', () => { warn.mockRestore() } - expect(h.fence()).toBe('legacy-orchestration-worker') + expect(h.fences()).toEqual({ [PANE_KEY]: true }) }) - // 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. + // A failed write publishes nothing and pins nothing: the next pass rewrites the same level. + it('recovers from a failed session write on the next pass', () => { + const pings: number[] = [] + let failWrite = true + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = sessionWithSleepingWorker() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + if (failWrite) { + throw new Error('workspace_session_write_failed') + } + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const task = orchestrationDb.createTask({ spec: 'fence me' }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + settle(orchestrationDb, task.id, started.dispatch.id) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => orchestrationDb, + () => LOCAL_EXECUTION_HOST_ID, + () => pings.push(1) + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + persistence.prepare() + } finally { + warn.mockRestore() + } + expect(session.legacyWorkerResumeFencesByPaneKey).toBeUndefined() + expect(pings).toEqual([]) + + failWrite = false + persistence.prepare() + + expect(session.legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) + expect(pings).toEqual([1]) + }) + + // The ping is invalidation only, and an unchanged level must not wake every session subscriber. + it('pings only when the set actually changes', () => { + const pings: number[] = [] + const h = harness(() => pings.push(1)) + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + h.persistence.prepare() + + expect(pings).toEqual([1]) + }) + + // A live worker's pane is 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(h.fences()).toEqual({ [PANE_KEY]: true }) expect(plan.blockedPanes).toEqual([ expect.objectContaining({ paneKey: PANE_KEY, settled: false }) ]) @@ -592,9 +350,7 @@ describe('worker_done without a release', () => { paneKey: PANE_KEY, processIncarnation: 'runtime:pty:1' }) - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - undefined - ) + expect(session.legacyWorkerResumeFencesByPaneKey).toBeUndefined() const send = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.send')! await send.handler( @@ -613,8 +369,6 @@ describe('worker_done without a release', () => { ) expect(orchestrationDb.getWorkerDispatch(started.dispatch.id)?.state).toBe('succeeded') - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - 'legacy-orchestration-worker' - ) + expect(session.legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) }) }) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 852ee509c27..80d739c985e 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -80,7 +80,8 @@ 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, generation: number): void + /** Invalidation ping: the fenced-pane set changed, re-read it. Carries no state. */ + legacyWorkerTerminalResumeFencesChanged?(): void splitTerminal( tabId: string, paneRuntimeId: number, diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts index c54b94b203a..4a24f107135 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -1,20 +1,14 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' -import type { LegacyWorkerTerminalRecoveryResult } from '../runtime/runtime-legacy-worker-terminal-recovery-types' - -/** Generation 0 never orders ahead of a real commit, so a renderer drops this over any live push. */ -const EMPTY_FENCE_SNAPSHOT: LegacyWorkerResumeFenceSnapshot = { generation: 0, blockedPaneKeys: [] } - type LegacyWorkerRendererRecoveryOptions = { firstWindowStartupServicesReady: Promise managedWslCliStartupBarrierReady: Promise localPtyProviderStartupReady: Promise - reconcile: () => Promise | undefined + reconcile: () => Promise | undefined onDeferredRecoveryError: (error: unknown) => void } export async function recoverLegacyWorkerTerminalsForRendererStartup( options: LegacyWorkerRendererRecoveryOptions -): Promise { +): Promise { const providerStartupResult = options.localPtyProviderStartupReady.then( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }) @@ -26,12 +20,11 @@ export async function recoverLegacyWorkerTerminalsForRendererStartup( ]) if (!providerResult.ok) { options.onDeferredRecoveryError(providerResult.error) - return EMPTY_FENCE_SNAPSHOT + return } try { - return (await options.reconcile())?.fenceSnapshot ?? EMPTY_FENCE_SNAPSHOT + await options.reconcile() } catch (error) { options.onDeferredRecoveryError(error) - return EMPTY_FENCE_SNAPSHOT } } diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 918fd844364..f8ee924ff45 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -39,6 +39,12 @@ export function registerMainProcessIpcHandlers(): void { } }) ) + // Why a pull rather than a push payload: the fenced-pane set is runtime-authored session state, + // and a push that carried it could arrive out of order with the session the renderer hydrated. + ipcMain.handle( + 'app:getLegacyWorkerResumeFences', + () => state.runtime?.getLegacyWorkerResumeFences() ?? {} + ) // Why: the renderer pulls this once its ui:openSettings listener attaches, so a Settings request queued before mount isn't lost. ipcMain.handle('ui:consumePendingOpenSettings', (event) => state.pendingOpenSettings.matches(event.sender.id, { consume: true }) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 9e220bac885..27ffd4e1cc4 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -149,8 +149,8 @@ export function registerRuntimeWindowLifecycle( resolution, ...(ptyId ? { ptyId } : {}) }), - setLegacyWorkerTerminalResumeFence: (paneKey, blocked, generation) => - send('agentStatus:legacyWorkerTerminalResumeFence', { paneKey, blocked, generation }), + legacyWorkerTerminalResumeFencesChanged: () => + send('agentStatus:legacyWorkerTerminalResumeFencesChanged', {}), splitTerminal: (tabId, paneRuntimeId, opts) => { send('ui:splitTerminal', { tabId, diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index ce5cd6a7870..079b4a762c5 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -28,10 +28,8 @@ 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; generation?: number }) => void - ) => () => void + /** Invalidation ping: the runtime-authored fenced-pane set changed. Carries no state. */ + onLegacyWorkerTerminalResumeFencesChanged: (callback: () => void) => () => void getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ drop: (paneKey: string) => void diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5207c332afc..397ff2a8b42 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -61,15 +61,11 @@ export const agentStatusApi = { ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) }, - onLegacyWorkerTerminalResumeFence: ( - callback: (data: { paneKey: string; blocked: boolean; generation?: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { paneKey: string; blocked: boolean; generation?: number } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFence', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFence', listener) + onLegacyWorkerTerminalResumeFencesChanged: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFencesChanged', listener) + return () => + ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFencesChanged', listener) }, getMigrationUnsupportedSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index fe12ef1efe4..71470d50050 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { AppIdentity } from '../../shared/app-identity' import type { E2EConfig } from '../../shared/e2e-config' import type { ExecutionHostId } from '../../shared/execution-host' @@ -44,9 +43,10 @@ export type AppApi = { awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise - /** Reconciles legacy worker authority around persisted terminal reconnect and returns the - * fenced-pane set the renderer must seed its volatile blocked-pane map from. */ - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise + /** Reconciles legacy worker authority around persisted terminal reconnect. */ + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise + /** Runtime-authored panes fenced against automatic resume, unioned across hosts. */ + getLegacyWorkerResumeFences: () => Promise> /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ startupDiagnostic: (event: string, details?: Record) => Promise /** macOS active input mode, or layout ID when no IME is selected (e.g. `com.apple.keylayout.PolishPro`). diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 20863cd762d..c34b2500b94 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import { ipcRenderer } from 'electron' import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' @@ -48,8 +47,10 @@ export const appApi = { ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => + recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), + getLegacyWorkerResumeFences: (): Promise> => + ipcRenderer.invoke('app:getLegacyWorkerResumeFences'), startupDiagnostic: (event: string, details?: Record): Promise => startupDiagnosticsEnabled ? ipcRenderer.invoke('app:startupDiagnostic', event, details) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 8ec1e01f90b..77da19ffd20 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -1,4 +1,3 @@ -import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from '@/startup/legacy-worker-resume-fence-seed' import { useEffect, useRef } from 'react' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' @@ -261,7 +260,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited // the first-window services, and main re-awaits them inside this handler anyway. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => - recoverLegacyWorkerTerminalsAndSeedResumeFences() + window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { return refreshTerminalProviderSnapshotCapabilities( @@ -273,7 +272,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.reconnectPersistedTerminals(abortController.signal) ) await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => - recoverLegacyWorkerTerminalsAndSeedResumeFences() + window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { await timeRendererStartupStep('project-structured-session-tabs', () => diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index b1832888dbd..fead2f6c7bb 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -224,7 +224,7 @@ describe('renderer startup runtime routing', () => { degradedStart ) const recoveryIndex = source.indexOf( - 'recoverLegacyWorkerTerminalsAndSeedResumeFences()', + 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', servicesIndex ) const capabilityRefreshIndex = source.indexOf( @@ -238,11 +238,6 @@ describe('renderer startup runtime routing', () => { expect(recoveryIndex).toBeGreaterThan(servicesIndex) expect(capabilityRefreshIndex).toBeGreaterThan(recoveryIndex) expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) - // The helper is what still reaches main, so the ordering above only means something while it - // owns the pull. Assert the indirection instead of letting the rename hollow the gate out. - expect(readSource('src/renderer/src/startup/legacy-worker-resume-fence-seed.ts')).toContain( - 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()' - ) }) it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts index c93294ba2f9..665fa865821 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts @@ -448,10 +448,10 @@ describe('connectPanePty', () => { prompt: 'finish the task', state: 'working', capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' + updatedAt: 1 } - } + }, + legacyWorkerResumeFencesByPaneKey: { [paneKey]: true } } as StoreState connectPanePty( @@ -503,10 +503,10 @@ describe('connectPanePty', () => { prompt: 'finish the task', state: 'working', capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' + updatedAt: 1 } - } + }, + legacyWorkerResumeFencesByPaneKey: { [paneKey]: true } } as StoreState const deps = createDeps({ restoredLeafId: LEAF_1, @@ -554,10 +554,10 @@ describe('connectPanePty', () => { prompt: 'finish the task', state: 'working', capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' + updatedAt: 1 } - } + }, + legacyWorkerResumeFencesByPaneKey: { [paneKey]: true } } as StoreState const deps = createDeps({ restoredLeafId: LEAF_1, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index 8ad0335179c..2fcbd16d0ed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -49,6 +49,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState retainedAgentsByPaneKey: {}, paneForegroundAgentByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, + legacyWorkerResumeFencesByPaneKey: {}, suppressedPtyExitIds: {}, agentLaunchConfigByPaneKey: {}, getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 0777dead431..1f9710f8e58 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -62,9 +62,11 @@ 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.isLegacyWorkerAutomaticResumeBlocked = (): boolean => { + const state = useAppStore.getState() + const paneKey = session.getSleepingRecordForPane(state)?.paneKey + return paneKey !== undefined && state.legacyWorkerResumeFencesByPaneKey[paneKey] === true + } session.clearSleepingRecordProviderDuplicates = ( state: ReturnType, consumed: { paneKey: string; record: SleepingAgentSessionRecord } diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts index 9c1c3512e5e..329aa4cd466 100644 --- a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts +++ b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts @@ -26,14 +26,14 @@ describe('selectSleepingRecordParkExemptTabIds', () => { ])('derives the owner from a valid pane key (%s)', (paneKey, tabId) => { const records = { [paneKey]: sleepingRecord({ paneKey }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([tabId]) + expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1', {})]).toEqual([tabId]) }) it('prefers the persisted tab id over the pane key owner', () => { const paneKey = `tab-stale:${LEAF_ID}` const records = { [paneKey]: sleepingRecord({ paneKey, tabId: 'tab-current' }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual(['tab-current']) + expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1', {})]).toEqual(['tab-current']) }) it('does not invent an owner for a delimiter-less pane key', () => { @@ -41,7 +41,7 @@ describe('selectSleepingRecordParkExemptTabIds', () => { 'orphan-pane-key': sleepingRecord({ paneKey: 'orphan-pane-key' }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([]) + expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1', {})]).toEqual([]) }) it('skips records that cannot resume in this worktree', () => { @@ -50,13 +50,21 @@ describe('selectSleepingRecordParkExemptTabIds', () => { 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' - }) + [`tab-done:${LEAF_ID}`]: sleepingRecord({ paneKey: `tab-done:${LEAF_ID}`, state: 'done' }) } - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([]) + expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1', {})]).toEqual([]) + }) + + // The fence is runtime-authored session state, not a record field: a fenced pane can never cold + // restore, so exempting its tab from the park would pin a hidden pane mounted forever. + it('skips a pane the runtime has fenced', () => { + const paneKey = `tab-fenced:${LEAF_ID}` + const records = { [paneKey]: sleepingRecord({ paneKey }) } + + expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1', {})]).toEqual(['tab-fenced']) + expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1', { [paneKey]: true })]).toEqual( + [] + ) }) }) diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts index f38ee52bb80..a3dfaacdac2 100644 --- a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts +++ b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts @@ -13,7 +13,8 @@ const EMPTY_TAB_IDS: ReadonlySet = new Set() * `Object.values` would allocate every record on every store write. */ export function selectSleepingRecordParkExemptTabIds( sleepingAgentSessionsByPaneKey: Record | undefined, - worktreeId: string + worktreeId: string, + legacyWorkerResumeFencesByPaneKey: Record ): ReadonlySet { if (!sleepingAgentSessionsByPaneKey) { return EMPTY_TAB_IDS @@ -24,7 +25,10 @@ export function selectSleepingRecordParkExemptTabIds( if (!record || record.worktreeId !== worktreeId) { continue } - if (record.automaticResumeBlockedBy || isPassiveCompletedHibernationEvidence(record)) { + if ( + legacyWorkerResumeFencesByPaneKey[paneKey] || + isPassiveCompletedHibernationEvidence(record) + ) { continue } // Why: malformed pane keys must yield no owner instead of a truncated tab id. diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx index 81153a03ca6..bd39db8f893 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx @@ -111,19 +111,21 @@ 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 + // Why: a fenced pane 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({ legacyWorkerResumeFencesByPaneKey: { 'tab-1:1': true } }) useAppStore.setState({ sleepingAgentSessionsByPaneKey: { - 'tab-1:1': sleepingRecord('tab-1:1', WORKTREE_ID, { - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) + 'tab-1:1': sleepingRecord('tab-1:1', WORKTREE_ID) } }) }) expect(harness.renders).toBe(0) + act(() => { + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: {} }) + }) }) it('still re-renders when this worktree gains a pending startup', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index ec2611d19e0..8dd77aa7781 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ runtimePaneTitlesByTabId: {} as Record>, settings: {} as Record, terminalLayoutsByTabId: {} as Record }>, + legacyWorkerResumeFencesByPaneKey: {} as Record, sleepingAgentSessionsByPaneKey: {} as Record< string, { paneKey: string; tabId?: string; worktreeId: string } @@ -544,10 +545,12 @@ describe('useTerminalTabColdParking measure-clock contract', () => { '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' + worktreeId: WORKTREE_ID } as never } + mocks.storeState.legacyWorkerResumeFencesByPaneKey = { + 'tab-2:22222222-2222-4222-8222-222222222222': true + } act(() => { rerender(hookArgs(false)) }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 2376f842c9a..131558339af 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -120,7 +120,11 @@ export function useTerminalTabColdParking(args: { // subscribing to it re-rendered this worktree on every other worktree's write. const sleepingRecordOwnedTabIds = useAppStore( useShallow((state) => - selectSleepingRecordParkExemptTabIds(state.sleepingAgentSessionsByPaneKey, worktreeId) + selectSleepingRecordParkExemptTabIds( + state.sleepingAgentSessionsByPaneKey, + worktreeId, + state.legacyWorkerResumeFencesByPaneKey + ) ) ) const terminalTabHiddenSinceRef = useRef(new Map()) diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 4141d34b07e..7b0f81462bd 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -8,6 +8,7 @@ import { rollbackLegacyWorkerTerminalSurfaceInStore } from '../legacy-worker-terminal-recovery-event' import { useAppStore } from '../../store' +import { refreshLegacyWorkerResumeFences } from '../../lib/legacy-worker-resume-fence-refresh' import { resolvePaneKey } from './agent-status-routing' import type { PendingAgentStatusEvent } from './agent-status-bridge-types' @@ -126,9 +127,9 @@ export function registerAgentStatusListeners(args: { if (unsubscribeLegacyWorkerTerminalRecovery) { unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) } - const unsubscribeResumeFence = window.api.agentStatus.onLegacyWorkerTerminalResumeFence?.( - ({ paneKey, blocked, generation }) => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked, generation) + const unsubscribeResumeFence = window.api.agentStatus.onLegacyWorkerTerminalResumeFencesChanged?.( + () => { + void refreshLegacyWorkerResumeFences() } ) if (unsubscribeResumeFence) { diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 2ba4d506077..6bceb3a734c 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -5,7 +5,7 @@ import { createHarnessStoreState } from './ipc-events-test-harness' const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'agentStatus.onClear', 'agentStatus.onLegacyWorkerTerminalRecovery', - 'agentStatus.onLegacyWorkerTerminalResumeFence', + 'agentStatus.onLegacyWorkerTerminalResumeFencesChanged', 'agentStatus.onMigrationUnsupported', 'agentStatus.onMigrationUnsupportedClear', 'agentStatus.onSet', @@ -199,7 +199,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'agentStatus.onMigrationUnsupported', 'agentStatus.onMigrationUnsupportedClear', 'agentStatus.onLegacyWorkerTerminalRecovery', - 'agentStatus.onLegacyWorkerTerminalResumeFence', + 'agentStatus.onLegacyWorkerTerminalResumeFencesChanged', 'runtime.onTerminalFitOverrideChanged', 'runtime.onTerminalDriverChanged', 'runtime.onNativeChatLaunchDraftResolved', diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 7dfab1fb874..9fe2bac6b99 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -85,7 +85,7 @@ function snapshotFromState( .map(([ptyId]) => ptyId), agentStatusByPaneKey: state.agentStatusByPaneKey, sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, - automaticResumeBlockedPaneKeys: state.automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey: state.legacyWorkerResumeFencesByPaneKey, // Why: input stamps are coalesced, so planning must see the not-yet-flushed keystroke. lastTerminalInputAtByPaneKey: mergePendingTerminalInputActivity( state.lastTerminalInputAtByPaneKey diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts index 73fe2c3e32f..2e401508225 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts @@ -23,7 +23,7 @@ const entry: AgentStatusEntry = { providerSession: { key: 'session_id', id: 'session-1' } } -const liveAnchor = (fenced: boolean): SleepingAgentSessionRecord => ({ +const liveAnchor: SleepingAgentSessionRecord = { paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', @@ -33,13 +33,12 @@ const liveAnchor = (fenced: boolean): SleepingAgentSessionRecord => ({ state: 'done', capturedAt: NOW - 600_000, updatedAt: NOW - 600_000, - origin: 'live', - ...(fenced ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } : {}) -}) + origin: 'live' +} function plan( record: SleepingAgentSessionRecord | undefined, - automaticResumeBlockedPaneKeys: Record = {} + legacyWorkerResumeFencesByPaneKey: Record = {} ) { return getEligiblePane({ entry, @@ -47,7 +46,7 @@ function plan( layout: { ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } } as never, livePtyIds: new Set(['pty-1']), sleepingAgentSessionsByPaneKey: record ? { [PANE_KEY]: record } : {}, - automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey, lastTerminalInputAtByPaneKey: {}, foregroundTerminalLastSeenAtByTabId: {}, ptyBindingFirstSeenAtByPaneKey: {}, @@ -58,32 +57,37 @@ function plan( }) } -// Hibernating a fenced pane kills a worker PTY that must not be automatically relaunched. The -// planner used to read the fence only off the sleeping record, so a worker that settled while its -// tab was still open — fenced before any record exists — was admitted for the kill. +// Hibernating a fenced pane kills a worker PTY that must not be automatically relaunched. The fence +// is runtime-authored session state keyed by pane, so it covers a worker that settled while its tab +// was still open — the case that has no sleeping record for a flag to live on. describe('hibernation eligibility and the settled-worker resume fence', () => { - it('blocks the kill when the record carries the fence', () => { - expect(plan(liveAnchor(true))).toBeNull() + it('blocks the kill for a fenced pane that has a record', () => { + expect(plan(liveAnchor, { [PANE_KEY]: true })).toBeNull() }) - it('blocks the kill when only the blocked-pane map carries the fence', () => { - expect(plan(liveAnchor(false), { [PANE_KEY]: true })).toBeNull() - }) - - it('blocks the kill when the pane is fenced before any record exists', () => { + it('blocks the kill for a fenced pane that has no record yet', () => { expect(plan(undefined, { [PANE_KEY]: true })).toBeNull() }) // Controls: the ordinary completed-agent population must still hibernate. - it('admits an unfenced completed pane holding only its live resume anchor', () => { - expect(plan(liveAnchor(false))).not.toBeNull() + it('admits an unfenced pane holding only its live resume anchor', () => { + expect(plan(liveAnchor)).not.toBeNull() }) - it('admits an unfenced completed pane with no record at all', () => { + it('admits an unfenced pane with no record at all', () => { expect(plan(undefined)).not.toBeNull() }) it('admits a pane when a different pane is the fenced one', () => { - expect(plan(liveAnchor(false), { 'tab-9:other-leaf': true })).not.toBeNull() + expect(plan(liveAnchor, { 'tab-9:other-leaf': true })).not.toBeNull() + }) + + // The record flag is an outbound projection for older clients, never an input to a decision. + it('ignores a record flag the runtime no longer claims', () => { + const stale = { + ...liveAnchor, + automaticResumeBlockedBy: 'legacy-orchestration-worker' as const + } + expect(plan(stale)).not.toBeNull() }) }) diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 1db4710ed3a..7c2c4063ca1 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -61,7 +61,7 @@ export function getEligiblePane(args: { layout: TerminalLayoutSnapshot | undefined livePtyIds: Set sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] - automaticResumeBlockedPaneKeys: Record + legacyWorkerResumeFencesByPaneKey: Record lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] ptyBindingFirstSeenAtByPaneKey: Record @@ -76,7 +76,7 @@ export function getEligiblePane(args: { layout, livePtyIds, sleepingAgentSessionsByPaneKey, - automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey, lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey, @@ -100,8 +100,7 @@ export function getEligiblePane(args: { (sleepingRecord && !hasOnlyLiveResumeAnchor) || // Why: a fenced worker must never be auto-relaunched; the kill would strand it. !isAutomaticHibernationAllowed({ - record: sleepingRecord, - automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey, paneKey: entry.paneKey }) ) { diff --git a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts index 94cc725d876..2e5670d8855 100644 --- a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts +++ b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts @@ -15,8 +15,8 @@ export type AgentHibernationPlannerSnapshot = { mobileLockedPtyIds: string[] agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: Record - /** Panes fenced before a record exists; main re-seeds this on every renderer start. */ - automaticResumeBlockedPaneKeys?: Record + /** Runtime-authored fenced-pane set, read from the workspace session. */ + legacyWorkerResumeFencesByPaneKey?: Record lastTerminalInputAtByPaneKey: Record foregroundTerminalLastSeenAtByTabId: Record ptyBindingFirstSeenAtByPaneKey?: Record diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 10414249dfb..723aa9df553 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -757,15 +757,14 @@ describe('live resume anchors do not block hibernation (#10238 regression)', () 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 }, + sleepingAgentSessionsByPaneKey: { + [agentEntry.paneKey]: liveAnchor('claude', providerSession) as never + }, + legacyWorkerResumeFencesByPaneKey: { [agentEntry.paneKey]: true }, ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD } }) ) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 6364e5ec647..dc725d3d172 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -146,7 +146,7 @@ export function planAgentHibernationCandidates( layout, livePtyIds: new Set(tabLivePtyIds), sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, - automaticResumeBlockedPaneKeys: snapshot.automaticResumeBlockedPaneKeys ?? {}, + legacyWorkerResumeFencesByPaneKey: snapshot.legacyWorkerResumeFencesByPaneKey ?? {}, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, diff --git a/src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts new file mode 100644 index 00000000000..1b3e1b23933 --- /dev/null +++ b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { refreshLegacyWorkerResumeFences } from './legacy-worker-resume-fence-refresh' + +const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' + +function stubFences(...replies: (Record | undefined)[]): ReturnType { + const get = vi.fn() + for (const reply of replies) { + get.mockResolvedValueOnce(reply) + } + get.mockResolvedValue(replies.at(-1)) + vi.stubGlobal('window', { + ...globalThis.window, + api: { app: { getLegacyWorkerResumeFences: get } } + }) + return get +} + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: {} }) +}) + +// The ping carries no state, so the renderer re-reads the runtime-authored set. Reads are +// serialized and coalesced: the last read is the last write, which is why no version is needed to +// stop an older reply from reinstating a fence the runtime already retired. +describe('re-reading the fenced-pane set after main invalidates it', () => { + it('installs the set main reports', async () => { + stubFences({ [PANE_KEY]: true }) + + await refreshLegacyWorkerResumeFences() + + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) + }) + + it('replaces the set rather than merging, so a retired pane is dropped', async () => { + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: { [PANE_KEY]: true } }) + stubFences({}) + + await refreshLegacyWorkerResumeFences() + + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + }) + + it('coalesces pings that arrive during a read and ends on the newest set', async () => { + const get = stubFences({ [PANE_KEY]: true }, {}) + + const first = refreshLegacyWorkerResumeFences() + const second = refreshLegacyWorkerResumeFences() + await Promise.all([first, second]) + + expect(get).toHaveBeenCalledTimes(2) + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + }) + + it('keeps the previous set when the read fails', async () => { + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: { [PANE_KEY]: true } }) + const get = vi.fn().mockRejectedValue(new Error('runtime_unavailable')) + vi.stubGlobal('window', { + ...globalThis.window, + api: { app: { getLegacyWorkerResumeFences: get } } + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + try { + await refreshLegacyWorkerResumeFences() + } finally { + warn.mockRestore() + } + + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) + }) +}) diff --git a/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts new file mode 100644 index 00000000000..15728c411de --- /dev/null +++ b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts @@ -0,0 +1,35 @@ +import { useAppStore } from '@/store' + +let inFlight: Promise | null = null +let repeat = false + +/** + * Re-reads the runtime-authored fenced-pane set after main says it changed. A pull rather than a + * pushed payload: the set is session state the renderer also hydrates, and an event carrying it + * could land out of order with that hydration. + * + * Reads are serialized and coalesced, so the last read is the last write with no version to + * compare — two overlapping pings cannot leave an older set installed. + */ +export async function refreshLegacyWorkerResumeFences(): Promise { + if (inFlight) { + repeat = true + return inFlight + } + inFlight = (async () => { + try { + do { + repeat = false + const fences = await window.api.app.getLegacyWorkerResumeFences() + useAppStore.getState().setLegacyWorkerResumeFences(fences ?? {}) + } while (repeat) + } catch (error) { + // Losing a refresh leaves the previously read set in place; the next ping or start re-reads. + console.warn('[orchestration] failed to read legacy worker resume fences', error) + } finally { + inFlight = null + repeat = false + } + })() + return inFlight +} diff --git a/src/renderer/src/lib/live-resume-anchor-record.ts b/src/renderer/src/lib/live-resume-anchor-record.ts index 73e6771d998..9fbb74fc008 100644 --- a/src/renderer/src/lib/live-resume-anchor-record.ts +++ b/src/renderer/src/lib/live-resume-anchor-record.ts @@ -52,18 +52,15 @@ export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord( } /** - * A durable orchestration fence against automatic provider relaunch; hibernating a fenced pane - * would strand it. The record is the fence's home, but a worker that settles while its tab is - * still open is fenced before any record exists — `automaticResumeBlockedPaneKeys` holds it for - * that window, so the planner must read both. + * A fence against automatic provider relaunch; hibernating a fenced pane would strand it. The + * orchestration authority is the only writer, and it publishes the whole fenced-pane set as + * runtime-authored session state — including panes whose worker settled with the tab still open, + * which have no sleeping record to carry a flag. `automaticResumeBlockedBy` on the record is an + * outbound projection of this set for older clients, never the thing a decision reads. */ export function isAutomaticHibernationAllowed(pane: { - record: SleepingAgentSessionRecord | undefined - automaticResumeBlockedPaneKeys: Record + legacyWorkerResumeFencesByPaneKey: Record paneKey: string }): boolean { - return ( - !pane.record?.automaticResumeBlockedBy && - pane.automaticResumeBlockedPaneKeys[pane.paneKey] !== true - ) + return pane.legacyWorkerResumeFencesByPaneKey[pane.paneKey] !== true } diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts index 1ec95258f05..3f28d678e62 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts @@ -22,10 +22,10 @@ describe('legacy worker sleeping-session recovery', () => { state: 'working', capturedAt: 1, updatedAt: 1, - origin: 'live', - automaticResumeBlockedBy: 'legacy-orchestration-worker' + origin: 'live' } useAppStore.setState({ + legacyWorkerResumeFencesByPaneKey: { 'tab-legacy:leaf-legacy': true }, tabsByWorktree: { 'wt-legacy': [ { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 94dc52bc7c6..4ab85c2e127 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -210,7 +210,7 @@ export function resumeSleepingAgentSessionsForWorktree( if (options?.skipClaimKeys?.has(claimKey)) { continue } - if (record.automaticResumeBlockedBy === 'legacy-orchestration-worker') { + if (currentState.legacyWorkerResumeFencesByPaneKey[record.paneKey]) { continue } if (isInvalidWorktreeActivationRecord(record)) { diff --git a/src/renderer/src/lib/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 4e56654a8f5..4976edf7b17 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -172,7 +172,12 @@ export function buildWorkspaceSessionPatch( snapshot.closedTerminalTabTombstonesByTabId ) } - if (changed.has('sleepingAgentSessionsByPaneKey')) { + // Why both: the projected flag is derived from the fence set, so a fence change alone still has + // to re-emit the records an older client reads it from. + if ( + changed.has('sleepingAgentSessionsByPaneKey') || + changed.has('legacyWorkerResumeFencesByPaneKey') + ) { patch.sleepingAgentSessionsByPaneKey = buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey } diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 2dc1346f534..f688ea7a062 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -37,6 +37,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { defaultTerminalTabsAppliedByWorktreeId: true, closedTerminalTabTombstonesByTabId: true, sleepingAgentSessionsByPaneKey: true, + legacyWorkerResumeFencesByPaneKey: true, clientHostedBrowserCloseIntentsByEnvironment: true, pendingReconnectPtyIdByTabId: true, deferredSshSessionIdsByTabId: true diff --git a/src/renderer/src/lib/workspace-session-sleeping-agents.ts b/src/renderer/src/lib/workspace-session-sleeping-agents.ts index 29ee3d0fe6a..8183731ec2d 100644 --- a/src/renderer/src/lib/workspace-session-sleeping-agents.ts +++ b/src/renderer/src/lib/workspace-session-sleeping-agents.ts @@ -1,10 +1,36 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +/** + * The single site where `automaticResumeBlockedBy` is stamped onto an outgoing record. + * + * The fence itself lives in `legacyWorkerResumeFencesByPaneKey`, which main owns and this renderer + * never writes. The record flag is a projection kept only so a client too old to read that field + * still hydrates fenced records as fenced. Projecting here — the one function both the patch path + * and the full-state path build records through — is what lets every record writer stay unaware of + * the fence: the flag is derived on the way out rather than carried through the store. + */ export function buildSleepingAgentSessionData(snapshot: { sleepingAgentSessionsByPaneKey?: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] + legacyWorkerResumeFencesByPaneKey?: Record }): Pick { const records = snapshot.sleepingAgentSessionsByPaneKey - return records && Object.keys(records).length > 0 - ? { sleepingAgentSessionsByPaneKey: records } - : {} + if (!records || Object.keys(records).length === 0) { + return {} + } + const fences = snapshot.legacyWorkerResumeFencesByPaneKey ?? {} + let projected: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] | undefined + for (const [paneKey, record] of Object.entries(records)) { + const fenced = fences[paneKey] === true + if (fenced === (record.automaticResumeBlockedBy === 'legacy-orchestration-worker')) { + continue + } + projected ??= { ...records } + if (fenced) { + projected[paneKey] = { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } else { + const { automaticResumeBlockedBy: _retired, ...unfenced } = record + projected[paneKey] = unfenced + } + } + return { sleepingAgentSessionsByPaneKey: projected ?? records } } diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 330a150b636..c5656c17b47 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -60,6 +60,8 @@ export type WorkspaceSessionSnapshot = Pick< > & { activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] + /** Read-only input to the record projection; never emitted, since main owns this field. */ + legacyWorkerResumeFencesByPaneKey?: AppState['legacyWorkerResumeFencesByPaneKey'] clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ pendingReconnectPtyIdByTabId?: AppState['pendingReconnectPtyIdByTabId'] @@ -100,6 +102,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'defaultTerminalTabsAppliedByWorktreeId', 'closedTerminalTabTombstonesByTabId', 'sleepingAgentSessionsByPaneKey', + 'legacyWorkerResumeFencesByPaneKey', 'clientHostedBrowserCloseIntentsByEnvironment', 'pendingReconnectPtyIdByTabId', 'deferredSshSessionIdsByTabId' diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts deleted file mode 100644 index d982c4e7bf3..00000000000 --- a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from './legacy-worker-resume-fence-seed' - -const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' - -function stubRecovery(result: unknown): ReturnType { - const recover = vi.fn().mockResolvedValue(result) - vi.stubGlobal('window', { - ...globalThis.window, - api: { app: { recoverLegacyWorkerTerminalsForRendererStartup: recover } } - }) - return recover -} - -afterEach(() => { - vi.unstubAllGlobals() - useAppStore.setState({ automaticResumeBlockedPaneKeys: {}, automaticResumeFenceGeneration: 0 }) -}) - -// The renderer's blocked-pane map starts empty on every boot, reload included, and is never -// persisted. Main answers the startup handshake with its committed fenced-pane set, versioned by -// its commit generation so this reply cannot override a lift that overtook it. -describe('seeding the resume fence from the renderer-startup handshake', () => { - it('blocks every pane main reports as fenced', async () => { - stubRecovery({ generation: 3, blockedPaneKeys: [PANE_KEY] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - }) - - // The snapshot is main's committed state, not the plan a pass started with, so a pane it no - // longer claims is retired here. This is the only channel that can lift a fence for a pane with - // no sleeping record after a reload, since `liftRetiredFences` can sweep only records. - it('retires a pane main no longer reports as fenced', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true, 4) - stubRecovery({ generation: 5, blockedPaneKeys: [] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) - - // A release or takeover can retire the fence after main read the reply but before it lands. The - // newer lift carries a higher commit generation, so the older reply must not walk it back. - it('drops a reply older than a lift that already arrived', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false, 9) - stubRecovery({ generation: 8, blockedPaneKeys: [PANE_KEY] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) - - // A pass whose session write threw commits nothing, so it reports the previous committed state - // at the previous generation rather than publishing keys the push channel never announced. - it('applies a reply at the generation already applied', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false, 6) - stubRecovery({ generation: 6, blockedPaneKeys: [PANE_KEY] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - }) - - // A paired/web client has no wire method for the fence yet and resolves an empty snapshot. - it('tolerates a client that cannot answer the handshake', async () => { - stubRecovery(undefined) - - await expect(recoverLegacyWorkerTerminalsAndSeedResumeFences()).resolves.toBeUndefined() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) -}) diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts deleted file mode 100644 index 2f36e89bc5f..00000000000 --- a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useAppStore } from '@/store' - -/** - * Main owns the settled-worker resume fence; the renderer's `automaticResumeBlockedPaneKeys` map is - * volatile and starts empty on every renderer boot, reload included. Pulling main's committed fence - * state on the startup handshake is what makes a fence survive a reload — a once-per-process push - * cannot. The reply carries main's commit generation, so a live lift that raced past it wins. - */ -export async function recoverLegacyWorkerTerminalsAndSeedResumeFences(): Promise { - const snapshot = await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - if (snapshot) { - useAppStore.getState().applyLegacyWorkerResumeFenceSnapshot(snapshot) - } -} diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index 52c422bd640..987a74a8341 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -1,4 +1,3 @@ -import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from './legacy-worker-resume-fence-seed' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' @@ -88,12 +87,12 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr } try { await window.api.app.awaitFirstWindowStartupServices() - await recoverLegacyWorkerTerminalsAndSeedResumeFences() + await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() await refreshTerminalProviderSnapshotCapabilities( collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) ) await reconnectPersistedTerminals(abortSignal) - await recoverLegacyWorkerTerminalsAndSeedResumeFences() + await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() } catch (reconnectErr) { console.error('[startup] reconnectPersistedTerminals failed in error path:', reconnectErr) // Why (issue #1158): the await may have run during StrictMode teardown; re-check cancellation so a cancelled pass 1 doesn't stomp pass 2's hydration. diff --git a/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts b/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts deleted file mode 100644 index 199cf60a6a0..00000000000 --- a/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import type { AppState } from '../types' -import { createTestStore, makeTab } from './store-test-helpers' - -const NOW = 1_800_000_000_000 -const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' - -function fencedRecord( - origin: SleepingAgentSessionRecord['origin'], - sessionId = 'session-1' -): SleepingAgentSessionRecord { - return { - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: sessionId }, - prompt: '', - state: 'done', - capturedAt: NOW - 10_000, - updatedAt: NOW - 10_000, - origin, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } -} - -function hydrate(record: SleepingAgentSessionRecord, blockedPaneKeys?: Record) { - const store = createTestStore() - store.setState({ - tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, - sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record }, - ...(blockedPaneKeys ? { automaticResumeBlockedPaneKeys: blockedPaneKeys } : {}) - } as Partial) - return store -} - -function writeStatus( - store: ReturnType, - status: { state: AgentStatusEntry['state']; prompt: string }, - sessionId = 'session-1' -): void { - store - .getState() - .setAgentStatus( - PANE_KEY, - { ...status, agentType: 'claude' } as never, - undefined, - { updatedAt: NOW }, - { tabId: 'tab-1', worktreeId: 'wt-1' } as never, - { providerSession: { key: 'session_id', id: sessionId } } - ) -} - -const fenceOf = (store: ReturnType): string | undefined => - store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy - -// The fence's durable home is the record. `automaticResumeBlockedPaneKeys` starts empty on every -// renderer boot and is not persisted, so deriving the rebuilt record's flag from that map alone -// erased a hydrated fence on the first status write — and worktree activation then relaunched the -// settled worker with `--resume` over its still-live PTY. -describe('a hydrated resume fence and the volatile blocked-pane map', () => { - it('is not restored into the blocked-pane map by hydration alone', () => { - expect( - hydrate(fencedRecord('worktree-sleep')).getState().automaticResumeBlockedPaneKeys - ).toEqual({}) - }) - - it('survives a status write with the blocked-pane map empty', () => { - const store = hydrate(fencedRecord('worktree-sleep')) - - writeStatus(store, { state: 'done', prompt: 'worker task' }) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - it('survives a live-origin record being rebuilt when the pane state changes', () => { - const store = hydrate(fencedRecord('live')) - - writeStatus(store, { state: 'working', prompt: 'user takes over' }) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - it('survives a manual worktree sleep that recaptures the pane', () => { - const store = hydrate(fencedRecord('live')) - - store.getState().captureSleepingAgentSessionsByWorktree('wt-1', [PANE_KEY]) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - // Control: the pre-existing population, where the fence arrived while the tab was still open. - it('survives when only the blocked-pane map holds it', () => { - const store = hydrate( - { ...fencedRecord('worktree-sleep'), automaticResumeBlockedBy: undefined }, - { [PANE_KEY]: true } - ) - - writeStatus(store, { state: 'done', prompt: 'worker task' }) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - // A new provider session in the pane is new work, not the fenced dispatch's work. - it('is not carried onto a record for a different provider session', () => { - const store = hydrate(fencedRecord('live')) - - writeStatus(store, { state: 'working', prompt: 'a fresh session' }, 'session-2') - - expect(fenceOf(store)).toBeUndefined() - }) - - it('is dropped from both homes when the runtime lifts it', () => { - const store = hydrate(fencedRecord('worktree-sleep'), { [PANE_KEY]: true }) - - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) - writeStatus(store, { state: 'done', prompt: 'worker task' }) - - expect(fenceOf(store)).toBeUndefined() - expect(store.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) -}) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts index f1deb30370f..64baade1e00 100644 --- a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts @@ -141,34 +141,29 @@ 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', () => { + // The fence is runtime-authored session state, so a manual-sleep recapture cannot drop it: no + // record writer touches it, and the replacement record carries no flag to lose. + it('leaves the runtime fence untouched while replacing the 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' }) - }, + agentStatusByPaneKey: { 'tab-1:leaf-1': makeAgentEntry() }, 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' + providerSession: { key: 'session_id', id: 'session-tab-1:leaf-1' } }) - } + }, + legacyWorkerResumeFencesByPaneKey: { 'tab-1:leaf-1': true } } as Partial) 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() + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ 'tab-1:leaf-1': true }) + expect( + store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'].automaticResumeBlockedBy + ).toBeUndefined() }) it('preserves retained completed sessions as intentional sleep records', () => { diff --git a/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts b/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts index cbd6b186997..7e9b40b951b 100644 --- a/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts +++ b/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { AppState } from '../types' +import { buildSleepingAgentSessionData } from '@/lib/workspace-session-sleeping-agents' import { createTestStore, makeTab } from './store-test-helpers' const NOW = 1_800_000_000_000 const PANE_KEY = 'tab-1:leaf-1' -function liveWorkerEntry(): AgentStatusEntry { +function liveWorkerEntry(state: AgentStatusEntry['state'] = 'working'): AgentStatusEntry { return { - state: 'working', + state, prompt: 'finish the task', updatedAt: NOW, stateStartedAt: NOW, @@ -21,40 +22,93 @@ function liveWorkerEntry(): AgentStatusEntry { } } -// 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) +function fencedStore() { + const store = createTestStore() + store.setState({ + tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, + agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() }, + legacyWorkerResumeFencesByPaneKey: { [PANE_KEY]: true } + } as Partial) + return store +} - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined() +// The worker settles while its tab is still open, so there is no sleeping record to carry a flag. +// The fence is runtime-authored session state keyed by pane, so it exists for that pane regardless, +// and no renderer writer has to remember to preserve it. +describe('a resume fence for a pane with no sleeping record', () => { + it('survives every record rebuild, because no record carries it', () => { + const store = fencedStore() store.getState().captureAllSleepingAgentSessions('quit') + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'done', prompt: 'finish the task', agentType: 'codex' } as never, + undefined, + { updatedAt: NOW + 1 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } as never, + { providerSession: { key: 'session_id', id: 'session-1' } } + ) - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject({ - paneKey: PANE_KEY, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[PANE_KEY]).toBe(true) }) - 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) + // Only the runtime writes the fence; the renderer installs whatever main published. + it('is retired only by the runtime replacing the set', () => { + const store = fencedStore() - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) + store.getState().setLegacyWorkerResumeFences({}) + + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + }) + + // Hydration installs the runtime's set rather than merging it, so a fence retired while this + // renderer was down does not survive the read that is supposed to replace it. + it('replaces a stale local fence on hydration rather than merging', () => { + const store = fencedStore() + + store.getState().hydrateWorkspaceSession( + { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + legacyWorkerResumeFencesByPaneKey: {} + }, + undefined + ) + + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + }) + + // Older clients read the fence off the record, so it is projected on the way out — at one site, + // derived from the runtime's set rather than carried through the store. + it('is projected onto outgoing records for older clients', () => { + const store = fencedStore() store.getState().captureAllSleepingAgentSessions('quit') + const snapshot = store.getState() + expect( + snapshot.sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + ).toBeUndefined() + + const projected = buildSleepingAgentSessionData(snapshot) + + expect(projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( + 'legacy-orchestration-worker' + ) + }) + + it('strips a stale projection once the runtime retires the fence', () => { + const store = fencedStore() + store.getState().captureAllSleepingAgentSessions('quit') + store.getState().setLegacyWorkerResumeFences({}) + + const projected = buildSleepingAgentSessionData(store.getState()) expect( - store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy ).toBeUndefined() }) }) diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 1f6578df0a2..1ffe1d7cb0e 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -20,7 +20,7 @@ import { } from './agent-status-pane-key-tab-binding' import { removePaneKeys } from './agent-status-pane-keyed-records' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { carriesAutomaticResumeBlock, copyLaunchConfig } from './agent-status-sleeping-records' +import { copyLaunchConfig } from './agent-status-sleeping-records' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime @@ -113,9 +113,6 @@ export function createAgentStatusProviderSessionActions( ? { connectionId: existingRecord.connectionId } : {}), ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(carriesAutomaticResumeBlock(s, { paneKey, agent, providerSession }) - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } - : {}), ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined ? { interrupted: existingRecord.interrupted } : {}), diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index 0d60d023956..42e31bb8465 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session.test.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session.test.ts @@ -327,62 +327,31 @@ describe('recordAgentProviderSession', () => { ).toBeUndefined() }) - it('preserves the legacy resume fence only for the same Pi session identity', () => { + // Opus finding 1: the old pane-key carry had no session gate, so a heartbeat for a brand-new + // user session inherited the fence. The fence is no longer a record field, so a rebuild for any + // session cannot acquire one, and only the runtime decides which pane is fenced. + it('never stamps a fence onto a record it rebuilds', () => { 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') - } + sleepingAgentSessionsByPaneKey: {}, + legacyWorkerResumeFencesByPaneKey: { 'tab-1:leaf-1': true } } as Partial) - 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' } - ) + store + .getState() + .recordAgentProviderSession( + 'tab-1:leaf-1', + 'pi', + { key: 'session_id', id: 'a-brand-new-user-session' }, + { updatedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } + ) expect( store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.automaticResumeBlockedBy ).toBeUndefined() + // The pane stays fenced regardless: the decision reads the runtime set, not the record. + expect(store.getState().legacyWorkerResumeFencesByPaneKey['tab-1:leaf-1']).toBe(true) }) it.each(PI_COMPATIBLE_CASES)( diff --git a/src/renderer/src/store/slices/agent-status-recovery-actions.ts b/src/renderer/src/store/slices/agent-status-recovery-actions.ts index efe469d5b58..c2c740af02b 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -1,5 +1,5 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import type { AppState } from '../types' +import { sameFenceSet } from './legacy-worker-resume-fences' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection' @@ -15,59 +15,6 @@ import { getLaunchConfigForEntry } from './agent-status-launch-config' import { findAgentPaneWorktreeId } from './agent-status-pane-key-tab-binding' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' -/** The single writer for the resume fence's two homes. The pane-key map is tracked even with no - * record, because a worker settled while its tab was open is fenced before the record is minted; - * the record is the durable home and must move with it. `generation` is main's commit counter, so - * a startup reply that lost a race with a later lift cannot walk the newer state backwards. */ -function applyFenceToPanes( - state: AppState, - changes: ReadonlyMap, - generation: number | undefined -): Partial | AppState { - let paneKeys = state.automaticResumeBlockedPaneKeys - let records = state.sleepingAgentSessionsByPaneKey - for (const [paneKey, blocked] of changes) { - if ((paneKeys[paneKey] === true) !== blocked) { - if (paneKeys === state.automaticResumeBlockedPaneKeys) { - paneKeys = { ...paneKeys } - } - if (blocked) { - paneKeys[paneKey] = true - } else { - delete paneKeys[paneKey] - } - } - const current = records[paneKey] - const recordBlocked = current?.automaticResumeBlockedBy === 'legacy-orchestration-worker' - if (!current || recordBlocked === blocked) { - continue - } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy - } - if (records === state.sleepingAgentSessionsByPaneKey) { - records = { ...records } - } - records[paneKey] = next - } - const nextGeneration = Math.max(state.automaticResumeFenceGeneration, generation ?? 0) - if ( - paneKeys === state.automaticResumeBlockedPaneKeys && - records === state.sleepingAgentSessionsByPaneKey && - nextGeneration === state.automaticResumeFenceGeneration - ) { - return state - } - return { - automaticResumeBlockedPaneKeys: paneKeys, - sleepingAgentSessionsByPaneKey: records, - automaticResumeFenceGeneration: nextGeneration - } -} - export function createAgentStatusRecoveryActions( runtime: AgentStatusRuntime ): Pick< @@ -76,8 +23,7 @@ export function createAgentStatusRecoveryActions( | 'captureAllSleepingAgentSessions' | 'clearSleepingAgentSession' | 'clearSleepingAgentSessionsByPaneKey' - | 'setSleepingAgentAutomaticResumeBlocked' - | 'applyLegacyWorkerResumeFenceSnapshot' + | 'setLegacyWorkerResumeFences' | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { @@ -164,30 +110,12 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked, generation) => { - set((s) => applyFenceToPanes(s, new Map([[paneKey, blocked]]), generation)) - }, - - // Why replace rather than merge: main hands over its whole committed fence state, so a pane it - // no longer claims is retired. An unreadable plan does not reach this — a pass that commits - // nothing leaves the previous committed state and generation in place. - applyLegacyWorkerResumeFenceSnapshot: (snapshot) => { - set((s) => { - if (snapshot.generation < s.automaticResumeFenceGeneration) { - return s - } - const blockedPaneKeys = new Set(snapshot.blockedPaneKeys) - const changes = new Map() - for (const paneKey of blockedPaneKeys) { - changes.set(paneKey, true) - } - for (const paneKey of Object.keys(s.automaticResumeBlockedPaneKeys)) { - if (!blockedPaneKeys.has(paneKey)) { - changes.set(paneKey, false) - } - } - return applyFenceToPanes(s, changes, snapshot.generation) - }) + setLegacyWorkerResumeFences: (fences) => { + set((s) => + sameFenceSet(s.legacyWorkerResumeFencesByPaneKey, fences) + ? s + : { legacyWorkerResumeFencesByPaneKey: fences } + ) }, clearSleepingAgentSessionsByWorktree: (worktreeId) => { diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts deleted file mode 100644 index 986a88579b7..00000000000 --- a/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import { - recoveryRecordMatches, - sleepingRecordsEquivalentIgnoringCaptureTime -} from './agent-status-recovery-equivalence' - -const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' - -function record(overrides: Partial = {}): SleepingAgentSessionRecord { - return { - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'session-1' }, - prompt: '', - state: 'done', - capturedAt: 1_000, - updatedAt: 2_000, - origin: 'live', - ...overrides - } -} - -const fenced = record({ automaticResumeBlockedBy: 'legacy-orchestration-worker' }) - -// Both predicates are equality shortcuts that suppress a write. Ignoring the fence let a rebuild -// that had dropped it count as equal, so the fenced record was quietly kept out of the update path -// that would have restored the flag — and out of the write that persists it. -describe('resume-fence significance in sleeping-record equality', () => { - it('rejects a rebuild that dropped the fence as a recovery match', () => { - expect(recoveryRecordMatches(fenced, record())).toBe(false) - }) - - it('rejects a rebuild that added the fence as a recovery match', () => { - expect(recoveryRecordMatches(record(), fenced)).toBe(false) - }) - - it('rejects a capture that dropped the fence as capture-time equivalent', () => { - expect(sleepingRecordsEquivalentIgnoringCaptureTime(fenced, record())).toBe(false) - }) - - it('rejects a capture that added the fence as capture-time equivalent', () => { - expect(sleepingRecordsEquivalentIgnoringCaptureTime(record(), fenced)).toBe(false) - }) - - // Controls: a stable fence must stay equal, or every pass would rewrite an unchanged record. - it('keeps two fenced records equal', () => { - expect(recoveryRecordMatches(fenced, { ...fenced, capturedAt: 9_999 })).toBe(true) - expect( - sleepingRecordsEquivalentIgnoringCaptureTime(fenced, { ...fenced, capturedAt: 9_999 }) - ).toBe(true) - }) - - it('keeps two unfenced records equal', () => { - expect(recoveryRecordMatches(record(), record({ capturedAt: 9_999 }))).toBe(true) - }) -}) diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts index 1fa2baf79d9..660d104abf6 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts @@ -43,7 +43,6 @@ export function sleepingRecordsEquivalentIgnoringCaptureTime( existing.lastAssistantMessage === next.lastAssistantMessage && existing.interrupted === next.interrupted && existing.origin === next.origin && - existing.automaticResumeBlockedBy === next.automaticResumeBlockedBy && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) } @@ -55,10 +54,8 @@ export function recoveryRecordMatches( if (!existing) { return false } - // Why: completion or interruption must replace a pre-status working checkpoint. The resume fence - // is significant here: a rebuild that dropped it must never be accepted as an equal record. + // Why: completion or interruption must replace a pre-status working checkpoint. return ( - existing.automaticResumeBlockedBy === next.automaticResumeBlockedBy && existing.origin === next.origin && existing.agent === next.agent && existing.worktreeId === next.worktreeId && diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 8522e2bb5be..6363fb9e216 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -1,7 +1,6 @@ import type { AppState } from '../types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { - agentProviderSessionsEqual, getAgentResumeArgv, isResumableTuiAgent, type SleepingAgentLaunchConfig, @@ -19,51 +18,6 @@ export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAge } } -/** - * The record is the fence's durable home. `automaticResumeBlockedPaneKeys` only covers the window - * between a worker settling with its tab still open and the record being minted, and main re-seeds - * it on every renderer start — so a rebuild must never drop a flag the record already carries. - * Session identity gates the carry-over: a new provider session is new work, not fenced work. - */ -export function carriesAutomaticResumeBlock( - state: Pick, - next: Pick -): boolean { - if (state.automaticResumeBlockedPaneKeys?.[next.paneKey]) { - return true - } - const previous = state.sleepingAgentSessionsByPaneKey?.[next.paneKey] - return ( - previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - previous.agent === next.agent && - agentProviderSessionsEqual(next.agent, previous.providerSession, next.providerSession) - ) -} - -/** Async stops (manual sleep, hibernation rollback) commit a capture taken before the await, so a - * fence delivered or retired during the stop has to be re-read from state at commit time. */ -export function withCurrentAutomaticResumeBlock( - state: Pick, - records: Readonly> -): Record { - const next: Record = {} - for (const [paneKey, record] of Object.entries(records)) { - const blocked = carriesAutomaticResumeBlock(state, record) - if (blocked === (record.automaticResumeBlockedBy === 'legacy-orchestration-worker')) { - next[paneKey] = record - continue - } - const updated = { ...record } - if (blocked) { - updated.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete updated.automaticResumeBlockedBy - } - next[paneKey] = updated - } - return next -} - export function sleepingRecordFromEntry(args: { state: AppState entry: AgentStatusEntry @@ -104,14 +58,7 @@ export function sleepingRecordFromEntry(args: { : {}), ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), ...(args.entry.interrupted ? { interrupted: true } : {}), - ...(args.origin ? { origin: args.origin } : {}), - ...(carriesAutomaticResumeBlock(args.state, { - paneKey: args.entry.paneKey, - agent, - providerSession: args.entry.providerSession - }) - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } - : {}) + ...(args.origin ? { origin: args.origin } : {}) } } diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 1e4d11c4d7a..d7b3edccfd6 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -25,7 +25,6 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { - LegacyWorkerResumeFenceSnapshot, ResumableTuiAgent, SleepingAgentLaunchConfig, SleepingAgentSessionRecord @@ -52,13 +51,9 @@ export type AgentStatusSlice = { /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ sleepingAgentSessionsByPaneKey: Record - /** 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 - - /** Highest main fence-commit generation this renderer has applied, so a startup reply that lost - * a race with a later lift is dropped instead of reapplying a retired fence. */ - automaticResumeFenceGeneration: number + /** Runtime-authored: panes the orchestration authority fenced against automatic resume. Read + * from the workspace session and refreshed on main's invalidation ping; never written here. */ + legacyWorkerResumeFencesByPaneKey: Record /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ agentLaunchConfigByPaneKey: Record @@ -167,13 +162,8 @@ export type AgentStatusSlice = { captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: ( - paneKey: string, - blocked: boolean, - generation?: number - ) => void - /** Replace the blocked-pane set with main's committed fence state from the startup handshake. */ - applyLegacyWorkerResumeFenceSnapshot: (snapshot: LegacyWorkerResumeFenceSnapshot) => void + /** Install main's fenced-pane set wholesale. The only writer, and level-triggered. */ + setLegacyWorkerResumeFences: (fences: Record) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 10abccc5d61..8939f0506d5 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -51,10 +51,7 @@ export { collectSleepingAgentSessionRecordsForWorktree, collectHibernatedCompletionEvidenceForWorktree } from './agent-status-recovery-collection' -export { - removeSleepingRecordsReplacedByManualWorktreeSleep, - withCurrentAutomaticResumeBlock -} from './agent-status-sleeping-records' +export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-sleeping-records' export { buildAgentStatusTabPrefixDropPatch, type AgentStatusTabPrefixDropState @@ -103,8 +100,7 @@ export const createAgentStatusSlice: StateCreator, next: Record): boolean { + const nextKeys = Object.keys(next) + return ( + Object.keys(current).length === nextKeys.length && + nextKeys.every((paneKey) => current[paneKey] === true) + ) +} diff --git a/src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts b/src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts deleted file mode 100644 index ab4f28b401d..00000000000 --- a/src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' -import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' -import { - applySleepRuntimeRpcDefault, - createStoreCascadesMockApi -} from './store-cascades-test-harness' - -const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) -const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } -})) - -vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ - restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown, - unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const mockApi = createStoreCascadesMockApi() - -const WORKTREE_ID = 'repo1::/path/wt1' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const PANE_KEY = `tab-1:${LEAF_ID}` - -function storeWithAgentPane(state: 'working' | 'done') { - const store = createTestStore() - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] - }, - tabsByWorktree: { - [WORKTREE_ID]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE_ID, ptyId: 'pty-agent' })] - }, - ptyIdsByTabId: { 'tab-1': ['pty-agent'] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_ID }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: 'pty-agent' } - } - } - }) - store - .getState() - .setAgentStatus( - PANE_KEY, - { state, prompt: 'worker', agentType: 'claude' }, - 'Claude', - { updatedAt: 2000, stateStartedAt: 1000 }, - { tabId: 'tab-1', worktreeId: WORKTREE_ID }, - { providerSession: { key: 'session_id', id: 'session-1' } } - ) - return store -} - -const fenceOf = (store: ReturnType): string | undefined => - store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy - -// Both stop paths capture the record before awaiting the kill and commit that capture afterwards. -// A fence delivered while the kill is in flight lands on the live record, and committing the older -// capture verbatim erased it — leaving a settled worker automatically resumable again. -describe('a resume fence delivered while a pane stop is in flight', () => { - beforeEach(() => { - vi.clearAllMocks() - clearRuntimeCompatibilityCacheForTests() - mockApi.pty.kill.mockResolvedValue(undefined) - applySleepRuntimeRpcDefault(mockApi) - shutdownBufferCaptures.clear() - }) - - it('survives the manual-sleep capture committed after the stop', async () => { - const store = storeWithAgentPane('working') - mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - }) - - await store.getState().shutdownWorktreeTerminals(WORKTREE_ID, { keepIdentifiers: true }) - - expect(mockApi.pty.kill).toHaveBeenCalled() - expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - it('survives the rollback of a hibernation stop that threw', async () => { - const store = storeWithAgentPane('done') - mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - throw new Error('kill_failed') - }) - - await expect( - store.getState().shutdownCompletedAgentPaneForHibernation(WORKTREE_ID, { - paneKey: PANE_KEY, - tabId: 'tab-1', - leafId: LEAF_ID, - ptyId: 'pty-agent' - }) - ).rejects.toThrow('kill_failed') - - expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - // Control: the commit re-reads state, so a lift that lands during the stop is honoured too — the - // writer follows current authority rather than pinning whatever the capture happened to hold. - it('drops a fence the runtime retires during the stop', async () => { - const store = storeWithAgentPane('working') - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) - }) - - await store.getState().shutdownWorktreeTerminals(WORKTREE_ID, { keepIdentifiers: true }) - - expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBeUndefined() - expect(fenceOf(store)).toBeUndefined() - }) -}) diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index 7606aa1aca5..6d5df4eab50 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -9,8 +9,7 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { collectHibernatedCompletionEvidenceForWorktree, - collectSleepingAgentSessionRecordsForWorktree, - withCurrentAutomaticResumeBlock + collectSleepingAgentSessionRecordsForWorktree } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' @@ -70,8 +69,7 @@ export function createTerminalPaneHibernationActions( const current = get() if ( !isAutomaticHibernationAllowed({ - record: current.sleepingAgentSessionsByPaneKey[opts.paneKey], - automaticResumeBlockedPaneKeys: current.automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey: current.legacyWorkerResumeFencesByPaneKey, paneKey: opts.paneKey }) ) { @@ -106,11 +104,8 @@ export function createTerminalPaneHibernationActions( delete next[ptyId] } const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } - // Why re-read: a fence can arrive or retire while the kill is in flight, and restoring - // the pre-kill record verbatim would roll that back with it. - const restored = withCurrentAutomaticResumeBlock(s, replacedSleepingRecords) for (const key of sleepingRecordKeys) { - const replaced = restored[key] + const replaced = replacedSleepingRecords[key] if (replaced) { nextSleeping[key] = replaced } else { @@ -127,7 +122,7 @@ export function createTerminalPaneHibernationActions( }, sleepingAgentSessionsByPaneKey: { ...s.sleepingAgentSessionsByPaneKey, - ...withCurrentAutomaticResumeBlock(s, sleepingAgentSessionRecords) + ...sleepingAgentSessionRecords } })) if (expectedRuntimePtyIds.length > 0) { diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts index 098c8ddb962..389c8166a18 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-state.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -8,7 +8,6 @@ import { } from '@/components/terminal-pane/pty-shutdown-exit-deferral' import { removeSleepingRecordsReplacedByManualWorktreeSleep, - withCurrentAutomaticResumeBlock, type AgentStatusWorktreeShutdownReason, type RetainedAgentEntry } from '../slices/agent-status' @@ -160,9 +159,7 @@ export function commitTerminalShutdownState({ return { sleepingAgentSessionsByPaneKey: { ...base, - // Why re-read: the capture above predates the stop, and a fence can arrive or retire - // while the kill is in flight; committing the stale capture would erase it. - ...withCurrentAutomaticResumeBlock(state, sleepingAgentSessionRecords) + ...sleepingAgentSessionRecords } } }) diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index 3ea374a5475..2c64e76a52c 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -23,6 +23,7 @@ export type WorkspaceHydrationPatch = Pick< | 'closedTerminalTabTombstonesByTabId' | 'automaticAgentResumeClaimsByTabId' | 'sleepingAgentSessionsByPaneKey' + | 'legacyWorkerResumeFencesByPaneKey' | 'pendingReconnectWorktreeIds' | 'pendingReconnectTabByWorktree' | 'pendingReconnectPtyIdByTabId' @@ -197,6 +198,12 @@ export function targetScopedWorkspaceHydrationPatch( targetTabIds ), sleepingAgentSessionsByPaneKey, + // Why passed through whole: runtime-authored and keyed by pane, so there is no workspace key + // for replaceHydratedRecordKeys to scope, and a scoped rehydration must not drop other hosts'. + legacyWorkerResumeFencesByPaneKey: { + ...state.legacyWorkerResumeFencesByPaneKey, + ...hydrated.legacyWorkerResumeFencesByPaneKey + }, pendingReconnectWorktreeIds: [ ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 39be3c94f0f..6f0e9d189a7 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -194,6 +194,9 @@ export function createWorkspaceTerminalHydrationActions( closedTerminalTabTombstonesByTabId: session.closedTerminalTabTombstonesByTabId ?? {}, automaticAgentResumeClaimsByTabId: {}, sleepingAgentSessionsByPaneKey, + // Runtime-authored: installed exactly as the session holds it, never merged with local + // state, because main is its only author and this read is the whole truth. + legacyWorkerResumeFencesByPaneKey: session.legacyWorkerResumeFencesByPaneKey ?? {}, pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index 1a07b6d6a6c..d39286679c7 100644 --- a/src/renderer/src/web/preload-api/web-agent-status-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-status-api.ts @@ -12,7 +12,7 @@ export function createWebAgentStatusApi(): Partial { onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, - onLegacyWorkerTerminalResumeFence: () => noopUnsubscribe, + onLegacyWorkerTerminalResumeFencesChanged: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), drop: () => {}, dropPersisted: () => {}, diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index f6db82659e8..771b946e10f 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -35,10 +35,9 @@ export function createWebAppApi(): Partial { awaitFirstWindowStartupServices: () => Promise.resolve(), awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), - // No wire method carries the worker resume fence yet, so a paired/web client cannot learn - // about a fenced pane that has no sleeping record. Tracked as a follow-up. - recoverLegacyWorkerTerminalsForRendererStartup: () => - Promise.resolve({ generation: 0, blockedPaneKeys: [] }), + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), + // The fences arrive with the host session it hydrates, so there is nothing to re-read here. + getLegacyWorkerResumeFences: () => Promise.resolve({}), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), // The web client cannot inspect local Mission Control shortcuts. diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 2d6792432e4..3e763fc20a6 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -73,15 +73,6 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } -/** The committed fenced-pane set main hands a starting renderer. The renderer's own blocked-pane - * map is volatile — it starts empty on every renderer boot — so it must be re-derived here, not - * pushed. `generation` counts main's fence commits: it orders this reply against the live pushes - * so a reply that lost a race with a later lift is dropped instead of reapplying a retired fence. */ -export type LegacyWorkerResumeFenceSnapshot = { - generation: number - blockedPaneKeys: string[] -} - const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) const PROVIDER_SESSION_ID_MAX_LENGTH = 512 diff --git a/src/shared/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index be2e4401818..4b7e2c3b1ee 100644 --- a/src/shared/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -47,6 +47,9 @@ export const WORKSPACE_SESSION_FIELD_OWNERSHIP = { browserPagesByWorkspace: 'browserWorkspaceKeyed', markdownFrontmatterVisible: 'fileKeyed', sleepingAgentSessionsByPaneKey: 'sleepingAgentKeyed', + // Runtime-authored like clientHostedBrowserPagesByWorktree, and pane-keyed so a merged read + // routes each pane's fence back to the host whose orchestration DB issued it. + legacyWorkerResumeFencesByPaneKey: 'paneKeyed', terminalPtyIncarnationsByPaneKey: 'paneKeyed', // Why: this host-issued fence must never collide while unified renderer state merges equal repo ids across hosts. terminalTopologyRevisionByRepoId: 'hostPrivate', diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index d371d488b67..3270c4be243 100644 --- a/src/shared/workspace-session-schema-field-coverage.test.ts +++ b/src/shared/workspace-session-schema-field-coverage.test.ts @@ -50,6 +50,7 @@ const PERSISTED_WORKSPACE_SESSION_FIELDS = { lastVisitedAtByWorktreeId: true, defaultTerminalTabsAppliedByWorktreeId: true, sleepingAgentSessionsByPaneKey: true, + legacyWorkerResumeFencesByPaneKey: true, terminalPtyIncarnationsByPaneKey: true, terminalTopologyRevisionByRepoId: true, terminalSurfaceTombstonesByPaneKey: true, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 48fe00a7f4d..96320bc9022 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -291,6 +291,10 @@ export const workspaceSessionStateSchema: z.ZodType = z.o 'sleepingAgentSessionsByPaneKey', sleepingAgentSessionsByPaneKeySchema ), + legacyWorkerResumeFencesByPaneKey: salvagedOptional( + 'legacyWorkerResumeFencesByPaneKey', + salvagingRecord(z.string(), z.literal(true)) + ), terminalPtyIncarnationsByPaneKey: salvagedOptional( 'terminalPtyIncarnationsByPaneKey', salvagingRecord(z.string(), z.string().min(1).max(128)) diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index e0cc7190cbf..5ebfa8b5bf0 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -112,6 +112,13 @@ export type WorkspaceSessionState = { defaultTerminalTabsAppliedByWorktreeId?: Record /** Provider-session resume records captured when workspaces sleep. */ sleepingAgentSessionsByPaneKey?: Record + /** + * Runtime-authored: panes the orchestration authority fenced against automatic resume, keyed by + * pane key. Written only by the runtime that owns the orchestration DB and never by a renderer, + * so a renderer's ordinary session write cannot erase a fence. Present whether or not the pane + * has a sleeping record yet, which is the case a settled worker with its tab still open is in. + */ + legacyWorkerResumeFencesByPaneKey?: Record /** Host-issued process incarnation for each durable terminal surface. */ terminalPtyIncarnationsByPaneKey?: Record /** Monotonic host authority watermark for terminal membership in each repo. */ diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index 7ad18c6b246..381495852ce 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -195,6 +195,26 @@ export async function listRuntimeTerminals( return (await client.call('terminal.list')).result.terminals } +/** The runtime-authored fence, read from the profile's persisted session. */ +export function readPersistedWorkerResumeFence( + userDataDir: string, + paneKey: string +): true | undefined { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + if (!existsSync(dataPath)) { + return undefined + } + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { legacyWorkerResumeFencesByPaneKey?: Record } + } + return data.workspaceSession?.legacyWorkerResumeFencesByPaneKey?.[paneKey] +} + export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { const dataPath = path.join( userDataDir, diff --git a/tests/e2e/settled-worker-resume-fence-restart.spec.ts b/tests/e2e/settled-worker-resume-fence-restart.spec.ts index 024068ffd98..d8abe6a9761 100644 --- a/tests/e2e/settled-worker-resume-fence-restart.spec.ts +++ b/tests/e2e/settled-worker-resume-fence-restart.spec.ts @@ -18,7 +18,7 @@ import { listRuntimeTerminals, readCompletedWorkerDispatchCapability, readCompletedWorkerLedger, - readPersistedWorkerRecoveryRecord, + readPersistedWorkerResumeFence, seedCurrentCodexTranscript } from './helpers/completed-worker-retirement-fixture' import { RuntimeClient } from '../../src/cli/runtime-client' @@ -263,27 +263,27 @@ test('a settled orchestration worker keeps its resume fence across restart and r { orchestrationCapability: dispatchCapability } ) expect(completed.result.message.type).toBe('worker_done') - // The settlement sweep stamps the resume fence on the renderer's record before the tab closes. + // The settlement sweep writes the runtime-authored fence set, and main's invalidation ping + // makes the live renderer re-read it while the tab is still open. await expect .poll( () => first.page.evaluate( (paneKey) => - window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] - ?.automaticResumeBlockedBy ?? null, + window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, workerPaneKey ), { timeout: 30_000, message: 'settled worker pane was never fenced' } ) - .toBe('legacy-orchestration-worker') + .toBe(true) await session.close(firstApp) firstApp = null await expect - .poll(() => readPersistedWorkerRecoveryRecord(session.userDataDir, workerPaneKey), { - message: 'the settled worker record never reached disk carrying its fence' + .poll(() => readPersistedWorkerResumeFence(session.userDataDir, workerPaneKey), { + message: 'the runtime-authored fence never reached disk' }) - .toMatchObject({ automaticResumeBlockedBy: 'legacy-orchestration-worker' }) + .toBe(true) expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([]) const second = await session.launch() @@ -308,9 +308,9 @@ test('a settled orchestration worker keeps its resume fence across restart and r ) ).toBe(true) - // The record is the fence's durable home. Deriving it from the renderer's volatile blocked-pane - // map alone let the first status write after any restart erase it, and worktree activation then - // relaunched the settled worker with `--resume` over its still-live PTY (#16904 regression). + // No record writer can erase the fence, because no record carries it: the pre-fix bug was a + // rebuilt record dropping the flag, after which worktree activation relaunched the settled + // worker with `--resume` over its still-live PTY (#16904). Drive the same status write. await second.page.evaluate( ({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => { window.__store?.getState().setAgentStatus( @@ -339,16 +339,14 @@ test('a settled orchestration worker keeps its resume fence across restart and r ) expect( await second.page.evaluate( - (paneKey) => - window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] - ?.automaticResumeBlockedBy ?? null, + (paneKey) => window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, workerPaneKey ), 'a status write after restart must not erase the fence' - ).toBe('legacy-orchestration-worker') + ).toBe(true) - // A renderer reload starts `automaticResumeBlockedPaneKeys` empty, so main must hand the fenced - // pane set back on the startup handshake; a once-per-process push never survives the reload. + // A renderer reload rebuilds the store from the session, and the fence is a field of it, so it + // comes back with ordinary hydration rather than a handshake reply the reload could lose. await second.page.reload() await waitForSessionReady(second.page) await expect @@ -356,7 +354,7 @@ test('a settled orchestration worker keeps its resume fence across restart and r () => second.page.evaluate( (paneKey) => - window.__store?.getState().automaticResumeBlockedPaneKeys[paneKey] === true, + window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, workerPaneKey ), { timeout: 60_000, message: 'the reloaded renderer never re-seeded the resume fence' }