From c0a49167f7661c4b73aaf2d0e941d4869d05267c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 15:49:59 -0400 Subject: [PATCH 1/7] fix(renderer): make the sleeping record the settled-worker fence's only home The resume fence that stops a settled orchestration worker being relaunched had two homes and a directional gap between them. `sleepingRecordFromEntry` derived `automaticResumeBlockedBy` solely from `automaticResumeBlockedPaneKeys`, which starts empty on every renderer boot and is never persisted, so the first `setAgentStatus` after any renderer start rebuilt the record without the flag. The hibernation planner then admitted the pane, killed a live worker PTY, and worktree activation relaunched it with `--resume`. The record is now the durable home. Every rebuild path resolves the flag through one predicate that reads the record first and the pane-key map second, gated on session identity, so `carryOverAutomaticResumeBlock` and the open-coded carry in `recordAgentProviderSession` collapse into it. `recoveryRecordMatches` treats the flag as significant, so a rebuild that would drop it is no longer accepted as an equal record. The planner and the hibernation coordinator's re-check now read the pane-key map too, which closes the window where a pane is fenced before any record exists. Main stops announcing each fence once per process. `prepare` re-announces every blocked pane on every pass and the renderer-startup handshake returns the fenced pane set, so a renderer that reloads, crashes or opens a second window re-derives its whole map instead of losing the fence. The announce and the lift edge are now committed only after the session write succeeds, so a staging failure retries rather than dropping the fence forever. --- ...output-and-worker-recovery-part-04.spec.ts | 1 + ...cy-worker-terminal-recovery-persistence.ts | 35 +- ...-legacy-worker-terminal-recovery-runner.ts | 1 + ...e-legacy-worker-terminal-recovery-types.ts | 2 + ...egacy-worker-terminal-resume-fence.test.ts | 101 +++++ .../legacy-worker-renderer-recovery.ts | 12 +- src/preload/api/app-api.ts | 6 +- src/preload/api/app-bridge.ts | 3 +- .../app-shell/use-app-startup-hydration.ts | 5 +- .../src/lib/agent-hibernation-coordinator.ts | 1 + ...tion-pane-eligibility-resume-fence.test.ts | 89 +++++ .../lib/agent-hibernation-pane-eligibility.ts | 11 +- .../lib/agent-hibernation-planner-snapshot.ts | 2 + .../src/lib/agent-hibernation-planner.ts | 1 + .../src/lib/live-resume-anchor-record.ts | 18 +- .../legacy-worker-resume-fence-seed.test.ts | 51 +++ .../legacy-worker-resume-fence-seed.ts | 18 + .../src/startup/startup-degraded-recovery.ts | 5 +- ...agent-status-hydrated-resume-fence.test.ts | 124 ++++++ .../agent-status-provider-session-actions.ts | 7 +- .../agent-status-recovery-collection.ts | 6 - .../agent-status-recovery-equivalence.ts | 5 +- .../slices/agent-status-sleeping-records.ts | 43 +- .../terminals/terminal-pane-hibernation.ts | 16 +- .../src/web/preload-api/web-app-api.ts | 5 +- src/shared/agent-session-resume.ts | 4 + ...ettled-worker-resume-fence-restart.spec.ts | 374 ++++++++++++++++++ 27 files changed, 875 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts create mode 100644 src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts create mode 100644 src/renderer/src/startup/legacy-worker-resume-fence-seed.ts create mode 100644 src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts create mode 100644 tests/e2e/settled-worker-resume-fence-restart.spec.ts 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..434d440b54a2 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 @@ -478,6 +478,7 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store) const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({ blockedPaneCount: 1, + blockedPaneKeys: [], adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: [] 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..544eca9b987c 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -23,9 +23,11 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { private readonly notifyFenceChanged?: (paneKey: string, blocked: boolean) => 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() + /** The previous pass's blocked set, kept only to emit the lift edge for a pane with no sleeping + * record — `liftRetiredFences` can sweep only panes that already have one. It is not a second + * home for the fence: it is committed after staging succeeds, and every renderer re-derives its + * whole blocked-pane map from `plan.blockedPanes` at startup, so a lost push heals there. */ + private lastPlanBlockedPaneKeys: ReadonlySet = new Set() prepare(): LegacyWorkerTerminalRecoveryPlan { const plan = this.getPlan() @@ -47,14 +49,14 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { { current: WorkspaceSessionState; next: WorkspaceSessionState } >() const changedHostIds = new Set() - const fenceChanges: [string, boolean][] = [] + const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey)) + const fenceChanges = new Map() 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]) - } + // Announced every pass because the renderer's map is volatile — a once-per-process push + // dies on reload and never reaches a client that pairs later. The renderer is idempotent. + fenceChanges.set(blocked.paneKey, true) let hostIds: ExecutionHostId[] try { const hostId = this.getHostId(blocked.worktreeId) @@ -94,16 +96,19 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { changedHostIds.add(hostId) } } - this.liftRetiredFences(store, plan, sessions, changedHostIds, fenceChanges) + this.liftRetiredFences(store, blockedPaneKeys, sessions, changedHostIds, fenceChanges) const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) try { for (const [hostId, state] of changed) { store.setWorkspaceSession(state.next, hostId) } } catch (error) { + // Why after the write: a staging failure must not consume the announce or the lift edge, + // or a record-less pane would keep a fence no later pass could ever deliver or retire. console.warn('[orchestration] failed to stage legacy worker resume fence', error) return plan } + this.lastPlanBlockedPaneKeys = blockedPaneKeys for (const [paneKey, blocked] of fenceChanges) { this.notifyFenceChanged?.(paneKey, blocked) } @@ -115,16 +120,14 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { * retire it here. An unreadable plan yields no blocked panes, so callers must not sweep. */ private liftRetiredFences( store: RuntimeStore, - plan: LegacyWorkerTerminalRecoveryPlan, + blockedPaneKeys: ReadonlySet, sessions: Map, changedHostIds: Set, - fenceChanges: [string, boolean][] + fenceChanges: Map ): void { - const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey)) - for (const paneKey of this.announcedBlockedPaneKeys) { + for (const paneKey of this.lastPlanBlockedPaneKeys) { if (!blockedPaneKeys.has(paneKey)) { - this.announcedBlockedPaneKeys.delete(paneKey) - fenceChanges.push([paneKey, false]) + fenceChanges.set(paneKey, false) } } for (const hostId of store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID]) { @@ -151,7 +154,7 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { for (const [paneKey, record] of retired) { const { automaticResumeBlockedBy: _retired, ...unfenced } = record next[paneKey] = unfenced - fenceChanges.push([paneKey, false]) + fenceChanges.set(paneKey, false) } state.next.sleepingAgentSessionsByPaneKey = next changedHostIds.add(hostId) diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index bd15abc7d4d7..5f7751f79806 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -105,6 +105,7 @@ export async function runLegacyWorkerTerminalRecovery( } const result = { blockedPaneCount: plan.blockedPanes.length, + blockedPaneKeys: plan.blockedPanes.map((blocked) => blocked.paneKey), adoptedDispatchIds, exitedDispatchIds, deferredDispatchIds: [...deferredDispatchIds] diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts index c65afd739523..9a5b88e14d02 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts @@ -6,6 +6,8 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' export type LegacyWorkerTerminalRecoveryResult = { blockedPaneCount: number + /** The authoritative fenced-pane set for this pass; a renderer seeds its volatile map from it. */ + blockedPaneKeys: string[] adoptedDispatchIds: string[] exitedDispatchIds: string[] deferredDispatchIds: string[] 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..c918d3ed6af7 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 @@ -127,6 +127,107 @@ describe('settled worker automatic-resume fence persistence', () => { ]) }) + // A renderer's blocked-pane map starts empty on every boot (reload, crash restart, asar swap, + // a new window). A once-per-process announcement left the reloaded renderer unfenced, so it + // minted an unfenced record on close and worktree activation respawned the settled worker. + it('re-announces the fence on every pass so a reloaded renderer is fenced again', () => { + const fenceChanges: [string, boolean][] = [] + const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false) + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + expect(fenceChanges).toEqual([[PANE_KEY, true]]) + + fenceChanges.length = 0 + const plan = h.persistence.prepare() + + expect(plan.blockedPanes).toEqual([expect.objectContaining({ paneKey: PANE_KEY })]) + expect(fenceChanges).toEqual([[PANE_KEY, true]]) + }) + + // Headless `orca serve` / orcad runs the same recovery with no window notifier. Burning the + // announcement there meant a client that paired afterwards never learned of the fence. + it('still announces to a client that attaches after a headless recovery pass', () => { + let notifierAttached = false + const fenceChanges: [string, boolean][] = [] + const h = harness((paneKey, blocked) => { + if (notifierAttached) { + fenceChanges.push([paneKey, blocked]) + } + }, false) + settle(h.db, h.taskId, h.dispatchId) + + h.persistence.prepare() + expect(fenceChanges).toEqual([]) + + notifierAttached = true + h.persistence.prepare() + + expect(fenceChanges).toEqual([[PANE_KEY, true]]) + }) + + // A staging failure must not consume the announcement: the pane stays fenced in the plan, so + // the next pass has to deliver it rather than leave a live renderer permanently unfenced. + it('redelivers the announcement after a failed session write', () => { + const fenceChanges: [string, boolean][] = [] + let failWrite = true + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = sessionWithSleepingWorker() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + if (failWrite) { + throw new Error('workspace_session_write_failed') + } + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const task = orchestrationDb.createTask({ spec: 'fence me' }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + settle(orchestrationDb, task.id, started.dispatch.id) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => orchestrationDb, + () => LOCAL_EXECUTION_HOST_ID, + (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + persistence.prepare() + } finally { + warn.mockRestore() + } + expect(fenceChanges).toEqual([]) + + failWrite = false + persistence.prepare() + + expect(fenceChanges).toEqual([[PANE_KEY, true]]) + expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( + 'legacy-orchestration-worker' + ) + }) + // 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', () => { diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts index 4a24f107135e..484fdfa38d4f 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -1,14 +1,17 @@ +import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' +import type { LegacyWorkerTerminalRecoveryResult } from '../runtime/runtime-legacy-worker-terminal-recovery-types' + type LegacyWorkerRendererRecoveryOptions = { firstWindowStartupServicesReady: Promise managedWslCliStartupBarrierReady: Promise localPtyProviderStartupReady: Promise - reconcile: () => Promise | undefined + reconcile: () => Promise | undefined onDeferredRecoveryError: (error: unknown) => void } export async function recoverLegacyWorkerTerminalsForRendererStartup( options: LegacyWorkerRendererRecoveryOptions -): Promise { +): Promise { const providerStartupResult = options.localPtyProviderStartupReady.then( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }) @@ -20,11 +23,12 @@ export async function recoverLegacyWorkerTerminalsForRendererStartup( ]) if (!providerResult.ok) { options.onDeferredRecoveryError(providerResult.error) - return + return { blockedPaneKeys: [] } } try { - await options.reconcile() + return { blockedPaneKeys: (await options.reconcile())?.blockedPaneKeys ?? [] } } catch (error) { options.onDeferredRecoveryError(error) + return { blockedPaneKeys: [] } } } diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index b2d6eed966c4..fe12ef1efe45 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -1,3 +1,4 @@ +import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { AppIdentity } from '../../shared/app-identity' import type { E2EConfig } from '../../shared/e2e-config' import type { ExecutionHostId } from '../../shared/execution-host' @@ -43,8 +44,9 @@ export type AppApi = { awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise - /** Reconciles legacy worker authority around persisted terminal reconnect. */ - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise + /** Reconciles legacy worker authority around persisted terminal reconnect and returns the + * fenced-pane set the renderer must seed its volatile blocked-pane map from. */ + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise /** 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..20863cd762da 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -1,3 +1,4 @@ +import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import { ipcRenderer } from 'electron' import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' @@ -47,7 +48,7 @@ export const appApi = { ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => + recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), startupDiagnostic: (event: string, details?: Record): Promise => startupDiagnosticsEnabled diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 77da19ffd204..8ec1e01f90b4 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -1,3 +1,4 @@ +import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from '@/startup/legacy-worker-resume-fence-seed' import { useEffect, useRef } from 'react' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' @@ -260,7 +261,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited // the first-window services, and main re-awaits them inside this handler anyway. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => - window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + recoverLegacyWorkerTerminalsAndSeedResumeFences() ) await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { return refreshTerminalProviderSnapshotCapabilities( @@ -272,7 +273,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.reconnectPersistedTerminals(abortController.signal) ) await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => - window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + recoverLegacyWorkerTerminalsAndSeedResumeFences() ) if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { await timeRendererStartupStep('project-structured-session-tabs', () => diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index 5f17e1e48345..7dfab1fb8747 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, + automaticResumeBlockedPaneKeys: state.automaticResumeBlockedPaneKeys, // 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..73fe2c3e32f2 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts @@ -0,0 +1,89 @@ +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 = (fenced: boolean): 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', + ...(fenced ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } : {}) +}) + +function plan( + record: SleepingAgentSessionRecord | undefined, + automaticResumeBlockedPaneKeys: Record = {} +) { + return getEligiblePane({ + entry, + tab, + layout: { ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } } as never, + livePtyIds: new Set(['pty-1']), + sleepingAgentSessionsByPaneKey: record ? { [PANE_KEY]: record } : {}, + automaticResumeBlockedPaneKeys, + 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 +// planner used to read the fence only off the sleeping record, so a worker that settled while its +// tab was still open — fenced before any record exists — was admitted for the kill. +describe('hibernation eligibility and the settled-worker resume fence', () => { + it('blocks the kill when the record carries the fence', () => { + expect(plan(liveAnchor(true))).toBeNull() + }) + + it('blocks the kill when only the blocked-pane map carries the fence', () => { + expect(plan(liveAnchor(false), { [PANE_KEY]: true })).toBeNull() + }) + + it('blocks the kill when the pane is fenced before any record exists', () => { + expect(plan(undefined, { [PANE_KEY]: true })).toBeNull() + }) + + // Controls: the ordinary completed-agent population must still hibernate. + it('admits an unfenced completed pane holding only its live resume anchor', () => { + expect(plan(liveAnchor(false))).not.toBeNull() + }) + + it('admits an unfenced completed pane with no record at all', () => { + expect(plan(undefined)).not.toBeNull() + }) + + it('admits a pane when a different pane is the fenced one', () => { + expect(plan(liveAnchor(false), { 'tab-9:other-leaf': true })).not.toBeNull() + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 839209ea809e..1db4710ed3a6 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'] + automaticResumeBlockedPaneKeys: Record lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] ptyBindingFirstSeenAtByPaneKey: Record @@ -75,6 +76,7 @@ export function getEligiblePane(args: { layout, livePtyIds, sleepingAgentSessionsByPaneKey, + automaticResumeBlockedPaneKeys, lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey, @@ -96,9 +98,12 @@ 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({ + record: sleepingRecord, + automaticResumeBlockedPaneKeys, + 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..94cc725d8761 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 + /** Panes fenced before a record exists; main re-seeds this on every renderer start. */ + automaticResumeBlockedPaneKeys?: Record lastTerminalInputAtByPaneKey: Record foregroundTerminalLastSeenAtByTabId: Record ptyBindingFirstSeenAtByPaneKey?: Record diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 410fb393043a..6364e5ec647a 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, + automaticResumeBlockedPaneKeys: snapshot.automaticResumeBlockedPaneKeys ?? {}, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, diff --git a/src/renderer/src/lib/live-resume-anchor-record.ts b/src/renderer/src/lib/live-resume-anchor-record.ts index 7fa9d3f88ed9..73e6771d9981 100644 --- a/src/renderer/src/lib/live-resume-anchor-record.ts +++ b/src/renderer/src/lib/live-resume-anchor-record.ts @@ -52,12 +52,18 @@ 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 durable orchestration fence against automatic provider relaunch; hibernating a fenced pane + * would strand it. The record is the fence's home, but a worker that settles while its tab is + * still open is fenced before any record exists — `automaticResumeBlockedPaneKeys` holds it for + * that window, so the planner must read both. */ -export function isAutomaticHibernationAllowed( +export function isAutomaticHibernationAllowed(pane: { record: SleepingAgentSessionRecord | undefined -): boolean { - return !record?.automaticResumeBlockedBy + automaticResumeBlockedPaneKeys: Record + paneKey: string +}): boolean { + return ( + !pane.record?.automaticResumeBlockedBy && + pane.automaticResumeBlockedPaneKeys[pane.paneKey] !== true + ) } diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts new file mode 100644 index 000000000000..226b076df34e --- /dev/null +++ b/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from './legacy-worker-resume-fence-seed' + +const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' + +function stubRecovery(result: unknown): ReturnType { + const recover = vi.fn().mockResolvedValue(result) + vi.stubGlobal('window', { + ...globalThis.window, + api: { app: { recoverLegacyWorkerTerminalsForRendererStartup: recover } } + }) + return recover +} + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) +}) + +// The renderer's blocked-pane map starts empty on every boot, reload included, and is never +// persisted. Main answers the startup handshake with the fenced-pane set so the fence survives. +describe('seeding the resume fence from the renderer-startup handshake', () => { + it('blocks every pane main reports as fenced', async () => { + stubRecovery({ blockedPaneKeys: [PANE_KEY] }) + + await recoverLegacyWorkerTerminalsAndSeedResumeFences() + + expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) + }) + + // An unreadable recovery plan also yields an empty set, and loss of contact with the plan is + // never evidence that a pane stopped needing its fence — so the seed only ever adds. + it('leaves an already-blocked pane alone when main reports nothing', async () => { + useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) + stubRecovery({ blockedPaneKeys: [] }) + + await recoverLegacyWorkerTerminalsAndSeedResumeFences() + + expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) + }) + + // A paired/web client has no wire method for the fence yet and resolves an empty snapshot. + it('tolerates a client that cannot answer the handshake', async () => { + stubRecovery(undefined) + + await expect(recoverLegacyWorkerTerminalsAndSeedResumeFences()).resolves.toBeUndefined() + + expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) + }) +}) diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts new file mode 100644 index 000000000000..e8ff43fb5284 --- /dev/null +++ b/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts @@ -0,0 +1,18 @@ +import { useAppStore } from '@/store' + +/** + * Main owns the settled-worker resume fence; the renderer's `automaticResumeBlockedPaneKeys` map is + * volatile and starts empty on every renderer boot, reload included. Pulling the fenced-pane set on + * the startup handshake is what makes a fence survive a reload — a once-per-process push cannot. + * + * Additive on purpose: an unreadable recovery plan also yields an empty set, and loss of contact + * with the plan is never evidence that a pane stopped needing its fence. Retirement arrives through + * the live lift push and through main sweeping the durable record. + */ +export async function recoverLegacyWorkerTerminalsAndSeedResumeFences(): Promise { + const snapshot = await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + const setBlocked = useAppStore.getState().setSleepingAgentAutomaticResumeBlocked + for (const paneKey of snapshot?.blockedPaneKeys ?? []) { + setBlocked(paneKey, true) + } +} diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index 987a74a8341e..52c422bd640a 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -1,3 +1,4 @@ +import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from './legacy-worker-resume-fence-seed' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' @@ -87,12 +88,12 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr } try { await window.api.app.awaitFirstWindowStartupServices() - await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + await recoverLegacyWorkerTerminalsAndSeedResumeFences() await refreshTerminalProviderSnapshotCapabilities( collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) ) await reconnectPersistedTerminals(abortSignal) - await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() + await recoverLegacyWorkerTerminalsAndSeedResumeFences() } catch (reconnectErr) { console.error('[startup] reconnectPersistedTerminals failed in error path:', reconnectErr) // Why (issue #1158): the await may have run during StrictMode teardown; re-check cancellation so a cancelled pass 1 doesn't stomp pass 2's hydration. diff --git a/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts b/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts new file mode 100644 index 000000000000..199cf60a6a0b --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { AppState } from '../types' +import { createTestStore, makeTab } from './store-test-helpers' + +const NOW = 1_800_000_000_000 +const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' + +function fencedRecord( + origin: SleepingAgentSessionRecord['origin'], + sessionId = 'session-1' +): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: sessionId }, + prompt: '', + state: 'done', + capturedAt: NOW - 10_000, + updatedAt: NOW - 10_000, + origin, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } +} + +function hydrate(record: SleepingAgentSessionRecord, blockedPaneKeys?: Record) { + const store = createTestStore() + store.setState({ + tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, + sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record }, + ...(blockedPaneKeys ? { automaticResumeBlockedPaneKeys: blockedPaneKeys } : {}) + } as Partial) + return store +} + +function writeStatus( + store: ReturnType, + status: { state: AgentStatusEntry['state']; prompt: string }, + sessionId = 'session-1' +): void { + store + .getState() + .setAgentStatus( + PANE_KEY, + { ...status, agentType: 'claude' } as never, + undefined, + { updatedAt: NOW }, + { tabId: 'tab-1', worktreeId: 'wt-1' } as never, + { providerSession: { key: 'session_id', id: sessionId } } + ) +} + +const fenceOf = (store: ReturnType): string | undefined => + store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + +// The fence's durable home is the record. `automaticResumeBlockedPaneKeys` starts empty on every +// renderer boot and is not persisted, so deriving the rebuilt record's flag from that map alone +// erased a hydrated fence on the first status write — and worktree activation then relaunched the +// settled worker with `--resume` over its still-live PTY. +describe('a hydrated resume fence and the volatile blocked-pane map', () => { + it('is not restored into the blocked-pane map by hydration alone', () => { + expect( + hydrate(fencedRecord('worktree-sleep')).getState().automaticResumeBlockedPaneKeys + ).toEqual({}) + }) + + it('survives a status write with the blocked-pane map empty', () => { + const store = hydrate(fencedRecord('worktree-sleep')) + + writeStatus(store, { state: 'done', prompt: 'worker task' }) + + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + }) + + it('survives a live-origin record being rebuilt when the pane state changes', () => { + const store = hydrate(fencedRecord('live')) + + writeStatus(store, { state: 'working', prompt: 'user takes over' }) + + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + }) + + it('survives a manual worktree sleep that recaptures the pane', () => { + const store = hydrate(fencedRecord('live')) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1', [PANE_KEY]) + + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + }) + + // Control: the pre-existing population, where the fence arrived while the tab was still open. + it('survives when only the blocked-pane map holds it', () => { + const store = hydrate( + { ...fencedRecord('worktree-sleep'), automaticResumeBlockedBy: undefined }, + { [PANE_KEY]: true } + ) + + writeStatus(store, { state: 'done', prompt: 'worker task' }) + + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + }) + + // A new provider session in the pane is new work, not the fenced dispatch's work. + it('is not carried onto a record for a different provider session', () => { + const store = hydrate(fencedRecord('live')) + + writeStatus(store, { state: 'working', prompt: 'a fresh session' }, 'session-2') + + expect(fenceOf(store)).toBeUndefined() + }) + + it('is dropped from both homes when the runtime lifts it', () => { + const store = hydrate(fencedRecord('worktree-sleep'), { [PANE_KEY]: true }) + + store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) + writeStatus(store, { state: 'done', prompt: 'worker task' }) + + expect(fenceOf(store)).toBeUndefined() + expect(store.getState().automaticResumeBlockedPaneKeys).toEqual({}) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 32e5378fe11a..1f6578df0a20 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -20,7 +20,7 @@ import { } from './agent-status-pane-key-tab-binding' import { removePaneKeys } from './agent-status-pane-keyed-records' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { copyLaunchConfig } from './agent-status-sleeping-records' +import { carriesAutomaticResumeBlock, copyLaunchConfig } from './agent-status-sleeping-records' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime @@ -113,9 +113,8 @@ export function createAgentStatusProviderSessionActions( ? { connectionId: existingRecord.connectionId } : {}), ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(existingRecordMatchesProviderSession && - existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } + ...(carriesAutomaticResumeBlock(s, { paneKey, agent, providerSession }) + ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } : {}), ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined ? { interrupted: existingRecord.interrupted } diff --git a/src/renderer/src/store/slices/agent-status-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-recovery-equivalence.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts index 660d104abf6f..1fa2baf79d9b 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts @@ -43,6 +43,7 @@ export function sleepingRecordsEquivalentIgnoringCaptureTime( existing.lastAssistantMessage === next.lastAssistantMessage && existing.interrupted === next.interrupted && existing.origin === next.origin && + existing.automaticResumeBlockedBy === next.automaticResumeBlockedBy && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) } @@ -54,8 +55,10 @@ export function recoveryRecordMatches( if (!existing) { return false } - // Why: completion or interruption must replace a pre-status working checkpoint. + // Why: completion or interruption must replace a pre-status working checkpoint. The resume fence + // is significant here: a rebuild that dropped it must never be accepted as an equal record. return ( + existing.automaticResumeBlockedBy === next.automaticResumeBlockedBy && existing.origin === next.origin && existing.agent === next.agent && existing.worktreeId === next.worktreeId && diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 48691b078bcb..727b2eebc82e 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -19,6 +19,27 @@ export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAge } } +/** + * The record is the fence's durable home. `automaticResumeBlockedPaneKeys` only covers the window + * between a worker settling with its tab still open and the record being minted, and main re-seeds + * it on every renderer start — so a rebuild must never drop a flag the record already carries. + * Session identity gates the carry-over: a new provider session is new work, not fenced work. + */ +export function carriesAutomaticResumeBlock( + state: Pick, + next: Pick +): boolean { + if (state.automaticResumeBlockedPaneKeys?.[next.paneKey]) { + return true + } + const previous = state.sleepingAgentSessionsByPaneKey?.[next.paneKey] + return ( + previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && + previous.agent === next.agent && + agentProviderSessionsEqual(next.agent, previous.providerSession, next.providerSession) + ) +} + export function sleepingRecordFromEntry(args: { state: AppState entry: AgentStatusEntry @@ -60,8 +81,11 @@ 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] + ...(carriesAutomaticResumeBlock(args.state, { + paneKey: args.entry.paneKey, + agent, + providerSession: args.entry.providerSession + }) ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } : {}) } @@ -113,21 +137,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/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index 9e6265c938eb..b7cead0187e4 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -63,13 +63,17 @@ 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({ + record: current.sleepingAgentSessionsByPaneKey[opts.paneKey], + automaticResumeBlockedPaneKeys: current.automaticResumeBlockedPaneKeys, + paneKey: opts.paneKey + }) + ) { throw new Error('agent_hibernation_automatic_resume_blocked') } } 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..009e63408a27 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -35,7 +35,10 @@ export function createWebAppApi(): Partial { awaitFirstWindowStartupServices: () => Promise.resolve(), awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), + // No wire method carries the worker resume fence yet, so a paired/web client cannot learn + // about a fenced pane that has no sleeping record. Tracked as a follow-up. + recoverLegacyWorkerTerminalsForRendererStartup: () => + Promise.resolve({ blockedPaneKeys: [] }), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), // The web client cannot inspect local Mission Control shortcuts. diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 3e763fc20a6f..54523ab4c984 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -73,6 +73,10 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } +/** The fenced-pane set main hands a starting renderer. The renderer's own blocked-pane map is + * volatile — it starts empty on every renderer boot — so it must be re-derived here, not pushed. */ +export type LegacyWorkerResumeFenceSnapshot = { blockedPaneKeys: string[] } + const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) const PROVIDER_SESSION_ID_MAX_LENGTH = 512 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..024068ffd983 --- /dev/null +++ b/tests/e2e/settled-worker-resume-fence-restart.spec.ts @@ -0,0 +1,374 @@ +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, + readPersistedWorkerRecoveryRecord, + 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 stamps the resume fence on the renderer's record before the tab closes. + await expect + .poll( + () => + first.page.evaluate( + (paneKey) => + window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + ?.automaticResumeBlockedBy ?? null, + workerPaneKey + ), + { timeout: 30_000, message: 'settled worker pane was never fenced' } + ) + .toBe('legacy-orchestration-worker') + + await session.close(firstApp) + firstApp = null + await expect + .poll(() => readPersistedWorkerRecoveryRecord(session.userDataDir, workerPaneKey), { + message: 'the settled worker record never reached disk carrying its fence' + }) + .toMatchObject({ automaticResumeBlockedBy: 'legacy-orchestration-worker' }) + 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) + + // The record is the fence's durable home. Deriving it from the renderer's volatile blocked-pane + // map alone let the first status write after any restart erase it, and worktree activation then + // relaunched the settled worker with `--resume` over its still-live PTY (#16904 regression). + 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().sleepingAgentSessionsByPaneKey[paneKey] + ?.automaticResumeBlockedBy ?? null, + workerPaneKey + ), + 'a status write after restart must not erase the fence' + ).toBe('legacy-orchestration-worker') + + // A renderer reload starts `automaticResumeBlockedPaneKeys` empty, so main must hand the fenced + // pane set back on the startup handshake; a once-per-process push never survives the reload. + await second.page.reload() + await waitForSessionReady(second.page) + await expect + .poll( + () => + second.page.evaluate( + (paneKey) => + window.__store?.getState().automaticResumeBlockedPaneKeys[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() + } +}) From 1045909bc1cfa6c43807b21aff6c1b41bc74fea2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 17:04:32 -0400 Subject: [PATCH 2/7] fix(orchestration): publish the resume fence only from main's committed state Codex review of #19344 found the startup reply could re-fence a retired pane. Two paths, one defect: the reply was built somewhere other than the object that owns fence publication. The runner read `plan.blockedPanes` captured before it awaited workspace resolution, inventory and persistence, so a release or takeover landing inside that window produced a reply older than its own lift; and a pass whose `setWorkspaceSession` threw published nothing through the push channel yet still returned those keys, fencing a pane no later pass could retire. The persistence object is now the only publisher. It exposes the set it has actually committed, counted by a commit generation, and the runner reads that after the pass finishes rather than deriving anything from the starting plan. A pass that commits nothing reports the previous committed state, which also keeps an unreadable plan from ever reading as "no pane needs its fence". The generation travels with both channels, so the renderer applies main's whole committed set and drops a reply that a newer lift has already overtaken. That makes the seed authoritative instead of additive, which is what finally retires a fence on a pane that has no sleeping record to sweep. Manual sleep and hibernation rollback both committed a record captured before the await, erasing a fence delivered during the stop. Both now resolve the flag from state at commit time through the same predicate as every other rebuild. Also: the degraded-startup routing gate searched for the call the seed helper replaced, so its ordering assertion silently passed on -1; it now follows the helper and asserts the indirection still reaches main. The shared fake-CLI e2e fixture is re-materialized on demand, because the first spec file's teardown deleted it out from under the next file in the same worker. --- .../orca-runtime-preserved-branch-cleanup.ts | 4 +- ...output-and-worker-recovery-part-04.spec.ts | 2 +- ...cy-worker-terminal-recovery-persistence.ts | 25 +- ...-legacy-worker-terminal-recovery-runner.ts | 4 +- ...e-legacy-worker-terminal-recovery-types.ts | 8 +- ...egacy-worker-terminal-resume-fence.test.ts | 233 ++++++++++++++++++ src/main/runtime/runtime-notifier-contract.ts | 2 +- .../legacy-worker-renderer-recovery.ts | 9 +- src/main/window/runtime-window-lifecycle.ts | 4 +- src/preload/api/agent-status-api.ts | 2 +- src/preload/api/agent-status-bridge.ts | 4 +- src/renderer/src/app-startup-routing.test.ts | 7 +- .../ipc-events/agent-status-listeners.ts | 4 +- .../legacy-worker-resume-fence-seed.test.ts | 40 ++- .../legacy-worker-resume-fence-seed.ts | 14 +- .../slices/agent-status-recovery-actions.ts | 106 +++++--- .../agent-status-recovery-equivalence.test.ts | 59 +++++ .../slices/agent-status-sleeping-records.ts | 24 ++ .../slices/agent-status-slice-contract.ts | 13 +- src/renderer/src/store/slices/agent-status.ts | 6 +- ...-sleep-fence-delivered-during-stop.test.ts | 129 ++++++++++ .../terminals/terminal-pane-hibernation.ts | 10 +- .../terminals/terminal-shutdown-state.ts | 5 +- .../src/web/preload-api/web-app-api.ts | 2 +- src/shared/agent-session-resume.ts | 11 +- .../completed-worker-retirement-fixture.ts | 23 +- 26 files changed, 660 insertions(+), 90 deletions(-) create mode 100644 src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts create mode 100644 src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index d53994032a18..b0ce7d966b32 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -137,11 +137,13 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin () => this.store, () => this.getOrchestrationDb(), (worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId), - (paneKey, blocked) => this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked) + (paneKey, blocked, generation) => + this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked, generation) ) protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({ preparePlan: () => this.legacyWorkerRecoveryPersistence.prepare(), + committedFenceSnapshot: () => this.legacyWorkerRecoveryPersistence.committedFenceSnapshot(), resolveWorkspace: async (candidate) => { const scope = await this.resolveTerminalWorkspaceLaunchScope(`id:${candidate.worktreeId}`) const resolved = scope.folderWorkspace diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts index 434d440b54a2..747a2e1357a7 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 @@ -478,7 +478,7 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store) const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({ blockedPaneCount: 1, - blockedPaneKeys: [], + fenceSnapshot: { generation: 0, blockedPaneKeys: [] }, adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: [] 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 544eca9b987c..4c62d2c5f782 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 type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' @@ -20,15 +21,26 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { private readonly getDb: () => OrchestrationDb, private readonly getHostId: (worktreeId: string) => ExecutionHostId | null, /** The store write only reaches the next app start; a live renderer holds its own copy. */ - private readonly notifyFenceChanged?: (paneKey: string, blocked: boolean) => void + private readonly notifyFenceChanged?: ( + paneKey: string, + blocked: boolean, + generation: number + ) => void ) {} - /** The previous pass's blocked set, kept only to emit the lift edge for a pane with no sleeping - * record — `liftRetiredFences` can sweep only panes that already have one. It is not a second - * home for the fence: it is committed after staging succeeds, and every renderer re-derives its - * whole blocked-pane map from `plan.blockedPanes` at startup, so a lost push heals there. */ + /** The blocked set this object has actually committed and published, which is what a startup + * reply must report — the raw plan is not, because a pass whose session write threw published + * nothing. It also supplies the lift edge for a pane with no sleeping record, which + * `liftRetiredFences` cannot sweep. */ private lastPlanBlockedPaneKeys: ReadonlySet = new Set() + /** Counts commits so a startup reply can be ordered against the live pushes. */ + private fenceGeneration = 0 + + committedFenceSnapshot(): LegacyWorkerResumeFenceSnapshot { + return { generation: this.fenceGeneration, blockedPaneKeys: [...this.lastPlanBlockedPaneKeys] } + } + prepare(): LegacyWorkerTerminalRecoveryPlan { const plan = this.getPlan() if (!plan) { @@ -109,8 +121,9 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { return plan } this.lastPlanBlockedPaneKeys = blockedPaneKeys + this.fenceGeneration += 1 for (const [paneKey, blocked] of fenceChanges) { - this.notifyFenceChanged?.(paneKey, blocked) + this.notifyFenceChanged?.(paneKey, blocked, this.fenceGeneration) } return plan } diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index 5f7751f79806..aeb5574d060d 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -105,7 +105,9 @@ export async function runLegacyWorkerTerminalRecovery( } const result = { blockedPaneCount: plan.blockedPanes.length, - blockedPaneKeys: plan.blockedPanes.map((blocked) => blocked.paneKey), + // Why re-read: `plan` predates the awaits above, and a release/retain/takeover sweep can retire + // a fence inside that window. Reporting the stale plan would re-fence a retired pane. + fenceSnapshot: ports.committedFenceSnapshot(), adoptedDispatchIds, exitedDispatchIds, deferredDispatchIds: [...deferredDispatchIds] diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts index 9a5b88e14d02..422e659bfd5e 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts @@ -1,3 +1,4 @@ +import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' @@ -6,8 +7,9 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' export type LegacyWorkerTerminalRecoveryResult = { blockedPaneCount: number - /** The authoritative fenced-pane set for this pass; a renderer seeds its volatile map from it. */ - blockedPaneKeys: string[] + /** Main's committed fence state as of the END of this pass, so a renderer seeding from it cannot + * reapply a fence a release/takeover retired while the pass was awaiting its terminal work. */ + fenceSnapshot: LegacyWorkerResumeFenceSnapshot adoptedDispatchIds: string[] exitedDispatchIds: string[] deferredDispatchIds: string[] @@ -42,6 +44,8 @@ export type LegacyWorkerRecoveryInventory = PtyControllerInventory export type LegacyWorkerRecoveryPorts = { preparePlan: () => LegacyWorkerTerminalRecoveryPlan + /** Read after the pass finishes; never derived from the plan the pass started with. */ + committedFenceSnapshot: () => LegacyWorkerResumeFenceSnapshot resolveWorkspace: ( candidate: LegacyWorkerRecoveryCandidate ) => Promise diff --git a/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts b/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts index c918d3ed6af7..b364cd4a9c85 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts @@ -6,6 +6,8 @@ import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' +import { runLegacyWorkerTerminalRecovery } from './runtime-legacy-worker-terminal-recovery-runner' +import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' import type { RuntimeStore } from './runtime-store-contract' const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333' @@ -228,6 +230,237 @@ describe('settled worker automatic-resume fence persistence', () => { ) }) + // A pass reads its plan up front and then awaits workspace resolution, inventory and persistence. + // A release/takeover sweep can retire a fence inside that window, so reporting the plan the pass + // started with would hand a starting renderer a fence the authority had already retired. + it('reports the fence state committed at the end of the pass, not the plan it started with', async () => { + const fenceChanges: [string, boolean][] = [] + const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false) + settle(h.db, h.taskId, h.dispatchId) + let releasePersist!: () => void + const ports = { + preparePlan: () => h.persistence.prepare(), + committedFenceSnapshot: () => h.persistence.committedFenceSnapshot(), + persist: () => + new Promise>((resolve) => { + releasePersist = () => resolve(new Set()) + }), + updateRetry: () => {}, + reconcileRequestedReleases: async () => {} + } as unknown as LegacyWorkerRecoveryPorts + const pass = runLegacyWorkerTerminalRecovery({} as never, ports, {}) + expect(fenceChanges).toEqual([[PANE_KEY, true]]) + + // The release lands while the pass is still awaiting its terminal work. + const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) + h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) + h.persistence.prepare() + releasePersist() + + const result = await pass + expect(result.fenceSnapshot.blockedPaneKeys).toEqual([]) + expect(fenceChanges).toEqual([ + [PANE_KEY, true], + [PANE_KEY, false] + ]) + }) + + // A pass whose session write threw published nothing, so it must report the previous committed + // state. Reporting the uncommitted plan would fence a pane through the reply that the push + // channel never announced — and that no later lift could retire. + it('reports nothing new when the session write failed', () => { + let failWrite = true + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = sessionWithSleepingWorker() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + if (failWrite) { + throw new Error('workspace_session_write_failed') + } + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const task = orchestrationDb.createTask({ spec: 'fence me' }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + settle(orchestrationDb, task.id, started.dispatch.id) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => orchestrationDb, + () => LOCAL_EXECUTION_HOST_ID + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + persistence.prepare() + } finally { + warn.mockRestore() + } + expect(persistence.committedFenceSnapshot()).toEqual({ generation: 0, blockedPaneKeys: [] }) + + failWrite = false + persistence.prepare() + + expect(persistence.committedFenceSnapshot()).toEqual({ + generation: 1, + blockedPaneKeys: [PANE_KEY] + }) + }) + + // The lift edge for a pane with no record lives in the committed set, so a failed write must not + // consume it: the pane must still be retired once the plan drops it after a successful pass. + it('still lifts a record-less fence retired after a failed then successful write', () => { + const fenceChanges: [string, boolean][] = [] + let failWrite = true + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = sessionWithSleepingWorker() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + if (failWrite) { + throw new Error('workspace_session_write_failed') + } + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const task = orchestrationDb.createTask({ spec: 'fence me' }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + settle(orchestrationDb, task.id, started.dispatch.id) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => orchestrationDb, + () => LOCAL_EXECUTION_HOST_ID, + (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + persistence.prepare() + } finally { + warn.mockRestore() + } + expect(fenceChanges).toEqual([]) + + failWrite = false + persistence.prepare() + expect(fenceChanges).toEqual([[PANE_KEY, true]]) + + const requested = orchestrationDb.requestWorkerTerminalRelease(started.dispatch.id) + orchestrationDb.settleWorkerTerminalRelease( + (requested as { resource: { id: string } }).resource.id + ) + persistence.prepare() + + expect(fenceChanges).toEqual([ + [PANE_KEY, true], + [PANE_KEY, false] + ]) + expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) + }) + + // The damaging order: the fence is retired while the write is still failing. A pass that booked + // its blocked set before staging would carry that uncommitted fence into the retry and publish a + // lift for a pane no renderer was ever told about — or, through the reply, the fence itself. + it('publishes nothing for a pane retired while the session write was failing', () => { + const fenceChanges: [string, boolean][] = [] + let failWrite = true + const orchestrationDb = new OrchestrationDb(':memory:') + db = orchestrationDb + let session = sessionWithSleepingWorker() + const store = { + getWorkspaceSession: () => session, + setWorkspaceSession: (next: WorkspaceSessionState) => { + if (failWrite) { + throw new Error('workspace_session_write_failed') + } + session = next + }, + getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], + flushOrThrow: vi.fn() + } as unknown as RuntimeStore + const task = orchestrationDb.createTask({ spec: 'fence me' }) + const started = orchestrationDb.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + orchestrationDb.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: PANE_KEY, + processIncarnation: 'runtime:pty:1', + worktreeId: WORKTREE_ID, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + orchestrationDb.markWorkerDispatchReady(started.dispatch.id) + settle(orchestrationDb, task.id, started.dispatch.id) + const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( + () => store, + () => orchestrationDb, + () => LOCAL_EXECUTION_HOST_ID, + (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) + ) + + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + persistence.prepare() + } finally { + warn.mockRestore() + } + expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) + + const requested = orchestrationDb.requestWorkerTerminalRelease(started.dispatch.id) + orchestrationDb.settleWorkerTerminalRelease( + (requested as { resource: { id: string } }).resource.id + ) + failWrite = false + persistence.prepare() + + expect(fenceChanges).toEqual([]) + expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) + }) + // The STA-4577 repro: worker_done, no release, restart, open the worktree — the pane still // holds a resumable provider session and must not respawn `codex resume`. it('fences a settled worker pane whose terminal was never released', () => { diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index aa2982082b42..852ee509c279 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -80,7 +80,7 @@ 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 + setLegacyWorkerTerminalResumeFence?(paneKey: string, blocked: boolean, generation: number): void splitTerminal( tabId: string, paneRuntimeId: number, diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts index 484fdfa38d4f..c54b94b203ae 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -1,6 +1,9 @@ import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { LegacyWorkerTerminalRecoveryResult } from '../runtime/runtime-legacy-worker-terminal-recovery-types' +/** Generation 0 never orders ahead of a real commit, so a renderer drops this over any live push. */ +const EMPTY_FENCE_SNAPSHOT: LegacyWorkerResumeFenceSnapshot = { generation: 0, blockedPaneKeys: [] } + type LegacyWorkerRendererRecoveryOptions = { firstWindowStartupServicesReady: Promise managedWslCliStartupBarrierReady: Promise @@ -23,12 +26,12 @@ export async function recoverLegacyWorkerTerminalsForRendererStartup( ]) if (!providerResult.ok) { options.onDeferredRecoveryError(providerResult.error) - return { blockedPaneKeys: [] } + return EMPTY_FENCE_SNAPSHOT } try { - return { blockedPaneKeys: (await options.reconcile())?.blockedPaneKeys ?? [] } + return (await options.reconcile())?.fenceSnapshot ?? EMPTY_FENCE_SNAPSHOT } catch (error) { options.onDeferredRecoveryError(error) - return { blockedPaneKeys: [] } + return EMPTY_FENCE_SNAPSHOT } } diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 2a6ab95a07f1..9e220bac8858 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 }), + setLegacyWorkerTerminalResumeFence: (paneKey, blocked, generation) => + send('agentStatus:legacyWorkerTerminalResumeFence', { paneKey, blocked, generation }), 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..ce5cd6a7870d 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -30,7 +30,7 @@ export type AgentStatusApi = { ) => () => 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 + callback: (data: { paneKey: string; blocked: boolean; generation?: number }) => void ) => () => void getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 3c3415cd2077..5207c332afc8 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -62,11 +62,11 @@ export const agentStatusApi = { return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) }, onLegacyWorkerTerminalResumeFence: ( - callback: (data: { paneKey: string; blocked: boolean }) => void + callback: (data: { paneKey: string; blocked: boolean; generation?: number }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { paneKey: string; blocked: boolean } + data: { paneKey: string; blocked: boolean; generation?: number } ) => callback(data) ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFence', listener) return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFence', listener) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index fead2f6c7bbd..b1832888dbd3 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -224,7 +224,7 @@ describe('renderer startup runtime routing', () => { degradedStart ) const recoveryIndex = source.indexOf( - 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', + 'recoverLegacyWorkerTerminalsAndSeedResumeFences()', servicesIndex ) const capabilityRefreshIndex = source.indexOf( @@ -238,6 +238,11 @@ describe('renderer startup runtime routing', () => { expect(recoveryIndex).toBeGreaterThan(servicesIndex) expect(capabilityRefreshIndex).toBeGreaterThan(recoveryIndex) expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) + // The helper is what still reaches main, so the ordering above only means something while it + // owns the pull. Assert the indirection instead of letting the rename hollow the gate out. + expect(readSource('src/renderer/src/startup/legacy-worker-resume-fence-seed.ts')).toContain( + 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()' + ) }) it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { diff --git a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts index 2426dcb932d9..4141d34b07e0 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -127,8 +127,8 @@ export function registerAgentStatusListeners(args: { unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) } const unsubscribeResumeFence = window.api.agentStatus.onLegacyWorkerTerminalResumeFence?.( - ({ paneKey, blocked }) => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked) + ({ paneKey, blocked, generation }) => { + useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked, generation) } ) if (unsubscribeResumeFence) { diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts index 226b076df34e..d982c4e7bf38 100644 --- a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts +++ b/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts @@ -15,25 +15,49 @@ function stubRecovery(result: unknown): ReturnType { afterEach(() => { vi.unstubAllGlobals() - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) + useAppStore.setState({ automaticResumeBlockedPaneKeys: {}, automaticResumeFenceGeneration: 0 }) }) // The renderer's blocked-pane map starts empty on every boot, reload included, and is never -// persisted. Main answers the startup handshake with the fenced-pane set so the fence survives. +// persisted. Main answers the startup handshake with its committed fenced-pane set, versioned by +// its commit generation so this reply cannot override a lift that overtook it. describe('seeding the resume fence from the renderer-startup handshake', () => { it('blocks every pane main reports as fenced', async () => { - stubRecovery({ blockedPaneKeys: [PANE_KEY] }) + stubRecovery({ generation: 3, blockedPaneKeys: [PANE_KEY] }) await recoverLegacyWorkerTerminalsAndSeedResumeFences() expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) }) - // An unreadable recovery plan also yields an empty set, and loss of contact with the plan is - // never evidence that a pane stopped needing its fence — so the seed only ever adds. - it('leaves an already-blocked pane alone when main reports nothing', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - stubRecovery({ blockedPaneKeys: [] }) + // The snapshot is main's committed state, not the plan a pass started with, so a pane it no + // longer claims is retired here. This is the only channel that can lift a fence for a pane with + // no sleeping record after a reload, since `liftRetiredFences` can sweep only records. + it('retires a pane main no longer reports as fenced', async () => { + useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true, 4) + stubRecovery({ generation: 5, blockedPaneKeys: [] }) + + await recoverLegacyWorkerTerminalsAndSeedResumeFences() + + expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) + }) + + // A release or takeover can retire the fence after main read the reply but before it lands. The + // newer lift carries a higher commit generation, so the older reply must not walk it back. + it('drops a reply older than a lift that already arrived', async () => { + useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false, 9) + stubRecovery({ generation: 8, blockedPaneKeys: [PANE_KEY] }) + + await recoverLegacyWorkerTerminalsAndSeedResumeFences() + + expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) + }) + + // A pass whose session write threw commits nothing, so it reports the previous committed state + // at the previous generation rather than publishing keys the push channel never announced. + it('applies a reply at the generation already applied', async () => { + useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false, 6) + stubRecovery({ generation: 6, blockedPaneKeys: [PANE_KEY] }) await recoverLegacyWorkerTerminalsAndSeedResumeFences() diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts index e8ff43fb5284..2f36e89bc5f1 100644 --- a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts +++ b/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts @@ -2,17 +2,13 @@ import { useAppStore } from '@/store' /** * Main owns the settled-worker resume fence; the renderer's `automaticResumeBlockedPaneKeys` map is - * volatile and starts empty on every renderer boot, reload included. Pulling the fenced-pane set on - * the startup handshake is what makes a fence survive a reload — a once-per-process push cannot. - * - * Additive on purpose: an unreadable recovery plan also yields an empty set, and loss of contact - * with the plan is never evidence that a pane stopped needing its fence. Retirement arrives through - * the live lift push and through main sweeping the durable record. + * volatile and starts empty on every renderer boot, reload included. Pulling main's committed fence + * state on the startup handshake is what makes a fence survive a reload — a once-per-process push + * cannot. The reply carries main's commit generation, so a live lift that raced past it wins. */ export async function recoverLegacyWorkerTerminalsAndSeedResumeFences(): Promise { const snapshot = await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - const setBlocked = useAppStore.getState().setSleepingAgentAutomaticResumeBlocked - for (const paneKey of snapshot?.blockedPaneKeys ?? []) { - setBlocked(paneKey, true) + if (snapshot) { + useAppStore.getState().applyLegacyWorkerResumeFenceSnapshot(snapshot) } } 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..efe469d5b586 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -1,4 +1,5 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { AppState } from '../types' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection' @@ -14,6 +15,59 @@ import { getLaunchConfigForEntry } from './agent-status-launch-config' import { findAgentPaneWorktreeId } from './agent-status-pane-key-tab-binding' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' +/** The single writer for the resume fence's two homes. The pane-key map is tracked even with no + * record, because a worker settled while its tab was open is fenced before the record is minted; + * the record is the durable home and must move with it. `generation` is main's commit counter, so + * a startup reply that lost a race with a later lift cannot walk the newer state backwards. */ +function applyFenceToPanes( + state: AppState, + changes: ReadonlyMap, + generation: number | undefined +): Partial | AppState { + let paneKeys = state.automaticResumeBlockedPaneKeys + let records = state.sleepingAgentSessionsByPaneKey + for (const [paneKey, blocked] of changes) { + if ((paneKeys[paneKey] === true) !== blocked) { + if (paneKeys === state.automaticResumeBlockedPaneKeys) { + paneKeys = { ...paneKeys } + } + if (blocked) { + paneKeys[paneKey] = true + } else { + delete paneKeys[paneKey] + } + } + const current = records[paneKey] + const recordBlocked = current?.automaticResumeBlockedBy === 'legacy-orchestration-worker' + if (!current || recordBlocked === blocked) { + continue + } + const next = { ...current } + if (blocked) { + next.automaticResumeBlockedBy = 'legacy-orchestration-worker' + } else { + delete next.automaticResumeBlockedBy + } + if (records === state.sleepingAgentSessionsByPaneKey) { + records = { ...records } + } + records[paneKey] = next + } + const nextGeneration = Math.max(state.automaticResumeFenceGeneration, generation ?? 0) + if ( + paneKeys === state.automaticResumeBlockedPaneKeys && + records === state.sleepingAgentSessionsByPaneKey && + nextGeneration === state.automaticResumeFenceGeneration + ) { + return state + } + return { + automaticResumeBlockedPaneKeys: paneKeys, + sleepingAgentSessionsByPaneKey: records, + automaticResumeFenceGeneration: nextGeneration + } +} + export function createAgentStatusRecoveryActions( runtime: AgentStatusRuntime ): Pick< @@ -23,6 +77,7 @@ export function createAgentStatusRecoveryActions( | 'clearSleepingAgentSession' | 'clearSleepingAgentSessionsByPaneKey' | 'setSleepingAgentAutomaticResumeBlocked' + | 'applyLegacyWorkerResumeFenceSnapshot' | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { @@ -109,44 +164,29 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { + setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked, generation) => { + set((s) => applyFenceToPanes(s, new Map([[paneKey, blocked]]), generation)) + }, + + // Why replace rather than merge: main hands over its whole committed fence state, so a pane it + // no longer claims is retired. An unreadable plan does not reach this — a pass that commits + // nothing leaves the previous committed state and generation in place. + applyLegacyWorkerResumeFenceSnapshot: (snapshot) => { set((s) => { - // 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 } + if (snapshot.generation < s.automaticResumeFenceGeneration) { + return s } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy + const blockedPaneKeys = new Set(snapshot.blockedPaneKeys) + const changes = new Map() + for (const paneKey of blockedPaneKeys) { + changes.set(paneKey, true) } - return { - automaticResumeBlockedPaneKeys: paneKeys, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: next + for (const paneKey of Object.keys(s.automaticResumeBlockedPaneKeys)) { + if (!blockedPaneKeys.has(paneKey)) { + changes.set(paneKey, false) } } + return applyFenceToPanes(s, changes, snapshot.generation) }) }, diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts new file mode 100644 index 000000000000..986a88579b7e --- /dev/null +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import { + recoveryRecordMatches, + sleepingRecordsEquivalentIgnoringCaptureTime +} from './agent-status-recovery-equivalence' + +const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' + +function record(overrides: Partial = {}): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'done', + capturedAt: 1_000, + updatedAt: 2_000, + origin: 'live', + ...overrides + } +} + +const fenced = record({ automaticResumeBlockedBy: 'legacy-orchestration-worker' }) + +// Both predicates are equality shortcuts that suppress a write. Ignoring the fence let a rebuild +// that had dropped it count as equal, so the fenced record was quietly kept out of the update path +// that would have restored the flag — and out of the write that persists it. +describe('resume-fence significance in sleeping-record equality', () => { + it('rejects a rebuild that dropped the fence as a recovery match', () => { + expect(recoveryRecordMatches(fenced, record())).toBe(false) + }) + + it('rejects a rebuild that added the fence as a recovery match', () => { + expect(recoveryRecordMatches(record(), fenced)).toBe(false) + }) + + it('rejects a capture that dropped the fence as capture-time equivalent', () => { + expect(sleepingRecordsEquivalentIgnoringCaptureTime(fenced, record())).toBe(false) + }) + + it('rejects a capture that added the fence as capture-time equivalent', () => { + expect(sleepingRecordsEquivalentIgnoringCaptureTime(record(), fenced)).toBe(false) + }) + + // Controls: a stable fence must stay equal, or every pass would rewrite an unchanged record. + it('keeps two fenced records equal', () => { + expect(recoveryRecordMatches(fenced, { ...fenced, capturedAt: 9_999 })).toBe(true) + expect( + sleepingRecordsEquivalentIgnoringCaptureTime(fenced, { ...fenced, capturedAt: 9_999 }) + ).toBe(true) + }) + + it('keeps two unfenced records equal', () => { + expect(recoveryRecordMatches(record(), record({ capturedAt: 9_999 }))).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 727b2eebc82e..8522e2bb5bea 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -40,6 +40,30 @@ export function carriesAutomaticResumeBlock( ) } +/** Async stops (manual sleep, hibernation rollback) commit a capture taken before the await, so a + * fence delivered or retired during the stop has to be re-read from state at commit time. */ +export function withCurrentAutomaticResumeBlock( + state: Pick, + records: Readonly> +): Record { + const next: Record = {} + for (const [paneKey, record] of Object.entries(records)) { + const blocked = carriesAutomaticResumeBlock(state, record) + if (blocked === (record.automaticResumeBlockedBy === 'legacy-orchestration-worker')) { + next[paneKey] = record + continue + } + const updated = { ...record } + if (blocked) { + updated.automaticResumeBlockedBy = 'legacy-orchestration-worker' + } else { + delete updated.automaticResumeBlockedBy + } + next[paneKey] = updated + } + return next +} + export function sleepingRecordFromEntry(args: { state: AppState entry: AgentStatusEntry 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..1e4d11c4d7a3 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -25,6 +25,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { + LegacyWorkerResumeFenceSnapshot, ResumableTuiAgent, SleepingAgentLaunchConfig, SleepingAgentSessionRecord @@ -55,6 +56,10 @@ export type AgentStatusSlice = { * settle while its tab is open, before the sleeping record the fence belongs on exists. */ automaticResumeBlockedPaneKeys: Record + /** Highest main fence-commit generation this renderer has applied, so a startup reply that lost + * a race with a later lift is dropped instead of reapplying a retired fence. */ + automaticResumeFenceGeneration: number + /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ agentLaunchConfigByPaneKey: Record @@ -162,7 +167,13 @@ export type AgentStatusSlice = { captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void + setSleepingAgentAutomaticResumeBlocked: ( + paneKey: string, + blocked: boolean, + generation?: number + ) => void + /** Replace the blocked-pane set with main's committed fence state from the startup handshake. */ + applyLegacyWorkerResumeFenceSnapshot: (snapshot: LegacyWorkerResumeFenceSnapshot) => void 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..10abccc5d618 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -51,7 +51,10 @@ export { collectSleepingAgentSessionRecordsForWorktree, collectHibernatedCompletionEvidenceForWorktree } from './agent-status-recovery-collection' -export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-sleeping-records' +export { + removeSleepingRecordsReplacedByManualWorktreeSleep, + withCurrentAutomaticResumeBlock +} from './agent-status-sleeping-records' export { buildAgentStatusTabPrefixDropPatch, type AgentStatusTabPrefixDropState @@ -101,6 +104,7 @@ export const createAgentStatusSlice: StateCreator vi.fn<() => unknown[]>(() => [])) +const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown, + unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const mockApi = createStoreCascadesMockApi() + +const WORKTREE_ID = 'repo1::/path/wt1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF_ID}` + +function storeWithAgentPane(state: 'working' | 'done') { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE_ID, ptyId: 'pty-agent' })] + }, + ptyIdsByTabId: { 'tab-1': ['pty-agent'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-agent' } + } + } + }) + store + .getState() + .setAgentStatus( + PANE_KEY, + { state, prompt: 'worker', agentType: 'claude' }, + 'Claude', + { updatedAt: 2000, stateStartedAt: 1000 }, + { tabId: 'tab-1', worktreeId: WORKTREE_ID }, + { providerSession: { key: 'session_id', id: 'session-1' } } + ) + return store +} + +const fenceOf = (store: ReturnType): string | undefined => + store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + +// Both stop paths capture the record before awaiting the kill and commit that capture afterwards. +// A fence delivered while the kill is in flight lands on the live record, and committing the older +// capture verbatim erased it — leaving a settled worker automatically resumable again. +describe('a resume fence delivered while a pane stop is in flight', () => { + beforeEach(() => { + vi.clearAllMocks() + clearRuntimeCompatibilityCacheForTests() + mockApi.pty.kill.mockResolvedValue(undefined) + applySleepRuntimeRpcDefault(mockApi) + shutdownBufferCaptures.clear() + }) + + it('survives the manual-sleep capture committed after the stop', async () => { + const store = storeWithAgentPane('working') + mockApi.pty.kill.mockImplementationOnce(async () => { + store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) + }) + + await store.getState().shutdownWorktreeTerminals(WORKTREE_ID, { keepIdentifiers: true }) + + expect(mockApi.pty.kill).toHaveBeenCalled() + expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + }) + + it('survives the rollback of a hibernation stop that threw', async () => { + const store = storeWithAgentPane('done') + mockApi.pty.kill.mockImplementationOnce(async () => { + store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) + throw new Error('kill_failed') + }) + + await expect( + store.getState().shutdownCompletedAgentPaneForHibernation(WORKTREE_ID, { + paneKey: PANE_KEY, + tabId: 'tab-1', + leafId: LEAF_ID, + ptyId: 'pty-agent' + }) + ).rejects.toThrow('kill_failed') + + expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + }) + + // Control: the commit re-reads state, so a lift that lands during the stop is honoured too — the + // writer follows current authority rather than pinning whatever the capture happened to hold. + it('drops a fence the runtime retires during the stop', async () => { + const store = storeWithAgentPane('working') + store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) + expect(fenceOf(store)).toBe('legacy-orchestration-worker') + mockApi.pty.kill.mockImplementationOnce(async () => { + store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) + }) + + await store.getState().shutdownWorktreeTerminals(WORKTREE_ID, { keepIdentifiers: true }) + + expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBeUndefined() + expect(fenceOf(store)).toBeUndefined() + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index b7cead0187e4..7606aa1aca5b 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -9,7 +9,8 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { collectHibernatedCompletionEvidenceForWorktree, - collectSleepingAgentSessionRecordsForWorktree + collectSleepingAgentSessionRecordsForWorktree, + withCurrentAutomaticResumeBlock } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' @@ -105,8 +106,11 @@ export function createTerminalPaneHibernationActions( delete next[ptyId] } const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } + // Why re-read: a fence can arrive or retire while the kill is in flight, and restoring + // the pre-kill record verbatim would roll that back with it. + const restored = withCurrentAutomaticResumeBlock(s, replacedSleepingRecords) for (const key of sleepingRecordKeys) { - const replaced = replacedSleepingRecords[key] + const replaced = restored[key] if (replaced) { nextSleeping[key] = replaced } else { @@ -123,7 +127,7 @@ export function createTerminalPaneHibernationActions( }, sleepingAgentSessionsByPaneKey: { ...s.sleepingAgentSessionsByPaneKey, - ...sleepingAgentSessionRecords + ...withCurrentAutomaticResumeBlock(s, sleepingAgentSessionRecords) } })) if (expectedRuntimePtyIds.length > 0) { diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts index 389c8166a18e..098c8ddb962f 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-state.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -8,6 +8,7 @@ import { } from '@/components/terminal-pane/pty-shutdown-exit-deferral' import { removeSleepingRecordsReplacedByManualWorktreeSleep, + withCurrentAutomaticResumeBlock, type AgentStatusWorktreeShutdownReason, type RetainedAgentEntry } from '../slices/agent-status' @@ -159,7 +160,9 @@ export function commitTerminalShutdownState({ return { sleepingAgentSessionsByPaneKey: { ...base, - ...sleepingAgentSessionRecords + // Why re-read: the capture above predates the stop, and a fence can arrive or retire + // while the kill is in flight; committing the stale capture would erase it. + ...withCurrentAutomaticResumeBlock(state, sleepingAgentSessionRecords) } } }) 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 009e63408a27..f6db82659e80 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -38,7 +38,7 @@ export function createWebAppApi(): Partial { // No wire method carries the worker resume fence yet, so a paired/web client cannot learn // about a fenced pane that has no sleeping record. Tracked as a follow-up. recoverLegacyWorkerTerminalsForRendererStartup: () => - Promise.resolve({ blockedPaneKeys: [] }), + Promise.resolve({ generation: 0, blockedPaneKeys: [] }), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), // The web client cannot inspect local Mission Control shortcuts. diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 54523ab4c984..2d6792432e40 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -73,9 +73,14 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } -/** The fenced-pane set main hands a starting renderer. The renderer's own blocked-pane map is - * volatile — it starts empty on every renderer boot — so it must be re-derived here, not pushed. */ -export type LegacyWorkerResumeFenceSnapshot = { blockedPaneKeys: string[] } +/** The committed fenced-pane set main hands a starting renderer. The renderer's own blocked-pane + * map is volatile — it starts empty on every renderer boot — so it must be re-derived here, not + * pushed. `generation` counts main's fence commits: it orders this reply against the live pushes + * so a reply that lost a race with a later lift is dropped instead of reapplying a retired fence. */ +export type LegacyWorkerResumeFenceSnapshot = { + generation: number + blockedPaneKeys: string[] +} const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) const PROVIDER_SESSION_ID_MAX_LENGTH = 512 diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index 43de5782648e..7ad18c6b2463 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 }) } From 4203db2457144b372dd44c797f0939bab2bf8cd0 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 18:03:10 -0400 Subject: [PATCH 3/7] refactor(orchestration): give the settled-worker fence one owner and one home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fence was main-owned state stored inside a renderer-owned field. `sleepingAgentSessionsByPaneKey` is co-authored: the renderer emits the whole map and main applies it as a wholesale replace, so main's stamp survived only until the renderer's next write. That inversion is what produced the original P0 — the fence was erased on disk, not just in memory — and every guard added since was compensation for it: a carry predicate at each record builder, two equality hunks behind that predicate, a volatile pane-key map with a seeded startup reply, and main-side bookkeeping to synthesise lift edges for panes with no record. The fence now lives in `legacyWorkerResumeFencesByPaneKey`, a runtime-authored session field classified like `clientHostedBrowserPagesByWorktree`. It is absent from the renderer's patch builder, so the renderer cannot write it, and the persistence layer carries it across any write that never mentions it. Main rewrites the whole set every recovery pass, record or no record. Level-triggered: no announcement to consume once, no edge to miss, no generation to compare. The renderer reads it through ordinary session hydration and re-reads it when main pings that it changed; the ping carries no state, and reads are coalesced so the last read is the last write. Hibernation eligibility, the resume decision, the reattach choice and the park exemption all consult that one field. `automaticResumeBlockedBy` stays on the record as an outbound projection for clients too old to read the new field, stamped in `buildSleepingAgentSessionData` alone — the single function both persistence paths build records through. Deleted: the pane-key map and its writer, the startup seed helper and the reply shape it needed, `carriesAutomaticResumeBlock` and its call in every record writer, both equivalence hunks, `lastPlanBlockedPaneKeys`, the fence generation, and the record-sweeping half of `liftRetiredFences`. All five Codex findings and all five Opus findings are now unreachable rather than handled. --- ...ofile-project-session-field-disposition.ts | 7 + .../session-worktree-ownership.ts | 1 + ...-authored-workspace-session-fields.test.ts | 44 ++ ...ntime-authored-workspace-session-fields.ts | 28 +- .../orca-runtime-fence-automation-owner.ts | 13 + .../orca-runtime-preserved-branch-cleanup.ts | 4 +- ...output-and-worker-recovery-part-02.spec.ts | 4 +- ...output-and-worker-recovery-part-03.spec.ts | 4 +- ...output-and-worker-recovery-part-04.spec.ts | 9 +- ...output-and-worker-recovery-part-05.spec.ts | 15 +- ...output-and-worker-recovery-part-06.spec.ts | 8 +- ...cy-worker-terminal-recovery-persistence.ts | 186 +++----- ...-legacy-worker-terminal-recovery-runner.ts | 3 - ...e-legacy-worker-terminal-recovery-types.ts | 6 - ...egacy-worker-terminal-resume-fence.test.ts | 414 ++++-------------- src/main/runtime/runtime-notifier-contract.ts | 3 +- .../legacy-worker-renderer-recovery.ts | 15 +- .../startup/main-process-ipc-bootstrap.ts | 6 + src/main/window/runtime-window-lifecycle.ts | 4 +- src/preload/api/agent-status-api.ts | 6 +- src/preload/api/agent-status-bridge.ts | 14 +- src/preload/api/app-api.ts | 8 +- src/preload/api/app-bridge.ts | 5 +- .../app-shell/use-app-startup-hydration.ts | 5 +- src/renderer/src/app-startup-routing.test.ts | 7 +- ...ty-connection-agent-session-resume.test.ts | 18 +- .../pty-connection-test-store-fixtures.ts | 1 + .../pty-connection/sleeping-record-access.ts | 8 +- .../sleeping-record-park-exemption.test.ts | 26 +- .../sleeping-record-park-exemption.ts | 8 +- ...k-subscription-narrowing.react185.test.tsx | 10 +- .../use-terminal-tab-cold-parking.test.ts | 7 +- .../use-terminal-tab-cold-parking.ts | 6 +- .../ipc-events/agent-status-listeners.ts | 7 +- .../src/hooks/useIpcEvents-lifecycle.test.ts | 4 +- .../src/lib/agent-hibernation-coordinator.ts | 2 +- ...tion-pane-eligibility-resume-fence.test.ts | 44 +- .../lib/agent-hibernation-pane-eligibility.ts | 7 +- .../lib/agent-hibernation-planner-snapshot.ts | 4 +- .../src/lib/agent-hibernation-planner.test.ts | 9 +- .../src/lib/agent-hibernation-planner.ts | 2 +- ...legacy-worker-resume-fence-refresh.test.ts | 74 ++++ .../lib/legacy-worker-resume-fence-refresh.ts | 35 ++ .../src/lib/live-resume-anchor-record.ts | 17 +- ...eeping-agent-session-legacy-worker.test.ts | 4 +- .../src/lib/resume-sleeping-agent-session.ts | 2 +- .../src/lib/workspace-session-patch.ts | 7 +- .../workspace-session-relevant-fields.test.ts | 1 + .../lib/workspace-session-sleeping-agents.ts | 32 +- src/renderer/src/lib/workspace-session.ts | 3 + .../legacy-worker-resume-fence-seed.test.ts | 75 ---- .../legacy-worker-resume-fence-seed.ts | 14 - .../src/startup/startup-degraded-recovery.ts | 5 +- ...agent-status-hydrated-resume-fence.test.ts | 124 ------ .../agent-status-manual-sleep-capture.test.ts | 27 +- ...agent-status-open-tab-resume-fence.test.ts | 106 +++-- .../agent-status-provider-session-actions.ts | 5 +- .../agent-status-provider-session.test.ts | 65 +-- .../slices/agent-status-recovery-actions.ts | 88 +--- .../agent-status-recovery-equivalence.test.ts | 59 --- .../agent-status-recovery-equivalence.ts | 5 +- .../slices/agent-status-sleeping-records.ts | 55 +-- .../slices/agent-status-slice-contract.ts | 20 +- src/renderer/src/store/slices/agent-status.ts | 8 +- .../slices/legacy-worker-resume-fences.ts | 9 + ...-sleep-fence-delivered-during-stop.test.ts | 129 ------ .../terminals/terminal-pane-hibernation.ts | 13 +- .../terminals/terminal-shutdown-state.ts | 5 +- .../workspace-terminal-hydration-patch.ts | 7 + .../terminals/workspace-terminal-hydration.ts | 3 + .../web/preload-api/web-agent-status-api.ts | 2 +- .../src/web/preload-api/web-app-api.ts | 7 +- src/shared/agent-session-resume.ts | 9 - .../workspace-session-host-field-ownership.ts | 3 + ...pace-session-schema-field-coverage.test.ts | 1 + src/shared/workspace-session-schema.ts | 4 + src/shared/workspace-session-state-types.ts | 7 + .../completed-worker-retirement-fixture.ts | 20 + ...ettled-worker-resume-fence-restart.spec.ts | 34 +- 79 files changed, 733 insertions(+), 1333 deletions(-) create mode 100644 src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts create mode 100644 src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts delete mode 100644 src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts delete mode 100644 src/renderer/src/startup/legacy-worker-resume-fence-seed.ts delete mode 100644 src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts delete mode 100644 src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts create mode 100644 src/renderer/src/store/slices/legacy-worker-resume-fences.ts delete mode 100644 src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts diff --git a/src/main/orca-profiles/profile-project-session-field-disposition.ts b/src/main/orca-profiles/profile-project-session-field-disposition.ts index 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/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..6142ef986404 100644 --- a/src/main/runtime/orca-runtime-fence-automation-owner.ts +++ b/src/main/runtime/orca-runtime-fence-automation-owner.ts @@ -20,6 +20,7 @@ import { join } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { makePaneKey } from '../../shared/stable-pane-id' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' @@ -171,6 +172,18 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg return this.legacyWorkerRecovery.prepare() } + /** The fenced-pane set across every host, unioned: pane keys are tab-scoped UUIDs, so they + * cannot collide between hosts, and the renderer asks about a pane without knowing its host. */ + getLegacyWorkerResumeFences(): Record { + const store = this.store + const hostIds = store?.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + const fences: Record = {} + for (const hostId of hostIds) { + Object.assign(fences, store?.getWorkspaceSession?.(hostId)?.legacyWorkerResumeFencesByPaneKey) + } + return fences + } + protected async flushWorkspaceSessionOrThrowAsync(): Promise { const store = this.store if (store?.flushPendingOrThrowAsync) { diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index b0ce7d966b32..4dd1620bed56 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -137,13 +137,11 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin () => this.store, () => this.getOrchestrationDb(), (worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId), - (paneKey, blocked, generation) => - this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked, generation) + () => this.notifier?.legacyWorkerTerminalResumeFencesChanged?.() ) protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({ preparePlan: () => this.legacyWorkerRecoveryPersistence.prepare(), - committedFenceSnapshot: () => this.legacyWorkerRecoveryPersistence.committedFenceSnapshot(), resolveWorkspace: async (candidate) => { const scope = await this.resolveTerminalWorkspaceLaunchScope(`id:${candidate.worktreeId}`) const resolved = scope.folderWorkspace diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 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 747a2e1357a7..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([]) @@ -478,7 +474,6 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store) const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({ blockedPaneCount: 1, - fenceSnapshot: { generation: 0, blockedPaneKeys: [] }, adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: [] diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 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-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index 4c62d2c5f782..e89e7f9eaf26 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' @@ -20,160 +19,89 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { private readonly getStore: () => RuntimeStore | null, private readonly getDb: () => OrchestrationDb, private readonly getHostId: (worktreeId: string) => ExecutionHostId | null, - /** The store write only reaches the next app start; a live renderer holds its own copy. */ - private readonly notifyFenceChanged?: ( - paneKey: string, - blocked: boolean, - generation: number - ) => void + /** Invalidation only. The state itself lives in the session field; a push that carried it is + * what made the fence lossy across renderer reloads. */ + private readonly notifyFenceChanged?: () => void ) {} - /** The blocked set this object has actually committed and published, which is what a startup - * reply must report — the raw plan is not, because a pass whose session write threw published - * nothing. It also supplies the lift edge for a pane with no sleeping record, which - * `liftRetiredFences` cannot sweep. */ - private lastPlanBlockedPaneKeys: ReadonlySet = new Set() - - /** Counts commits so a startup reply can be ordered against the live pushes. */ - private fenceGeneration = 0 - - committedFenceSnapshot(): LegacyWorkerResumeFenceSnapshot { - return { generation: this.fenceGeneration, blockedPaneKeys: [...this.lastPlanBlockedPaneKeys] } - } - + /** + * Writes the whole fenced-pane set for every host on every pass. Level-triggered on purpose: + * there is no edge to miss, nothing to announce once, and no bookkeeping to keep in step with + * the store, so a renderer that reloads simply reads the field again. + * + * `legacyWorkerResumeFencesByPaneKey` is runtime-authored and absent from the renderer's patch + * builder, so this write is the only author and a renderer session write cannot erase it. + */ prepare(): LegacyWorkerTerminalRecoveryPlan { const plan = this.getPlan() if (!plan) { - // An unreadable plan is not evidence that any pane stopped needing its fence: stamp - // nothing, lift nothing, retry on the next pass. + // An unreadable plan is not evidence that any pane stopped needing its fence: write nothing + // and retry on the next pass, leaving the previously written set in place. return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } } const store = this.getStore() - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { + if (!store?.getWorkspaceSession || !store.setWorkspaceSession) { return plan } - const sessions = new Map< - ExecutionHostId, - { current: WorkspaceSessionState; next: WorkspaceSessionState } - >() - const changedHostIds = new Set() - const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey)) - const fenceChanges = new Map() + const hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + // Why seeded from the listed hosts: a host that no longer owns any fenced pane still has to be + // written with an empty set, or its last set would stay pinned forever. + const fencedByHost = new Map>( + hostIds.map((hostId) => [hostId, {}]) + ) for (const blocked of plan.blockedPanes) { - // A worker can settle while its tab is still open, so there is no sleeping record to stamp - // yet. Tell the live renderer anyway: it mints the record on close and must fence it there. - // Announced every pass because the renderer's map is volatile — a once-per-process push - // dies on reload and never reaches a client that pairs later. The renderer is idempotent. - fenceChanges.set(blocked.paneKey, true) - let hostIds: ExecutionHostId[] + let owners: ExecutionHostId[] try { const hostId = this.getHostId(blocked.worktreeId) if (!hostId) { throw new Error('folder_workspace_not_found') } - hostIds = [hostId] + owners = [hostId] } catch (error) { + // An owner this store cannot name is written to whichever partition already retains this + // pane, and to every host only when none does. Losing the fence relaunches a worker that + // is still running, so the fallback widens rather than skipping. console.warn('[orchestration] legacy worker resume fence owner is unavailable', { worktreeId: blocked.worktreeId, error }) - hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] + const retaining = hostIds.filter((hostId) => { + const session = store.getWorkspaceSession?.(hostId) + return ( + session?.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] !== undefined || + session?.legacyWorkerResumeFencesByPaneKey?.[blocked.paneKey] === true + ) + }) + owners = retaining.length > 0 ? retaining : hostIds } - for (const hostId of hostIds) { - let state = sessions.get(hostId) - if (!state) { - const current = store.getWorkspaceSession(hostId) - if (!current) { - continue - } - state = { current, next: structuredClone(current) } - sessions.set(hostId, state) - } - const record = state.next.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] - if ( - !record || - !runtimeWorktreeIdsEqual(record.worktreeId, blocked.worktreeId) || - record.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ) { - continue - } - state.next.sleepingAgentSessionsByPaneKey = { - ...state.next.sleepingAgentSessionsByPaneKey, - [blocked.paneKey]: { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' } - } - changedHostIds.add(hostId) + for (const hostId of owners) { + // An owner outside the listed hosts still gets its own entry; the list is a floor. + const fenced = fencedByHost.get(hostId) ?? {} + fenced[blocked.paneKey] = true + fencedByHost.set(hostId, fenced) } } - this.liftRetiredFences(store, blockedPaneKeys, sessions, changedHostIds, fenceChanges) - const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) + let changed = false try { - for (const [hostId, state] of changed) { - store.setWorkspaceSession(state.next, hostId) + for (const [hostId, fenced] of fencedByHost) { + const current = store.getWorkspaceSession(hostId) + if (!current || sameFenceSet(current.legacyWorkerResumeFencesByPaneKey, fenced)) { + continue + } + store.setWorkspaceSession({ ...current, legacyWorkerResumeFencesByPaneKey: fenced }, hostId) + changed = true } } catch (error) { - // Why after the write: a staging failure must not consume the announce or the lift edge, - // or a record-less pane would keep a fence no later pass could ever deliver or retire. - console.warn('[orchestration] failed to stage legacy worker resume fence', error) + // A failed write publishes nothing, and the next pass rewrites the same level. + console.warn('[orchestration] failed to write legacy worker resume fences', error) return plan } - this.lastPlanBlockedPaneKeys = blockedPaneKeys - this.fenceGeneration += 1 - for (const [paneKey, blocked] of fenceChanges) { - this.notifyFenceChanged?.(paneKey, blocked, this.fenceGeneration) + if (changed) { + this.notifyFenceChanged?.() } return plan } - /** A fence that outlives its dispatch leaves a pane that can never spawn again, so release, - * retain, user takeover and dispatch pruning — each of which drops the row from the plan — - * retire it here. An unreadable plan yields no blocked panes, so callers must not sweep. */ - private liftRetiredFences( - store: RuntimeStore, - blockedPaneKeys: ReadonlySet, - sessions: Map, - changedHostIds: Set, - fenceChanges: Map - ): void { - for (const paneKey of this.lastPlanBlockedPaneKeys) { - if (!blockedPaneKeys.has(paneKey)) { - fenceChanges.set(paneKey, false) - } - } - for (const hostId of store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID]) { - const staged = sessions.get(hostId) - const session = staged?.next ?? store.getWorkspaceSession?.(hostId) - const retired = Object.entries(session?.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([paneKey, record]) => - record.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - !blockedPaneKeys.has(paneKey) - ) - if (retired.length === 0) { - continue - } - let state = staged - if (!state) { - const current = store.getWorkspaceSession?.(hostId) - if (!current) { - continue - } - state = { current, next: structuredClone(current) } - sessions.set(hostId, state) - } - const next = { ...state.next.sleepingAgentSessionsByPaneKey } - for (const [paneKey, record] of retired) { - const { automaticResumeBlockedBy: _retired, ...unfenced } = record - next[paneKey] = unfenced - fenceChanges.set(paneKey, false) - } - state.next.sleepingAgentSessionsByPaneKey = next - changedHostIds.add(hostId) - } - } - async persist( resolutions: readonly LegacyWorkerRecoveryResolution[] ): Promise> { @@ -286,3 +214,17 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { throw new Error('workspace_session_persistence_unavailable') } } + +/** Identity is not enough: `prepare` rebuilds the set every pass, so compare by content or every + * pass would rewrite the session and wake every session subscriber. */ +function sameFenceSet( + current: Record | undefined, + next: Record +): boolean { + const currentKeys = Object.keys(current ?? {}) + const nextKeys = Object.keys(next) + return ( + currentKeys.length === nextKeys.length && + nextKeys.every((paneKey) => current?.[paneKey] === true) + ) +} diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts index aeb5574d060d..bd15abc7d4d7 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -105,9 +105,6 @@ export async function runLegacyWorkerTerminalRecovery( } const result = { blockedPaneCount: plan.blockedPanes.length, - // Why re-read: `plan` predates the awaits above, and a release/retain/takeover sweep can retire - // a fence inside that window. Reporting the stale plan would re-fence a retired pane. - fenceSnapshot: ports.committedFenceSnapshot(), adoptedDispatchIds, exitedDispatchIds, deferredDispatchIds: [...deferredDispatchIds] diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts index 422e659bfd5e..c65afd739523 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { Repo } from '../../shared/repo-types' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' @@ -7,9 +6,6 @@ import type { ResolvedWorktree } from './runtime-worktree-path-identity' export type LegacyWorkerTerminalRecoveryResult = { blockedPaneCount: number - /** Main's committed fence state as of the END of this pass, so a renderer seeding from it cannot - * reapply a fence a release/takeover retired while the pass was awaiting its terminal work. */ - fenceSnapshot: LegacyWorkerResumeFenceSnapshot adoptedDispatchIds: string[] exitedDispatchIds: string[] deferredDispatchIds: string[] @@ -44,8 +40,6 @@ export type LegacyWorkerRecoveryInventory = PtyControllerInventory export type LegacyWorkerRecoveryPorts = { preparePlan: () => LegacyWorkerTerminalRecoveryPlan - /** Read after the pass finishes; never derived from the plan the pass started with. */ - committedFenceSnapshot: () => LegacyWorkerResumeFenceSnapshot resolveWorkspace: ( candidate: LegacyWorkerRecoveryCandidate ) => Promise diff --git a/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts b/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts index b364cd4a9c85..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 @@ -6,8 +6,6 @@ import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeService } from './orca-runtime' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' -import { runLegacyWorkerTerminalRecovery } from './runtime-legacy-worker-terminal-recovery-runner' -import type { LegacyWorkerRecoveryPorts } from './runtime-legacy-worker-terminal-recovery-types' import type { RuntimeStore } from './runtime-store-contract' const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333' @@ -39,15 +37,16 @@ describe('settled worker automatic-resume fence persistence', () => { afterEach(() => db?.close()) function harness( - onFenceChanged?: (paneKey: string, blocked: boolean) => void, - /** False models a worker that settles while its tab is still open: no record to stamp yet. */ + onFencesChanged?: () => void, + /** False models a worker that settles while its tab is still open: no sleeping record. */ withSleepingRecord = true ): { db: OrchestrationDb taskId: string dispatchId: string persistence: RuntimeLegacyWorkerTerminalRecoveryPersistence - fence: () => string | undefined + fences: () => Record + recordFlag: () => string | undefined } { const orchestrationDb = new OrchestrationDb(':memory:') db = orchestrationDb @@ -88,9 +87,10 @@ describe('settled worker automatic-resume fence persistence', () => { () => store, () => orchestrationDb, () => LOCAL_EXECUTION_HOST_ID, - onFenceChanged + onFencesChanged ), - fence: () => session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy + fences: () => session.legacyWorkerResumeFencesByPaneKey ?? {}, + recordFlag: () => session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy } } @@ -100,307 +100,117 @@ 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) 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.fences()).toEqual({ [PANE_KEY]: true }) }) - // A renderer's blocked-pane map starts empty on every boot (reload, crash restart, asar swap, - // a new window). A once-per-process announcement left the reloaded renderer unfenced, so it - // minted an unfenced record on close and worktree activation respawned the settled worker. - it('re-announces the fence on every pass so a reloaded renderer is fenced again', () => { - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false) + // Main never writes the record flag: it is the renderer's outbound projection for old clients. + it('leaves the sleeping record untouched', () => { + const h = harness() settle(h.db, h.taskId, h.dispatchId) h.persistence.prepare() - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - fenceChanges.length = 0 - const plan = h.persistence.prepare() - - expect(plan.blockedPanes).toEqual([expect.objectContaining({ paneKey: PANE_KEY })]) - expect(fenceChanges).toEqual([[PANE_KEY, true]]) + expect(h.recordFlag()).toBeUndefined() }) - // Headless `orca serve` / orcad runs the same recovery with no window notifier. Burning the - // announcement there meant a client that paired afterwards never learned of the fence. - it('still announces to a client that attaches after a headless recovery pass', () => { - let notifierAttached = false - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => { - if (notifierAttached) { - fenceChanges.push([paneKey, blocked]) - } - }, false) + // 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() - expect(fenceChanges).toEqual([]) - - notifierAttached = true h.persistence.prepare() - expect(fenceChanges).toEqual([[PANE_KEY, true]]) + expect(h.fences()).toEqual({ [PANE_KEY]: true }) }) - // A staging failure must not consume the announcement: the pane stays fenced in the plan, so - // the next pass has to deliver it rather than leave a live renderer permanently unfenced. - it('redelivers the announcement after a failed session write', () => { - const fenceChanges: [string, boolean][] = [] - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID, - (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - persistence.prepare() - } finally { - warn.mockRestore() - } - expect(fenceChanges).toEqual([]) + 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 }) - failWrite = false - persistence.prepare() + 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(fenceChanges).toEqual([[PANE_KEY, true]]) - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - 'legacy-orchestration-worker' - ) + expect(h.fences()).toEqual({}) }) - // A pass reads its plan up front and then awaits workspace resolution, inventory and persistence. - // A release/takeover sweep can retire a fence inside that window, so reporting the plan the pass - // started with would hand a starting renderer a fence the authority had already retired. - it('reports the fence state committed at the end of the pass, not the plan it started with', async () => { - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false) + // 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) - let releasePersist!: () => void - const ports = { - preparePlan: () => h.persistence.prepare(), - committedFenceSnapshot: () => h.persistence.committedFenceSnapshot(), - persist: () => - new Promise>((resolve) => { - releasePersist = () => resolve(new Set()) - }), - updateRetry: () => {}, - reconcileRequestedReleases: async () => {} - } as unknown as LegacyWorkerRecoveryPorts - const pass = runLegacyWorkerTerminalRecovery({} as never, ports, {}) - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - - // The release lands while the pass is still awaiting its terminal work. + 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() - releasePersist() - const result = await pass - expect(result.fenceSnapshot.blockedPaneKeys).toEqual([]) - expect(fenceChanges).toEqual([ - [PANE_KEY, true], - [PANE_KEY, false] - ]) + expect(h.fences()).toEqual({}) }) - // A pass whose session write threw published nothing, so it must report the previous committed - // state. Reporting the uncommitted plan would fence a pane through the reply that the push - // channel never announced — and that no later lift could retire. - it('reports nothing new when the session write failed', () => { - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID - ) - - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - persistence.prepare() - } finally { - warn.mockRestore() - } - expect(persistence.committedFenceSnapshot()).toEqual({ generation: 0, blockedPaneKeys: [] }) + 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 }) - failWrite = false - persistence.prepare() + expect(h.db.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1) + h.persistence.prepare() - expect(persistence.committedFenceSnapshot()).toEqual({ - generation: 1, - blockedPaneKeys: [PANE_KEY] - }) + expect(h.fences()).toEqual({}) }) - // The lift edge for a pane with no record lives in the committed set, so a failed write must not - // consume it: the pane must still be retired once the plan drops it after a successful pass. - it('still lifts a record-less fence retired after a failed then successful write', () => { - const fenceChanges: [string, boolean][] = [] - let failWrite = true - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - if (failWrite) { - throw new Error('workspace_session_write_failed') - } - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - settle(orchestrationDb, task.id, started.dispatch.id) - const persistence = new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID, - (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) - ) + // 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 { - persistence.prepare() + expect(h.persistence.prepare()).toEqual({ + blockedPanes: [], + candidates: [], + ambiguousDispatchIds: [] + }) } finally { warn.mockRestore() } - expect(fenceChanges).toEqual([]) - - failWrite = false - persistence.prepare() - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - const requested = orchestrationDb.requestWorkerTerminalRelease(started.dispatch.id) - orchestrationDb.settleWorkerTerminalRelease( - (requested as { resource: { id: string } }).resource.id - ) - persistence.prepare() - - expect(fenceChanges).toEqual([ - [PANE_KEY, true], - [PANE_KEY, false] - ]) - expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) + expect(h.fences()).toEqual({ [PANE_KEY]: true }) }) - // The damaging order: the fence is retired while the write is still failing. A pass that booked - // its blocked set before staging would carry that uncommitted fence into the retry and publish a - // lift for a pane no renderer was ever told about — or, through the reply, the fence itself. - it('publishes nothing for a pane retired while the session write was failing', () => { - const fenceChanges: [string, boolean][] = [] + // 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 @@ -439,7 +249,7 @@ describe('settled worker automatic-resume fence persistence', () => { () => store, () => orchestrationDb, () => LOCAL_EXECUTION_HOST_ID, - (paneKey, blocked) => fenceChanges.push([paneKey, blocked]) + () => pings.push(1) ) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -448,88 +258,36 @@ describe('settled worker automatic-resume fence persistence', () => { } finally { warn.mockRestore() } - expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) + expect(session.legacyWorkerResumeFencesByPaneKey).toBeUndefined() + expect(pings).toEqual([]) - const requested = orchestrationDb.requestWorkerTerminalRelease(started.dispatch.id) - orchestrationDb.settleWorkerTerminalRelease( - (requested as { resource: { id: string } }).resource.id - ) failWrite = false persistence.prepare() - expect(fenceChanges).toEqual([]) - expect(persistence.committedFenceSnapshot().blockedPaneKeys).toEqual([]) - }) - - // The STA-4577 repro: worker_done, no release, restart, open the worktree — the pane still - // holds a resumable provider session and must not respawn `codex resume`. - it('fences a settled worker pane whose terminal was never released', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) - - h.persistence.prepare() - - expect(h.fence()).toBe('legacy-orchestration-worker') - }) - - it('lifts the fence once release retires the terminal resource', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) - h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') - - const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) - expect(requested.disposition).toBe('requested') - h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) - h.persistence.prepare() - - expect(h.fence()).toBeUndefined() + expect(session.legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) + expect(pings).toEqual([1]) }) - it('lifts the fence when the user takes the pane over', () => { - const h = harness() + // 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() - expect(h.fence()).toBe('legacy-orchestration-worker') - expect(h.db.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1) h.persistence.prepare() - - expect(h.fence()).toBeUndefined() - }) - - // An unreadable plan is not evidence a pane stopped needing its fence. - it('keeps the fence when the recovery plan cannot be read', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') - vi.spyOn(h.db, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => { - throw new Error('orchestration_db_unavailable') - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - expect(h.persistence.prepare()).toEqual({ - blockedPanes: [], - candidates: [], - ambiguousDispatchIds: [] - }) - } finally { - warn.mockRestore() - } - - expect(h.fence()).toBe('legacy-orchestration-worker') + expect(pings).toEqual([1]) }) - // 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 live worker's pane is fenced while main reconciles it against PTY inventory; the settled arm + // must not disturb that, and the plan must still name it as unsettled. it('keeps a live worker pane fenced and marked unsettled', () => { const h = harness() const plan = h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') + expect(h.fences()).toEqual({ [PANE_KEY]: true }) expect(plan.blockedPanes).toEqual([ expect.objectContaining({ paneKey: PANE_KEY, settled: false }) ]) @@ -592,9 +350,7 @@ describe('worker_done without a release', () => { paneKey: PANE_KEY, processIncarnation: 'runtime:pty:1' }) - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - undefined - ) + expect(session.legacyWorkerResumeFencesByPaneKey).toBeUndefined() const send = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.send')! await send.handler( @@ -613,8 +369,6 @@ describe('worker_done without a release', () => { ) expect(orchestrationDb.getWorkerDispatch(started.dispatch.id)?.state).toBe('succeeded') - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - 'legacy-orchestration-worker' - ) + expect(session.legacyWorkerResumeFencesByPaneKey).toEqual({ [PANE_KEY]: true }) }) }) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index 852ee509c279..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, generation: number): void + /** Invalidation ping: the fenced-pane set changed, re-read it. Carries no state. */ + legacyWorkerTerminalResumeFencesChanged?(): void splitTerminal( tabId: string, paneRuntimeId: number, diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts index c54b94b203ae..4a24f107135e 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -1,20 +1,14 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' -import type { LegacyWorkerTerminalRecoveryResult } from '../runtime/runtime-legacy-worker-terminal-recovery-types' - -/** Generation 0 never orders ahead of a real commit, so a renderer drops this over any live push. */ -const EMPTY_FENCE_SNAPSHOT: LegacyWorkerResumeFenceSnapshot = { generation: 0, blockedPaneKeys: [] } - type LegacyWorkerRendererRecoveryOptions = { firstWindowStartupServicesReady: Promise managedWslCliStartupBarrierReady: Promise localPtyProviderStartupReady: Promise - reconcile: () => Promise | undefined + reconcile: () => Promise | undefined onDeferredRecoveryError: (error: unknown) => void } export async function recoverLegacyWorkerTerminalsForRendererStartup( options: LegacyWorkerRendererRecoveryOptions -): Promise { +): Promise { const providerStartupResult = options.localPtyProviderStartupReady.then( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }) @@ -26,12 +20,11 @@ export async function recoverLegacyWorkerTerminalsForRendererStartup( ]) if (!providerResult.ok) { options.onDeferredRecoveryError(providerResult.error) - return EMPTY_FENCE_SNAPSHOT + return } try { - return (await options.reconcile())?.fenceSnapshot ?? EMPTY_FENCE_SNAPSHOT + await options.reconcile() } catch (error) { options.onDeferredRecoveryError(error) - return EMPTY_FENCE_SNAPSHOT } } diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 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 9e220bac8858..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, generation) => - send('agentStatus:legacyWorkerTerminalResumeFence', { paneKey, blocked, generation }), + legacyWorkerTerminalResumeFencesChanged: () => + send('agentStatus:legacyWorkerTerminalResumeFencesChanged', {}), splitTerminal: (tabId, paneRuntimeId, opts) => { send('ui:splitTerminal', { tabId, diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index ce5cd6a7870d..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; generation?: number }) => void - ) => () => void + /** Invalidation ping: the runtime-authored fenced-pane set changed. Carries no state. */ + onLegacyWorkerTerminalResumeFencesChanged: (callback: () => void) => () => void getMigrationUnsupportedSnapshot: () => Promise /** Drop a paneKey from the main-process hook cache and on-disk last-status file. Fire-and-forget. */ drop: (paneKey: string) => void diff --git a/src/preload/api/agent-status-bridge.ts b/src/preload/api/agent-status-bridge.ts index 5207c332afc8..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; generation?: number }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { paneKey: string; blocked: boolean; generation?: number } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFence', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFence', listener) + onLegacyWorkerTerminalResumeFencesChanged: (callback: () => void): (() => void) => { + const listener = (): void => callback() + ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFencesChanged', listener) + return () => + ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFencesChanged', listener) }, getMigrationUnsupportedSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index fe12ef1efe45..71470d50050f 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import type { AppIdentity } from '../../shared/app-identity' import type { E2EConfig } from '../../shared/e2e-config' import type { ExecutionHostId } from '../../shared/execution-host' @@ -44,9 +43,10 @@ export type AppApi = { awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise - /** Reconciles legacy worker authority around persisted terminal reconnect and returns the - * fenced-pane set the renderer must seed its volatile blocked-pane map from. */ - recoverLegacyWorkerTerminalsForRendererStartup: () => Promise + /** Reconciles legacy worker authority around persisted terminal reconnect. */ + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise + /** Runtime-authored panes fenced against automatic resume, unioned across hosts. */ + getLegacyWorkerResumeFences: () => Promise> /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ startupDiagnostic: (event: string, details?: Record) => Promise /** macOS active input mode, or layout ID when no IME is selected (e.g. `com.apple.keylayout.PolishPro`). diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 20863cd762da..c34b2500b94b 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -1,4 +1,3 @@ -import type { LegacyWorkerResumeFenceSnapshot } from '../../shared/agent-session-resume' import { ipcRenderer } from 'electron' import type { AppIdentity } from '../../shared/app-identity' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' @@ -48,8 +47,10 @@ export const appApi = { ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), - recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => + recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), + getLegacyWorkerResumeFences: (): Promise> => + ipcRenderer.invoke('app:getLegacyWorkerResumeFences'), startupDiagnostic: (event: string, details?: Record): Promise => startupDiagnosticsEnabled ? ipcRenderer.invoke('app:startupDiagnostic', event, details) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 8ec1e01f90b4..77da19ffd204 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -1,4 +1,3 @@ -import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from '@/startup/legacy-worker-resume-fence-seed' import { useEffect, useRef } from 'react' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' @@ -261,7 +260,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited // the first-window services, and main re-awaits them inside this handler anyway. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => - recoverLegacyWorkerTerminalsAndSeedResumeFences() + window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) await timeRendererStartupStep('terminal-provider-snapshot-capabilities', () => { return refreshTerminalProviderSnapshotCapabilities( @@ -273,7 +272,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.reconnectPersistedTerminals(abortController.signal) ) await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => - recoverLegacyWorkerTerminalsAndSeedResumeFences() + window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) if (useAppStore.getState().settings?.experimentalStructuredNativeChat === true) { await timeRendererStartupStep('project-structured-session-tabs', () => diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index b1832888dbd3..fead2f6c7bbd 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -224,7 +224,7 @@ describe('renderer startup runtime routing', () => { degradedStart ) const recoveryIndex = source.indexOf( - 'recoverLegacyWorkerTerminalsAndSeedResumeFences()', + 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()', servicesIndex ) const capabilityRefreshIndex = source.indexOf( @@ -238,11 +238,6 @@ describe('renderer startup runtime routing', () => { expect(recoveryIndex).toBeGreaterThan(servicesIndex) expect(capabilityRefreshIndex).toBeGreaterThan(recoveryIndex) expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) - // The helper is what still reaches main, so the ordering above only means something while it - // owns the pull. Assert the indirection instead of letting the rename hollow the gate out. - expect(readSource('src/renderer/src/startup/legacy-worker-resume-fence-seed.ts')).toContain( - 'window.api.app.recoverLegacyWorkerTerminalsForRendererStartup()' - ) }) it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts index 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.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 0777dead4310..1f9710f8e584 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -62,9 +62,11 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi const [paneKey, record] = selectedLegacyMatch return { paneKey, record } } - session.isLegacyWorkerAutomaticResumeBlocked = (): boolean => - session.getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy === - 'legacy-orchestration-worker' + session.isLegacyWorkerAutomaticResumeBlocked = (): boolean => { + const state = useAppStore.getState() + const paneKey = session.getSleepingRecordForPane(state)?.paneKey + return paneKey !== undefined && state.legacyWorkerResumeFencesByPaneKey[paneKey] === true + } session.clearSleepingRecordProviderDuplicates = ( state: ReturnType, consumed: { paneKey: string; record: SleepingAgentSessionRecord } diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts index 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 4141d34b07e0..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, generation }) => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked, generation) + const unsubscribeResumeFence = window.api.agentStatus.onLegacyWorkerTerminalResumeFencesChanged?.( + () => { + void refreshLegacyWorkerResumeFences() } ) if (unsubscribeResumeFence) { diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 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 7dfab1fb8747..9fe2bac6b999 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -85,7 +85,7 @@ function snapshotFromState( .map(([ptyId]) => ptyId), agentStatusByPaneKey: state.agentStatusByPaneKey, sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, - automaticResumeBlockedPaneKeys: state.automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey: state.legacyWorkerResumeFencesByPaneKey, // Why: input stamps are coalesced, so planning must see the not-yet-flushed keystroke. lastTerminalInputAtByPaneKey: mergePendingTerminalInputActivity( state.lastTerminalInputAtByPaneKey diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts index 73fe2c3e32f2..2e4015082254 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility-resume-fence.test.ts @@ -23,7 +23,7 @@ const entry: AgentStatusEntry = { providerSession: { key: 'session_id', id: 'session-1' } } -const liveAnchor = (fenced: boolean): SleepingAgentSessionRecord => ({ +const liveAnchor: SleepingAgentSessionRecord = { paneKey: PANE_KEY, tabId: 'tab-1', worktreeId: 'wt-1', @@ -33,13 +33,12 @@ const liveAnchor = (fenced: boolean): SleepingAgentSessionRecord => ({ state: 'done', capturedAt: NOW - 600_000, updatedAt: NOW - 600_000, - origin: 'live', - ...(fenced ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } : {}) -}) + origin: 'live' +} function plan( record: SleepingAgentSessionRecord | undefined, - automaticResumeBlockedPaneKeys: Record = {} + legacyWorkerResumeFencesByPaneKey: Record = {} ) { return getEligiblePane({ entry, @@ -47,7 +46,7 @@ function plan( layout: { ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } } as never, livePtyIds: new Set(['pty-1']), sleepingAgentSessionsByPaneKey: record ? { [PANE_KEY]: record } : {}, - automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey, lastTerminalInputAtByPaneKey: {}, foregroundTerminalLastSeenAtByTabId: {}, ptyBindingFirstSeenAtByPaneKey: {}, @@ -58,32 +57,37 @@ function plan( }) } -// Hibernating a fenced pane kills a worker PTY that must not be automatically relaunched. The -// planner used to read the fence only off the sleeping record, so a worker that settled while its -// tab was still open — fenced before any record exists — was admitted for the kill. +// Hibernating a fenced pane kills a worker PTY that must not be automatically relaunched. The fence +// is runtime-authored session state keyed by pane, so it covers a worker that settled while its tab +// was still open — the case that has no sleeping record for a flag to live on. describe('hibernation eligibility and the settled-worker resume fence', () => { - it('blocks the kill when the record carries the fence', () => { - expect(plan(liveAnchor(true))).toBeNull() - }) - - it('blocks the kill when only the blocked-pane map carries the fence', () => { - expect(plan(liveAnchor(false), { [PANE_KEY]: true })).toBeNull() + it('blocks the kill for a fenced pane that has a record', () => { + expect(plan(liveAnchor, { [PANE_KEY]: true })).toBeNull() }) - it('blocks the kill when the pane is fenced before any record exists', () => { + it('blocks the kill for a fenced pane that has no record yet', () => { expect(plan(undefined, { [PANE_KEY]: true })).toBeNull() }) // Controls: the ordinary completed-agent population must still hibernate. - it('admits an unfenced completed pane holding only its live resume anchor', () => { - expect(plan(liveAnchor(false))).not.toBeNull() + it('admits an unfenced pane holding only its live resume anchor', () => { + expect(plan(liveAnchor)).not.toBeNull() }) - it('admits an unfenced completed pane with no record at all', () => { + it('admits an unfenced pane with no record at all', () => { expect(plan(undefined)).not.toBeNull() }) it('admits a pane when a different pane is the fenced one', () => { - expect(plan(liveAnchor(false), { 'tab-9:other-leaf': true })).not.toBeNull() + expect(plan(liveAnchor, { 'tab-9:other-leaf': true })).not.toBeNull() + }) + + // The record flag is an outbound projection for older clients, never an input to a decision. + it('ignores a record flag the runtime no longer claims', () => { + const stale = { + ...liveAnchor, + automaticResumeBlockedBy: 'legacy-orchestration-worker' as const + } + expect(plan(stale)).not.toBeNull() }) }) diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 1db4710ed3a6..7c2c4063ca13 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -61,7 +61,7 @@ export function getEligiblePane(args: { layout: TerminalLayoutSnapshot | undefined livePtyIds: Set sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] - automaticResumeBlockedPaneKeys: Record + legacyWorkerResumeFencesByPaneKey: Record lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] ptyBindingFirstSeenAtByPaneKey: Record @@ -76,7 +76,7 @@ export function getEligiblePane(args: { layout, livePtyIds, sleepingAgentSessionsByPaneKey, - automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey, lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey, @@ -100,8 +100,7 @@ export function getEligiblePane(args: { (sleepingRecord && !hasOnlyLiveResumeAnchor) || // Why: a fenced worker must never be auto-relaunched; the kill would strand it. !isAutomaticHibernationAllowed({ - record: sleepingRecord, - automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey, paneKey: entry.paneKey }) ) { diff --git a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts index 94cc725d8761..2e5670d88552 100644 --- a/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts +++ b/src/renderer/src/lib/agent-hibernation-planner-snapshot.ts @@ -15,8 +15,8 @@ export type AgentHibernationPlannerSnapshot = { mobileLockedPtyIds: string[] agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: Record - /** Panes fenced before a record exists; main re-seeds this on every renderer start. */ - automaticResumeBlockedPaneKeys?: Record + /** Runtime-authored fenced-pane set, read from the workspace session. */ + legacyWorkerResumeFencesByPaneKey?: Record lastTerminalInputAtByPaneKey: Record foregroundTerminalLastSeenAtByTabId: Record ptyBindingFirstSeenAtByPaneKey?: Record diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 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 6364e5ec647a..dc725d3d172a 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -146,7 +146,7 @@ export function planAgentHibernationCandidates( layout, livePtyIds: new Set(tabLivePtyIds), sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, - automaticResumeBlockedPaneKeys: snapshot.automaticResumeBlockedPaneKeys ?? {}, + legacyWorkerResumeFencesByPaneKey: snapshot.legacyWorkerResumeFencesByPaneKey ?? {}, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, diff --git a/src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.test.ts new file mode 100644 index 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..15728c411dec --- /dev/null +++ b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts @@ -0,0 +1,35 @@ +import { useAppStore } from '@/store' + +let inFlight: Promise | null = null +let repeat = false + +/** + * Re-reads the runtime-authored fenced-pane set after main says it changed. A pull rather than a + * pushed payload: the set is session state the renderer also hydrates, and an event carrying it + * could land out of order with that hydration. + * + * Reads are serialized and coalesced, so the last read is the last write with no version to + * compare — two overlapping pings cannot leave an older set installed. + */ +export async function refreshLegacyWorkerResumeFences(): Promise { + if (inFlight) { + repeat = true + return inFlight + } + inFlight = (async () => { + try { + do { + repeat = false + const fences = await window.api.app.getLegacyWorkerResumeFences() + useAppStore.getState().setLegacyWorkerResumeFences(fences ?? {}) + } while (repeat) + } catch (error) { + // Losing a refresh leaves the previously read set in place; the next ping or start re-reads. + console.warn('[orchestration] failed to read legacy worker resume fences', error) + } finally { + inFlight = null + repeat = false + } + })() + return inFlight +} diff --git a/src/renderer/src/lib/live-resume-anchor-record.ts b/src/renderer/src/lib/live-resume-anchor-record.ts index 73e6771d9981..9fbb74fc0085 100644 --- a/src/renderer/src/lib/live-resume-anchor-record.ts +++ b/src/renderer/src/lib/live-resume-anchor-record.ts @@ -52,18 +52,15 @@ export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord( } /** - * A durable orchestration fence against automatic provider relaunch; hibernating a fenced pane - * would strand it. The record is the fence's home, but a worker that settles while its tab is - * still open is fenced before any record exists — `automaticResumeBlockedPaneKeys` holds it for - * that window, so the planner must read both. + * A fence against automatic provider relaunch; hibernating a fenced pane would strand it. The + * orchestration authority is the only writer, and it publishes the whole fenced-pane set as + * runtime-authored session state — including panes whose worker settled with the tab still open, + * which have no sleeping record to carry a flag. `automaticResumeBlockedBy` on the record is an + * outbound projection of this set for older clients, never the thing a decision reads. */ export function isAutomaticHibernationAllowed(pane: { - record: SleepingAgentSessionRecord | undefined - automaticResumeBlockedPaneKeys: Record + legacyWorkerResumeFencesByPaneKey: Record paneKey: string }): boolean { - return ( - !pane.record?.automaticResumeBlockedBy && - pane.automaticResumeBlockedPaneKeys[pane.paneKey] !== true - ) + return pane.legacyWorkerResumeFencesByPaneKey[pane.paneKey] !== true } diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts index 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/workspace-session-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 4e56654a8f5d..4976edf7b179 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -172,7 +172,12 @@ export function buildWorkspaceSessionPatch( snapshot.closedTerminalTabTombstonesByTabId ) } - if (changed.has('sleepingAgentSessionsByPaneKey')) { + // Why both: the projected flag is derived from the fence set, so a fence change alone still has + // to re-emit the records an older client reads it from. + if ( + changed.has('sleepingAgentSessionsByPaneKey') || + changed.has('legacyWorkerResumeFencesByPaneKey') + ) { patch.sleepingAgentSessionsByPaneKey = buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey } diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index 2dc1346f5349..f688ea7a0626 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -37,6 +37,7 @@ describe('SESSION_RELEVANT_FIELDS', () => { defaultTerminalTabsAppliedByWorktreeId: true, closedTerminalTabTombstonesByTabId: true, sleepingAgentSessionsByPaneKey: true, + legacyWorkerResumeFencesByPaneKey: true, clientHostedBrowserCloseIntentsByEnvironment: true, pendingReconnectPtyIdByTabId: true, deferredSshSessionIdsByTabId: true diff --git a/src/renderer/src/lib/workspace-session-sleeping-agents.ts b/src/renderer/src/lib/workspace-session-sleeping-agents.ts index 29ee3d0fe6a8..8183731ec2d5 100644 --- a/src/renderer/src/lib/workspace-session-sleeping-agents.ts +++ b/src/renderer/src/lib/workspace-session-sleeping-agents.ts @@ -1,10 +1,36 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +/** + * The single site where `automaticResumeBlockedBy` is stamped onto an outgoing record. + * + * The fence itself lives in `legacyWorkerResumeFencesByPaneKey`, which main owns and this renderer + * never writes. The record flag is a projection kept only so a client too old to read that field + * still hydrates fenced records as fenced. Projecting here — the one function both the patch path + * and the full-state path build records through — is what lets every record writer stay unaware of + * the fence: the flag is derived on the way out rather than carried through the store. + */ export function buildSleepingAgentSessionData(snapshot: { sleepingAgentSessionsByPaneKey?: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] + legacyWorkerResumeFencesByPaneKey?: Record }): Pick { const records = snapshot.sleepingAgentSessionsByPaneKey - return records && Object.keys(records).length > 0 - ? { sleepingAgentSessionsByPaneKey: records } - : {} + if (!records || Object.keys(records).length === 0) { + return {} + } + const fences = snapshot.legacyWorkerResumeFencesByPaneKey ?? {} + let projected: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] | undefined + for (const [paneKey, record] of Object.entries(records)) { + const fenced = fences[paneKey] === true + if (fenced === (record.automaticResumeBlockedBy === 'legacy-orchestration-worker')) { + continue + } + projected ??= { ...records } + if (fenced) { + projected[paneKey] = { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } else { + const { automaticResumeBlockedBy: _retired, ...unfenced } = record + projected[paneKey] = unfenced + } + } + return { sleepingAgentSessionsByPaneKey: projected ?? records } } diff --git a/src/renderer/src/lib/workspace-session.ts b/src/renderer/src/lib/workspace-session.ts index 330a150b6366..c5656c17b471 100644 --- a/src/renderer/src/lib/workspace-session.ts +++ b/src/renderer/src/lib/workspace-session.ts @@ -60,6 +60,8 @@ export type WorkspaceSessionSnapshot = Pick< > & { activeWorkspaceExecutionHostId?: AppState['activeWorkspaceExecutionHostId'] sleepingAgentSessionsByPaneKey?: AppState['sleepingAgentSessionsByPaneKey'] + /** Read-only input to the record projection; never emitted, since main owns this field. */ + legacyWorkerResumeFencesByPaneKey?: AppState['legacyWorkerResumeFencesByPaneKey'] clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ pendingReconnectPtyIdByTabId?: AppState['pendingReconnectPtyIdByTabId'] @@ -100,6 +102,7 @@ export const SESSION_RELEVANT_FIELDS = [ 'defaultTerminalTabsAppliedByWorktreeId', 'closedTerminalTabTombstonesByTabId', 'sleepingAgentSessionsByPaneKey', + 'legacyWorkerResumeFencesByPaneKey', 'clientHostedBrowserCloseIntentsByEnvironment', 'pendingReconnectPtyIdByTabId', 'deferredSshSessionIdsByTabId' diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts deleted file mode 100644 index d982c4e7bf38..000000000000 --- a/src/renderer/src/startup/legacy-worker-resume-fence-seed.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from './legacy-worker-resume-fence-seed' - -const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' - -function stubRecovery(result: unknown): ReturnType { - const recover = vi.fn().mockResolvedValue(result) - vi.stubGlobal('window', { - ...globalThis.window, - api: { app: { recoverLegacyWorkerTerminalsForRendererStartup: recover } } - }) - return recover -} - -afterEach(() => { - vi.unstubAllGlobals() - useAppStore.setState({ automaticResumeBlockedPaneKeys: {}, automaticResumeFenceGeneration: 0 }) -}) - -// The renderer's blocked-pane map starts empty on every boot, reload included, and is never -// persisted. Main answers the startup handshake with its committed fenced-pane set, versioned by -// its commit generation so this reply cannot override a lift that overtook it. -describe('seeding the resume fence from the renderer-startup handshake', () => { - it('blocks every pane main reports as fenced', async () => { - stubRecovery({ generation: 3, blockedPaneKeys: [PANE_KEY] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - }) - - // The snapshot is main's committed state, not the plan a pass started with, so a pane it no - // longer claims is retired here. This is the only channel that can lift a fence for a pane with - // no sleeping record after a reload, since `liftRetiredFences` can sweep only records. - it('retires a pane main no longer reports as fenced', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true, 4) - stubRecovery({ generation: 5, blockedPaneKeys: [] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) - - // A release or takeover can retire the fence after main read the reply but before it lands. The - // newer lift carries a higher commit generation, so the older reply must not walk it back. - it('drops a reply older than a lift that already arrived', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false, 9) - stubRecovery({ generation: 8, blockedPaneKeys: [PANE_KEY] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) - - // A pass whose session write threw commits nothing, so it reports the previous committed state - // at the previous generation rather than publishing keys the push channel never announced. - it('applies a reply at the generation already applied', async () => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false, 6) - stubRecovery({ generation: 6, blockedPaneKeys: [PANE_KEY] }) - - await recoverLegacyWorkerTerminalsAndSeedResumeFences() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - }) - - // A paired/web client has no wire method for the fence yet and resolves an empty snapshot. - it('tolerates a client that cannot answer the handshake', async () => { - stubRecovery(undefined) - - await expect(recoverLegacyWorkerTerminalsAndSeedResumeFences()).resolves.toBeUndefined() - - expect(useAppStore.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) -}) diff --git a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts b/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts deleted file mode 100644 index 2f36e89bc5f1..000000000000 --- a/src/renderer/src/startup/legacy-worker-resume-fence-seed.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useAppStore } from '@/store' - -/** - * Main owns the settled-worker resume fence; the renderer's `automaticResumeBlockedPaneKeys` map is - * volatile and starts empty on every renderer boot, reload included. Pulling main's committed fence - * state on the startup handshake is what makes a fence survive a reload — a once-per-process push - * cannot. The reply carries main's commit generation, so a live lift that raced past it wins. - */ -export async function recoverLegacyWorkerTerminalsAndSeedResumeFences(): Promise { - const snapshot = await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() - if (snapshot) { - useAppStore.getState().applyLegacyWorkerResumeFenceSnapshot(snapshot) - } -} diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index 52c422bd640a..987a74a8341e 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -1,4 +1,3 @@ -import { recoverLegacyWorkerTerminalsAndSeedResumeFences } from './legacy-worker-resume-fence-seed' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '../store' @@ -88,12 +87,12 @@ export async function recoverFromDegradedStartup(args: DegradedStartupRecoveryAr } try { await window.api.app.awaitFirstWindowStartupServices() - await recoverLegacyWorkerTerminalsAndSeedResumeFences() + await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() await refreshTerminalProviderSnapshotCapabilities( collectTerminalProviderSnapshotPtyIds(useAppStore.getState()) ) await reconnectPersistedTerminals(abortSignal) - await recoverLegacyWorkerTerminalsAndSeedResumeFences() + await window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() } catch (reconnectErr) { console.error('[startup] reconnectPersistedTerminals failed in error path:', reconnectErr) // Why (issue #1158): the await may have run during StrictMode teardown; re-check cancellation so a cancelled pass 1 doesn't stomp pass 2's hydration. diff --git a/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts b/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts deleted file mode 100644 index 199cf60a6a0b..000000000000 --- a/src/renderer/src/store/slices/agent-status-hydrated-resume-fence.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import type { AppState } from '../types' -import { createTestStore, makeTab } from './store-test-helpers' - -const NOW = 1_800_000_000_000 -const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' - -function fencedRecord( - origin: SleepingAgentSessionRecord['origin'], - sessionId = 'session-1' -): SleepingAgentSessionRecord { - return { - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: sessionId }, - prompt: '', - state: 'done', - capturedAt: NOW - 10_000, - updatedAt: NOW - 10_000, - origin, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } -} - -function hydrate(record: SleepingAgentSessionRecord, blockedPaneKeys?: Record) { - const store = createTestStore() - store.setState({ - tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, - sleepingAgentSessionsByPaneKey: { [PANE_KEY]: record }, - ...(blockedPaneKeys ? { automaticResumeBlockedPaneKeys: blockedPaneKeys } : {}) - } as Partial) - return store -} - -function writeStatus( - store: ReturnType, - status: { state: AgentStatusEntry['state']; prompt: string }, - sessionId = 'session-1' -): void { - store - .getState() - .setAgentStatus( - PANE_KEY, - { ...status, agentType: 'claude' } as never, - undefined, - { updatedAt: NOW }, - { tabId: 'tab-1', worktreeId: 'wt-1' } as never, - { providerSession: { key: 'session_id', id: sessionId } } - ) -} - -const fenceOf = (store: ReturnType): string | undefined => - store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy - -// The fence's durable home is the record. `automaticResumeBlockedPaneKeys` starts empty on every -// renderer boot and is not persisted, so deriving the rebuilt record's flag from that map alone -// erased a hydrated fence on the first status write — and worktree activation then relaunched the -// settled worker with `--resume` over its still-live PTY. -describe('a hydrated resume fence and the volatile blocked-pane map', () => { - it('is not restored into the blocked-pane map by hydration alone', () => { - expect( - hydrate(fencedRecord('worktree-sleep')).getState().automaticResumeBlockedPaneKeys - ).toEqual({}) - }) - - it('survives a status write with the blocked-pane map empty', () => { - const store = hydrate(fencedRecord('worktree-sleep')) - - writeStatus(store, { state: 'done', prompt: 'worker task' }) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - it('survives a live-origin record being rebuilt when the pane state changes', () => { - const store = hydrate(fencedRecord('live')) - - writeStatus(store, { state: 'working', prompt: 'user takes over' }) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - it('survives a manual worktree sleep that recaptures the pane', () => { - const store = hydrate(fencedRecord('live')) - - store.getState().captureSleepingAgentSessionsByWorktree('wt-1', [PANE_KEY]) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - // Control: the pre-existing population, where the fence arrived while the tab was still open. - it('survives when only the blocked-pane map holds it', () => { - const store = hydrate( - { ...fencedRecord('worktree-sleep'), automaticResumeBlockedBy: undefined }, - { [PANE_KEY]: true } - ) - - writeStatus(store, { state: 'done', prompt: 'worker task' }) - - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - // A new provider session in the pane is new work, not the fenced dispatch's work. - it('is not carried onto a record for a different provider session', () => { - const store = hydrate(fencedRecord('live')) - - writeStatus(store, { state: 'working', prompt: 'a fresh session' }, 'session-2') - - expect(fenceOf(store)).toBeUndefined() - }) - - it('is dropped from both homes when the runtime lifts it', () => { - const store = hydrate(fencedRecord('worktree-sleep'), { [PANE_KEY]: true }) - - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) - writeStatus(store, { state: 'done', prompt: 'worker task' }) - - expect(fenceOf(store)).toBeUndefined() - expect(store.getState().automaticResumeBlockedPaneKeys).toEqual({}) - }) -}) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts index 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..7e9b40b951b1 100644 --- a/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts +++ b/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts @@ -1,14 +1,15 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import type { AppState } from '../types' +import { buildSleepingAgentSessionData } from '@/lib/workspace-session-sleeping-agents' import { createTestStore, makeTab } from './store-test-helpers' const NOW = 1_800_000_000_000 const PANE_KEY = 'tab-1:leaf-1' -function liveWorkerEntry(): AgentStatusEntry { +function liveWorkerEntry(state: AgentStatusEntry['state'] = 'working'): AgentStatusEntry { return { - state: 'working', + state, prompt: 'finish the task', updatedAt: NOW, stateStartedAt: NOW, @@ -21,40 +22,93 @@ function liveWorkerEntry(): AgentStatusEntry { } } -// The worker settles while its tab is still open, so there is no sleeping record to stamp; the -// record is minted on close and used to arrive unfenced, respawning settled work on reopen. -describe('a resume fence that arrives before the sleeping record exists', () => { - it('carries the block onto the record minted after the tab closes', () => { - const store = createTestStore() - store.setState({ - tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, - agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() } - } as Partial) +function fencedStore() { + const store = createTestStore() + store.setState({ + tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, + agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() }, + legacyWorkerResumeFencesByPaneKey: { [PANE_KEY]: true } + } as Partial) + return store +} - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined() +// The worker settles while its tab is still open, so there is no sleeping record to carry a flag. +// The fence is runtime-authored session state keyed by pane, so it exists for that pane regardless, +// and no renderer writer has to remember to preserve it. +describe('a resume fence for a pane with no sleeping record', () => { + it('survives every record rebuild, because no record carries it', () => { + const store = fencedStore() store.getState().captureAllSleepingAgentSessions('quit') + store + .getState() + .setAgentStatus( + PANE_KEY, + { state: 'done', prompt: 'finish the task', agentType: 'codex' } as never, + undefined, + { updatedAt: NOW + 1 }, + { tabId: 'tab-1', worktreeId: 'wt-1' } as never, + { providerSession: { key: 'session_id', id: 'session-1' } } + ) + + expect(store.getState().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.getState().setLegacyWorkerResumeFences({}) + + expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) + }) + + // Hydration installs the runtime's set rather than merging it, so a fence retired while this + // renderer was down does not survive the read that is supposed to replace it. + it('replaces a stale local fence on hydration rather than merging', () => { + const store = fencedStore() - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject({ - paneKey: PANE_KEY, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) + store.getState().hydrateWorkspaceSession( + { + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + legacyWorkerResumeFencesByPaneKey: {} + }, + undefined + ) + + 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) + // Older clients read the fence off the record, so it is projected on the way out — at one site, + // derived from the runtime's set rather than carried through the store. + it('is projected onto outgoing records for older clients', () => { + const store = fencedStore() + store.getState().captureAllSleepingAgentSessions('quit') + const snapshot = store.getState() + expect( + snapshot.sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + ).toBeUndefined() + + const projected = buildSleepingAgentSessionData(snapshot) + + expect(projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( + 'legacy-orchestration-worker' + ) + }) - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) + it('strips a stale projection once the runtime retires the fence', () => { + const store = fencedStore() store.getState().captureAllSleepingAgentSessions('quit') + store.getState().setLegacyWorkerResumeFences({}) + + const projected = buildSleepingAgentSessionData(store.getState()) expect( - store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy + projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy ).toBeUndefined() }) }) diff --git a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts index 1f6578df0a20..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 @@ -20,7 +20,7 @@ import { } from './agent-status-pane-key-tab-binding' import { removePaneKeys } from './agent-status-pane-keyed-records' import { registryEntryMatchesStatus } from './agent-status-launch-config' -import { carriesAutomaticResumeBlock, copyLaunchConfig } from './agent-status-sleeping-records' +import { copyLaunchConfig } from './agent-status-sleeping-records' export function createAgentStatusProviderSessionActions( runtime: AgentStatusRuntime @@ -113,9 +113,6 @@ export function createAgentStatusProviderSessionActions( ? { connectionId: existingRecord.connectionId } : {}), ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(carriesAutomaticResumeBlock(s, { paneKey, agent, providerSession }) - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } - : {}), ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined ? { interrupted: existingRecord.interrupted } : {}), diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index 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 efe469d5b586..c2c740af02b8 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -1,5 +1,5 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import type { AppState } from '../types' +import { sameFenceSet } from './legacy-worker-resume-fences' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection' @@ -15,59 +15,6 @@ import { getLaunchConfigForEntry } from './agent-status-launch-config' import { findAgentPaneWorktreeId } from './agent-status-pane-key-tab-binding' import { isCompletedPiCompatibleAgentWithLiveRecoveryRecord } from '@/lib/live-resume-anchor-record' -/** The single writer for the resume fence's two homes. The pane-key map is tracked even with no - * record, because a worker settled while its tab was open is fenced before the record is minted; - * the record is the durable home and must move with it. `generation` is main's commit counter, so - * a startup reply that lost a race with a later lift cannot walk the newer state backwards. */ -function applyFenceToPanes( - state: AppState, - changes: ReadonlyMap, - generation: number | undefined -): Partial | AppState { - let paneKeys = state.automaticResumeBlockedPaneKeys - let records = state.sleepingAgentSessionsByPaneKey - for (const [paneKey, blocked] of changes) { - if ((paneKeys[paneKey] === true) !== blocked) { - if (paneKeys === state.automaticResumeBlockedPaneKeys) { - paneKeys = { ...paneKeys } - } - if (blocked) { - paneKeys[paneKey] = true - } else { - delete paneKeys[paneKey] - } - } - const current = records[paneKey] - const recordBlocked = current?.automaticResumeBlockedBy === 'legacy-orchestration-worker' - if (!current || recordBlocked === blocked) { - continue - } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy - } - if (records === state.sleepingAgentSessionsByPaneKey) { - records = { ...records } - } - records[paneKey] = next - } - const nextGeneration = Math.max(state.automaticResumeFenceGeneration, generation ?? 0) - if ( - paneKeys === state.automaticResumeBlockedPaneKeys && - records === state.sleepingAgentSessionsByPaneKey && - nextGeneration === state.automaticResumeFenceGeneration - ) { - return state - } - return { - automaticResumeBlockedPaneKeys: paneKeys, - sleepingAgentSessionsByPaneKey: records, - automaticResumeFenceGeneration: nextGeneration - } -} - export function createAgentStatusRecoveryActions( runtime: AgentStatusRuntime ): Pick< @@ -76,8 +23,7 @@ export function createAgentStatusRecoveryActions( | 'captureAllSleepingAgentSessions' | 'clearSleepingAgentSession' | 'clearSleepingAgentSessionsByPaneKey' - | 'setSleepingAgentAutomaticResumeBlocked' - | 'applyLegacyWorkerResumeFenceSnapshot' + | 'setLegacyWorkerResumeFences' | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { @@ -164,30 +110,12 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked, generation) => { - set((s) => applyFenceToPanes(s, new Map([[paneKey, blocked]]), generation)) - }, - - // Why replace rather than merge: main hands over its whole committed fence state, so a pane it - // no longer claims is retired. An unreadable plan does not reach this — a pass that commits - // nothing leaves the previous committed state and generation in place. - applyLegacyWorkerResumeFenceSnapshot: (snapshot) => { - set((s) => { - if (snapshot.generation < s.automaticResumeFenceGeneration) { - return s - } - const blockedPaneKeys = new Set(snapshot.blockedPaneKeys) - const changes = new Map() - for (const paneKey of blockedPaneKeys) { - changes.set(paneKey, true) - } - for (const paneKey of Object.keys(s.automaticResumeBlockedPaneKeys)) { - if (!blockedPaneKeys.has(paneKey)) { - changes.set(paneKey, false) - } - } - return applyFenceToPanes(s, changes, snapshot.generation) - }) + setLegacyWorkerResumeFences: (fences) => { + set((s) => + sameFenceSet(s.legacyWorkerResumeFencesByPaneKey, fences) + ? s + : { legacyWorkerResumeFencesByPaneKey: fences } + ) }, clearSleepingAgentSessionsByWorktree: (worktreeId) => { diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts deleted file mode 100644 index 986a88579b7e..000000000000 --- a/src/renderer/src/store/slices/agent-status-recovery-equivalence.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import { - recoveryRecordMatches, - sleepingRecordsEquivalentIgnoringCaptureTime -} from './agent-status-recovery-equivalence' - -const PANE_KEY = 'tab-1:11111111-2222-4333-8444-555555555555' - -function record(overrides: Partial = {}): SleepingAgentSessionRecord { - return { - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'claude', - providerSession: { key: 'session_id', id: 'session-1' }, - prompt: '', - state: 'done', - capturedAt: 1_000, - updatedAt: 2_000, - origin: 'live', - ...overrides - } -} - -const fenced = record({ automaticResumeBlockedBy: 'legacy-orchestration-worker' }) - -// Both predicates are equality shortcuts that suppress a write. Ignoring the fence let a rebuild -// that had dropped it count as equal, so the fenced record was quietly kept out of the update path -// that would have restored the flag — and out of the write that persists it. -describe('resume-fence significance in sleeping-record equality', () => { - it('rejects a rebuild that dropped the fence as a recovery match', () => { - expect(recoveryRecordMatches(fenced, record())).toBe(false) - }) - - it('rejects a rebuild that added the fence as a recovery match', () => { - expect(recoveryRecordMatches(record(), fenced)).toBe(false) - }) - - it('rejects a capture that dropped the fence as capture-time equivalent', () => { - expect(sleepingRecordsEquivalentIgnoringCaptureTime(fenced, record())).toBe(false) - }) - - it('rejects a capture that added the fence as capture-time equivalent', () => { - expect(sleepingRecordsEquivalentIgnoringCaptureTime(record(), fenced)).toBe(false) - }) - - // Controls: a stable fence must stay equal, or every pass would rewrite an unchanged record. - it('keeps two fenced records equal', () => { - expect(recoveryRecordMatches(fenced, { ...fenced, capturedAt: 9_999 })).toBe(true) - expect( - sleepingRecordsEquivalentIgnoringCaptureTime(fenced, { ...fenced, capturedAt: 9_999 }) - ).toBe(true) - }) - - it('keeps two unfenced records equal', () => { - expect(recoveryRecordMatches(record(), record({ capturedAt: 9_999 }))).toBe(true) - }) -}) diff --git a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts index 1fa2baf79d9b..660d104abf6f 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-equivalence.ts @@ -43,7 +43,6 @@ export function sleepingRecordsEquivalentIgnoringCaptureTime( existing.lastAssistantMessage === next.lastAssistantMessage && existing.interrupted === next.interrupted && existing.origin === next.origin && - existing.automaticResumeBlockedBy === next.automaticResumeBlockedBy && launchConfigsEqual(existing.launchConfig, next.launchConfig) ) } @@ -55,10 +54,8 @@ export function recoveryRecordMatches( if (!existing) { return false } - // Why: completion or interruption must replace a pre-status working checkpoint. The resume fence - // is significant here: a rebuild that dropped it must never be accepted as an equal record. + // Why: completion or interruption must replace a pre-status working checkpoint. return ( - existing.automaticResumeBlockedBy === next.automaticResumeBlockedBy && existing.origin === next.origin && existing.agent === next.agent && existing.worktreeId === next.worktreeId && diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 8522e2bb5bea..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, @@ -19,51 +18,6 @@ export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAge } } -/** - * The record is the fence's durable home. `automaticResumeBlockedPaneKeys` only covers the window - * between a worker settling with its tab still open and the record being minted, and main re-seeds - * it on every renderer start — so a rebuild must never drop a flag the record already carries. - * Session identity gates the carry-over: a new provider session is new work, not fenced work. - */ -export function carriesAutomaticResumeBlock( - state: Pick, - next: Pick -): boolean { - if (state.automaticResumeBlockedPaneKeys?.[next.paneKey]) { - return true - } - const previous = state.sleepingAgentSessionsByPaneKey?.[next.paneKey] - return ( - previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - previous.agent === next.agent && - agentProviderSessionsEqual(next.agent, previous.providerSession, next.providerSession) - ) -} - -/** Async stops (manual sleep, hibernation rollback) commit a capture taken before the await, so a - * fence delivered or retired during the stop has to be re-read from state at commit time. */ -export function withCurrentAutomaticResumeBlock( - state: Pick, - records: Readonly> -): Record { - const next: Record = {} - for (const [paneKey, record] of Object.entries(records)) { - const blocked = carriesAutomaticResumeBlock(state, record) - if (blocked === (record.automaticResumeBlockedBy === 'legacy-orchestration-worker')) { - next[paneKey] = record - continue - } - const updated = { ...record } - if (blocked) { - updated.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete updated.automaticResumeBlockedBy - } - next[paneKey] = updated - } - return next -} - export function sleepingRecordFromEntry(args: { state: AppState entry: AgentStatusEntry @@ -104,14 +58,7 @@ export function sleepingRecordFromEntry(args: { : {}), ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), ...(args.entry.interrupted ? { interrupted: true } : {}), - ...(args.origin ? { origin: args.origin } : {}), - ...(carriesAutomaticResumeBlock(args.state, { - paneKey: args.entry.paneKey, - agent, - providerSession: args.entry.providerSession - }) - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } - : {}) + ...(args.origin ? { origin: args.origin } : {}) } } diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index 1e4d11c4d7a3..d7b3edccfd60 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -25,7 +25,6 @@ import type { MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import type { - LegacyWorkerResumeFenceSnapshot, ResumableTuiAgent, SleepingAgentLaunchConfig, SleepingAgentSessionRecord @@ -52,13 +51,9 @@ export type AgentStatusSlice = { /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ sleepingAgentSessionsByPaneKey: Record - /** Panes the runtime fenced against automatic resume. Held separately because a worker can - * settle while its tab is open, before the sleeping record the fence belongs on exists. */ - automaticResumeBlockedPaneKeys: Record - - /** Highest main fence-commit generation this renderer has applied, so a startup reply that lost - * a race with a later lift is dropped instead of reapplying a retired fence. */ - automaticResumeFenceGeneration: number + /** Runtime-authored: panes the orchestration authority fenced against automatic resume. Read + * from the workspace session and refreshed on main's invalidation ping; never written here. */ + legacyWorkerResumeFencesByPaneKey: Record /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ agentLaunchConfigByPaneKey: Record @@ -167,13 +162,8 @@ export type AgentStatusSlice = { captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: ( - paneKey: string, - blocked: boolean, - generation?: number - ) => void - /** Replace the blocked-pane set with main's committed fence state from the startup handshake. */ - applyLegacyWorkerResumeFenceSnapshot: (snapshot: LegacyWorkerResumeFenceSnapshot) => void + /** Install main's fenced-pane set wholesale. The only writer, and level-triggered. */ + setLegacyWorkerResumeFences: (fences: Record) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 10abccc5d618..8939f0506d59 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -51,10 +51,7 @@ export { collectSleepingAgentSessionRecordsForWorktree, collectHibernatedCompletionEvidenceForWorktree } from './agent-status-recovery-collection' -export { - removeSleepingRecordsReplacedByManualWorktreeSleep, - withCurrentAutomaticResumeBlock -} from './agent-status-sleeping-records' +export { removeSleepingRecordsReplacedByManualWorktreeSleep } from './agent-status-sleeping-records' export { buildAgentStatusTabPrefixDropPatch, type AgentStatusTabPrefixDropState @@ -103,8 +100,7 @@ export const createAgentStatusSlice: StateCreator, next: Record): boolean { + const nextKeys = Object.keys(next) + return ( + Object.keys(current).length === nextKeys.length && + nextKeys.every((paneKey) => current[paneKey] === true) + ) +} diff --git a/src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts b/src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts deleted file mode 100644 index ab4f28b401d6..000000000000 --- a/src/renderer/src/store/slices/store-sleep-fence-delivered-during-stop.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import type * as AgentStatusModule from '@/lib/agent-status' -import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' -import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' -import { shutdownBufferCaptures } from '@/components/terminal-pane/shutdown-buffer-captures' -import { - applySleepRuntimeRpcDefault, - createStoreCascadesMockApi -} from './store-cascades-test-harness' - -const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) -const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } -})) - -vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ - restorePtyDataHandlersAfterFailedShutdown: mockRestorePtyDataHandlersAfterFailedShutdown, - unregisterPtyDataHandlers: mockUnregisterPtyDataHandlers -})) - -vi.mock('@/lib/agent-status', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } -}) - -const mockApi = createStoreCascadesMockApi() - -const WORKTREE_ID = 'repo1::/path/wt1' -const LEAF_ID = '11111111-1111-4111-8111-111111111111' -const PANE_KEY = `tab-1:${LEAF_ID}` - -function storeWithAgentPane(state: 'working' | 'done') { - const store = createTestStore() - seedStore(store, { - worktreesByRepo: { - repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] - }, - tabsByWorktree: { - [WORKTREE_ID]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE_ID, ptyId: 'pty-agent' })] - }, - ptyIdsByTabId: { 'tab-1': ['pty-agent'] }, - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_ID }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: 'pty-agent' } - } - } - }) - store - .getState() - .setAgentStatus( - PANE_KEY, - { state, prompt: 'worker', agentType: 'claude' }, - 'Claude', - { updatedAt: 2000, stateStartedAt: 1000 }, - { tabId: 'tab-1', worktreeId: WORKTREE_ID }, - { providerSession: { key: 'session_id', id: 'session-1' } } - ) - return store -} - -const fenceOf = (store: ReturnType): string | undefined => - store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]?.automaticResumeBlockedBy - -// Both stop paths capture the record before awaiting the kill and commit that capture afterwards. -// A fence delivered while the kill is in flight lands on the live record, and committing the older -// capture verbatim erased it — leaving a settled worker automatically resumable again. -describe('a resume fence delivered while a pane stop is in flight', () => { - beforeEach(() => { - vi.clearAllMocks() - clearRuntimeCompatibilityCacheForTests() - mockApi.pty.kill.mockResolvedValue(undefined) - applySleepRuntimeRpcDefault(mockApi) - shutdownBufferCaptures.clear() - }) - - it('survives the manual-sleep capture committed after the stop', async () => { - const store = storeWithAgentPane('working') - mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - }) - - await store.getState().shutdownWorktreeTerminals(WORKTREE_ID, { keepIdentifiers: true }) - - expect(mockApi.pty.kill).toHaveBeenCalled() - expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - it('survives the rollback of a hibernation stop that threw', async () => { - const store = storeWithAgentPane('done') - mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - throw new Error('kill_failed') - }) - - await expect( - store.getState().shutdownCompletedAgentPaneForHibernation(WORKTREE_ID, { - paneKey: PANE_KEY, - tabId: 'tab-1', - leafId: LEAF_ID, - ptyId: 'pty-agent' - }) - ).rejects.toThrow('kill_failed') - - expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBe(true) - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - }) - - // Control: the commit re-reads state, so a lift that lands during the stop is honoured too — the - // writer follows current authority rather than pinning whatever the capture happened to hold. - it('drops a fence the runtime retires during the stop', async () => { - const store = storeWithAgentPane('working') - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - expect(fenceOf(store)).toBe('legacy-orchestration-worker') - mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) - }) - - await store.getState().shutdownWorktreeTerminals(WORKTREE_ID, { keepIdentifiers: true }) - - expect(store.getState().automaticResumeBlockedPaneKeys[PANE_KEY]).toBeUndefined() - expect(fenceOf(store)).toBeUndefined() - }) -}) diff --git a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts index 7606aa1aca5b..6d5df4eab50e 100644 --- a/src/renderer/src/store/terminals/terminal-pane-hibernation.ts +++ b/src/renderer/src/store/terminals/terminal-pane-hibernation.ts @@ -9,8 +9,7 @@ import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { collectHibernatedCompletionEvidenceForWorktree, - collectSleepingAgentSessionRecordsForWorktree, - withCurrentAutomaticResumeBlock + collectSleepingAgentSessionRecordsForWorktree } from '../slices/agent-status' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { equalStringSets, sortedUniquePtyIds } from './terminal-pty-identities' @@ -70,8 +69,7 @@ export function createTerminalPaneHibernationActions( const current = get() if ( !isAutomaticHibernationAllowed({ - record: current.sleepingAgentSessionsByPaneKey[opts.paneKey], - automaticResumeBlockedPaneKeys: current.automaticResumeBlockedPaneKeys, + legacyWorkerResumeFencesByPaneKey: current.legacyWorkerResumeFencesByPaneKey, paneKey: opts.paneKey }) ) { @@ -106,11 +104,8 @@ export function createTerminalPaneHibernationActions( delete next[ptyId] } const nextSleeping = { ...s.sleepingAgentSessionsByPaneKey } - // Why re-read: a fence can arrive or retire while the kill is in flight, and restoring - // the pre-kill record verbatim would roll that back with it. - const restored = withCurrentAutomaticResumeBlock(s, replacedSleepingRecords) for (const key of sleepingRecordKeys) { - const replaced = restored[key] + const replaced = replacedSleepingRecords[key] if (replaced) { nextSleeping[key] = replaced } else { @@ -127,7 +122,7 @@ export function createTerminalPaneHibernationActions( }, sleepingAgentSessionsByPaneKey: { ...s.sleepingAgentSessionsByPaneKey, - ...withCurrentAutomaticResumeBlock(s, sleepingAgentSessionRecords) + ...sleepingAgentSessionRecords } })) if (expectedRuntimePtyIds.length > 0) { diff --git a/src/renderer/src/store/terminals/terminal-shutdown-state.ts b/src/renderer/src/store/terminals/terminal-shutdown-state.ts index 098c8ddb962f..389c8166a18e 100644 --- a/src/renderer/src/store/terminals/terminal-shutdown-state.ts +++ b/src/renderer/src/store/terminals/terminal-shutdown-state.ts @@ -8,7 +8,6 @@ import { } from '@/components/terminal-pane/pty-shutdown-exit-deferral' import { removeSleepingRecordsReplacedByManualWorktreeSleep, - withCurrentAutomaticResumeBlock, type AgentStatusWorktreeShutdownReason, type RetainedAgentEntry } from '../slices/agent-status' @@ -160,9 +159,7 @@ export function commitTerminalShutdownState({ return { sleepingAgentSessionsByPaneKey: { ...base, - // Why re-read: the capture above predates the stop, and a fence can arrive or retire - // while the kill is in flight; committing the stale capture would erase it. - ...withCurrentAutomaticResumeBlock(state, sleepingAgentSessionRecords) + ...sleepingAgentSessionRecords } } }) diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index 3ea374a54755..2c64e76a52c4 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -23,6 +23,7 @@ export type WorkspaceHydrationPatch = Pick< | 'closedTerminalTabTombstonesByTabId' | 'automaticAgentResumeClaimsByTabId' | 'sleepingAgentSessionsByPaneKey' + | 'legacyWorkerResumeFencesByPaneKey' | 'pendingReconnectWorktreeIds' | 'pendingReconnectTabByWorktree' | 'pendingReconnectPtyIdByTabId' @@ -197,6 +198,12 @@ export function targetScopedWorkspaceHydrationPatch( targetTabIds ), sleepingAgentSessionsByPaneKey, + // Why passed through whole: runtime-authored and keyed by pane, so there is no workspace key + // for replaceHydratedRecordKeys to scope, and a scoped rehydration must not drop other hosts'. + legacyWorkerResumeFencesByPaneKey: { + ...state.legacyWorkerResumeFencesByPaneKey, + ...hydrated.legacyWorkerResumeFencesByPaneKey + }, pendingReconnectWorktreeIds: [ ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 39be3c94f0fb..6f0e9d189a7d 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -194,6 +194,9 @@ export function createWorkspaceTerminalHydrationActions( closedTerminalTabTombstonesByTabId: session.closedTerminalTabTombstonesByTabId ?? {}, automaticAgentResumeClaimsByTabId: {}, sleepingAgentSessionsByPaneKey, + // Runtime-authored: installed exactly as the session holds it, never merged with local + // state, because main is its only author and this read is the whole truth. + legacyWorkerResumeFencesByPaneKey: session.legacyWorkerResumeFencesByPaneKey ?? {}, pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index 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 f6db82659e80..771b946e10f1 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -35,10 +35,9 @@ export function createWebAppApi(): Partial { awaitFirstWindowStartupServices: () => Promise.resolve(), awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), - // No wire method carries the worker resume fence yet, so a paired/web client cannot learn - // about a fenced pane that has no sleeping record. Tracked as a follow-up. - recoverLegacyWorkerTerminalsForRendererStartup: () => - Promise.resolve({ generation: 0, blockedPaneKeys: [] }), + recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), + // The fences arrive with the host session it hydrates, so there is nothing to re-read here. + getLegacyWorkerResumeFences: () => Promise.resolve({}), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), // The web client cannot inspect local Mission Control shortcuts. diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 2d6792432e40..3e763fc20a6f 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -73,15 +73,6 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } -/** The committed fenced-pane set main hands a starting renderer. The renderer's own blocked-pane - * map is volatile — it starts empty on every renderer boot — so it must be re-derived here, not - * pushed. `generation` counts main's fence commits: it orders this reply against the live pushes - * so a reply that lost a race with a later lift is dropped instead of reapplying a retired fence. */ -export type LegacyWorkerResumeFenceSnapshot = { - generation: number - blockedPaneKeys: string[] -} - const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) const PROVIDER_SESSION_ID_MAX_LENGTH = 512 diff --git a/src/shared/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts index 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-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 7ad18c6b2463..381495852cec 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -195,6 +195,26 @@ export async function listRuntimeTerminals( return (await client.call('terminal.list')).result.terminals } +/** The runtime-authored fence, read from the profile's persisted session. */ +export function readPersistedWorkerResumeFence( + userDataDir: string, + paneKey: string +): true | undefined { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + if (!existsSync(dataPath)) { + return undefined + } + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { legacyWorkerResumeFencesByPaneKey?: Record } + } + return data.workspaceSession?.legacyWorkerResumeFencesByPaneKey?.[paneKey] +} + export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { const dataPath = path.join( userDataDir, diff --git a/tests/e2e/settled-worker-resume-fence-restart.spec.ts b/tests/e2e/settled-worker-resume-fence-restart.spec.ts index 024068ffd983..d8abe6a9761c 100644 --- a/tests/e2e/settled-worker-resume-fence-restart.spec.ts +++ b/tests/e2e/settled-worker-resume-fence-restart.spec.ts @@ -18,7 +18,7 @@ import { listRuntimeTerminals, readCompletedWorkerDispatchCapability, readCompletedWorkerLedger, - readPersistedWorkerRecoveryRecord, + readPersistedWorkerResumeFence, seedCurrentCodexTranscript } from './helpers/completed-worker-retirement-fixture' import { RuntimeClient } from '../../src/cli/runtime-client' @@ -263,27 +263,27 @@ test('a settled orchestration worker keeps its resume fence across restart and r { orchestrationCapability: dispatchCapability } ) expect(completed.result.message.type).toBe('worker_done') - // The settlement sweep stamps the resume fence on the renderer's record before the tab closes. + // The settlement sweep writes the runtime-authored fence set, and main's invalidation ping + // makes the live renderer re-read it while the tab is still open. await expect .poll( () => first.page.evaluate( (paneKey) => - window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] - ?.automaticResumeBlockedBy ?? null, + window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, workerPaneKey ), { timeout: 30_000, message: 'settled worker pane was never fenced' } ) - .toBe('legacy-orchestration-worker') + .toBe(true) await session.close(firstApp) firstApp = null await expect - .poll(() => readPersistedWorkerRecoveryRecord(session.userDataDir, workerPaneKey), { - message: 'the settled worker record never reached disk carrying its fence' + .poll(() => readPersistedWorkerResumeFence(session.userDataDir, workerPaneKey), { + message: 'the runtime-authored fence never reached disk' }) - .toMatchObject({ automaticResumeBlockedBy: 'legacy-orchestration-worker' }) + .toBe(true) expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual([]) const second = await session.launch() @@ -308,9 +308,9 @@ test('a settled orchestration worker keeps its resume fence across restart and r ) ).toBe(true) - // The record is the fence's durable home. Deriving it from the renderer's volatile blocked-pane - // map alone let the first status write after any restart erase it, and worktree activation then - // relaunched the settled worker with `--resume` over its still-live PTY (#16904 regression). + // No record writer can erase the fence, because no record carries it: the pre-fix bug was a + // rebuilt record dropping the flag, after which worktree activation relaunched the settled + // worker with `--resume` over its still-live PTY (#16904). Drive the same status write. await second.page.evaluate( ({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => { window.__store?.getState().setAgentStatus( @@ -339,16 +339,14 @@ test('a settled orchestration worker keeps its resume fence across restart and r ) expect( await second.page.evaluate( - (paneKey) => - window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] - ?.automaticResumeBlockedBy ?? null, + (paneKey) => window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, workerPaneKey ), 'a status write after restart must not erase the fence' - ).toBe('legacy-orchestration-worker') + ).toBe(true) - // A renderer reload starts `automaticResumeBlockedPaneKeys` empty, so main must hand the fenced - // pane set back on the startup handshake; a once-per-process push never survives the reload. + // A renderer reload rebuilds the store from the session, and the fence is a field of it, so it + // comes back with ordinary hydration rather than a handshake reply the reload could lose. await second.page.reload() await waitForSessionReady(second.page) await expect @@ -356,7 +354,7 @@ test('a settled orchestration worker keeps its resume fence across restart and r () => second.page.evaluate( (paneKey) => - window.__store?.getState().automaticResumeBlockedPaneKeys[paneKey] === true, + window.__store?.getState().legacyWorkerResumeFencesByPaneKey[paneKey] === true, workerPaneKey ), { timeout: 60_000, message: 'the reloaded renderer never re-seeded the resume fence' } From 57d0ef6ab168984ef668bede8698bc50f95586e8 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 18:48:42 -0400 Subject: [PATCH 4/7] fix: close settled-worker fence publication and hydration gaps --- config/reliability-gates.jsonc | 78 ++++ .../legacy-worker-session-projection.ts | 25 ++ .../loading-store/review-r2-store.test.ts | 249 +++++++++++ .../loading-store/session-host-partitions.ts | 7 +- .../orca-runtime-fence-automation-owner.ts | 6 +- src/main/runtime/review-r2-compat.test.ts | 388 ++++++++++++++++++ .../runtime/review-r2-partial-write.test.ts | 57 +++ src/main/runtime/review-r2-round1.test.ts | 93 +++++ ...cy-worker-terminal-recovery-persistence.ts | 9 +- .../review-r2-recordless.test.ts | 17 + .../pty-connection/sleeping-record-access.ts | 5 +- .../lib/legacy-worker-resume-fence-refresh.ts | 22 +- .../src/lib/review-r2-old-host.test.ts | 59 +++ .../lib/review-r2-refresh-hydration.test.ts | 39 ++ .../lib/runtime-session-application.test.ts | 138 +++++++ .../src/lib/runtime-session-application.ts | 98 +++++ .../lib/workspace-session-host-hydration.ts | 34 +- .../src/lib/workspace-session-patch.ts | 7 +- .../workspace-session-relevant-fields.test.ts | 1 - .../lib/workspace-session-sleeping-agents.ts | 32 +- .../src/lib/workspace-session-snapshot.ts | 87 ++++ src/renderer/src/lib/workspace-session.ts | 94 +---- ...agent-status-open-tab-resume-fence.test.ts | 11 +- .../store/slices/review-r2-hydration.test.ts | 29 ++ .../src/store/slices/review-r2-writer.test.ts | 117 ++++++ .../workspace-terminal-hydration-patch.ts | 7 - .../terminals/workspace-terminal-hydration.ts | 13 +- src/shared/workspace-session-resume-fences.ts | 17 + 28 files changed, 1576 insertions(+), 163 deletions(-) create mode 100644 src/main/persistence/legacy-worker-session-projection.ts create mode 100644 src/main/persistence/loading-store/review-r2-store.test.ts create mode 100644 src/main/runtime/review-r2-compat.test.ts create mode 100644 src/main/runtime/review-r2-partial-write.test.ts create mode 100644 src/main/runtime/review-r2-round1.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.test.ts create mode 100644 src/renderer/src/lib/review-r2-old-host.test.ts create mode 100644 src/renderer/src/lib/review-r2-refresh-hydration.test.ts create mode 100644 src/renderer/src/lib/runtime-session-application.test.ts create mode 100644 src/renderer/src/lib/runtime-session-application.ts create mode 100644 src/renderer/src/lib/workspace-session-snapshot.ts create mode 100644 src/renderer/src/store/slices/review-r2-hydration.test.ts create mode 100644 src/renderer/src/store/slices/review-r2-writer.test.ts create mode 100644 src/shared/workspace-session-resume-fences.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 899914339c76..0f6d0405dd00 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,84 @@ } }, "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 publication/read-boundary tests; each of the six round-3 fix mutations must fail. 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-application.test.ts src/main/persistence/loading-store/review-r2-store.test.ts src/main/runtime/review-r2-compat.test.ts src/main/runtime/review-r2-partial-write.test.ts src/main/runtime/review-r2-round1.test.ts src/renderer/src/lib/review-r2-old-host.test.ts src/renderer/src/lib/review-r2-refresh-hydration.test.ts src/renderer/src/store/slices/review-r2-writer.test.ts src/renderer/src/store/slices/review-r2-hydration.test.ts src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.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-application.test.ts", + "src/main/persistence/loading-store/review-r2-store.test.ts", + "src/main/runtime/review-r2-compat.test.ts", + "src/main/runtime/review-r2-partial-write.test.ts", + "src/main/runtime/review-r2-round1.test.ts", + "src/renderer/src/lib/review-r2-old-host.test.ts", + "src/renderer/src/lib/review-r2-refresh-hydration.test.ts", + "src/renderer/src/store/slices/review-r2-writer.test.ts", + "src/renderer/src/store/slices/review-r2-hydration.test.ts", + "src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.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" + ] + } + ], + "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/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/review-r2-store.test.ts b/src/main/persistence/loading-store/review-r2-store.test.ts new file mode 100644 index 000000000000..b33ca30a3e56 --- /dev/null +++ b/src/main/persistence/loading-store/review-r2-store.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/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/runtime/orca-runtime-fence-automation-owner.ts b/src/main/runtime/orca-runtime-fence-automation-owner.ts index 6142ef986404..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, @@ -179,7 +180,10 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg const hostIds = store?.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] const fences: Record = {} for (const hostId of hostIds) { - Object.assign(fences, store?.getWorkspaceSession?.(hostId)?.legacyWorkerResumeFencesByPaneKey) + const session = store?.getWorkspaceSession?.(hostId) + if (session) { + Object.assign(fences, readWorkspaceSessionResumeFences(session)) + } } return fences } diff --git a/src/main/runtime/review-r2-compat.test.ts b/src/main/runtime/review-r2-compat.test.ts new file mode 100644 index 000000000000..e35db05281fb --- /dev/null +++ b/src/main/runtime/review-r2-compat.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/review-r2-partial-write.test.ts b/src/main/runtime/review-r2-partial-write.test.ts new file mode 100644 index 000000000000..555a7fe41347 --- /dev/null +++ b/src/main/runtime/review-r2-partial-write.test.ts @@ -0,0 +1,57 @@ +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() +}) diff --git a/src/main/runtime/review-r2-round1.test.ts b/src/main/runtime/review-r2-round1.test.ts new file mode 100644 index 000000000000..57cd94f49b7f --- /dev/null +++ b/src/main/runtime/review-r2-round1.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('round1 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('round1 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-terminal-recovery-persistence.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts index e89e7f9eaf26..0c4c20cc4529 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts @@ -81,7 +81,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { fencedByHost.set(hostId, fenced) } } - let changed = false try { for (const [hostId, fenced] of fencedByHost) { const current = store.getWorkspaceSession(hostId) @@ -89,16 +88,13 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { continue } store.setWorkspaceSession({ ...current, legacyWorkerResumeFencesByPaneKey: fenced }, hostId) - changed = true + this.notifyFenceChanged?.() } } catch (error) { - // A failed write publishes nothing, and the next pass rewrites the same level. + // Earlier host commits have already published; retry only the remaining changes. console.warn('[orchestration] failed to write legacy worker resume fences', error) return plan } - if (changed) { - this.notifyFenceChanged?.() - } return plan } @@ -224,6 +220,7 @@ function sameFenceSet( const currentKeys = Object.keys(current ?? {}) const nextKeys = Object.keys(next) return ( + current !== undefined && currentKeys.length === nextKeys.length && nextKeys.every((paneKey) => current?.[paneKey] === true) ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.test.ts new file mode 100644 index 000000000000..6e2526111eaf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.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 1f9710f8e584..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 @@ -65,7 +65,10 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi session.isLegacyWorkerAutomaticResumeBlocked = (): boolean => { const state = useAppStore.getState() const paneKey = session.getSleepingRecordForPane(state)?.paneKey - return paneKey !== undefined && state.legacyWorkerResumeFencesByPaneKey[paneKey] === true + return ( + state.legacyWorkerResumeFencesByPaneKey[session.cacheKey] === true || + (paneKey !== undefined && state.legacyWorkerResumeFencesByPaneKey[paneKey] === true) + ) } session.clearSleepingRecordProviderDuplicates = ( state: ReturnType, diff --git a/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts index 15728c411dec..97abc6cc950e 100644 --- a/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts +++ b/src/renderer/src/lib/legacy-worker-resume-fence-refresh.ts @@ -1,16 +1,11 @@ +import { readAndApplyRuntimeSession, applyReadRuntimeSession } from './runtime-session-application' +import { getDefaultWorkspaceSession } from '../../../shared/constants' import { useAppStore } from '@/store' let inFlight: Promise | null = null let repeat = false -/** - * Re-reads the runtime-authored fenced-pane set after main says it changed. A pull rather than a - * pushed payload: the set is session state the renderer also hydrates, and an event carrying it - * could land out of order with that hydration. - * - * Reads are serialized and coalesced, so the last read is the last write with no version to - * compare — two overlapping pings cannot leave an older set installed. - */ +// Coalesce invalidations; their reads use the same application lane as ordinary hydration. export async function refreshLegacyWorkerResumeFences(): Promise { if (inFlight) { repeat = true @@ -20,8 +15,15 @@ export async function refreshLegacyWorkerResumeFences(): Promise { try { do { repeat = false - const fences = await window.api.app.getLegacyWorkerResumeFences() - useAppStore.getState().setLegacyWorkerResumeFences(fences ?? {}) + 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. diff --git a/src/renderer/src/lib/review-r2-old-host.test.ts b/src/renderer/src/lib/review-r2-old-host.test.ts new file mode 100644 index 000000000000..39067a7662c3 --- /dev/null +++ b/src/renderer/src/lib/review-r2-old-host.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/review-r2-refresh-hydration.test.ts b/src/renderer/src/lib/review-r2-refresh-hydration.test.ts new file mode 100644 index 000000000000..612f6c999429 --- /dev/null +++ b/src/renderer/src/lib/review-r2-refresh-hydration.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-application.test.ts b/src/renderer/src/lib/runtime-session-application.test.ts new file mode 100644 index 000000000000..f0c11fd18336 --- /dev/null +++ b/src/renderer/src/lib/runtime-session-application.test.ts @@ -0,0 +1,138 @@ +import { afterEach, 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({}) +}) 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..dc579863a96d --- /dev/null +++ b/src/renderer/src/lib/runtime-session-application.ts @@ -0,0 +1,98 @@ +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 pending: (() => void)[] = [] +let reading = false +const appliedSessions = new WeakMap>() + +function drainApplications(): void { + reading = false + while (!reading && pending.length > 0) { + pending.shift()!() + } +} + +// Reads and synchronous hydration share this lane, including the read before startup's catalog wait. +export function readAndApplyRuntimeSession( + read: () => Promise, + apply: (value: T) => void +): Promise { + return new Promise((resolve, reject) => { + const start = (): void => { + reading = true + void (async () => read())().then( + (value) => { + try { + apply(value) + drainApplications() + resolve(value) + } catch (error) { + drainApplications() + reject(error) + } + }, + (error) => { + drainApplications() + reject(error) + } + ) + } + if (reading) { + pending.push(start) + } else { + start() + } + }) +} + +export function applyReadRuntimeSession( + session: WorkspaceSessionState, + set: TerminalStoreSet, + get: TerminalStoreGet, + targetTabIds?: ReadonlySet +): void { + 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) + } + const inScope = (key: string): boolean => { + const tabId = parsePaneKey(key)?.tabId ?? parseLegacyNumericPaneKey(key)?.tabId + return tabId !== undefined && targetTabIds!.has(tabId) + } + set({ + legacyWorkerResumeFencesByPaneKey: targetTabIds + ? { + ...Object.fromEntries(Object.entries(current).filter(([key]) => !inScope(key))), + ...Object.fromEntries(Object.entries(fences).filter(([key]) => inScope(key))) + } + : fences + }) +} + +export function hydrateRuntimeSessionFields( + session: WorkspaceSessionState, + set: TerminalStoreSet, + get: TerminalStoreGet, + targetTabIds?: ReadonlySet +): void { + if (appliedSessions.get(get)?.has(session)) { + return + } + const apply = (): void => applyReadRuntimeSession(session, set, get, targetTabIds) + if (reading) { + pending.push(apply) + } else { + apply() + } +} 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-patch.ts b/src/renderer/src/lib/workspace-session-patch.ts index 4976edf7b179..4e56654a8f5d 100644 --- a/src/renderer/src/lib/workspace-session-patch.ts +++ b/src/renderer/src/lib/workspace-session-patch.ts @@ -172,12 +172,7 @@ export function buildWorkspaceSessionPatch( snapshot.closedTerminalTabTombstonesByTabId ) } - // Why both: the projected flag is derived from the fence set, so a fence change alone still has - // to re-emit the records an older client reads it from. - if ( - changed.has('sleepingAgentSessionsByPaneKey') || - changed.has('legacyWorkerResumeFencesByPaneKey') - ) { + if (changed.has('sleepingAgentSessionsByPaneKey')) { patch.sleepingAgentSessionsByPaneKey = buildSleepingAgentSessionData(snapshot).sleepingAgentSessionsByPaneKey } diff --git a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts index f688ea7a0626..2dc1346f5349 100644 --- a/src/renderer/src/lib/workspace-session-relevant-fields.test.ts +++ b/src/renderer/src/lib/workspace-session-relevant-fields.test.ts @@ -37,7 +37,6 @@ describe('SESSION_RELEVANT_FIELDS', () => { defaultTerminalTabsAppliedByWorktreeId: true, closedTerminalTabTombstonesByTabId: true, sleepingAgentSessionsByPaneKey: true, - legacyWorkerResumeFencesByPaneKey: true, clientHostedBrowserCloseIntentsByEnvironment: true, pendingReconnectPtyIdByTabId: true, deferredSshSessionIdsByTabId: true diff --git a/src/renderer/src/lib/workspace-session-sleeping-agents.ts b/src/renderer/src/lib/workspace-session-sleeping-agents.ts index 8183731ec2d5..29ee3d0fe6a8 100644 --- a/src/renderer/src/lib/workspace-session-sleeping-agents.ts +++ b/src/renderer/src/lib/workspace-session-sleeping-agents.ts @@ -1,36 +1,10 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -/** - * The single site where `automaticResumeBlockedBy` is stamped onto an outgoing record. - * - * The fence itself lives in `legacyWorkerResumeFencesByPaneKey`, which main owns and this renderer - * never writes. The record flag is a projection kept only so a client too old to read that field - * still hydrates fenced records as fenced. Projecting here — the one function both the patch path - * and the full-state path build records through — is what lets every record writer stay unaware of - * the fence: the flag is derived on the way out rather than carried through the store. - */ export function buildSleepingAgentSessionData(snapshot: { sleepingAgentSessionsByPaneKey?: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] - legacyWorkerResumeFencesByPaneKey?: Record }): Pick { const records = snapshot.sleepingAgentSessionsByPaneKey - if (!records || Object.keys(records).length === 0) { - return {} - } - const fences = snapshot.legacyWorkerResumeFencesByPaneKey ?? {} - let projected: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] | undefined - for (const [paneKey, record] of Object.entries(records)) { - const fenced = fences[paneKey] === true - if (fenced === (record.automaticResumeBlockedBy === 'legacy-orchestration-worker')) { - continue - } - projected ??= { ...records } - if (fenced) { - projected[paneKey] = { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' } - } else { - const { automaticResumeBlockedBy: _retired, ...unfenced } = record - projected[paneKey] = unfenced - } - } - return { sleepingAgentSessionsByPaneKey: projected ?? records } + return records && Object.keys(records).length > 0 + ? { sleepingAgentSessionsByPaneKey: records } + : {} } 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 c5656c17b471..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,95 +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'] - /** Read-only input to the record projection; never emitted, since main owns this field. */ - legacyWorkerResumeFencesByPaneKey?: AppState['legacyWorkerResumeFencesByPaneKey'] - clientHostedBrowserCloseIntentsByEnvironment?: AppState['clientHostedBrowserCloseIntentsByEnvironment'] - /** Optional so the many partial snapshot fixtures keep type-checking; see buildTerminalSessionData. */ - pendingReconnectPtyIdByTabId?: AppState['pendingReconnectPtyIdByTabId'] - 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', - 'legacyWorkerResumeFencesByPaneKey', - '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-status-open-tab-resume-fence.test.ts b/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts index 7e9b40b951b1..03fdb8bf8439 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 @@ -83,9 +83,8 @@ describe('a resume fence for a pane with no sleeping record', () => { expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) }) - // Older clients read the fence off the record, so it is projected on the way out — at one site, - // derived from the runtime's set rather than carried through the store. - it('is projected onto outgoing records for older clients', () => { + // 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() @@ -95,9 +94,9 @@ describe('a resume fence for a pane with no sleeping record', () => { const projected = buildSleepingAgentSessionData(snapshot) - expect(projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - 'legacy-orchestration-worker' - ) + expect( + projected.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy + ).toBeUndefined() }) it('strips a stale projection once the runtime retires the fence', () => { diff --git a/src/renderer/src/store/slices/review-r2-hydration.test.ts b/src/renderer/src/store/slices/review-r2-hydration.test.ts new file mode 100644 index 000000000000..1b7a5da671e7 --- /dev/null +++ b/src/renderer/src/store/slices/review-r2-hydration.test.ts @@ -0,0 +1,29 @@ +import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { it, expect } from 'vitest' +import { createTestStore, makeTab } from './store-test-helpers' +import { getDefaultWorkspaceSession } from '../../../../shared/constants' +it('scoped authoritative retirement removes only target fences', () => { + 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/slices/review-r2-writer.test.ts b/src/renderer/src/store/slices/review-r2-writer.test.ts new file mode 100644 index 000000000000..082d123cd837 --- /dev/null +++ b/src/renderer/src/store/slices/review-r2-writer.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('review writer fence race', () => { + 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.getState().setLegacyWorkerResumeFences({ [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.getState().setLegacyWorkerResumeFences({ [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/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index 2c64e76a52c4..3ea374a54755 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -23,7 +23,6 @@ export type WorkspaceHydrationPatch = Pick< | 'closedTerminalTabTombstonesByTabId' | 'automaticAgentResumeClaimsByTabId' | 'sleepingAgentSessionsByPaneKey' - | 'legacyWorkerResumeFencesByPaneKey' | 'pendingReconnectWorktreeIds' | 'pendingReconnectTabByWorktree' | 'pendingReconnectPtyIdByTabId' @@ -198,12 +197,6 @@ export function targetScopedWorkspaceHydrationPatch( targetTabIds ), sleepingAgentSessionsByPaneKey, - // Why passed through whole: runtime-authored and keyed by pane, so there is no workspace key - // for replaceHydratedRecordKeys to scope, and a scoped rehydration must not drop other hosts'. - legacyWorkerResumeFencesByPaneKey: { - ...state.legacyWorkerResumeFencesByPaneKey, - ...hydrated.legacyWorkerResumeFencesByPaneKey - }, pendingReconnectWorktreeIds: [ ...state.pendingReconnectWorktreeIds.filter((key) => !workspaceKeys.has(key)), ...hydrated.pendingReconnectWorktreeIds.filter((key) => workspaceKeys.has(key)) diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 6f0e9d189a7d..5f53dd25ff8c 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,15 @@ 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 + hydrateRuntimeSessionFields(session, set, get, targetTabIds) const ownershipTransferTabIds = options?.replaceWorkspaceKeys ? new Set( options.replaceWorkspaceKeys.flatMap((workspaceKey) => @@ -194,9 +204,6 @@ export function createWorkspaceTerminalHydrationActions( closedTerminalTabTombstonesByTabId: session.closedTerminalTabTombstonesByTabId ?? {}, automaticAgentResumeClaimsByTabId: {}, sleepingAgentSessionsByPaneKey, - // Runtime-authored: installed exactly as the session holds it, never merged with local - // state, because main is its only author and this read is the whole truth. - legacyWorkerResumeFencesByPaneKey: session.legacyWorkerResumeFencesByPaneKey ?? {}, pendingReconnectWorktreeIds, pendingReconnectTabByWorktree, pendingReconnectPtyIdByTabId, diff --git a/src/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]) + ) + ) +} From 67209085ad272ed7562220231c094cff83eef2f0 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 19:16:51 -0400 Subject: [PATCH 5/7] fix: skip untouched resume-fence host partitions --- config/reliability-gates.jsonc | 20 +++---- ...e-runtime-authored-session-fences.test.ts} | 0 ...acy-worker-host-write-publication.test.ts} | 52 +++++++++++++++++++ ...worker-recovery-publication-order.test.ts} | 4 +- ...legacy-worker-session-publication.test.ts} | 0 ...cy-worker-terminal-recovery-persistence.ts | 19 ++++--- ...ng-record-access-recordless-fence.test.ts} | 0 ...> legacy-worker-old-source-resume.test.ts} | 0 ...e-session-refresh-hydration-order.test.ts} | 0 ...=> agent-sleep-resume-fence-races.test.ts} | 2 +- ...ace-scoped-resume-fence-hydration.test.ts} | 0 11 files changed, 77 insertions(+), 20 deletions(-) rename src/main/persistence/loading-store/{review-r2-store.test.ts => store-runtime-authored-session-fences.test.ts} (100%) rename src/main/runtime/{review-r2-partial-write.test.ts => runtime-legacy-worker-host-write-publication.test.ts} (55%) rename src/main/runtime/{review-r2-round1.test.ts => runtime-legacy-worker-recovery-publication-order.test.ts} (94%) rename src/main/runtime/{review-r2-compat.test.ts => runtime-legacy-worker-session-publication.test.ts} (100%) rename src/renderer/src/components/terminal-pane/pty-connection/{review-r2-recordless.test.ts => sleeping-record-access-recordless-fence.test.ts} (100%) rename src/renderer/src/lib/{review-r2-old-host.test.ts => legacy-worker-old-source-resume.test.ts} (100%) rename src/renderer/src/lib/{review-r2-refresh-hydration.test.ts => runtime-session-refresh-hydration-order.test.ts} (100%) rename src/renderer/src/store/slices/{review-r2-writer.test.ts => agent-sleep-resume-fence-races.test.ts} (98%) rename src/renderer/src/store/slices/{review-r2-hydration.test.ts => workspace-scoped-resume-fence-hydration.test.ts} (100%) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0f6d0405dd00..30898c88b9af 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -27,20 +27,20 @@ "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 publication/read-boundary tests; each of the six round-3 fix mutations must fail. 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-application.test.ts src/main/persistence/loading-store/review-r2-store.test.ts src/main/runtime/review-r2-compat.test.ts src/main/runtime/review-r2-partial-write.test.ts src/main/runtime/review-r2-round1.test.ts src/renderer/src/lib/review-r2-old-host.test.ts src/renderer/src/lib/review-r2-refresh-hydration.test.ts src/renderer/src/store/slices/review-r2-writer.test.ts src/renderer/src/store/slices/review-r2-hydration.test.ts src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 npx vitest run --config config/vitest.config.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-application.test.ts", - "src/main/persistence/loading-store/review-r2-store.test.ts", - "src/main/runtime/review-r2-compat.test.ts", - "src/main/runtime/review-r2-partial-write.test.ts", - "src/main/runtime/review-r2-round1.test.ts", - "src/renderer/src/lib/review-r2-old-host.test.ts", - "src/renderer/src/lib/review-r2-refresh-hydration.test.ts", - "src/renderer/src/store/slices/review-r2-writer.test.ts", - "src/renderer/src/store/slices/review-r2-hydration.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.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" ], diff --git a/src/main/persistence/loading-store/review-r2-store.test.ts b/src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts similarity index 100% rename from src/main/persistence/loading-store/review-r2-store.test.ts rename to src/main/persistence/loading-store/store-runtime-authored-session-fences.test.ts diff --git a/src/main/runtime/review-r2-partial-write.test.ts b/src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts similarity index 55% rename from src/main/runtime/review-r2-partial-write.test.ts rename to src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts index 555a7fe41347..772976f242de 100644 --- a/src/main/runtime/review-r2-partial-write.test.ts +++ b/src/main/runtime/runtime-legacy-worker-host-write-publication.test.ts @@ -55,3 +55,55 @@ it('announces a successfully written host when a sibling fails and disappears be 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/review-r2-round1.test.ts b/src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts similarity index 94% rename from src/main/runtime/review-r2-round1.test.ts rename to src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts index 57cd94f49b7f..3e53625f1c0d 100644 --- a/src/main/runtime/review-r2-round1.test.ts +++ b/src/main/runtime/runtime-legacy-worker-recovery-publication-order.test.ts @@ -57,7 +57,7 @@ function harness() { } } } -it('round1 stale recovery result cannot reinstall a retired fence', async () => { +it('a stale recovery result cannot reinstall a retired fence', async () => { const h = harness() let release!: (x: ReadonlySet) => void h.ports.persist = () => @@ -75,7 +75,7 @@ it('round1 stale recovery result cannot reinstall a retired fence', async () => h.p.prepare() expect(h.fences()).toEqual({}) }) -it('round1 failed staging does not publish an untracked fence', async () => { +it('failed staging does not publish an untracked fence', async () => { const h = harness() h.fail(true) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/src/main/runtime/review-r2-compat.test.ts b/src/main/runtime/runtime-legacy-worker-session-publication.test.ts similarity index 100% rename from src/main/runtime/review-r2-compat.test.ts rename to src/main/runtime/runtime-legacy-worker-session-publication.test.ts 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 0c4c20cc4529..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' @@ -84,7 +85,7 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { try { for (const [hostId, fenced] of fencedByHost) { const current = store.getWorkspaceSession(hostId) - if (!current || sameFenceSet(current.legacyWorkerResumeFencesByPaneKey, fenced)) { + if (!current || sameFenceSet(current, fenced)) { continue } store.setWorkspaceSession({ ...current, legacyWorkerResumeFencesByPaneKey: fenced }, hostId) @@ -213,14 +214,18 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { /** Identity is not enough: `prepare` rebuilds the set every pass, so compare by content or every * pass would rewrite the session and wake every session subscriber. */ -function sameFenceSet( - current: Record | undefined, - next: Record -): boolean { - const currentKeys = Object.keys(current ?? {}) +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 ( - current !== undefined && currentKeys.length === nextKeys.length && nextKeys.every((paneKey) => current?.[paneKey] === true) ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access-recordless-fence.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/pty-connection/review-r2-recordless.test.ts rename to src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access-recordless-fence.test.ts diff --git a/src/renderer/src/lib/review-r2-old-host.test.ts b/src/renderer/src/lib/legacy-worker-old-source-resume.test.ts similarity index 100% rename from src/renderer/src/lib/review-r2-old-host.test.ts rename to src/renderer/src/lib/legacy-worker-old-source-resume.test.ts diff --git a/src/renderer/src/lib/review-r2-refresh-hydration.test.ts b/src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts similarity index 100% rename from src/renderer/src/lib/review-r2-refresh-hydration.test.ts rename to src/renderer/src/lib/runtime-session-refresh-hydration-order.test.ts diff --git a/src/renderer/src/store/slices/review-r2-writer.test.ts b/src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts similarity index 98% rename from src/renderer/src/store/slices/review-r2-writer.test.ts rename to src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts index 082d123cd837..f7ae1b8a4213 100644 --- a/src/renderer/src/store/slices/review-r2-writer.test.ts +++ b/src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts @@ -32,7 +32,7 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const mockApi = createStoreCascadesMockApi() -describe('review writer fence race', () => { +describe('resume fences during asynchronous sleep writes', () => { beforeEach(() => { vi.clearAllMocks() clearRuntimeCompatibilityCacheForTests() diff --git a/src/renderer/src/store/slices/review-r2-hydration.test.ts b/src/renderer/src/store/slices/workspace-scoped-resume-fence-hydration.test.ts similarity index 100% rename from src/renderer/src/store/slices/review-r2-hydration.test.ts rename to src/renderer/src/store/slices/workspace-scoped-resume-fence-hydration.test.ts From 7f368807595a7dbd32ccfbf463a40fd950e6211a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 19:51:36 -0400 Subject: [PATCH 6/7] fix: hydrate worker records and resume fences atomically --- config/reliability-gates.jsonc | 8 +- .../lib/runtime-session-application.test.ts | 63 ++++++++++++++++ .../src/lib/runtime-session-application.ts | 74 ++++++++++++++----- .../runtime-session-pending-hydration.test.ts | 66 +++++++++++++++++ .../agent-sleep-resume-fence-races.test.ts | 4 +- ...agent-status-open-tab-resume-fence.test.ts | 4 +- .../slices/agent-status-recovery-actions.ts | 10 --- .../slices/agent-status-slice-contract.ts | 2 - .../slices/legacy-worker-resume-fences.ts | 9 --- .../terminals/workspace-terminal-hydration.ts | 10 ++- 10 files changed, 198 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/lib/runtime-session-pending-hydration.test.ts delete mode 100644 src/renderer/src/store/slices/legacy-worker-resume-fences.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 30898c88b9af..1f0e9924e036 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -25,12 +25,13 @@ "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 publication/read-boundary tests; each of the six round-3 fix mutations must fail. Restart/reload does not resume a fenced worker, while explicit retirement permits resume.", + "oracle": "Nine round-2 reviewer probes plus atomic pending-hydration and publication/read-boundary tests; queue-only-fence 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-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 npx vitest run --config config/vitest.config.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-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", @@ -49,7 +50,8 @@ "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" + "orders a scoped retirement after an in-flight invalidation and retains its sibling", + "publishes sleeping records and their authority in a single subscriber notification" ] } ], diff --git a/src/renderer/src/lib/runtime-session-application.test.ts b/src/renderer/src/lib/runtime-session-application.test.ts index f0c11fd18336..b09e067b0f9c 100644 --- a/src/renderer/src/lib/runtime-session-application.test.ts +++ b/src/renderer/src/lib/runtime-session-application.test.ts @@ -136,3 +136,66 @@ it('an authoritative empty set retires a legacy flag while an absent set preserv 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({}) + } +) diff --git a/src/renderer/src/lib/runtime-session-application.ts b/src/renderer/src/lib/runtime-session-application.ts index dc579863a96d..f2d9123edff2 100644 --- a/src/renderer/src/lib/runtime-session-application.ts +++ b/src/renderer/src/lib/runtime-session-application.ts @@ -3,18 +3,20 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st import { parsePaneKey, parseLegacyNumericPaneKey } from '../../../shared/stable-pane-id' import type { TerminalStoreGet, TerminalStoreSet } from '../store/terminals/terminal-state' -const pending: (() => void)[] = [] +const pendingReads: (() => void)[] = [] +let hydratedDuringRead = new Map | null>() let reading = false const appliedSessions = new WeakMap>() function drainApplications(): void { reading = false - while (!reading && pending.length > 0) { - pending.shift()!() + hydratedDuringRead.clear() + while (!reading && pendingReads.length > 0) { + pendingReads.shift()!() } } -// Reads and synchronous hydration share this lane, including the read before startup's catalog wait. +// Serialize runtime reads; synchronous hydration supersedes their overlapping snapshot scopes. export function readAndApplyRuntimeSession( read: () => Promise, apply: (value: T) => void @@ -22,6 +24,7 @@ export function readAndApplyRuntimeSession( return new Promise((resolve, reject) => { const start = (): void => { reading = true + hydratedDuringRead = new Map() void (async () => read())().then( (value) => { try { @@ -40,7 +43,7 @@ export function readAndApplyRuntimeSession( ) } if (reading) { - pending.push(start) + pendingReads.push(start) } else { start() } @@ -53,6 +56,35 @@ export function applyReadRuntimeSession( get: TerminalStoreGet, targetTabIds?: ReadonlySet ): void { + const fields = runtimeSessionFields(session, get, targetTabIds) + const superseded = hydratedDuringRead.get(get) + if (superseded === null) { + return + } + if (superseded) { + const current = get().legacyWorkerResumeFencesByPaneKey + fields.legacyWorkerResumeFencesByPaneKey = { + ...Object.fromEntries( + Object.entries(fields.legacyWorkerResumeFencesByPaneKey).filter( + ([key]) => !paneInScope(key, superseded) + ) + ), + ...Object.fromEntries(Object.entries(current).filter(([key]) => paneInScope(key, superseded))) + } + } + set(fields) +} + +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() @@ -66,33 +98,35 @@ export function applyReadRuntimeSession( ...current, ...readWorkspaceSessionResumeFences(session) } - const inScope = (key: string): boolean => { - const tabId = parsePaneKey(key)?.tabId ?? parseLegacyNumericPaneKey(key)?.tabId - return tabId !== undefined && targetTabIds!.has(tabId) - } - set({ + return { legacyWorkerResumeFencesByPaneKey: targetTabIds ? { - ...Object.fromEntries(Object.entries(current).filter(([key]) => !inScope(key))), - ...Object.fromEntries(Object.entries(fences).filter(([key]) => inScope(key))) + ...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, - set: TerminalStoreSet, get: TerminalStoreGet, targetTabIds?: ReadonlySet -): void { +): { legacyWorkerResumeFencesByPaneKey: Record } { if (appliedSessions.get(get)?.has(session)) { - return + return { legacyWorkerResumeFencesByPaneKey: get().legacyWorkerResumeFencesByPaneKey } } - const apply = (): void => applyReadRuntimeSession(session, set, get, targetTabIds) if (reading) { - pending.push(apply) - } else { - apply() + const previous = hydratedDuringRead.get(get) + // Track snapshot scopes, never policy values, until the older read settles. + hydratedDuringRead.set( + get, + !targetTabIds || previous === null ? null : new Set([...(previous ?? []), ...targetTabIds]) + ) } + 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/store/slices/agent-sleep-resume-fence-races.test.ts b/src/renderer/src/store/slices/agent-sleep-resume-fence-races.test.ts index f7ae1b8a4213..9b0dee95240b 100644 --- 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 @@ -61,7 +61,7 @@ describe('resume fences during asynchronous sleep writes', () => { { providerSession: { key: 'session_id', id: 'session-1' } } ) mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setLegacyWorkerResumeFences({ [pane]: true }) + store.setState({ legacyWorkerResumeFencesByPaneKey: { [pane]: true } }) expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) }) await store.getState().shutdownWorktreeTerminals(wt, { keepIdentifiers: true }) @@ -98,7 +98,7 @@ describe('resume fences during asynchronous sleep writes', () => { { providerSession: { key: 'session_id', id: 'session-1' } } ) mockApi.pty.kill.mockImplementationOnce(async () => { - store.getState().setLegacyWorkerResumeFences({ [pane]: true }) + store.setState({ legacyWorkerResumeFencesByPaneKey: { [pane]: true } }) expect(store.getState().legacyWorkerResumeFencesByPaneKey[pane]).toBe(true) throw new Error('kill_failed') }) 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 03fdb8bf8439..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 @@ -58,7 +58,7 @@ describe('a resume fence for a pane with no sleeping record', () => { it('is retired only by the runtime replacing the set', () => { const store = fencedStore() - store.getState().setLegacyWorkerResumeFences({}) + store.setState({ legacyWorkerResumeFencesByPaneKey: {} }) expect(store.getState().legacyWorkerResumeFencesByPaneKey).toEqual({}) }) @@ -102,7 +102,7 @@ describe('a resume fence for a pane with no sleeping record', () => { it('strips a stale projection once the runtime retires the fence', () => { const store = fencedStore() store.getState().captureAllSleepingAgentSessions('quit') - store.getState().setLegacyWorkerResumeFences({}) + store.setState({ legacyWorkerResumeFencesByPaneKey: {} }) const projected = buildSleepingAgentSessionData(store.getState()) 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 c2c740af02b8..0e256834a9f7 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -1,5 +1,4 @@ import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' -import { sameFenceSet } from './legacy-worker-resume-fences' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' import { collectSleepingAgentSessionRecordsForWorktree } from './agent-status-recovery-collection' @@ -23,7 +22,6 @@ export function createAgentStatusRecoveryActions( | 'captureAllSleepingAgentSessions' | 'clearSleepingAgentSession' | 'clearSleepingAgentSessionsByPaneKey' - | 'setLegacyWorkerResumeFences' | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { @@ -110,14 +108,6 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), clearSleepingAgentSessionsByPaneKey, - setLegacyWorkerResumeFences: (fences) => { - set((s) => - sameFenceSet(s.legacyWorkerResumeFencesByPaneKey, fences) - ? s - : { legacyWorkerResumeFencesByPaneKey: fences } - ) - }, - clearSleepingAgentSessionsByWorktree: (worktreeId) => { set((s) => { let changed = false 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 d7b3edccfd60..db78487e0ba7 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -162,8 +162,6 @@ export type AgentStatusSlice = { captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - /** Install main's fenced-pane set wholesale. The only writer, and level-triggered. */ - setLegacyWorkerResumeFences: (fences: Record) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void diff --git a/src/renderer/src/store/slices/legacy-worker-resume-fences.ts b/src/renderer/src/store/slices/legacy-worker-resume-fences.ts deleted file mode 100644 index 60f92d70ba62..000000000000 --- a/src/renderer/src/store/slices/legacy-worker-resume-fences.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Main rewrites the whole set on every recovery pass, so compare by content: an identity check - * would republish an unchanged set and wake every session subscriber. */ -export function sameFenceSet(current: Record, next: Record): boolean { - const nextKeys = Object.keys(next) - return ( - Object.keys(current).length === nextKeys.length && - nextKeys.every((paneKey) => current[paneKey] === true) - ) -} diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index 5f53dd25ff8c..5aabd9acd9fa 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -40,7 +40,6 @@ export function createWorkspaceTerminalHydrationActions( ]) ) : undefined - hydrateRuntimeSessionFields(session, set, get, targetTabIds) const ownershipTransferTabIds = options?.replaceWorkspaceKeys ? new Set( options.replaceWorkspaceKeys.flatMap((workspaceKey) => @@ -225,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) From 2197d23bba1350ec71627791c29f697a570cd45b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 20:27:10 -0400 Subject: [PATCH 7/7] fix: supersede runtime fences only with supplied authority --- config/reliability-gates.jsonc | 9 +- .../lib/runtime-session-application.test.ts | 290 +++++++++++++++++- .../src/lib/runtime-session-application.ts | 48 ++- ...e-session-ssh-projection-authority.test.ts | 78 +++++ 4 files changed, 411 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/runtime-session-ssh-projection-authority.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 1f0e9924e036..7506bc50f37c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -25,12 +25,13 @@ "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 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.", + "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-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 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", @@ -51,7 +52,9 @@ "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" + "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" ] } ], diff --git a/src/renderer/src/lib/runtime-session-application.test.ts b/src/renderer/src/lib/runtime-session-application.test.ts index b09e067b0f9c..82fb2c93bf70 100644 --- a/src/renderer/src/lib/runtime-session-application.test.ts +++ b/src/renderer/src/lib/runtime-session-application.test.ts @@ -1,4 +1,6 @@ -import { afterEach, expect, it, vi } from 'vitest' +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' @@ -199,3 +201,289 @@ it.each(['resolve', 'reject'] as const)( 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 index f2d9123edff2..5c59a1c847ad 100644 --- a/src/renderer/src/lib/runtime-session-application.ts +++ b/src/renderer/src/lib/runtime-session-application.ts @@ -4,7 +4,11 @@ import { parsePaneKey, parseLegacyNumericPaneKey } from '../../../shared/stable- import type { TerminalStoreGet, TerminalStoreSet } from '../store/terminals/terminal-state' const pendingReads: (() => void)[] = [] -let hydratedDuringRead = new Map | null>() +type HydratedAuthority = { + tabIds: ReadonlySet | null + paneKeys: ReadonlySet +} +let hydratedDuringRead = new Map() let reading = false const appliedSessions = new WeakMap>() @@ -58,7 +62,7 @@ export function applyReadRuntimeSession( ): void { const fields = runtimeSessionFields(session, get, targetTabIds) const superseded = hydratedDuringRead.get(get) - if (superseded === null) { + if (superseded?.tabIds === null) { return } if (superseded) { @@ -66,15 +70,23 @@ export function applyReadRuntimeSession( fields.legacyWorkerResumeFencesByPaneKey = { ...Object.fromEntries( Object.entries(fields.legacyWorkerResumeFencesByPaneKey).filter( - ([key]) => !paneInScope(key, superseded) + ([key]) => !authorityIncludesPane(key, superseded) ) ), - ...Object.fromEntries(Object.entries(current).filter(([key]) => paneInScope(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) @@ -121,12 +133,28 @@ export function hydrateRuntimeSessionFields( return { legacyWorkerResumeFencesByPaneKey: get().legacyWorkerResumeFencesByPaneKey } } if (reading) { - const previous = hydratedDuringRead.get(get) - // Track snapshot scopes, never policy values, until the older read settles. - hydratedDuringRead.set( - get, - !targetTabIds || previous === null ? null : new Set([...(previous ?? []), ...targetTabIds]) - ) + 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-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) + } +)