diff --git a/config/ts-nocheck-baseline.txt b/config/ts-nocheck-baseline.txt index b770b06f827..e897af7387c 100644 --- a/config/ts-nocheck-baseline.txt +++ b/config/ts-nocheck-baseline.txt @@ -34,7 +34,7 @@ src/main/runtime/orca-runtime-create-terminal-side-effect-command-code-detector. src/main/runtime/orca-runtime-create-terminal.ts src/main/runtime/orca-runtime-deliver-pending-messages.ts src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts -src/main/runtime/orca-runtime-fence-automation-owner.ts +src/main/runtime/orca-runtime-automation-operations.ts src/main/runtime/orca-runtime-file-commands.ts src/main/runtime/orca-runtime-fit-override-listeners.ts src/main/runtime/orca-runtime-focus-terminal.ts diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index ebee3673b77..75762752848 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 42m + + downloads: 43m @@ -15,7 +15,7 @@ downloads downloads - 42m - 42m + 43m + 43m diff --git a/package.json b/package.json index 321f2ada9ed..0536f4fd606 100644 --- a/package.json +++ b/package.json @@ -243,6 +243,7 @@ "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", "emojibase-data": "17.0.0", + "esbuild": "^0.25.12", "happy-dom": "^20.11.8", "html-to-image": "^1.11.13", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ee9fff6785..7add63b397b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -366,6 +366,9 @@ importers: emojibase-data: specifier: 17.0.0 version: 17.0.0(emojibase@17.0.0) + esbuild: + specifier: ^0.25.12 + version: 0.25.12 happy-dom: specifier: ^20.11.8 version: 20.11.8 diff --git a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts index 9a77dd7fbb9..e68bcf6ec99 100644 --- a/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-relay-absence-respawn.test.ts @@ -81,6 +81,49 @@ function sessionStore(leaves: string[]): { store: Store; read: () => WorkspaceSe } describe('stable pane adoption after the relay reports the PTY absent', () => { + it.each([false, true])( + 'reattaches a live pane without launching a provider process (settled worker: %s)', + async (settledWorker) => { + const { store, read } = sessionStore([LEAF]) + const paneKey = `${OWNER.tabId}:${LEAF}` + const record = { + paneKey, + tabId: OWNER.tabId, + worktreeId: WORKTREE, + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'provider-session' }, + prompt: '', + state: 'done' as const, + capturedAt: 1, + updatedAt: 1, + ...(settledWorker ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } : {}) + } + store.setWorkspaceSession({ + ...read(), + sleepingAgentSessionsByPaneKey: { [paneKey]: record } + }) + const spawn = vi.fn().mockResolvedValue({ id: OWNER.ptyId, isReattach: true }) + const onFreshSpawn = vi.fn() + const result = await spawnForStablePane({ + runtime: undefined, + store, + worktreeId: WORKTREE, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24, command: 'claude --resume provider-session' }, + owner: OWNER, + connectionId: 'conn-1', + resolveOwner: () => OWNER, + onFreshSpawn + }) + expect(result.owner).toBe(OWNER) + expect(spawn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ sessionId: OWNER.ptyId, attachOnly: true, command: undefined }) + ) + expect(onFreshSpawn).not.toHaveBeenCalled() + expect(read().tabsByWorktree[WORKTREE]).toHaveLength(1) + } + ) + it('spawns fresh once the relay has positively answered for that id', async () => { const { run, spawn } = spawnAfterAttachRejection( new SshPtyAbsentFromRelayError(`${SSH_SESSION_EXPIRED_ERROR}: pty-1`) diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index fed179837db..fa9823d76dd 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -481,12 +481,14 @@ describe('getPiAgentStatusExtensionSource', () => { await handlerCall }) - it('leaves runtime shutdown to PTY teardown instead of reporting turn completion', () => { + it('leaves runtime shutdown to PTY teardown instead of reporting turn completion', async () => { const harness = createHarness({ kind: 'pi' }) - // Why: Pi emits session_shutdown for reload/new/resume/fork while its PTY - // stays alive. agent_end is the only extension event that proves done. - expect(harness.handlers.session_shutdown).toBeUndefined() + // Why: Pi emits session_shutdown for reload/new/resume/fork while its PTY stays + // alive. agent_end is the only extension event that proves done, so the handler + // exists solely to release a dialog Pi tore down without a close. + await harness.callHook('session_shutdown') + expect(harness.fetchMock).not.toHaveBeenCalled() }) it('bounds stalled delivery to one active request and the latest pending status', async () => { diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 8b046e0db79..38775ca1973 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -101,7 +101,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin '// Orca receiver from building an unbounded queue of obsolete snapshots.', 'const HOOK_POST_TIMEOUT_MS = 1000', 'let activePost = false', - ...(kind === 'pi' ? ['let piUiPromptActive = false'] : []), + ...(kind === 'pi' ? ['let piUiPromptDepth = 0', 'let piTurnInFlight = false'] : []), 'let pendingPost: { hookEventName: string; extra: Record; metadata: Record; ompRuntime: boolean } | null = null', ...sessionMetadataSourceLines, '', @@ -167,7 +167,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' hookEventName,', // Why: every coalesced snapshot must retain an open modal, not just its start event. kind === 'pi' - ? ' extra: { ...extra, ...(!ompRuntime && piUiPromptActive ? { ui_prompt_active: true } : {}) },' + ? ' extra: { ...extra, ...(!ompRuntime && piUiPromptDepth > 0 ? { ui_prompt_active: true } : {}) },' : ' extra,', ' metadata: getPostSessionMetadata(ompRuntime),', ' ompRuntime,', diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index a769bfc74d2..9d02abbd78d 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -9,6 +9,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ? [ " pi.on('session_start', (event, ctx) => {", ' updateSessionMetadata(ctx)', + ...(kind === 'pi' ? [' piUiPromptDepth = 0'] : []), ' // Why: /reload re-registers the active session, but it is not a', ' // turn boundary and must not clear the visible status or unread state.', " if (event.reason === 'reload') return", @@ -105,6 +106,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ...captureSessionMetadata, ' clearPendingAgentEndCheck()', ' agentEndReported = false', + // Why: a turn cannot begin under a dialog holding input focus, so this is the one + // boundary that can recover a modal whose close never arrived. + ...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []), " post('agent_start')", ' })', '', @@ -168,6 +172,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' function postAgentEndOnce(): void {', ' if (agentEndReported) return', ' agentEndReported = true', + // Why: distinct from agentEndReported, which also dedupes the completion post and so + // starts false on a pane that has not run a turn yet — that pane is idle, not busy. + ...(kind === 'pi' ? [' piTurnInFlight = false'] : []), " post('agent_end')", ' }', '', diff --git a/src/main/pi/agent-status-runtime-detection-source.ts b/src/main/pi/agent-status-runtime-detection-source.ts index 5d9cdbf6de8..6ba5edb69b9 100644 --- a/src/main/pi/agent-status-runtime-detection-source.ts +++ b/src/main/pi/agent-status-runtime-detection-source.ts @@ -1,26 +1,15 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' -export function getPiAgentStatusRuntimeDetectionSourceLines(kind: PiAgentKind): string[] { - if (kind === 'prime-agent') { - return [ - `const CONFIGURED_HOOK_PATH = '/hook/${kind}'`, - '', - 'function isOmpRuntime(): boolean {', - ' return false', - '}', - '', - 'function resolveHookPath(_ompRuntime: boolean): string {', - ' return CONFIGURED_HOOK_PATH', - '}' - ] - } - +/** Why: a bare-shell OMP launch runs inside a pi-kind pane, so every extension that has to + * defer to OMP's own approval events needs this check — not just the status extension it + * was first written for. */ +export function getPiOmpRuntimeDetectionSourceLines(configuredHookPath: string): string[] { return [ 'function processName(value: unknown): string {', " return String(value || '').split(/[\\\\/]/).pop()?.toLowerCase() || ''", '}', '', - `const CONFIGURED_HOOK_PATH = '/hook/${kind}'`, + `const CONFIGURED_HOOK_PATH = '${configuredHookPath}'`, 'let cachedOmpRuntime: boolean | null = null', '', 'function isOmpRuntime(): boolean {', @@ -39,7 +28,27 @@ export function getPiAgentStatusRuntimeDetectionSourceLines(kind: PiAgentKind): " ['omp', 'omp.js', 'omp.sh', 'omp.cmd', 'omp.exe', 'omp.bat'].includes(name)", ' )', ' return cachedOmpRuntime', - '}', + '}' + ] +} + +export function getPiAgentStatusRuntimeDetectionSourceLines(kind: PiAgentKind): string[] { + if (kind === 'prime-agent') { + return [ + `const CONFIGURED_HOOK_PATH = '/hook/${kind}'`, + '', + 'function isOmpRuntime(): boolean {', + ' return false', + '}', + '', + 'function resolveHookPath(_ompRuntime: boolean): string {', + ' return CONFIGURED_HOOK_PATH', + '}' + ] + } + + return [ + ...getPiOmpRuntimeDetectionSourceLines(`/hook/${kind}`), '', 'function resolveHookPath(ompRuntime: boolean): string {', ' // Why: runtime detection keeps a bare-shell OMP launch from reporting as Pi.', diff --git a/src/main/pi/agent-status-ui-prompt-source.ts b/src/main/pi/agent-status-ui-prompt-source.ts index 2f1ed92c9ae..5790c5c30a7 100644 --- a/src/main/pi/agent-status-ui-prompt-source.ts +++ b/src/main/pi/agent-status-ui-prompt-source.ts @@ -1,6 +1,6 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' -/** Pi owns nested prompt depth and emits one pair around select/confirm/input/editor/custom. */ +/** Mirrors the titlebar extension's dialog tracking so both agree on when the wait ends. */ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): string[] { if (kind !== 'pi') { return [] @@ -9,14 +9,35 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s return [ " pi.on('ui_prompt_start', () => {", ' if (isOmpRuntime()) return', - ' piUiPromptActive = true', + ' piUiPromptDepth++', + ' if (piUiPromptDepth > 1) return', " post('ui_prompt_start')", ' })', '', " pi.on('ui_prompt_end', (_event, ctx) => {", - ' if (isOmpRuntime() || !piUiPromptActive) return', - ' piUiPromptActive = false', - " post('ui_prompt_end', { is_idle: ctx?.isIdle?.() === true })", + ' if (isOmpRuntime() || piUiPromptDepth === 0) return', + ' piUiPromptDepth--', + ' if (piUiPromptDepth > 0) return', + ' // Why: ctx.isIdle throws outright once a session-switching modal invalidates the', + ' // runner (it calls assertActive), so local turn state is the floor, not a fallback:', + ' // with no turn in flight, no later event is coming to correct a working verdict, so', + ' // only consult ctx when this process believes work is running.', + ' let isIdle = !piTurnInFlight', + ' try {', + " if (!isIdle && typeof ctx?.isIdle === 'function') isIdle = ctx.isIdle() === true", + ' } catch {', + ' // Why: a runner this very modal invalidated cannot answer; keep the local verdict.', + ' }', + " post('ui_prompt_end', { is_idle: isIdle })", + ' })', + '', + " pi.on('session_shutdown', () => {", + ' if (isOmpRuntime()) return', + ' // Why: pi tears an open dialog down through resetExtensionUI without resolving its', + ' // promise, so a replaced session never emits the matching ui_prompt_end and the wait', + ' // would stick forever. Reset without posting: shutdown is not a turn boundary, and', + ' // the session_start that follows republishes the corrected state.', + ' piUiPromptDepth = 0', ' })', '' ] diff --git a/src/main/pi/agent-status-ui-prompt.test.ts b/src/main/pi/agent-status-ui-prompt.test.ts index 4ab9341589e..d91ccc37b34 100644 --- a/src/main/pi/agent-status-ui-prompt.test.ts +++ b/src/main/pi/agent-status-ui-prompt.test.ts @@ -92,11 +92,21 @@ describe('Pi UI prompt status', () => { expect(harness.statuses.map((status) => status?.payload.state)).toEqual(['waiting', 'done']) }) - it('does not infer done when the context cannot establish idleness', async () => { + it('returns a pane that never ran a turn to done when idleness is unreadable', async () => { const harness = createHarness() await post(harness, 'ui_prompt_start') await post(harness, 'ui_prompt_end') - expect(harness.statuses.at(-1)?.payload.state).toBe('working') + // Why: no turn has started, so the pane is idle — reporting working would spin forever. + expect(harness.statuses.at(-1)?.payload.state).toBe('done') + }) + + it('trusts local turn state over a ctx that claims work on an idle pane', async () => { + const harness = createHarness() + await post(harness, 'ui_prompt_start') + await harness.callHook('ui_prompt_end', {}, { isIdle: () => false }) + await flushPosts() + // Why: no turn ever started, so nothing later would correct a working verdict. + expect(harness.statuses.at(-1)?.payload.state).toBe('done') }) it('lets the normal settlement hook finish work after a modal closes', async () => { @@ -118,20 +128,139 @@ describe('Pi UI prompt status', () => { const harness = createHarness() await post(harness, 'ui_prompt_start') harness.reload() - await post(harness, 'session_start', { reason: 'reload' }) await post(harness, 'tool_execution_end', { toolName: 'bash' }) + // Why: re-registering handlers is not a session boundary and must not lose the wait. expect(harness.statuses.at(-1)?.payload.state).toBe('waiting') }) - it('keeps a session-switching modal blocked until it actually closes', async () => { + it('releases a modal that a session replacement tore down without a close', async () => { const harness = createHarness() await post(harness, 'before_agent_start', { prompt: 'Old session prompt' }) await post(harness, 'ui_prompt_start') - await post(harness, 'session_start', { reason: 'switch' }) expect(harness.statuses.at(-1)?.payload.state).toBe('waiting') - expect(harness.statuses.at(-1)?.payload.prompt).toBe('') + // Why: pi hides the dialog through resetExtensionUI without resolving its promise, + // so no ui_prompt_end is ever emitted — these two boundaries are the only release. + await post(harness, 'session_shutdown') + await post(harness, 'session_start', { reason: 'switch' }) + await post(harness, 'tool_execution_end', { toolName: 'bash' }) + expect(harness.statuses.at(-1)?.payload.state).not.toBe('waiting') + }) + + it('releases a modal dropped by a reload that emits no shutdown', async () => { + const harness = createHarness() + await post(harness, 'ui_prompt_start') + await post(harness, 'session_start', { reason: 'reload' }) + await post(harness, 'tool_execution_end', { toolName: 'bash' }) + expect(harness.statuses.at(-1)?.payload.state).not.toBe('waiting') + }) + + it('still captures the assistant reply that lands while a modal is open', async () => { + const harness = createHarness() + await post(harness, 'agent_start') + await post(harness, 'message_end', { + message: { role: 'assistant', content: [{ type: 'text', text: 'Before modal' }] } + }) + await post(harness, 'ui_prompt_start') + await post(harness, 'message_end', { + message: { role: 'assistant', content: [{ type: 'text', text: 'Final reply' }] } + }) await harness.callHook('ui_prompt_end', {}, { isIdle: () => true }) await flushPosts() + expect(harness.statuses.at(-1)?.payload).toMatchObject({ + state: 'done', + lastAssistantMessage: 'Final reply' + }) + expect(harness.statuses.at(-1)?.payload.toolName).toBeUndefined() + expect(harness.statuses.at(-1)?.payload.interactivePrompt).toBeUndefined() + }) + + it('still reports the close when the modal invalidated its own runner', async () => { + const harness = createHarness() + await post(harness, 'ui_prompt_start') + await harness.callHook( + 'ui_prompt_end', + {}, + { + isIdle: () => { + throw new Error('extension runner is no longer active') + } + } + ) + await flushPosts() + // Why: a lost close would strand the pane on waiting; no turn is running, so done. + expect(harness.statuses.at(-1)?.payload.state).toBe('done') + }) + + it('keeps a mid-turn modal working when its runner throws on close', async () => { + const harness = createHarness() + await post(harness, 'agent_start') + await post(harness, 'ui_prompt_start') + await harness.callHook( + 'ui_prompt_end', + {}, + { + isIdle: () => { + throw new Error('extension runner is no longer active') + } + } + ) + await flushPosts() + // Why: the turn is still in flight, so done would ring the completion bell early. + expect(harness.statuses.at(-1)?.payload.state).toBe('working') + await post(harness, 'agent_settled') + expect(harness.statuses.at(-1)?.payload.state).toBe('done') + }) + + it('recovers on a new turn when a modal close was lost', async () => { + const harness = createHarness() + await post(harness, 'ui_prompt_start') + expect(harness.statuses.at(-1)?.payload.state).toBe('waiting') + // Why: a turn cannot begin under a dialog holding input focus, so this is recovery. + await post(harness, 'agent_start') + await post(harness, 'tool_execution_end', { toolName: 'bash' }) + expect(harness.statuses.at(-1)?.payload.state).toBe('working') + }) + + it('keeps the wait until the outermost of nested modals closes', async () => { + const harness = createHarness() + await post(harness, 'ui_prompt_start') + await post(harness, 'ui_prompt_start') + await harness.callHook('ui_prompt_end', {}, { isIdle: () => true }) + await flushPosts() + expect(harness.statuses.at(-1)?.payload.state).toBe('waiting') + await harness.callHook('ui_prompt_end', {}, { isIdle: () => true }) + await flushPosts() + expect(harness.statuses.at(-1)?.payload.state).toBe('done') + }) + + it('returns an idle pane to done when its modal lost the runner', async () => { + const harness = createHarness() + await post(harness, 'agent_start') + await post(harness, 'agent_settled') + expect(harness.statuses.at(-1)?.payload.state).toBe('done') + await post(harness, 'ui_prompt_start') + await harness.callHook( + 'ui_prompt_end', + {}, + { + isIdle: () => { + throw new Error('extension runner is no longer active') + } + } + ) + await flushPosts() + // Why: the turn already reported its end, so no later event is coming to correct a + // guess of working — fall back to what this process knows rather than strand it. + expect(harness.statuses.at(-1)?.payload.state).toBe('done') + }) + + it('keeps a mid-turn modal working when its close cannot read idleness', async () => { + const harness = createHarness() + await post(harness, 'agent_start') + await post(harness, 'ui_prompt_start') + await post(harness, 'ui_prompt_end') + expect(harness.statuses.at(-1)?.payload.state).toBe('working') + await post(harness, 'agent_settled') expect(harness.statuses.at(-1)?.payload.state).toBe('done') }) diff --git a/src/main/pi/titlebar-extension-service.ts b/src/main/pi/titlebar-extension-service.ts index 3a43ce4ac38..8a093096f4e 100644 --- a/src/main/pi/titlebar-extension-service.ts +++ b/src/main/pi/titlebar-extension-service.ts @@ -150,7 +150,7 @@ export class PiTitlebarExtensionService { if (kind !== 'prime-agent') { this.writeManagedExtension( join(extensionsDir, ORCA_PI_EXTENSION_FILE), - withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource()) + withOrcaManagedExtensionMarker(getPiTitlebarExtensionSource(kind)) ) this.writeManagedExtension( join(extensionsDir, ORCA_PI_PREFILL_EXTENSION_FILE), diff --git a/src/main/pi/titlebar-extension-source.test.ts b/src/main/pi/titlebar-extension-source.test.ts index bee2e007c57..be21f8c6a16 100644 --- a/src/main/pi/titlebar-extension-source.test.ts +++ b/src/main/pi/titlebar-extension-source.test.ts @@ -3,6 +3,8 @@ import { runInNewContext } from 'node:vm' import ts from 'typescript-api' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { detectAgentStatusFromTitle } from '../../shared/agent-detection' +import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiTitlebarExtensionSource } from './titlebar-extension-source' const BRAILLE_RE = /[⠀-⣿]/ @@ -23,8 +25,19 @@ type Harness = { const CWD = '/repo/orca-app' const SESSION = 'omp-session' const IDLE_TITLE = `π - ${SESSION} - orca-app` +const PROMPT_TITLE = `π ! ${SESSION} - orca-app` -function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = {}): Harness { +function createHarness( + options: { + paneKey?: string + isIdle?: () => boolean + kind?: PiAgentKind + processTitle?: string + cwdImpl?: () => string + sessionNameImpl?: () => string + env?: Record + } = {} +): Harness { const titles: string[] = [] const ctx: TitlebarContext = { ui: { @@ -48,8 +61,11 @@ function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = { module, exports: module.exports, process: { - env: { ORCA_PANE_KEY: options.paneKey ?? 'pane-1' }, - cwd: () => CWD + env: { ORCA_PANE_KEY: options.paneKey ?? 'pane-1', ...options.env }, + pid: options.env?.ORCA_PI_TITLE_MARKER_OWNED === undefined ? 111 : 222, + title: options.processTitle ?? 'pi', + argv: ['node', 'pi'], + cwd: options.cwdImpl ?? (() => CWD) }, console: { warn: vi.fn(), error: vi.fn(), log: vi.fn() }, Promise, @@ -61,7 +77,7 @@ function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = { } as Record context.globalThis = context - const output = ts.transpileModule(getPiTitlebarExtensionSource(), { + const output = ts.transpileModule(getPiTitlebarExtensionSource(options.kind ?? 'pi'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText runInNewContext(output, context) @@ -76,7 +92,7 @@ function createHarness(options: { paneKey?: string; isIdle?: () => boolean } = { on(name: string, handler: HookHandler) { handlers[name] = handler }, - getSessionName: () => SESSION + getSessionName: options.sessionNameImpl ?? (() => SESSION) }) return { @@ -247,4 +263,329 @@ describe('getPiTitlebarExtensionSource', () => { expect(vi.getTimerCount()).toBe(0) expect(harness.lastTitle()).toBe(IDLE_TITLE) }) + + it('marks a mid-turn dialog as needing input and holds it against the spinner', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + expect(detectAgentStatusFromTitle(PROMPT_TITLE)).toBe('permission') + + // Why: the spinner interval keeps running, but must not repaint over the marker. + await vi.advanceTimersByTimeAsync(800) + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + await harness.callHook('ui_prompt_end') + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + expect(vi.getTimerCount()).toBe(1) + }) + + it('returns an idle pane to its plain title when the dialog closes', async () => { + const harness = createHarness() + + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + await harness.callHook('ui_prompt_end') + expect(harness.lastTitle()).toBe(IDLE_TITLE) + expect(vi.getTimerCount()).toBe(0) + }) + + it('only the outermost of nested dialogs moves the title', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + await harness.callHook('ui_prompt_start') + await harness.callHook('ui_prompt_end') + // Why: the outer dialog still holds input focus. + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + await harness.callHook('ui_prompt_end') + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('ignores an unmatched dialog close', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + const titleCount = harness.titles.length + await harness.callHook('ui_prompt_end') + expect(harness.titles.length).toBe(titleCount) + }) + + it.each(['agent_settled', 'session_shutdown'])( + 'keeps the marker when %s lands under an open dialog', + async (name) => { + const harness = createHarness() + + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + await harness.callHook(name) + // Why: settling does not answer the dialog, so the pane still needs the user. + const expected = name === 'session_shutdown' ? IDLE_TITLE : PROMPT_TITLE + expect(harness.lastTitle()).toBe(expected) + // Why: settling stops the spinner but must leave the marker re-assert running, or + // pi's own next title write would silently retire a dialog that is still open. + expect(vi.getTimerCount()).toBe(name === 'session_shutdown' ? 0 : 1) + } + ) + + it('keeps the marker across an idle compaction that finishes under a dialog', async () => { + const harness = createHarness() + + await harness.callHook('ui_prompt_start') + await harness.callHook('auto_compaction_start', { reason: 'idle' }) + await harness.callHook('auto_compaction_end') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + }) + + it('recovers the spinner on a new turn when a dialog close was lost', async () => { + const harness = createHarness() + + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + // Why: a turn cannot start under a dialog holding input focus, so this is recovery. + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('leaves the marker to OMP approval events instead of painting it', () => { + expect(createHarness({ kind: 'omp' }).handlers.ui_prompt_start).toBeUndefined() + }) + + it('still caps idle maintenance while a dialog holds the title', async () => { + const harness = createHarness() + + await harness.callHook('auto_compaction_start', { reason: 'idle' }) + await harness.callHook('ui_prompt_start') + // Why: an open dialog must not suspend the cap that stops a stranded spinner. + vi.advanceTimersByTime(301_000) + + // Why: the spinner is capped, but the marker re-assert survives it — the dialog is + // still open, so the pane must keep reporting that it needs input. + expect(vi.getTimerCount()).toBe(1) + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + }) + + it('survives a dialog event that carries no ui context', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + await expect(harness.handlers.ui_prompt_start?.({}, undefined)).resolves.toBeUndefined() + await expect(harness.handlers.ui_prompt_end?.({}, undefined)).resolves.toBeUndefined() + }) + + it('keeps spinning when the dialog event could not paint the marker', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + await harness.handlers.ui_prompt_start?.({}, undefined) + // Why: suppressing frames without a marker would freeze the title mid-spinner, which + // still reads as working — the opposite of what the marker is for. + await vi.advanceTimersByTimeAsync(160) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('marks a nested dialog when the outer one could not paint', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + await harness.handlers.ui_prompt_start?.({}, undefined) + await harness.callHook('ui_prompt_start') + // Why: the outer ctx cannot decide that the whole stack stays unmarked. + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + }) + + it('clears the marker through the opening ctx when the close carries none', async () => { + const harness = createHarness() + + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + await harness.handlers.ui_prompt_end?.({}, undefined) + // Why: otherwise the pane asks for attention until the next turn. + expect(harness.lastTitle()).toBe(IDLE_TITLE) + }) + + it('does not reject when the dialog ctx can no longer paint', async () => { + const harness = createHarness() + const throwing = { + ui: { + setTitle: () => { + throw new Error('extension runner is no longer active') + } + } + } + + await expect(harness.handlers.ui_prompt_start?.({}, throwing)).resolves.toBeUndefined() + // Why: the marker never went up, so the spinner must not stay suppressed. + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('does not reject when the captured ctx dies before the dialog closes', async () => { + const harness = createHarness() + let live = true + const dying = { + ui: { + setTitle: (title: string) => { + if (!live) { + throw new Error('extension runner is no longer active') + } + harness.titles.push(title) + } + } + } + + await harness.handlers.ui_prompt_start?.({}, dying) + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + live = false + // Why: the close carries no ui, so it falls back to the ctx the modal invalidated. + await expect(harness.handlers.ui_prompt_end?.({}, undefined)).resolves.toBeUndefined() + // Why: a later turn still recovers a clean title through a live ctx. + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('does not strand the marker when the closing ctx throws on ui access', async () => { + const harness = createHarness() + // Why: pi's ctx.ui is a getter that calls assertActive(); a session-replacing dialog + // invalidates the runner, so reading ctx.ui throws rather than yielding undefined. + const stale = { + get ui(): never { + throw new Error('This extension ctx is stale') + } + } + + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + await expect(harness.handlers.ui_prompt_end?.({}, stale as never)).resolves.toBeUndefined() + // Why: the opening ctx still paints, so the pane stops asking for input. + expect(harness.lastTitle()).toBe(IDLE_TITLE) + + // Why: a stranded markerPainted would suppress every later working frame. + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('does not reject when the opening ctx throws on ui access', async () => { + const harness = createHarness() + const stale = { + get ui(): never { + throw new Error('This extension ctx is stale') + } + } + + await harness.callHook('agent_start') + await expect(harness.handlers.ui_prompt_start?.({}, stale as never)).resolves.toBeUndefined() + // Why: no marker went up, so the spinner must keep running. + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('re-asserts the marker when pi repaints the title under a dialog', async () => { + const harness = createHarness() + + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + // Why: pi repaints on session_info_changed/rebindCurrentSession with no event we see, + // so a marker that is merely "not overwritten by us" would be silently lost. + harness.titles.push('π - other - orca-app') + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + }) + + it('re-asserts the marker on an idle pane with no spinner running', async () => { + const harness = createHarness() + + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + // Why: no turn is running, so renderFrame never fires — only the slow re-assert can + // undo a title pi writes from session_info_changed or its update-check restore. + harness.titles.push('\u03c0 - other - orca-app') + await vi.advanceTimersByTimeAsync(1000) + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + await harness.callHook('ui_prompt_end') + expect(harness.lastTitle()).toBe(IDLE_TITLE) + expect(vi.getTimerCount()).toBe(0) + }) + + it('releases the marker when a session replacement drops the dialog', async () => { + const harness = createHarness() + + await harness.callHook('ui_prompt_start') + expect(harness.lastTitle()).toBe(PROMPT_TITLE) + + // Why: pi hides the dialog without resolving it, so no close is coming. + await harness.callHook('session_start', { reason: 'switch' }) + expect(vi.getTimerCount()).toBe(0) + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(80) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('survives a deleted cwd instead of crashing the pi process', async () => { + const harness = createHarness({ + cwdImpl: () => { + throw new Error('ENOENT: uv_cwd') + } + }) + + // Why: these run inside setInterval callbacks, where an escape is an uncaught + // exception and pi exits(1) through its own uncaughtException handler. + await expect(harness.callHook('agent_start')).resolves.toBeUndefined() + await expect(harness.callHook('ui_prompt_start')).resolves.toBeUndefined() + // Why: an unguarded throw in the interval would surface here as an unhandled error. + await vi.advanceTimersByTimeAsync(2000) + await expect(harness.callHook('ui_prompt_end')).resolves.toBeUndefined() + await expect(harness.callHook('agent_settled')).resolves.toBeUndefined() + }) + + it('survives a session name that throws on a stale runtime', async () => { + let live = true + const harness = createHarness({ + sessionNameImpl: () => { + if (!live) { + throw new Error('This extension API is stale') + } + return SESSION + } + }) + + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + live = false + await vi.advanceTimersByTimeAsync(2000) + await expect(harness.callHook('ui_prompt_end')).resolves.toBeUndefined() + }) + + it('leaves the needs-input marker to the process that owns the pane', async () => { + // Why: child agents inherit ORCA_PANE_KEY, and a second process asserting the marker + // would report needs-input for a pane it does not speak for. + const harness = createHarness({ env: { ORCA_PI_TITLE_MARKER_OWNED: '111' } }) + + await harness.callHook('agent_start') + await harness.callHook('ui_prompt_start') + await vi.advanceTimersByTimeAsync(1000) + expect(harness.titles).not.toContain(PROMPT_TITLE) + expect(harness.lastTitle()).toMatch(BRAILLE_RE) + }) + + it('leaves an OMP runtime to its own approval events', () => { + const harness = createHarness({ processTitle: 'omp' }) + + expect(harness.handlers.ui_prompt_start).toBeDefined() + expect(() => harness.handlers.ui_prompt_start?.({}, undefined)).not.toThrow() + }) }) diff --git a/src/main/pi/titlebar-extension-source.ts b/src/main/pi/titlebar-extension-source.ts index a41eafb896f..7fc15c191bc 100644 --- a/src/main/pi/titlebar-extension-source.ts +++ b/src/main/pi/titlebar-extension-source.ts @@ -1,7 +1,54 @@ +import type { PiAgentKind } from '../../shared/pi-agent-kind' +import { getPiOmpRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' + export const ORCA_PI_EXTENSION_FILE = 'orca-titlebar-spinner.ts' -export function getPiTitlebarExtensionSource(): string { +export function getPiTitlebarExtensionSource(kind: PiAgentKind = 'pi'): string { + // Why: OMP reports input waits through its own approval events, which the status + // extension already maps, and it writes this same marker natively. The runtime check + // matters as well as the kind: a bare-shell OMP launch runs inside a pi-kind pane. + const uiPromptHandlers = + kind === 'pi' + ? [ + " pi.on('ui_prompt_start', async (_event, ctx) => {", + ' if (isOmpRuntime() || !ownsMarker) return', + ' promptDepth++', + ' // Why: retry on every open rather than only the outermost, so an outer ctx', + ' // that could not paint cannot decide the whole stack stays unmarked.', + ' if (markerPainted) return', + ' const painter = resolvePainter(ctx)', + ' // Why: only hold the spinner off once the marker is actually up, or a ctx', + ' // that cannot paint would freeze the title on its last working frame.', + " if (!paintTitle(painter, () => getMarkedTitle(pi, '!'))) return", + ' markerPainted = true', + ' promptCtx = painter', + ' startMarkerReassert(painter)', + ' })', + '', + " pi.on('ui_prompt_end', async (_event, ctx) => {", + ' if (isOmpRuntime() || !ownsMarker || promptDepth === 0) return', + ' promptDepth--', + ' if (promptDepth > 0) return', + ' // Why: the opening ctx already painted once, so a close whose own ctx is stale', + ' // does not leave the needs-input marker up until the next turn.', + ' const painter = resolvePainter(ctx) ?? promptCtx', + ' markerPainted = false', + ' promptCtx = null', + ' stopMarkerReassert()', + ' // Why: a still-live turn resumes its spinner in place; otherwise the pane is idle', + ' // and must drop the needs-input marker rather than keep asking for attention.', + ' if (timer) {', + ' renderFrame(painter)', + ' return', + ' }', + ' paintTitle(painter, () => getBaseTitle(pi))', + ' })', + '' + ] + : [] + return [ + ...(kind === 'pi' ? [...getPiOmpRuntimeDetectionSourceLines(`/hook/${kind}`), ''] : []), 'const BRAILLE_FRAMES = [', " '\\u280b',", " '\\u2819',", @@ -16,36 +63,111 @@ export function getPiTitlebarExtensionSource(): string { ']', '', 'const FRAME_INTERVAL_MS = 80', + '// Why: pi repaints the title from its own writers (session_info_changed, the win32', + '// update-check restore) with no event we observe, so the marker has to be re-asserted', + '// even when no spinner frame is due. Coarse on purpose: it only rewrites one string.', + 'const MARKER_REASSERT_MS = 1000', 'const AGENT_END_IDLE_RECHECK_MS = 25', 'const AGENT_END_IDLE_RECHECK_MAX_MS = 250', '// Why: a failed idle compaction can end without auto_compaction_end, and no agent turn will', '// close a maintenance spinner — cap it so idle maintenance cannot strand a working title.', 'const IDLE_COMPACTION_MAX_FRAMES = Math.ceil(300000 / FRAME_INTERVAL_MS)', '', - 'function getBaseTitle(pi) {', + '// Why: `-` is the plain separator; `!` is the state marker Orca reads as needs-input', + '// (src/shared/pi-state-title-marker.ts), so mobile and the CLI see the wait too.', + 'function getMarkedTitle(pi, marker) {', ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', ' const session = pi.getSessionName()', - ' return session ? `\\u03c0 - ${session} - ${cwd}` : `\\u03c0 - ${cwd}`', + ' return session', + ' ? `\\u03c0 ${marker} ${session} - ${cwd}`', + ' : `\\u03c0 ${marker} ${cwd}`', + '}', + '', + 'function getBaseTitle(pi) {', + " return getMarkedTitle(pi, '-')", + '}', + '', + '// Why: the ctx.ui pi passes is a getter that calls assertActive() and throws once a', + '// session-replacing dialog invalidates the runner; optional chaining cannot screen', + '// that out. Read it behind a try and never mutate state before a paint has succeeded.', + 'function resolvePainter(ctx) {', + ' try {', + " return typeof ctx?.ui?.setTitle === 'function' ? ctx : null", + ' } catch {', + ' return null', + ' }', + '}', + '', + '// Why: buildTitle runs inside the try because it is not safe either — getSessionName()', + '// calls assertActive() and process.cwd() throws ENOENT once the worktree is deleted.', + '// Most call sites are timer callbacks, where an escape is an uncaught exception and pi', + '// exits(1) through its own uncaughtException handler.', + 'function paintTitle(ctx, buildTitle) {', + ' if (!ctx) return false', + ' try {', + ' ctx.ui.setTitle(buildTitle())', + ' return true', + ' } catch {', + ' return false', + ' }', '}', '', 'export default function (pi) {', ' if (!process.env.ORCA_PANE_KEY) return', + ...(kind === 'pi' + ? [ + ' // Why: child agents inherit the pane env, and the spinner is harmlessly', + ' // per-process — but the needs-input marker is status the pane reports, so only', + ' // one process may assert it. Mirrors ORCA_PI_STATUS_OWNED in the status hook.', + ' const markerOwnerPid = process.env.ORCA_PI_TITLE_MARKER_OWNED', + ' const ownsMarker = !markerOwnerPid || markerOwnerPid === String(process.pid)', + ' if (ownsMarker) process.env.ORCA_PI_TITLE_MARKER_OWNED = String(process.pid)' + ] + : []), + ' let timer = null', ' let frameIndex = 0', ' // Why: only idle maintenance owns a spinner of its own. A threshold compaction runs', ' // inside an agent turn, whose spinner must outlive it, and any newer start clears the', ' // marker so a late idle completion cannot stop current work (#16470).', ' let idleCompactionOwnsSpinner = false', + ' // Why: pi already collapses nested prompts into one start/end pair, so this counter', + ' // guards a close that never arrives, not nesting. A new turn cannot start under a', + ' // dialog holding input focus, so agent_start doubles as recovery.', + ' let promptDepth = 0', + ' let markerPainted = false', + ' let promptCtx = null', + ' // Why: a separate handle from `timer`, which clearAnimation() nulls — the marker must', + ' // survive a turn settling, a shutdown of the spinner, and the idle-maintenance cap.', + ' let markerTimer = null', ' let pendingAgentEndCheck = null', ' let pendingAgentEndContext = null', ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', '', + ' function resetPromptState() {', + ' stopMarkerReassert()', + ' promptDepth = 0', + ' markerPainted = false', + ' promptCtx = null', + ' }', + '', ' function clearPendingAgentEndCheck() {', ' if (pendingAgentEndCheck !== null) clearTimeout(pendingAgentEndCheck)', ' pendingAgentEndCheck = null', ' pendingAgentEndContext = null', ' }', '', + ' function stopMarkerReassert() {', + ' if (markerTimer) clearInterval(markerTimer)', + ' markerTimer = null', + ' }', + '', + ' function startMarkerReassert(ctx) {', + ' stopMarkerReassert()', + " markerTimer = setInterval(() => paintTitle(ctx, () => getMarkedTitle(pi, '!')), MARKER_REASSERT_MS)", + " if (typeof markerTimer.unref === 'function') markerTimer.unref()", + ' }', + '', ' function clearAnimation() {', ' if (timer) {', ' clearInterval(timer)', @@ -58,19 +180,35 @@ export function getPiTitlebarExtensionSource(): string { ' function stopAnimation(ctx) {', ' clearPendingAgentEndCheck()', ' clearAnimation()', - ' ctx.ui.setTitle(getBaseTitle(pi))', + ' // Why: settling under an open dialog still leaves the pane waiting on the user, so', + ' // the idle title must not retire the marker the dialog is holding.', + " paintTitle(ctx, () => (markerPainted ? getMarkedTitle(pi, '!') : getBaseTitle(pi)))", ' }', '', ' function renderFrame(ctx) {', + ' // Why: the maintenance cap runs before the dialog guard so a dialog left open', + ' // cannot suspend it; stopAnimation keeps the marker while a dialog is open.', ' if (idleCompactionOwnsSpinner && frameIndex >= IDLE_COMPACTION_MAX_FRAMES) {', ' stopAnimation(ctx)', ' return', ' }', - ' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]', - ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', - ' const session = pi.getSessionName()', - ' const title = session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`', - ' ctx.ui.setTitle(title)', + ' // Why: an 80ms working frame would repaint over the needs-input marker within one', + ' // tick, so a mid-turn dialog would still look busy everywhere the title is the', + ' // only evidence. Re-assert rather than skip: pi repaints the title on its own', + ' // (session_info_changed, resetExtensionUI, rebindCurrentSession) and would', + ' // otherwise wipe the marker with nothing to restore it. The frame still counts,', + ' // so the cap above keeps accruing in wall-clock.', + ' if (markerPainted) {', + " paintTitle(ctx, () => getMarkedTitle(pi, '!'))", + ' frameIndex++', + ' return', + ' }', + ' paintTitle(ctx, () => {', + ' const frame = BRAILLE_FRAMES[frameIndex % BRAILLE_FRAMES.length]', + ' const cwd = process.cwd().split(/[\\\\/]/).filter(Boolean).at(-1) || process.cwd()', + ' const session = pi.getSessionName()', + ' return session ? `${frame} \\u03c0 - ${session} - ${cwd}` : `${frame} \\u03c0 - ${cwd}`', + ' })', ' frameIndex++', ' }', '', @@ -101,9 +239,17 @@ export function getPiTitlebarExtensionSource(): string { ' }', '', " pi.on('agent_start', async (_event, ctx) => {", + ' resetPromptState()', ' startAnimation(ctx)', ' })', '', + ' // Why: pi drops an open dialog through resetExtensionUI without resolving its promise,', + ' // so a replaced or reloaded session never sends the matching close. Both boundaries', + ' // prove no dialog from the old session is still on screen.', + " pi.on('session_start', async () => {", + ' resetPromptState()', + ' })', + '', ' // Why: modern Pi/OMP emit agent_end mid-run and only settle later, so settlement is the', ' // authoritative completion boundary. Legacy runtimes never emit it, so agent_end stays.', " pi.on('agent_settled', async (_event, ctx) => {", @@ -126,6 +272,7 @@ export function getPiTitlebarExtensionSource(): string { " if (typeof pendingAgentEndCheck.unref === 'function') pendingAgentEndCheck.unref()", ' })', '', + ...uiPromptHandlers, " pi.on('auto_compaction_start', async (event, ctx) => {", " if (event?.reason !== 'idle') return", ' // Why: the idle worker can fire against a turn that just started, and reason alone does', @@ -142,6 +289,7 @@ export function getPiTitlebarExtensionSource(): string { ' })', '', " pi.on('session_shutdown', async (_event, ctx) => {", + ' resetPromptState()', ' stopAnimation(ctx)', ' })', '}', diff --git a/src/main/runtime/orca-runtime-fence-automation-owner.ts b/src/main/runtime/orca-runtime-automation-operations.ts similarity index 97% rename from src/main/runtime/orca-runtime-fence-automation-owner.ts rename to src/main/runtime/orca-runtime-automation-operations.ts index a90730c7886..fe65979ed1e 100644 --- a/src/main/runtime/orca-runtime-fence-automation-owner.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -23,7 +23,7 @@ import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker import { makePaneKey } from '../../shared/stable-pane-id' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' -export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForegroundProcessReads { +export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForegroundProcessReads { protected fenceAutomationOwner( id: string, expectedOwner: AutomationOwnerPrecondition | undefined, @@ -167,10 +167,6 @@ export class OrcaRuntimeWithFenceAutomationOwner extends OrcaRuntimeWithPtyForeg this.scheduleRestoredMessageRepoints() } - prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan { - return this.legacyWorkerRecovery.prepare() - } - protected async flushWorkspaceSessionOrThrowAsync(): Promise { const store = this.store if (store?.flushPendingOrThrowAsync) { diff --git a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts index 6956644c748..d0425cdc1f9 100644 --- a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts @@ -1,5 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. -import { OrcaRuntimeWithFenceAutomationOwner } from './orca-runtime-fence-automation-owner' +import { OrcaRuntimeWithAutomationOperations } from './orca-runtime-automation-operations' import { resolveTerminalSessionWorktreeId, runtimeWorktreeIdsEqual @@ -26,7 +26,7 @@ import type { ArtifactWriteRequest } from '../../shared/artifacts' -export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithFenceAutomationOwner { +export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends OrcaRuntimeWithAutomationOperations { protected hasExactPersistedTerminalSurfaceIdentity(expected: { worktreeId: string tabId: string diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 9196544a81d..90734841085 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -136,8 +136,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin new RuntimeLegacyWorkerTerminalRecoveryPersistence( () => this.store, () => this.getOrchestrationDb(), - (worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId), - (paneKey, blocked) => this.notifier?.setLegacyWorkerTerminalResumeFence?.(paneKey, blocked) + (worktreeId) => this.tryGetWorkspaceSessionHostIdForWorktree(worktreeId) ) protected readonly legacyWorkerRecovery = new RuntimeLegacyWorkerTerminalRecoveryController({ diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 410798a329d..dae2519ca9f 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -80,6 +80,15 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) }) + const hostScope = this.getOrchestrationCompatibilityHostScope(pty) + if (paneKey && binding?.incarnationId && hostScope) { + this._orchestrationDb?.retainReplacedWorkerTerminalResources({ + paneKey, + worktreeId, + hostScope: JSON.stringify(hostScope), + processIncarnation: `${ptyId}:${binding.incarnationId}` + }) + } const agentLaunchAuthority = binding?.agentLaunchAuthority if ( agentLaunchAuthority && diff --git a/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts b/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts index 484ea73064e..1cef2bbf23a 100644 --- a/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts +++ b/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts @@ -54,16 +54,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp // dispatch contexts immediately, rather than waiting for the coordinator's // next poll cycle. This catches agent crashes and unexpected exits within // milliseconds. The task is set back to 'pending' so it can be re-dispatched. - /** A worker settled by its own process exit makes its pane fenceable now, not at the next app - * start; a fence sweep must never fail the exit path behind it. */ - private sweepSettledWorkerResumeFencesAfterExit(): void { - try { - this.prepareLegacyWorkerTerminalRecovery() - } catch (error) { - console.warn('[orchestration] settled worker resume fence sweep failed', error) - } - } - protected failActiveDispatchOnExit( handle: string, paneKey: string | null, @@ -90,7 +80,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp const stopping = this._orchestrationDb.getWorkerDispatch?.(dispatch.id) if (stopping?.state === 'stopping' && stopping.runtime_epoch === this.getRuntimeId()) { this._orchestrationDb.settleWorkerStop(dispatch.id) - this.sweepSettledWorkerResumeFencesAfterExit() return } @@ -99,7 +88,6 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp workerProcessExited: true, terminationReason: cause.kind }) - this.sweepSettledWorkerResumeFencesAfterExit() if (isDeliberateTerminalExit(cause)) { return } diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 3c61985e597..a2d45395b26 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 @@ -390,7 +390,7 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID]).toBe(1) }) - it('fences provider resume and reveals one exact live legacy worker without stealing focus', async () => { + it('reveals one exact live legacy worker without stealing focus', async () => { const workerLeafId = HEADLESS_LEAF_ID const coordinatorLeafId = HEADLESS_SECOND_LEAF_ID const workerPaneKey = `legacy-worker:${workerLeafId}` @@ -526,10 +526,7 @@ describe('OrcaRuntimeService', () => { resolveLegacyWorkerTerminalRecovery } as never) - runtime.prepareLegacyWorkerTerminalRecovery() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() const recovered = await runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts index 44edd9aa371..00a08310877 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 @@ -17,7 +17,7 @@ import { } from '../orca-runtime-test-scenario-builders.spec' describe('OrcaRuntimeService', () => { - it('retries renderer reveal before clearing an adopted legacy worker resume fence', async () => { + it('retries renderer reveal before clearing an adopted legacy worker sleeping record', async () => { const workerPaneKey = `legacy-worker:${HEADLESS_LEAF_ID}` const incarnationId = '44444444-4444-4444-8444-444444444444' const session: WorkspaceSessionState = { @@ -106,7 +106,7 @@ describe('OrcaRuntimeService', () => { expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith(workerPaneKey, 'adopted') }) - it('keeps a revealed worker fenced until its exact renderer graph is published', async () => { + it('defers a revealed worker until its exact renderer graph is published', async () => { vi.useFakeTimers() try { const harness = makePostRevealWorkerRecoveryHarness(() => true) @@ -288,7 +288,7 @@ describe('OrcaRuntimeService', () => { } }) - it('keeps recovery fenced when the renderer omits the exact reveal identity', async () => { + it('defers recovery when the renderer omits the exact reveal identity', async () => { const harness = makePostRevealWorkerRecoveryHarness(() => false) harness.revealTerminalSession.mockResolvedValue({ tabId: 'legacy-post-reveal' }) @@ -417,7 +417,7 @@ describe('OrcaRuntimeService', () => { ) }) - it('keeps the legacy worker resume fence in memory when persistence fails', async () => { + it('keeps the legacy worker sleeping record in memory when persistence fails', async () => { const workerPaneKey = `legacy-worker:${HEADLESS_LEAF_ID}` const incarnationId = '99999999-9999-4999-8999-999999999999' const session: WorkspaceSessionState = { @@ -526,9 +526,7 @@ describe('OrcaRuntimeService', () => { }) expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2) expect(revealTerminalSession).toHaveBeenCalledOnce() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe( 'concurrent-tab' ) 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 48f820cee01..ea70b730388 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 @@ -334,10 +334,7 @@ describe('OrcaRuntimeService', () => { resolveLegacyWorkerTerminalRecovery } as never) - runtime.prepareLegacyWorkerTerminalRecovery() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ adoptedDispatchIds: ['dispatch-exited-two'], @@ -451,9 +448,7 @@ describe('OrcaRuntimeService', () => { exitedDispatchIds: [], deferredDispatchIds: ['dispatch-inventory-unavailable'] }) - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled() expect(listProcesses).toHaveBeenCalledOnce() expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) @@ -483,7 +478,6 @@ describe('OrcaRuntimeService', () => { try { const runtime = new OrcaRuntimeService(store) const reconcile = vi.spyOn(runtime, 'reconcileLegacyWorkerTerminals').mockResolvedValue({ - blockedPaneCount: 1, adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: [] diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index 5798916570e..389c70d4f42 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 @@ -18,13 +18,12 @@ import { TEST_WORKTREE_PATH, makeFolderProjectGroup, makeFolderWorkspace, - makeRuntimeStoreWithWorkspaceSession, - store + makeRuntimeStoreWithWorkspaceSession } from '../orca-runtime-test-fixtures.spec' import { publishLegacyWorkerReveal } from '../orca-runtime-test-scenario-builders.spec' describe('OrcaRuntimeService', () => { - it('keeps live workers fenced without exact controller identity evidence', async () => { + it('defers live workers without exact controller identity evidence', async () => { const incarnationId = '56565656-5656-4656-8656-565656565656' const cases = [ { @@ -152,8 +151,7 @@ describe('OrcaRuntimeService', () => { for (const { name, leafId } of cases.slice(0, 2)) { expect( getSession().sleepingAgentSessionsByPaneKey?.[`legacy-${name}:${leafId}`] - ?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + ).toBeDefined() } for (const { name, leafId } of cases.slice(2)) { expect( @@ -374,12 +372,7 @@ describe('OrcaRuntimeService', () => { } as never) try { - expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({ - blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey })] - }) - expect( - sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(sshSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() await expect( runtime.reconcileLegacyWorkerTerminals({ @@ -422,74 +415,4 @@ describe('OrcaRuntimeService', () => { } }) }) - - it('fences an unresolved folder legacy worker in its exact retained session partition', () => { - const connectionId = 'ssh-unresolved-folder' - const worktreeId = 'folder:missing-folder' - const workerPaneKey = `legacy-unresolved-folder-worker:${HEADLESS_LEAF_ID}` - const remoteInitialSession: WorkspaceSessionState = { - ...getDefaultWorkspaceSession(), - tabsByWorktree: { [worktreeId]: [] }, - sleepingAgentSessionsByPaneKey: { - [workerPaneKey]: { - paneKey: workerPaneKey, - tabId: 'legacy-unresolved-folder-worker', - worktreeId, - agent: 'codex', - providerSession: { key: 'session_id', id: 'legacy-unresolved-folder-session' }, - prompt: 'continue', - state: 'working', - capturedAt: 1, - updatedAt: 1, - origin: 'live', - connectionId - } - } - } - const localSession = getDefaultWorkspaceSession() - let remoteSession = remoteInitialSession - const getWorkspaceSession = vi.fn((hostId?: string | null) => - hostId === `ssh:${connectionId}` ? remoteSession : localSession - ) - const setWorkspaceSession = vi.fn((next: WorkspaceSessionState, hostId?: string | null) => { - if (hostId !== `ssh:${connectionId}`) { - throw new Error(`unexpected workspace-session host ${hostId ?? 'default'}`) - } - remoteSession = next - }) - const runtime = new OrcaRuntimeService({ - ...store, - getFolderWorkspaces: () => [], - getWorkspaceSession, - getWorkspaceSessionHostIds: () => ['local', `ssh:${connectionId}`], - setWorkspaceSession, - flushOrThrow: vi.fn() - } as never) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-unresolved-folder', - task_id: 'task-unresolved-folder', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_unresolved_folder', - assignee_pane_key: workerPaneKey, - process_incarnation: 'pty-unresolved-folder:68686868-6868-4868-8868-686868686868', - worker_state: 'ready', - worktree_id: worktreeId, - agent_terminal_handle: 'term_unresolved_folder' - } - ] - } as unknown as OrchestrationDb) - - expect(runtime.prepareLegacyWorkerTerminalRecovery()).toMatchObject({ - blockedPanes: [expect.objectContaining({ paneKey: workerPaneKey, worktreeId })] - }) - expect( - remoteSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') - expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - expect(setWorkspaceSession).toHaveBeenCalledOnce() - expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`) - }) }) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 4078a291ab5..1907b2bb450 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,11 +153,8 @@ describe('OrcaRuntimeService', () => { deferredDispatchIds: ['dispatch-ssh'] }) expect(listProcesses).not.toHaveBeenCalled() - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() expect(localSession.sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() - expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) await expect( runtime.reconcileLegacyWorkerTerminals({ @@ -175,6 +172,7 @@ describe('OrcaRuntimeService', () => { } expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() + expect(getWorkspaceSession).toHaveBeenCalledWith(`ssh:${connectionId}`) expect(setWorkspaceSession).toHaveBeenCalledWith(expect.any(Object), `ssh:${connectionId}`) expect(listProcesses).toHaveBeenCalledTimes(3) expect(revealTerminalSession).toHaveBeenCalledWith(TEST_WORKTREE_ID, { @@ -297,9 +295,7 @@ describe('OrcaRuntimeService', () => { exitedDispatchIds: [], deferredDispatchIds: ['dispatch-wsl'] }) - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeDefined() expect(revealTerminalSession).not.toHaveBeenCalled() observedDistro = 'Ubuntu' diff --git a/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts b/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts index 21816c4492a..a747ae07a51 100644 --- a/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts +++ b/src/main/runtime/orchestration/db-stopping-worker-task-guard.test.ts @@ -12,7 +12,7 @@ describe('a Task whose supervised worker is stopping', () => { afterEach(() => db.close()) function localWorker() { - const task = db.createTask({ spec: 'local work' }) + const task = db.createTask({ runId: 'run_legacy_local', spec: 'local work' }) const { dispatch } = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {}, @@ -59,7 +59,7 @@ describe('a Task whose supervised worker is stopping', () => { }) it('control: still accepts dispatched for an active Dispatch with no supervised worker', () => { - const task = db.createTask({ spec: 'unsupervised work' }) + const task = db.createTask({ runId: 'run_legacy_local', spec: 'unsupervised work' }) createRootDispatch(db, task.id, 'term_worker') expect(db.updateTaskStatus(task.id, 'dispatched')?.status).toBe('dispatched') diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts index 97179eb0185..410318bf9c6 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-terminal-recovery.ts @@ -9,7 +9,6 @@ import { DISPATCH_CIRCUIT_BREAK_FAILURES } from '../dispatch-context/dispatch-ci import type { OrchestrationDb } from '../orchestration-db' import { reconcileTaskAfterDispatchInterruption } from '../dispatch-context/task-dispatch-reconciliation' import { transitionLifecycleWithDb } from '../lifecycle-transition' -import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership' export function listLegacyWorkerTerminalRecoveryRows( this: OrchestrationDb @@ -23,18 +22,9 @@ export function listLegacyWorkerTerminalRecoveryRows( FROM dispatch_contexts dc INNER JOIN worker_dispatches wd ON wd.dispatch_id = dc.id WHERE wd.state IN ('starting', 'ready', 'start_unknown', 'stopping', 'stop_unknown') - -- A settled worker whose terminal orchestration still owns keeps a resumable agent - -- session; it needs the resume fence until release or retain retires the pane. - OR (wd.state IN (${WORKER_SETTLED_STATES.map(() => '?').join(', ')}) - AND EXISTS ( - SELECT 1 FROM worker_terminal_resources wtr - WHERE wtr.owner_dispatch_id = dc.id - AND wtr.ownership_state = 'owned' - AND wtr.release_state NOT IN ('released', 'retained') - )) ORDER BY dc.rowid` ) - .all(...WORKER_SETTLED_STATES) as LegacyWorkerTerminalRecoveryRow[] + .all() as LegacyWorkerTerminalRecoveryRow[] } export function reconcileMissingWorkerTerminal( diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index 1d7232107b1..e6942503dbf 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -2,6 +2,7 @@ import type { WorkerTerminalResourceRow, WorkerTerminalOwnershipState } from '../../worker-terminal-ownership' +import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership' import { OrchestrationError } from '../../orchestration-error' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' @@ -199,9 +200,40 @@ export function transferWorkerTerminalResourceStatement( return this.getWorkerTerminalResource(params.resourceId) as WorkerTerminalResourceRow } +// A new process in the same pane is ordinary user work, not the settled Dispatch's resource. +export function retainReplacedWorkerTerminalResources( + this: OrchestrationDb, + params: { paneKey: string; worktreeId: string; hostScope: string; processIncarnation: string } +): number { + return Number( + this.db + .prepare( + `UPDATE worker_terminal_resources + SET release_state = 'retained', retained_reason = 'identity_unproven', + updated_at = datetime('now') + WHERE pane_key = ? AND worktree_id = ? AND host_scope = ? + AND process_incarnation IS NOT NULL AND process_incarnation != ? + AND ownership_state = 'owned' AND release_state = 'not_requested' + AND EXISTS ( + SELECT 1 FROM worker_dispatches w + WHERE w.dispatch_id = worker_terminal_resources.owner_dispatch_id + AND w.state IN (${WORKER_SETTLED_STATES.map(() => '?').join(', ')}) + )` + ) + .run( + params.paneKey, + params.worktreeId, + params.hostScope, + params.processIncarnation, + ...WORKER_SETTLED_STATES + ).changes + ) +} + // Finds an owned, settled, exact-match resource for an explicitly reused terminal. export type WorkerTerminalResourceStoreMethods = { + retainReplacedWorkerTerminalResources: typeof retainReplacedWorkerTerminalResources backfillWorkerTerminalResources: typeof backfillWorkerTerminalResources createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource @@ -214,6 +246,7 @@ export type WorkerTerminalResourceStoreMethods = { export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { + retainReplacedWorkerTerminalResources, backfillWorkerTerminalResources, createWorkerTerminalResourceStatement, getWorkerTerminalResource, diff --git a/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts b/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts index abb0b7bdfcc..7d0f0931263 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts @@ -26,14 +26,6 @@ function recoveryRow( describe('legacy worker terminal recovery planning', () => { it('retains completed Dispatches when the worker process row is still live', () => { expect(planLegacyWorkerTerminalRecovery([recoveryRow()])).toEqual({ - blockedPanes: [ - { - worktreeId: 'repo::/workspace', - paneKey: `tab-worker:${LEAF_ID}`, - contractVersion: 0, - settled: false - } - ], candidates: [ expect.objectContaining({ dispatchId: 'dispatch-1', @@ -45,18 +37,10 @@ describe('legacy worker terminal recovery planning', () => { }) }) - it('blocks resume but refuses recovery when durable handles disagree', () => { + it('refuses recovery when durable handles disagree', () => { expect( planLegacyWorkerTerminalRecovery([recoveryRow({ agent_terminal_handle: 'term-replacement' })]) ).toEqual({ - blockedPanes: [ - { - worktreeId: 'repo::/workspace', - paneKey: `tab-worker:${LEAF_ID}`, - contractVersion: 0, - settled: false - } - ], candidates: [], ambiguousDispatchIds: [] }) @@ -70,8 +54,6 @@ describe('legacy worker terminal recovery planning', () => { expect(plan.candidates).toEqual([expect.objectContaining({ dispatchId: 'dispatch-live' })]) expect(plan.ambiguousDispatchIds).toEqual([]) - // A live dispatch still holds this pane, so it must not be reported as a settled fence. - expect(plan.blockedPanes).toEqual([expect.objectContaining({ settled: false })]) }) it('fails closed when two Dispatches claim one terminal identity', () => { @@ -82,7 +64,6 @@ describe('legacy worker terminal recovery planning', () => { expect(plan.candidates).toEqual([]) expect(plan.ambiguousDispatchIds).toEqual(['dispatch-1', 'dispatch-2']) - expect(plan.blockedPanes).toHaveLength(1) }) it('does not trust malformed pane or process identities', () => { @@ -94,7 +75,6 @@ describe('legacy worker terminal recovery planning', () => { ]) expect(plan).toEqual({ - blockedPanes: [], candidates: [], ambiguousDispatchIds: [] }) diff --git a/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts b/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts index 8b1426cb07e..7a159a89ea0 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts @@ -19,16 +19,7 @@ export type LegacyWorkerTerminalRecoveryCandidate = { incarnationId: PtyIncarnationId } -export type LegacyWorkerTerminalRecoveryBlockedPane = { - worktreeId: string - paneKey: string - contractVersion: number - /** The dispatch reported an outcome; its pane needs the fence but owns no process to recover. */ - settled: boolean -} - export type LegacyWorkerTerminalRecoveryPlan = { - blockedPanes: LegacyWorkerTerminalRecoveryBlockedPane[] candidates: LegacyWorkerTerminalRecoveryCandidate[] ambiguousDispatchIds: string[] } @@ -65,26 +56,13 @@ function countCandidateKeys( export function planLegacyWorkerTerminalRecovery( rows: readonly LegacyWorkerTerminalRecoveryRow[] ): LegacyWorkerTerminalRecoveryPlan { - const blockedPanes = new Map() const parsedCandidates: LegacyWorkerTerminalRecoveryCandidate[] = [] for (const row of rows) { const worktreeId = row.worktree_id?.trim() const paneKey = row.assignee_pane_key?.trim() const pane = paneKey ? parsePaneKey(paneKey) : null const settled = WORKER_SETTLED_STATES.includes(row.worker_state) - if (worktreeId && paneKey && pane) { - const blockedKey = `${worktreeId}\0${paneKey}` - const alreadySettled = blockedPanes.get(blockedKey)?.settled - blockedPanes.set(blockedKey, { - worktreeId, - paneKey, - contractVersion: row.contract_version, - // A pane reused across dispatches is settled only once every dispatch holding it is. - settled: (alreadySettled ?? true) && settled - }) - } - // A settled worker owns no live process to adopt or roll back, so its identity must never - // compete with a running worker's in the ambiguity count below. + // Settled dispatches need no adoption and must not make an active worker's identity ambiguous. if (settled) { continue } @@ -134,7 +112,6 @@ export function planLegacyWorkerTerminalRecovery( return !ambiguous }) return { - blockedPanes: [...blockedPanes.values()], candidates, ambiguousDispatchIds: [...ambiguousDispatchIds] } diff --git a/src/main/runtime/orchestration/orchestration-settled-worker-resume-fence-db.test.ts b/src/main/runtime/orchestration/orchestration-settled-worker-resume-fence-db.test.ts deleted file mode 100644 index 5cde241093d..00000000000 --- a/src/main/runtime/orchestration/orchestration-settled-worker-resume-fence-db.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { OrchestrationDb } from './db' -import { planLegacyWorkerTerminalRecovery } from './orchestration-legacy-worker-terminal-recovery' -import type { WorkerTerminalResourceRow } from './worker-terminal-ownership' - -const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333' - -describe('settled worker terminal resume fence rows', () => { - let db: OrchestrationDb | undefined - - afterEach(() => db?.close()) - - function createReadyWorker(): { db: OrchestrationDb; taskId: string; dispatchId: string } { - const d = new OrchestrationDb(':memory:') - db = d - const task = d.createTask({ runId: 'run_legacy_local', spec: 'settled worker' }) - const started = d.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - d.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: 'repo::worktree', - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - d.markWorkerDispatchReady(started.dispatch.id) - return { db: d, taskId: task.id, dispatchId: started.dispatch.id } - } - - /** Asserts the `requested` arm so the resource row is non-null for the caller. */ - function requestRelease(d: OrchestrationDb, dispatchId: string): WorkerTerminalResourceRow { - const requested = d.requestWorkerTerminalRelease(dispatchId) - if (requested.disposition !== 'requested') { - throw new Error(`expected a release request, got ${requested.disposition}`) - } - return requested.resource - } - - function settle(d: OrchestrationDb, taskId: string, dispatchId: string): void { - expect( - d.settleWorkerReport({ - taskId, - dispatchId, - outcome: 'succeeded', - result: 'worker succeeded' - }).action - ).toBe('settled') - } - - it('keeps a settled-but-unreleased worker terminal in the recovery rows', () => { - const { db: d, taskId, dispatchId } = createReadyWorker() - settle(d, taskId, dispatchId) - - expect(d.getWorkerDispatch(dispatchId)?.state).toBe('succeeded') - expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([ - expect.objectContaining({ - dispatch_id: dispatchId, - worker_state: 'succeeded', - assignee_pane_key: PANE_KEY - }) - ]) - }) - - // A settled worker owns no live process, so it must only fence — never be offered for adoption. - it('plans a settled pane as a fence with no adoption candidate', () => { - const { db: d, taskId, dispatchId } = createReadyWorker() - settle(d, taskId, dispatchId) - - const plan = planLegacyWorkerTerminalRecovery(d.listLegacyWorkerTerminalRecoveryRows()) - - expect(plan.blockedPanes).toEqual([ - expect.objectContaining({ paneKey: PANE_KEY, settled: true }) - ]) - expect(plan.candidates).toEqual([]) - expect(plan.ambiguousDispatchIds).toEqual([]) - }) - - // `release_unknown` is the ticket's own repro: release could not be proven, the pane keeps a - // resumable provider session, and dropping it here would re-open the auto-resume. - it('keeps a settled worker terminal whose release could not be proven', () => { - const { db: d, taskId, dispatchId } = createReadyWorker() - settle(d, taskId, dispatchId) - const resource = requestRelease(d, dispatchId) - expect( - d.markWorkerTerminalReleaseUnknown(resource.id, 'terminal no longer resolves').release_state - ).toBe('unknown') - - expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([ - expect.objectContaining({ dispatch_id: dispatchId, assignee_pane_key: PANE_KEY }) - ]) - }) - - it('drops a settled worker terminal once its resource is released', () => { - const { db: d, taskId, dispatchId } = createReadyWorker() - settle(d, taskId, dispatchId) - const resource = requestRelease(d, dispatchId) - expect(d.settleWorkerTerminalRelease(resource.id).release_state).toBe('released') - - expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([]) - }) - - it('drops a settled worker terminal the user chose to retain', () => { - const { db: d, taskId, dispatchId } = createReadyWorker() - d.retainWorkerTerminalResource(dispatchId) - settle(d, taskId, dispatchId) - - expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([]) - }) - - it('drops a settled worker terminal the user took over', () => { - const { db: d, taskId, dispatchId } = createReadyWorker() - settle(d, taskId, dispatchId) - expect(d.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1) - - expect(d.listLegacyWorkerTerminalRecoveryRows()).toEqual([]) - }) -}) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index fbc8f263cd0..ed89ae4519d 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -1,5 +1,4 @@ import type { RpcMethod } from '../core' -import { sweepingSettledWorkerResumeFences } from './settled-worker-resume-fence-sweep' import { ORCHESTRATION_RUN_METHODS } from './orchestration/runs/runs' import { ORCHESTRATION_WORKER_METHODS } from './orchestration/worker/worker-methods' import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration/federation/federation-methods' @@ -24,4 +23,4 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ ...ORCHESTRATION_ASK_METHODS, ...ORCHESTRATION_GATE_METHODS, ...ORCHESTRATION_RESET_METHODS -].map(sweepingSettledWorkerResumeFences) +] diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts index c7386acd49f..807e709003a 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts @@ -7,7 +7,6 @@ import type { SendParams } from '../schemas' import { legacyWorkerDeliveryContract } from '../routing' import { exposeMessage } from './mailbox-message-receipt' import { recordReceiptForPostCommitNudge } from './mutation-replay-nudge' -import { sweepSettledWorkerResumeFences } from '../../settled-worker-resume-fence-sweep' import type { SendRecipientWarning } from './recipient-routing' import type { z } from 'zod' @@ -150,11 +149,6 @@ export function sendPointToPointMessage(args: { ? db.commitWorkerDoneMessageMutation(commitMessage) : commitMessage() committed.nudge() - if (messageType === 'worker_done') { - // Settlement is what makes the pane fenceable; without this the fence only appeared at the - // next app start and reopening the pane in the same session respawned the agent. - sweepSettledWorkerResumeFences(runtime) - } return committed.receipt } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts index 5207b83c8de..e6466ed48c4 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts @@ -322,18 +322,36 @@ describe('orchestration worker release', () => { expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.ownership_state).toBe('user_owned') }) - it('retains when the exact process identity changed instead of closing', async () => { + it('keeps a resumed settled worker retained in worker-list without re-dispatch or release', async () => { h.setup() - const { dispatchId } = await h.startSettledWorker() + const { dispatchId, taskId } = await h.startSettledWorker() + const dispatch = h.db.getDispatchContextById(dispatchId) + const task = h.db.getTask(taskId) + vi.mocked(h.runtime.createTerminal).mockClear() + vi.mocked(h.runtime.sendTerminalAgentPrompt).mockClear() vi.mocked(h.runtime.getTerminalProcessIncarnation).mockImplementation((handle) => handle === 'term_worker' ? 'runtime_test:term_worker:2' : null ) - const receipt = (await h.call('orchestration.workerRelease', { dispatch: dispatchId })) as { - state: string - reason?: string + + await expect( + h.call('orchestration.workerRelease', { dispatch: dispatchId }) + ).resolves.toMatchObject({ + state: 'retained', + reason: 'identity_unproven', + processAction: 'none' + }) + const listed = (await h.call('orchestration.workerList', { run: h.activeRunId })) as { + workers: { dispatchId: string; terminalState: string; workerState: string }[] } - expect(receipt).toMatchObject({ state: 'retained', reason: 'identity_unproven' }) + expect(listed.workers).toEqual([ + expect.objectContaining({ dispatchId, terminalState: 'retained', workerState: 'succeeded' }) + ]) + expect(h.db.getTask(taskId)).toEqual(task) + expect(h.db.getDispatchContextById(dispatchId)).toEqual(dispatch) + expect(h.db.getWorkerTerminalResourceByOwner(dispatchId)?.release_state).toBe('retained') expect(h.runtime.closeTerminal).not.toHaveBeenCalled() + expect(h.runtime.createTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) it('retains when the terminal host scope changed instead of closing', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts index 2a24a3efd0e..5d219d76591 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-release.ts @@ -10,7 +10,6 @@ import { type WorkerReleaseReceipt } from './worker-release-completion' import { WorkerDispatchParams, WorkerRetainParams } from './worker-release-schemas' -import { sweepSettledWorkerResumeFences } from '../../settled-worker-resume-fence-sweep' export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ defineMethod({ @@ -148,11 +147,6 @@ export const ORCHESTRATION_WORKER_RELEASE_METHODS: RpcMethod[] = [ const changed = paneKey ? runtime.getOrchestrationDb().markWorkerTerminalUserOwned(paneKey) : 0 - if (changed > 0) { - // Only a real takeover retires the resource; ordinary panes report here too and must not - // pay for a plan read on every keystroke window. - sweepSettledWorkerResumeFences(runtime) - } return { changed } } }) diff --git a/src/main/runtime/rpc/methods/settled-worker-resume-fence-sweep.ts b/src/main/runtime/rpc/methods/settled-worker-resume-fence-sweep.ts deleted file mode 100644 index e3aac0e5803..00000000000 --- a/src/main/runtime/rpc/methods/settled-worker-resume-fence-sweep.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { OrcaRuntimeService } from '../../orca-runtime' -import type { RpcMethod } from '../core' - -/** - * One pass both stamps the automatic-resume fence on every settled worker pane and lifts it from - * every pane the recovery plan no longer claims. A fenced pane refuses a fresh spawn, so any path - * that drops a worker's row from that plan — release, user retain, user takeover — has to run the - * sweep in the same call, or the fence outlives its dispatch and the pane stays unspawnable until - * the next app start. Failures are swallowed: a fence sweep must never fail the RPC behind it. - */ -export function sweepSettledWorkerResumeFences(runtime: OrcaRuntimeService): void { - try { - runtime.prepareLegacyWorkerTerminalRecovery() - } catch (error) { - console.warn('[orchestration] settled worker resume fence sweep failed', error) - } -} - -/** Settling a worker is what makes its pane fenceable, and release/retain/takeover are what make it - * unfenceable again — so every one of those has to sweep in the same call. Without the settlement - * half the fence only appeared at the next app start, and reopening the pane in the same session - * respawned the agent. */ -const FENCE_SWEEPING_METHOD_NAMES = new Set([ - 'orchestration.workerRelease', - 'orchestration.workerRetain', - 'orchestration.workerStop', - 'orchestration.workerAbandon', - // Reusing a settled worker's pane for a new Dispatch drops the old row from the plan; without - // this the stale fence stays on the pane it just relaunched into. - 'orchestration.workerStart' -]) - -export function sweepingSettledWorkerResumeFences(method: RpcMethod): RpcMethod { - if (!FENCE_SWEEPING_METHOD_NAMES.has(method.name)) { - return method - } - return { - ...method, - handler: async (params, ctx) => { - const result = await method.handler(params, ctx) - sweepSettledWorkerResumeFences(ctx.runtime) - return result - } - } -} diff --git a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts index cbb28e20336..ce034d8e349 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-controller.ts @@ -22,10 +22,6 @@ export class RuntimeLegacyWorkerTerminalRecoveryController { constructor(private readonly ports: LegacyWorkerRecoveryPorts) {} - prepare(): LegacyWorkerTerminalRecoveryPlan { - return this.ports.preparePlan() - } - reconcile( options: LegacyWorkerRecoveryOptions = {} ): Promise { 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 613718de7e5..df794567737 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,4 @@ -import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import type { ExecutionHostId } from '../../shared/execution-host' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' import type { OrchestrationDb } from './orchestration/db' @@ -18,144 +18,11 @@ export class RuntimeLegacyWorkerTerminalRecoveryPersistence { constructor( private readonly getStore: () => RuntimeStore | null, private readonly getDb: () => OrchestrationDb, - private readonly getHostId: (worktreeId: string) => ExecutionHostId | null, - /** The store write only reaches the next app start; a live renderer holds its own copy. */ - private readonly notifyFenceChanged?: (paneKey: string, blocked: boolean) => void + private readonly getHostId: (worktreeId: string) => ExecutionHostId | null ) {} - /** Panes announced as fenced before any sleeping record existed; the only place a lift for one - * can come from, because `liftRetiredFences` can only see panes that already have a record. */ - private readonly announcedBlockedPaneKeys = new Set() - prepare(): LegacyWorkerTerminalRecoveryPlan { - const plan = this.getPlan() - if (!plan) { - // An unreadable plan is not evidence that any pane stopped needing its fence: stamp - // nothing, lift nothing, retry on the next pass. - return { blockedPanes: [], candidates: [], ambiguousDispatchIds: [] } - } - const store = this.getStore() - if ( - !store?.getWorkspaceSession || - !store.setWorkspaceSession || - (!store.flushPendingOrThrowAsync && !store.flushOrThrow) - ) { - return plan - } - const sessions = new Map< - ExecutionHostId, - { current: WorkspaceSessionState; next: WorkspaceSessionState } - >() - const changedHostIds = new Set() - const fenceChanges: [string, boolean][] = [] - for (const blocked of plan.blockedPanes) { - // A worker can settle while its tab is still open, so there is no sleeping record to stamp - // yet. Tell the live renderer anyway: it mints the record on close and must fence it there. - if (!this.announcedBlockedPaneKeys.has(blocked.paneKey)) { - this.announcedBlockedPaneKeys.add(blocked.paneKey) - fenceChanges.push([blocked.paneKey, true]) - } - let hostIds: ExecutionHostId[] - try { - const hostId = this.getHostId(blocked.worktreeId) - if (!hostId) { - throw new Error('folder_workspace_not_found') - } - hostIds = [hostId] - } catch (error) { - console.warn('[orchestration] legacy worker resume fence owner is unavailable', { - worktreeId: blocked.worktreeId, - error - }) - hostIds = store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID] - } - for (const hostId of hostIds) { - let state = sessions.get(hostId) - if (!state) { - const current = store.getWorkspaceSession(hostId) - if (!current) { - continue - } - state = { current, next: structuredClone(current) } - sessions.set(hostId, state) - } - const record = state.next.sleepingAgentSessionsByPaneKey?.[blocked.paneKey] - if ( - !record || - !runtimeWorktreeIdsEqual(record.worktreeId, blocked.worktreeId) || - record.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ) { - continue - } - state.next.sleepingAgentSessionsByPaneKey = { - ...state.next.sleepingAgentSessionsByPaneKey, - [blocked.paneKey]: { ...record, automaticResumeBlockedBy: 'legacy-orchestration-worker' } - } - changedHostIds.add(hostId) - } - } - this.liftRetiredFences(store, plan, sessions, changedHostIds, fenceChanges) - const changed = [...sessions].filter(([hostId]) => changedHostIds.has(hostId)) - try { - for (const [hostId, state] of changed) { - store.setWorkspaceSession(state.next, hostId) - } - } catch (error) { - console.warn('[orchestration] failed to stage legacy worker resume fence', error) - return plan - } - for (const [paneKey, blocked] of fenceChanges) { - this.notifyFenceChanged?.(paneKey, blocked) - } - return plan - } - - /** A fence that outlives its dispatch leaves a pane that can never spawn again, so release, - * retain, user takeover and dispatch pruning — each of which drops the row from the plan — - * retire it here. An unreadable plan yields no blocked panes, so callers must not sweep. */ - private liftRetiredFences( - store: RuntimeStore, - plan: LegacyWorkerTerminalRecoveryPlan, - sessions: Map, - changedHostIds: Set, - fenceChanges: [string, boolean][] - ): void { - const blockedPaneKeys = new Set(plan.blockedPanes.map((blocked) => blocked.paneKey)) - for (const paneKey of this.announcedBlockedPaneKeys) { - if (!blockedPaneKeys.has(paneKey)) { - this.announcedBlockedPaneKeys.delete(paneKey) - fenceChanges.push([paneKey, false]) - } - } - for (const hostId of store.getWorkspaceSessionHostIds?.() ?? [LOCAL_EXECUTION_HOST_ID]) { - const staged = sessions.get(hostId) - const session = staged?.next ?? store.getWorkspaceSession?.(hostId) - const retired = Object.entries(session?.sleepingAgentSessionsByPaneKey ?? {}).filter( - ([paneKey, record]) => - record.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - !blockedPaneKeys.has(paneKey) - ) - if (retired.length === 0) { - continue - } - let state = staged - if (!state) { - const current = store.getWorkspaceSession?.(hostId) - if (!current) { - continue - } - state = { current, next: structuredClone(current) } - sessions.set(hostId, state) - } - const next = { ...state.next.sleepingAgentSessionsByPaneKey } - for (const [paneKey, record] of retired) { - const { automaticResumeBlockedBy: _retired, ...unfenced } = record - next[paneKey] = unfenced - fenceChanges.push([paneKey, false]) - } - state.next.sleepingAgentSessionsByPaneKey = next - changedHostIds.add(hostId) - } + return this.getPlan() ?? { candidates: [], ambiguousDispatchIds: [] } } async persist( 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 bd15abc7d4d..6bbe3b2ed2f 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-runner.ts @@ -104,7 +104,6 @@ export async function runLegacyWorkerTerminalRecovery( exitedDispatchIds.push(candidate.dispatchId) } const result = { - blockedPaneCount: plan.blockedPanes.length, 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 c65afd73952..16ca330647c 100644 --- a/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts +++ b/src/main/runtime/runtime-legacy-worker-terminal-recovery-types.ts @@ -5,7 +5,6 @@ import type { PtyControllerInventory } from './runtime-pty-controller-contract' import type { ResolvedWorktree } from './runtime-worktree-path-identity' export type LegacyWorkerTerminalRecoveryResult = { - blockedPaneCount: number adoptedDispatchIds: string[] exitedDispatchIds: string[] deferredDispatchIds: string[] 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 deleted file mode 100644 index 4d1f81869d5..00000000000 --- a/src/main/runtime/runtime-legacy-worker-terminal-resume-fence.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { getDefaultWorkspaceSession } from '../../shared/constants' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { OrchestrationDb } from './orchestration/db' -import { OrcaRuntimeService } from './orca-runtime' -import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' -import { RuntimeLegacyWorkerTerminalRecoveryPersistence } from './runtime-legacy-worker-terminal-recovery-persistence' -import type { RuntimeStore } from './runtime-store-contract' - -const PANE_KEY = 'tab_worker:33333333-3333-4333-8333-333333333333' -const WORKTREE_ID = 'repo::worktree' - -function sessionWithSleepingWorker(): WorkspaceSessionState { - return { - ...getDefaultWorkspaceSession(), - sleepingAgentSessionsByPaneKey: { - [PANE_KEY]: { - paneKey: PANE_KEY, - tabId: 'tab_worker', - worktreeId: WORKTREE_ID, - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: '', - state: 'done', - capturedAt: 1, - updatedAt: 1, - origin: 'live' - } - } - } as WorkspaceSessionState -} - -describe('settled worker automatic-resume fence persistence', () => { - let db: OrchestrationDb | undefined - - afterEach(() => db?.close()) - - function harness( - onFenceChanged?: (paneKey: string, blocked: boolean) => void, - /** False models a worker that settles while its tab is still open: no record to stamp yet. */ - withSleepingRecord = true - ): { - db: OrchestrationDb - taskId: string - dispatchId: string - persistence: RuntimeLegacyWorkerTerminalRecoveryPersistence - fence: () => string | undefined - } { - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = withSleepingRecord - ? sessionWithSleepingWorker() - : (getDefaultWorkspaceSession() as WorkspaceSessionState) - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const task = orchestrationDb.createTask({ runId: 'run_legacy_local', spec: 'fence me' }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - return { - db: orchestrationDb, - taskId: task.id, - dispatchId: started.dispatch.id, - persistence: new RuntimeLegacyWorkerTerminalRecoveryPersistence( - () => store, - () => orchestrationDb, - () => LOCAL_EXECUTION_HOST_ID, - onFenceChanged - ), - fence: () => session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy - } - } - - function settle(d: OrchestrationDb, taskId: string, dispatchId: string): void { - expect( - d.settleWorkerReport({ taskId, dispatchId, outcome: 'succeeded', result: 'done' }).action - ).toBe('settled') - } - - it('pushes the fence to the live renderer instead of waiting for the next app start', () => { - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked])) - settle(h.db, h.taskId, h.dispatchId) - - h.persistence.prepare() - - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - }) - - it('announces the fence for a pane that has no sleeping record to stamp yet', () => { - const fenceChanges: [string, boolean][] = [] - const h = harness((paneKey, blocked) => fenceChanges.push([paneKey, blocked]), false) - settle(h.db, h.taskId, h.dispatchId) - - h.persistence.prepare() - expect(fenceChanges).toEqual([[PANE_KEY, true]]) - - const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) - h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) - h.persistence.prepare() - - // A fence the plan no longer claims must be lifted even with no record to read it from. - expect(fenceChanges).toEqual([ - [PANE_KEY, true], - [PANE_KEY, false] - ]) - }) - - // The STA-4577 repro: worker_done, no release, restart, open the worktree — the pane still - // holds a resumable provider session and must not respawn `codex resume`. - it('fences a settled worker pane whose terminal was never released', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) - - h.persistence.prepare() - - expect(h.fence()).toBe('legacy-orchestration-worker') - }) - - it('lifts the fence once release retires the terminal resource', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) - h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') - - const requested = h.db.requestWorkerTerminalRelease(h.dispatchId) - expect(requested.disposition).toBe('requested') - h.db.settleWorkerTerminalRelease((requested as { resource: { id: string } }).resource.id) - h.persistence.prepare() - - expect(h.fence()).toBeUndefined() - }) - - it('lifts the fence when the user takes the pane over', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) - h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') - - expect(h.db.markWorkerTerminalUserOwned(PANE_KEY)).toBe(1) - h.persistence.prepare() - - expect(h.fence()).toBeUndefined() - }) - - // An unreadable plan is not evidence a pane stopped needing its fence. - it('keeps the fence when the recovery plan cannot be read', () => { - const h = harness() - settle(h.db, h.taskId, h.dispatchId) - h.persistence.prepare() - expect(h.fence()).toBe('legacy-orchestration-worker') - - vi.spyOn(h.db, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => { - throw new Error('orchestration_db_unavailable') - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - expect(h.persistence.prepare()).toEqual({ - blockedPanes: [], - candidates: [], - ambiguousDispatchIds: [] - }) - } finally { - warn.mockRestore() - } - - expect(h.fence()).toBe('legacy-orchestration-worker') - }) - - // A live worker's pane was already fenced while main reconciles it against PTY inventory; the - // settled arm must not disturb that, and the plan must still name it as unsettled. - it('keeps a live worker pane fenced and marked unsettled', () => { - const h = harness() - - const plan = h.persistence.prepare() - - expect(h.fence()).toBe('legacy-orchestration-worker') - expect(plan.blockedPanes).toEqual([ - expect.objectContaining({ paneKey: PANE_KEY, settled: false }) - ]) - expect(plan.candidates).toEqual([expect.objectContaining({ dispatchId: h.dispatchId })]) - }) -}) - -// STA-4577's other half: settlement with no release and no restart. The stamp only ran at startup -// and after release/retain/takeover, so reopening the pane in the same session respawned the agent. -describe('worker_done without a release', () => { - let db: OrchestrationDb | undefined - - afterEach(() => db?.close()) - - it('fences the pane in the same session', async () => { - const orchestrationDb = new OrchestrationDb(':memory:') - db = orchestrationDb - let session = sessionWithSleepingWorker() - const store = { - getWorkspaceSession: () => session, - setWorkspaceSession: (next: WorkspaceSessionState) => { - session = next - }, - getWorkspaceSessionHostIds: () => [LOCAL_EXECUTION_HOST_ID], - flushOrThrow: vi.fn() - } as unknown as RuntimeStore - const runtime = new OrcaRuntimeService(store) - runtime.setOrchestrationDb(orchestrationDb) - vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_worker' ? PANE_KEY : 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - ) - vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1') - vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - - const run = orchestrationDb.createRun({ - objective: 'settle without release', - coordinatorHandle: 'term_coord', - coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - }) - const task = orchestrationDb.createTask({ spec: 'settle without release', runId: run.id }) - const started = orchestrationDb.createStartingWorkerDispatch({ - creator: { kind: 'system' }, - maxDepth: Number.MAX_SAFE_INTEGER, - taskId: task.id, - startOptions: {} - }) - orchestrationDb.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1', - worktreeId: WORKTREE_ID, - setupState: 'not_applicable', - effects: [], - terminalOwnership: 'created' - }) - orchestrationDb.markWorkerDispatchReady(started.dispatch.id) - const capability = orchestrationDb.mintDispatchCapability({ - dispatchId: started.dispatch.id, - paneKey: PANE_KEY, - processIncarnation: 'runtime:pty:1' - }) - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - undefined - ) - - const send = ORCHESTRATION_METHODS.find((method) => method.name === 'orchestration.send')! - await send.handler( - send.params!.parse({ - from: 'term_worker', - to: 'term_coord', - subject: 'Done', - type: 'worker_done', - payload: JSON.stringify({ - taskId: task.id, - dispatchId: started.dispatch.id, - outcome: 'succeeded' - }) - }), - { runtime, orchestrationCapability: capability } - ) - - expect(orchestrationDb.getWorkerDispatch(started.dispatch.id)?.state).toBe('succeeded') - expect(session.sleepingAgentSessionsByPaneKey?.[PANE_KEY]?.automaticResumeBlockedBy).toBe( - 'legacy-orchestration-worker' - ) - }) -}) diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index aa2982082b4..9cdc365e1ba 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -80,7 +80,6 @@ export type RuntimeNotifier = { ptyId?: string ): void /** The fence lives in the workspace session, which a live renderer only re-reads at startup. */ - setLegacyWorkerTerminalResumeFence?(paneKey: string, blocked: boolean): void splitTerminal( tabId: string, paneRuntimeId: number, diff --git a/src/main/runtime/settled-worker-process-replacement.test.ts b/src/main/runtime/settled-worker-process-replacement.test.ts new file mode 100644 index 00000000000..2e575cd4546 --- /dev/null +++ b/src/main/runtime/settled-worker-process-replacement.test.ts @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' + +const TAB = 'worker-tab' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE = `${TAB}:${LEAF}` +const WORKSPACE = '/folder-workspace' +const LOCAL_HOST = JSON.stringify({ kind: 'local', hostId: 'local' }) +const SSH_HOST = JSON.stringify({ kind: 'ssh', targetId: 'remote-host' }) +let db: OrchestrationDb +let runtime: OrcaRuntimeService + +afterEach(() => { + db?.close() +}) + +function seedWorker(hostScope: string, settled = true) { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService(null) + runtime.setOrchestrationDb(db) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskSpec: 'Ordinary pane after worker completion', + taskRunId: 'run_legacy_local', + startOptions: {} + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_original', + paneKey: PANE, + processIncarnation: 'pty-original:inc-original', + hostScope, + worktreeId: WORKSPACE, + setupState: 'not_applicable', + effects: [], + terminalOwnership: 'created' + }) + db.markWorkerDispatchReady(started.dispatch.id) + if (settled) { + db.settleWorkerReport({ + taskId: started.task.id, + dispatchId: started.dispatch.id, + outcome: 'succeeded', + result: '{}' + }) + } + return { + dispatchId: started.dispatch.id, + task: db.getTask(started.task.id), + dispatch: db.getDispatchContextById(started.dispatch.id) + } +} + +function register(ptyId: string, incarnationId?: string, connectionId: string | null = null) { + runtime.registerPty(ptyId, WORKSPACE, connectionId, { + tabId: TAB, + leafId: LEAF, + ...(incarnationId ? { incarnationId } : {}) + }) +} + +describe('settled worker process replacement accounting', () => { + it.each([null, 'remote-host'])( + 'retains the replaced resource on owning host %s', + (connectionId) => { + const worker = seedWorker(connectionId ? SSH_HOST : LOCAL_HOST) + register('pty-resumed', 'inc-resumed', connectionId) + register('pty-resumed', 'inc-resumed', connectionId) + expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toMatchObject({ + release_state: 'retained', + retained_reason: 'identity_unproven', + process_incarnation: 'pty-original:inc-original' + }) + expect(db.getTask(worker.task!.id)).toEqual(worker.task) + expect(db.getDispatchContextById(worker.dispatchId)).toEqual(worker.dispatch) + expect(db.listWorkerTerminalResources({})).toEqual([ + expect.objectContaining({ dispatchId: worker.dispatchId, terminalState: 'retained' }) + ]) + } + ) + + it('keeps the original live resource unchanged across reattach', () => { + const worker = seedWorker(LOCAL_HOST) + const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId) + register('pty-original', 'inc-original') + expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original) + }) + + it('does not use missing incarnation evidence as proof of replacement', () => { + const worker = seedWorker(LOCAL_HOST) + const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId) + register('pty-unverifiable') + expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original) + }) + + it('does not change another execution host with the same pane and folder', () => { + const worker = seedWorker(SSH_HOST) + const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId) + register('pty-resumed', 'inc-resumed') + expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original) + }) + + it('does not change an active Dispatch resource', () => { + const worker = seedWorker(LOCAL_HOST, false) + const original = db.getWorkerTerminalResourceByOwner(worker.dispatchId) + register('pty-resumed', 'inc-resumed') + expect(db.getWorkerTerminalResourceByOwner(worker.dispatchId)).toEqual(original) + }) +}) diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index a684e951bc3..1b7f69213ad 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -129,7 +129,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) ) - runtime.prepareLegacyWorkerTerminalRecovery() // Why before anything can attach: a client host that reattaches to a restarted runtime is only // handed its pages back if the runtime found them first. runtime.rehydrateClientHostedBrowserPages() diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 2a6ab95a07f..78c5f2ec426 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -149,8 +149,6 @@ export function registerRuntimeWindowLifecycle( resolution, ...(ptyId ? { ptyId } : {}) }), - setLegacyWorkerTerminalResumeFence: (paneKey, blocked) => - send('agentStatus:legacyWorkerTerminalResumeFence', { paneKey, blocked }), splitTerminal: (tabId, paneRuntimeId, opts) => { send('ui:splitTerminal', { tabId, diff --git a/src/preload/api/agent-status-api.ts b/src/preload/api/agent-status-api.ts index 89677022506..7aa6c21115d 100644 --- a/src/preload/api/agent-status-api.ts +++ b/src/preload/api/agent-status-api.ts @@ -28,10 +28,6 @@ export type AgentStatusApi = { ptyId?: string }) => void ) => () => void - /** Listen for the automatic-resume fence a settled worker's pane gains or loses mid-session. */ - onLegacyWorkerTerminalResumeFence: ( - callback: (data: { paneKey: string; blocked: boolean }) => void - ) => () => void getMigrationUnsupportedSnapshot: () => Promise /** 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 3c3415cd207..3cc1654aaed 100644 --- a/src/preload/api/agent-status-bridge.ts +++ b/src/preload/api/agent-status-bridge.ts @@ -61,16 +61,6 @@ export const agentStatusApi = { ipcRenderer.on('agentStatus:legacyWorkerTerminalRecovery', listener) return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalRecovery', listener) }, - onLegacyWorkerTerminalResumeFence: ( - callback: (data: { paneKey: string; blocked: boolean }) => void - ): (() => void) => { - const listener = ( - _event: Electron.IpcRendererEvent, - data: { paneKey: string; blocked: boolean } - ) => callback(data) - ipcRenderer.on('agentStatus:legacyWorkerTerminalResumeFence', listener) - return () => ipcRenderer.removeListener('agentStatus:legacyWorkerTerminalResumeFence', listener) - }, getMigrationUnsupportedSnapshot: (): Promise => ipcRenderer.invoke('agentStatus:getMigrationUnsupportedSnapshot'), /** Drop the cached hook status for a paneKey on both sides (memory + on-disk) so a relaunch can't resurrect a dismissed row. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-agent-session-resume.test.ts index c93294ba2f9..9fec98aa409 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 @@ -1,7 +1,6 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import { flushAsyncTicks } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' import { @@ -406,177 +405,81 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() }) - it('does not resume a live provider session while legacy worker recovery owns the pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const retainedPtyId = 'wt-1@@lost-pty' - const transport = createMockTransport() - transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => - sessionId - ? { - id: 'fresh-pty', - coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + it.each(['ordinary', 'settled-worker'])( + 'restores %s through main with one resume command', + async (kind) => { + const { connectPanePty } = await import('./pty-connection') + const retainedPtyId = 'wt-1@@lost-pty' + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + : 'fresh-pty' + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }] + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + state: 'working', + prompt: 'finish the task', + agentType: 'claude', + providerSession: { key: 'session_id', id: 'claude-session-1' } + } + }, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'claude-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + ...(kind === 'settled-worker' + ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } + : {}) } - : 'fresh-pty' - ) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }] - }, - settings: { - ...mockStoreState.settings, - agentCmdOverrides: {} - }, - agentStatusByPaneKey: { - [paneKey]: { - paneKey, - state: 'working', - prompt: 'finish the task', - agentType: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' } - } - }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId } + }) as never + ) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.attach).not.toHaveBeenCalled() + const options = transport.connect.mock.calls[0]?.[0] as { + sessionId?: string + command?: string } - } as StoreState - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId } - }) as never - ) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(transport.connect).not.toHaveBeenCalled() - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: retainedPtyId }) - ) - const attachOptions = transport.attach.mock.calls[0]?.[0] as Record - expect(attachOptions).not.toHaveProperty('cols') - expect(attachOptions).not.toHaveProperty('rows') - expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() - expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() - }) - - it('does not replace a missing retained legacy worker over direct SSH', async () => { - const { connectPanePty } = await import('./pty-connection') - const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker') - const transport = createMockTransport() - transport.getConnectionId.mockReturnValue('ssh-a') - transport.attach.mockImplementation(() => { - throw new Error('remote PTY missing') - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }] - }, - repos: [{ id: 'repo1', connectionId: 'ssh-a' }], - sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } - } - } as StoreState - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: retainedPtyId }) - ) - expect(transport.connect).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() - }) - - it('preserves a missing retained legacy worker through direct SSH reconnect', async () => { - const { connectPanePty } = await import('./pty-connection') - const retainedPtyId = toAppSshPtyId('ssh-a', 'missing-legacy-worker') - const transport = createMockTransport() - transport.getConnectionId.mockReturnValue('ssh-a') - transport.attach.mockImplementation(() => { - throw new Error('remote PTY missing') - }) - transportFactoryQueue.push(transport) - const paneKey = makePaneKey('tab-1', LEAF_1) - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ptyId: retainedPtyId }] - }, - repos: [{ id: 'repo1', connectionId: 'ssh-a' }], - sshConnectionStates: new Map([['ssh-a', { status: 'disconnected' }]]), - deferredSshReconnectTargets: ['ssh-a'], - deferredSshSessionIdsByTabId: { 'tab-1': retainedPtyId }, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } - } - } as StoreState - const deps = createDeps({ - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: retainedPtyId } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - await new Promise((resolve) => setTimeout(resolve, 70)) - - expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'ssh-a' }) - expect(transport.attach).toHaveBeenCalledWith( - expect.objectContaining({ existingPtyId: retainedPtyId }) - ) - expect(transport.connect).not.toHaveBeenCalled() - expect(mockStoreState.removeDeferredSshSessionId).not.toHaveBeenCalled() - expect(deps.clearTabPtyId).not.toHaveBeenCalled() - expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() - }) + expect(options.sessionId).toBe(retainedPtyId) + expect(options.command).toContain('claude-session-1') + expect(options.command?.match(/--resume/g)).toHaveLength(1) + expect(mockStoreState.tabsByWorktree['wt-1']).toHaveLength(1) + } + ) it('ignores stale live launch config when cold restore identity lookup rejects it', async () => { const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts index 3e4ab1e38d5..c719e4a83ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts @@ -25,9 +25,7 @@ export function bindBuildColdRestoreAgentResumeStartup(session: ConnectPanePtySe const entry = state.agentStatusByPaneKey[session.cacheKey] const sleepingRecordEntry = session.getSleepingRecordForPane(state) const sleepingRecord = sleepingRecordEntry?.record - if (session.isLegacyWorkerAutomaticResumeBlocked()) { - return null - } + const useLiveEntry = entry && entry.state !== 'done' const agent = useLiveEntry ? entry.agentType : sleepingRecord?.agent if (!agent || !isResumableTuiAgent(agent)) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts index 1b05fa09e60..726744b7729 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts @@ -1,4 +1,3 @@ -import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { isRuntimeOwnedSshTargetId } from '../../../../../shared/execution-host' import { resolveSshPaneConnectGate } from '../ssh-pane-connect-gate' @@ -61,8 +60,7 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void { console.warn( `[pty-connection] SSH tab=${session.deps.tabId} connectionId=${session.connectionId} pendingSessionId=${pendingSessionId} sshConnected=${gate.sshConnected}` ) - const legacyWorkerOwnsPane = session.isLegacyWorkerAutomaticResumeBlocked() - if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) { + if (gate.enterDeferredFlow) { // Paint main's parked model while SSH recovery continues off the render path. session.prepaintParkedSshSnapshot(pendingSessionId) void (async () => { @@ -115,13 +113,6 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void { } useAppStore.getState().removeDeferredSshReconnectTarget(session.connectionId) if (pendingSessionId) { - if (session.isLegacyWorkerAutomaticResumeBlocked()) { - if (session.attachRetainedLegacyPty(pendingSessionId)) { - useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) - scheduleRuntimeGraphSync() - } - return - } console.warn( `[pty-connection] Attempting reattach for tab=${session.deps.tabId} sessionId=${pendingSessionId}` ) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts index bb4eab2444c..f56f3c54f6c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-choice.ts @@ -89,9 +89,6 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession) : null // Why: after a daemon crash + cold restore, a stale session-to-tab mapping can make a tab hold a ptyId from another worktree. // Restoring it would paint the wrong terminal content, so drop the reattach and spawn fresh. - const legacyAttachOnlyPtyId = session.isLegacyWorkerAutomaticResumeBlocked() - ? candidateReattachSessionId - : null const pairedParkedReattachSessionId = session.mountFollowsTerminalPark && candidateReattachSessionId && @@ -99,64 +96,51 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession) canRestorePairedParkedTerminal(candidateReattachSessionId) ? candidateReattachSessionId : null - const deferredReattachSessionId = legacyAttachOnlyPtyId - ? null - : (runtimeHostPtyWakeHint ?? - pairedParkedReattachSessionId ?? - (candidateReattachSessionId && - !isRemoteRuntimePtyId(candidateReattachSessionId) && - !candidateHasEagerBuffer && - isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId) - ? candidateReattachSessionId - : null)) + const deferredReattachSessionId = + runtimeHostPtyWakeHint ?? + pairedParkedReattachSessionId ?? + (candidateReattachSessionId && + !isRemoteRuntimePtyId(candidateReattachSessionId) && + !candidateHasEagerBuffer && + isSessionOwnedByWorktree(candidateReattachSessionId, session.deps.worktreeId) + ? candidateReattachSessionId + : null) recordPtyConnectDiagnostic( `pane=${session.pane.id} tab=${session.deps.tabId} restored=${restoredPtyId} existing=${existingPtyId} detached=${detachedRemoteLeafPtyId ?? detachedLivePtyId} reattach=${deferredReattachSessionId} hasTransport=${session.hadExistingPaneTransportAtConnect} pendingKey=${session.pendingSpawnKey}` ) if (deferredReattachSessionId) { startDeferredSessionReattach(session, deferredReattachSessionId) - } else if ( - legacyAttachOnlyPtyId || - detachedRemoteLeafPtyId || - detachedLivePtyId || - eagerLivePtyId - ) { + } else if (detachedRemoteLeafPtyId || detachedLivePtyId || eagerLivePtyId) { // Why: mirrored web-leaf panes must attach to their exact remote PTY, not spawn a replacement host tab. // eagerLivePtyId covers a still-live background PTY (e.g. an automation agent) with a live eager buffer to adopt. - const attachPtyId = - legacyAttachOnlyPtyId ?? detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId! + const attachPtyId = detachedRemoteLeafPtyId ?? detachedLivePtyId ?? eagerLivePtyId! recordPtyConnectDiagnostic(`pane=${session.pane.id} -> ATTACH detached=${attachPtyId}`) session.allowInitialIdleCacheSeed = false - if (legacyAttachOnlyPtyId) { - if (session.attachRetainedLegacyPty(legacyAttachOnlyPtyId) && session.connectionId) { - useAppStore.getState().removeDeferredSshSessionId(session.deps.tabId) - } - } else { - // Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface. - // On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here. - try { - session.clearPaneMode2031State() - session.clearHiddenOutputRestoreState() - const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null) - session.transport.attach({ - existingPtyId: attachPtyId, - cols: session.cols, - rows: session.rows, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = session.transport.getPtyId() ?? attachPtyId - session.bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { - session.registerPaneSerializerFor(attachedPtyId) - } - } catch (err) { - session.reportError(err instanceof Error ? err.message : String(err)) - session.deps.clearTabPtyId(session.deps.tabId, attachPtyId) - session.startFreshSpawn() + // Why: surface synchronous attach failures via session.reportError so the pane shows a diagnostic instead of a blank surface. + // On throw, clear the stale ptyId from the tab and fresh-spawn — else the next remount reads the same dead id and loops here. + try { + session.clearPaneMode2031State() + session.clearHiddenOutputRestoreState() + const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null) + session.transport.attach({ + existingPtyId: attachPtyId, + cols: session.cols, + rows: session.rows, + callbacks: outputCallbacks.callbacks + }) + const attachedPtyId = session.transport.getPtyId() ?? attachPtyId + session.bindActivePanePty(attachedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) + if (attachPtyId === eagerLivePtyId || isRemoteRuntimePtyId(attachedPtyId)) { + session.registerPaneSerializerFor(attachedPtyId) } + } catch (err) { + session.reportError(err instanceof Error ? err.message : String(err)) + session.deps.clearTabPtyId(session.deps.tabId, attachPtyId) + session.startFreshSpawn() } } else { session.allowInitialIdleCacheSeed = false diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index f3dab2d5425..b1319e3137c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -34,10 +34,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { } } } - if (session.isLegacyWorkerAutomaticResumeBlocked()) { - releaseDeferredCwdFence() - return Promise.resolve(null) - } + if (useAppStore.getState().deleteStateByWorktreeId?.[session.deps.worktreeId]?.isDeleting) { // Why: the worktree is being deleted; its PTYs were just killed for the // filesystem teardown. A fresh shell must not spawn into a directory the diff --git a/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts deleted file mode 100644 index db1d277dcc3..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection/retained-legacy-pty-attach.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore' - -import type { ConnectPanePtySession } from './connect-pane-pty-session' - -export function bindAttachRetainedLegacyPty(session: ConnectPanePtySession): void { - session.attachRetainedLegacyPty = (ptyId: string): boolean => { - try { - session.authoritativeReattachGeneration += 1 - session.clearPaneMode2031State() - session.clearHiddenOutputRestoreState() - const outputCallbacks = session.captureTransportOutputCallbacks(session.reportError, null) - session.transport.attach({ - existingPtyId: ptyId, - callbacks: outputCallbacks.callbacks - }) - const attachedPtyId = session.transport.getPtyId() ?? ptyId - session.bindActivePanePty(attachedPtyId, { - updateTabPtyId: 'if-missing', - sampleVisibleForegroundAgent: true - }) - if (isRemoteRuntimePtyId(attachedPtyId)) { - session.registerPaneSerializerFor(attachedPtyId) - } - return true - } catch (err) { - session.reportError(err instanceof Error ? err.message : String(err)) - return false - } - } -} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index efc1ef59b7c..120805f9d29 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -12,7 +12,6 @@ import { bindPrepaintParkedSshSnapshot } from './ssh-snapshot-prepaint' import { bindForegroundOutputRefresh } from './foreground-output-refresh' import { bindRegisterPaneSerializer } from './pane-serializer-register' import { bindHandleReattachResult } from './reattach-result-handler' -import { bindAttachRetainedLegacyPty } from './retained-legacy-pty-attach' import { runDeferredSessionAttach } from './deferred-session-attach' import { bindSerializeHiddenOutputSnapshot } from './hidden-output-snapshot-serialize' @@ -154,7 +153,6 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void bindPrepaintParkedSshSnapshot(session) bindHandleReattachResult(session) - bindAttachRetainedLegacyPty(session) runDeferredSessionAttach(session) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 0777dead431..c756a29e80a 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,6 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi const [paneKey, record] = selectedLegacyMatch return { paneKey, record } } - session.isLegacyWorkerAutomaticResumeBlocked = (): boolean => - session.getSleepingRecordForPane(useAppStore.getState())?.record.automaticResumeBlockedBy === - 'legacy-orchestration-worker' session.clearSleepingRecordProviderDuplicates = ( state: ReturnType, consumed: { paneKey: string; record: SleepingAgentSessionRecord } diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.test.ts index 9c1c3512e5e..0d731bc07f0 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 @@ -43,20 +43,4 @@ describe('selectSleepingRecordParkExemptTabIds', () => { expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([]) }) - - it('skips records that cannot resume in this worktree', () => { - const records = { - [`tab-other:${LEAF_ID}`]: sleepingRecord({ - paneKey: `tab-other:${LEAF_ID}`, - worktreeId: 'wt-2' - }), - [`tab-done:${LEAF_ID}`]: sleepingRecord({ paneKey: `tab-done:${LEAF_ID}`, state: 'done' }), - [`tab-blocked:${LEAF_ID}`]: sleepingRecord({ - paneKey: `tab-blocked:${LEAF_ID}`, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) - } - - expect([...selectSleepingRecordParkExemptTabIds(records, 'wt-1')]).toEqual([]) - }) }) diff --git a/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts b/src/renderer/src/components/terminal-pane/sleeping-record-park-exemption.ts index f38ee52bb80..5a2ccec0e2a 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 @@ -6,7 +6,7 @@ const EMPTY_TAB_IDS: ReadonlySet = new Set() /** Tab ids whose panes own a sleeping record a mount can actually consume. * Why: a parked pane can never cold-restore, so per-tab parks must exempt - * these — but only these: blocked and passive-completed records never resume, + * these — but only these: passive-completed records never resume, * and exempting them would pin a hidden pane mounted indefinitely. * Callers subscribe through `useShallow`, which compares the set structurally, * so a write for another worktree cannot re-render this one. Iterates in place — @@ -24,7 +24,7 @@ export function selectSleepingRecordParkExemptTabIds( if (!record || record.worktreeId !== worktreeId) { continue } - if (record.automaticResumeBlockedBy || isPassiveCompletedHibernationEvidence(record)) { + if (isPassiveCompletedHibernationEvidence(record)) { continue } // Why: malformed pane keys must yield no owner instead of a truncated tab id. diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-subscription-narrowing.react185.test.tsx index 81153a03ca6..98c0d4dc0cc 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,21 +111,6 @@ describe('cold-park store subscription narrowing', () => { expect(harness.renders).toBe(0) }) - // Why: a blocked record never resumes, so it leaves the exempt set — and the - // narrowed subscription's compared value — unchanged. - it('ignores a sleeping-session write this worktree can never resume', () => { - act(() => { - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - 'tab-1:1': sleepingRecord('tab-1:1', WORKTREE_ID, { - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) - } - }) - }) - expect(harness.renders).toBe(0) - }) - it('still re-renders when this worktree gains a pending startup', () => { act(() => { useAppStore.setState({ diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index ec2611d19e0..586df3d71a6 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 @@ -528,43 +528,6 @@ describe('useTerminalTabColdParking measure-clock contract', () => { expect(result.current).toEqual(new Set(['tab-2'])) }) - // Why: blocked and passive-completed records never auto-resume, so exempting + // Why: passive-completed records never auto-resume, so exempting // them would pin a hidden pane mounted indefinitely for nothing. - it('keeps parking panes whose records cannot be consumed', () => { - const { result, rerender } = renderHook( - (args: ReturnType) => useTerminalTabColdParking(args), - { initialProps: hookArgs(false) } - ) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - - mocks.storeState.sleepingAgentSessionsByPaneKey = { - 'tab-2:22222222-2222-4222-8222-222222222222': { - paneKey: 'tab-2:22222222-2222-4222-8222-222222222222', - tabId: 'tab-2', - worktreeId: WORKTREE_ID, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } as never - } - act(() => { - rerender(hookArgs(false)) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - - mocks.storeState.sleepingAgentSessionsByPaneKey = { - 'tab-2:22222222-2222-4222-8222-222222222222': { - paneKey: 'tab-2:22222222-2222-4222-8222-222222222222', - tabId: 'tab-2', - worktreeId: WORKTREE_ID, - origin: 'worktree-sleep', - state: 'done' - } as never - } - act(() => { - rerender(hookArgs(false)) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - }) }) 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 2426dcb932d..70b13e22a41 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-listeners.ts @@ -126,12 +126,4 @@ export function registerAgentStatusListeners(args: { if (unsubscribeLegacyWorkerTerminalRecovery) { unsubs.push(unsubscribeLegacyWorkerTerminalRecovery) } - const unsubscribeResumeFence = window.api.agentStatus.onLegacyWorkerTerminalResumeFence?.( - ({ paneKey, blocked }) => { - useAppStore.getState().setSleepingAgentAutomaticResumeBlocked(paneKey, blocked) - } - ) - if (unsubscribeResumeFence) { - unsubs.push(unsubscribeResumeFence) - } } diff --git a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts index 4a3287e8477..bc6ec581586 100644 --- a/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-agent-status-ssh-authority.test.ts @@ -19,13 +19,11 @@ describe('useIpcEvents agent status snapshot integration', () => { it('retires the exact sleeping record after adopted or exited legacy worker recovery', async () => { const clearSleepingAgentSession = vi.fn() - const setSleepingAgentAutomaticResumeBlocked = vi.fn() let listener: | ((data: { paneKey: string; resolution: 'adopted' | 'exited' }) => void) | undefined const storeState = buildStoreState({ - clearSleepingAgentSession, - setSleepingAgentAutomaticResumeBlocked + clearSleepingAgentSession }) stubReactSyncEffect() @@ -54,12 +52,10 @@ describe('useIpcEvents agent status snapshot integration', () => { listener?.({ paneKey: 'tab-adopted:leaf-adopted', resolution: 'adopted' }) expect(clearSleepingAgentSession).toHaveBeenCalledWith('tab-adopted:leaf-adopted') - expect(setSleepingAgentAutomaticResumeBlocked).not.toHaveBeenCalled() clearSleepingAgentSession.mockClear() listener?.({ paneKey: 'tab-exited:leaf-exited', resolution: 'exited' }) expect(clearSleepingAgentSession).toHaveBeenCalledWith('tab-exited:leaf-exited') - expect(setSleepingAgentAutomaticResumeBlocked).not.toHaveBeenCalled() }) it.each([ diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 2ba4d506077..5991c40c4de 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -5,7 +5,6 @@ import { createHarnessStoreState } from './ipc-events-test-harness' const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'agentStatus.onClear', 'agentStatus.onLegacyWorkerTerminalRecovery', - 'agentStatus.onLegacyWorkerTerminalResumeFence', 'agentStatus.onMigrationUnsupported', 'agentStatus.onMigrationUnsupportedClear', 'agentStatus.onSet', @@ -199,7 +198,6 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'agentStatus.onMigrationUnsupported', 'agentStatus.onMigrationUnsupportedClear', 'agentStatus.onLegacyWorkerTerminalRecovery', - 'agentStatus.onLegacyWorkerTerminalResumeFence', 'runtime.onTerminalFitOverrideChanged', 'runtime.onTerminalDriverChanged', 'runtime.onNativeChatLaunchDraftResolved', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a6f2be8a893..cf9ec510580 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8631,15 +8631,6 @@ "fastDescription": "Extra multiplier while scrolling with a modifier key.", "tui": "TUI", "tuiDescription": "Discrete wheel reports for full-screen terminal apps." - }, - "minimumContrast": { - "title": "Minimum Contrast Ratio", - "description": "Lifts terminal foreground colors that sit too close to the background. Leave blank for automatic, or set 1 to render program colors exactly as sent.", - "automatic": "Automatic: {{light}} on light backgrounds, {{dark}} on dark.", - "disabled": "Correction off. Programs that rely on low contrast, like Powerline separators, render as sent.", - "pinned": "Targets {{ratio}}:1 contrast for foreground colors, where possible.", - "placeholder": "Auto", - "suffix": "blank = automatic, 1 = off" } }, "TerminalSettingsPreview": { diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 839209ea809..bc55936fd24 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -4,10 +4,7 @@ import { parsePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { lastInputBlocksHibernation } from './agent-hibernation-input-guard' -import { - isAutomaticHibernationAllowed, - isLiveResumeAnchorForCompletedAgent -} from './live-resume-anchor-record' +import { isLiveResumeAnchorForCompletedAgent } from './live-resume-anchor-record' import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' export type EligiblePane = { @@ -95,10 +92,7 @@ export function getEligiblePane(args: { entry.interrupted === true || Boolean(entry.subagents?.length) || hasUnsettledOrUnknownDispatch(entry) || - (sleepingRecord && !hasOnlyLiveResumeAnchor) || - // Why: a fenced worker must never be auto-relaunched; killing it would also - // erase the fence, since the capture does not copy it. - !isAutomaticHibernationAllowed(sleepingRecord) + (sleepingRecord && !hasOnlyLiveResumeAnchor) ) { return null } diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 10414249dfb..5be241004bb 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -753,37 +753,6 @@ describe('live resume anchors do not block hibernation (#10238 regression)', () ) ).toEqual([agentEntry.paneKey]) }) - - it('still refuses a pane fenced against automatic resume', () => { - const providerSession = { key: 'session_id' as const, id: 'claude-session-1' } - const agentEntry = entry({ agentType: 'claude', providerSession }) - const fenced = { - ...liveAnchor('claude', providerSession), - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } - expect( - plannedPaneKeys( - snapshot({ - agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry }, - sleepingAgentSessionsByPaneKey: { [agentEntry.paneKey]: fenced as never }, - ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD } - }) - ) - ).toEqual([]) - // Control: the identical pane IS planned once the fence is gone, so the rejection - // above isolates the fence rather than some other guard. - expect( - plannedPaneKeys( - snapshot({ - agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry }, - sleepingAgentSessionsByPaneKey: { - [agentEntry.paneKey]: liveAnchor('claude', providerSession) as never - }, - ptyBindingFirstSeenAtByPaneKey: { [agentEntry.paneKey]: OLD } - }) - ) - ).toEqual([agentEntry.paneKey]) - }) }) describe('idle clock anchors on stateStartedAt, not updatedAt', () => { diff --git a/src/renderer/src/lib/live-resume-anchor-record.ts b/src/renderer/src/lib/live-resume-anchor-record.ts index 7fa9d3f88ed..bc00a2f52c5 100644 --- a/src/renderer/src/lib/live-resume-anchor-record.ts +++ b/src/renderer/src/lib/live-resume-anchor-record.ts @@ -50,14 +50,3 @@ export function isCompletedPiCompatibleAgentWithLiveRecoveryRecord( isLiveResumeAnchorForCompletedAgent(entry, record, worktreeId) ) } - -/** - * A durable orchestration fence against automatic provider relaunch. Hibernating - * a fenced pane would strand it or — since `sleepingRecordFromEntry` does not copy - * the flag — erase the fence and later auto-resume prohibited work. - */ -export function isAutomaticHibernationAllowed( - record: SleepingAgentSessionRecord | undefined -): boolean { - return !record?.automaticResumeBlockedBy -} 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 deleted file mode 100644 index 1ec95258f05..00000000000 --- a/src/renderer/src/lib/resume-sleeping-agent-session-legacy-worker.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' -import { useAppStore } from '@/store' -import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' - -const initialAppStoreState = useAppStore.getState() - -afterEach(() => { - vi.unstubAllGlobals() - useAppStore.setState(initialAppStoreState, true) -}) - -describe('legacy worker sleeping-session recovery', () => { - it('never resumes a proven-exited legacy worker on workspace activation', () => { - const record: SleepingAgentSessionRecord = { - paneKey: 'tab-legacy:leaf-legacy', - tabId: 'tab-legacy', - worktreeId: 'wt-legacy', - agent: 'claude', - providerSession: { key: 'session_id', id: 'session-legacy' }, - prompt: 'continue legacy work', - state: 'working', - capturedAt: 1, - updatedAt: 1, - origin: 'live', - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } - useAppStore.setState({ - tabsByWorktree: { - 'wt-legacy': [ - { - id: 'tab-legacy', - ptyId: null, - worktreeId: 'wt-legacy', - title: 'Legacy worker', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } - } as never) - - expect(resumeSleepingAgentSessionsForWorktree('wt-legacy')).toBe(0) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) - - useAppStore.getState().clearSleepingAgentSession(record.paneKey) - expect(resumeSleepingAgentSessionsForWorktree('wt-legacy')).toBe(0) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() - }) -}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 94dc52bc7c6..e0b5b79ac6e 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -210,9 +210,6 @@ export function resumeSleepingAgentSessionsForWorktree( if (options?.skipClaimKeys?.has(claimKey)) { continue } - if (record.automaticResumeBlockedBy === 'legacy-orchestration-worker') { - continue - } if (isInvalidWorktreeActivationRecord(record)) { state.clearSleepingAgentSession(record.paneKey) continue diff --git a/src/renderer/src/lib/settled-worker-wake-policy.test.ts b/src/renderer/src/lib/settled-worker-wake-policy.test.ts new file mode 100644 index 00000000000..d227568cb00 --- /dev/null +++ b/src/renderer/src/lib/settled-worker-wake-policy.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialState = useAppStore.getState() + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialState, true) +}) + +it('resumes a settled worker and an ordinary agent once each in the same wake sweep', () => { + const records = ['settled-worker', 'ordinary-agent'].map((id) => ({ + paneKey: `${id}:leaf`, + tabId: id, + worktreeId: 'wt-1', + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id }, + prompt: 'continue the session', + state: 'working' as const, + capturedAt: Date.now(), + updatedAt: Date.now(), + origin: 'worktree-sleep' as const, + // Old clients can still publish the withdrawn policy field. + ...(id === 'settled-worker' ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } : {}) + })) + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: Object.fromEntries(records.map((r) => [r.paneKey, r])) + }) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(2) + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(0) + const state = useAppStore.getState() + const tabs = state.tabsByWorktree['wt-1'] + expect(tabs).toHaveLength(2) + const commands = tabs.map((tab) => state.pendingStartupByTabId[tab.id]?.command ?? '') + for (const record of records) { + expect(commands.filter((command) => command.includes(record.providerSession.id))).toHaveLength( + 1 + ) + } + for (const command of commands) { + expect(command.match(/--resume/g)).toHaveLength(1) + } + expect(state.sleepingAgentSessionsByPaneKey).toEqual({}) +}) diff --git a/src/renderer/src/store/slices/agent-pane-authority.test.ts b/src/renderer/src/store/slices/agent-pane-authority.test.ts index 01e99f27d6f..767104fda87 100644 --- a/src/renderer/src/store/slices/agent-pane-authority.test.ts +++ b/src/renderer/src/store/slices/agent-pane-authority.test.ts @@ -156,7 +156,7 @@ describe('agent pane authority', () => { expect(store.getState().agentStatusByPaneKey[SIBLING]).toBeUndefined() }) - it('can retire live pane authority while retaining a migration recovery fence', () => { + it('can retire live pane authority while retaining its sleeping session', () => { const store = createTestStore() store.getState().setAgentStatus(TARGET, { state: 'working', prompt: 'target' }) store.getState().registerAgentLaunchConfig(TARGET, { agentArgs: '', agentEnv: {} }) @@ -171,8 +171,7 @@ describe('agent pane authority', () => { prompt: 'continue', state: 'working', capturedAt: 1, - updatedAt: 1, - automaticResumeBlockedBy: 'legacy-orchestration-worker' + updatedAt: 1 } } }) @@ -183,7 +182,7 @@ describe('agent pane authority', () => { expect(state.agentStatusByPaneKey[TARGET]).toBeUndefined() expect(state.agentLaunchConfigByPaneKey[TARGET]).toBeUndefined() expect(state.sleepingAgentSessionsByPaneKey[TARGET]).toMatchObject({ - automaticResumeBlockedBy: 'legacy-orchestration-worker' + providerSession: { key: 'session_id', id: 'session-1' } }) expect(state.recentlyRetiredAgentStatusPaneKeys[TARGET]).toBe(true) expect(retirePaneAuthority).toHaveBeenCalledWith(TARGET) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts index f1deb30370f..527db809596 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,36 +141,6 @@ describe('manual sleep agent session capture', () => { expect(records['tab-1:working'].restoreOnTabOpenOnly).toBeUndefined() }) - it('carries a blocked legacy-orchestration-worker flag onto the replacement record', () => { - vi.useFakeTimers() - vi.setSystemTime(NOW) - const store = createTestStore() - seedTabs(store) - store.setState({ - agentStatusByPaneKey: { - 'tab-1:leaf-1': makeAgentEntry(), - 'tab-1:leaf-2': makeAgentEntry({ paneKey: 'tab-1:leaf-2' }) - }, - sleepingAgentSessionsByPaneKey: { - 'tab-1:leaf-1': makeSleepingRecord({ - providerSession: { key: 'session_id', id: 'session-tab-1:leaf-1' }, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }), - 'tab-1:leaf-2': makeSleepingRecord({ - paneKey: 'tab-1:leaf-2', - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) - } - } as Partial) - - store.getState().captureSleepingAgentSessionsByWorktree('wt-1') - - const records = store.getState().sleepingAgentSessionsByPaneKey - expect(records['tab-1:leaf-1'].automaticResumeBlockedBy).toBe('legacy-orchestration-worker') - // Different provider session: the block belonged to a session that is no longer running here. - expect(records['tab-1:leaf-2'].automaticResumeBlockedBy).toBeUndefined() - }) - it('preserves retained completed sessions as intentional sleep records', () => { vi.useFakeTimers() vi.setSystemTime(NOW) @@ -231,38 +201,6 @@ describe('manual sleep agent session capture', () => { expect(record.interrupted).toBeUndefined() }) - it('carries a blocked legacy-orchestration-worker flag onto a retained replacement record', () => { - vi.useFakeTimers() - vi.setSystemTime(NOW) - const store = createTestStore() - seedTabs(store) - const entry = makeAgentEntry({ paneKey: 'tab-1:retained', state: 'done' }) - store.setState({ - retainedAgentsByPaneKey: { - 'tab-1:retained': { - entry, - tab: makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), - worktreeId: 'wt-1', - agentType: 'codex', - startedAt: entry.stateStartedAt - } - }, - sleepingAgentSessionsByPaneKey: { - 'tab-1:retained': makeSleepingRecord({ - paneKey: 'tab-1:retained', - providerSession: { key: 'session_id', id: 'session-tab-1:retained' }, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) - } - } as Partial) - - store.getState().captureSleepingAgentSessionsByWorktree('wt-1') - - expect( - store.getState().sleepingAgentSessionsByPaneKey['tab-1:retained'].automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') - }) - // Why: the promoted checkpoint owns the pane's recovery identity (connection, transcript); the // retained pass must not re-derive over it any more than the live pass may. it('keeps a promoted live checkpoint that also has a retained row', () => { 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 deleted file mode 100644 index cbd6b186997..00000000000 --- a/src/renderer/src/store/slices/agent-status-open-tab-resume-fence.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { AppState } from '../types' -import { createTestStore, makeTab } from './store-test-helpers' - -const NOW = 1_800_000_000_000 -const PANE_KEY = 'tab-1:leaf-1' - -function liveWorkerEntry(): AgentStatusEntry { - return { - state: 'working', - prompt: 'finish the task', - updatedAt: NOW, - stateStartedAt: NOW, - stateHistory: [], - agentType: 'codex', - paneKey: PANE_KEY, - tabId: 'tab-1', - worktreeId: 'wt-1', - providerSession: { key: 'session_id', id: 'session-1' } - } -} - -// The worker settles while its tab is still open, so there is no sleeping record to stamp; the -// record is minted on close and used to arrive unfenced, respawning settled work on reopen. -describe('a resume fence that arrives before the sleeping record exists', () => { - it('carries the block onto the record minted after the tab closes', () => { - const store = createTestStore() - store.setState({ - tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, - agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() } - } as Partial) - - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toBeUndefined() - - store.getState().captureAllSleepingAgentSessions('quit') - - expect(store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject({ - paneKey: PANE_KEY, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - }) - }) - - it('mints an unfenced record once the runtime lifts the block', () => { - const store = createTestStore() - store.setState({ - tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }, - agentStatusByPaneKey: { [PANE_KEY]: liveWorkerEntry() } - } as Partial) - - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, true) - store.getState().setSleepingAgentAutomaticResumeBlocked(PANE_KEY, false) - store.getState().captureAllSleepingAgentSessions('quit') - - expect( - store.getState().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 32e5378fe11..1ffe1d7cb0e 100644 --- a/src/renderer/src/store/slices/agent-status-provider-session-actions.ts +++ b/src/renderer/src/store/slices/agent-status-provider-session-actions.ts @@ -113,10 +113,6 @@ export function createAgentStatusProviderSessionActions( ? { connectionId: existingRecord.connectionId } : {}), ...(launchConfig ? { launchConfig: copyLaunchConfig(launchConfig) } : {}), - ...(existingRecordMatchesProviderSession && - existingRecord.automaticResumeBlockedBy === 'legacy-orchestration-worker' - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' } - : {}), ...(preservesCompletedRecoveryRecord && existingRecord.interrupted !== undefined ? { interrupted: existingRecord.interrupted } : {}), diff --git a/src/renderer/src/store/slices/agent-status-provider-session.test.ts b/src/renderer/src/store/slices/agent-status-provider-session.test.ts index 0d60d023956..786b45378ec 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,64 +327,6 @@ describe('recordAgentProviderSession', () => { ).toBeUndefined() }) - it('preserves the legacy resume fence only for the same Pi session identity', () => { - const store = createTestStore() - const makeRecord = (transcriptPath: string): SleepingAgentSessionRecord => ({ - paneKey: 'tab-1:leaf-1', - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'pi', - providerSession: { - key: 'session_id', - id: 'pi-session-1', - transcriptPath - }, - prompt: '', - state: 'working', - capturedAt: 10, - updatedAt: 10, - automaticResumeBlockedBy: 'legacy-orchestration-worker', - origin: 'live' - }) - store.setState({ - sleepingAgentSessionsByPaneKey: { - 'tab-1:leaf-1': makeRecord('/tmp/pi-session-1.jsonl') - } - } as Partial) - - store.getState().recordAgentProviderSession( - 'tab-1:leaf-1', - 'pi', - { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-1.jsonl' - }, - { updatedAt: 20 }, - { tabId: 'tab-1', worktreeId: 'wt-1' } - ) - - expect( - store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') - - store.getState().recordAgentProviderSession( - 'tab-1:leaf-1', - 'pi', - { - key: 'session_id', - id: 'pi-session-1', - transcriptPath: '/tmp/pi-session-2.jsonl' - }, - { updatedAt: 30 }, - { tabId: 'tab-1', worktreeId: 'wt-1' } - ) - - expect( - store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']?.automaticResumeBlockedBy - ).toBeUndefined() - }) - it.each(PI_COMPATIBLE_CASES)( 'keeps a completed $label session resumable through manual worktree sleep', async ({ agent, label }) => { 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 d750f5c0df9..0e256834a9f 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-actions.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-actions.ts @@ -22,7 +22,6 @@ export function createAgentStatusRecoveryActions( | 'captureAllSleepingAgentSessions' | 'clearSleepingAgentSession' | 'clearSleepingAgentSessionsByPaneKey' - | 'setSleepingAgentAutomaticResumeBlocked' | 'clearSleepingAgentSessionsByWorktree' | 'pruneSleepingAgentSessions' > { @@ -109,47 +108,6 @@ export function createAgentStatusRecoveryActions( clearSleepingAgentSession: (paneKey) => clearSleepingAgentSessionsByPaneKey([paneKey]), clearSleepingAgentSessionsByPaneKey, - setSleepingAgentAutomaticResumeBlocked: (paneKey, blocked) => { - set((s) => { - // The pane key is tracked even with no record: a worker settled while its tab was open - // is fenced before the record exists, and the record is only minted on close. - const wasBlocked = s.automaticResumeBlockedPaneKeys[paneKey] === true - let paneKeys = s.automaticResumeBlockedPaneKeys - if (blocked !== wasBlocked) { - paneKeys = { ...s.automaticResumeBlockedPaneKeys } - if (blocked) { - paneKeys[paneKey] = true - } else { - delete paneKeys[paneKey] - } - } - const current = s.sleepingAgentSessionsByPaneKey[paneKey] - if ( - !current || - (blocked - ? current.automaticResumeBlockedBy === 'legacy-orchestration-worker' - : current.automaticResumeBlockedBy === undefined) - ) { - return paneKeys === s.automaticResumeBlockedPaneKeys - ? s - : { automaticResumeBlockedPaneKeys: paneKeys } - } - const next = { ...current } - if (blocked) { - next.automaticResumeBlockedBy = 'legacy-orchestration-worker' - } else { - delete next.automaticResumeBlockedBy - } - return { - automaticResumeBlockedPaneKeys: paneKeys, - sleepingAgentSessionsByPaneKey: { - ...s.sleepingAgentSessionsByPaneKey, - [paneKey]: next - } - } - }) - }, - clearSleepingAgentSessionsByWorktree: (worktreeId) => { set((s) => { let changed = false diff --git a/src/renderer/src/store/slices/agent-status-recovery-collection.ts b/src/renderer/src/store/slices/agent-status-recovery-collection.ts index 4587f919d36..689dbf861a7 100644 --- a/src/renderer/src/store/slices/agent-status-recovery-collection.ts +++ b/src/renderer/src/store/slices/agent-status-recovery-collection.ts @@ -10,7 +10,6 @@ import { retainedAgentEntryFromLive } from './agent-status-pane-key-tab-binding' import { - carryOverAutomaticResumeBlock, isValidCompletedAgentHibernationEntry, manualSleepCaptureEntry, markManualSleepLazyRestore, @@ -96,10 +95,6 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (record) { if (isManualWorktreeSleep) { markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock( - record, - state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] - ) } records[record.paneKey] = record } @@ -133,7 +128,6 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (record) { if (isManualWorktreeSleep) { markManualSleepLazyRestore(record) - carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) } records[record.paneKey] = record } diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 48691b078bc..6363fb9e216 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -1,7 +1,6 @@ import type { AppState } from '../types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { - agentProviderSessionsEqual, getAgentResumeArgv, isResumableTuiAgent, type SleepingAgentLaunchConfig, @@ -59,11 +58,7 @@ export function sleepingRecordFromEntry(args: { : {}), ...(args.launchConfig ? { launchConfig: copyLaunchConfig(args.launchConfig) } : {}), ...(args.entry.interrupted ? { interrupted: true } : {}), - ...(args.origin ? { origin: args.origin } : {}), - // The worker can settle while the tab is open, so the fence arrives before this record exists. - ...(args.state.automaticResumeBlockedPaneKeys?.[args.entry.paneKey] - ? { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const } - : {}) + ...(args.origin ? { origin: args.origin } : {}) } } @@ -113,21 +108,6 @@ export function manualSleepCaptureEntry( return { ...entry, updatedAt: capturedAt, interrupted: false } } -// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately -// blocked worker must not become auto-resumable at wake. -export function carryOverAutomaticResumeBlock( - record: SleepingAgentSessionRecord, - previous: SleepingAgentSessionRecord | undefined -): void { - if ( - previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && - previous.agent === record.agent && - agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) - ) { - record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy - } -} - export function removeSleepingRecordsReplacedByManualWorktreeSleep( records: Record, worktreeId: string, diff --git a/src/renderer/src/store/slices/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status-slice-contract.ts index f9c02abda5a..8dc01fc5598 100644 --- a/src/renderer/src/store/slices/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status-slice-contract.ts @@ -51,10 +51,6 @@ export type AgentStatusSlice = { /** Durable agent sessions captured on sleep (not live rows); power the one-click CLI resume on wake. */ sleepingAgentSessionsByPaneKey: Record - /** 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 - /** Ephemeral launch snapshots keyed by pane; hook payloads lack Orca launch settings, so the renderer supplies them from startup. */ agentLaunchConfigByPaneKey: Record @@ -162,7 +158,6 @@ export type AgentStatusSlice = { captureAllSleepingAgentSessions: (mode: AllAgentSessionCaptureMode) => void clearSleepingAgentSession: (paneKey: string) => void clearSleepingAgentSessionsByPaneKey: (paneKeys: readonly string[]) => void - setSleepingAgentAutomaticResumeBlocked: (paneKey: string, blocked: boolean) => void clearSleepingAgentSessionsByWorktree: (worktreeId: string) => void pruneSleepingAgentSessions: (validWorktreeIds: Set) => void diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 64941669dfb..6a1ed10025c 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -100,7 +100,6 @@ export const createAgentStatusSlice: StateCreator { - const record = get().sleepingAgentSessionsByPaneKey[opts.paneKey] - if (!isAutomaticHibernationAllowed(record)) { - throw new Error('agent_hibernation_automatic_resume_blocked') - } - } - assertAutomaticHibernationStillAllowed() const capture = shutdownBufferCaptures.get(opts.tabId) if (capture) { try { @@ -82,8 +70,6 @@ export function createTerminalPaneHibernationActions( // Don't let one tab's capture failure block the pane hibernation. } } - // Why: the capture callback runs synchronously above and can itself fence the pane. - assertAutomaticHibernationStillAllowed() // Why: store sleeping records before kill, since pty:exit can arrive first. const sleepingRecordKeys = Object.keys(sleepingAgentSessionRecords) const replacedSleepingRecords: Record = diff --git a/src/renderer/src/web/preload-api/web-agent-status-api.ts b/src/renderer/src/web/preload-api/web-agent-status-api.ts index 1a07b6d6a6c..d7c9740018c 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,6 @@ export function createWebAgentStatusApi(): Partial { onMigrationUnsupported: () => noopUnsubscribe, onMigrationUnsupportedClear: () => noopUnsubscribe, onLegacyWorkerTerminalRecovery: () => noopUnsubscribe, - onLegacyWorkerTerminalResumeFence: () => noopUnsubscribe, getMigrationUnsupportedSnapshot: () => Promise.resolve([]), drop: () => {}, dropPersisted: () => {}, diff --git a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts index d20b4aedbf7..65c61981868 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-tool-fields.ts @@ -36,7 +36,15 @@ export function extractPiToolFields( eventName === 'ui_prompt_start' || eventName === 'ui_prompt_end') ) { - return clearActiveToolFieldsUpdate() + // Why: the reply is the agent's own text, not modal content, so a turn that finishes + // while a dialog is open must not leave the preview stuck on the previous message. + const assistantText = + eventName === 'message_end' && hookPayload.role === 'assistant' + ? readString(hookPayload, 'text') + : undefined + return assistantText + ? { ...clearActiveToolFieldsUpdate(), lastAssistantMessage: assistantText } + : clearActiveToolFieldsUpdate() } if ( eventName === 'tool_call' || diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 3e763fc20a6..9ae49955d84 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -63,9 +63,6 @@ export type SleepingAgentSessionRecord = { * so only the pane's own cold-restore path may consume them — activation * launching a tab too would duplicate a warm-reattached session (#5232). */ origin?: 'worktree-sleep' | 'quit' | 'live' - /** Prevents provider-session relaunch while main reconciles a durable - * orchestration assignment against authoritative PTY inventory. */ - automaticResumeBlockedBy?: 'legacy-orchestration-worker' /** Set on a finished pane captured by an explicit workspace sleep. Its * `--resume` is issued by the pane's own cold restore when its tab is * opened, so a mobile wake must not background-mount every such tab and diff --git a/src/shared/workspace-session-schema.sleeping-agent.test.ts b/src/shared/workspace-session-schema.sleeping-agent.test.ts index 0f34528bcb5..57d4a832b46 100644 --- a/src/shared/workspace-session-schema.sleeping-agent.test.ts +++ b/src/shared/workspace-session-schema.sleeping-agent.test.ts @@ -41,6 +41,38 @@ describe('parseWorkspaceSession sleeping agents', () => { } }) + it.each([undefined, 'legacy-orchestration-worker'])( + 'new host ignores an old client resume fence (%s)', + (automaticResumeBlockedBy) => { + const record = { + paneKey: 'tab1:pane-1', + tabId: 'tab1', + worktreeId: 'wt', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session' }, + prompt: 'continue', + state: 'done', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep' + } + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: { + [record.paneKey]: { ...record, automaticResumeBlockedBy } + } + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.sleepingAgentSessionsByPaneKey?.[record.paneKey]).toEqual(record) + } + } + ) + it('hydrates a persisted Kimi sleeping agent record', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index 4ee620efa82..2f2ac1252bb 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -99,7 +99,6 @@ const sleepingAgentSessionRecordSchema = z connectionId: z.string().nullable().optional(), launchConfig: sleepingAgentLaunchConfigSchema.optional(), origin: z.enum(['worktree-sleep', 'quit', 'live']).optional(), - automaticResumeBlockedBy: z.enum(['legacy-orchestration-worker']).optional(), restoreOnTabOpenOnly: z.boolean().optional() }) .refine( diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts index 8e3eb9c4470..82511787f57 100644 --- a/tests/e2e/completed-worker-retirement-resume.unit.test.ts +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -434,17 +434,11 @@ describe('completed background-worker retirement resume matrix', () => { expect(retiredRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) expect(retiredRestart.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeUndefined() - // Case 4: legacy rollback preserves a fenced record; exited resolution clears it. + // Case 4: legacy rollback preserves the settled worker's record as an ordinary sleeping + // record; with its tab gone it is passive completed evidence that wake clears, and an exited + // resolution clears it too. No fence: a finished worker follows the same rule as any agent pane. seedWorkspace() - const legacyRecord = recordCompletedWorker() - useAppStore.setState({ - sleepingAgentSessionsByPaneKey: { - [ORIGINAL_PANE_KEY]: { - ...legacyRecord, - automaticResumeBlockedBy: 'legacy-orchestration-worker' - } - } - }) + recordCompletedWorker() const legacyAction = resolveLegacyWorkerTerminalRecoveryAction({ paneKey: ORIGINAL_PANE_KEY, resolution: 'rolled_back', @@ -456,17 +450,19 @@ describe('completed background-worker retirement resume matrix', () => { rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), legacyAction.detail) ).toBe('removed') } - expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'done' + }) expect( useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] - ?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + ).not.toHaveProperty('automaticResumeBlockedBy') + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() const exitedAction = resolveLegacyWorkerTerminalRecoveryAction({ paneKey: ORIGINAL_PANE_KEY, resolution: 'exited' }) expect(exitedAction).toEqual({ kind: 'clear-sleeping', paneKey: ORIGINAL_PANE_KEY }) - useAppStore.getState().clearSleepingAgentSession(ORIGINAL_PANE_KEY) // Case 5: coordinator manual close is the same safe exact-tab retirement boundary. seedWorkspace() diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts index 43de5782648..d3ef3fb8195 100644 --- a/tests/e2e/helpers/completed-worker-retirement-fixture.ts +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -60,18 +60,23 @@ process.stdin.resume() setInterval(() => {}, 60_000) ` -if (process.platform === 'win32') { - writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) - writeFileSync( - path.join(fakeCliDir, 'codex.cmd'), - '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' - ) -} else { - const executable = path.join(fakeCliDir, 'codex') - writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) - chmodSync(executable, 0o755) +function installCompletedWorkerFakeCodex(): void { + mkdirSync(fakeCliDir, { recursive: true }) + if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) + } else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) + } } +installCompletedWorkerFakeCodex() + export const completedWorkerLaunchEnv = { PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath @@ -91,6 +96,8 @@ export type TerminalIdentity = Pick< > export function clearCompletedWorkerLedger(): void { + // Another spec can clean up this cached fixture before the next test uses it. + installCompletedWorkerFakeCodex() rmSync(lifecycleLedgerPath, { force: true }) } diff --git a/tests/e2e/settled-worker-tab-survives-restart.spec.ts b/tests/e2e/settled-worker-tab-survives-restart.spec.ts new file mode 100644 index 00000000000..db3b03409dd --- /dev/null +++ b/tests/e2e/settled-worker-tab-survives-restart.spec.ts @@ -0,0 +1,530 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { DaemonClient } from '../../src/main/daemon/client' +import { getDaemonSocketPath, getDaemonTokenPath } from '../../src/main/daemon/daemon-spawner' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { FAKE_AGENT_WINDOWS_SHELL } from './helpers/fake-agent-command-override' +import { + clearCompletedWorkerLedger, + completedWorkerFakeCodexCommand, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + seedCurrentCodexTranscript +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree/id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dd' + +test.describe.configure({ mode: 'serial' }) + +async function findSecondaryWorktree( + page: Page, + client: RuntimeClient, + coordinatorWorktreeId: string +): Promise { + 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) +} + +function readPersistedSession(userDataDir: string) { + return JSON.parse( + readFileSync( + path.join(userDataDir, 'profiles', DEFAULT_LOCAL_ORCA_PROFILE_ID, 'orca-data.json'), + 'utf8' + ) + ).workspaceSession +} + +function expectNoPersistedWorkerFence(userDataDir: string, paneKey: string): void { + const persisted = readPersistedSession(userDataDir) + // Keep the baseline running through reveal even when it still writes the withdrawn policy. + expect + .soft(persisted.sleepingAgentSessionsByPaneKey?.[paneKey] ?? {}) + .not.toHaveProperty('automaticResumeBlockedBy') + expect.soft(persisted.legacyWorkerResumeFencesByPaneKey ?? {}).not.toHaveProperty(paneKey) +} + +// A restored worker must attach through main so revealing it never fabricates a missing PTY. +for (const daemonSessionGone of [false, true]) { + test(`a settled worker tab survives restart with daemon session ${daemonSessionGone ? 'exited' : 'live'}`, async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. + {}, testInfo) => { + test.setTimeout(300_000) + const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + if (!repoPath || !existsSync(repoPath)) { + test.skip(true, 'Global setup did not produce a seeded test repo') + return + } + clearCompletedWorkerLedger() + + const session = createRestartSession(testInfo, completedWorkerLaunchEnv) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + const coordinatorWorktreeId = await attachRepoAndOpenTerminal(first.page, repoPath) + await waitForSessionReady(first.page) + await waitForActiveWorktree(first.page) + await ensureTerminalVisible(first.page) + await waitForActiveTerminalManager(first.page) + await waitForActivePanePtyId(first.page) + await first.page.evaluate( + async ({ agentCommand, terminalWindowsShell }) => { + await window.__store?.getState().updateSettings({ + agentCmdOverrides: { codex: agentCommand }, + terminalWindowsShell, + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }, + { + agentCommand: completedWorkerFakeCodexCommand, + terminalWindowsShell: FAKE_AGENT_WINDOWS_SHELL + } + ) + const isolatedHome = await firstApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(session.userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(first.page) + const coordinatorHandle = ( + await client.call<{ terminal: { handle: string } }>('terminal.resolvePane', { + paneKey: coordinatorPane.paneKey + }) + ).result.terminal.handle + const targetWorktreeId = await findSecondaryWorktree( + first.page, + client, + coordinatorWorktreeId + ) + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Keep one settled worker tab across restart', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion and stay open', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${targetWorktreeId}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId) { + throw new Error('Background worker did not publish its PTY') + } + const workerPtyId = worker.ptyId + const workerTabId = worker.tabId + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + await backgroundMountTab(first.page, targetWorktreeId, workerTabId) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + await first.page.evaluate( + ({ + agentCommand, + paneKey, + providerSessionId, + tabId, + terminalHandle, + transcriptPath, + worktreeId + }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession: { key: 'session_id' as const, id: providerSessionId, transcriptPath }, + launchConfig: { + agentCommand, + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + for (const agentState of ['working', 'done'] as const) { + state.setAgentStatus( + paneKey, + { state: agentState, prompt: 'Report completion and stay open', agentType: 'codex' }, + 'Settled background worker', + undefined, + metadata, + recovery + ) + } + }, + { + agentCommand: completedWorkerFakeCodexCommand, + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: workerTabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed and stays open for inspection.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + const taskBeforeRestart = ( + await client.call('orchestration.taskList', { run: run.result.run.id }) + ).result + const dispatchBeforeRestart = ( + await client.call('orchestration.dispatchShow', { task: task.result.task.id }) + ).result + + await session.close(firstApp) + firstApp = null + expectNoPersistedWorkerFence(session.userDataDir, workerPaneKey) + expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual( + [] + ) + + const launchesBeforeRestart = readCompletedWorkerLedger().filter( + (event) => event.event === 'spawn' + ) + if (daemonSessionGone) { + const daemonDir = path.join(session.userDataDir, 'daemon') + const daemon = new DaemonClient({ + socketPath: getDaemonSocketPath(daemonDir), + tokenPath: getDaemonTokenPath(daemonDir) + }) + try { + await daemon.ensureConnected() + await daemon.request('kill', { sessionId: workerPtyId, immediate: true }) + await expect + .poll(async () => { + const result = await daemon.request<{ sessions: { sessionId: string }[] }>( + 'listSessions', + undefined + ) + return result.sessions.some((entry) => entry.sessionId === workerPtyId) + }) + .toBe(false) + } finally { + daemon.disconnect() + } + } + const second = await session.launch() + secondApp = second.app + await waitForSessionReady(second.page) + if (!daemonSessionGone) { + // The restarted runtime must rediscover the daemon-owned worker before reveal. + await expect + .poll( + async () => + (await listRuntimeTerminals(client)).find( + (terminal) => terminal.ptyId === workerPtyId + )?.connected ?? null, + { timeout: 60_000, message: 'restarted runtime never rediscovered the worker PTY' } + ) + .toBe(true) + } + expect( + await second.page.evaluate( + ({ tabId, worktreeId }) => + Boolean( + window.__store?.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + { tabId: workerTabId, worktreeId: targetWorktreeId } + ) + ).toBe(true) + + // Hidden mount, then click to reveal: reveal runs the missing-session reconciler. + await backgroundMountTab(second.page, targetWorktreeId, workerTabId) + // Poll, don't sample: main's cache learns the session when the pane's deferred reattach lands, + // and backgroundMountTab only waits for the pane manager to exist. A restarted main that never + // attaches stays false for the whole window, which is the regression this guards. + if (!daemonSessionGone) { + await expect + .configure({ soft: true }) + .poll(() => second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), workerPtyId), { + timeout: 20_000, + message: 'liveness before reveal' + }) + .toBe(true) + } + await second.page.evaluate( + ({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + type Transition = { + activeWorktreeId: string | null + tabPresent: boolean + leafPtyIds: string[] + activeTabId: string | null + } + const snapshot = (state: ReturnType): Transition => ({ + activeWorktreeId: state.activeWorktreeId ?? null, + tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + leafPtyIds: Object.values(state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId ?? {}), + activeTabId: state.activeTabIdByWorktree[worktreeId] ?? null + }) + const transitions: Transition[] = [snapshot(store.getState())] + const e2eWindow = window as typeof window & { __orcaRevealTransitions?: Transition[] } + e2eWindow.__orcaRevealTransitions = transitions + store.subscribe((state) => { + const next = snapshot(state) + if (JSON.stringify(next) !== JSON.stringify(transitions.at(-1))) { + transitions.push(next) + } + }) + }, + { tabId: workerTabId, worktreeId: targetWorktreeId } + ) + await second.page + .locator(`[role="option"][data-worktree-id="${targetWorktreeId}"]`) + .first() + .click() + const visibleTab = second.page + .locator(`[data-testid="sortable-tab"][data-tab-id="${workerTabId}"]`) + .first() + await visibleTab.click({ timeout: 10_000 }) + await expect(visibleTab).toBeVisible() + await ensureTerminalVisible(second.page) + // Give the reconciler's async verdict time to land; the tab must never have left. + await second.page.waitForTimeout(3_000) + const transitions = await second.page.evaluate( + () => + ( + window as typeof window & { + __orcaRevealTransitions?: { + activeWorktreeId: string | null + tabPresent: boolean + leafPtyIds: string[] + }[] + } + ).__orcaRevealTransitions ?? [] + ) + // Pre-fix this read: leaf binding cleared -> tab removed -> worktree deselected -> tab re-added by graph sync. + expect( + transitions.filter( + (step) => !step.tabPresent || (!daemonSessionGone && step.leafPtyIds.length === 0) + ), + 'reveal must not tear the settled worker tab down' + ).toEqual([]) + expect(transitions.at(-1)?.activeWorktreeId).toBe(targetWorktreeId) + expect( + await second.page.evaluate( + (tabId) => Boolean(window.__paneManagers?.get(tabId)), + workerTabId + ) + ).toBe(true) + if (!daemonSessionGone) { + expect( + (await listRuntimeTerminals(client)).find((terminal) => terminal.ptyId === workerPtyId) + ?.connected + ).toBe(true) + expect( + readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit') + ).toEqual([]) + } + const newLaunches = readCompletedWorkerLedger() + .filter((event) => event.event === 'spawn') + .slice(launchesBeforeRestart.length) + if (daemonSessionGone) { + expect(newLaunches.length).toBeLessThanOrEqual(1) + for (const launch of newLaunches) { + // Codex's --resume equivalent is the `resume ` subcommand. + expect(launch.args).toContain('resume') + expect(launch.args).toContain(PROVIDER_SESSION_ID) + } + const listed = await client.call<{ + workers: { dispatchId: string; terminalState: string; workerState: string }[] + }>('orchestration.workerList', { run: run.result.run.id }) + await testInfo.attach('resumed-worker-accounting', { + body: JSON.stringify({ newLaunches, workers: listed.result.workers }), + contentType: 'application/json' + }) + expect(listed.result.workers).toEqual([ + expect.objectContaining({ + dispatchId: started.result.dispatchId, + terminalState: 'retained', + workerState: 'succeeded' + }) + ]) + } else { + expect(newLaunches).toEqual([]) + expect( + await second.page.evaluate((ptyId) => window.api.pty.hasPty(ptyId), workerPtyId) + ).toBe(true) + } + expect(readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')).toEqual( + [] + ) + expect( + (await client.call('orchestration.taskList', { run: run.result.run.id })).result + ).toEqual(taskBeforeRestart) + expect( + (await client.call('orchestration.dispatchShow', { task: task.result.task.id })).result + ).toEqual(dispatchBeforeRestart) + await expect(visibleTab).toBeVisible() + const paneKeys = await second.page.evaluate((tabId) => { + const layout = window.__store?.getState().terminalLayoutsByTabId[tabId] + const leaves: string[] = [] + const visit = (node: NonNullable['root']) => { + if (node.type === 'leaf') { + leaves.push(`${tabId}:${node.leafId}`) + } else { + visit(node.first) + visit(node.second) + } + } + if (layout?.root) { + visit(layout.root) + } + return leaves + }, workerTabId) + expect(paneKeys).toContain(workerPaneKey) + expect( + await secondApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map((window) => ({ + visible: window.isVisible(), + focused: window.isFocused() + })) + ) + ).toEqual([{ visible: false, focused: false }]) + await second.page.screenshot({ path: testInfo.outputPath('settled-worker-revealed.png') }) + await session.close(secondApp) + secondApp = null + const persisted = readPersistedSession(session.userDataDir) + expectNoPersistedWorkerFence(session.userDataDir, workerPaneKey) + expect( + persisted.tabsByWorktree[targetWorktreeId].some( + (tab: { id: string }) => tab.id === workerTabId + ) + ).toBe(true) + expect(persisted.terminalLayoutsByTabId[workerTabId]).toBeDefined() + if (!daemonSessionGone) { + expect( + Object.values(persisted.terminalLayoutsByTabId[workerTabId].ptyIdsByLeafId) + ).toContain(workerPtyId) + } + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } + }) +}