From 4203db2457144b372dd44c797f0939bab2bf8cd0 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 18:03:10 -0400 Subject: [PATCH] refactor(orchestration): give the settled-worker fence one owner and one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fence was main-owned state stored inside a renderer-owned field. `sleepingAgentSessionsByPaneKey` is co-authored: the renderer emits the whole map and main applies it as a wholesale replace, so main's stamp survived only until the renderer's next write. That inversion is what produced the original P0 — the fence was erased on disk, not just in memory — and every guard added since was compensation for it: a carry predicate at each record builder, two equality hunks behind that predicate, a volatile pane-key map with a seeded startup reply, and main-side bookkeeping to synthesise lift edges for panes with no record. The fence now lives in `legacyWorkerResumeFencesByPaneKey`, a runtime-authored session field classified like `clientHostedBrowserPagesByWorktree`. It is absent from the renderer's patch builder, so the renderer cannot write it, and the persistence layer carries it across any write that never mentions it. Main rewrites the whole set every recovery pass, record or no record. Level-triggered: no announcement to consume once, no edge to miss, no generation to compare. The renderer reads it through ordinary session hydration and re-reads it when main pings that it changed; the ping carries no state, and reads are coalesced so the last read is the last write. Hibernation eligibility, the resume decision, the reattach choice and the park exemption all consult that one field. `automaticResumeBlockedBy` stays on the record as an outbound projection for clients too old to read the new field, stamped in `buildSleepingAgentSessionData` alone — the single function both persistence paths build records through. Deleted: the pane-key map and its writer, the startup seed helper and the reply shape it needed, `carriesAutomaticResumeBlock` and its call in every record writer, both equivalence hunks, `lastPlanBlockedPaneKeys`, the fence generation, and the record-sweeping half of `liftRetiredFences`. All five Codex findings and all five Opus findings are now unreachable rather than handled. --- ...ofile-project-session-field-disposition.ts | 7 + .../session-worktree-ownership.ts | 1 + ...-authored-workspace-session-fields.test.ts | 44 ++ ...ntime-authored-workspace-session-fields.ts | 28 +- .../orca-runtime-fence-automation-owner.ts | 13 + .../orca-runtime-preserved-branch-cleanup.ts | 4 +- ...output-and-worker-recovery-part-02.spec.ts | 4 +- ...output-and-worker-recovery-part-03.spec.ts | 4 +- ...output-and-worker-recovery-part-04.spec.ts | 9 +- ...output-and-worker-recovery-part-05.spec.ts | 15 +- ...output-and-worker-recovery-part-06.spec.ts | 8 +- ...cy-worker-terminal-recovery-persistence.ts | 186 +++---- ...-legacy-worker-terminal-recovery-runner.ts | 3 - ...e-legacy-worker-terminal-recovery-types.ts | 6 - ...egacy-worker-terminal-resume-fence.test.ts | 526 +++++------------- src/main/runtime/runtime-notifier-contract.ts | 3 +- .../legacy-worker-renderer-recovery.ts | 15 +- .../startup/main-process-ipc-bootstrap.ts | 6 + src/main/window/runtime-window-lifecycle.ts | 4 +- src/preload/api/agent-status-api.ts | 6 +- src/preload/api/agent-status-bridge.ts | 14 +- src/preload/api/app-api.ts | 8 +- src/preload/api/app-bridge.ts | 5 +- .../app-shell/use-app-startup-hydration.ts | 5 +- src/renderer/src/app-startup-routing.test.ts | 7 +- ...ty-connection-agent-session-resume.test.ts | 18 +- .../pty-connection-test-store-fixtures.ts | 1 + .../pty-connection/sleeping-record-access.ts | 8 +- .../sleeping-record-park-exemption.test.ts | 26 +- .../sleeping-record-park-exemption.ts | 8 +- ...k-subscription-narrowing.react185.test.tsx | 10 +- .../use-terminal-tab-cold-parking.test.ts | 7 +- .../use-terminal-tab-cold-parking.ts | 6 +- .../ipc-events/agent-status-listeners.ts | 7 +- .../src/hooks/useIpcEvents-lifecycle.test.ts | 4 +- .../src/lib/agent-hibernation-coordinator.ts | 2 +- ...tion-pane-eligibility-resume-fence.test.ts | 44 +- .../lib/agent-hibernation-pane-eligibility.ts | 7 +- .../lib/agent-hibernation-planner-snapshot.ts | 4 +- .../src/lib/agent-hibernation-planner.test.ts | 9 +- .../src/lib/agent-hibernation-planner.ts | 2 +- ...legacy-worker-resume-fence-refresh.test.ts | 74 +++ .../lib/legacy-worker-resume-fence-refresh.ts | 35 ++ .../src/lib/live-resume-anchor-record.ts | 17 +- ...eeping-agent-session-legacy-worker.test.ts | 4 +- .../src/lib/resume-sleeping-agent-session.ts | 2 +- .../src/lib/workspace-session-patch.ts | 7 +- .../workspace-session-relevant-fields.test.ts | 1 + .../lib/workspace-session-sleeping-agents.ts | 32 +- src/renderer/src/lib/workspace-session.ts | 3 + .../legacy-worker-resume-fence-seed.test.ts | 75 --- .../legacy-worker-resume-fence-seed.ts | 14 - .../src/startup/startup-degraded-recovery.ts | 5 +- ...agent-status-hydrated-resume-fence.test.ts | 124 ----- .../agent-status-manual-sleep-capture.test.ts | 27 +- ...agent-status-open-tab-resume-fence.test.ts | 106 +++- .../agent-status-provider-session-actions.ts | 5 +- .../agent-status-provider-session.test.ts | 65 +-- .../slices/agent-status-recovery-actions.ts | 88 +-- .../agent-status-recovery-equivalence.test.ts | 59 -- .../agent-status-recovery-equivalence.ts | 5 +- .../slices/agent-status-sleeping-records.ts | 55 +- .../slices/agent-status-slice-contract.ts | 20 +- src/renderer/src/store/slices/agent-status.ts | 8 +- .../slices/legacy-worker-resume-fences.ts | 9 + ...-sleep-fence-delivered-during-stop.test.ts | 129 ----- .../terminals/terminal-pane-hibernation.ts | 13 +- .../terminals/terminal-shutdown-state.ts | 5 +- .../workspace-terminal-hydration-patch.ts | 7 + .../terminals/workspace-terminal-hydration.ts | 3 + .../web/preload-api/web-agent-status-api.ts | 2 +- .../src/web/preload-api/web-app-api.ts | 7 +- src/shared/agent-session-resume.ts | 9 - .../workspace-session-host-field-ownership.ts | 3 + ...pace-session-schema-field-coverage.test.ts | 1 + src/shared/workspace-session-schema.ts | 4 + src/shared/workspace-session-state-types.ts | 7 + .../completed-worker-retirement-fixture.ts | 20 + ...ettled-worker-resume-fence-restart.spec.ts | 34 +- 79 files changed, 789 insertions(+), 1389 deletions(-) create mode 100644 src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts create mode 100644 src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts delete mode 100644 src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts delete mode 100644 src/renderer/src/startup/legacy-worker-resume-fence-seed.ts delete mode 100644 src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts delete mode 100644 src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts create mode 100644 src/renderer/src/store/slices/legacy-worker-resume-fences.ts delete mode 100644 src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts 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' }