refactor(terminal): attach survivors at inventory adoption only; waits on unattached survivors fail at once; one ptyId output clock

This commit is contained in:
Jinwoo-H
2026-10-26 18:37:38 -04:00
parent 7de8a1be03
commit fe906ea2a2
15 changed files with 130 additions and 201 deletions
+1 -9
View File
@@ -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() ||
@@ -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)
@@ -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<string, RuntimeHeadlessTerminal>
protected abstract readHeadlessVisibleTerminalState(
ptyId: string
@@ -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
}
@@ -25,10 +25,7 @@ type RuntimeInternals = {
) => unknown
issuePtyHandle: (pty: unknown) => string
headlessTerminals: Map<string, unknown>
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 () => {
@@ -138,21 +138,13 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun
launchReadiness?: boolean
}
): Promise<RuntimeTerminalWait> {
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<RuntimeTerminalWait> {
// 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 ?? {})
@@ -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<void> | 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)
@@ -3,102 +3,70 @@ type MainDaemonAttachmentDependencies = {
attachProvider: (ptyId: string) => Promise<boolean> | 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<boolean> }
| { 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<string, StreamState>()
private readonly inventoryWaiters = new Set<string>()
// Sessions whose stream this run already holds: spawned, pane-attached or main-attached.
private readonly attached = new Set<string>()
private readonly attaching = new Map<string, Promise<boolean>>()
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<boolean> | 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<boolean> {
private async attach(ptyId: string): Promise<boolean> {
if (this.running >= MAX_CONCURRENT_ATTACHES) {
// The finishing attach hands its slot over without releasing it.
await new Promise<void>((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 {
@@ -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).
@@ -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
@@ -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)
+34 -12
View File
@@ -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<TuiIdleEvidenceSource, 'getOutputClockAt'>,
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,
+1 -8
View File
@@ -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. */
+30 -11
View File
@@ -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'])
})
@@ -221,7 +221,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime
signal
}: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {}
): Promise<RuntimeTerminalWait> {
await this.attachTerminalOutputToMain(handle)
this.assertTerminalOutputReachesMain(handle)
const initialPtyId =
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
const stop = new AbortController()