diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index e4e3c81acbe..e06bf559f2f 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -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 diff --git a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts index bd9033d592e..445fda53163 100644 --- a/src/main/daemon/daemon-pty-adapter-protocol-compatibility.test.ts +++ b/src/main/daemon/daemon-pty-adapter-protocol-compatibility.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', diff --git a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts index 51f19725b1b..816ef61ccb8 100644 --- a/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts +++ b/src/main/daemon/daemon-pty-adapter-session-adoption.test.ts @@ -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() diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 0387accc950..ff8137390dc 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -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', () => { diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index 60db90030d5..02ffc7f971b 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -22,7 +22,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { deadlineMs?: number ): Promise - async attach(id: string): Promise | void> { + async attach(id: string): Promise { 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 { diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 7093c34b44f..2f5a868075c 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -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 () => { diff --git a/src/main/ipc/pty/provider/bind-listeners.ts b/src/main/ipc/pty/provider/bind-listeners.ts index 0323d6995ea..9489117f9b1 100644 --- a/src/main/ipc/pty/provider/bind-listeners.ts +++ b/src/main/ipc/pty/provider/bind-listeners.ts @@ -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() || diff --git a/src/main/ipc/pty/pty-controller-ownership-routing.test.ts b/src/main/ipc/pty/pty-controller-ownership-routing.test.ts index aec78c5acd8..c0e0cba5f6a 100644 --- a/src/main/ipc/pty/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty/pty-controller-ownership-routing.test.ts @@ -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({}) diff --git a/src/main/ipc/pty/register-without-renderer.test.ts b/src/main/ipc/pty/register-without-renderer.test.ts index bf467533fdc..53039858ede 100644 --- a/src/main/ipc/pty/register-without-renderer.test.ts +++ b/src/main/ipc/pty/register-without-renderer.test.ts @@ -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' diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index 65d51f6bf38..cc549ecd725 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -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 } } diff --git a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts index 33a9ce17299..4c0dbe12775 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -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, + 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 { diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 930636c9461..30bee33c5c1 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -136,7 +136,7 @@ export type IPtyProvider = { killFenced?: (request: PtyFencedTerminationRequest) => Promise /** Whether fresh structured creates can replay one spawn across a lost relay response. */ supportsAgentSessionCreateOperations?: (options?: PtyProbeOptions) => boolean | Promise - attach(id: string): Promise | void> + attach(id: string): Promise hasPty?: (id: string) => boolean /** Exact provider readback: false only when the provider answered that the PTY is absent. */ probePtyLiveness?: (id: string) => Promise diff --git a/src/main/runtime/rpc/terminal-multiplex-paused-output-drop.test.ts b/src/main/runtime/rpc/terminal-multiplex-paused-output-drop.test.ts index 600931ba316..3c8c304f91d 100644 --- a/src/main/runtime/rpc/terminal-multiplex-paused-output-drop.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-paused-output-drop.test.ts @@ -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) => { diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index e1cd456abed..f3e0ab78102 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -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 } diff --git a/src/main/runtime/runtime-terminal-view-subscribers.ts b/src/main/runtime/runtime-terminal-view-subscribers.ts index 8820e38468c..14e60b4636a 100644 --- a/src/main/runtime/runtime-terminal-view-subscribers.ts +++ b/src/main/runtime/runtime-terminal-view-subscribers.ts @@ -1,8 +1,7 @@ type RuntimeTerminalViewSubscriberDependencies = { notifyPresenceChanged: (ptyId: string) => void hasMobileSubscribers: (ptyId: string) => boolean - isUnattachedLocalCandidate: (ptyId: string) => boolean - attachProvider: (ptyId: string) => Promise | null + ensureProviderAttach: (ptyId: string) => void } type SilenceableRemoteView = { isSilent: () => boolean } @@ -11,22 +10,9 @@ export class RuntimeTerminalViewSubscribers { private readonly remoteCounts = new Map() private readonly silenceableRemoteViews = new Map>() private readonly rawCounts = new Map() - private readonly providerAttaches = new Map>() - private readonly attachInventoryWaiters = new Set() - private readonly spawnPublishedPtys = new Set() 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 | 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, ptyId: string): void { const next = (counts.get(ptyId) ?? 1) - 1 if (next <= 0) { diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 4a3b576135c..6b564603af8 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -25,9 +25,9 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map - 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, diff --git a/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts b/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts index 66fb54ff4c2..4bb80fb2a66 100644 --- a/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts +++ b/src/main/runtime/terminal/input/terminal-agent-prompt-render.ts @@ -138,6 +138,13 @@ export abstract class OrcaRuntimeWithCreateAgentPromptRenderGate extends OrcaRun launchReadiness?: boolean } ): Promise { + 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 ?? {}) diff --git a/src/main/runtime/terminal/layout/terminal-view-presence.ts b/src/main/runtime/terminal/layout/terminal-view-presence.ts index c7fc2e49fe7..e29b4ee4b20 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -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. */ diff --git a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts new file mode 100644 index 00000000000..736d72c2823 --- /dev/null +++ b/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts @@ -0,0 +1,119 @@ +type MainDaemonAttachmentDependencies = { + isUnattachedLocalCandidate: (ptyId: string) => boolean + attachProvider: (ptyId: string) => Promise | 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>() + private readonly inventoryWaiters = new Set() + private readonly spawnPublishedPtys = new Set() + // Endpoint loss detached these; they need a re-attach although main already holds their model. + private readonly lostPtys = new Set() + 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 { + if (this.running >= MAX_CONCURRENT_ATTACHES) { + // The finishing attach hands its slot over without releasing it. + await new Promise((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 + } + } + } +} diff --git a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts index 31bcf9a2f2a..a754f654021 100644 --- a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts +++ b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts @@ -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) { diff --git a/src/main/runtime/terminal/lifecycle/pty-handle-invalidation.ts b/src/main/runtime/terminal/lifecycle/pty-handle-invalidation.ts index 146aaa6afbb..a6136761900 100644 --- a/src/main/runtime/terminal/lifecycle/pty-handle-invalidation.ts +++ b/src/main/runtime/terminal/lifecycle/pty-handle-invalidation.ts @@ -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) { diff --git a/src/main/runtime/terminal/lifecycle/pty-registration.ts b/src/main/runtime/terminal/lifecycle/pty-registration.ts index f0349df5dc9..4436db6f3d1 100644 --- a/src/main/runtime/terminal/lifecycle/pty-registration.ts +++ b/src/main/runtime/terminal/lifecycle/pty-registration.ts @@ -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) diff --git a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts index aebba5b5380..1eb9ecfd589 100644 --- a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts +++ b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts @@ -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) + }) } diff --git a/src/main/runtime/terminal/output/terminal-output-generation.ts b/src/main/runtime/terminal/output/terminal-output-generation.ts index d20b02353cd..c8e437ad79d 100644 --- a/src/main/runtime/terminal/output/terminal-output-generation.ts +++ b/src/main/runtime/terminal/output/terminal-output-generation.ts @@ -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) diff --git a/src/main/runtime/terminal/wait/terminal-idle-polls.ts b/src/main/runtime/terminal/wait/terminal-idle-polls.ts index 9d02262c1d2..2498defe2bb 100644 --- a/src/main/runtime/terminal/wait/terminal-idle-polls.ts +++ b/src/main/runtime/terminal/wait/terminal-idle-polls.ts @@ -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) ) } diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts index 83b680f50e9..9eea2e5a0f4 100644 --- a/src/main/runtime/tui-idle-evidence-source.ts +++ b/src/main/runtime/tui-idle-evidence-source.ts @@ -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), diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 40fd8c3356b..1bdbf83f348 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -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) }) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index 98b57276e3f..d9423b4108f 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -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. */ diff --git a/src/main/runtime/workspaces/workspace-activation.ts b/src/main/runtime/workspaces/workspace-activation.ts index 0a50d6e6a85..c1bb414ebb0 100644 --- a/src/main/runtime/workspaces/workspace-activation.ts +++ b/src/main/runtime/workspaces/workspace-activation.ts @@ -221,6 +221,7 @@ export abstract class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntime signal }: { requireComposerMarker?: boolean; stopOnDialog?: boolean; signal?: AbortSignal } = {} ): Promise { + this.assertTerminalOutputReachesMain(handle) const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const stop = new AbortController() diff --git a/tests/e2e/restart-unopened-workspace-terminal-attach.spec.ts b/tests/e2e/restart-unopened-workspace-terminal-attach.spec.ts new file mode 100644 index 00000000000..4bef99b4d14 --- /dev/null +++ b/tests/e2e/restart-unopened-workspace-terminal-attach.spec.ts @@ -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 = (method: string, params?: unknown) => Promise + +async function launchWithUnopenedWorkspace(session: ReturnType) { + 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 (method: string, params?: unknown) => + (await new RuntimeClient(session.userDataDir, 30_000, null, null).call(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 { + 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 => + (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) + } +})