fix(terminal): narrow survivor attach: waits behave as on main, one quiet clock per generation, keep main's hidden mark on failed attach

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