mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
fix(terminal): restore only pane-less streams after endpoint loss; one attach state
- Endpoint loss re-attaches in main only sessions no renderer view reports; a pane restores its own. - The attach hide skips views mid-attach and leaves a pane's own hidden claim alone on failure. - One per-session stream state (attaching, attached, lost) replaces three sets and is forgotten on exit. - Waits retry the attach before failing, including after a failed re-attach. - The output clock starts wherever a reattach result is seeded, so pane reattaches get it too.
This commit is contained in:
@@ -971,6 +971,7 @@
|
||||
1 test-no-wall-clock:src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts
|
||||
2 test-no-wall-clock:src/main/runtime/terminal/surface/pty-pane-copies-untouched-main.test.ts
|
||||
2 test-no-wall-clock:src/main/runtime/tui-idle-claude-task-wakeup.test.ts
|
||||
12 test-no-wall-clock:src/main/runtime/tui-idle-evidence.test.ts
|
||||
18 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane-runtime.test.ts
|
||||
10 test-no-wall-clock:src/main/runtime/tui-idle-hook-lane.test.ts
|
||||
4 test-no-wall-clock:src/main/runtime/tui-idle-name-only-real-pty.integration.test.ts
|
||||
|
||||
@@ -367,9 +367,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
)
|
||||
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
|
||||
try {
|
||||
await expect(legacy.attach('legacy-session')).resolves.not.toHaveProperty(
|
||||
'providerSequence'
|
||||
)
|
||||
const attached = await legacy.attach('legacy-session')
|
||||
expect(attached).toMatchObject({
|
||||
id: 'legacy-session',
|
||||
isReattach: true,
|
||||
incarnationId: 'legacy-attach-incarnation'
|
||||
})
|
||||
expect(attached.providerSequence).toBeUndefined()
|
||||
} finally {
|
||||
legacy.dispose()
|
||||
request.mockRestore()
|
||||
|
||||
@@ -10,6 +10,11 @@ export const visibleRendererPtys = new Set<string>()
|
||||
export const rendererVisibilityKnownPtys = new Set<string>()
|
||||
// Visible attaches suppress main-start hiding; remember it so failed attaches can hide the PTY again.
|
||||
export const attachingRendererViewPtys = new Map<string, { views: number; skippedHide: boolean }>()
|
||||
/** A visible view, or one mid-attach, owns delivery; hiding it would drop output it is about to paint. */
|
||||
export function isRendererViewShowingPty(id: string): boolean {
|
||||
return visibleRendererPtys.has(id) || attachingRendererViewPtys.has(id)
|
||||
}
|
||||
|
||||
// Keep null-initialized wrapper bridges to avoid Rolldown no-op folding (STA-5661; see debug.ts).
|
||||
let invalidatePendingPtyDrainPriorityImpl: ((id?: string, schedule?: boolean) => void) | null = null
|
||||
let invalidatePendingPtyDrainPolicyImpl: ((id?: string, schedule?: boolean) => void) | null = null
|
||||
|
||||
@@ -39,6 +39,9 @@ export function seedTerminalRestoreRecordsFromSpawnResult(
|
||||
...(lastTitle !== undefined ? { lastTitle } : {})
|
||||
})
|
||||
}
|
||||
if (result.isReattach === true) {
|
||||
runtime?.startReattachedOutputClock?.(result.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSpawnReplyWasLive(result: PtySpawnResult): void {
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
import { localProvider } from './registry'
|
||||
import { clearProviderPtyState } from './state-cleanup'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { providerSnapshotRequiredPtys, visibleRendererPtys } from '../delivery/visibility-state'
|
||||
import {
|
||||
providerSnapshotRequiredPtys,
|
||||
rendererVisibilityKnownPtys,
|
||||
visibleRendererPtys
|
||||
} from '../delivery/visibility-state'
|
||||
import type { PtyIpcSession } from '../session'
|
||||
import { publishPtyEndpointLoss } from '../../../runtime/terminal/pty-endpoint-loss-signal'
|
||||
|
||||
@@ -27,8 +31,10 @@ 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)
|
||||
// Panes remount and re-attach themselves; main must restore its own stream for unmounted ones.
|
||||
session.runtime?.reattachPtyAfterEndpointLoss(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,6 +3,7 @@ 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()))
|
||||
@@ -114,7 +115,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => {
|
||||
})
|
||||
|
||||
describe('the daemon-death fan-out', () => {
|
||||
function installProviderCapturingFanout(): (payload: { id: string }) => void {
|
||||
function installProviderCapturingFanout(runtime?: unknown): (payload: { id: string }) => void {
|
||||
let fanout: ((payload: { id: string }) => void) | null = null
|
||||
installDaemonTestProvider({
|
||||
onWriteUnavailable: vi.fn((callback: (payload: { id: string }) => void) => {
|
||||
@@ -122,7 +123,7 @@ describe('pty:writeUnavailable renderer-liveness guard', () => {
|
||||
return () => {}
|
||||
})
|
||||
})
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
if (!fanout) {
|
||||
throw new Error('registerPtyHandlers did not subscribe to onWriteUnavailable')
|
||||
}
|
||||
@@ -139,6 +140,23 @@ 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)
|
||||
|
||||
@@ -3,7 +3,8 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import type { IPtyProvider } from '../../../providers/pty-provider-contract'
|
||||
import { LocalPtyProvider } from '../../../providers/local-pty-provider'
|
||||
import { ptyOwnership } from '../provider/ownership-state'
|
||||
import { ptySizes, visibleRendererPtys } from '../delivery/visibility-state'
|
||||
import { isRendererViewShowingPty, ptySizes } from '../delivery/visibility-state'
|
||||
import { isHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate'
|
||||
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
|
||||
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
|
||||
import { hideRuntimeOwnedRendererDelivery } from './spawn-hidden-delivery'
|
||||
@@ -128,18 +129,22 @@ export async function attachPtyFromRuntimeController(
|
||||
if (provider !== localProvider || provider instanceof LocalPtyProvider) {
|
||||
return false
|
||||
}
|
||||
const hidden = !visibleRendererPtys.has(ptyId) && hideRuntimeOwnedRendererDelivery(deps, ptyId)
|
||||
// Only a mark this attach set may be undone; a pane's own hidden claim stays.
|
||||
const hidden =
|
||||
!isRendererViewShowingPty(ptyId) &&
|
||||
!isHiddenRendererPty(ptyId) &&
|
||||
hideRuntimeOwnedRendererDelivery(deps, ptyId)
|
||||
try {
|
||||
const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0
|
||||
const attachResult = await provider.attach(ptyId)
|
||||
if (attachResult?.providerSequence) {
|
||||
deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
ptyId,
|
||||
attachResult.providerSequence,
|
||||
sequenceBeforeProviderAttach
|
||||
)
|
||||
}
|
||||
if (attachResult) {
|
||||
if (attachResult.providerSequence) {
|
||||
deps.runtime?.synchronizePtyOutputSequenceFromProvider?.(
|
||||
ptyId,
|
||||
attachResult.providerSequence,
|
||||
sequenceBeforeProviderAttach
|
||||
)
|
||||
}
|
||||
// A restarted runtime has never seen this screen; an idle agent may print nothing after attach.
|
||||
seedHeadlessTerminalFromSpawnResult(deps.runtime, attachResult, null)
|
||||
seedTerminalRestoreRecordsFromSpawnResult(deps.runtime, attachResult)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate'
|
||||
import { attachingRendererViewPtys, visibleRendererPtys } from '../delivery/visibility-state'
|
||||
import { attachingRendererViewPtys, isRendererViewShowingPty } from '../delivery/visibility-state'
|
||||
import { closeStartupQueryAuthorityForPty } from '../provider/registry'
|
||||
import type { RuntimePtySpawnState } from './spawn-state'
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
@@ -43,7 +43,7 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi
|
||||
if (attaching) {
|
||||
attaching.skippedHide = true
|
||||
}
|
||||
if (visibleRendererPtys.has(id) || attaching) {
|
||||
if (isRendererViewShowingPty(id)) {
|
||||
releaseRuntimeSpawnPreSpawnHiddenMark(ctx)
|
||||
ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn')
|
||||
return
|
||||
|
||||
@@ -13,7 +13,6 @@ export class RuntimeTerminalViewSubscribers {
|
||||
|
||||
constructor(private readonly deps: RuntimeTerminalViewSubscriberDependencies) {}
|
||||
|
||||
// Why: exit drops subscribers only; attach/publish state survives until the respawn advances the generation.
|
||||
clearSubscribers(ptyId: string): void {
|
||||
this.remoteCounts.delete(ptyId)
|
||||
this.silenceableRemoteViews.delete(ptyId)
|
||||
|
||||
@@ -453,7 +453,18 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
|
||||
expect(model.emitData(PTY_ID, 'after loss\r\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('fails a wait at once when main could not attach the surviving session', async () => {
|
||||
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 () => {
|
||||
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
|
||||
const refuse = model.deferNextAttach(false)
|
||||
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
|
||||
@@ -463,8 +474,10 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => {
|
||||
)
|
||||
|
||||
await expect(
|
||||
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
|
||||
).rejects.toThrow('terminal_not_connected_since_restart')
|
||||
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)
|
||||
})
|
||||
|
||||
it('does not subscriber-attach or provider-read a session this app already spawned', async () => {
|
||||
|
||||
@@ -138,13 +138,21 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun
|
||||
launchReadiness?: boolean
|
||||
}
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
if (options?.condition !== 'exit') {
|
||||
try {
|
||||
this.assertTerminalOutputReachesMain(handle)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
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
|
||||
}
|
||||
): 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,16 +68,17 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
|
||||
return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId)
|
||||
}
|
||||
|
||||
/** A failed attach leaves a surviving session silent to main; waiting on it could only time out. */
|
||||
protected assertTerminalOutputReachesMain(handle: string): void {
|
||||
/** 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 {
|
||||
const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId
|
||||
if (
|
||||
ptyId &&
|
||||
this.isKnownUnattachedLocalDaemonPty(ptyId) &&
|
||||
!this.mainDaemonAttachments.hasPendingAttach(ptyId)
|
||||
) {
|
||||
throw new Error('terminal_not_connected_since_restart')
|
||||
}
|
||||
const attaching = ptyId ? this.mainDaemonAttachments.ensureAttached(ptyId) : null
|
||||
return (
|
||||
attaching?.then((attached) => {
|
||||
if (!attached) {
|
||||
throw new Error('terminal_not_connected_since_restart')
|
||||
}
|
||||
}) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
protected reconcileMainDaemonAttach(ptyId: string): void {
|
||||
|
||||
@@ -3,38 +3,41 @@ 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 attaches = new Map<string, Promise<boolean>>()
|
||||
private readonly states = new Map<string, StreamState>()
|
||||
private readonly inventoryWaiters = new Set<string>()
|
||||
private readonly spawnPublishedPtys = new Set<string>()
|
||||
// Endpoint loss detached these; they need a re-attach although main already holds their model.
|
||||
private readonly lostPtys = new Set<string>()
|
||||
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.spawnPublishedPtys.add(ptyId)
|
||||
this.states.set(ptyId, { kind: 'attached' })
|
||||
}
|
||||
|
||||
resetGeneration(ptyId: string): void {
|
||||
this.attaches.delete(ptyId)
|
||||
/** Respawn or exit ends the generation whose stream this state described. */
|
||||
forget(ptyId: string): void {
|
||||
this.states.delete(ptyId)
|
||||
this.inventoryWaiters.delete(ptyId)
|
||||
this.spawnPublishedPtys.delete(ptyId)
|
||||
this.lostPtys.delete(ptyId)
|
||||
}
|
||||
|
||||
isKnownUnattachedLocal(ptyId: string): boolean {
|
||||
return !this.spawnPublishedPtys.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
|
||||
return !this.states.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
|
||||
}
|
||||
|
||||
hasPendingAttach(ptyId: string): boolean {
|
||||
return this.attaches.has(ptyId)
|
||||
return this.states.get(ptyId)?.kind === 'attaching'
|
||||
}
|
||||
|
||||
inventoryWaiterIds(): string[] {
|
||||
@@ -43,8 +46,8 @@ export class MainDaemonAttachments {
|
||||
|
||||
/** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */
|
||||
reconcile(ptyId: string): void {
|
||||
const pending = this.attaches.get(ptyId)
|
||||
if (!pending) {
|
||||
const state = this.states.get(ptyId)
|
||||
if (state?.kind !== 'attaching') {
|
||||
this.ensure(ptyId)
|
||||
return
|
||||
}
|
||||
@@ -52,47 +55,50 @@ export class MainDaemonAttachments {
|
||||
return
|
||||
}
|
||||
this.inventoryWaiters.add(ptyId)
|
||||
void pending.then((attached) => {
|
||||
void state.attempt.then(() => {
|
||||
this.inventoryWaiters.delete(ptyId)
|
||||
if (attached) {
|
||||
return
|
||||
}
|
||||
if (this.attaches.get(ptyId) === pending) {
|
||||
this.attaches.delete(ptyId)
|
||||
}
|
||||
this.ensure(ptyId)
|
||||
})
|
||||
}
|
||||
|
||||
/** A dropped daemon endpoint detaches every stream; panes remount, sessions without one re-attach here. */
|
||||
/** An endpoint drop detached a stream no pane will restore; re-attach it once the daemon answers. */
|
||||
reattachAfterEndpointLoss(ptyId: string): void {
|
||||
this.attaches.delete(ptyId)
|
||||
this.lostPtys.add(ptyId)
|
||||
this.states.set(ptyId, { kind: 'lost' })
|
||||
this.ensure(ptyId)
|
||||
}
|
||||
|
||||
ensure(ptyId: string): void {
|
||||
if (
|
||||
this.attaches.has(ptyId) ||
|
||||
(!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId))
|
||||
) {
|
||||
return
|
||||
}
|
||||
const attempt = this.attach(ptyId)
|
||||
this.attaches.set(ptyId, attempt)
|
||||
void attempt.then((attached) => {
|
||||
if (this.attaches.get(ptyId) !== attempt) {
|
||||
return
|
||||
}
|
||||
if (attached) {
|
||||
this.lostPtys.delete(ptyId)
|
||||
} else {
|
||||
this.attaches.delete(ptyId)
|
||||
}
|
||||
})
|
||||
void this.ensureAttached(ptyId)
|
||||
}
|
||||
|
||||
private async attach(ptyId: string): Promise<boolean> {
|
||||
/** 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return attached
|
||||
})
|
||||
return attaching.attempt
|
||||
}
|
||||
|
||||
private async attach(ptyId: string, attaching: StreamState): 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))
|
||||
@@ -100,9 +106,9 @@ export class MainDaemonAttachments {
|
||||
this.running += 1
|
||||
}
|
||||
try {
|
||||
// A pane may have attached, or the session respawned, while this waited.
|
||||
if (!this.lostPtys.has(ptyId) && !this.isKnownUnattachedLocal(ptyId)) {
|
||||
return false
|
||||
// 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'
|
||||
}
|
||||
return (await this.deps.attachProvider(ptyId)) ?? false
|
||||
} catch {
|
||||
|
||||
@@ -164,6 +164,7 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi
|
||||
advertisedUrlWatcher.unbindPty(ptyId)
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
this.terminalViewSubscribers.clearSubscribers(ptyId)
|
||||
this.mainDaemonAttachments.forget(ptyId)
|
||||
this.mobileDisplayModes.delete(ptyId)
|
||||
this.resizeListeners.delete(ptyId)
|
||||
this.lastRendererSizes.delete(ptyId)
|
||||
|
||||
@@ -162,18 +162,7 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
|
||||
},
|
||||
attachProvider: (ptyId) => {
|
||||
const attach = this.ptyController?.attach
|
||||
if (!attach) {
|
||||
return null
|
||||
}
|
||||
return (async () => {
|
||||
const attached = await attach(ptyId)
|
||||
const pty = attached ? this.ptysById.get(ptyId) : undefined
|
||||
// An idle agent may print nothing after attach; from now on its silence is observed.
|
||||
if (pty && pty.lastOutputAt === null) {
|
||||
pty.outputObservedSince = Date.now()
|
||||
}
|
||||
return attached
|
||||
})()
|
||||
return attach ? (async () => attach(ptyId))() : null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -225,6 +225,14 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun
|
||||
})
|
||||
}
|
||||
|
||||
/** A reattached live process may print nothing; from now on its silence is observed quiet. */
|
||||
startReattachedOutputClock(ptyId: string): void {
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty && pty.lastOutputAt === null) {
|
||||
pty.outputObservedSince ??= Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -96,7 +96,7 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc
|
||||
this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now())
|
||||
this.legacyWorkerRecovery.deleteRecoveredPty(ptyId)
|
||||
// Why: a respawn under the same session id needs its own main attach.
|
||||
this.mainDaemonAttachments.resetGeneration(ptyId)
|
||||
this.mainDaemonAttachments.forget(ptyId)
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (pty) {
|
||||
pty.outputObservedSince = null
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { detectAgentStatusFromTitle } from '../../shared/agent-title-status'
|
||||
import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-title'
|
||||
import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config'
|
||||
@@ -63,18 +63,6 @@ describe('hasQuietReadyScreen', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('measures quiet from when main began receiving a stream that has printed nothing since', () => {
|
||||
vi.useFakeTimers({ now: 100_000 })
|
||||
try {
|
||||
const silent = record({ 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)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses without a ready screen', () => {
|
||||
expect(hasQuietReadyScreen(record(), 'muse', () => false, QUIESCENCE_MS)).toBe(false)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { hasQuietReadyScreen } from './tui-idle-evidence'
|
||||
|
||||
const QUIESCENCE_MS = 3000
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -221,7 +221,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime
|
||||
signal
|
||||
}: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {}
|
||||
): Promise<RuntimeTerminalWait> {
|
||||
this.assertTerminalOutputReachesMain(handle)
|
||||
await this.attachTerminalOutputToMain(handle)
|
||||
const initialPtyId =
|
||||
this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId
|
||||
const stop = new AbortController()
|
||||
|
||||
Reference in New Issue
Block a user