diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 899914339c76..7506bc50f37c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,89 @@ } }, "gates": [ + { + "id": "terminal-recovery.settled-worker-resume-fence", + "title": "Runtime-owned settled-worker resume fences survive publication and hydration", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "store-runtime-unit-and-electron-cdp", + "surfaces": ["workspace session hydration", "automatic agent resume"], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "daemon"], + "coverageNotes": "Deterministic local/SSH partition and mixed-source tests; hidden desktop restart/reload and retirement E2Es. No live independently versioned paired-server or physical Windows/Linux claim.", + "motivatingLinks": ["https://github.com/stablyai/orca/pull/19344"], + "invariant": "A runtime-authored settled-worker fence survives old writers, absent old-source authority, overlapping hydration, and sibling host write failure; authoritative scoped retirement preserves sibling fences.", + "oracle": "Nine round-2 reviewer probes plus atomic pending-hydration and publication/read-boundary tests; queue-only-fence, absent-authority supersession, scope accumulation, and stale-read mutations must fail alongside the six round-3 fix mutations. Restart/reload does not resume a fenced worker, while explicit retirement permits resume.", + "commands": [ + "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.ts src/renderer/src/lib/runtime-session-ssh-projection-authority.test.ts src/renderer/src/lib/runtime-session-pending-hydration.test.ts src/renderer/src/lib/runtime-session-application.test.ts src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts src/main/runtime/runtime-legacy-worker-session-publication.test.ts src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts src/renderer/src/lib/legacy-worker-old-source-resume.test.ts src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts src/renderer/src/store/slices/workspace-scoped-resume-fence-hydration.test.ts src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access-recordless-fence.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-resume-fence-restart.spec.ts tests/e2e/completed-worker-retirement-resume.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1" + ], + "testFiles": [ + "src/renderer/src/lib/runtime-session-ssh-projection-authority.test.ts", + "src/renderer/src/lib/runtime-session-pending-hydration.test.ts", + "src/renderer/src/lib/runtime-session-application.test.ts", + "src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts", + "src/main/runtime/runtime-legacy-worker-session-publication.test.ts", + "src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts", + "src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts", + "src/renderer/src/lib/legacy-worker-old-source-resume.test.ts", + "src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts", + "src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts", + "src/renderer/src/store/slices/workspace-scoped-resume-fence-hydration.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access-recordless-fence.test.ts", + "tests/e2e/settled-worker-resume-fence-restart.spec.ts", + "tests/e2e/completed-worker-retirement-resume.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/runtime-session-application.test.ts", + "assertions": [ + "applies startup runtime fields at read time, before delayed catalog hydration", + "orders a scoped retirement after an in-flight invalidation and retains its sibling", + "publishes sleeping records and their authority in a single subscriber notification", + "full supersession remains full after scoped hydration", + "two overlapping scoped hydrations retain both scopes and accept a third scope reply" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-07", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 SKIP_BUILD=1 npx playwright test tests/e2e/settled-worker-resume-fence-restart.spec.ts tests/e2e/completed-worker-retirement-resume.spec.ts --config tests/playwright.config.ts --project=electron-headless --workers=1", + "result": "passed", + "durationSeconds": 24.8, + "summary": "Three hidden Electron tests pass on a fresh build, with isolated homes/profiles." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "Reviewer probes and paired hidden Electron specs." + }, + "flakeHistory": { + "status": "not-started", + "evidence": "Local acceptance runs only; no CI soak claimed." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Baseline reviewer overlay: 7 failures / 29 tests. Candidate passes. All six fix mutations and two additional ordering/scoping mutations fail checked-in assertions." + }, + "performanceBudget": { + "required": false, + "evidence": "One serialized session application lane and coalesced invalidations; no polling or per-pane subprocesses." + }, + "knownGaps": [ + "No live independently versioned paired runtime, physical Windows/Linux, or SSH transport journey." + ], + "promotionCriteria": [ + "Complete CI soak and cross-platform runtime validation without weakening the mutation oracles." + ], + "demotionRule": "Keep experimental until the declared CI soak is complete." + }, { "id": "terminal-performance.padded-fullscreen-redraw", "title": "Fullscreen redraw padding does not stall terminal delivery", 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 e1831ff3443f..1abef2d7e6b6 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/legacy-worker-session-projection.ts b/src/main/persistence/legacy-worker-session-projection.ts new file mode 100644 index 000000000000..33d03045fcd4 --- /dev/null +++ b/src/main/persistence/legacy-worker-session-projection.ts @@ -0,0 +1,25 @@ +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +// Old readers consume a derived record flag; only the runtime-authored set can retire it. +export function projectLegacyWorkerSession(session: WorkspaceSessionState): WorkspaceSessionState { + const fences = session.legacyWorkerResumeFencesByPaneKey + const records = session.sleepingAgentSessionsByPaneKey + if (fences === undefined || !records) { + return session + } + let projected: typeof records | 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 projected ? { ...session, sleepingAgentSessionsByPaneKey: projected } : session +} diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index 353c89da4e56..a5af943270c5 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -1,3 +1,4 @@ +import { projectLegacyWorkerSession } from '../legacy-worker-session-projection' import type { PersistedState } from '../../../shared/persisted-state-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { sanitizeWorkspaceSessionTerminalRetirements } from '../../runtime/mobile-session-terminal-persistence-retirement' @@ -55,12 +56,12 @@ export class SessionHostPartitionOperations { getWorkspaceSession(hostId?: string | null): PersistedState['workspaceSession'] { const resolved = resolveHostId(hostId) if (resolved === LOCAL_EXECUTION_HOST_ID) { - return ( + return projectLegacyWorkerSession( this[sessionHostPartitionOperationsContext].runtime.state.workspaceSession ?? - getDefaultWorkspaceSession() + getDefaultWorkspaceSession() ) } - return ( + return projectLegacyWorkerSession( this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId?.[ resolved ] ?? getDefaultWorkspaceSession() diff --git a/src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts b/src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts new file mode 100644 index 000000000000..b33ca30a3e56 --- /dev/null +++ b/src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts @@ -0,0 +1,249 @@ +/** + * Drives the real `Store`, not the helper and not a fake. + * + * `preserveRuntimeAuthoredWorkspaceSessionFields` is only worth anything where the shipping writers + * call it, and a mutation that unwires it at the call site survives every test that drives the + * helper directly. Both desktop write paths belong here: the ordinary session write and the + * before-unload stage, which is the one the desktop actually takes when the user quits. + */ +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PersistedClientHostedBrowserPage } from '../../../shared/client-hosted-browser-page-record' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') + +const HOST_ID = 'ssh:user@host' +const WT = 'repo-1::/tmp/worktree-a' + +const stores: InstanceType[] = [] + +afterEach(() => { + // Leaving a debounced save armed would write into a temp dir after the test file finishes. + for (const store of stores.splice(0)) { + store.flush() + } + vi.restoreAllMocks() +}) + +function createStore(): InstanceType { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-runtime-authored-'))) + const store = new Store({ dataFile: join(dir, 'orca-data.json') }) + stores.push(store) + return store +} + +function page(): PersistedClientHostedBrowserPage { + return { + v: 1, + browserPageId: 'page-a', + workspaceId: WT, + browserProfileId: 'default', + url: 'https://example.test/a', + title: 'A', + pairedDeviceId: 'device-1', + savedAt: 1 + } +} + +/** What the renderer builds from Zustand: it has never heard of the runtime-authored field. */ +function rendererSession(activeTabId: string): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + } as WorkspaceSessionState +} + +function seedRuntimeRows(store: InstanceType, hostId?: string): void { + store.setWorkspaceSession( + { + ...rendererSession('seed'), + clientHostedBrowserPagesByWorktree: { [WT]: [page()] } + }, + hostId + ) +} + +describe('Store keeps runtime-authored session fields across desktop writes', () => { + it('preserves the rows when the renderer writes the local session', () => { + const store = createStore() + seedRuntimeRows(store) + + store.setWorkspaceSession(rendererSession('after-write')) + + expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({ + [WT]: [page()] + }) + expect(store.getWorkspaceSession().activeTabId).toBe('after-write') + }) + + it('preserves the rows on the quit path the desktop actually takes', () => { + const store = createStore() + seedRuntimeRows(store) + + // The beforeunload chain (use-app-session-persistence -> stageBeforeUnloadSync -> + // renderer-shutdown-checkpoint) lands here, and it is the last write before the process exits. + store.stageWorkspaceSessionBeforeUnload(rendererSession('before-unload')) + + expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({ + [WT]: [page()] + }) + }) + + it('preserves the rows on a remote host partition, on both write paths', () => { + const store = createStore() + seedRuntimeRows(store, HOST_ID) + + store.setWorkspaceSession(rendererSession('after-write'), HOST_ID) + expect(store.getWorkspaceSession(HOST_ID).clientHostedBrowserPagesByWorktree).toEqual({ + [WT]: [page()] + }) + + store.stageWorkspaceSessionBeforeUnload(rendererSession('before-unload'), HOST_ID) + expect(store.getWorkspaceSession(HOST_ID).clientHostedBrowserPagesByWorktree).toEqual({ + [WT]: [page()] + }) + }) + + it('still lets the runtime clear its own rows', () => { + const store = createStore() + seedRuntimeRows(store) + + // The runtime writes an empty map rather than omitting the field, which is what separates its + // authoritative clear from a renderer write that simply never mentions it. + store.setWorkspaceSession({ + ...rendererSession('runtime-clear'), + clientHostedBrowserPagesByWorktree: {} + }) + + expect(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree).toEqual({}) + }) + + it('drops the rows for a removed worktree and keeps its siblings', () => { + const store = createStore() + const other = 'repo-1::/tmp/worktree-b' + store.setWorkspaceSession({ + ...rendererSession('seed'), + clientHostedBrowserPagesByWorktree: { + [WT]: [page()], + [other]: [{ ...page(), browserPageId: 'page-b', workspaceId: other }] + } + }) + + store.removeWorkspaceSessionStateForWorktree(WT) + + expect( + Object.keys(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree ?? {}) + ).toEqual([other]) + }) + + it('still lets the runtime drop one worktree while keeping another', () => { + const store = createStore() + const other = 'repo-1::/tmp/worktree-b' + store.setWorkspaceSession({ + ...rendererSession('seed'), + clientHostedBrowserPagesByWorktree: { + [WT]: [page()], + [other]: [{ ...page(), browserPageId: 'page-b', workspaceId: other }] + } + }) + + store.setWorkspaceSession({ + ...rendererSession('runtime-partial-clear'), + clientHostedBrowserPagesByWorktree: { + [other]: [{ ...page(), browserPageId: 'page-b', workspaceId: other }] + } + }) + + expect( + Object.keys(store.getWorkspaceSession().clientHostedBrowserPagesByWorktree ?? {}) + ).toEqual([other]) + }) +}) + +it('preserves fence across real full, patch and before-unload writes on local and SSH hosts', () => { + const store = createStore() + for (const host of ['local', HOST_ID]) { + store.setWorkspaceSession( + { ...rendererSession('a'), legacyWorkerResumeFencesByPaneKey: { 'tab:leaf': true } }, + host + ) + store.setWorkspaceSession(rendererSession('b'), host) + expect(store.getWorkspaceSession(host).legacyWorkerResumeFencesByPaneKey).toEqual({ + 'tab:leaf': true + }) + store.patchWorkspaceSession({ activeTabId: 'c' }, host) + expect(store.getWorkspaceSession(host).legacyWorkerResumeFencesByPaneKey).toEqual({ + 'tab:leaf': true + }) + store.stageWorkspaceSessionBeforeUnload(rendererSession('d'), host) + expect(store.getWorkspaceSession(host).legacyWorkerResumeFencesByPaneKey).toEqual({ + 'tab:leaf': true + }) + } +}) + +it.each([undefined, HOST_ID])( + 'publishes old-client flags after old-writer replacement on %s', + (host) => { + const store = createStore() + const paneKey = 'tab:11111111-2222-4333-8444-555555555555' + const record = { + paneKey, + tabId: 'tab', + worktreeId: WT, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'provider' }, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const + } + const oldWriter = { + ...rendererSession('tab'), + sleepingAgentSessionsByPaneKey: { [paneKey]: record } + } + store.setWorkspaceSession( + { ...oldWriter, legacyWorkerResumeFencesByPaneKey: { [paneKey]: true } }, + host + ) + store.stageWorkspaceSessionBeforeUnload(oldWriter, host) + expect( + store.getWorkspaceSession(host).sleepingAgentSessionsByPaneKey?.[paneKey] + ?.automaticResumeBlockedBy + ).toBe('legacy-orchestration-worker') + expect(record).not.toHaveProperty('automaticResumeBlockedBy') + store.setWorkspaceSession( + { ...store.getWorkspaceSession(host), legacyWorkerResumeFencesByPaneKey: {} }, + host + ) + expect( + store.getWorkspaceSession(host).sleepingAgentSessionsByPaneKey?.[paneKey] + ?.automaticResumeBlockedBy + ).toBeUndefined() + } +) diff --git a/src/main/persistence/restoring-sessions/session-worktree-ownership.ts b/src/main/persistence/restoring-sessions/session-worktree-ownership.ts index 5c54af92975b..e3571c872af3 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 4badf344d41f..57d613a24c99 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 a07454776cda..6deaf57009ed 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 a90730c78868..ddf67471c9a8 100644 --- a/src/main/runtime/orca-runtime-fence-automation-owner.ts +++ b/src/main/runtime/orca-runtime-fence-automation-owner.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { readWorkspaceSessionResumeFences } from '../../shared/workspace-session-resume-fences' import { OrcaRuntimeWithPtyForegroundProcessReads } from './orca-runtime-pty-foreground-process-reads' import type { AutomationOwnerFenceOperation, @@ -20,6 +21,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 +173,21 @@ 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) { + const session = store?.getWorkspaceSession?.(hostId) + if (session) { + Object.assign(fences, readWorkspaceSessionResumeFences(session)) + } + } + 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 d53994032a18..4dd1620bed56 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -137,7 +137,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin () => this.store, () => this.getOrchestrationDb(), (worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId), - (paneKey, blocked) => this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked) + () => this.notifier?.legacyWorkerTerminalResumeFencesChanged?.() ) protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({ 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 3c61985e597b..c6923694972c 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 44edd9aa371b..caa020f0e6e1 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 d09b4c64ce81..d16f82be26c0 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([]) 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 5798916570e3..3b1b6762fb84 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 4078a291ab54..6fb536dd8bf0 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-host-write-publication.test.ts b/src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts new file mode 100644 index 000000000000..772976f242de --- /dev/null +++ b/src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts @@ -0,0 +1,109 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' +import type { RuntimeStore } from './runtime-store-contract' +import { it, expect, vi } from 'vitest' +import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' +it('announces a successfully written host when a sibling fails and disappears before retry', () => { + let plan: LegacyWorkerTerminalRecoveryPlan = { + blockedPanes: [ + { paneKey: 'a', worktreeId: 'local', contractVersion: 1, settled: true }, + { paneKey: 'b', worktreeId: 'ssh:x', contractVersion: 1, settled: true } + ], + candidates: [], + ambiguousDispatchIds: [] + } + const sessions: Record = { + local: getDefaultWorkspaceSession(), + 'ssh:x': getDefaultWorkspaceSession() + } + let fail = true + const notify = vi.fn() + const store = { + getWorkspaceSessionHostIds: () => ['local', 'ssh:x'], + getWorkspaceSession: (host: string) => sessions[host], + setWorkspaceSession: (next: WorkspaceSessionState, host: string) => { + if (fail && host === 'ssh:x') { + throw new Error('host write failed') + } + sessions[host] = next + } + } + const p = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store as unknown as RuntimeStore, + () => null as never, + (wt) => wt as never, + notify + ) + vi.spyOn( + p as unknown as { getPlan: () => LegacyWorkerTerminalRecoveryPlan }, + 'getPlan' + ).mockImplementation(() => plan) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + p.prepare() + } finally { + warn.mockRestore() + } + expect(sessions.local.legacyWorkerResumeFencesByPaneKey).toEqual({ a: true }) + expect(notify).toHaveBeenCalledTimes(1) + fail = false + plan = { + ...plan, + blockedPanes: [{ paneKey: 'a', worktreeId: 'local', contractVersion: 1, settled: true }] + } + p.prepare() + expect(notify).toHaveBeenCalled() +}) + +it.each([true, false])( + 'publishes canonical authority for a legacy-only record (fenced=%s)', + (fenced) => { + const paneKey = 'worker:11111111-2222-4333-8444-555555555555' + let session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'worker', + worktreeId: 'folder:worker', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'live', + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + } + } + const notify = vi.fn() + const setWorkspaceSession = vi.fn((next: WorkspaceSessionState) => { + session = next + }) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => + ({ getWorkspaceSession: () => session, setWorkspaceSession }) as unknown as RuntimeStore, + () => null as never, + () => 'local', + notify + ) + vi.spyOn( + persistence as unknown as { getPlan: () => LegacyWorkerTerminalRecoveryPlan }, + 'getPlan' + ).mockReturnValue({ + blockedPanes: fenced + ? [{ paneKey, worktreeId: 'folder:worker', contractVersion: 1, settled: true }] + : [], + candidates: [], + ambiguousDispatchIds: [] + }) + persistence.prepare() + expect(setWorkspaceSession).toHaveBeenCalledOnce() + expect(session.legacyWorkerResumeFencesByPaneKey).toEqual(fenced ? { [paneKey]: true } : {}) + expect(notify).toHaveBeenCalledOnce() + persistence.prepare() + expect(setWorkspaceSession).toHaveBeenCalledOnce() + } +) diff --git a/src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts b/src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts new file mode 100644 index 000000000000..3e53625f1c0d --- /dev/null +++ b/src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts @@ -0,0 +1,93 @@ +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' +import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' +import type { RuntimeStore } from './runtime-store-contract' +import { it, expect, vi } from 'vitest' +import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' +import { runLegacyWorkerTerminalRecovery } from './runtime-legacy-worker-terminal-recovery-runner' +function harness() { + let plan: LegacyWorkerTerminalRecoveryPlan = { + blockedPanes: [{ paneKey: 'a', worktreeId: 'wt', contractVersion: 1, settled: true }], + candidates: [], + ambiguousDispatchIds: [] + } + let session: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: {} + } + let fail = false + const notify = vi.fn() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + if (fail) { + throw new Error('stage failed') + } + session = next + }, + getWorkspaceSessionHostIds: () => ['local'] + } + const p = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store as unknown as RuntimeStore, + () => null as never, + () => 'local' as never, + notify + ) + vi.spyOn( + p as unknown as { getPlan: () => LegacyWorkerTerminalRecoveryPlan }, + 'getPlan' + ).mockImplementation(() => plan) + const ports = { + preparePlan: () => p.prepare(), + persist: async () => new Set(), + updateRetry: () => {}, + reconcileRequestedReleases: async () => {} + } as unknown as LegacyWorkerRecoveryPorts + return { + p, + ports, + notify, + fences: () => session.legacyWorkerResumeFencesByPaneKey ?? {}, + clear: () => { + plan = { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } + }, + fail: (v: boolean) => { + fail = v + } + } +} +it('a stale recovery result cannot reinstall a retired fence', async () => { + const h = harness() + let release!: (x: ReadonlySet) => void + h.ports.persist = () => + new Promise((r) => { + release = r + }) + const recovery = runLegacyWorkerTerminalRecovery({} as never, h.ports, {}) + expect(h.fences()).toEqual({ a: true }) + h.clear() + h.p.prepare() + release(new Set()) + const result = await recovery + expect(result).not.toHaveProperty('blockedPaneKeys') + expect(h.fences()).toEqual({}) + h.p.prepare() + expect(h.fences()).toEqual({}) +}) +it('failed staging does not publish an untracked fence', async () => { + const h = harness() + h.fail(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await runLegacyWorkerTerminalRecovery({} as never, h.ports, {}) + } finally { + warn.mockRestore() + } + expect(h.fences()).toEqual({}) + expect(h.notify).not.toHaveBeenCalled() + h.clear() + h.fail(false) + h.p.prepare() + expect(h.fences()).toEqual({}) +}) diff --git a/src/main/runtime/runtime-legacy-worker-session-publication.test.ts b/src/main/runtime/runtime-legacy-worker-session-publication.test.ts new file mode 100644 index 000000000000..e35db05281fb --- /dev/null +++ b/src/main/runtime/runtime-legacy-worker-session-publication.test.ts @@ -0,0 +1,388 @@ +import { SessionHostPartitionOperations } from '../persistence/loading-store/session-host-partitions' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { OrchestrationDb } from './orchestration/db' +import { OrcaRuntimeService } from './orca-runtime' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' +import type { RuntimeStore } from './runtime-store-contract' + +const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333' +const WORKTREE_ID = 'repo::worktree' + +function sessionWithSleepingWorker(): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab_worker', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + } as WorkspaceSessionState +} + +describe('settled worker automatic-resume fence persistence', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + function harness( + 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 + fences: () => Record + recordFlag: () => string | undefined + } { + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = withSleepingRecord + ? sessionWithSleepingWorker() + : (getDefaultWorkspaceSession() as WorkspaceSessionState) + const partitions = new SessionHostPartitionOperations( + { + state: { + get workspaceSession() { + return session + } + } + } as never, + {} as never, + {} as never + ) + const store = { + getWorkspaceSession: () => partitions.getWorkspaceSession(), + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const task = orchestrationDb.createTask({ spec: 'fence me' }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + return { + db: orchestrationDb, + taskId: task.id, + dispatchId: started.dispatch.id, + persistence: new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => orchestrationDb, + () => LOCAL_EXECUTION_HOST_ID, + onFencesChanged + ), + fences: () => session.legacyWorkerResumeFencesByPaneKey ?? {}, + recordFlag: () => + partitions.getWorkspaceSession().sleepingAgentSessionsByPaneKey?.[PANE_KEY] + ?.automaticResumeBlockedBy + } + } + + function settle(d: OrchestrationDb, taskId: string, dispatchId: string): void { + expect( + d.settleWorkerReport({ taskId, dispatchId, outcome: 'succeeded', result: 'done' }).action + ).toBe('settled') + } + + // 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.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 derives compatibility on reads, including before any renderer writes. + it('publishes the legacy flag for an older client without a newer renderer', () => { + const h = harness() + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + + expect(h.recordFlag()).toBe('legacy-orchestration-worker') + }) + + // 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.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.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.fences()).toEqual({ [PANE_KEY]: true }) + + expect(h.db.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1) + h.persistence.prepare() + + expect(h.fences()).toEqual({}) + }) + + // An unreadable plan is not evidence a pane stopped needing its fence. + it('keeps the fence when the recovery plan cannot be read', () => { + const h = harness() + settle(h.db, h.taskId, h.dispatchId) + h.persistence.prepare() + expect(h.fences()).toEqual({ [PANE_KEY]: true }) + + vi.spyOn(h.db, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => { + throw new Error('orchestration_db_unavailable') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + expect(h.persistence.prepare()).toEqual({ + blockedPanes: [], + candidates: [], + ambiguousDispatchIds: [] + }) + } finally { + warn.mockRestore() + } + + expect(h.fences()).toEqual({ [PANE_KEY]: true }) + }) + + // 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.fences()).toEqual({ [PANE_KEY]: true }) + expect(plan.blockedPanes).toEqual([ + expect.objectContaining({ paneKey: PANE_KEY, settled: false }) + ]) + expect(plan.candidates).toEqual([expect.objectContaining({ dispatchId: h.dispatchId })]) + }) +}) + +// STA-4577's other half: settlement with no release and no restart. The stamp only ran at startup +// and after release/retain/takeover, so reopening the pane in the same session respawned the agent. +describe('worker_done without a release', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('fences the pane in the same session', async () => { + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = sessionWithSleepingWorker() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const runtime = new OrcaRuntimeService(store) + runtime.setOrchestrationDb(orchestrationDb) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? PANE_KEY : 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1') + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const run = orchestrationDb.createRun({ + objective: 'settle without release', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + const task = orchestrationDb.createTask({ spec: 'settle without release', runId: run.id }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + const capability = orchestrationDb.mintDispatchCapability({ + dispatchId: started.dispatch.id, + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1' + }) + expect(session.legacyWorkerResumeFencesByPaneKey).toBeUndefined() + + const send = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.send')! + await send.handler( + send.params!.parse({ + from: 'term_worker', + to: 'term_coord', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: started.dispatch.id, + outcome: 'succeeded' + }) + }), + { runtime, orchestrationCapability: capability } + ) + + expect(orchestrationDb.getWorkerDispatch(started.dispatch.id)?.state).toBe('succeeded') + expect(session.legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) + }) +}) 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 613718de7e55..902eef60ece9 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -1,3 +1,4 @@ +import { readWorkspaceSessionResumeFences } from '../../shared/workspace-session-resume-fences' 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' @@ -19,145 +20,85 @@ 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) => 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 ) {} - /** Panes announced as fenced before any sleeping record existed; the only place a lift for one - * can come from, because `liftRetiredFences` can only see panes that already have a record. */ - private readonly announcedBlockedPaneKeys = new Set() - + /** + * 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 fenceChanges: [string, boolean][] = [] + 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. - if (!this.announcedBlockedPaneKeys.has(blocked.paneKey)) { - this.announcedBlockedPaneKeys.add(blocked.paneKey) - fenceChanges.push([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, plan, sessions, changedHostIds, fenceChanges) - const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) 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, fenced)) { + continue + } + store.setWorkspaceSession({ ...current, legacyWorkerResumeFencesByPaneKey: fenced }, hostId) + this.notifyFenceChanged?.() } } catch (error) { - console.warn('[orchestration] failed to stage legacy worker resume fence', error) + // Earlier host commits have already published; retry only the remaining changes. + console.warn('[orchestration] failed to write legacy worker resume fences', error) return plan } - for (const [paneKey, blocked] of fenceChanges) { - this.notifyFenceChanged?.(paneKey, blocked) - } return plan } - /** A fence that outlives its dispatch leaves a pane that can never spawn again, so release, - * retain, user takeover and dispatch pruning — each of which drops the row from the plan — - * retire it here. An unreadable plan yields no blocked panes, so callers must not sweep. */ - private liftRetiredFences( - store: RuntimeStore, - plan: LegacyWorkerTerminalRecoveryPlan, - sessions: Map, - changedHostIds: Set, - fenceChanges: [string, boolean][] - ): void { - const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey)) - for (const paneKey of this.announcedBlockedPaneKeys) { - if (!blockedPaneKeys.has(paneKey)) { - this.announcedBlockedPaneKeys.delete(paneKey) - fenceChanges.push([paneKey, false]) - } - } - for (const hostId of store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID]) { - const staged = sessions.get(hostId) - const session = staged?.next ?? store.getWorkspaceSession?.(hostId) - const retired = Object.entries(session?.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([paneKey, record]) => - record.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - !blockedPaneKeys.has(paneKey) - ) - if (retired.length === 0) { - continue - } - let state = staged - if (!state) { - const current = store.getWorkspaceSession?.(hostId) - if (!current) { - continue - } - state = { current, next: structuredClone(current) } - sessions.set(hostId, state) - } - const next = { ...state.next.sleepingAgentSessionsByPaneKey } - for (const [paneKey, record] of retired) { - const { automaticResumeBlockedBy: _retired, ...unfenced } = record - next[paneKey] = unfenced - fenceChanges.push([paneKey, false]) - } - state.next.sleepingAgentSessionsByPaneKey = next - changedHostIds.add(hostId) - } - } - async persist( resolutions: readonly LegacyWorkerRecoveryResolution[] ): Promise> { @@ -270,3 +211,22 @@ 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(session: WorkspaceSessionState, next: Record): boolean { + const current = session.legacyWorkerResumeFencesByPaneKey + // An untouched partition needs no write; legacy record protection still needs explicit retirement. + if (current === undefined) { + return ( + Object.keys(next).length === 0 && + Object.keys(readWorkspaceSessionResumeFences(session)).length === 0 + ) + } + 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-resume-fence.test.ts b/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts index 6fe14f2abe7f..f5907e0334e5 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 @@ -37,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 @@ -86,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 } } @@ -98,70 +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])) + // 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(fenceChanges).toEqual([[PANE_KEY, true]]) + expect(h.fences()).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) + // 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(fenceChanges).toEqual([[PANE_KEY, true]]) - const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) - h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) + 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() - // 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] - ]) + expect(h.recordFlag()).toBeUndefined() }) - // 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() + // 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.fence()).toBe('legacy-orchestration-worker') + 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. @@ -169,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') @@ -185,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 }) ]) @@ -258,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( @@ -279,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 aa2982082b42..80d739c985e9 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): 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/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 918fd8443640..f8ee924ff453 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 2a6ab95a07f1..27ffd4e1cc4a 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) => - send('agentStatus:legacyWorkerTerminalResumeFence', { paneKey, blocked }), + 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 89677022506b..079b4a762c5b 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 }) => 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 3c3415cd2077..397ff2a8b423 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 }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { paneKey: string; blocked: boolean } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFence', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFence', listener) + 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 b2d6eed966c4..71470d50050f 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -45,6 +45,8 @@ export type AppApi = { prepareTerminalStartupRestoration: () => 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 2a0d50e9de41..c34b2500b94b 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -49,6 +49,8 @@ export const appApi = { ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), 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/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 c93294ba2f9b..665fa865821c 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 8ad0335179c7..2fcbd16d0ede 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-recordless-fence.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access-recordless-fence.test.ts new file mode 100644 index 000000000000..6e2526111eaf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access-recordless-fence.test.ts @@ -0,0 +1,17 @@ +import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { it, expect } from 'vitest' +import { useAppStore } from '@/store' +import { installSleepingRecordAccess } from './sleeping-record-access' +it('recordless pane fence reaches the materialization gate', () => { + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: {}, + legacyWorkerResumeFencesByPaneKey: { 'tab:leaf': true } + }) + const session = { + cacheKey: 'tab:leaf', + deps: { tabId: 'tab', worktreeId: 'wt' }, + pane: { id: 1 } + } as unknown as ConnectPanePtySession + installSleepingRecordAccess(session) + expect(session.isLegacyWorkerAutomaticResumeBlocked()).toBe(true) +}) 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 0777dead4310..9bd61126655c 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,14 @@ 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 ( + state.legacyWorkerResumeFencesByPaneKey[session.cacheKey] === true || + (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 9c1c3512e5e4..329aa4cd4660 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 f38ee52bb806..a3dfaacdac2c 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 81153a03ca6a..bd39db8f8932 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 ec2611d19e0b..8dd77aa77815 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 2376f842c9a1..131558339af3 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 2426dcb932d9..7b0f81462bd7 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 }) => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked) + 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 2ba4d506077c..6bceb3a734c6 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 5f17e1e48345..9fe2bac6b999 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -85,6 +85,7 @@ function snapshotFromState( .map(([ptyId]) => ptyId), agentStatusByPaneKey: state.agentStatusByPaneKey, sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, + 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 new file mode 100644 index 000000000000..2e4015082254 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { getEligiblePane } from './agent-hibernation-pane-eligibility' + +const NOW = 1_800_000_000_000 +const LEAF_ID = '11111111-2222-4333-8444-555555555555' +const PANE_KEY = `tab-1:${LEAF_ID}` + +const tab = { id: 'tab-1', worktreeId: 'wt-1', title: 't' } as unknown as TerminalTab + +const entry: AgentStatusEntry = { + state: 'done', + prompt: 'worker task', + updatedAt: NOW - 600_000, + stateStartedAt: NOW - 600_000, + stateHistory: [], + agentType: 'claude', + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'session-1' } +} + +const liveAnchor: SleepingAgentSessionRecord = { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'done', + capturedAt: NOW - 600_000, + updatedAt: NOW - 600_000, + origin: 'live' +} + +function plan( + record: SleepingAgentSessionRecord | undefined, + legacyWorkerResumeFencesByPaneKey: Record = {} +) { + return getEligiblePane({ + entry, + tab, + layout: { ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } } as never, + livePtyIds: new Set(['pty-1']), + sleepingAgentSessionsByPaneKey: record ? { [PANE_KEY]: record } : {}, + legacyWorkerResumeFencesByPaneKey, + lastTerminalInputAtByPaneKey: {}, + foregroundTerminalLastSeenAtByTabId: {}, + ptyBindingFirstSeenAtByPaneKey: {}, + boundaryResolvedAtByPaneKey: {}, + mobileLockedPtyIds: new Set(), + now: NOW, + idleMs: 60_000 + }) +} + +// 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 for a fenced pane that has a record', () => { + expect(plan(liveAnchor, { [PANE_KEY]: true })).toBeNull() + }) + + 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 pane holding only its live resume anchor', () => { + expect(plan(liveAnchor)).not.toBeNull() + }) + + 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, { '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 839209ea809e..7c2c4063ca13 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -61,6 +61,7 @@ export function getEligiblePane(args: { layout: TerminalLayoutSnapshot | undefined livePtyIds: Set sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] + legacyWorkerResumeFencesByPaneKey: Record lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] ptyBindingFirstSeenAtByPaneKey: Record @@ -75,6 +76,7 @@ export function getEligiblePane(args: { layout, livePtyIds, sleepingAgentSessionsByPaneKey, + legacyWorkerResumeFencesByPaneKey, lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey, @@ -96,9 +98,11 @@ export function getEligiblePane(args: { Boolean(entry.subagents?.length) || hasUnsettledOrUnknownDispatch(entry) || (sleepingRecord && !hasOnlyLiveResumeAnchor) || - // Why: a fenced worker must never be auto-relaunched; killing it would also - // erase the fence, since the capture does not copy it. - !isAutomaticHibernationAllowed(sleepingRecord) + // Why: a fenced worker must never be auto-relaunched; the kill would strand it. + !isAutomaticHibernationAllowed({ + legacyWorkerResumeFencesByPaneKey, + paneKey: entry.paneKey + }) ) { return null } diff --git a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts index 715500d9fb72..2e5670d88552 100644 --- a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts +++ b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts @@ -15,6 +15,8 @@ export type AgentHibernationPlannerSnapshot = { mobileLockedPtyIds: string[] agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: 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 10414249dfb8..723aa9df5534 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 410fb393043a..dc725d3d172a 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -146,6 +146,7 @@ export function planAgentHibernationCandidates( layout, livePtyIds: new Set(tabLivePtyIds), sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, + legacyWorkerResumeFencesByPaneKey: snapshot.legacyWorkerResumeFencesByPaneKey ?? {}, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, diff --git a/src/renderer/src/lib/legacy-worker-old-source-resume.test.ts b/src/renderer/src/lib/legacy-worker-old-source-resume.test.ts new file mode 100644 index 000000000000..39067a7662c3 --- /dev/null +++ b/src/renderer/src/lib/legacy-worker-old-source-resume.test.ts @@ -0,0 +1,59 @@ +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +describe('legacy worker sleeping-session recovery', () => { + it('never resumes a proven-exited legacy worker on workspace activation', () => { + const record: SleepingAgentSessionRecord = { + paneKey: 'tab-legacy:leaf-legacy', + tabId: 'tab-legacy', + worktreeId: 'folder:legacy', + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-legacy' }, + prompt: 'continue legacy work', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live', + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + useAppStore.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + 'folder:legacy': [ + { + id: 'tab-legacy', + ptyId: null, + worktreeId: 'folder:legacy', + title: 'Legacy worker', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + }, + { additionalValidWorkspaceKeys: [folderWorkspaceKey('legacy')] } + ) + + expect(resumeSleepingAgentSessionsForWorktree('folder:legacy')).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + + useAppStore.getState().clearSleepingAgentSession(record.paneKey) + expect(resumeSleepingAgentSessionsForWorktree('folder:legacy')).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + }) +}) 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 000000000000..1b3e1b23933e --- /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 000000000000..97abc6cc950e --- /dev/null +++ b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts @@ -0,0 +1,37 @@ +import { readAndApplyRuntimeSession, applyReadRuntimeSession } from './runtime-session-application' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { useAppStore } from '@/store' + +let inFlight: Promise | null = null +let repeat = false + +// Coalesce invalidations; their reads use the same application lane as ordinary hydration. +export async function refreshLegacyWorkerResumeFences(): Promise { + if (inFlight) { + repeat = true + return inFlight + } + inFlight = (async () => { + try { + do { + repeat = false + await readAndApplyRuntimeSession( + () => window.api.app.getLegacyWorkerResumeFences(), + (fences) => + applyReadRuntimeSession( + { ...getDefaultWorkspaceSession(), legacyWorkerResumeFencesByPaneKey: fences }, + useAppStore.setState, + useAppStore.getState + ) + ) + } 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 7fa9d3f88ed9..9fbb74fc0085 100644 --- a/src/renderer/src/lib/live-resume-anchor-record.ts +++ b/src/renderer/src/lib/live-resume-anchor-record.ts @@ -52,12 +52,15 @@ export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord( } /** - * A durable orchestration fence against automatic provider relaunch. Hibernating - * a fenced pane would strand it or — since `sleepingRecordFromEntry` does not copy - * the flag — erase the fence and later auto-resume prohibited work. + * 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( - record: SleepingAgentSessionRecord | undefined -): boolean { - return !record?.automaticResumeBlockedBy +export function isAutomaticHibernationAllowed(pane: { + legacyWorkerResumeFencesByPaneKey: Record + paneKey: string +}): boolean { + 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 1ec95258f059..3f28d678e62c 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 94dc52bc7c61..4ab85c2e127e 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/runtime-session-application.test.ts b/src/renderer/src/lib/runtime-session-application.test.ts new file mode 100644 index 000000000000..82fb2c93bf70 --- /dev/null +++ b/src/renderer/src/lib/runtime-session-application.test.ts @@ -0,0 +1,489 @@ +import { readAndApplyRuntimeSession, applyReadRuntimeSession } from './runtime-session-application' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration' +import { refreshLegacyWorkerResumeFences } from './legacy-worker-resume-fence-refresh' +import { createTestStore, makeTab } from '../store/slices/store-test-helpers' +import { worktreeWorkspaceKey } from '../../../shared/workspace-scope' + +const initialState = useAppStore.getState() +const pane = 'target:11111111-2222-4333-8444-555555555555' +const sibling = 'sibling:22222222-2222-4333-8444-555555555555' +const worktreeId = 'repo::/target' + +afterEach(() => { + useAppStore.setState(initialState, true) + vi.unstubAllGlobals() +}) + +it('applies startup runtime fields at read time, before delayed catalog hydration', async () => { + const old = { ...getDefaultWorkspaceSession(), legacyWorkerResumeFencesByPaneKey: {} } + const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get: async () => old }, []) + vi.stubGlobal('window', { + api: { + app: { + getLegacyWorkerResumeFences: async () => ({ [pane]: true }) + } + } + }) + await refreshLegacyWorkerResumeFences() + useAppStore.getState().hydrateWorkspaceSession(read.session) + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [pane]: true }) +}) + +it('orders a scoped retirement after an in-flight invalidation and retains its sibling', async () => { + let resolve!: (value: Record) => void + vi.stubGlobal('window', { + api: { + app: { + getLegacyWorkerResumeFences: () => + new Promise>((done) => { + resolve = done + }) + } + } + }) + const refresh = refreshLegacyWorkerResumeFences() + const tab = makeTab({ id: 'target', worktreeId }) + useAppStore.setState({ tabsByWorktree: { [worktreeId]: [tab] } }) + useAppStore.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [tab] }, + legacyWorkerResumeFencesByPaneKey: {} + }, + { + replaceWorkspaceKeys: [worktreeId], + additionalValidWorkspaceKeys: [worktreeWorkspaceKey(worktreeId)] + } + ) + resolve({ [pane]: true, [sibling]: true }) + await refresh + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [sibling]: true }) +}) + +it('an absent direct SSH authority cannot retire a target fence', () => { + const store = createTestStore() + const tab = makeTab({ id: 'target', worktreeId }) + store.setState({ + tabsByWorktree: { [worktreeId]: [tab] }, + legacyWorkerResumeFencesByPaneKey: { [pane]: true, [sibling]: true } + }) + store.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [tab] } + }, + { + replaceWorkspaceKeys: [worktreeId], + additionalValidWorkspaceKeys: [worktreeWorkspaceKey(worktreeId)] + } + ) + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ + [pane]: true, + [sibling]: true + }) +}) + +it('normalizes old-host records before union with an authoritative new-host empty set', async () => { + const record = { + paneKey: pane, + tabId: 'target', + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'provider' }, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const, + automaticResumeBlockedBy: 'legacy-orchestration-worker' as const + } + await fetchWorkspaceSessionWithRuntimeHostOwners( + { + get: async (host) => + host + ? { ...getDefaultWorkspaceSession(), sleepingAgentSessionsByPaneKey: { [pane]: record } } + : { ...getDefaultWorkspaceSession(), legacyWorkerResumeFencesByPaneKey: {} } + }, + [], + ['runtime:old-host'] + ) + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) +}) + +it('an authoritative empty set retires a legacy flag while an absent set preserves it', () => { + const store = createTestStore() + const record = { + paneKey: pane, + tabId: 'target', + worktreeId, + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'provider' }, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const, + automaticResumeBlockedBy: 'legacy-orchestration-worker' as const + } + const oldSession = { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { [pane]: record } + } + store.getState().hydrateWorkspaceSession(oldSession) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + store.getState().hydrateWorkspaceSession({ ...oldSession, legacyWorkerResumeFencesByPaneKey: {} }) + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) +}) + +it('publishes sleeping records and their authority in a single subscriber notification', () => { + const session = { + ...getDefaultWorkspaceSession(), + legacyWorkerResumeFencesByPaneKey: { [pane]: true as const }, + sleepingAgentSessionsByPaneKey: { + [pane]: { + paneKey: pane, + tabId: 'target', + worktreeId: 'folder:legacy', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: 'provider' }, + prompt: '', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const + } + } + } + const observed: boolean[][] = [] + const unsubscribe = useAppStore.subscribe((state) => { + observed.push([ + !!state.sleepingAgentSessionsByPaneKey[pane], + !!state.legacyWorkerResumeFencesByPaneKey[pane] + ]) + }) + try { + useAppStore.getState().hydrateWorkspaceSession(session, { + additionalValidWorkspaceKeys: ['folder:legacy'] + }) + expect(observed).toEqual([[true, true]]) + } finally { + unsubscribe() + } +}) + +it.each(['resolve', 'reject'] as const)( + 'a later refresh can retire hydration after the older read %ss', + async (outcome) => { + let settle!: () => void + const read = vi + .fn() + .mockImplementationOnce( + () => + new Promise>((resolve, reject) => { + settle = () => (outcome === 'resolve' ? resolve({}) : reject(new Error('offline'))) + }) + ) + .mockResolvedValue({}) + vi.stubGlobal('window', { api: { app: { getLegacyWorkerResumeFences: read } } }) + const pending = refreshLegacyWorkerResumeFences() + useAppStore.getState().hydrateWorkspaceSession({ + ...getDefaultWorkspaceSession(), + legacyWorkerResumeFencesByPaneKey: { [pane]: true } + }) + settle() + await pending + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [pane]: true }) + await refreshLegacyWorkerResumeFences() + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + } +) + +describe('runtime snapshot interleavings', () => { + const initial = useAppStore.getState() + const pane = 'target:11111111-2222-4333-8444-555555555555' + const sibling = 'sibling:22222222-2222-4333-8444-555555555555' + const worktreeId = 'folder:legacy' + const session = (fences: Record) => ({ + ...getDefaultWorkspaceSession(), + legacyWorkerResumeFencesByPaneKey: fences + }) + const apply = (s: ReturnType) => + applyReadRuntimeSession(s, useAppStore.setState, useAppStore.getState) + function deferred() { + let resolve!: (v: T) => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } + } + function scoped(fences?: Record) { + const tab = makeTab({ id: 'target', worktreeId }) + useAppStore.setState({ tabsByWorktree: { [worktreeId]: [tab] } }) + useAppStore.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [worktreeId]: [tab] }, + ...(fences === undefined ? {} : { legacyWorkerResumeFencesByPaneKey: fences }) + }, + { replaceWorkspaceKeys: [worktreeId], additionalValidWorkspaceKeys: [worktreeId] } + ) + } + afterEach(() => { + useAppStore.setState(initial, true) + vi.unstubAllGlobals() + }) + it('queued second read begins after hydration and may retire it', async () => { + const d = deferred>() + const first = readAndApplyRuntimeSession(() => d.promise, apply) + const secondRead = vi.fn(async () => session({})) + const second = readAndApplyRuntimeSession(secondRead, apply) + scoped({ [pane]: true }) + expect(secondRead).not.toHaveBeenCalled() + d.resolve(session({ [sibling]: true })) + await first + await second + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + }) + it('scoped supersession rejects in-scope update but applies new sibling', async () => { + const d = deferred>() + const first = readAndApplyRuntimeSession(() => d.promise, apply) + scoped({}) + d.resolve(session({ [pane]: true, [sibling]: true })) + await first + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [sibling]: true }) + }) + it('full supersession remains full after scoped hydration', async () => { + const d = deferred>() + const first = readAndApplyRuntimeSession(() => d.promise, apply) + useAppStore.getState().hydrateWorkspaceSession(session({ [sibling]: true })) + scoped({ [pane]: true }) + d.resolve(session({})) + await first + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ + [pane]: true, + [sibling]: true + }) + }) + it('authority-absent SSH hydration must not suppress a runtime acquisition', async () => { + const d = deferred>() + vi.stubGlobal('window', { api: { app: { getLegacyWorkerResumeFences: () => d.promise } } }) + const first = refreshLegacyWorkerResumeFences() + scoped() + d.resolve({ [pane]: true, [sibling]: true }) + await first + console.log( + 'absent-source acquisition', + useAppStore.getState().legacyWorkerResumeFencesByPaneKey + ) + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ + [pane]: true, + [sibling]: true + }) + }) + it('discarded authoritative retirement prevents a real resume sweep', async () => { + const d = deferred>() + vi.stubGlobal('window', { api: { app: { getLegacyWorkerResumeFences: () => d.promise } } }) + const record = { + paneKey: pane, + tabId: 'target', + worktreeId, + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'session-legacy' }, + prompt: 'continue', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const + } + useAppStore.setState({ + legacyWorkerResumeFencesByPaneKey: { [pane]: true }, + tabsByWorktree: { [worktreeId]: [makeTab({ id: 'target', worktreeId })] } + }) + const first = refreshLegacyWorkerResumeFences() + useAppStore + .getState() + .hydrateWorkspaceSession( + { ...getDefaultWorkspaceSession(), sleepingAgentSessionsByPaneKey: { [pane]: record } }, + { replaceWorkspaceKeys: [worktreeId], additionalValidWorkspaceKeys: [worktreeId] } + ) + d.resolve({}) + await first + const count = resumeSleepingAgentSessionsForWorktree(worktreeId) + console.log('retired main, post-reply sweep', { + count, + fences: useAppStore.getState().legacyWorkerResumeFencesByPaneKey, + recordPresent: !!useAppStore.getState().sleepingAgentSessionsByPaneKey[pane] + }) + expect(count).toBe(1) + }) + it('a genuinely newer retirement reply is discarded after a stale canonical hydration', async () => { + const d = deferred>() + vi.stubGlobal('window', { api: { app: { getLegacyWorkerResumeFences: () => d.promise } } }) + // Snapshot captured before the retirement; delivery happens during its invalidation read. + const old = session({ [pane]: true }) + const first = refreshLegacyWorkerResumeFences() + useAppStore.getState().hydrateWorkspaceSession(old) + d.resolve({}) + await first + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [pane]: true }) + }) + it('two overlapping scoped hydrations retain both scopes and accept a third scope reply', async () => { + const d = deferred>() + const first = readAndApplyRuntimeSession(() => d.promise, apply) + scoped({}) + const other = 'folder:other' + const tab = makeTab({ id: 'sibling', worktreeId: other }) + useAppStore + .getState() + .hydrateWorkspaceSession( + { ...session({}), tabsByWorktree: { [other]: [tab] } }, + { replaceWorkspaceKeys: [other], additionalValidWorkspaceKeys: [other] } + ) + const third = 'third:33333333-2222-4333-8444-555555555555' + d.resolve(session({ [pane]: true, [sibling]: true, [third]: true })) + await first + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [third]: true }) + }) + it('a different runtime host reply overlapping the target tab respects viewer scope and applies its sibling', async () => { + const { fetchWorkspaceSessionWithRuntimeHostOwners } = + await import('./workspace-session-host-hydration') + const d = deferred>() + let started!: () => void + const hostStarted = new Promise((r) => (started = r)) + const first = fetchWorkspaceSessionWithRuntimeHostOwners( + { + get: async (host) => { + if (!host) { + return session({}) + } + started() + return d.promise + } + }, + [], + ['runtime:other-host'] + ) + await hostStarted + scoped({}) + d.resolve(session({ [pane]: true, [sibling]: true })) + await first + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [sibling]: true }) + }) +}) + +describe('legacy-only hydration authority', () => { + const workspace = 'folder:legacy' + const acquired = 'target:33333333-2222-4333-8444-555555555555' + const retired = 'target:44444444-2222-4333-8444-555555555555' + const tab = makeTab({ id: 'target', worktreeId: workspace }) + const scopedOptions = { + replaceWorkspaceKeys: [workspace], + additionalValidWorkspaceKeys: [workspace] + } as const + function legacySession(keys: string[]) { + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [workspace]: [tab] }, + sleepingAgentSessionsByPaneKey: Object.fromEntries( + keys.map((key) => [ + key, + { + paneKey: key, + tabId: key.split(':')[0], + worktreeId: workspace, + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: key }, + prompt: '', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const, + automaticResumeBlockedBy: 'legacy-orchestration-worker' as const + } + ]) + ) + } + } + function pendingRefresh() { + let resolve!: (fences: Record) => void + vi.stubGlobal('window', { + api: { + app: { + getLegacyWorkerResumeFences: () => + new Promise>((done) => { + resolve = done + }) + } + } + }) + return { + pending: refreshLegacyWorkerResumeFences(), + resolve: (fences: Record) => resolve(fences) + } + } + it.each([false, true])( + 'protects only supplied panes while allowing same-tab acquisition and retirement (scoped=%s)', + async (scoped) => { + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: { [retired]: true } }) + const read = pendingRefresh() + useAppStore + .getState() + .hydrateWorkspaceSession(legacySession([pane]), scoped ? scopedOptions : undefined) + read.resolve({ [acquired]: true, [sibling]: true }) + await read.pending + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ + [pane]: true, + [acquired]: true, + [sibling]: true + }) + } + ) + it('does not supersede a legacy pane outside the hydrated scope', async () => { + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: { [sibling]: true } }) + const read = pendingRefresh() + useAppStore.getState().hydrateWorkspaceSession(legacySession([pane, sibling]), scopedOptions) + read.resolve({}) + await read.pending + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [pane]: true }) + }) + it('accumulates legacy panes without losing earlier protection', async () => { + const read = pendingRefresh() + useAppStore.getState().hydrateWorkspaceSession(legacySession([pane])) + useAppStore.getState().hydrateWorkspaceSession(legacySession([acquired])) + read.resolve({}) + await read.pending + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ + [pane]: true, + [acquired]: true + }) + }) + it('lets a later canonical retirement replace earlier legacy protection', async () => { + const read = pendingRefresh() + useAppStore.getState().hydrateWorkspaceSession(legacySession([pane])) + useAppStore + .getState() + .hydrateWorkspaceSession( + { ...legacySession([]), legacyWorkerResumeFencesByPaneKey: {} }, + scopedOptions + ) + read.resolve({ [pane]: true, [sibling]: true }) + await read.pending + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ [sibling]: true }) + }) + it('preserves full canonical supersession through a later legacy snapshot', async () => { + const read = pendingRefresh() + useAppStore.getState().hydrateWorkspaceSession({ + ...getDefaultWorkspaceSession(), + legacyWorkerResumeFencesByPaneKey: { [sibling]: true } + }) + useAppStore.getState().hydrateWorkspaceSession(legacySession([pane]), scopedOptions) + read.resolve({}) + await read.pending + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey).toEqual({ + [pane]: true, + [sibling]: true + }) + }) +}) diff --git a/src/renderer/src/lib/runtime-session-application.ts b/src/renderer/src/lib/runtime-session-application.ts new file mode 100644 index 000000000000..5c59a1c847ad --- /dev/null +++ b/src/renderer/src/lib/runtime-session-application.ts @@ -0,0 +1,160 @@ +import { readWorkspaceSessionResumeFences } from '../../../shared/workspace-session-resume-fences' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { parsePaneKey, parseLegacyNumericPaneKey } from '../../../shared/stable-pane-id' +import type { TerminalStoreGet, TerminalStoreSet } from '../store/terminals/terminal-state' + +const pendingReads: (() => void)[] = [] +type HydratedAuthority = { + tabIds: ReadonlySet | null + paneKeys: ReadonlySet +} +let hydratedDuringRead = new Map() +let reading = false +const appliedSessions = new WeakMap>() + +function drainApplications(): void { + reading = false + hydratedDuringRead.clear() + while (!reading && pendingReads.length > 0) { + pendingReads.shift()!() + } +} + +// Serialize runtime reads; synchronous hydration supersedes their overlapping snapshot scopes. +export function readAndApplyRuntimeSession( + read: () => Promise, + apply: (value: T) => void +): Promise { + return new Promise((resolve, reject) => { + const start = (): void => { + reading = true + hydratedDuringRead = new Map() + void (async () => read())().then( + (value) => { + try { + apply(value) + drainApplications() + resolve(value) + } catch (error) { + drainApplications() + reject(error) + } + }, + (error) => { + drainApplications() + reject(error) + } + ) + } + if (reading) { + pendingReads.push(start) + } else { + start() + } + }) +} + +export function applyReadRuntimeSession( + session: WorkspaceSessionState, + set: TerminalStoreSet, + get: TerminalStoreGet, + targetTabIds?: ReadonlySet +): void { + const fields = runtimeSessionFields(session, get, targetTabIds) + const superseded = hydratedDuringRead.get(get) + if (superseded?.tabIds === null) { + return + } + if (superseded) { + const current = get().legacyWorkerResumeFencesByPaneKey + fields.legacyWorkerResumeFencesByPaneKey = { + ...Object.fromEntries( + Object.entries(fields.legacyWorkerResumeFencesByPaneKey).filter( + ([key]) => !authorityIncludesPane(key, superseded) + ) + ), + ...Object.fromEntries( + Object.entries(current).filter(([key]) => authorityIncludesPane(key, superseded)) + ) + } + } + set(fields) +} + +function authorityIncludesPane(key: string, authority: HydratedAuthority): boolean { + return ( + authority.paneKeys.has(key) || authority.tabIds === null || paneInScope(key, authority.tabIds) + ) +} + +function paneInScope(key: string, tabIds: ReadonlySet): boolean { + const tabId = parsePaneKey(key)?.tabId ?? parseLegacyNumericPaneKey(key)?.tabId + return tabId !== undefined && tabIds.has(tabId) +} + +function runtimeSessionFields( + session: WorkspaceSessionState, + get: TerminalStoreGet, + targetTabIds?: ReadonlySet +): { legacyWorkerResumeFencesByPaneKey: Record } { + let applied = appliedSessions.get(get) + if (!applied) { + applied = new WeakSet() + appliedSessions.set(get, applied) + } + applied.add(session) + const incoming = session.legacyWorkerResumeFencesByPaneKey + const current = get().legacyWorkerResumeFencesByPaneKey + // An unsupported source cannot retire known protection; normalize its legacy records on ingress. + const fences = incoming ?? { + ...current, + ...readWorkspaceSessionResumeFences(session) + } + return { + legacyWorkerResumeFencesByPaneKey: targetTabIds + ? { + ...Object.fromEntries( + Object.entries(current).filter(([key]) => !paneInScope(key, targetTabIds)) + ), + ...Object.fromEntries( + Object.entries(fences).filter(([key]) => paneInScope(key, targetTabIds)) + ) + } + : fences + } +} + +export function hydrateRuntimeSessionFields( + session: WorkspaceSessionState, + get: TerminalStoreGet, + targetTabIds?: ReadonlySet +): { legacyWorkerResumeFencesByPaneKey: Record } { + if (appliedSessions.get(get)?.has(session)) { + return { legacyWorkerResumeFencesByPaneKey: get().legacyWorkerResumeFencesByPaneKey } + } + if (reading) { + const prior = hydratedDuringRead.get(get) + const previous = prior?.tabIds + if (session.legacyWorkerResumeFencesByPaneKey !== undefined) { + hydratedDuringRead.set(get, { + tabIds: + !targetTabIds || previous === null + ? null + : new Set([...(previous ?? []), ...targetTabIds]), + paneKeys: prior?.paneKeys ?? new Set() + }) + } else { + const protectedKeys = Object.keys(readWorkspaceSessionResumeFences(session)).filter( + (key) => !targetTabIds || paneInScope(key, targetTabIds) + ) + // Legacy records supply protection for individual panes, never scope-wide retirement authority. + if (protectedKeys.length > 0) { + hydratedDuringRead.set(get, { + tabIds: previous === undefined ? new Set() : previous, + paneKeys: new Set([...(prior?.paneKeys ?? []), ...protectedKeys]) + }) + } + } + } + return runtimeSessionFields(session, get, targetTabIds) +} diff --git a/src/renderer/src/lib/runtime-session-pending-hydration.test.ts b/src/renderer/src/lib/runtime-session-pending-hydration.test.ts new file mode 100644 index 000000000000..d8e2cc16ffbe --- /dev/null +++ b/src/renderer/src/lib/runtime-session-pending-hydration.test.ts @@ -0,0 +1,66 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { refreshLegacyWorkerResumeFences } from './legacy-worker-resume-fence-refresh' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +const initial = useAppStore.getState() +afterEach(() => { + useAppStore.setState(initial, true) + vi.unstubAllGlobals() +}) +it.each(['canonical', 'legacy'] as const)( + 'cannot auto-resume %s protected records while hydration waits behind a read', + async (kind) => { + let resolve!: (value: Record) => void + vi.stubGlobal('window', { + api: { + app: { + getLegacyWorkerResumeFences: () => + new Promise>((done) => { + resolve = done + }) + } + } + }) + const pending = refreshLegacyWorkerResumeFences() + const paneKey = 'tab-legacy:11111111-2222-4333-8444-555555555555' + const record = { + paneKey, + tabId: 'tab-legacy', + worktreeId: 'folder:legacy', + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'session-legacy' }, + prompt: 'continue legacy work', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const, + ...(kind === 'legacy' + ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } + : {}) + } + useAppStore.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + sleepingAgentSessionsByPaneKey: { [paneKey]: record }, + ...(kind === 'canonical' + ? { legacyWorkerResumeFencesByPaneKey: { [paneKey]: true as const } } + : {}) + }, + { additionalValidWorkspaceKeys: [folderWorkspaceKey('legacy')] } + ) + const count = resumeSleepingAgentSessionsForWorktree('folder:legacy') + const observed = { + count, + tabs: useAppStore.getState().tabsByWorktree['folder:legacy']?.map((tab) => tab.id), + claims: useAppStore.getState().automaticAgentResumeClaimsByTabId, + recordStillPresent: !!useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey] + } + resolve({}) + await pending + console.log(kind, observed) + expect(count).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBe(record) + } +) diff --git a/src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts b/src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts new file mode 100644 index 000000000000..612f6c999429 --- /dev/null +++ b/src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts @@ -0,0 +1,39 @@ +import { it, expect, vi, afterEach } from 'vitest' +import { useAppStore } from '@/store' +import { refreshLegacyWorkerResumeFences } from './legacy-worker-resume-fence-refresh' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState({ legacyWorkerResumeFencesByPaneKey: {} }) +}) +it('startup hydration cannot roll back a newer ping read', async () => { + const oldSession = getDefaultWorkspaceSession() + vi.stubGlobal('window', { + api: { app: { getLegacyWorkerResumeFences: vi.fn().mockResolvedValue({ 'tab:leaf': true }) } } + }) + await refreshLegacyWorkerResumeFences() + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey['tab:leaf']).toBe(true) + useAppStore.getState().hydrateWorkspaceSession(oldSession) + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey['tab:leaf']).toBe(true) +}) +it('a pending read cannot overwrite newer hydration', async () => { + let resolve!: (v: Record) => void + vi.stubGlobal('window', { + api: { + app: { + getLegacyWorkerResumeFences: () => + new Promise((r) => { + resolve = r + }) + } + } + }) + const read = refreshLegacyWorkerResumeFences() + useAppStore.getState().hydrateWorkspaceSession({ + ...getDefaultWorkspaceSession(), + legacyWorkerResumeFencesByPaneKey: { 'tab:leaf': true } + }) + resolve({}) + await read + expect(useAppStore.getState().legacyWorkerResumeFencesByPaneKey['tab:leaf']).toBe(true) +}) diff --git a/src/renderer/src/lib/runtime-session-ssh-projection-authority.test.ts b/src/renderer/src/lib/runtime-session-ssh-projection-authority.test.ts new file mode 100644 index 000000000000..6e5f4887b4aa --- /dev/null +++ b/src/renderer/src/lib/runtime-session-ssh-projection-authority.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { makeTab } from '../store/slices/store-test-helpers' +import { buildWorkspaceSessionPayload } from './workspace-session' +import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection' +import { mergeDirectSshRemoteWorkspaceSession } from '../hooks/remote-workspace-session-merge' +import { refreshLegacyWorkerResumeFences } from './legacy-worker-resume-fence-refresh' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' +const initial = useAppStore.getState() +afterEach(() => { + useAppStore.setState(initial, true) + vi.unstubAllGlobals() +}) +it.each(['acquire', 'retire'] as const)( + 'SSH projection cannot consume authoritative %s reply', + async (kind) => { + const worktreeId = 'folder:legacy', + paneKey = 'target:11111111-2222-4333-8444-555555555555' + const tab = makeTab({ id: 'target', worktreeId }) + const record = { + paneKey, + tabId: 'target', + worktreeId, + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'session-legacy' }, + prompt: 'continue', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1, + origin: 'live' as const + } + useAppStore.setState({ + tabsByWorktree: { [worktreeId]: [tab] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record }, + legacyWorkerResumeFencesByPaneKey: kind === 'retire' ? { [paneKey]: true } : {} + }) + let resolve!: (v: Record) => void + const get = vi.fn(() => new Promise>((r) => (resolve = r))) + vi.stubGlobal('window', { api: { app: { getLegacyWorkerResumeFences: get } } }) + const pending = refreshLegacyWorkerResumeFences() + const remote = importRemoteWorkspaceSession( + { + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: { '/legacy': [{ ...tab, worktreePath: '/legacy' }] }, + terminalLayoutsByTabId: {} + }, + { resolveWorktreeId: () => worktreeId, executionHostId: 'ssh:target' } + ) + const state = useAppStore.getState() + const merged = mergeDirectSshRemoteWorkspaceSession( + buildWorkspaceSessionPayload(state), + remote, + new Set([worktreeId]), + state.tabsByWorktree, + new Set(), + 'ssh:target', + 1 + ) + expect(merged.legacyWorkerResumeFencesByPaneKey).toBeUndefined() + useAppStore.getState().hydrateWorkspaceSession(merged, { + replaceWorkspaceKeys: [worktreeId], + additionalValidWorkspaceKeys: [worktreeId] + }) + resolve(kind === 'acquire' ? { [paneKey]: true } : {}) + await pending + const count = resumeSleepingAgentSessionsForWorktree(worktreeId) + console.log(kind, { + count, + fences: useAppStore.getState().legacyWorkerResumeFencesByPaneKey, + recordPresent: !!useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey], + tabs: useAppStore.getState().tabsByWorktree[worktreeId]?.map((t) => t.id), + claims: useAppStore.getState().automaticAgentResumeClaimsByTabId + }) + expect(get).toHaveBeenCalledTimes(1) + expect(count).toBe(kind === 'acquire' ? 0 : 1) + } +) diff --git a/src/renderer/src/lib/workspace-session-host-hydration.ts b/src/renderer/src/lib/workspace-session-host-hydration.ts index 05e967208510..0a0856d560bd 100644 --- a/src/renderer/src/lib/workspace-session-host-hydration.ts +++ b/src/renderer/src/lib/workspace-session-host-hydration.ts @@ -1,3 +1,6 @@ +import { readWorkspaceSessionResumeFences } from '../../../shared/workspace-session-resume-fences' +import { useAppStore } from '@/store' +import { readAndApplyRuntimeSession, applyReadRuntimeSession } from './runtime-session-application' import type { Repo } from '../../../shared/repo-types' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { @@ -130,6 +133,17 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( api: SessionReadApi, repos: readonly Pick[], additionalRuntimeHostIds: readonly ExecutionHostId[] = [] +): Promise { + return readAndApplyRuntimeSession( + () => readWorkspaceSessionHosts(api, repos, additionalRuntimeHostIds), + ({ session }) => applyReadRuntimeSession(session, useAppStore.setState, useAppStore.getState) + ) +} + +async function readWorkspaceSessionHosts( + api: SessionReadApi, + repos: readonly Pick[], + additionalRuntimeHostIds: readonly ExecutionHostId[] ): Promise { const slices: HostSessionSlices = { [LOCAL_EXECUTION_HOST_ID]: await api.get() @@ -150,8 +164,26 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( }) ) const merged = mergeWorkspaceSessionsWithHostShadow(slices) + // Normalize only the viewer mirror; host shadows must retain the source's absent authority. + const legacyFences = Object.fromEntries( + Object.values(merged.slices).flatMap((slice) => + slice && slice.legacyWorkerResumeFencesByPaneKey === undefined + ? Object.entries(readWorkspaceSessionResumeFences(slice)) + : [] + ) + ) + const session = + Object.keys(legacyFences).length > 0 + ? { + ...merged.session, + legacyWorkerResumeFencesByPaneKey: { + ...legacyFences, + ...merged.session.legacyWorkerResumeFencesByPaneKey + } + } + : merged.session return { - session: merged.session, + session, // Why the merged slices and not the raw ones: a row parked out of the renderer session must not // still name its host as the owner, or startup builds runtime placeholders for a local row. runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(merged.slices), diff --git a/src/renderer/src/lib/workspace-session-snapshot.ts b/src/renderer/src/lib/workspace-session-snapshot.ts new file mode 100644 index 000000000000..68955dfbe70e --- /dev/null +++ b/src/renderer/src/lib/workspace-session-snapshot.ts @@ -0,0 +1,87 @@ +import type { AppState } from '../store' + +export type WorkspaceSessionSnapshot = Pick< + AppState, + | 'activeRepoId' + | 'activeWorkspaceKey' + | 'activeWorktreeId' + | 'activeTabId' + | 'tabsByWorktree' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' + | 'activeTabIdByWorktree' + | 'openFiles' + | 'editorDrafts' + | 'markdownFrontmatterVisible' + | 'activeFileIdByWorktree' + | 'activeTabTypeByWorktree' + | 'browserTabsByWorktree' + | 'browserPagesByWorkspace' + | 'activeBrowserTabIdByWorktree' + | 'browserUrlHistory' + | 'workspaceDocHistory' + | 'remoteBrowserPageHandlesByPageId' + | 'unifiedTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'activeGroupIdByWorktree' + | 'sshConnectionStates' + | 'repos' + | 'worktreesByRepo' + | 'lastKnownRelayPtyIdByTabId' + | 'lastVisitedAtByWorktreeId' + | 'defaultTerminalTabsAppliedByWorktreeId' + | 'closedTerminalTabTombstonesByTabId' +> & { + activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] + sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] + clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] + /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ + pendingReconnectPtyIdByTabId?: AppState['pendingReconnectPtyIdByTabId'] + deferredSshSessionIdsByTabId?: AppState['deferredSshSessionIdsByTabId'] +} + +// Why: shallow-equality gate for the debounced session writer; _exhaustive below keeps it in sync with the snapshot type. +export const SESSION_RELEVANT_FIELDS = [ + 'activeRepoId', + 'activeWorkspaceKey', + 'activeWorkspaceExecutionHostId', + 'activeWorktreeId', + 'activeTabId', + 'tabsByWorktree', + 'ptyIdsByTabId', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'openFiles', + 'editorDrafts', + 'markdownFrontmatterVisible', + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'browserTabsByWorktree', + 'browserPagesByWorkspace', + 'activeBrowserTabIdByWorktree', + 'browserUrlHistory', + 'workspaceDocHistory', + 'remoteBrowserPageHandlesByPageId', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'sshConnectionStates', + 'repos', + 'worktreesByRepo', + 'lastKnownRelayPtyIdByTabId', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId', + 'closedTerminalTabTombstonesByTabId', + 'sleepingAgentSessionsByPaneKey', + 'clientHostedBrowserCloseIntentsByEnvironment', + 'pendingReconnectPtyIdByTabId', + 'deferredSshSessionIdsByTabId' +] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[] + +type _MissingSessionField = Exclude< + keyof WorkspaceSessionSnapshot, + (typeof SESSION_RELEVANT_FIELDS)[number] +> +void (true satisfies [_MissingSessionField] extends [never] ? true : never) diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 330a150b6366..821726dbf03a 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -1,3 +1,8 @@ +import type { WorkspaceSessionSnapshot } from './workspace-session-snapshot' +export { + SESSION_RELEVANT_FIELDS, + type WorkspaceSessionSnapshot +} from './workspace-session-snapshot' import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { PersistedOpenFile, @@ -25,92 +30,6 @@ export function shouldPersistWorkspaceSession( return state.workspaceSessionReady && state.hydrationSucceeded } -export type WorkspaceSessionSnapshot = Pick< - AppState, - | 'activeRepoId' - | 'activeWorkspaceKey' - | 'activeWorktreeId' - | 'activeTabId' - | 'tabsByWorktree' - | 'ptyIdsByTabId' - | 'terminalLayoutsByTabId' - | 'activeTabIdByWorktree' - | 'openFiles' - | 'editorDrafts' - | 'markdownFrontmatterVisible' - | 'activeFileIdByWorktree' - | 'activeTabTypeByWorktree' - | 'browserTabsByWorktree' - | 'browserPagesByWorkspace' - | 'activeBrowserTabIdByWorktree' - | 'browserUrlHistory' - | 'workspaceDocHistory' - | 'remoteBrowserPageHandlesByPageId' - | 'unifiedTabsByWorktree' - | 'groupsByWorktree' - | 'layoutByWorktree' - | 'activeGroupIdByWorktree' - | 'sshConnectionStates' - | 'repos' - | 'worktreesByRepo' - | 'lastKnownRelayPtyIdByTabId' - | 'lastVisitedAtByWorktreeId' - | 'defaultTerminalTabsAppliedByWorktreeId' - | 'closedTerminalTabTombstonesByTabId' -> & { - activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] - sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] - clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] - /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ - pendingReconnectPtyIdByTabId?: AppState['pendingReconnectPtyIdByTabId'] - deferredSshSessionIdsByTabId?: AppState['deferredSshSessionIdsByTabId'] -} - -// Why: shallow-equality gate for the debounced session writer; _exhaustive below keeps it in sync with the snapshot type. -export const SESSION_RELEVANT_FIELDS = [ - 'activeRepoId', - 'activeWorkspaceKey', - 'activeWorkspaceExecutionHostId', - 'activeWorktreeId', - 'activeTabId', - 'tabsByWorktree', - 'ptyIdsByTabId', - 'terminalLayoutsByTabId', - 'activeTabIdByWorktree', - 'openFiles', - 'editorDrafts', - 'markdownFrontmatterVisible', - 'activeFileIdByWorktree', - 'activeTabTypeByWorktree', - 'browserTabsByWorktree', - 'browserPagesByWorkspace', - 'activeBrowserTabIdByWorktree', - 'browserUrlHistory', - 'workspaceDocHistory', - 'remoteBrowserPageHandlesByPageId', - 'unifiedTabsByWorktree', - 'groupsByWorktree', - 'layoutByWorktree', - 'activeGroupIdByWorktree', - 'sshConnectionStates', - 'repos', - 'worktreesByRepo', - 'lastKnownRelayPtyIdByTabId', - 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId', - 'closedTerminalTabTombstonesByTabId', - 'sleepingAgentSessionsByPaneKey', - 'clientHostedBrowserCloseIntentsByEnvironment', - 'pendingReconnectPtyIdByTabId', - 'deferredSshSessionIdsByTabId' -] as const satisfies readonly (keyof WorkspaceSessionSnapshot)[] - -type _MissingSessionField = Exclude< - keyof WorkspaceSessionSnapshot, - (typeof SESSION_RELEVANT_FIELDS)[number] -> -void (true satisfies [_MissingSessionField] extends [never] ? true : never) - /** Build the editor-file portion of the workspace session for persistence. * Only edit-mode files are saved — diffs and conflict views are transient. */ export function buildEditorSessionData( diff --git a/src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts b/src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts new file mode 100644 index 000000000000..9b0dee95240b --- /dev/null +++ b/src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts @@ -0,0 +1,117 @@ +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()) + +// Mock sonner (imported by repos.ts) +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 +})) + +// Mock agent-status (imported by terminal-helpers) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) + } +}) + +const mockApi = createStoreCascadesMockApi() + +describe('resume fences during asynchronous sleep writes', () => { + beforeEach(() => { + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + mockApi.pty.kill.mockResolvedValue(undefined) + applySleepRuntimeRpcDefault(mockApi) + shutdownBufferCaptures.clear() + }) + it('preserves a fence delivered during manual sleep stop', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const leaf = '11111111-1111-4111-8111-111111111111' + const pane = `tab-1:${leaf}` + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] }, + tabsByWorktree: { [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'pty-agent' })] }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] } + }) + store + .getState() + .setAgentStatus( + pane, + { state: 'working', prompt: 'worker', agentType: 'claude' }, + 'Claude', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'session-1' } } + ) + mockApi.pty.kill.mockImplementationOnce(async () => { + store.setState({ legacyWorkerResumeFencesByPaneKey: { [pane]: true } }) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + }) + await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) + expect(mockApi.pty.kill).toHaveBeenCalled() + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + }) + it('preserves a fence delivered during a failed hibernation stop', async () => { + const store = createTestStore() + const wt = 'repo1::/path/wt1' + const leaf = '11111111-1111-4111-8111-111111111111' + const pane = `tab-1:${leaf}` + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })] }, + tabsByWorktree: { [wt]: [makeTab({ id: 'tab-1', worktreeId: wt, ptyId: 'pty-agent' })] }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: leaf }, + activeLeafId: leaf, + expandedLeafId: null, + ptyIdsByLeafId: { [leaf]: 'pty-agent' } + } + } + }) + store + .getState() + .setAgentStatus( + pane, + { state: 'done', prompt: 'worker', agentType: 'claude' }, + 'Claude', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: wt }, + { providerSession: { key: 'session_id', id: 'session-1' } } + ) + mockApi.pty.kill.mockImplementationOnce(async () => { + store.setState({ legacyWorkerResumeFencesByPaneKey: { [pane]: true } }) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + throw new Error('kill_failed') + }) + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(wt, { + paneKey: pane, + tabId: 'tab-1', + leafId: leaf, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('kill_failed') + expect(mockApi.pty.kill).toHaveBeenCalled() + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) + }) +}) 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 f1deb30370f9..64baade1e002 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 cbd6b1869972..68b97aeae63e 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,92 @@ 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().legacyWorkerResumeFencesByPaneKey[PANE_KEY]).toBe(true) + }) + + // 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.setState({ legacyWorkerResumeFencesByPaneKey: {} }) + + 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().sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject({ - paneKey: PANE_KEY, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) }) - 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) + // Compatibility projection belongs to main, so outgoing renderer records stay untouched. + it('does not project runtime fences onto outgoing records', () => { + const store = fencedStore() + store.getState().captureAllSleepingAgentSessions('quit') + const snapshot = store.getState() + expect( + snapshot.sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + ).toBeUndefined() - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) + const projected = buildSleepingAgentSessionData(snapshot) + + expect( + projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy + ).toBeUndefined() + }) + + it('strips a stale projection once the runtime retires the fence', () => { + const store = fencedStore() store.getState().captureAllSleepingAgentSessions('quit') + store.setState({ legacyWorkerResumeFencesByPaneKey: {} }) + + 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 32e5378fe11a..1ffe1d7cb0e8 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 @@ -113,10 +113,6 @@ export function createAgentStatusProviderSessionActions( ? { connectionId: existingRecord.connectionId } : {}), ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(existingRecordMatchesProviderSession && - existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } - : {}), ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined ? { interrupted: existingRecord.interrupted } : {}), 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 0d60d0239560..42e31bb8465a 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 d750f5c0df9e..0e256834a9f7 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -22,7 +22,6 @@ export function createAgentStatusRecoveryActions( | 'captureAllSleepingAgentSessions' | 'clearSleepingAgentSession' | 'clearSleepingAgentSessionsByPaneKey' - | 'setSleepingAgentAutomaticResumeBlocked' | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { @@ -109,47 +108,6 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { - set((s) => { - // The pane key is tracked even with no record: a worker settled while its tab was open - // is fenced before the record exists, and the record is only minted on close. - const wasBlocked = s.automaticResumeBlockedPaneKeys[paneKey] === true - let paneKeys = s.automaticResumeBlockedPaneKeys - if (blocked !== wasBlocked) { - paneKeys = { ...s.automaticResumeBlockedPaneKeys } - if (blocked) { - paneKeys[paneKey] = true - } else { - delete paneKeys[paneKey] - } - } - const current = s.sleepingAgentSessionsByPaneKey[paneKey] - if ( - !current || - (blocked - ? current.automaticResumeBlockedBy === 'legacy-orchestration-worker' - : current.automaticResumeBlockedBy === undefined) - ) { - return paneKeys === s.automaticResumeBlockedPaneKeys - ? s - : { automaticResumeBlockedPaneKeys: paneKeys } - } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy - } - return { - automaticResumeBlockedPaneKeys: paneKeys, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: next - } - } - }) - }, - clearSleepingAgentSessionsByWorktree: (worktreeId) => { set((s) => { let changed = false diff --git a/src/renderer/src/store/slices/agent-status-recovery-collection.ts b/src/renderer/src/store/slices/agent-status-recovery-collection.ts index 4587f919d368..689dbf861a7d 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-collection.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-collection.ts @@ -10,7 +10,6 @@ import { retainedAgentEntryFromLive } from './agent-status-pane-key-tab-binding' import { - carryOverAutomaticResumeBlock, isValidCompletedAgentHibernationEntry, manualSleepCaptureEntry, markManualSleepLazyRestore, @@ -96,10 +95,6 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (record) { if (isManualWorktreeSleep) { markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock( - record, - state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] - ) } records[record.paneKey] = record } @@ -133,7 +128,6 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (record) { if (isManualWorktreeSleep) { markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) } records[record.paneKey] = record } 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 48691b078bcb..6363fb9e2166 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, @@ -59,11 +58,7 @@ export function sleepingRecordFromEntry(args: { : {}), ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), ...(args.entry.interrupted ? { interrupted: true } : {}), - ...(args.origin ? { origin: args.origin } : {}), - // The worker can settle while the tab is open, so the fence arrives before this record exists. - ...(args.state.automaticResumeBlockedPaneKeys?.[args.entry.paneKey] - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } - : {}) + ...(args.origin ? { origin: args.origin } : {}) } } @@ -113,21 +108,6 @@ export function manualSleepCaptureEntry( return { ...entry, updatedAt: capturedAt, interrupted: false } } -// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately -// blocked worker must not become auto-resumable at wake. -export function carryOverAutomaticResumeBlock( - record: SleepingAgentSessionRecord, - previous: SleepingAgentSessionRecord | undefined -): void { - if ( - previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - previous.agent === record.agent && - agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) - ) { - record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy - } -} - export function removeSleepingRecordsReplacedByManualWorktreeSleep( records: Record, worktreeId: string, 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 f9c02abda5a8..db78487e0ba7 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -51,9 +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 + /** 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 @@ -162,7 +162,6 @@ export type AgentStatusSlice = { captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 64941669dfb3..8939f0506d59 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -100,7 +100,7 @@ export const createAgentStatusSlice: StateCreator { + const store = createTestStore() + const wt = 'repo::/wt' + const pane = 'tab-a:11111111-2222-4333-8444-555555555555' + const tab = makeTab({ id: 'tab-a', worktreeId: wt }) + store.setState({ + tabsByWorktree: { [wt]: [tab] }, + legacyWorkerResumeFencesByPaneKey: { + [pane]: true, + 'tab-b:22222222-2222-4333-8444-555555555555': true + } + }) + store.getState().hydrateWorkspaceSession( + { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { [wt]: [tab] }, + legacyWorkerResumeFencesByPaneKey: {} + }, + { replaceWorkspaceKeys: [wt], additionalValidWorkspaceKeys: [worktreeWorkspaceKey(wt)] } + ) + expect( + store.getState().legacyWorkerResumeFencesByPaneKey['tab-b:22222222-2222-4333-8444-555555555555'] + ).toBe(true) + expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBeUndefined() +}) diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index 9e6265c938eb..6d5df4eab50e 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -63,13 +63,16 @@ export function createTerminalPaneHibernationActions( // Why: killing the PTY with no persisted resume record strands the pane unwakeable; abort instead of hibernating unrecoverably. throw new Error('agent_hibernation_capture_missing') } - // Why: the planner's fence check happened before the coordinator's async re-plan; - // the fence can be set in that window. Capture below OVERWRITES the record, and - // sleepingRecordFromEntry does not copy the flag, so losing this race erases the - // fence and later auto-resumes work whose relaunch was explicitly prohibited. + // Why: the planner's fence check happened before the coordinator's async re-plan; the fence + // can be set in that window, and the kill would strand work whose relaunch is prohibited. const assertAutomaticHibernationStillAllowed = (): void => { - const record = get().sleepingAgentSessionsByPaneKey[opts.paneKey] - if (!isAutomaticHibernationAllowed(record)) { + const current = get() + if ( + !isAutomaticHibernationAllowed({ + legacyWorkerResumeFencesByPaneKey: current.legacyWorkerResumeFencesByPaneKey, + paneKey: opts.paneKey + }) + ) { throw new Error('agent_hibernation_automatic_resume_blocked') } } diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 39be3c94f0fb..5aabd9acd9fa 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -1,3 +1,4 @@ +import { hydrateRuntimeSessionFields } from '@/lib/runtime-session-application' import type { WorkspaceKey } from '../../../../shared/folder-workspace-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { @@ -31,6 +32,14 @@ export function createWorkspaceTerminalHydrationActions( ): Pick { return { hydrateWorkspaceSession: (session, options) => { + const targetTabIds = options?.replaceWorkspaceKeys + ? new Set( + options.replaceWorkspaceKeys.flatMap((key) => [ + ...(get().tabsByWorktree[key] ?? []).map((tab) => tab.id), + ...(session.tabsByWorktree[key] ?? []).map((tab) => tab.id) + ]) + ) + : undefined const ownershipTransferTabIds = options?.replaceWorkspaceKeys ? new Set( options.replaceWorkspaceKeys.flatMap((workspaceKey) => @@ -215,9 +224,12 @@ export function createWorkspaceTerminalHydrationActions( validTabIds }) } - return options?.replaceWorkspaceKeys - ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) - : hydrated + return { + ...(options?.replaceWorkspaceKeys + ? targetScopedWorkspaceHydrationPatch(s, hydrated, session, options) + : hydrated), + ...hydrateRuntimeSessionFields(session, get, targetTabIds) + } }) for (const [tabId, transfers] of ownershipTransfersByTabId) { transferNormalizedTerminalLayoutPtyOwnership(get(), tabId, transfers) 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 1a07b6d6a6cc..d39286679c76 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 28fd9f99176d..771b946e10f1 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -36,6 +36,8 @@ export function createWebAppApi(): Partial { awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), 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/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index be2e44018187..4b7e2c3b1ee3 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-resume-fences.ts b/src/shared/workspace-session-resume-fences.ts new file mode 100644 index 000000000000..29a2ce3621a2 --- /dev/null +++ b/src/shared/workspace-session-resume-fences.ts @@ -0,0 +1,17 @@ +import type { WorkspaceSessionState } from './workspace-session-state-types' + +export function readWorkspaceSessionResumeFences( + session: Pick< + WorkspaceSessionState, + 'legacyWorkerResumeFencesByPaneKey' | 'sleepingAgentSessionsByPaneKey' + > +): Record { + return ( + session.legacyWorkerResumeFencesByPaneKey ?? + Object.fromEntries( + Object.entries(session.sleepingAgentSessionsByPaneKey ?? {}) + .filter(([, record]) => record.automaticResumeBlockedBy === 'legacy-orchestration-worker') + .map(([key]) => [key, true as const]) + ) + ) +} diff --git a/src/shared/workspace-session-schema-field-coverage.test.ts b/src/shared/workspace-session-schema-field-coverage.test.ts index d371d488b671..3270c4be2432 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 48fe00a7f4da..96320bc90222 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 e0cc7190cbfb..5ebfa8b5bf09 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 43de5782648e..381495852cec 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -60,18 +60,26 @@ process.stdin.resume() setInterval(() => {}, 60_000) ` -if (process.platform === 'win32') { - writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) - writeFileSync( - path.join(fakeCliDir, 'codex.cmd'), - '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' - ) -} else { +// Why re-materialized rather than written once at import: the module is shared across spec files in +// a worker, and the first file's `cleanupCompletedWorkerFixture` removes the whole directory. A +// later file in the same worker would otherwise launch a `codex` that no longer exists. +function ensureCompletedWorkerFakeCli(): void { + mkdirSync(fakeCliDir, { recursive: true }) + if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) + return + } const executable = path.join(fakeCliDir, 'codex') writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) chmodSync(executable, 0o755) } +ensureCompletedWorkerFakeCli() + export const completedWorkerLaunchEnv = { PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath @@ -91,6 +99,7 @@ export type TerminalIdentity = Pick< > export function clearCompletedWorkerLedger(): void { + ensureCompletedWorkerFakeCli() rmSync(lifecycleLedgerPath, { force: true }) } @@ -186,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 new file mode 100644 index 000000000000..d8abe6a9761c --- /dev/null +++ b/tests/e2e/settled-worker-resume-fence-restart.spec.ts @@ -0,0 +1,372 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override' +import { + cleanupCompletedWorkerFixture, + clearCompletedWorkerLedger, + completedWorkerFakeCodexCommand, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + readPersistedWorkerResumeFence, + seedCurrentCodexTranscript +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree/id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dd' + +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + cleanupCompletedWorkerFixture() +}) + +async function findSecondaryWorktree( + page: Page, + client: RuntimeClient, + coordinatorWorktreeId: string +): Promise { + let targetWorktreeId: string | null = null + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + // The restart fixture only waits for the primary; refetch until the seeded secondary lands. + const rendererWorktreeIds = await page.evaluate(async () => { + const store = window.__store + if (!store) { + return [] + } + await Promise.all( + store.getState().repos.map((repo) => store.getState().fetchWorktrees(repo.id)) + ) + return Object.values(store.getState().worktreesByRepo) + .flat() + .map((worktree) => worktree.id) + }) + targetWorktreeId = + listed.result.worktrees.find( + (worktree) => + worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id) + )?.id ?? null + return targetWorktreeId + }, + { timeout: 60_000, message: 'runtime never registered the secondary worktree' } + ) + .not.toBeNull() + if (!targetWorktreeId) { + throw new Error('The seeded repository did not expose its secondary worktree') + } + return targetWorktreeId +} + +async function backgroundMountTab(page: Page, worktreeId: string, tabId: string): Promise { + await page.evaluate( + ({ tabId, worktreeId }) => { + window.dispatchEvent( + new CustomEvent('orca-background-mount-terminal-worktree', { + detail: { worktreeId, tabIds: [tabId] } + }) + ) + }, + { tabId, worktreeId } + ) + await expect + .poll(() => page.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), tabId)) + .toBe(true) +} + +// A worker that reported done while its terminal stays open is fenced from automatic resume. The +// fence used to live only in the renderer's volatile blocked-pane map, so the first status write +// after any restart erased it and worktree activation relaunched the settled worker over its own +// still-live PTY (#16904 regression). +test('a settled orchestration worker keeps its resume fence across restart and reload', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + clearCompletedWorkerLedger() + + const session = createRestartSession(testInfo, completedWorkerLaunchEnv) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + const coordinatorWorktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page) + await waitForActivePanePtyId(first.page) + await first.page.evaluate( + async ({ agentCommand, terminalWindowsShell }) => { + await window.__store?.getState().updateSettings({ + agentCmdOverrides: { codex: agentCommand }, + terminalWindowsShell, + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }, + { + agentCommand: completedWorkerFakeCodexCommand, + terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL + } + ) + const isolatedHome = await firstApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(session.userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(first.page) + const coordinatorHandle = ( + await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey: coordinatorPane.paneKey + }) + ).result.terminal.handle + const targetWorktreeId = await findSecondaryWorktree(first.page, client, coordinatorWorktreeId) + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Keep one settled worker tab across restart', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion and stay open', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${targetWorktreeId}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId) { + throw new Error('Background worker did not publish its PTY') + } + const workerPtyId = worker.ptyId + const workerTabId = worker.tabId + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + await backgroundMountTab(first.page, targetWorktreeId, workerTabId) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + await first.page.evaluate( + ({ + agentCommand, + paneKey, + providerSessionId, + tabId, + terminalHandle, + transcriptPath, + worktreeId + }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession: { key: 'session_id' as const, id: providerSessionId, transcriptPath }, + launchConfig: { + agentCommand, + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + for (const agentState of ['working', 'done'] as const) { + state.setAgentStatus( + paneKey, + { state: agentState, prompt: 'Report completion and stay open', agentType: 'codex' }, + 'Settled background worker', + undefined, + metadata, + recovery + ) + } + }, + { + agentCommand: completedWorkerFakeCodexCommand, + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: workerTabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed and stays open for inspection.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + // 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().legacyWorkerResumeFencesByPaneKey[paneKey] === true, + workerPaneKey + ), + { timeout: 30_000, message: 'settled worker pane was never fenced' } + ) + .toBe(true) + + await session.close(firstApp) + firstApp = null + await expect + .poll(() => readPersistedWorkerResumeFence(session.userDataDir, workerPaneKey), { + message: 'the runtime-authored fence never reached disk' + }) + .toBe(true) + expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([]) + + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + // The daemon kept the worker alive across the app restart; the new main process never attached it. + await expect + .poll( + async () => + (await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId) + ?.connected ?? null, + { timeout: 60_000, message: 'restarted runtime never rediscovered the worker PTY' } + ) + .toBe(true) + expect( + await second.page.evaluate( + ({ tabId, worktreeId }) => + Boolean( + window.__store?.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + { tabId: workerTabId, worktreeId: targetWorktreeId } + ) + ).toBe(true) + + // 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( + paneKey, + { state: 'done', prompt: 'Report completion and stay open', agentType: 'codex' }, + 'Settled background worker', + undefined, + { tabId, worktreeId, terminalHandle }, + { + providerSession: { + key: 'session_id' as const, + id: providerSessionId, + transcriptPath + } + } + ) + }, + { + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: workerTabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + expect( + await second.page.evaluate( + (paneKey) => window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, + workerPaneKey + ), + 'a status write after restart must not erase the fence' + ).toBe(true) + + // 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 + .poll( + () => + second.page.evaluate( + (paneKey) => + window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, + workerPaneKey + ), + { timeout: 60_000, message: 'the reloaded renderer never re-seeded the resume fence' } + ) + .toBe(true) + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +})