diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index e06bf559f2f..e4e3c81acbe 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -971,6 +971,7 @@ 1 test-no-wall-clock:src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts 2 test-no-wall-clock:src/main/runtime/terminal/surface/pty-pane-copies-untouched-main.test.ts 2 test-no-wall-clock:src/main/runtime/tui-idle-claude-task-wakeup.test.ts +12 test-no-wall-clock:src/main/runtime/tui-idle-evidence.test.ts 18 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane-runtime.test.ts 10 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane.test.ts 4 test-no-wall-clock:src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts diff --git a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts index 816ef61ccb8..cc36c7a544b 100644 --- a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts @@ -367,9 +367,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { ) const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 }) try { - await expect(legacy.attach('legacy-session')).resolves.not.toHaveProperty( - 'providerSequence' - ) + const attached = await legacy.attach('legacy-session') + expect(attached).toMatchObject({ + id: 'legacy-session', + isReattach: true, + incarnationId: 'legacy-attach-incarnation' + }) + expect(attached.providerSequence).toBeUndefined() } finally { legacy.dispose() request.mockRestore() diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts index 2aca6692fb3..3a5b19b584b 100644 --- a/src/main/ipc/pty/delivery/visibility-state.ts +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -10,6 +10,11 @@ export const visibleRendererPtys = new Set() export const rendererVisibilityKnownPtys = new Set() // Visible attaches suppress main-start hiding; remember it so failed attaches can hide the PTY again. export const attachingRendererViewPtys = new Map() +/** A visible view, or one mid-attach, owns delivery; hiding it would drop output it is about to paint. */ +export function isRendererViewShowingPty(id: string): boolean { + return visibleRendererPtys.has(id) || attachingRendererViewPtys.has(id) +} + // Keep null-initialized wrapper bridges to avoid Rolldown no-op folding (STA-5661; see debug.ts). let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null diff --git a/src/main/ipc/pty/pane/agent-session-owners.ts b/src/main/ipc/pty/pane/agent-session-owners.ts index 0a0ad557bfc..aabdeb8d6fa 100644 --- a/src/main/ipc/pty/pane/agent-session-owners.ts +++ b/src/main/ipc/pty/pane/agent-session-owners.ts @@ -39,6 +39,9 @@ export function seedTerminalRestoreRecordsFromSpawnResult( ...(lastTitle !== undefined ? { lastTitle } : {}) }) } + if (result.isReattach === true) { + runtime?.startReattachedOutputClock?.(result.id) + } } export function assertSpawnReplyWasLive(result: PtySpawnResult): void { diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 9489117f9b1..3ba4cb9d1c0 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -12,7 +12,11 @@ import { import { localProvider } from './registry' import { clearProviderPtyState } from './state-cleanup' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { providerSnapshotRequiredPtys, visibleRendererPtys } from '../delivery/visibility-state' +import { + providerSnapshotRequiredPtys, + rendererVisibilityKnownPtys, + visibleRendererPtys +} from '../delivery/visibility-state' import type { PtyIpcSession } from '../session' import { publishPtyEndpointLoss } from '../../../runtime/terminal/pty-endpoint-loss-signal' @@ -27,8 +31,10 @@ export function bindProviderListeners(session: PtyIpcSession): void { localProvider.onWriteUnavailable?.((payload) => { // Before the window check: a headless host has no window, only remote streams. publishPtyEndpointLoss(payload.id) - // Panes remount and re-attach themselves; main must restore its own stream for unmounted ones. - session.runtime?.reattachPtyAfterEndpointLoss(payload.id) + // A pane remounts and re-attaches itself; main restores only streams no pane will. + if (!rendererVisibilityKnownPtys.has(payload.id)) { + session.runtime?.reattachPtyAfterEndpointLoss(payload.id) + } if ( !session.mainWindow || session.mainWindow.isDestroyed() || diff --git a/src/main/ipc/pty/pty-renderer-liveness-guard.test.ts b/src/main/ipc/pty/pty-renderer-liveness-guard.test.ts index ac13f4754e8..256f70d5467 100644 --- a/src/main/ipc/pty/pty-renderer-liveness-guard.test.ts +++ b/src/main/ipc/pty/pty-renderer-liveness-guard.test.ts @@ -3,6 +3,7 @@ import { setupPtyIpcSuite } from '../pty-ipc-test-harness' import { PtyWriteUnavailableError } from '../../providers/pty-write-unavailable-error' import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../../shared/terminal-input' import { registerPtyHandlers } from './register-handlers' +import { rendererVisibilityKnownPtys } from './delivery/visibility-state' import { subscribeToPtyEndpointLoss } from '../../runtime/terminal/pty-endpoint-loss-signal' vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock())) @@ -114,7 +115,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { }) describe('the daemon-death fan-out', () => { - function installProviderCapturingFanout(): (payload: { id: string }) => void { + function installProviderCapturingFanout(runtime?: unknown): (payload: { id: string }) => void { let fanout: ((payload: { id: string }) => void) | null = null installDaemonTestProvider({ onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { @@ -122,7 +123,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { return () => {} }) }) - registerPtyHandlers(mainWindow as never) + registerPtyHandlers(mainWindow as never, runtime as never) if (!fanout) { throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable') } @@ -139,6 +140,23 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { }) }) + it('re-attaches in main only the sessions no mounted pane will restore', () => { + const reattachPtyAfterEndpointLoss = vi.fn() + const fanout = installProviderCapturingFanout({ + setPtyController: vi.fn(), + reattachPtyAfterEndpointLoss + }) + rendererVisibilityKnownPtys.add('pane-1') + try { + fanout({ id: 'pane-1' }) + fanout({ id: 'unmounted-1' }) + } finally { + rendererVisibilityKnownPtys.delete('pane-1') + } + + expect(reattachPtyAfterEndpointLoss.mock.calls).toEqual([['unmounted-1']]) + }) + it('stays silent when the WebContents is destroyed under a live window', () => { const fanout = installProviderCapturingFanout() mainWindow.webContents.isDestroyed.mockReturnValue(true) diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index cc549ecd725..605b2337fe3 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -3,7 +3,8 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { IPtyProvider } from '../../../providers/pty-provider-contract' import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { ptyOwnership } from '../provider/ownership-state' -import { ptySizes, visibleRendererPtys } from '../delivery/visibility-state' +import { isRendererViewShowingPty, ptySizes } from '../delivery/visibility-state' +import { isHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate' import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners' import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore' import { hideRuntimeOwnedRendererDelivery } from './spawn-hidden-delivery' @@ -128,18 +129,22 @@ export async function attachPtyFromRuntimeController( if (provider !== localProvider || provider instanceof LocalPtyProvider) { return false } - const hidden = !visibleRendererPtys.has(ptyId) && hideRuntimeOwnedRendererDelivery(deps, ptyId) + // Only a mark this attach set may be undone; a pane's own hidden claim stays. + const hidden = + !isRendererViewShowingPty(ptyId) && + !isHiddenRendererPty(ptyId) && + hideRuntimeOwnedRendererDelivery(deps, ptyId) try { const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0 const attachResult = await provider.attach(ptyId) + if (attachResult?.providerSequence) { + deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( + ptyId, + attachResult.providerSequence, + sequenceBeforeProviderAttach + ) + } if (attachResult) { - if (attachResult.providerSequence) { - deps.runtime?.synchronizePtyOutputSequenceFromProvider?.( - ptyId, - attachResult.providerSequence, - sequenceBeforeProviderAttach - ) - } // A restarted runtime has never seen this screen; an idle agent may print nothing after attach. seedHeadlessTerminalFromSpawnResult(deps.runtime, attachResult, null) seedTerminalRestoreRecordsFromSpawnResult(deps.runtime, attachResult) diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 4c0dbe12775..0c47aa95593 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -1,5 +1,5 @@ import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate' -import { attachingRendererViewPtys, visibleRendererPtys } from '../delivery/visibility-state' +import { attachingRendererViewPtys, isRendererViewShowingPty } from '../delivery/visibility-state' import { closeStartupQueryAuthorityForPty } from '../provider/registry' import type { RuntimePtySpawnState } from './spawn-state' import type { PtyRuntimeControllerDeps } from './controller-deps' @@ -43,7 +43,7 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi if (attaching) { attaching.skippedHide = true } - if (visibleRendererPtys.has(id) || attaching) { + if (isRendererViewShowingPty(id)) { releaseRuntimeSpawnPreSpawnHiddenMark(ctx) ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn') return diff --git a/src/main/runtime/runtime-terminal-view-subscribers.ts b/src/main/runtime/runtime-terminal-view-subscribers.ts index 14e60b4636a..8da7e33956d 100644 --- a/src/main/runtime/runtime-terminal-view-subscribers.ts +++ b/src/main/runtime/runtime-terminal-view-subscribers.ts @@ -13,7 +13,6 @@ export class RuntimeTerminalViewSubscribers { constructor(private readonly deps: RuntimeTerminalViewSubscriberDependencies) {} - // Why: exit drops subscribers only; attach/publish state survives until the respawn advances the generation. clearSubscribers(ptyId: string): void { this.remoteCounts.delete(ptyId) this.silenceableRemoteViews.delete(ptyId) 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 6b564603af8..a5aff180401 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -453,7 +453,18 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { expect(model.emitData(PTY_ID, 'after loss\r\n')).toBe(true) }) - it('fails a wait at once when main could not attach the surviving session', async () => { + it('fails a wait at once when main cannot attach the surviving session', async () => { + const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false }) + // The daemon refuses an attach-only request for a session it does not own. + model.sessions.delete(PTY_ID) + + 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('retries a refused attach when a wait arrives, instead of failing the wait', async () => { const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false }) const refuse = model.deferNextAttach(false) await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) @@ -463,8 +474,10 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { ) await expect( - runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) - ).rejects.toThrow('terminal_not_connected_since_restart') + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 50 }) + ).rejects.toThrow('timeout') + expect(model.attachCalls).toEqual([PTY_ID, PTY_ID]) + expect(model.emitData(PTY_ID, 'attached on retry\r\n')).toBe(true) }) it('does not subscriber-attach or provider-read a session this app already spawned', async () => { 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 4bb80fb2a66..bb6a94d0097 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,21 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun launchReadiness?: boolean } ): Promise { - if (options?.condition !== 'exit') { - try { - this.assertTerminalOutputReachesMain(handle) - } catch (error) { - return Promise.reject(error) - } + const attaching = options?.condition === 'exit' ? null : this.attachTerminalOutputToMain(handle) + return attaching + ? attaching.then(() => this.waitForReachableTerminal(handle, options)) + : this.waitForReachableTerminal(handle, options) + } + + private waitForReachableTerminal( + handle: string, + options?: { + condition?: RuntimeTerminalWaitCondition + timeoutMs?: number + signal?: AbortSignal + launchReadiness?: boolean } + ): Promise { // 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 e29b4ee4b20..49753cd6a84 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -68,16 +68,17 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId) } - /** A failed attach leaves a surviving session silent to main; waiting on it could only time out. */ - protected assertTerminalOutputReachesMain(handle: string): void { + /** Null when main already receives the terminal; else attaches it, rejecting if it cannot, since a wait could only time out. */ + protected attachTerminalOutputToMain(handle: string): Promise | null { const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId - if ( - ptyId && - this.isKnownUnattachedLocalDaemonPty(ptyId) && - !this.mainDaemonAttachments.hasPendingAttach(ptyId) - ) { - throw new Error('terminal_not_connected_since_restart') - } + const attaching = ptyId ? this.mainDaemonAttachments.ensureAttached(ptyId) : null + return ( + attaching?.then((attached) => { + if (!attached) { + throw new Error('terminal_not_connected_since_restart') + } + }) ?? null + ) } protected reconcileMainDaemonAttach(ptyId: string): void { diff --git a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts index 736d72c2823..116b3d7d4aa 100644 --- a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts +++ b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts @@ -3,38 +3,41 @@ type MainDaemonAttachmentDependencies = { attachProvider: (ptyId: string) => Promise | null } +/** Who holds a session's stream: an attach in flight, a stream this run holds, or one an endpoint drop detached. */ +type StreamState = + | { kind: 'attaching'; restoring: boolean; attempt: Promise } + | { kind: 'attached' } + | { kind: 'lost' } + // Why capped: 100 concurrent snapshot attaches delayed the visible terminal's first echo by ~0.7 s. const MAX_CONCURRENT_ATTACHES = 4 /** Main holds the stream for every live local daemon session it adopts, so its records fill without a mounted pane. */ export class MainDaemonAttachments { - private readonly attaches = new Map>() + private readonly states = new Map() private readonly inventoryWaiters = new Set() - private readonly spawnPublishedPtys = new Set() - // Endpoint loss detached these; they need a re-attach although main already holds their model. - private readonly lostPtys = new Set() private readonly queued: (() => void)[] = [] private running = 0 constructor(private readonly deps: MainDaemonAttachmentDependencies) {} + /** This run's spawn or a pane's attach already carries the stream. */ markSpawnPublished(ptyId: string): void { - this.spawnPublishedPtys.add(ptyId) + this.states.set(ptyId, { kind: 'attached' }) } - resetGeneration(ptyId: string): void { - this.attaches.delete(ptyId) + /** Respawn or exit ends the generation whose stream this state described. */ + forget(ptyId: string): void { + this.states.delete(ptyId) this.inventoryWaiters.delete(ptyId) - this.spawnPublishedPtys.delete(ptyId) - this.lostPtys.delete(ptyId) } isKnownUnattachedLocal(ptyId: string): boolean { - return !this.spawnPublishedPtys.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId) + return !this.states.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId) } hasPendingAttach(ptyId: string): boolean { - return this.attaches.has(ptyId) + return this.states.get(ptyId)?.kind === 'attaching' } inventoryWaiterIds(): string[] { @@ -43,8 +46,8 @@ export class MainDaemonAttachments { /** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */ reconcile(ptyId: string): void { - const pending = this.attaches.get(ptyId) - if (!pending) { + const state = this.states.get(ptyId) + if (state?.kind !== 'attaching') { this.ensure(ptyId) return } @@ -52,47 +55,50 @@ export class MainDaemonAttachments { return } this.inventoryWaiters.add(ptyId) - void pending.then((attached) => { + void state.attempt.then(() => { this.inventoryWaiters.delete(ptyId) - if (attached) { - return - } - if (this.attaches.get(ptyId) === pending) { - this.attaches.delete(ptyId) - } this.ensure(ptyId) }) } - /** A dropped daemon endpoint detaches every stream; panes remount, sessions without one re-attach here. */ + /** An endpoint drop detached a stream no pane will restore; re-attach it once the daemon answers. */ reattachAfterEndpointLoss(ptyId: string): void { - this.attaches.delete(ptyId) - this.lostPtys.add(ptyId) + this.states.set(ptyId, { kind: 'lost' }) this.ensure(ptyId) } ensure(ptyId: string): void { - if ( - this.attaches.has(ptyId) || - (!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId)) - ) { - return - } - const attempt = this.attach(ptyId) - this.attaches.set(ptyId, attempt) - void attempt.then((attached) => { - if (this.attaches.get(ptyId) !== attempt) { - return - } - if (attached) { - this.lostPtys.delete(ptyId) - } else { - this.attaches.delete(ptyId) - } - }) + void this.ensureAttached(ptyId) } - private async attach(ptyId: string): Promise { + /** Null when the stream needs nothing from main; otherwise whether an attach (or retry) got it. */ + ensureAttached(ptyId: string): Promise | null { + const state = this.states.get(ptyId) + if (state?.kind === 'attaching') { + return state.attempt + } + const restoring = state?.kind === 'lost' + if (state?.kind === 'attached' || (!restoring && !this.isKnownUnattachedLocal(ptyId))) { + return null + } + const attaching: StreamState = { kind: 'attaching', restoring, attempt: Promise.resolve(false) } + this.states.set(ptyId, attaching) + attaching.attempt = this.attach(ptyId, attaching).then((attached) => { + if (this.states.get(ptyId) === attaching) { + if (attached) { + this.states.set(ptyId, { kind: 'attached' }) + } else if (restoring) { + this.states.set(ptyId, { kind: 'lost' }) + } else { + this.states.delete(ptyId) + } + } + return attached + }) + return attaching.attempt + } + + private async attach(ptyId: string, attaching: StreamState): 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)) @@ -100,9 +106,9 @@ export class MainDaemonAttachments { this.running += 1 } try { - // A pane may have attached, or the session respawned, while this waited. - if (!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId)) { - return false + // A pane's attach or a respawn replaced this state while it waited. + if (this.states.get(ptyId) !== attaching) { + return this.states.get(ptyId)?.kind === 'attached' } return (await this.deps.attachProvider(ptyId)) ?? false } catch { diff --git a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts index 5a87bee49db..2419acf5d1c 100644 --- a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts +++ b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts @@ -164,6 +164,7 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi advertisedUrlWatcher.unbindPty(ptyId) this.mobileSubscribers.delete(ptyId) this.terminalViewSubscribers.clearSubscribers(ptyId) + this.mainDaemonAttachments.forget(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) diff --git a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts index 1eb9ecfd589..d65e42647d7 100644 --- a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts +++ b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts @@ -162,18 +162,7 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit }, attachProvider: (ptyId) => { const attach = this.ptyController?.attach - if (!attach) { - return null - } - return (async () => { - const attached = await attach(ptyId) - const pty = attached ? this.ptysById.get(ptyId) : undefined - // An idle agent may print nothing after attach; from now on its silence is observed. - if (pty && pty.lastOutputAt === null) { - pty.outputObservedSince = Date.now() - } - return attached - })() + return attach ? (async () => attach(ptyId))() : null } }) diff --git a/src/main/runtime/terminal/output/terminal-buffer-operations.ts b/src/main/runtime/terminal/output/terminal-buffer-operations.ts index b44deb05b22..bc6ed49cc16 100644 --- a/src/main/runtime/terminal/output/terminal-buffer-operations.ts +++ b/src/main/runtime/terminal/output/terminal-buffer-operations.ts @@ -225,6 +225,14 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun }) } + /** A reattached live process may print nothing; from now on its silence is observed quiet. */ + startReattachedOutputClock(ptyId: string): void { + const pty = this.ptysById.get(ptyId) + if (pty && pty.lastOutputAt === null) { + pty.outputObservedSince ??= Date.now() + } + } + // Restore RPC bytes bypass onPtyData; seed read/list state without waiters, orchestration events or fresh-output clocks. seedTerminalRestoreTail(ptyId: string, restore: { text?: string; lastTitle?: string }): void { const seed = restore.text ? buildRestoredTerminalTailSeed(restore.text) : null diff --git a/src/main/runtime/terminal/output/terminal-output-generation.ts b/src/main/runtime/terminal/output/terminal-output-generation.ts index c8e437ad79d..933c2eb9082 100644 --- a/src/main/runtime/terminal/output/terminal-output-generation.ts +++ b/src/main/runtime/terminal/output/terminal-output-generation.ts @@ -96,7 +96,7 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now()) this.legacyWorkerRecovery.deleteRecoveredPty(ptyId) // Why: a respawn under the same session id needs its own main attach. - this.mainDaemonAttachments.resetGeneration(ptyId) + this.mainDaemonAttachments.forget(ptyId) const pty = this.ptysById.get(ptyId) if (pty) { pty.outputObservedSince = null diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 1bdbf83f348..40fd8c3356b 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { detectAgentStatusFromTitle } from '../../shared/agent-title-status' import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -63,18 +63,6 @@ describe('hasQuietReadyScreen', () => { ).toBe(false) }) - it('measures quiet from when main began receiving a stream that has printed nothing since', () => { - vi.useFakeTimers({ now: 100_000 }) - try { - const silent = record({ lastOutputAt: null, outputObservedSince: 100_000 }) - expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(false) - vi.advanceTimersByTime(QUIESCENCE_MS) - expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(true) - } finally { - vi.useRealTimers() - } - }) - it('refuses without a ready screen', () => { expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false) }) diff --git a/src/main/runtime/tui-idle-output-clock.test.ts b/src/main/runtime/tui-idle-output-clock.test.ts new file mode 100644 index 00000000000..a0c2176b898 --- /dev/null +++ b/src/main/runtime/tui-idle-output-clock.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { hasQuietReadyScreen } from './tui-idle-evidence' + +const QUIESCENCE_MS = 3000 + +afterEach(() => { + vi.useRealTimers() +}) + +describe('output clock for a stream main attached after the process last printed', () => { + it('measures quiet from when main began receiving a stream that has printed nothing since', () => { + vi.useFakeTimers({ now: 100_000 }) + const silent = { lastAgentStatus: null, lastOutputAt: null, outputObservedSince: 100_000 } + expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(false) + vi.advanceTimersByTime(QUIESCENCE_MS) + expect(hasQuietReadyScreen(silent, 'codex', () => true, QUIESCENCE_MS)).toBe(true) + }) +}) diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index c1bb414ebb0..9e38a24b287 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -221,7 +221,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime signal }: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {} ): Promise { - this.assertTerminalOutputReachesMain(handle) + await this.attachTerminalOutputToMain(handle) const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const stop = new AbortController()