From 2f945fa715be6833bc865b84c1326a8bc6df66be Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 28 Oct 2026 17:44:43 -0400 Subject: [PATCH] Revert "Merge #33720 (restart: attach surviving terminals) into integration" This reverts commit d4b0c6c5f7eb2e6e93ee3146f7e3766951c0bc78, reversing changes made to 53f9735bfea6ac097d34b8cb7703878725688bff. --- ...pty-adapter-protocol-compatibility.test.ts | 4 +- ...aemon-pty-adapter-session-adoption.test.ts | 10 +- src/main/daemon/daemon-pty-router.test.ts | 7 +- src/main/daemon/daemon-pty-session-control.ts | 11 +- .../degraded-daemon-pty-provider.test.ts | 7 +- src/main/ipc/pty/delivery/visibility-state.ts | 5 - src/main/ipc/pty/pane/agent-session-owners.ts | 3 - .../pty-controller-ownership-routing.test.ts | 27 +-- .../ipc/pty/register-without-renderer.test.ts | 2 +- src/main/ipc/pty/runtime/operations.ts | 17 +- .../ipc/pty/runtime/spawn-hidden-delivery.ts | 15 +- src/main/providers/pty-provider-contract.ts | 2 +- .../runtime-construction-services.ts | 1 - .../construction/runtime-identity-state.ts | 1 - ...minal-multiplex-paused-output-drop.test.ts | 3 +- .../runtime/runtime-terminal-state-records.ts | 2 - .../runtime-terminal-view-subscribers.ts | 78 +++++++- ...al-subscriber-driven-daemon-attach.test.ts | 22 +-- .../terminal/layout/terminal-view-presence.ts | 6 +- .../lifecycle/main-daemon-attachments.ts | 91 --------- .../pty-controller-inventory-refresh.ts | 2 +- .../terminal/lifecycle/pty-exit-lifecycle.ts | 1 - .../lifecycle/pty-handle-invalidation.ts | 2 +- .../terminal/lifecycle/pty-registration.ts | 2 +- .../lifecycle/terminal-view-resources.ts | 18 +- .../output/terminal-buffer-operations.ts | 13 -- .../output/terminal-output-generation.ts | 8 +- .../terminal/wait/terminal-idle-polls.ts | 5 +- .../wait/terminal-wait-visible-probe.ts | 8 +- .../runtime/terminal/wait/terminal-wait.ts | 24 +-- src/main/runtime/tui-idle-evidence-source.ts | 35 +--- .../runtime/tui-idle-output-clock.test.ts | 40 ---- ...unopened-workspace-terminal-attach.spec.ts | 174 ------------------ 33 files changed, 150 insertions(+), 496 deletions(-) delete mode 100644 src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts delete mode 100644 src/main/runtime/tui-idle-output-clock.test.ts delete mode 100644 tests/e2e/restart-unopened-workspace-terminal-attach.spec.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 445fda53163..bd9033d592e 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.toMatchObject({ id: sessionId }) + await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined() 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.toMatchObject({ id: sessionId }) + await expect(rig.adapter.attach(sessionId)).resolves.toBeUndefined() 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 cc36c7a544b..51f19725b1b 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.toMatchObject({ + await expect(adapter.attach(sessionId)).resolves.toEqual({ providerSequence: { value: 'preserved output'.length, generation: 'continued' @@ -367,13 +367,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { ) const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 30 }) try { - const attached = await legacy.attach('legacy-session') - expect(attached).toMatchObject({ - id: 'legacy-session', - isReattach: true, - incarnationId: 'legacy-attach-incarnation' - }) - expect(attached.providerSequence).toBeUndefined() + await expect(legacy.attach('legacy-session')).resolves.toBeUndefined() } 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 ff8137390dc..0387accc950 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -78,17 +78,14 @@ 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({ id: 'legacy-session', providerSequence }) + vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence }) const router = new DaemonPtyRouter({ current: createAdapter('current'), legacy: [legacy] }) await router.discoverLegacySessions() - await expect(router.attach('legacy-session')).resolves.toEqual({ - id: 'legacy-session', - providerSequence - }) + await expect(router.attach('legacy-session')).resolves.toEqual({ providerSequence }) }) describe('DaemonPtyRouter', () => { diff --git a/src/main/daemon/daemon-pty-session-control.ts b/src/main/daemon/daemon-pty-session-control.ts index 88f229d7c06..9e861909097 100644 --- a/src/main/daemon/daemon-pty-session-control.ts +++ b/src/main/daemon/daemon-pty-session-control.ts @@ -25,7 +25,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { deadlineMs?: number ): Promise - async attach(id: string): Promise { + async attach(id: string): Promise | void> { await this.ensureConnected() if (!this.canDelegateBackgroundToDaemon) { this.setPtyBackgrounded(id, false) @@ -46,8 +46,13 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput { if (result.exitedBeforeSpawnReply) { throw new SessionNotFoundError(id) } - // A restarted runtime has never seen the surviving session's screen or title. - return result + // 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 } 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 2f5a868075c..7093c34b44f 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({ id: 'daemon-session', providerSequence }) + vi.mocked(legacy.attach).mockResolvedValueOnce({ providerSequence }) const provider = new DegradedDaemonPtyProvider({ current: createDaemonAdapter('current'), legacy: [legacy], @@ -331,10 +331,7 @@ describe('DegradedDaemonPtyProvider', () => { }) await provider.discoverDaemonSessions() - await expect(provider.attach('daemon-session')).resolves.toEqual({ - id: 'daemon-session', - providerSequence - }) + await expect(provider.attach('daemon-session')).resolves.toEqual({ providerSequence }) }) it('only delegates owner-listing authority to the provider that owns the id', async () => { diff --git a/src/main/ipc/pty/delivery/visibility-state.ts b/src/main/ipc/pty/delivery/visibility-state.ts index 3a5b19b584b..2aca6692fb3 100644 --- a/src/main/ipc/pty/delivery/visibility-state.ts +++ b/src/main/ipc/pty/delivery/visibility-state.ts @@ -10,11 +10,6 @@ export const visibleRendererPtys = new Set() export const rendererVisibilityKnownPtys = new Set() // Visible attaches suppress main-start hiding; remember it so failed attaches can hide the PTY again. export const attachingRendererViewPtys = new Map() -/** 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 diff --git a/src/main/ipc/pty/pane/agent-session-owners.ts b/src/main/ipc/pty/pane/agent-session-owners.ts index aabdeb8d6fa..0a0ad557bfc 100644 --- a/src/main/ipc/pty/pane/agent-session-owners.ts +++ b/src/main/ipc/pty/pane/agent-session-owners.ts @@ -39,9 +39,6 @@ export function seedTerminalRestoreRecordsFromSpawnResult( ...(lastTitle !== undefined ? { lastTitle } : {}) }) } - if (result.isReattach === true) { - runtime?.startReattachedOutputClock?.(result.id) - } } export function assertSpawnReplyWasLive(result: PtySpawnResult): void { 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 f33cc4cbcb7..c0030426eba 100644 --- a/src/main/ipc/pty/pty-controller-ownership-routing.test.ts +++ b/src/main/ipc/pty/pty-controller-ownership-routing.test.ts @@ -180,36 +180,17 @@ describe('registerPtyHandlers', () => { 0 ) }) - it('seeds the screen and title of a session adopted by main attach', async () => { + it('seeds the title of a session adopted by subscriber-driven attach', async () => { const daemonPtyId = 'repo-1::/tmp/wt@@title-handoff' const localProvider = createAgentClaimProvider({}) - localProvider.attach.mockResolvedValueOnce({ - id: daemonPtyId, - snapshot: 'idle composer\r\n', - snapshotCols: 120, - snapshotRows: 40, - lastTitle: '⠋ Claude' - }) + localProvider.attach.mockResolvedValueOnce({ lastTitle: '⠋ Claude' }) setLocalPtyProvider(localProvider as never) - const seedHeadlessTerminal = vi.fn() const seedTerminalRestoreTail = vi.fn() - const controller = registerAgentClaimController({ - seedHeadlessTerminal, - seedTerminalRestoreTail - }) + const controller = registerAgentClaimController({ seedTerminalRestoreTail }) await expect(controller.attach(daemonPtyId)).resolves.toBe(true) - expect(seedHeadlessTerminal).toHaveBeenCalledWith( - daemonPtyId, - 'idle composer\r\n', - { cols: 120, rows: 40 }, - {} - ) - expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, { - text: 'idle composer\r\n', - lastTitle: '⠋ Claude' - }) + expect(seedTerminalRestoreTail).toHaveBeenCalledWith(daemonPtyId, { 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 ef6b630f67b..65d30b3a7af 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 9f789263950..16fa7490017 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -3,11 +3,7 @@ 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 { 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' +import { ptySizes } from '../delivery/visibility-state' import { rendererSerializerReadiness } from '../pane/serializer-state' import { getProviderForPty, localProvider, resolvePtyExecutionHost } from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' @@ -129,10 +125,6 @@ export async function attachPtyFromRuntimeController( if (provider !== localProvider || provider instanceof LocalPtyProvider) { return false } - // A pane's own hidden claim stays its own; a reveal or exit clears main's. - if (!isRendererViewShowingPty(ptyId) && !isHiddenRendererPty(ptyId)) { - hideRuntimeOwnedRendererDelivery(deps, ptyId) - } try { const sequenceBeforeProviderAttach = deps.runtime?.getPtyOutputSequence?.(ptyId) ?? 0 const attachResult = await provider.attach(ptyId) @@ -143,12 +135,9 @@ export async function attachPtyFromRuntimeController( sequenceBeforeProviderAttach ) } - if (attachResult) { - // 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) + if (attachResult?.lastTitle) { + deps.runtime?.seedTerminalRestoreTail?.(ptyId, { lastTitle: attachResult.lastTitle }) } - deps.syncPtyBackgroundedDelivery?.(ptyId, 'main-attach') return true } catch { 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 24a8e9a78e3..33a9ce17299 100644 --- a/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts +++ b/src/main/ipc/pty/runtime/spawn-hidden-delivery.ts @@ -1,8 +1,7 @@ import { markRuntimeOwnedHiddenRendererPty } from '../delivery/pty-hidden-delivery-gate' -import { attachingRendererViewPtys, isRendererViewShowingPty } from '../delivery/visibility-state' +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. @@ -43,7 +42,7 @@ export function commitRuntimeSpawnHiddenDelivery(ctx: RuntimePtySpawnState): voi if (attaching) { attaching.skippedHide = true } - if (isRendererViewShowingPty(id)) { + if (visibleRendererPtys.has(id) || attaching) { releaseRuntimeSpawnPreSpawnHiddenMark(ctx) ctx.deps.syncPtyBackgroundedDelivery?.(id, 'spawn') return @@ -66,15 +65,7 @@ export function releaseRuntimeSpawnPreSpawnHiddenMark(ctx: RuntimePtySpawnState) } function markRuntimeSpawnHidden(ctx: RuntimePtySpawnState, id: string): void { - 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 -): void { - const transition = deps.transitionSpawnHiddenRendererPtyDeliveryState + const transition = ctx.deps.transitionSpawnHiddenRendererPtyDeliveryState if (!transition) { return } diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index e037fd73b41..ebf8dd13237 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 + attach(id: string): Promise | void> 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/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index 78a52b3e7ff..773cedfeaab 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -109,7 +109,6 @@ export abstract class RuntimeConstructionServices { protected abstract readCaughtUpRuledScreen(ptyId: string): RuledScreen | null protected abstract clearWaitBlockedStamp(ptyId: string): void protected abstract readRuledScreen(ptyId: string | null | undefined): RuledScreen | null - protected abstract readOutputClockAt(ptyId: string | null | undefined): number | null protected abstract headlessTerminals: Map protected abstract readHeadlessVisibleTerminalState( ptyId: string diff --git a/src/main/runtime/construction/runtime-identity-state.ts b/src/main/runtime/construction/runtime-identity-state.ts index 46bc9cd6d1e..f59357dffb2 100644 --- a/src/main/runtime/construction/runtime-identity-state.ts +++ b/src/main/runtime/construction/runtime-identity-state.ts @@ -342,7 +342,6 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent), readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), readRuledScreen: (ptyId) => this.readRuledScreen(ptyId), - getOutputClockAt: (ptyId) => this.readOutputClockAt(ptyId), getTitleObservedAtEpochMs: (ptyId) => (ptyId ? this.ptysById.get(ptyId)?.lastOscTitleEpochMs : null) ?? null } 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 3c8c304f91d..600931ba316 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,7 +23,8 @@ async function subscribePausable( const viewSubscribers = new RuntimeTerminalViewSubscribers({ notifyPresenceChanged: () => {}, hasMobileSubscribers: () => false, - ensureProviderAttach: () => {} + isUnattachedLocalCandidate: () => false, + attachProvider: () => null }) 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 f3e0ab78102..e1cd456abed 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -125,8 +125,6 @@ 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 8da7e33956d..8820e38468c 100644 --- a/src/main/runtime/runtime-terminal-view-subscribers.ts +++ b/src/main/runtime/runtime-terminal-view-subscribers.ts @@ -1,7 +1,8 @@ type RuntimeTerminalViewSubscriberDependencies = { notifyPresenceChanged: (ptyId: string) => void hasMobileSubscribers: (ptyId: string) => boolean - ensureProviderAttach: (ptyId: string) => void + isUnattachedLocalCandidate: (ptyId: string) => boolean + attachProvider: (ptyId: string) => Promise | null } type SilenceableRemoteView = { isSilent: () => boolean } @@ -10,9 +11,23 @@ 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) this.silenceableRemoteViews.delete(ptyId) @@ -27,7 +42,7 @@ export class RuntimeTerminalViewSubscribers { views.add(view) this.silenceableRemoteViews.set(ptyId, views) } - this.deps.ensureProviderAttach(ptyId) + this.ensureProviderAttach(ptyId) this.deps.notifyPresenceChanged(ptyId) return this.releaseOnce(() => { this.decrement(this.remoteCounts, ptyId) @@ -71,6 +86,65 @@ 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 64d37a5ccf1..4a3b576135c 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -25,6 +25,10 @@ type RuntimeInternals = { ) => unknown issuePtyHandle: (pty: unknown) => string headlessTerminals: Map + terminalViewSubscribers: { + hasPendingProviderAttach: (ptyId: string) => boolean + attachInventoryWaiterIds: () => string[] + } refreshPtyWorktreeRecordsWithControllerInventory: ( worktrees: [], targetWorktreeId: string | null @@ -406,6 +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(runtime.hasRemoteTerminalViewSubscriber(PTY_ID)).toBe(true) expect(model.emitData(PTY_ID, 'before inventory\r\n')).toBe(false) @@ -413,24 +418,19 @@ 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]) 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(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('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/layout/terminal-view-presence.ts b/src/main/runtime/terminal/layout/terminal-view-presence.ts index 9fcc36a402c..cea0a42cb19 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -65,7 +65,11 @@ 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.mainDaemonAttachments.isKnownUnattachedLocal(ptyId) + return this.terminalViewSubscribers.isKnownUnattachedLocal(ptyId) + } + + protected reconcileSubscriberDrivenProviderAttach(ptyId: string): void { + this.terminalViewSubscribers.reconcileProviderAttach(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 deleted file mode 100644 index a6cedb8379c..00000000000 --- a/src/main/runtime/terminal/lifecycle/main-daemon-attachments.ts +++ /dev/null @@ -1,91 +0,0 @@ -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 { - // Sessions whose stream this run already holds: spawned, pane-attached or main-attached. - private readonly attached = new Set() - private readonly attaching = new Map>() - private readonly queued: (() => void)[] = [] - private running = 0 - - constructor(private readonly deps: MainDaemonAttachmentDependencies) {} - - markSpawnPublished(ptyId: string): void { - this.attached.add(ptyId) - } - - /** Exit or respawn ends the generation whose stream this described. */ - forget(ptyId: string): void { - this.attached.delete(ptyId) - this.attaching.delete(ptyId) - } - - isKnownUnattachedLocal(ptyId: string): boolean { - return !this.attached.has(ptyId) && this.deps.isUnattachedLocalCandidate(ptyId) - } - - /** Inventory proved the session live; attach it, or retry once an in-flight refusal settles. */ - reconcile(ptyId: string): void { - const attempt = this.attaching.get(ptyId) - if (!attempt) { - this.ensure(ptyId) - return - } - void attempt.then((attached) => { - if (!attached) { - this.ensure(ptyId) - } - }) - } - - ensure(ptyId: string): void { - if (this.attaching.has(ptyId) || !this.isKnownUnattachedLocal(ptyId)) { - return - } - const attempt: Promise = this.acquireSlot().then(() => this.attach(ptyId, attempt)) - this.attaching.set(ptyId, attempt) - void attempt.then((attached) => { - if (this.attaching.get(ptyId) !== attempt) { - return - } - this.attaching.delete(ptyId) - if (attached) { - this.attached.add(ptyId) - } - }) - } - - private acquireSlot(): Promise { - if (this.running < MAX_CONCURRENT_ATTACHES) { - this.running += 1 - return Promise.resolve() - } - // The finishing attach hands its slot over without releasing it. - return new Promise((resolve) => this.queued.push(resolve)) - } - - private async attach(ptyId: string, attempt: Promise): Promise { - try { - // While this queued, an exit or respawn may have superseded it, or a pane may have attached. - if (this.attaching.get(ptyId) !== attempt || !this.isKnownUnattachedLocal(ptyId)) { - return false - } - return (await this.deps.attachProvider(ptyId)) ?? false - } 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 0588bfa131b..31bcf9a2f2a 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.mainDaemonAttachments.reconcile(session.id) + this.reconcileSubscriberDrivenProviderAttach(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-exit-lifecycle.ts b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts index facd7c8f3f9..be683b22655 100644 --- a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts +++ b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts @@ -166,7 +166,6 @@ 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) diff --git a/src/main/runtime/terminal/lifecycle/pty-handle-invalidation.ts b/src/main/runtime/terminal/lifecycle/pty-handle-invalidation.ts index a6136761900..146aaa6afbb 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.mainDaemonAttachments.markSpawnPublished(ptyId) + this.terminalViewSubscribers.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 4436db6f3d1..f0349df5dc9 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.mainDaemonAttachments.markSpawnPublished(ptyId) + this.terminalViewSubscribers.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 1ebd96ff7c5..23504451841 100644 --- a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts +++ b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts @@ -13,7 +13,6 @@ 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 { @@ -142,7 +141,10 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit > >() - protected readonly mainDaemonAttachments = new MainDaemonAttachments({ + // 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, isUnattachedLocalCandidate: (ptyId) => { if ( this.headlessTerminals.has(ptyId) || @@ -155,13 +157,9 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit const pty = this.ptysById.get(ptyId) return pty !== undefined && pty.connectionId === null && pty.connected }, - attachProvider: (ptyId) => this.ptyController?.attach?.(ptyId) ?? null - }) - - // 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) + attachProvider: (ptyId) => { + const attach = this.ptyController?.attach + return attach ? (async () => attach(ptyId))() : null + } }) } diff --git a/src/main/runtime/terminal/output/terminal-buffer-operations.ts b/src/main/runtime/terminal/output/terminal-buffer-operations.ts index 7f6e5e4ba9b..d2435725ff0 100644 --- a/src/main/runtime/terminal/output/terminal-buffer-operations.ts +++ b/src/main/runtime/terminal/output/terminal-buffer-operations.ts @@ -225,19 +225,6 @@ export abstract class OrcaRuntimeWithSerializeMainTerminalBuffer extends OrcaRun }) } - /** A reattached live process may print nothing; its silence is observed quiet from the first attach of this generation. */ - startReattachedOutputClock(ptyId: string): void { - const pty = this.ptysById.get(ptyId) - if (pty && pty.lastOutputAt === null) { - pty.outputObservedSince ??= Date.now() - } - } - - protected readOutputClockAt(ptyId: string | null | undefined): number | null { - const pty = ptyId ? this.ptysById.get(ptyId) : undefined - return pty ? (pty.lastOutputAt ?? pty.outputObservedSince ?? null) : null - } - // 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 diff --git a/src/main/runtime/terminal/output/terminal-output-generation.ts b/src/main/runtime/terminal/output/terminal-output-generation.ts index 053bcac09fd..10265e957b6 100644 --- a/src/main/runtime/terminal/output/terminal-output-generation.ts +++ b/src/main/runtime/terminal/output/terminal-output-generation.ts @@ -109,12 +109,8 @@ 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 main attach. - this.mainDaemonAttachments.forget(ptyId) - const pty = this.ptysById.get(ptyId) - if (pty) { - pty.outputObservedSince = null - } + // Why: a respawn under the same session id needs its own subscriber-driven attach. + this.terminalViewSubscribers.resetGeneration(ptyId) // 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 8ee828d733a..9d02262c1d2 100644 --- a/src/main/runtime/terminal/wait/terminal-idle-polls.ts +++ b/src/main/runtime/terminal/wait/terminal-idle-polls.ts @@ -18,7 +18,6 @@ import { } from '../../tui-idle-evidence' import { leafTuiIdleEvidence, - outputClockOf, ptyTuiIdleEvidence, type TuiIdleEvidenceSource } from '../../tui-idle-evidence-source' @@ -146,7 +145,7 @@ export class RuntimeTerminalIdlePolls { ready: () => buildPtyTerminalWaitResult(handle, 'tui-idle', pty), blocked: (reason) => buildPtyTerminalWaitBlockedResult(handle, 'tui-idle', pty, reason), isQuiet: (lane) => - isQuietForQuiescence(outputClockOf(this.deps, pty), this.deps.quiescenceMs, lane, () => + isQuietForQuiescence(pty.lastOutputAt, this.deps.quiescenceMs, lane, () => this.deps.hasCommandPainted(pty.ptyId) ) } @@ -166,7 +165,7 @@ export class RuntimeTerminalIdlePolls { blocked: (reason) => buildTerminalWaitBlockedResult(handle, 'tui-idle', live(), reason), isQuiet: (lane) => isQuietForQuiescence( - outputClockOf(this.deps, live()), + live().lastOutputAt, this.deps.quiescenceMs, lane, () => !leaf.ptyId || this.deps.hasCommandPainted(leaf.ptyId) diff --git a/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts b/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts index 15420ea055d..9d86333b584 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-visible-probe.ts @@ -25,7 +25,6 @@ import { buildTerminalWaitBlockedResult, buildTerminalWaitResult } from '../../terminal-wait-results' -import { outputClockOf } from '../../tui-idle-evidence-source' import { createSetupCompletionScanner } from '../../orchestration/setup-completion-signal' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -80,11 +79,10 @@ export abstract class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRu agent, () => restoreProjectedComposerDraft(projection.tail, projection.draft), // Why read now: output since the probe started makes the pane clocked. - outputClockOf( - this.tuiIdleEvidenceSource, + ( this.getLivePtyForHandle(waiter.handle)?.pty ?? - this.getLiveLeafForHandle(waiter.handle).leaf - ) !== null + this.getLiveLeafForHandle(waiter.handle).leaf + ).lastOutputAt !== null ) : isKnownReadyPromptSettled(snapshotText) if (!blockedReason && !ready) { diff --git a/src/main/runtime/terminal/wait/terminal-wait.ts b/src/main/runtime/terminal/wait/terminal-wait.ts index a0a4dfee2c2..c977fcefbf0 100644 --- a/src/main/runtime/terminal/wait/terminal-wait.ts +++ b/src/main/runtime/terminal/wait/terminal-wait.ts @@ -15,7 +15,6 @@ import { buildTerminalWaitText } from '../../terminal-wait-tail-state' import { evaluateTuiIdle, type TuiIdleVerdict } from '../../tui-idle-evidence' import { leafTuiIdleEvidence, - outputClockOf, ptyTuiIdleEvidence, type TuiIdleEvidenceSource } from '../../tui-idle-evidence-source' @@ -31,14 +30,13 @@ import type { RuntimeTerminalWaiterRegistry } from './terminal-waiter-registry' /** Probe unseen/provider-only panes and clockless screen-ruled agents with untrusted restored models; clocked panes settle through quiet polling. profile.screenProbeBanner also opts in. */ function shouldProbeVisibleScreen( paneAgent: TuiAgent | null, - record: Pick, - outputClockAt: number | null, + record: Pick, waitText: string ): boolean { return ( (record.lastAgentStatus === null && waitText.length === 0) || showsScreenProbeBanner(waitText) || - (readsTrustedScreen(paneAgent) && outputClockAt === null) + (readsTrustedScreen(paneAgent) && record.lastOutputAt === null) ) } @@ -156,14 +154,7 @@ export class RuntimeTerminalWait { } else { this.polls.startPty(waiter, live.pty, verdict) const paneAgent = this.deps.getPaneAgent(live.pty.ptyId) - if ( - shouldProbeVisibleScreen( - paneAgent, - live.pty, - outputClockOf(this.deps, live.pty), - livePtyWaitText - ) - ) { + if (shouldProbeVisibleScreen(paneAgent, live.pty, livePtyWaitText)) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } @@ -244,14 +235,7 @@ export class RuntimeTerminalWait { // Ready previews can precede idle OSC titles; keep polling both. this.polls.startLeaf(waiter, live.leaf, verdict) const paneAgent = this.deps.getPaneAgent(live.leaf.ptyId) - if ( - shouldProbeVisibleScreen( - paneAgent, - live.leaf, - outputClockOf(this.deps, live.leaf), - liveLeafWaitText - ) - ) { + if (shouldProbeVisibleScreen(paneAgent, live.leaf, liveLeafWaitText)) { this.deps.startVisibleReadProbe(waiter, effectiveTimeoutMs, paneAgent) } } diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts index a03accb6327..83b680f50e9 100644 --- a/src/main/runtime/tui-idle-evidence-source.ts +++ b/src/main/runtime/tui-idle-evidence-source.ts @@ -35,27 +35,6 @@ export type TuiIdleEvidenceSource = { readRuledScreen?(ptyId: string | null | undefined): RuledScreen | null /** When the PTY last observed a title of its own. Absent, no title has an age. */ getTitleObservedAtEpochMs?(ptyId: string | null | undefined): number | null - /** The PTY's quiet clock: its last output, else when main began receiving its silent stream. */ - getOutputClockAt?(ptyId: string | null | undefined): number | null -} - -/** One clock per terminal, read by ptyId, so a pane leaf and its PTY settle alike. */ -export function outputClockOf( - source: Pick, - record: { ptyId: string | null; lastOutputAt: number | null } -): number | null { - return source.getOutputClockAt?.(record.ptyId) ?? record.lastOutputAt -} - -function evidenceRecord( - source: TuiIdleEvidenceSource, - record: TuiIdleEvidenceRecord & { ptyId: string | null } -): TuiIdleEvidenceRecord { - return { - lastAgentStatus: record.lastAgentStatus, - lastOscTitle: record.lastOscTitle, - lastOutputAt: outputClockOf(source, record) - } } // Why per agent: every other agent keeps the live screen its rules were recorded against. @@ -126,15 +105,14 @@ export function leafTuiIdleEvidence( const waitText = lazyWaitText(readWaitText) const agent = source.getPaneAgent(leaf.ptyId) const readScreen = screenReader(source, agent, leaf.ptyId) - const record = evidenceRecord(source, leaf) return { - record, + record: leaf, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText(), agent, readScreen, record.lastOutputAt !== null), + isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, record, readScreen, waitText), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), readScreenInputVeto: screenInputVetoReader(source, agent, leaf.ptyId), titleObservedAtEpochMs: source.getTitleObservedAtEpochMs?.(leaf.ptyId) ?? null, agent, @@ -152,18 +130,17 @@ export function ptyTuiIdleEvidence( const waitText = lazyWaitText(readWaitText) const agent = source.getPaneAgent(pty.ptyId) const readScreen = screenReader(source, agent, pty.ptyId) - const record = evidenceRecord(source, pty) return { - record, + record: pty, readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), readPositiveBodyEvidence: () => (agent !== 'qoder' && agent !== 'qoder-cn' && agent !== 'hermes' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || - isKnownReadyPromptBody(waitText(), agent, readScreen, record.lastOutputAt !== null), + isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, record, readScreen, waitText), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId), titleObservedAtEpochMs: pty.lastOscTitleEpochMs, agent, diff --git a/src/main/runtime/tui-idle-output-clock.test.ts b/src/main/runtime/tui-idle-output-clock.test.ts deleted file mode 100644 index 5d887e948f5..00000000000 --- a/src/main/runtime/tui-idle-output-clock.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import './rpc/unused-default-rpc-methods.test-fixture' -import { afterEach, expect, it, vi } from 'vitest' -import { runtimeWithLeaf } from './orca-runtime-pty-leaf.test-fixture' -import { evaluateTuiIdle } from './tui-idle-evidence' -import { leafTuiIdleEvidence, ptyTuiIdleEvidence } from './tui-idle-evidence-source' - -const PTY_ID = 'repo-1::/tmp/wt@@1a2b3c4d' -const CODEX_COMPOSER = '› Ask Codex to do anything' - -afterEach(() => { - vi.useRealTimers() -}) - -it('a silent reattached Codex reads quiet on its PTY and its pane leaf alike, and stays quiet when its pane mounts', () => { - vi.useFakeTimers({ now: 100_000 }) - const { runtime, leaf } = runtimeWithLeaf(PTY_ID) - runtime.seedTerminalRestoreTail(PTY_ID, { text: CODEX_COMPOSER }) - runtime.startReattachedOutputClock(PTY_ID) - const pty = runtime['ptysById'].get(PTY_ID)! - const source = { - ...runtime['tuiIdleEvidenceSource'], - getPaneAgent: () => 'codex' as const, - readScreenLines: () => [CODEX_COMPOSER], - readRuledScreen: () => ({ lines: [CODEX_COMPOSER], alternateScreen: false }) - } - const verdicts = () => - [ - evaluateTuiIdle(ptyTuiIdleEvidence(source, pty, () => pty.preview)), - evaluateTuiIdle(leafTuiIdleEvidence(source, leaf, () => leaf.preview)) - ].map((verdict) => verdict.kind) - - expect(verdicts()).toEqual(['pending', 'pending']) - vi.advanceTimersByTime(source.quiescenceMs) - // Neither record ever saw output; the clock is main's observation since the reattach. - expect([pty.lastOutputAt, leaf.lastOutputAt]).toEqual([null, null]) - expect(verdicts()).toEqual(['ready-strong', 'ready-strong']) - // A pane mounting later reattaches the same process; its observed quiet must not restart. - runtime.startReattachedOutputClock(PTY_ID) - expect(verdicts()).toEqual(['ready-strong', 'ready-strong']) -}) diff --git a/tests/e2e/restart-unopened-workspace-terminal-attach.spec.ts b/tests/e2e/restart-unopened-workspace-terminal-attach.spec.ts deleted file mode 100644 index f90128da584..00000000000 --- a/tests/e2e/restart-unopened-workspace-terminal-attach.spec.ts +++ /dev/null @@ -1,174 +0,0 @@ -// 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 } -} - -// Scoped to one workspace: an unscoped list refuses while any other pane is still binding its PTY. -function listTerminals(call: RuntimeCall, worktreeId: string): Promise { - return call<{ terminals: RuntimeTerminalSummary[] }>('terminal.list', { - worktree: `id:${worktreeId}` - }).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, unopened)).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, unopened)).find((terminal) => terminal.ptyId === ptyId) - await expect.poll(async () => (await survivor())?.connected, { timeout: 60_000 }).toBe(true) - const expectMainReceiving = async (message: string): Promise => { - let baseline: number | null = null - await expect - .poll( - async () => { - const lastOutputAt = (await survivor())?.lastOutputAt ?? null - baseline ??= lastOutputAt - return baseline !== null && lastOutputAt !== null && lastOutputAt > baseline - }, - { timeout: 5_000, message } - ) - .toBe(true) - } - await expectMainReceiving('main never received the surviving printer output') - - // 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 ') - await expectMainReceiving('main stopped receiving after the pane mounted') - } finally { - await session.dispose(app).catch(() => undefined) - } -})