mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
refactor(terminal): attach survivors at inventory adoption only; waits on unattached survivors fail at once; one ptyId output clock
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user