mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user