diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 3ba4cb9d1c0..0323d6995ea 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -12,11 +12,7 @@ import { import { localProvider } from './registry' import { clearProviderPtyState } from './state-cleanup' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { - providerSnapshotRequiredPtys, - rendererVisibilityKnownPtys, - visibleRendererPtys -} from '../delivery/visibility-state' +import { providerSnapshotRequiredPtys, visibleRendererPtys } from '../delivery/visibility-state' import type { PtyIpcSession } from '../session' import { publishPtyEndpointLoss } from '../../../runtime/terminal/pty-endpoint-loss-signal' @@ -31,10 +27,6 @@ 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) - // 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 256f70d5467..ac13f4754e8 100644 --- a/src/main/ipc/pty/pty-renderer-liveness-guard.test.ts +++ b/src/main/ipc/pty/pty-renderer-liveness-guard.test.ts @@ -3,7 +3,6 @@ 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())) @@ -115,7 +114,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { }) describe('the daemon-death fan-out', () => { - function installProviderCapturingFanout(runtime?: unknown): (payload: { id: string }) => void { + function installProviderCapturingFanout(): (payload: { id: string }) => void { let fanout: ((payload: { id: string }) => void) | null = null installDaemonTestProvider({ onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => { @@ -123,7 +122,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => { return () => {} }) }) - registerPtyHandlers(mainWindow as never, runtime as never) + registerPtyHandlers(mainWindow as never) if (!fanout) { throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable') } @@ -140,23 +139,6 @@ 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/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index 2b84cd5718d..a278bd38b10 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -108,6 +108,7 @@ export abstract class RuntimeConstructionServices { protected abstract readCaughtUpRuledScreen(ptyId: string): RuledScreen | null protected abstract clearWaitBlockedStamp(ptyId: string): void protected abstract readRuledScreen(ptyId: string | null | undefined): RuledScreen | null + protected abstract readOutputClockAt(ptyId: string | null | undefined): number | null protected abstract headlessTerminals: Map protected abstract readHeadlessVisibleTerminalState( ptyId: string diff --git a/src/main/runtime/construction/runtime-identity-state.ts b/src/main/runtime/construction/runtime-identity-state.ts index f59357dffb2..46bc9cd6d1e 100644 --- a/src/main/runtime/construction/runtime-identity-state.ts +++ b/src/main/runtime/construction/runtime-identity-state.ts @@ -342,6 +342,7 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent), readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), readRuledScreen: (ptyId) => this.readRuledScreen(ptyId), + getOutputClockAt: (ptyId) => this.readOutputClockAt(ptyId), getTitleObservedAtEpochMs: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastOscTitleEpochMs : null) ?? null } 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 a5aff180401..fa1067b1001 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -25,10 +25,7 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map - mainDaemonAttachments: { - hasPendingAttach: (ptyId: string) => boolean - inventoryWaiterIds: () => string[] - } + mainDaemonAttachments: { isUnreachable: (ptyId: string) => boolean } refreshPtyWorktreeRecordsWithControllerInventory: ( worktrees: [], targetWorktreeId: string | null @@ -410,7 +407,6 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { sendSubscribe(harness, 1, handle) await waitForSubscribed(harness, 1) await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID])) - expect(internals(runtime).mainDaemonAttachments.hasPendingAttach(PTY_ID)).toBe(true) expect(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true) expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false) @@ -418,13 +414,9 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) expect(model.attachCalls).toEqual([PTY_ID]) - expect(internals(runtime).mainDaemonAttachments.inventoryWaiterIds()).toEqual([PTY_ID]) refuseFirstAttach() await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID])) - await vi.waitFor(() => - expect(internals(runtime).mainDaemonAttachments.inventoryWaiterIds()).not.toContain(PTY_ID) - ) expect(model.emitData(PTY_ID, 'after inventory\r\n')).toBe(true) await vi.waitFor(() => expect(outputText(harness)).toContain('after inventory')) }) @@ -440,44 +432,19 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { expect(read.tail.join('\n')).toContain('worker still printing') }) - it('re-attaches a session main already holds after the daemon endpoint drops', async () => { - const { runtime, model } = setupNeverAttachedDaemonSession({ snapshotCapable: false }) - await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) - await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID])) - expect(model.emitData(PTY_ID, 'before loss\r\n')).toBe(true) - model.sessions.get(PTY_ID)!.attached = false - - runtime.reattachPtyAfterEndpointLoss(PTY_ID) - - await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID, PTY_ID])) - expect(model.emitData(PTY_ID, 'after loss\r\n')).toBe(true) - }) - - 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 () => { + 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.hasPendingAttach(PTY_ID)).toBe(false) + expect(internals(runtime).mainDaemonAttachments.isUnreachable(PTY_ID)).toBe(true) ) await expect( - 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) + 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 () => { 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 bb6a94d0097..d3d65985db9 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts @@ -138,21 +138,13 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun launchReadiness?: boolean } ): Promise { - 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 + if ((options?.condition ?? 'exit') !== 'exit') { + try { + this.assertTerminalOutputReachesMain(handle) + } catch (error) { + return Promise.reject(error) + } } - ): 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 49753cd6a84..685d0e93d1a 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -68,28 +68,18 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId) } - /** 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 { + /** 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 - const attaching = ptyId ? this.mainDaemonAttachments.ensureAttached(ptyId) : null - return ( - attaching?.then((attached) => { - if (!attached) { - throw new Error('terminal_not_connected_since_restart') - } - }) ?? null - ) + if (ptyId && this.mainDaemonAttachments.isUnreachable(ptyId)) { + throw new Error('terminal_not_connected_since_restart') + } } protected reconcileMainDaemonAttach(ptyId: string): void { this.mainDaemonAttachments.reconcile(ptyId) } - /** The provider endpoint dropped this session's stream; restore main's attachment once it reconnects. */ - reattachPtyAfterEndpointLoss(ptyId: string): void { - this.mainDaemonAttachments.reattachAfterEndpointLoss(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 116b3d7d4aa..aef131724d1 100644 --- a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts +++ b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts @@ -3,102 +3,70 @@ 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 states = new Map() - private readonly inventoryWaiters = new Set() + // Sessions whose stream this run already holds: spawned, pane-attached or main-attached. + private readonly attached = new Set() + private readonly attaching = new Map>() 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.states.set(ptyId, { kind: 'attached' }) + this.attached.add(ptyId) } - /** Respawn or exit ends the generation whose stream this state described. */ + /** Exit or respawn ends the generation whose stream this described. */ forget(ptyId: string): void { - this.states.delete(ptyId) - this.inventoryWaiters.delete(ptyId) + this.attached.delete(ptyId) + this.attaching.delete(ptyId) } isKnownUnattachedLocal(ptyId: string): boolean { - return !this.states.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId) + return !this.attached.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId) } - hasPendingAttach(ptyId: string): boolean { - return this.states.get(ptyId)?.kind === 'attaching' - } - - inventoryWaiterIds(): string[] { - return [...this.inventoryWaiters] + /** 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 state = this.states.get(ptyId) - if (state?.kind !== 'attaching') { + const attempt = this.attaching.get(ptyId) + if (!attempt) { this.ensure(ptyId) return } - if (this.inventoryWaiters.has(ptyId)) { - return - } - this.inventoryWaiters.add(ptyId) - void state.attempt.then(() => { - this.inventoryWaiters.delete(ptyId) - this.ensure(ptyId) + void attempt.then((attached) => { + if (!attached) { + this.ensure(ptyId) + } }) } - /** An endpoint drop detached a stream no pane will restore; re-attach it once the daemon answers. */ - reattachAfterEndpointLoss(ptyId: string): void { - this.states.set(ptyId, { kind: 'lost' }) - this.ensure(ptyId) - } - ensure(ptyId: string): void { - void this.ensureAttached(ptyId) - } - - /** 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 + if (this.attaching.has(ptyId) || !this.isKnownUnattachedLocal(ptyId)) { + return } - 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) - } + const attempt = this.attach(ptyId) + this.attaching.set(ptyId, attempt) + void attempt.then((attached) => { + if (this.attaching.get(ptyId) !== attempt) { + return + } + this.attaching.delete(ptyId) + if (attached) { + this.attached.add(ptyId) } - return attached }) - return attaching.attempt } - private async attach(ptyId: string, attaching: StreamState): Promise { + 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)) @@ -106,9 +74,9 @@ export class MainDaemonAttachments { this.running += 1 } try { - // 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' + // A pane may have attached, or the session exited, while this waited. + if (this.attached.has(ptyId) || !this.deps.isUnattachedLocalCandidate(ptyId)) { + return false } return (await this.deps.attachProvider(ptyId)) ?? false } catch { diff --git a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts index d65e42647d7..7e37e8b0259 100644 --- a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts +++ b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts @@ -160,10 +160,7 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit const pty = this.ptysById.get(ptyId) return pty !== undefined && pty.connectionId === null && pty.connected }, - attachProvider: (ptyId) => { - const attach = this.ptyController?.attach - return attach ? (async () => attach(ptyId))() : null - } + attachProvider: (ptyId) => this.ptyController?.attach?.(ptyId) ?? null }) // Remote xterms own query replies; ref-count streams and retain suppression through mobile soft-leave grace (terminal-query-authority.md). diff --git a/src/main/runtime/terminal/output/terminal-buffer-operations.ts b/src/main/runtime/terminal/output/terminal-buffer-operations.ts index bc6ed49cc16..318de8da6c7 100644 --- a/src/main/runtime/terminal/output/terminal-buffer-operations.ts +++ b/src/main/runtime/terminal/output/terminal-buffer-operations.ts @@ -229,10 +229,15 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun startReattachedOutputClock(ptyId: string): void { const pty = this.ptysById.get(ptyId) if (pty && pty.lastOutputAt === null) { - pty.outputObservedSince ??= Date.now() + pty.outputObservedSince = Date.now() } } + protected readOutputClockAt(ptyId: string | null | undefined): number | null { + const pty = ptyId ? this.ptysById.get(ptyId) : undefined + return pty ? (pty.lastOutputAt ?? pty.outputObservedSince ?? null) : null + } + // 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/wait/terminal-idle-polls.ts b/src/main/runtime/terminal/wait/terminal-idle-polls.ts index 2498defe2bb..8ee828d733a 100644 --- a/src/main/runtime/terminal/wait/terminal-idle-polls.ts +++ b/src/main/runtime/terminal/wait/terminal-idle-polls.ts @@ -13,12 +13,12 @@ import { import { buildTerminalWaitText } from '../../terminal-wait-tail-state' import { evaluateTuiIdle, - outputClockAt, type QuietForegroundLane, type TuiIdleVerdict } from '../../tui-idle-evidence' import { leafTuiIdleEvidence, + outputClockOf, ptyTuiIdleEvidence, type TuiIdleEvidenceSource } from '../../tui-idle-evidence-source' @@ -146,7 +146,7 @@ export class RuntimeTerminalIdlePolls { ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty), blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason), isQuiet: (lane) => - isQuietForQuiescence(outputClockAt(pty), this.deps.quiescenceMs, lane, () => + isQuietForQuiescence(outputClockOf(this.deps, pty), this.deps.quiescenceMs, lane, () => this.deps.hasCommandPainted(pty.ptyId) ) } @@ -166,7 +166,7 @@ export class RuntimeTerminalIdlePolls { blocked: (reason) => buildTerminalWaitBlockedResult(handle, 'tui-idle', live(), reason), isQuiet: (lane) => isQuietForQuiescence( - live().lastOutputAt, + outputClockOf(this.deps, live()), this.deps.quiescenceMs, lane, () => !leaf.ptyId || this.deps.hasCommandPainted(leaf.ptyId) diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts index 9eea2e5a0f4..a03accb6327 100644 --- a/src/main/runtime/tui-idle-evidence-source.ts +++ b/src/main/runtime/tui-idle-evidence-source.ts @@ -14,11 +14,10 @@ import { type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' import type { TuiIdleHookTurn } from './tui-idle-hook-lane' -import { - outputClockAt, - type FirstPartyAgentStatus, - type TuiIdleEvaluationInput, - type TuiIdleEvidenceRecord +import type { + FirstPartyAgentStatus, + TuiIdleEvaluationInput, + TuiIdleEvidenceRecord } from './tui-idle-evidence' /** The runtime state every tui-idle site reads its evidence from. */ @@ -36,6 +35,27 @@ export type TuiIdleEvidenceSource = { readRuledScreen?(ptyId: string | null | undefined): RuledScreen | null /** When the PTY last observed a title of its own. Absent, no title has an age. */ getTitleObservedAtEpochMs?(ptyId: string | null | undefined): number | null + /** The PTY's quiet clock: its last output, else when main began receiving its silent stream. */ + getOutputClockAt?(ptyId: string | null | undefined): number | null +} + +/** One clock per terminal, read by ptyId, so a pane leaf and its PTY settle alike. */ +export function outputClockOf( + source: Pick, + record: { ptyId: string | null; lastOutputAt: number | null } +): number | null { + return source.getOutputClockAt?.(record.ptyId) ?? record.lastOutputAt +} + +function evidenceRecord( + source: TuiIdleEvidenceSource, + record: TuiIdleEvidenceRecord & { ptyId: string | null } +): TuiIdleEvidenceRecord { + return { + lastAgentStatus: record.lastAgentStatus, + lastOscTitle: record.lastOscTitle, + lastOutputAt: outputClockOf(source, record) + } } // Why per agent: every other agent keeps the live screen its rules were recorded against. @@ -62,7 +82,7 @@ function readAgentRuleVerdict( readScreenLines, readText: () => waitText().toLowerCase(), readTitleStatus: () => record.lastAgentStatus, - hasOutputClock: outputClockAt(record) !== null + hasOutputClock: record.lastOutputAt !== null }) } @@ -106,14 +126,15 @@ export function leafTuiIdleEvidence( const waitText = lazyWaitText(readWaitText) const agent = source.getPaneAgent(leaf.ptyId) const readScreen = screenReader(source, agent, leaf.ptyId) + const record = evidenceRecord(source, leaf) return { - record: leaf, + record, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), + isKnownReadyPromptBody(waitText(), agent, readScreen, record.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, record, readScreen, waitText), readScreenInputVeto: screenInputVetoReader(source, agent, leaf.ptyId), titleObservedAtEpochMs: source.getTitleObservedAtEpochMs?.(leaf.ptyId) ?? null, agent, @@ -131,17 +152,18 @@ export function ptyTuiIdleEvidence( const waitText = lazyWaitText(readWaitText) const agent = source.getPaneAgent(pty.ptyId) const readScreen = screenReader(source, agent, pty.ptyId) + const record = evidenceRecord(source, pty) return { - record: pty, + record, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => (agent !== 'qoder' && agent !== 'qoder-cn' && agent !== 'hermes' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || - isKnownReadyPromptBody(waitText(), agent, readScreen, outputClockAt(pty) !== null), + isKnownReadyPromptBody(waitText(), agent, readScreen, record.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, record, readScreen, waitText), readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId), titleObservedAtEpochMs: pty.lastOscTitleEpochMs, agent, diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index d9423b4108f..98b57276e3f 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -27,15 +27,9 @@ import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' export type TuiIdleEvidenceRecord = { lastAgentStatus: AgentStatus | null lastOutputAt: number | null - outputObservedSince?: number | null lastOscTitle?: string | null } -/** The output clock: last output, or when main began receiving a stream that has stayed silent since. */ -export function outputClockAt(record: TuiIdleEvidenceRecord): number | null { - return record.lastOutputAt ?? record.outputObservedSince ?? null -} - export type FirstPartyAgentStatus = { state: AgentStatusState updatedAt: number @@ -109,8 +103,7 @@ export function hasSustainedTitleIdle( /** Why a missing clock is not quiet: an adopted or restored pane cannot measure it. */ function hasQuietOutput(record: TuiIdleEvidenceRecord, quiescenceMs: number): boolean { - const clock = outputClockAt(record) - return clock !== null && Date.now() - clock >= quiescenceMs + return record.lastOutputAt !== null && Date.now() - record.lastOutputAt >= quiescenceMs } /** Closed lanes require stronger rest evidence; after-paint lanes require agent output beyond shell echo; open unknown panes may settle without an output clock. */ diff --git a/src/main/runtime/tui-idle-output-clock.test.ts b/src/main/runtime/tui-idle-output-clock.test.ts index a0c2176b898..ccfe254faaf 100644 --- a/src/main/runtime/tui-idle-output-clock.test.ts +++ b/src/main/runtime/tui-idle-output-clock.test.ts @@ -1,18 +1,37 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { hasQuietReadyScreen } from './tui-idle-evidence' +import './rpc/unused-default-rpc-methods.test-fixture' +import { afterEach, expect, it, vi } from 'vitest' +import { runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture' +import { evaluateTuiIdle } from './tui-idle-evidence' +import { leafTuiIdleEvidence, ptyTuiIdleEvidence } from './tui-idle-evidence-source' -const QUIESCENCE_MS = 3000 +const PTY_ID = 'repo-1::/tmp/wt@@1a2b3c4d' +const CODEX_COMPOSER = '› Ask Codex to do anything' 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) - }) +it('a silent reattached Codex reads quiet on its PTY and its pane leaf alike', () => { + vi.useFakeTimers({ now: 100_000 }) + const { runtime, leaf } = runtimeWithLeaf(PTY_ID) + runtime.seedTerminalRestoreTail(PTY_ID, { text: CODEX_COMPOSER }) + runtime.startReattachedOutputClock(PTY_ID) + const pty = runtime['ptysById'].get(PTY_ID)! + const source = { + ...runtime['tuiIdleEvidenceSource'], + getPaneAgent: () => 'codex' as const, + readScreenLines: () => [CODEX_COMPOSER], + readRuledScreen: () => ({ lines: [CODEX_COMPOSER], alternateScreen: false }) + } + const verdicts = () => + [ + evaluateTuiIdle(ptyTuiIdleEvidence(source, pty, () => pty.preview)), + evaluateTuiIdle(leafTuiIdleEvidence(source, leaf, () => leaf.preview)) + ].map((verdict) => verdict.kind) + + expect(verdicts()).toEqual(['pending', 'pending']) + vi.advanceTimersByTime(source.quiescenceMs) + // 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']) }) diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index 9e38a24b287..c1bb414ebb0 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 { - await this.attachTerminalOutputToMain(handle) + this.assertTerminalOutputReachesMain(handle) const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const stop = new AbortController()