From f46777ba1876ae24b954e86ed9fc3a9e635c1218 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 26 Oct 2026 15:40:39 -0400 Subject: [PATCH] fix(terminal): narrow survivor attach: waits behave as on main, one quiet clock per generation, keep main's hidden mark on failed attach --- src/main/ipc/pty/runtime/operations.ts | 10 +++----- .../ipc/pty/runtime/spawn-hidden-delivery.ts | 5 ++-- ...al-subscriber-driven-daemon-attach.test.ts | 16 ------------- .../input/terminal-agent-prompt-render.ts | 7 ------ .../terminal/layout/terminal-view-presence.ts | 12 ---------- .../lifecycle/main-daemon-attachments.ts | 24 +++++++++---------- .../pty-controller-inventory-refresh.ts | 2 +- .../output/terminal-buffer-operations.ts | 4 ++-- .../wait/terminal-wait-visible-probe.ts | 8 ++++--- .../runtime/terminal/wait/terminal-wait.ts | 24 +++++++++++++++---- .../runtime/tui-idle-output-clock.test.ts | 5 +++- .../workspaces/workspace-activation.ts | 1 - 12 files changed, 48 insertions(+), 70 deletions(-) diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 605b2337fe3..b607aeabef2 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -129,11 +129,10 @@ export async function attachPtyFromRuntimeController( if (provider !== localProvider || provider instanceof LocalPtyProvider) { return false } - // Only a mark this attach set may be undone; a pane's own hidden claim stays. - const hidden = - !isRendererViewShowingPty(ptyId) && - !isHiddenRendererPty(ptyId) && + // A pane's own hidden claim stays its own; a reveal or exit clears main's. + if (!isRendererViewShowingPty(ptyId) && !isHiddenRendererPty(ptyId)) { hideRuntimeOwnedRendererDelivery(deps, ptyId) + } try { const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0 const attachResult = await provider.attach(ptyId) @@ -152,9 +151,6 @@ export async function attachPtyFromRuntimeController( deps.syncPtyBackgroundedDelivery?.(ptyId, 'main-attach') return true } catch { - if (hidden) { - deps.transitionSpawnHiddenRendererPtyDeliveryState?.(ptyId, false) - } return false } } diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 0c47aa95593..24a8e9a78e3 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -73,16 +73,15 @@ function markRuntimeSpawnHidden(ctx: RuntimePtySpawnState, id: string): void { export function hideRuntimeOwnedRendererDelivery( deps: Pick, id: string -): boolean { +): void { const transition = deps.transitionSpawnHiddenRendererPtyDeliveryState if (!transition) { - return false + return } // Transition first so a fresh mark still invalidates the drain policy. transition(id, true) // No renderer exists to re-mark this PTY after reload. markRuntimeOwnedHiddenRendererPty(id) - return true } function isAdoptedAgentSession(ensure: unknown): boolean { diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index fa1067b1001..64d37a5ccf1 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -25,7 +25,6 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map - mainDaemonAttachments: { isUnreachable: (ptyId: string) => boolean } refreshPtyWorktreeRecordsWithControllerInventory: ( worktrees: [], targetWorktreeId: string | null @@ -432,21 +431,6 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { expect(read.tail.join('\n')).toContain('worker still printing') }) - it('fails a wait at once when main could not attach the surviving session', async () => { - const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false }) - const refuse = model.deferNextAttach(false) - await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) - refuse() - await vi.waitFor(() => - expect(internals(runtime).mainDaemonAttachments.isUnreachable(PTY_ID)).toBe(true) - ) - - await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - ).rejects.toThrow('terminal_not_connected_since_restart') - expect(model.attachCalls).toEqual([PTY_ID]) - }) - it('does not subscriber-attach or provider-read a session this app already spawned', async () => { const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: true, diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts b/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts index d3d65985db9..66fb54ff4c2 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts @@ -138,13 +138,6 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun launchReadiness?: boolean } ): Promise { - if ((options?.condition ?? 'exit') !== 'exit') { - try { - this.assertTerminalOutputReachesMain(handle) - } catch (error) { - return Promise.reject(error) - } - } // Switch point: a saved pane main holds with no terminal yet starts first (once, shared). const starting = ORCA_LAYOUT_OWNER_IS_MAIN ? this.startRestoredHandlePane(handle, options ?? {}) diff --git a/src/main/runtime/terminal/layout/terminal-view-presence.ts b/src/main/runtime/terminal/layout/terminal-view-presence.ts index 685d0e93d1a..796b3467773 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -68,18 +68,6 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId) } - /** A survivor main holds no stream for can never satisfy a wait; say so instead of timing out. */ - protected assertTerminalOutputReachesMain(handle: string): void { - const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId - if (ptyId && this.mainDaemonAttachments.isUnreachable(ptyId)) { - throw new Error('terminal_not_connected_since_restart') - } - } - - protected reconcileMainDaemonAttach(ptyId: string): void { - this.mainDaemonAttachments.reconcile(ptyId) - } - /** Mark a raw-output viewer without transferring terminal query authority. */ registerRawTerminalViewSubscriber(ptyId: string): () => void { return this.terminalViewSubscribers.registerRaw(ptyId) diff --git a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts index aef131724d1..a6cedb8379c 100644 --- a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts +++ b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts @@ -30,11 +30,6 @@ export class MainDaemonAttachments { return !this.attached.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId) } - /** No stream and no attach in flight: nothing this session prints can reach main. */ - isUnreachable(ptyId: string): boolean { - return !this.attaching.has(ptyId) && this.isKnownUnattachedLocal(ptyId) - } - /** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */ reconcile(ptyId: string): void { const attempt = this.attaching.get(ptyId) @@ -53,7 +48,7 @@ export class MainDaemonAttachments { if (this.attaching.has(ptyId) || !this.isKnownUnattachedLocal(ptyId)) { return } - const attempt = this.attach(ptyId) + const attempt: Promise = this.acquireSlot().then(() => this.attach(ptyId, attempt)) this.attaching.set(ptyId, attempt) void attempt.then((attached) => { if (this.attaching.get(ptyId) !== attempt) { @@ -66,16 +61,19 @@ export class MainDaemonAttachments { }) } - private async attach(ptyId: string): Promise { - if (this.running >= MAX_CONCURRENT_ATTACHES) { - // The finishing attach hands its slot over without releasing it. - await new Promise((resolve) => this.queued.push(resolve)) - } else { + private acquireSlot(): Promise { + if (this.running < MAX_CONCURRENT_ATTACHES) { this.running += 1 + return Promise.resolve() } + // The finishing attach hands its slot over without releasing it. + return new Promise((resolve) => this.queued.push(resolve)) + } + + private async attach(ptyId: string, attempt: Promise): Promise { try { - // A pane may have attached, or the session exited, while this waited. - if (this.attached.has(ptyId) || !this.deps.isUnattachedLocalCandidate(ptyId)) { + // While this queued, an exit or respawn may have superseded it, or a pane may have attached. + if (this.attaching.get(ptyId) !== attempt || !this.isKnownUnattachedLocal(ptyId)) { return false } return (await this.deps.attachProvider(ptyId)) ?? false diff --git a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts index a754f654021..0588bfa131b 100644 --- a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts +++ b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts @@ -190,7 +190,7 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } pty.controllerTitle = session.title?.trim() || null - this.reconcileMainDaemonAttach(session.id) + this.mainDaemonAttachments.reconcile(session.id) } // Do not serialize relay probes or let one throw abort inventory cleanup. if (inventoryOptions?.refreshForegroundAgents !== false) { diff --git a/src/main/runtime/terminal/output/terminal-buffer-operations.ts b/src/main/runtime/terminal/output/terminal-buffer-operations.ts index 318de8da6c7..506daac8fc0 100644 --- a/src/main/runtime/terminal/output/terminal-buffer-operations.ts +++ b/src/main/runtime/terminal/output/terminal-buffer-operations.ts @@ -225,11 +225,11 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun }) } - /** A reattached live process may print nothing; from now on its silence is observed quiet. */ + /** A reattached live process may print nothing; its silence is observed quiet from the first attach of this generation. */ startReattachedOutputClock(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (pty && pty.lastOutputAt === null) { - pty.outputObservedSince = Date.now() + pty.outputObservedSince ??= Date.now() } } diff --git a/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts b/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts index 9d86333b584..15420ea055d 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts @@ -25,6 +25,7 @@ import { buildTerminalWaitBlockedResult, buildTerminalWaitResult } from '../../terminal-wait-results' +import { outputClockOf } from '../../tui-idle-evidence-source' import { createSetupCompletionScanner } from '../../orchestration/setup-completion-signal' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -79,10 +80,11 @@ export abstract class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRu agent, () => restoreProjectedComposerDraft(projection.tail, projection.draft), // Why read now: output since the probe started makes the pane clocked. - ( + outputClockOf( + this.tuiIdleEvidenceSource, this.getLivePtyForHandle(waiter.handle)?.pty ?? - this.getLiveLeafForHandle(waiter.handle).leaf - ).lastOutputAt !== null + this.getLiveLeafForHandle(waiter.handle).leaf + ) !== null ) : isKnownReadyPromptSettled(snapshotText) if (!blockedReason && !ready) { diff --git a/src/main/runtime/terminal/wait/terminal-wait.ts b/src/main/runtime/terminal/wait/terminal-wait.ts index c977fcefbf0..a0a4dfee2c2 100644 --- a/src/main/runtime/terminal/wait/terminal-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-wait.ts @@ -15,6 +15,7 @@ import { buildTerminalWaitText } from '../../terminal-wait-tail-state' import { evaluateTuiIdle, type TuiIdleVerdict } from '../../tui-idle-evidence' import { leafTuiIdleEvidence, + outputClockOf, ptyTuiIdleEvidence, type TuiIdleEvidenceSource } from '../../tui-idle-evidence-source' @@ -30,13 +31,14 @@ import type { RuntimeTerminalWaiterRegistry } from './terminal-waiter-registry' /** Probe unseen/provider-only panes and clockless screen-ruled agents with untrusted restored models; clocked panes settle through quiet polling. profile.screenProbeBanner also opts in. */ function shouldProbeVisibleScreen( paneAgent: TuiAgent | null, - record: Pick, + record: Pick, + outputClockAt: number | null, waitText: string ): boolean { return ( (record.lastAgentStatus === null && waitText.length === 0) || showsScreenProbeBanner(waitText) || - (readsTrustedScreen(paneAgent) && record.lastOutputAt === null) + (readsTrustedScreen(paneAgent) && outputClockAt === null) ) } @@ -154,7 +156,14 @@ export class RuntimeTerminalWait { } else { this.polls.startPty(waiter, live.pty, verdict) const paneAgent = this.deps.getPaneAgent(live.pty.ptyId) - if (shouldProbeVisibleScreen(paneAgent, live.pty, livePtyWaitText)) { + if ( + shouldProbeVisibleScreen( + paneAgent, + live.pty, + outputClockOf(this.deps, live.pty), + livePtyWaitText + ) + ) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } @@ -235,7 +244,14 @@ export class RuntimeTerminalWait { // Ready previews can precede idle OSC titles; keep polling both. this.polls.startLeaf(waiter, live.leaf, verdict) const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId) - if (shouldProbeVisibleScreen(paneAgent, live.leaf, liveLeafWaitText)) { + if ( + shouldProbeVisibleScreen( + paneAgent, + live.leaf, + outputClockOf(this.deps, live.leaf), + liveLeafWaitText + ) + ) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } diff --git a/src/main/runtime/tui-idle-output-clock.test.ts b/src/main/runtime/tui-idle-output-clock.test.ts index ccfe254faaf..5d887e948f5 100644 --- a/src/main/runtime/tui-idle-output-clock.test.ts +++ b/src/main/runtime/tui-idle-output-clock.test.ts @@ -11,7 +11,7 @@ afterEach(() => { vi.useRealTimers() }) -it('a silent reattached Codex reads quiet on its PTY and its pane leaf alike', () => { +it('a silent reattached Codex reads quiet on its PTY and its pane leaf alike, and stays quiet when its pane mounts', () => { vi.useFakeTimers({ now: 100_000 }) const { runtime, leaf } = runtimeWithLeaf(PTY_ID) runtime.seedTerminalRestoreTail(PTY_ID, { text: CODEX_COMPOSER }) @@ -34,4 +34,7 @@ it('a silent reattached Codex reads quiet on its PTY and its pane leaf alike', ( // Neither record ever saw output; the clock is main's observation since the reattach. expect([pty.lastOutputAt, leaf.lastOutputAt]).toEqual([null, null]) expect(verdicts()).toEqual(['ready-strong', 'ready-strong']) + // A pane mounting later reattaches the same process; its observed quiet must not restart. + runtime.startReattachedOutputClock(PTY_ID) + expect(verdicts()).toEqual(['ready-strong', 'ready-strong']) }) diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index c1bb414ebb0..0a50d6e6a85 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -221,7 +221,6 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime signal }: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {} ): Promise { - this.assertTerminalOutputReachesMain(handle) const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const stop = new AbortController()