fix(terminal): keep surviving terminals streaming to main after a restart

Main now attaches every live local daemon session it adopts at startup,
not only ones a window pane or remote view is showing, and re-attaches
after a daemon endpoint drop. The attach snapshot seeds main's screen,
tail and title, and an attached-but-silent agent gets an output clock so
readiness can see it has gone quiet. Waits on a survivor main could not
attach fail at once instead of timing out.
This commit is contained in:
Jinwoo-H
2026-10-26 18:37:38 -04:00
parent 8291297b0b
commit 72b3b79fd3
30 changed files with 505 additions and 142 deletions
-1
View File
@@ -971,7 +971,6 @@
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
@@ -623,7 +623,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
protocolVersion: GET_SIZE_PROTOCOL_VERSION - 1
})
try {
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
await expect(rig.adapter.attach(sessionId)).resolves.toMatchObject({ id: sessionId })
expect(rig.request).not.toHaveBeenCalledWith('getSize', expect.anything())
expect(rig.request).toHaveBeenCalledWith('listSessions', undefined)
expect(rig.request).toHaveBeenCalledWith(
@@ -647,7 +647,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
getSizeError: new Error('Unknown request type: getSize')
})
try {
await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined()
await expect(rig.adapter.attach(sessionId)).resolves.toMatchObject({ id: sessionId })
expect(rig.request).toHaveBeenCalledWith('listSessions', undefined)
expect(rig.request).toHaveBeenCalledWith(
'createOrAttach',
@@ -142,7 +142,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
lastSubprocess._simulateData('preserved output')
await new Promise((r) => setTimeout(r, 50))
await expect(adapter.attach(sessionId)).resolves.toEqual({
await expect(adapter.attach(sessionId)).resolves.toMatchObject({
providerSequence: {
value: 'preserved output'.length,
generation: 'continued'
@@ -367,7 +367,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
)
const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 })
try {
await expect(legacy.attach('legacy-session')).resolves.toBeUndefined()
await expect(legacy.attach('legacy-session')).resolves.not.toHaveProperty(
'providerSequence'
)
} finally {
legacy.dispose()
request.mockRestore()
+5 -2
View File
@@ -78,14 +78,17 @@ it('preserves client-only unverifiable inspection from the owning legacy daemon'
it('forwards the owning legacy daemon sequence from attach', async () => {
const legacy = createAdapter('legacy', ['legacy-session'])
const providerSequence = { value: 204, generation: 'continued' as const }
vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence })
vi.mocked(legacy.attach).mockResolvedValueOnce({ id: 'legacy-session', providerSequence })
const router = new DaemonPtyRouter({
current: createAdapter('current'),
legacy: [legacy]
})
await router.discoverLegacySessions()
await expect(router.attach('legacy-session')).resolves.toEqual({ providerSequence })
await expect(router.attach('legacy-session')).resolves.toEqual({
id: 'legacy-session',
providerSequence
})
})
describe('DaemonPtyRouter', () => {
@@ -22,7 +22,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
deadlineMs?: number
): Promise<void>
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence' | 'lastTitle'> | void> {
async attach(id: string): Promise<PtySpawnResult> {
await this.ensureConnected()
if (!this.canDelegateBackgroundToDaemon) {
this.setPtyBackgrounded(id, false)
@@ -43,13 +43,8 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
if (result.exitedBeforeSpawnReply) {
throw new SessionNotFoundError(id)
}
// A restarted runtime has never seen the surviving session's title.
return result.providerSequence || result.lastTitle
? {
...(result.providerSequence ? { providerSequence: result.providerSequence } : {}),
...(result.lastTitle ? { lastTitle: result.lastTitle } : {})
}
: undefined
// A restarted runtime has never seen the surviving session's screen or title.
return result
}
hasPty(id: string): boolean {
@@ -323,7 +323,7 @@ describe('DegradedDaemonPtyProvider', () => {
it('forwards the owning daemon sequence from attach', async () => {
const legacy = createDaemonAdapter('legacy', ['daemon-session'])
const providerSequence = { value: 204, generation: 'continued' as const }
vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence })
vi.mocked(legacy.attach).mockResolvedValueOnce({ id: 'daemon-session', providerSequence })
const provider = new DegradedDaemonPtyProvider({
current: createDaemonAdapter('current'),
legacy: [legacy],
@@ -331,7 +331,10 @@ describe('DegradedDaemonPtyProvider', () => {
})
await provider.discoverDaemonSessions()
await expect(provider.attach('daemon-session')).resolves.toEqual({ providerSequence })
await expect(provider.attach('daemon-session')).resolves.toEqual({
id: 'daemon-session',
providerSequence
})
})
it('only delegates owner-listing authority to the provider that owns the id', async () => {
@@ -27,6 +27,8 @@ 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)
if (
!session.mainWindow ||
session.mainWindow.isDestroyed() ||
@@ -180,17 +180,36 @@ describe('registerPtyHandlers', () => {
0
)
})
it('seeds the title of a session adopted by subscriber-driven attach', async () => {
it('seeds the screen and title of a session adopted by main attach', async () => {
const daemonPtyId = 'repo-1::/tmp/wt@@title-handoff'
const localProvider = createAgentClaimProvider({})
localProvider.attach.mockResolvedValueOnce({ lastTitle: '⠋ Claude' })
localProvider.attach.mockResolvedValueOnce({
id: daemonPtyId,
snapshot: 'idle composer\r\n',
snapshotCols: 120,
snapshotRows: 40,
lastTitle: '⠋ Claude'
})
setLocalPtyProvider(localProvider as never)
const seedHeadlessTerminal = vi.fn()
const seedTerminalRestoreTail = vi.fn()
const controller = registerAgentClaimController({ seedTerminalRestoreTail })
const controller = registerAgentClaimController({
seedHeadlessTerminal,
seedTerminalRestoreTail
})
await expect(controller.attach(daemonPtyId)).resolves.toBe(true)
expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, { lastTitle: '⠋ Claude' })
expect(seedHeadlessTerminal).toHaveBeenCalledWith(
daemonPtyId,
'idle composer\r\n',
{ cols: 120, rows: 40 },
{}
)
expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, {
text: 'idle composer\r\n',
lastTitle: '⠋ Claude'
})
})
it('does not dispatch a runtime PTY spawn after its client disconnects', async () => {
const provider = createAgentClaimProvider({})
@@ -108,7 +108,7 @@ describe('PTY registration without renderer delivery', () => {
const local = installObservableDaemonTestProvider()
const localProvider = getLocalPtyProvider()
const localClear = vi.spyOn(localProvider, 'clearBuffer')
const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue({})
const attach = vi.spyOn(localProvider, 'attach').mockResolvedValue()
const runtime = new OrcaRuntimeService()
const setController = vi.spyOn(runtime, 'setPtyController')
const remoteId = 'ssh:ssh-a@@remote-pty'
+20 -10
View File
@@ -3,7 +3,10 @@ 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 } from '../delivery/visibility-state'
import { ptySizes, visibleRendererPtys } from '../delivery/visibility-state'
import { seedTerminalRestoreRecordsFromSpawnResult } from '../pane/agent-session-owners'
import { seedHeadlessTerminalFromSpawnResult } from '../pane/terminal-spawn-restore'
import { hideRuntimeOwnedRendererDelivery } from './spawn-hidden-delivery'
import { rendererSerializerReadiness } from '../pane/serializer-state'
import { getProviderForPty, localProvider, resolvePtyExecutionHost } from '../provider/registry'
import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection'
@@ -125,21 +128,28 @@ export async function attachPtyFromRuntimeController(
if (provider !== localProvider || provider instanceof LocalPtyProvider) {
return false
}
const hidden = !visibleRendererPtys.has(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?.lastTitle) {
deps.runtime?.seedTerminalRestoreTail?.(ptyId, { lastTitle: attachResult.lastTitle })
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)
}
deps.syncPtyBackgroundedDelivery?.(ptyId, 'main-attach')
return true
} catch {
if (hidden) {
deps.transitionSpawnHiddenRendererPtyDeliveryState?.(ptyId, false)
}
return false
}
}
@@ -2,6 +2,7 @@ import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delive
import { attachingRendererViewPtys, visibleRendererPtys } from '../delivery/visibility-state'
import { closeStartupQueryAuthorityForPty } from '../provider/registry'
import type { RuntimePtySpawnState } from './spawn-state'
import type { PtyRuntimeControllerDeps } from './controller-deps'
// Background launches have no renderer to answer startup queries; main must own hidden-query replies.
@@ -65,14 +66,23 @@ export function releaseRuntimeSpawnPreSpawnHiddenMark(ctx: RuntimePtySpawnState)
}
function markRuntimeSpawnHidden(ctx: RuntimePtySpawnState, id: string): void {
const transition = ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState
hideRuntimeOwnedRendererDelivery(ctx.deps, id)
}
/** Main-held streams with no view drop renderer bytes until a pane reveals them. */
export function hideRuntimeOwnedRendererDelivery(
deps: Pick<PtyRuntimeControllerDeps, 'transitionSpawnHiddenRendererPtyDeliveryState'>,
id: string
): boolean {
const transition = deps.transitionSpawnHiddenRendererPtyDeliveryState
if (!transition) {
return
return false
}
// 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 {
+1 -1
View File
@@ -136,7 +136,7 @@ export type IPtyProvider = {
killFenced?: (request: PtyFencedTerminationRequest) => Promise<PtyFencedTerminationOutcome>
/** Whether fresh structured creates can replay one spawn across a lost relay response. */
supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise<boolean>
attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence' | 'lastTitle'> | void>
attach(id: string): Promise<PtySpawnResult | void>
hasPty?: (id: string) => boolean
/** Exact provider readback: false only when the provider answered that the PTY is absent. */
probePtyLiveness?: (id: string) => Promise<boolean | null>
@@ -23,8 +23,7 @@ async function subscribePausable(
const viewSubscribers = new RuntimeTerminalViewSubscribers({
notifyPresenceChanged: () => {},
hasMobileSubscribers: () => false,
isUnattachedLocalCandidate: () => false,
attachProvider: () => null
ensureProviderAttach: () => {}
})
const harness = startDesktopMultiplexSubscribe({
subscribeToTerminalData: vi.fn((_ptyId, listener) => {
@@ -125,6 +125,8 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
/** The name a rename gave this terminal: display only, never title evidence (#20626). */
manualTitle?: string | null
lastOutputAt: number | null
/** When main began receiving a stream that had printed nothing yet; silence since then is measured quiet. */
outputObservedSince?: number | null
/** See terminal-command-paint.ts; absent until the pane's first output, and again after a gap or a new process. */
commandPaint?: TerminalCommandPaint
}
@@ -1,8 +1,7 @@
type RuntimeTerminalViewSubscriberDependencies = {
notifyPresenceChanged: (ptyId: string) => void
hasMobileSubscribers: (ptyId: string) => boolean
isUnattachedLocalCandidate: (ptyId: string) => boolean
attachProvider: (ptyId: string) => Promise<boolean> | null
ensureProviderAttach: (ptyId: string) => void
}
type SilenceableRemoteView = { isSilent: () => boolean }
@@ -11,22 +10,9 @@ export class RuntimeTerminalViewSubscribers {
private readonly remoteCounts = new Map<string, number>()
private readonly silenceableRemoteViews = new Map<string, Set<SilenceableRemoteView>>()
private readonly rawCounts = new Map<string, number>()
private readonly providerAttaches = new Map<string, Promise<boolean>>()
private readonly attachInventoryWaiters = new Set<string>()
private readonly spawnPublishedPtys = new Set<string>()
constructor(private readonly deps: RuntimeTerminalViewSubscriberDependencies) {}
markSpawnPublished(ptyId: string): void {
this.spawnPublishedPtys.add(ptyId)
}
resetGeneration(ptyId: string): void {
this.providerAttaches.delete(ptyId)
this.attachInventoryWaiters.delete(ptyId)
this.spawnPublishedPtys.delete(ptyId)
}
// Why: exit drops subscribers only; attach/publish state survives until the respawn advances the generation.
clearSubscribers(ptyId: string): void {
this.remoteCounts.delete(ptyId)
@@ -42,7 +28,7 @@ export class RuntimeTerminalViewSubscribers {
views.add(view)
this.silenceableRemoteViews.set(ptyId, views)
}
this.ensureProviderAttach(ptyId)
this.deps.ensureProviderAttach(ptyId)
this.deps.notifyPresenceChanged(ptyId)
return this.releaseOnce(() => {
this.decrement(this.remoteCounts, ptyId)
@@ -86,65 +72,6 @@ export class RuntimeTerminalViewSubscribers {
return (this.remoteCounts.get(ptyId) ?? 0) - silent
}
isKnownUnattachedLocal(ptyId: string): boolean {
return !this.spawnPublishedPtys.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId)
}
hasPendingProviderAttach(ptyId: string): boolean {
return this.providerAttaches.has(ptyId)
}
attachInventoryWaiterIds(): string[] {
return [...this.attachInventoryWaiters]
}
reconcileProviderAttach(ptyId: string): void {
if (!this.hasRemote(ptyId)) {
return
}
const pending = this.providerAttaches.get(ptyId)
if (!pending) {
this.ensureProviderAttach(ptyId)
return
}
if (this.attachInventoryWaiters.has(ptyId)) {
return
}
this.attachInventoryWaiters.add(ptyId)
void pending.then((attached) => {
this.attachInventoryWaiters.delete(ptyId)
if (attached || !this.hasRemote(ptyId)) {
return
}
if (this.providerAttaches.get(ptyId) === pending) {
this.providerAttaches.delete(ptyId)
}
this.ensureProviderAttach(ptyId)
})
}
private ensureProviderAttach(ptyId: string): void {
if (this.providerAttaches.has(ptyId) || !this.isKnownUnattachedLocal(ptyId)) {
return
}
let attempt: Promise<boolean> | null
try {
attempt = this.deps.attachProvider(ptyId)
} catch {
attempt = Promise.resolve(false)
}
if (!attempt) {
return
}
const guardedAttempt = Promise.resolve(attempt).catch(() => false)
this.providerAttaches.set(ptyId, guardedAttempt)
void guardedAttempt.then((attached) => {
if (!attached && this.providerAttaches.get(ptyId) === guardedAttempt) {
this.providerAttaches.delete(ptyId)
}
})
}
private decrement(counts: Map<string, number>, ptyId: string): void {
const next = (counts.get(ptyId) ?? 1) - 1
if (next <= 0) {
@@ -25,9 +25,9 @@ type RuntimeInternals = {
) => unknown
issuePtyHandle: (pty: unknown) => string
headlessTerminals: Map<string, unknown>
terminalViewSubscribers: {
hasPendingProviderAttach: (ptyId: string) => boolean
attachInventoryWaiterIds: () => string[]
mainDaemonAttachments: {
hasPendingAttach: (ptyId: string) => boolean
inventoryWaiterIds: () => string[]
}
refreshPtyWorktreeRecordsWithControllerInventory: (
worktrees: [],
@@ -410,7 +410,7 @@ 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).terminalViewSubscribers.hasPendingProviderAttach(PTY_ID)).toBe(true)
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,19 +418,55 @@ 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).terminalViewSubscribers.attachInventoryWaiterIds()).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).terminalViewSubscribers.attachInventoryWaiterIds()).not.toContain(
PTY_ID
)
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'))
})
it('attaches a surviving session at inventory adoption with no view subscribed', async () => {
const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: false })
await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null)
await vi.waitFor(() => expect(model.attachCalls).toEqual([PTY_ID]))
expect(model.emitData(PTY_ID, 'worker still printing\r\n')).toBe(true)
const read = await runtime.readTerminal(handle)
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 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)
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 })
).rejects.toThrow('terminal_not_connected_since_restart')
})
it('does not subscriber-attach or provider-read a session this app already spawned', async () => {
const { runtime, model, handle } = setupNeverAttachedDaemonSession({
snapshotCapable: true,
@@ -138,6 +138,13 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun
launchReadiness?: boolean
}
): Promise<RuntimeTerminalWait> {
if (options?.condition !== '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 ?? {})
@@ -65,11 +65,28 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
/** Live local daemon sessions with no ingested bytes need attachment; existing headless state or snapshot reconciliation means attach already began. */
protected isKnownUnattachedLocalDaemonPty(ptyId: string): boolean {
return this.terminalViewSubscribers.isKnownUnattachedLocal(ptyId)
return this.mainDaemonAttachments.isKnownUnattachedLocal(ptyId)
}
protected reconcileSubscriberDrivenProviderAttach(ptyId: string): void {
this.terminalViewSubscribers.reconcileProviderAttach(ptyId)
/** A failed attach leaves a surviving session silent to main; waiting on it could only time out. */
protected assertTerminalOutputReachesMain(handle: string): void {
const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId
if (
ptyId &&
this.isKnownUnattachedLocalDaemonPty(ptyId) &&
!this.mainDaemonAttachments.hasPendingAttach(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. */
@@ -0,0 +1,119 @@
type MainDaemonAttachmentDependencies = {
isUnattachedLocalCandidate: (ptyId: string) => boolean
attachProvider: (ptyId: string) => Promise<boolean> | null
}
// 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 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) {}
markSpawnPublished(ptyId: string): void {
this.spawnPublishedPtys.add(ptyId)
}
resetGeneration(ptyId: string): void {
this.attaches.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)
}
hasPendingAttach(ptyId: string): boolean {
return this.attaches.has(ptyId)
}
inventoryWaiterIds(): string[] {
return [...this.inventoryWaiters]
}
/** 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) {
this.ensure(ptyId)
return
}
if (this.inventoryWaiters.has(ptyId)) {
return
}
this.inventoryWaiters.add(ptyId)
void pending.then((attached) => {
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. */
reattachAfterEndpointLoss(ptyId: string): void {
this.attaches.delete(ptyId)
this.lostPtys.add(ptyId)
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)
}
})
}
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 {
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
}
return (await this.deps.attachProvider(ptyId)) ?? false
} catch {
return false
} finally {
const next = this.queued.shift()
if (next) {
next()
} else {
this.running -= 1
}
}
}
}
@@ -190,7 +190,7 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve
this.restoredOrchestrationAuthorityByPtyId.delete(session.id)
}
pty.controllerTitle = session.title?.trim() || null
this.reconcileSubscriberDrivenProviderAttach(session.id)
this.reconcileMainDaemonAttach(session.id)
}
// Do not serialize relay probes or let one throw abort inventory cleanup.
if (inventoryOptions?.refreshForegroundAgents !== false) {
@@ -176,7 +176,7 @@ export abstract class OrcaRuntimeWithInvalidateAllHandlesForPty extends OrcaRunt
})
}
}
this.terminalViewSubscribers.markSpawnPublished(ptyId)
this.mainDaemonAttachments.markSpawnPublished(ptyId)
const pty = this.getOrCreatePtyWorktreeRecord(ptyId)
if (pty) {
if (incarnationId) {
@@ -89,7 +89,7 @@ export abstract class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalida
}
}
this.ptyLivenessVerdictByPtyId.delete(ptyId)
this.terminalViewSubscribers.markSpawnPublished(ptyId)
this.mainDaemonAttachments.markSpawnPublished(ptyId)
// Record spawn-time pane identity so stalled graph sync cannot hide the live PTY backing a pending mobile create.
const paneKey =
binding && isValidTerminalTabId(binding.tabId) && isTerminalLeafId(binding.leafId)
@@ -13,6 +13,7 @@ import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/termin
import type { PtyProviderBufferSnapshot } from '../../../providers/pty-provider-contract'
import type { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc'
import { RuntimeTerminalViewSubscribers } from '../../runtime-terminal-view-subscribers'
import { MainDaemonAttachments } from './main-daemon-attachments'
import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id'
export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequestedPtyIds {
@@ -146,10 +147,7 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
>
>()
// Remote xterms own query replies; ref-count streams and retain suppression through mobile soft-leave grace (terminal-query-authority.md).
protected readonly terminalViewSubscribers = new RuntimeTerminalViewSubscribers({
notifyPresenceChanged: (ptyId) => this.notifyRemoteTerminalViewPresenceChanged(ptyId),
hasMobileSubscribers: (ptyId) => (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0,
protected readonly mainDaemonAttachments = new MainDaemonAttachments({
isUnattachedLocalCandidate: (ptyId) => {
if (
this.headlessTerminals.has(ptyId) ||
@@ -164,7 +162,25 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit
},
attachProvider: (ptyId) => {
const attach = this.ptyController?.attach
return attach ? (async () => attach(ptyId))() : null
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
})()
}
})
// Remote xterms own query replies; ref-count streams and retain suppression through mobile soft-leave grace (terminal-query-authority.md).
protected readonly terminalViewSubscribers = new RuntimeTerminalViewSubscribers({
notifyPresenceChanged: (ptyId) => this.notifyRemoteTerminalViewPresenceChanged(ptyId),
hasMobileSubscribers: (ptyId) => (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0,
ensureProviderAttach: (ptyId) => this.mainDaemonAttachments.ensure(ptyId)
})
}
@@ -95,8 +95,12 @@ export abstract class OrcaRuntimeWithRecordAgentPromptLifecycleState extends Orc
this.agentPromptPermissionSequenceByPtyId.delete(ptyId)
this.agentPromptExplicitStatusFloorByPtyId.set(ptyId, Date.now())
this.legacyWorkerRecovery.deleteRecoveredPty(ptyId)
// Why: a respawn under the same session id needs its own subscriber-driven attach.
this.terminalViewSubscribers.resetGeneration(ptyId)
// Why: a respawn under the same session id needs its own main attach.
this.mainDaemonAttachments.resetGeneration(ptyId)
const pty = this.ptysById.get(ptyId)
if (pty) {
pty.outputObservedSince = null
}
// Why: a provider response belongs to the process generation that issued
// it; a respawn must neither reuse its frame nor join its in-flight call.
this.providerBufferAcquisitionsByPtyId.delete(ptyId)
@@ -13,6 +13,7 @@ import {
import { buildTerminalWaitText } from '../../terminal-wait-tail-state'
import {
evaluateTuiIdle,
outputClockAt,
type QuietForegroundLane,
type TuiIdleVerdict
} from '../../tui-idle-evidence'
@@ -145,7 +146,7 @@ export class RuntimeTerminalIdlePolls {
ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty),
blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason),
isQuiet: (lane) =>
isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs, lane, () =>
isQuietForQuiescence(outputClockAt(pty), this.deps.quiescenceMs, lane, () =>
this.deps.hasCommandPainted(pty.ptyId)
)
}
+7 -6
View File
@@ -14,10 +14,11 @@ import {
type AgentStateVerdict
} from './agent-state-rules/agent-state-rules-engine'
import type { TuiIdleHookTurn } from './tui-idle-hook-lane'
import type {
FirstPartyAgentStatus,
TuiIdleEvaluationInput,
TuiIdleEvidenceRecord
import {
outputClockAt,
type FirstPartyAgentStatus,
type TuiIdleEvaluationInput,
type TuiIdleEvidenceRecord
} from './tui-idle-evidence'
/** The runtime state every tui-idle site reads its evidence from. */
@@ -61,7 +62,7 @@ function readAgentRuleVerdict(
readScreenLines,
readText: () => waitText().toLowerCase(),
readTitleStatus: () => record.lastAgentStatus,
hasOutputClock: record.lastOutputAt !== null
hasOutputClock: outputClockAt(record) !== null
})
}
@@ -138,7 +139,7 @@ export function ptyTuiIdleEvidence(
agent !== 'qoder-cn' &&
agent !== 'hermes' &&
source.getAdoptedPtyIdleStatus(pty) === 'idle') ||
isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null),
isKnownReadyPromptBody(waitText(), agent, readScreen, outputClockAt(pty) !== null),
readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen),
readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText),
readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId),
+13 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } 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,6 +63,18 @@ 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)
})
+8 -1
View File
@@ -27,9 +27,15 @@ 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
@@ -103,7 +109,8 @@ 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 {
return record.lastOutputAt !== null && Date.now() - record.lastOutputAt >= quiescenceMs
const clock = outputClockAt(record)
return clock !== null && Date.now() - clock >= 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. */
@@ -221,6 +221,7 @@ 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()
@@ -0,0 +1,171 @@
// A terminal that survives a quit in a workspace the window never opened must stream to main after relaunch.
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { RuntimeClient } from '../../src/cli/runtime-client'
import type { RuntimeTerminalSummary } from '../../src/shared/runtime-types'
import { expect, test } from './helpers/orca-app'
import { TEST_REPO_PATH_FILE } from './global-setup'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
import { createRetentionFixtureDirectory } from './helpers/host-created-terminal-retention-oracle'
const scratch = createRetentionFixtureDirectory()
const printerPath = path.join(scratch, 'printer.mjs')
writeFileSync(
printerPath,
'setInterval(() => process.stdout.write(`tick ${Date.now()}\\r\\n`), 250)\n'
)
test.afterAll(() => rmSync(scratch, { recursive: true, force: true }))
type RuntimeCall = <T>(method: string, params?: unknown) => Promise<T>
async function launchWithUnopenedWorkspace(session: ReturnType<typeof createRestartSession>) {
const repoPath = readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim()
const { app } = await session.launch()
const page = await app.firstWindow()
const active = await attachRepoAndOpenTerminal(page, repoPath)
await waitForSessionReady(page)
const call: RuntimeCall = async <T>(method: string, params?: unknown) =>
(await new RuntimeClient(session.userDataDir, 30_000, null, null).call<T>(method, params))
.result
await expect
.poll(
() =>
page.evaluate(async () => {
const state = window.__store!.getState()
await state.fetchWorktrees(state.repos[0]!.id)
return window.__store!.getState().allWorktrees().length
}),
{ timeout: 60_000 }
)
.toBeGreaterThan(1)
const unopened = await page.evaluate(
(activeId) =>
window
.__store!.getState()
.allWorktrees()
.find((worktree) => worktree.id !== activeId)!.id,
active
)
await expect
.poll(
() =>
call('worktree.show', { worktree: `id:${unopened}` }).then(
() => true,
() => false
),
{ timeout: 120_000 }
)
.toBe(true)
return { app, page, call, active, unopened }
}
function listTerminals(call: RuntimeCall): Promise<RuntimeTerminalSummary[]> {
return call<{ terminals: RuntimeTerminalSummary[] }>('terminal.list').then(
(listed) => listed.terminals
)
}
// oxlint-disable-next-line no-empty-pattern -- Owns both app launches; Playwright needs the fixtures argument.
test('a surviving printer in a never-opened workspace streams to main after relaunch', async ({}, testInfo) => {
// Two full app launches with a quit between them.
test.setTimeout(300_000)
const session = createRestartSession(testInfo)
const first = await launchWithUnopenedWorkspace(session)
let app = first.app
try {
const { call, unopened } = first
const { terminal } = await call<{ terminal: { handle: string } }>('terminal.create', {
worktree: `id:${unopened}`,
command: `node ${JSON.stringify(printerPath)}`,
focus: false,
activate: false,
presentation: 'background'
})
await expect
.poll(
async () =>
(
await call<{ terminal: { tail: string[] } }>('terminal.read', {
terminal: terminal.handle,
limit: 20
})
).terminal.tail.join('\n'),
{ timeout: 30_000 }
)
.toContain('tick ')
const { ptyId } = (await listTerminals(call)).find((t) => t.handle === terminal.handle)!
await session.close(app)
const second = await session.launch()
app = second.app
await waitForSessionReady(second.page)
await waitForActiveWorktree(second.page)
// Precondition: no window pane holds the printer, so only main can attach it.
expect(
await second.page.evaluate(
(worktreeId) => window.__store!.getState().activeWorktreeId === worktreeId,
unopened
)
).toBe(false)
const survivor = async (): Promise<RuntimeTerminalSummary | undefined> =>
(await listTerminals(call)).find((terminal) => terminal.ptyId === ptyId)
await expect.poll(async () => (await survivor())?.connected, { timeout: 60_000 }).toBe(true)
let firstOutputAt: number | null = null
await expect
.poll(
async () => {
const lastOutputAt = (await survivor())?.lastOutputAt ?? null
firstOutputAt ??= lastOutputAt
return firstOutputAt !== null && lastOutputAt !== null && lastOutputAt > firstOutputAt
},
{ timeout: 5_000, message: 'main never received the surviving printer output' }
)
.toBe(true)
// A pane mounting later shares main's daemon client, so main keeps receiving.
await expect
.poll(
() =>
second.page.evaluate((worktreeId) => {
let state = window.__store!.getState()
if (state.activeWorktreeId !== worktreeId) {
state.setActiveWorktree(worktreeId)
return false
}
const tab = state.tabsByWorktree[worktreeId]?.[0]
if (!tab) {
return false
}
if (state.activeTabId !== tab.id) {
state.setActiveTab(tab.id)
state.setActiveTabType('terminal', worktreeId)
}
state = window.__store!.getState()
return state.activeWorktreeId === worktreeId && state.activeTabId === tab.id
}, unopened),
{ timeout: 30_000 }
)
.toBe(true)
// The pane paints from its own attach; xterm draws on a canvas, so read its buffer.
await expect
.poll(
() =>
second.page.evaluate(() => {
const state = window.__store!.getState()
const buffer = window.__paneManagers?.get(state.activeTabId ?? '')?.getActivePane()
?.terminal.buffer.active
const line = buffer?.getLine(Math.max(0, buffer.baseY + buffer.cursorY - 1))
return line?.translateToString(true) ?? ''
}),
{ timeout: 30_000 }
)
.toContain('tick ')
const afterMount = (await survivor())!.lastOutputAt!
await expect
.poll(async () => (await survivor())?.lastOutputAt ?? 0, { timeout: 5_000 })
.toBeGreaterThan(afterMount)
} finally {
await session.dispose(app).catch(() => undefined)
}
})