From af8e4922ade8389e997e0f4870a3c8be8840eb3c Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 22:44:01 -0700 Subject: [PATCH 01/29] feat(orchestration): wake an auto-slept pane when mail arrives for it --- pnpm-lock.yaml | 19 +++ .../orca-runtime-stop-requested-pty-ids.ts | 2 + .../runtime/orca-runtime-sync-window-graph.ts | 5 + .../orca-runtime-wait-for-leaf-pty-id.ts | 88 ++++++++++- .../orchestration/mailbox-pointer-delivery.ts | 143 ++++------------- .../orchestration/mailbox-pointer-stage.ts | 124 +++++++++++++++ .../orchestration/mailbox-pointer-submit.ts | 4 + .../mailbox-sleeping-recipient-wake.test.ts | 116 ++++++++++++++ .../sleeping-pane-wake-scheduler.test.ts | 144 ++++++++++++++++++ .../sleeping-pane-wake-scheduler.ts | 134 ++++++++++++++++ .../sleeping-pane-wake-target.test.ts | 122 +++++++++++++++ .../sleeping-pane-wake-target.ts | 62 ++++++++ .../terminal/terminal-subscribe-method.ts | 5 +- .../runtime-workspace-session-controller.ts | 20 +++ .../sleeping-pane-record-lookup.test.ts | 57 +++++++ .../runtime/sleeping-pane-record-lookup.ts | 31 ++++ src/shared/agent-session-resume.ts | 19 +++ src/shared/terminal-tab-mount-intent.ts | 18 +++ 18 files changed, 990 insertions(+), 123 deletions(-) create mode 100644 src/main/runtime/orchestration/mailbox-pointer-stage.ts create mode 100644 src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts create mode 100644 src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts create mode 100644 src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts create mode 100644 src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts create mode 100644 src/main/runtime/orchestration/sleeping-pane-wake-target.ts create mode 100644 src/main/runtime/sleeping-pane-record-lookup.test.ts create mode 100644 src/main/runtime/sleeping-pane-record-lookup.ts create mode 100644 src/shared/terminal-tab-mount-intent.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a045833577f8..4f5d3b3b3cfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ importers: .: configDependencies: {} packageManagerDependencies: + '@pnpm/exe': + specifier: 12.0.0 + version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -56,6 +59,11 @@ packages: cpu: [x64] os: [win32] + '@pnpm/exe@12.0.0': + resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} + engines: {node: '>=18.*'} + hasBin: true + pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -87,6 +95,17 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true + '@pnpm/exe@12.0.0': + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 346006cd5fd9..397d7ba1d1ec 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -201,6 +201,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId), redriveMailbox: (mailboxHandle, reservedTypes) => this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes), + requestSleepingRecipientWake: (mailboxHandle) => + this.requestSleepingRecipientWake(mailboxHandle), writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data) }) diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 7d44d4af2c12..29409ceea013 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -149,6 +149,11 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow if (!adoptedFirstPty) { this.invalidateLeafHandle(leafKey) } + } else if (!existing && ptyId) { + // Why: a slept pane's handle is minted from its resume record with no leaf + // behind it. Adopt the PTY the wake produced so the handle a sender + // addressed keeps resolving instead of being orphaned by a remint. + this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) } } diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 37ac8c5a8394..32835e78f8f7 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -1,6 +1,18 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals } from './orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { + mayBackgroundWakeSleepingAgentSession, + type SleepingAgentSessionRecord +} from '../../shared/agent-session-resume' +import { + isInboundMessageTabMount, + type TerminalTabMountIntent +} from '../../shared/terminal-tab-mount-intent' +import { makePaneKey } from '../../shared/stable-pane-id' +import { findSleepingAgentSessionRecord } from './sleeping-pane-record-lookup' +import { resolveSleepingPaneWakeTarget } from './orchestration/sleeping-pane-wake-target' +import { SleepingPaneWakeScheduler } from './orchestration/sleeping-pane-wake-scheduler' export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals { // Why: mobile may subscribe before the PTY spawns; wait for it so subscribe proceeds with phone-fit instead of a bare scrollback+end. @@ -67,21 +79,46 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP } // Why: never-mounted tabs have no PTY or snapshot; synthetic handles need the ptyId to mount the exact owning tab. - requestRendererTerminalTabMount(handle: string): boolean { + requestRendererTerminalTabMount(handle: string, intent?: TerminalTabMountIntent): boolean { const record = this.handles.get(handle) if (!record?.worktreeId) { return false } - const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId - const ptyId = record.ptyId ?? undefined - if (!tabId && !ptyId) { + return this.requestRendererTerminalTabMountForPane({ + worktreeId: record.worktreeId, + tabId: record.tabId.startsWith('pty:') ? undefined : record.tabId, + ptyId: record.ptyId ?? undefined, + paneKey: record.leafId ? makePaneKey(record.tabId, record.leafId) : undefined, + intent + }) + } + + // Why: a slept pane has no handle record left, so mail-driven wakes address the + // tab the sleeping record names instead of a handle that no longer resolves. + requestRendererTerminalTabMountForPane(args: { + worktreeId: string + tabId?: string + ptyId?: string + paneKey?: string + intent?: TerminalTabMountIntent + }): boolean { + if (!args.worktreeId || (!args.tabId && !args.ptyId)) { + return false + } + // Why: opening a tab is the documented wake gesture for a pane the user slept + // (#11598), so only an inbound message may be refused for one. + if ( + isInboundMessageTabMount(args.intent) && + args.paneKey && + !this.mayBackgroundWakeSleepingPane(args.paneKey) + ) { return false } try { this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { - worktreeId: record.worktreeId, - ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) + worktreeId: args.worktreeId, + ...(args.tabId ? { tabId: args.tabId } : {}), + ...(args.ptyId ? { ptyId: args.ptyId } : {}) }) return true } catch { @@ -90,6 +127,43 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP } } + findSleepingAgentRecordForPane(paneKey: string): SleepingAgentSessionRecord | undefined { + return findSleepingAgentSessionRecord(this.workspaceSessions.listSessions(), paneKey) + } + + protected mayBackgroundWakeSleepingPane(paneKey: string): boolean { + const record = this.findSleepingAgentRecordForPane(paneKey) + return !record || mayBackgroundWakeSleepingAgentSession(record) + } + + protected readonly sleepingPaneWakes = new SleepingPaneWakeScheduler({ + wake: (request) => + this.requestRendererTerminalTabMountForPane({ ...request, intent: 'inbound-message' }) + }) + + /** + * Mail landed for a mailbox whose pane has no process. The message arriving IS + * the evidence the pane is owed something, so every type wakes — no allowlist. + */ + requestSleepingRecipientWake(mailboxHandle: string): void { + const db = this._orchestrationDb + if (!db) { + return + } + const resolution = resolveSleepingPaneWakeTarget(mailboxHandle, { + getRunCoordinatorPaneKey: (runId) => db.getRun?.(runId)?.coordinator_pane_key ?? undefined, + getDispatchAssigneePaneKey: (dispatchId) => + db.getDispatchContextById?.(dispatchId)?.assignee_pane_key ?? + db.getRemoteDispatchAttachment?.(dispatchId)?.pane_key ?? + undefined, + getPaneKeyForHandle: (handle) => this.getTerminalPaneKey(handle), + getSleepingRecord: (paneKey) => this.findSleepingAgentRecordForPane(paneKey) + }) + if (resolution.ok) { + this.sleepingPaneWakes.request(resolution.request) + } + } + getRendererTerminalSerializerGeneration(ptyId: string): number { return this.ptyController?.getRendererSerializerGeneration?.(ptyId) ?? 0 } diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index 3efb121cc78e..934f34f9c355 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -1,19 +1,16 @@ -import { isCursorAgentTitle } from '../../../shared/agent-detection' import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type OrchestrationDb } from './db' -import { formatMessagePointer } from './formatter' import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' import { hasUnfilteredOrchestrationWaiter, messageTypeHasOrchestrationWaiter, - shouldReleaseOrchestrationPointer, type OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import { stageOrchestrationMailboxPointer } from './mailbox-pointer-stage' import { OrchestrationMailboxPointerState, type OrchestrationMailboxDeliveryFlight } from './mailbox-pointer-state' -import { submitOrchestrationMailboxPointer } from './mailbox-pointer-submit' export type { OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' @@ -29,6 +26,9 @@ type PointerDeliveryDependencies = { getTerminalHandleForLeafKey: (leafKey: string) => string | undefined isLeafPtyProvenAbsent: (ptyId: string) => Promise redriveMailbox: (mailboxHandle: string, reservedTypes?: ReadonlySet) => void + /** Ask for an auto-slept recipient to be woken. Optional so hosts that predate + * the wake path keep today's silent give-up. */ + requestSleepingRecipientWake?: (mailboxHandle: string) => void writePty: (ptyId: string, data: string) => boolean | Promise } @@ -39,6 +39,10 @@ export class OrchestrationMailboxPointerDelivery): void { const terminalHandle = this.deps.deliveryTarget.resolveTerminalHandle(handle) if (!terminalHandle) { + // No live pane owns this mailbox. The message arriving is itself the + // evidence the recipient is owed work, so ask for a wake rather than + // leaving the mail to an idle edge that a slept pane will never reach. + this.deps.requestSleepingRecipientWake?.(handle) return } try { @@ -52,6 +56,7 @@ export class OrchestrationMailboxPointerDelivery this.settle(settledPtyId, settledFlight), + redrive: (redriveMailbox, force) => this.redrive(redriveMailbox, force) + }, + { leaf, mailboxHandle, unread, newestSequence } + ) } parkRedelivery(mailboxHandle: string, reservedTypes?: ReadonlySet): void { @@ -167,116 +190,6 @@ export class OrchestrationMailboxPointerDelivery - this.finishPointerWrite( - leaf, - mailboxHandle, - unread, - newestSequence, - ptyId, - flight, - accepted - ), - () => - this.finishPointerWrite(leaf, mailboxHandle, unread, newestSequence, ptyId, flight, false) - ) - .catch(() => undefined) - } - - private finishPointerWrite( - leaf: OrchestrationMailboxLeaf, - mailboxHandle: string, - unread: readonly { id: string; type: string; sequence: number }[], - newestSequence: number, - ptyId: string, - flight: OrchestrationMailboxDeliveryFlight, - accepted: boolean - ): void { - let delayedSettle = false - try { - if (!accepted || !this.state.isCurrentFlight(ptyId, flight)) { - return - } - const db = this.deps.getDb() - if ( - !db || - shouldReleaseOrchestrationPointer( - db, - mailboxHandle, - unread, - this.deps.getMessageWaiters(mailboxHandle) - ) - ) { - return - } - flight.stagedMessageIds = unread.map((message) => message.id) - db.markAsDelivered(flight.stagedMessageIds) - this.state.setWatermark(mailboxHandle, newestSequence, ptyId, this.leafKey(leaf)) - if ( - [leaf.lastOscTitle, leaf.paneTitle, this.deps.getTabTitle(leaf.tabId)].some( - isCursorAgentTitle - ) - ) { - this.state.clearWatermark(mailboxHandle, newestSequence, ptyId) - this.redrive(mailboxHandle) - return - } - flight.enterTimer = setTimeout( - () => - submitOrchestrationMailboxPointer( - { - mailboxOwner: this.deps.mailboxOwner, - state: this.state, - getDb: this.deps.getDb, - getLeaf: this.deps.getLeaf, - getLeafKey: this.deps.getLeafKey, - getMessageWaiters: this.deps.getMessageWaiters, - isLeafPtyProvenAbsent: this.deps.isLeafPtyProvenAbsent, - writePty: this.deps.writePty, - settle: (settledPtyId, settledFlight) => this.settle(settledPtyId, settledFlight), - redrive: (redriveMailbox, force) => this.redrive(redriveMailbox, force) - }, - { leaf, mailboxHandle, messages: unread, newestSequence, ptyId, flight } - ), - 500 - ) - delayedSettle = true - } finally { - if (!delayedSettle) { - this.settle(ptyId, flight) - } - } - } - private settle(ptyId: string, flight: OrchestrationMailboxDeliveryFlight): void { const parked = this.state.settleFlight(ptyId, flight) if (!parked) { diff --git a/src/main/runtime/orchestration/mailbox-pointer-stage.ts b/src/main/runtime/orchestration/mailbox-pointer-stage.ts new file mode 100644 index 000000000000..4c2c5262e18c --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-pointer-stage.ts @@ -0,0 +1,124 @@ +import { isCursorAgentTitle } from '../../../shared/agent-detection' +import type { OrchestrationDb } from './db' +import { formatMessagePointer } from './formatter' +import { + shouldReleaseOrchestrationPointer, + type OrchestrationMessageWaiter +} from './mailbox-pointer-eligibility' +import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import type { + OrchestrationMailboxDeliveryFlight, + OrchestrationMailboxPointerState +} from './mailbox-pointer-state' +import { submitOrchestrationMailboxPointer } from './mailbox-pointer-submit' + +/** Delay between the pointer text landing in the composer and the submit keystroke. */ +const POINTER_SUBMIT_DELAY_MS = 500 + +type PointerStageDependencies = { + mailboxOwner: OrchestrationMailboxOwner + state: OrchestrationMailboxPointerState + getDb: () => OrchestrationDb | null + getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined + getLeafKey: (tabId: string, leafId: string) => string + getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined + getTabTitle: (tabId: string) => string | null | undefined + isLeafPtyProvenAbsent: (ptyId: string) => Promise + requestSleepingRecipientWake?: (mailboxHandle: string) => void + writePty: (ptyId: string, data: string) => boolean | Promise + settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void + redrive: (mailboxHandle: string, force?: boolean) => void +} + +type PointerStageInput = { + leaf: OrchestrationMailboxLeaf + mailboxHandle: string + unread: readonly { id: string; type: string; sequence: number }[] + newestSequence: number +} + +/** Write the pointer text into the recipient's composer, then arm its submit. */ +export function stageOrchestrationMailboxPointer( + deps: PointerStageDependencies, + input: PointerStageInput +): void { + const ptyId = input.leaf.ptyId + if (!ptyId) { + return + } + const flight = deps.state.beginFlight(ptyId) + const writeResult = deps.writePty( + ptyId, + formatMessagePointer(input.unread.length, input.mailboxHandle) + ) + if (typeof writeResult === 'boolean') { + finishPointerWrite(deps, input, ptyId, flight, writeResult) + return + } + void writeResult + .then( + (accepted) => finishPointerWrite(deps, input, ptyId, flight, accepted), + () => finishPointerWrite(deps, input, ptyId, flight, false) + ) + .catch(() => undefined) +} + +function finishPointerWrite( + deps: PointerStageDependencies, + input: PointerStageInput, + ptyId: string, + flight: OrchestrationMailboxDeliveryFlight, + accepted: boolean +): void { + const { leaf, mailboxHandle, unread, newestSequence } = input + let delayedSettle = false + try { + if (!accepted || !deps.state.isCurrentFlight(ptyId, flight)) { + return + } + const db = deps.getDb() + if ( + !db || + shouldReleaseOrchestrationPointer( + db, + mailboxHandle, + unread, + deps.getMessageWaiters(mailboxHandle) + ) + ) { + return + } + flight.stagedMessageIds = unread.map((message) => message.id) + db.markAsDelivered(flight.stagedMessageIds) + deps.state.setWatermark( + mailboxHandle, + newestSequence, + ptyId, + deps.getLeafKey(leaf.tabId, leaf.leafId) + ) + if ( + [leaf.lastOscTitle, leaf.paneTitle, deps.getTabTitle(leaf.tabId)].some(isCursorAgentTitle) + ) { + deps.state.clearWatermark(mailboxHandle, newestSequence, ptyId) + deps.redrive(mailboxHandle) + return + } + flight.enterTimer = setTimeout( + () => + submitOrchestrationMailboxPointer(deps, { + leaf, + mailboxHandle, + messages: unread, + newestSequence, + ptyId, + flight + }), + POINTER_SUBMIT_DELAY_MS + ) + delayedSettle = true + } finally { + if (!delayedSettle) { + deps.settle(ptyId, flight) + } + } +} diff --git a/src/main/runtime/orchestration/mailbox-pointer-submit.ts b/src/main/runtime/orchestration/mailbox-pointer-submit.ts index 9692e52dd50c..8043376c37f4 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-submit.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-submit.ts @@ -17,6 +17,7 @@ type PointerSubmitDependencies = { getLeafKey: (tabId: string, leafId: string) => string getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined isLeafPtyProvenAbsent: (ptyId: string) => Promise + requestSleepingRecipientWake?: (mailboxHandle: string) => void writePty: (ptyId: string, data: string) => boolean | Promise settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void redrive: (mailboxHandle: string, force?: boolean) => void @@ -41,7 +42,10 @@ export function submitOrchestrationMailboxPointer { if (absent) { + // The pane died between staging and submitting; the staged mail is put + // back undelivered below, so ask for a wake or it waits for a tab open. clearAndRedrive = true + deps.requestSleepingRecipientWake?.(input.mailboxHandle) return } if (!deps.state.isCurrentFlight(input.ptyId, input.flight)) { diff --git a/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts b/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts new file mode 100644 index 000000000000..da7c9ed4f117 --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts @@ -0,0 +1,116 @@ +/** + * A slept recipient's mail must ask for a wake instead of going quiet. + * + * Both give-up points are covered: the pointer push finding no live pane at all + * (the coordinator-slept-mid-run deadlock), and a pane that dies between staging + * a pointer and submitting it. + */ +import { describe, expect, it, vi } from 'vitest' +import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' +import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import { OrchestrationMailboxPointerDelivery } from './mailbox-pointer-delivery' +import { OrchestrationMailboxPointerState } from './mailbox-pointer-state' +import { submitOrchestrationMailboxPointer } from './mailbox-pointer-submit' +import type { OrchestrationDb } from './db' + +const MAILBOX = 'run:run-1' + +function liveLeaf(): OrchestrationMailboxLeaf { + return { + tabId: 'tab-1', + leafId: 'leaf-1', + ptyId: 'pty-1', + writable: true, + lastAgentStatus: 'idle', + lastAgentStatusObservedLive: true, + lastOscTitle: null + } +} + +function makeDelivery(overrides: { + resolveTerminalHandle: (handle: string) => string | null + getLiveLeafForHandle?: (handle: string) => OrchestrationMailboxLeaf +}) { + const requestSleepingRecipientWake = vi.fn() + const delivery = new OrchestrationMailboxPointerDelivery({ + mailboxOwner: { resolve: () => null } as unknown as OrchestrationMailboxOwner, + deliveryTarget: { + resolveTerminalHandle: overrides.resolveTerminalHandle + } as unknown as OrchestrationMailboxDeliveryTarget, + getDb: () => null, + getLeaf: () => undefined, + getLeafKey: (tabId, leafId) => `${tabId}:${leafId}`, + getLiveLeafForHandle: + overrides.getLiveLeafForHandle ?? + (() => { + throw new Error('no_active_terminal') + }), + getMessageWaiters: () => undefined, + getTabTitle: () => null, + getTerminalHandleForLeafKey: () => undefined, + isLeafPtyProvenAbsent: () => Promise.resolve(false), + redriveMailbox: () => undefined, + requestSleepingRecipientWake, + writePty: () => true + }) + return { delivery, requestSleepingRecipientWake } +} + +describe('mail delivery to a pane with no process', () => { + it('requests a wake when no live pane owns the mailbox', () => { + const { delivery, requestSleepingRecipientWake } = makeDelivery({ + resolveTerminalHandle: () => null + }) + delivery.deliverForHandle(MAILBOX) + expect(requestSleepingRecipientWake).toHaveBeenCalledWith(MAILBOX) + }) + + it('requests a wake when the owning handle no longer resolves to a live leaf', () => { + const { delivery, requestSleepingRecipientWake } = makeDelivery({ + resolveTerminalHandle: () => 'term_stale' + }) + delivery.deliverForHandle(MAILBOX) + expect(requestSleepingRecipientWake).toHaveBeenCalledWith(MAILBOX) + }) + + it('does not request a wake while the recipient is live', () => { + const { delivery, requestSleepingRecipientWake } = makeDelivery({ + resolveTerminalHandle: () => 'term_live', + getLiveLeafForHandle: () => liveLeaf() + }) + delivery.deliverForHandle(MAILBOX) + expect(requestSleepingRecipientWake).not.toHaveBeenCalled() + }) + + it('requests a wake when the PTY is proven absent while a pointer is in flight', async () => { + const requestSleepingRecipientWake = vi.fn() + const markAsUndelivered = vi.fn() + const state = new OrchestrationMailboxPointerState() + const flight = state.beginFlight('pty-1') + submitOrchestrationMailboxPointer( + { + mailboxOwner: { resolve: () => MAILBOX } as unknown as OrchestrationMailboxOwner, + state, + getDb: () => ({ markAsUndelivered }) as unknown as OrchestrationDb, + getLeaf: () => undefined, + getLeafKey: (tabId, leafId) => `${tabId}:${leafId}`, + getMessageWaiters: () => undefined, + isLeafPtyProvenAbsent: () => Promise.resolve(true), + requestSleepingRecipientWake, + writePty: () => true, + settle: () => undefined, + redrive: () => undefined + }, + { + leaf: liveLeaf(), + mailboxHandle: MAILBOX, + messages: [{ id: 'msg-1', type: 'worker_done' }], + newestSequence: 1, + ptyId: 'pty-1', + flight + } + ) + await vi.waitFor(() => expect(markAsUndelivered).toHaveBeenCalled()) + expect(requestSleepingRecipientWake).toHaveBeenCalledWith(MAILBOX) + }) +}) diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts new file mode 100644 index 000000000000..69c19297ec63 --- /dev/null +++ b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest' +import { + SLEEPING_PANE_WAKE_QUEUE_LIMIT, + SLEEPING_PANE_WAKE_SPACING_MS, + SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS, + SleepingPaneWakeScheduler, + type SleepingPaneWakeRequest +} from './sleeping-pane-wake-scheduler' + +type Timer = { run: () => void; delayMs: number } + +function harness(options: { wake?: (request: SleepingPaneWakeRequest) => void } = {}) { + let now = 1_000_000 + const woken: SleepingPaneWakeRequest[] = [] + const timers: Timer[] = [] + const scheduler = new SleepingPaneWakeScheduler({ + wake: (request) => { + woken.push(request) + options.wake?.(request) + }, + now: () => now, + schedule: (run, delayMs) => { + timers.push({ run, delayMs }) + return timers.length as unknown as ReturnType + }, + cancel: () => undefined + }) + return { + scheduler, + woken, + timers, + advance: (ms: number) => { + now += ms + }, + fireTimers: () => { + const pending = timers.splice(0) + for (const timer of pending) { + timer.run() + } + } + } +} + +function paneRequest(paneKey: string): SleepingPaneWakeRequest { + return { paneKey, worktreeId: 'wt-1', tabId: `tab-${paneKey}` } +} + +describe('SleepingPaneWakeScheduler', () => { + it('wakes the first request immediately', () => { + const h = harness() + expect(h.scheduler.request(paneRequest('a'))).toBe('requested') + expect(h.woken).toEqual([paneRequest('a')]) + }) + + it('suppresses a repeat wake for the same pane inside the TTL', () => { + const h = harness() + h.scheduler.request(paneRequest('a')) + h.advance(SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS - 1) + expect(h.scheduler.request(paneRequest('a'))).toBe('suppressed') + expect(h.woken).toHaveLength(1) + }) + + it('allows the pane again once the TTL lapses', () => { + const h = harness() + h.scheduler.request(paneRequest('a')) + h.advance(SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS) + expect(h.scheduler.request(paneRequest('a'))).toBe('requested') + expect(h.woken).toHaveLength(2) + }) + + it('does not retry a wake that threw', () => { + const h = harness({ + wake: () => { + throw new Error('no authoritative window') + } + }) + expect(() => h.scheduler.request(paneRequest('a'))).not.toThrow() + expect(h.scheduler.request(paneRequest('a'))).toBe('suppressed') + }) + + it('spreads a broadcast instead of waking every pane at once', () => { + const h = harness() + expect(h.scheduler.request(paneRequest('a'))).toBe('requested') + expect(h.scheduler.request(paneRequest('b'))).toBe('queued') + expect(h.scheduler.request(paneRequest('c'))).toBe('queued') + expect(h.woken.map((request) => request.paneKey)).toEqual(['a']) + + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(h.woken.map((request) => request.paneKey)).toEqual(['a', 'b']) + + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(h.woken.map((request) => request.paneKey)).toEqual(['a', 'b', 'c']) + expect(h.timers).toHaveLength(0) + }) + + it('queues each pane once', () => { + const h = harness() + h.scheduler.request(paneRequest('a')) + expect(h.scheduler.request(paneRequest('b'))).toBe('queued') + expect(h.scheduler.request(paneRequest('b'))).toBe('suppressed') + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(h.woken.map((request) => request.paneKey)).toEqual(['a', 'b']) + }) + + it('drops overflow past the queue limit rather than growing without bound', () => { + const h = harness() + h.scheduler.request(paneRequest('head')) + for (let i = 0; i < SLEEPING_PANE_WAKE_QUEUE_LIMIT; i += 1) { + expect(h.scheduler.request(paneRequest(`pane-${i}`))).toBe('queued') + } + expect(h.scheduler.request(paneRequest('overflow'))).toBe('dropped') + }) + + it('stops scheduling after dispose', () => { + const h = harness() + h.scheduler.request(paneRequest('a')) + h.scheduler.request(paneRequest('b')) + h.scheduler.dispose() + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(h.woken.map((request) => request.paneKey)).toEqual(['a']) + }) + + it('defaults to real timers when none are injected', () => { + vi.useFakeTimers() + try { + const woken: string[] = [] + const scheduler = new SleepingPaneWakeScheduler({ + wake: (request) => woken.push(request.paneKey) + }) + scheduler.request(paneRequest('a')) + scheduler.request(paneRequest('b')) + expect(woken).toEqual(['a']) + vi.advanceTimersByTime(SLEEPING_PANE_WAKE_SPACING_MS) + expect(woken).toEqual(['a', 'b']) + scheduler.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts new file mode 100644 index 000000000000..f0e4bb3a74d7 --- /dev/null +++ b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts @@ -0,0 +1,134 @@ +/** + * Paces the wakes that inbound orchestration mail asks for. + * + * Two independent bounds, both caller-side — the mount door keeps no bookkeeping + * of its own, mirroring the close-intent guard (`web-session-close-intent.ts`): + * - per-pane suppression, so a wake that fails (no window, record already gone) + * is not retried in a loop by the next message; + * - a minimum spacing between wakes, so one `@all` broadcast spreads its wakes + * instead of respawning a workspace at once. Queued wakes still happen. + */ + +/** Mirrors the PTY wait `terminal.subscribe` allows a mount it just requested. */ +export const SLEEPING_PANE_WAKE_MOUNT_SETTLE_MS = 10_000 + +/** Why derived: a wake is only observable once the pane has remounted AND its + * agent has cold-restored. Re-asking inside that window cannot learn anything + * new, so the TTL must outlast the mount wait plus the resume it triggers. */ +const WAKE_RESUME_GRACE_MS = 20_000 +export const SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS = + SLEEPING_PANE_WAKE_MOUNT_SETTLE_MS + WAKE_RESUME_GRACE_MS + +export const SLEEPING_PANE_WAKE_SPACING_MS = 1_500 + +/** Bounds a broadcast's queue; overflow stays durable mail, woken by the next arrival. */ +export const SLEEPING_PANE_WAKE_QUEUE_LIMIT = 64 + +export type SleepingPaneWakeRequest = { + paneKey: string + worktreeId: string + tabId?: string + ptyId?: string +} + +export type SleepingPaneWakeOutcome = 'requested' | 'queued' | 'suppressed' | 'dropped' + +/** Node returns a Timeout, jsdom/browser a number; neither is unref-able for sure. */ +type SleepingPaneWakeTimer = { unref?: () => void } | number + +type SleepingPaneWakeSchedulerDependencies = { + wake: (request: SleepingPaneWakeRequest) => void + now?: () => number + schedule?: (run: () => void, delayMs: number) => SleepingPaneWakeTimer + cancel?: (timer: SleepingPaneWakeTimer) => void +} + +export class SleepingPaneWakeScheduler { + private readonly requestedAtByPaneKey = new Map() + private readonly queue = new Map() + private lastWakeAt = Number.NEGATIVE_INFINITY + private drainTimer: SleepingPaneWakeTimer | null = null + + constructor(private readonly deps: SleepingPaneWakeSchedulerDependencies) {} + + request(request: SleepingPaneWakeRequest): SleepingPaneWakeOutcome { + const now = this.now() + this.pruneSuppressions(now) + if (this.requestedAtByPaneKey.has(request.paneKey) || this.queue.has(request.paneKey)) { + return 'suppressed' + } + if (now - this.lastWakeAt >= SLEEPING_PANE_WAKE_SPACING_MS && this.queue.size === 0) { + this.fire(request, now) + return 'requested' + } + if (this.queue.size >= SLEEPING_PANE_WAKE_QUEUE_LIMIT) { + return 'dropped' + } + this.queue.set(request.paneKey, request) + this.scheduleDrain(now) + return 'queued' + } + + dispose(): void { + if (this.drainTimer !== null) { + ;(this.deps.cancel ?? clearTimeout)(this.drainTimer as ReturnType) + this.drainTimer = null + } + this.queue.clear() + this.requestedAtByPaneKey.clear() + } + + private fire(request: SleepingPaneWakeRequest, now: number): void { + // Stamp before the call: a wake that throws must still be suppressed, or a + // chatty run retries it on every message. + this.requestedAtByPaneKey.set(request.paneKey, now) + this.lastWakeAt = now + try { + this.deps.wake(request) + } catch { + // The message stays durable; the tab-open path still delivers it. + } + } + + private scheduleDrain(now: number): void { + if (this.drainTimer !== null) { + return + } + const delay = Math.max(0, this.lastWakeAt + SLEEPING_PANE_WAKE_SPACING_MS - now) + const timer = (this.deps.schedule ?? setTimeout)(() => { + this.drainTimer = null + this.drain() + }, delay) + this.drainTimer = timer + if (typeof timer !== 'number') { + timer.unref?.() + } + } + + private drain(): void { + const now = this.now() + this.pruneSuppressions(now) + const next = this.queue.entries().next() + if (next.done) { + return + } + const [paneKey, request] = next.value + this.queue.delete(paneKey) + this.fire(request, now) + if (this.queue.size > 0) { + this.scheduleDrain(now) + } + } + + private pruneSuppressions(now: number): void { + for (const [paneKey, requestedAt] of this.requestedAtByPaneKey) { + if (now - requestedAt >= SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS) { + this.requestedAtByPaneKey.delete(paneKey) + } + } + } + + private now(): number { + return (this.deps.now ?? Date.now)() + } +} diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts b/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts new file mode 100644 index 000000000000..859616f63b7d --- /dev/null +++ b/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { + resolveSleepingPaneWakeTarget, + type SleepingPaneWakeLookups +} from './sleeping-pane-wake-target' + +const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111' + +function autoSleptRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep', + ...overrides + } +} + +function lookups(overrides: Partial = {}): SleepingPaneWakeLookups { + return { + getRunCoordinatorPaneKey: () => undefined, + getDispatchAssigneePaneKey: () => undefined, + getPaneKeyForHandle: () => null, + getSleepingRecord: () => undefined, + ...overrides + } +} + +describe('resolveSleepingPaneWakeTarget', () => { + it('resolves a run mailbox through its coordinator pane', () => { + const resolution = resolveSleepingPaneWakeTarget( + 'run:run-1', + lookups({ + getRunCoordinatorPaneKey: (runId) => (runId === 'run-1' ? PANE_KEY : undefined), + getSleepingRecord: () => autoSleptRecord() + }) + ) + expect(resolution).toEqual({ + ok: true, + request: { paneKey: PANE_KEY, worktreeId: 'wt-1', tabId: 'tab-1' } + }) + }) + + it('resolves a dispatch mailbox through its assignee pane', () => { + const resolution = resolveSleepingPaneWakeTarget( + 'dispatch:ctx-1', + lookups({ + getDispatchAssigneePaneKey: (id) => (id === 'ctx-1' ? PANE_KEY : undefined), + getSleepingRecord: () => autoSleptRecord() + }) + ) + expect(resolution.ok).toBe(true) + }) + + it('resolves a bare terminal mailbox through its remembered pane', () => { + const resolution = resolveSleepingPaneWakeTarget( + 'term_abc', + lookups({ + getPaneKeyForHandle: (handle) => (handle === 'term_abc' ? PANE_KEY : null), + getSleepingRecord: () => autoSleptRecord() + }) + ) + expect(resolution.ok).toBe(true) + }) + + it('refuses when no pane identity survives', () => { + expect(resolveSleepingPaneWakeTarget('run:run-1', lookups())).toEqual({ + ok: false, + reason: 'no-pane-identity' + }) + }) + + it('refuses when the pane has no sleeping record', () => { + expect( + resolveSleepingPaneWakeTarget( + 'run:run-1', + lookups({ getRunCoordinatorPaneKey: () => PANE_KEY }) + ) + ).toEqual({ ok: false, reason: 'not-slept' }) + }) + + it.each([ + ['an explicit workspace sleep', { restoreOnTabOpenOnly: true }], + ['a still-working manual sleep', { state: 'working' as const }], + ['a quit capture', { origin: 'quit' as const }], + ['a live resume anchor', { origin: 'live' as const }], + ['an interrupted turn', { interrupted: true }], + ['a fenced legacy worker', { automaticResumeBlockedBy: 'legacy-orchestration-worker' as const }] + ])('never auto-wakes %s', (_label, overrides) => { + expect( + resolveSleepingPaneWakeTarget( + 'run:run-1', + lookups({ + getRunCoordinatorPaneKey: () => PANE_KEY, + getSleepingRecord: () => autoSleptRecord(overrides) + }) + ) + ).toEqual({ ok: false, reason: 'user-slept' }) + }) + + it('omits an absent tab id rather than sending an empty one', () => { + const record = autoSleptRecord() + delete record.tabId + const resolution = resolveSleepingPaneWakeTarget( + 'run:run-1', + lookups({ getRunCoordinatorPaneKey: () => PANE_KEY, getSleepingRecord: () => record }) + ) + expect(resolution).toEqual({ + ok: true, + request: { paneKey: PANE_KEY, worktreeId: 'wt-1' } + }) + }) +}) diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-target.ts b/src/main/runtime/orchestration/sleeping-pane-wake-target.ts new file mode 100644 index 000000000000..3a2bd7a52da8 --- /dev/null +++ b/src/main/runtime/orchestration/sleeping-pane-wake-target.ts @@ -0,0 +1,62 @@ +import { + mayBackgroundWakeSleepingAgentSession, + type SleepingAgentSessionRecord +} from '../../../shared/agent-session-resume' +import type { SleepingPaneWakeRequest } from './sleeping-pane-wake-scheduler' + +export type SleepingPaneWakeLookups = { + /** Remint-stable coordinator identity for a run mailbox. */ + getRunCoordinatorPaneKey: (runId: string) => string | undefined + /** Assignee identity for a dispatch mailbox, including remote attachments. */ + getDispatchAssigneePaneKey: (dispatchId: string) => string | undefined + /** Pane identity a still-registered handle record remembers. */ + getPaneKeyForHandle: (handle: string) => string | null | undefined + getSleepingRecord: (paneKey: string) => SleepingAgentSessionRecord | undefined +} + +export type SleepingPaneWakeRefusal = 'no-pane-identity' | 'not-slept' | 'user-slept' + +export type SleepingPaneWakeResolution = + | { ok: true; request: SleepingPaneWakeRequest } + | { ok: false; reason: SleepingPaneWakeRefusal } + +function resolvePaneKey(mailboxHandle: string, lookups: SleepingPaneWakeLookups): string | null { + if (mailboxHandle.startsWith('run:')) { + return lookups.getRunCoordinatorPaneKey(mailboxHandle.slice('run:'.length)) ?? null + } + if (mailboxHandle.startsWith('dispatch:')) { + return lookups.getDispatchAssigneePaneKey(mailboxHandle.slice('dispatch:'.length)) ?? null + } + return lookups.getPaneKeyForHandle(mailboxHandle) ?? null +} + +/** + * Which pane an undeliverable mailbox belongs to, and whether inbound mail may + * wake it. Every message type resolves the same way — the arriving message IS + * the evidence the pane is owed something, so there is deliberately no type + * filter here (see the r4 plan). + */ +export function resolveSleepingPaneWakeTarget( + mailboxHandle: string, + lookups: SleepingPaneWakeLookups +): SleepingPaneWakeResolution { + const paneKey = resolvePaneKey(mailboxHandle, lookups) + if (!paneKey) { + return { ok: false, reason: 'no-pane-identity' } + } + const record = lookups.getSleepingRecord(paneKey) + if (!record) { + return { ok: false, reason: 'not-slept' } + } + if (!mayBackgroundWakeSleepingAgentSession(record)) { + return { ok: false, reason: 'user-slept' } + } + return { + ok: true, + request: { + paneKey, + worktreeId: record.worktreeId, + ...(record.tabId ? { tabId: record.tabId } : {}) + } + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts index bd16382d747e..72f6a0325a94 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-subscribe-method.ts @@ -30,7 +30,10 @@ export const TERMINAL_SUBSCRIBE_METHODS: RpcAnyMethod[] = [ } if (!leaf?.ptyId && params.client) { - rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount(params.terminal) + rendererMountRequestedBeforePty = runtime.requestRendererTerminalTabMount( + params.terminal, + 'client-subscribe' + ) try { const ptyId = await runtime.waitForLeafPtyId(params.terminal, 10_000, signal) leaf = { ptyId } diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 83f0ce80ef46..0a105813c5fd 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -109,6 +109,26 @@ export class RuntimeWorkspaceSessionController { return hostId ? (this.deps.getStore()?.getWorkspaceSession?.(hostId) ?? null) : null } + /** Every persisted host partition. Callers that key off pane identity — which + * carries no host — must search all of them, not just 'local'. */ + listSessions(): WorkspaceSessionState[] { + const store = this.deps.getStore() + if (!store) { + return [] + } + const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) + for (const repo of store.getRepos?.() ?? []) { + hostIds.add(getRepoExecutionHostId(repo)) + } + for (const hostId of store.getWorkspaceSessionHostIds?.() ?? []) { + hostIds.add(hostId) + } + return [...hostIds].flatMap((hostId) => { + const session = store.getWorkspaceSession?.(hostId) + return session ? [session] : [] + }) + } + set(worktreeId: string, session: WorkspaceSessionState): void { this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId)) } diff --git a/src/main/runtime/sleeping-pane-record-lookup.test.ts b/src/main/runtime/sleeping-pane-record-lookup.test.ts new file mode 100644 index 000000000000..94572c5f1ebe --- /dev/null +++ b/src/main/runtime/sleeping-pane-record-lookup.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { findSleepingAgentSessionRecord } from './sleeping-pane-record-lookup' + +const LEAF = '11111111-1111-4111-8111-111111111111' + +function record(paneKey: string, worktreeId: string): SleepingAgentSessionRecord { + return { + paneKey, + worktreeId, + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 1 + } +} + +function session(records: Record): WorkspaceSessionState { + return { sleepingAgentSessionsByPaneKey: records } as unknown as WorkspaceSessionState +} + +describe('findSleepingAgentSessionRecord', () => { + it('finds the record in a non-local host partition', () => { + const remote = record(`tab-9:${LEAF}`, 'wt-ssh') + expect( + findSleepingAgentSessionRecord( + [session({}), null, session({ [`tab-9:${LEAF}`]: remote })], + `tab-9:${LEAF}` + ) + ).toBe(remote) + }) + + it('matches a reminted pane key by its stable leaf id', () => { + const stored = record(`old-tab:${LEAF}`, 'wt-1') + expect( + findSleepingAgentSessionRecord([session({ [`old-tab:${LEAF}`]: stored })], `new-tab:${LEAF}`) + ).toBe(stored) + }) + + it('returns undefined when nothing matches', () => { + expect( + findSleepingAgentSessionRecord( + [session({ [`tab-1:${LEAF}`]: record(`tab-1:${LEAF}`, 'wt') })], + 'tab-2:other' + ) + ).toBeUndefined() + }) + + it('tolerates partitions with no sleeping records', () => { + expect( + findSleepingAgentSessionRecord([{} as WorkspaceSessionState, undefined], `tab-1:${LEAF}`) + ).toBeUndefined() + }) +}) diff --git a/src/main/runtime/sleeping-pane-record-lookup.ts b/src/main/runtime/sleeping-pane-record-lookup.ts new file mode 100644 index 000000000000..0214f12661b4 --- /dev/null +++ b/src/main/runtime/sleeping-pane-record-lookup.ts @@ -0,0 +1,31 @@ +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { isEquivalentPaneKey } from './orchestration/db/pane-key-match' + +/** + * The resume record for a pane whose process is gone, searched across every host + * partition. Why all of them: a pane key carries no host, and a slept pane on an + * SSH-worktree keeps its own partition (#12723), so reading only 'local' reports + * a remote slept pane as never having existed. + */ +export function findSleepingAgentSessionRecord( + sessions: Iterable, + paneKey: string +): SleepingAgentSessionRecord | undefined { + for (const session of sessions) { + const byPaneKey = session?.sleepingAgentSessionsByPaneKey + if (!byPaneKey) { + continue + } + const exact = byPaneKey[paneKey] + if (exact) { + return exact + } + for (const [candidateKey, record] of Object.entries(byPaneKey)) { + if (isEquivalentPaneKey(candidateKey, paneKey)) { + return record + } + } + } + return undefined +} diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 3e763fc20a6f..27b9ab4df280 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -73,6 +73,25 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } +/** + * Whether a background actor — inbound orchestration mail — may respawn this + * slept pane without anyone asking. + * + * Fail-closed on purpose: only an idle-agent-sleep capture (a finished turn the + * runtime parked on its own) is ours to undo. `restoreOnTabOpenOnly` marks the + * explicit user sleep (#11598); `origin` alone cannot separate the two because + * automatic hibernation writes 'worktree-sleep' as well. + */ +export function mayBackgroundWakeSleepingAgentSession(record: SleepingAgentSessionRecord): boolean { + return ( + record.origin === 'worktree-sleep' && + record.state === 'done' && + record.restoreOnTabOpenOnly !== true && + record.interrupted !== true && + record.automaticResumeBlockedBy === undefined + ) +} + const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) const PROVIDER_SESSION_ID_MAX_LENGTH = 512 diff --git a/src/shared/terminal-tab-mount-intent.ts b/src/shared/terminal-tab-mount-intent.ts new file mode 100644 index 000000000000..03e70a59bede --- /dev/null +++ b/src/shared/terminal-tab-mount-intent.ts @@ -0,0 +1,18 @@ +export const TERMINAL_TAB_MOUNT_INTENTS = ['client-subscribe', 'inbound-message'] as const + +/** + * Who asked for a terminal tab to be background-mounted: a client subscribing to + * the terminal (a person opening it), or orchestration mail arriving for a pane + * with no process. Opening a terminal is the documented way to wake a + * deliberately slept pane, so only an inbound message may be refused for one. + */ +export type TerminalTabMountIntent = (typeof TERMINAL_TAB_MOUNT_INTENTS)[number] + +/** + * Why: the field is additive on an existing request, so a caller that predates it + * sends nothing. Absent must keep today's permissive behavior or the mobile + * subscribe path silently loses its mount. + */ +export function isInboundMessageTabMount(intent: TerminalTabMountIntent | undefined): boolean { + return intent === 'inbound-message' +} From 89991fffbb187218e1ebe77cd762dc668418536f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 22:51:45 -0700 Subject: [PATCH 02/29] feat(terminals): list slept panes as resumable and let orchestration address them --- src/cli/terminal-format.ts | 2 +- .../orca-runtime-stop-requested-pty-ids.ts | 2 + .../orca-runtime-wait-for-leaf-pty-id.ts | 18 ++ ...runtime-write-orchestration-pointer-pty.ts | 54 +++++- .../resumable-slept-pane-listing.test.ts | 101 +++++++++++ .../runtime/resumable-slept-pane-listing.ts | 67 +++++++ ...hestration-recipient-routing-slept.test.ts | 96 +++++++++++ .../orchestration-recipient-routing.ts | 42 ++++- .../terminal-legacy-subscribe-snapshot.ts | 3 +- ...terminal-multiplex-subscribe-resolution.ts | 2 +- ...rminal-multiplex-pty-wait-capacity.test.ts | 10 +- .../terminal-subscribe-blank-mount.test.ts | 2 +- .../runtime-terminal-list-slept-panes.test.ts | 163 ++++++++++++++++++ src/main/runtime/runtime-terminal-list.ts | 35 +++- .../slices/agent-status-sleeping-records.ts | 9 +- src/shared/agent-session-resume.ts | 6 + src/shared/runtime-terminal-contracts.ts | 8 + 17 files changed, 600 insertions(+), 20 deletions(-) create mode 100644 src/main/runtime/resumable-slept-pane-listing.test.ts create mode 100644 src/main/runtime/resumable-slept-pane-listing.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts create mode 100644 src/main/runtime/runtime-terminal-list-slept-panes.test.ts diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index e61a2e48b761..040c6061942c 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -28,7 +28,7 @@ export function formatTerminalList( const body = result.terminals .map( (terminal) => - `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` + `${terminal.handle} ${terminal.title ?? '(untitled)'} ${terminal.connected ? 'connected' : 'disconnected'}${terminal.resumable ? ' asleep(resumable)' : ''} host=${terminal.executionHostId ?? 'unverifiable'} ${terminal.worktreePath}\n${terminal.preview ? `preview: ${terminal.preview}` : 'preview: '}` ) .join('\n\n') const visualLayout = formatTerminalVisualLayouts(result.visualLayouts) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 397d7ba1d1ec..d284aa10c621 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -124,6 +124,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId buildLeafSummary: (leaf, worktrees, livePtyIds) => this.buildTerminalSummary(leaf, worktrees, livePtyIds), buildPtySummary: (pty, worktrees) => this.buildPtyTerminalSummary(pty, worktrees), + listResumableSleptPanes: (targetWorktreeId) => this.listResumableSleptPanes(targetWorktreeId), + buildSleptPaneSummary: (pane, worktrees) => this.buildSleptPaneTerminalSummary(pane, worktrees), getSnapshots: () => this.mobileSessionTabsByWorktree, getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getTopologyRevision: (worktreeId) => this.getTerminalTopologyRevision(worktreeId), diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 32835e78f8f7..095f84c66e0e 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -2,6 +2,7 @@ import { OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals } from './orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import { + isDurableSleepingCapture, mayBackgroundWakeSleepingAgentSession, type SleepingAgentSessionRecord } from '../../shared/agent-session-resume' @@ -131,6 +132,23 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP return findSleepingAgentSessionRecord(this.workspaceSessions.listSessions(), paneKey) } + /** + * The pane behind a handle whose process is gone but whose resume record can + * bring it back, plus whether inbound mail may wake it on its own. Lets a + * sender tell "asleep, will read this later" from "gone, nothing to talk to". + */ + getResumableSleptRecipientPane(handle: string): { paneKey: string; autoWakes: boolean } | null { + if (this.getLiveTerminalPaneKey(handle)) { + return null + } + const paneKey = this.getTerminalPaneKey(handle) + const record = paneKey ? this.findSleepingAgentRecordForPane(paneKey) : undefined + if (!paneKey || !record || !isDurableSleepingCapture(record)) { + return null + } + return { paneKey, autoWakes: mayBackgroundWakeSleepingAgentSession(record) } + } + protected mayBackgroundWakeSleepingPane(paneKey: string): boolean { const record = this.findSleepingAgentRecordForPane(paneKey) return !record || mayBackgroundWakeSleepingAgentSession(record) diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 58fd52ebc6c5..b02c76f65cac 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -10,7 +10,8 @@ import { isTuiAgent } from '../../shared/tui-agent-config' import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity' import type { RuntimeTerminalSummary, RuntimeWorktreePsSummary } from '../../shared/runtime-types' import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' -import { parseRuntimeWorktreeId } from './runtime-worktree-path-identity' +import { parseRuntimeWorktreeId, runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' +import { collectResumableSleptPanes, type ResumableSleptPane } from './resumable-slept-pane-listing' import { findRuntimeWorktreeSummaryByPath } from './runtime-worktree-summary-paths' import type { ResolvedWorktree } from './runtime-worktree-path-identity' import { getLatestLeafTitle } from './runtime-worktree-status-projection' @@ -115,6 +116,57 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith return null } + protected listResumableSleptPanes(targetWorktreeId: string | null): ResumableSleptPane[] { + return collectResumableSleptPanes(this.workspaceSessions.listSessions(), { + targetWorktreeId, + matchesTargetWorktree: runtimeWorktreeIdsEqual + }) + } + + /** Why not `issueHandle` directly: minting from a synthetic leaf would clobber a + * live leaf's handle if one exists for this pane; hand back that leaf's handle. */ + protected issueResumableSleptPaneHandle(pane: ResumableSleptPane): string { + const existingLeaf = this.leaves.get(this.getLeafKey(pane.tabId, pane.leafId)) + return this.issueHandle( + existingLeaf ?? { + tabId: pane.tabId, + leafId: pane.leafId, + worktreeId: pane.worktreeId, + ptyId: null, + ptyGeneration: 0 + } + ) + } + + /** A pane whose process exited and whose resume record can bring it back. + * `connected: false` keeps its execution-host meaning; `resumable` carries the + * new fact, so nothing reading liveness changes (ssh-execution-boundary.md). */ + protected buildSleptPaneTerminalSummary( + pane: ResumableSleptPane, + worktreesById: Map + ): RuntimeTerminalSummary { + const worktree = worktreesById.get(pane.worktreeId) + return { + handle: this.issueResumableSleptPaneHandle(pane), + ptyId: null, + incarnationId: null, + orphaned: false, + worktreeId: pane.worktreeId, + worktreePath: worktree?.path ?? '', + branch: worktree?.branch ?? '', + tabId: pane.tabId, + leafId: pane.leafId, + title: pane.title ?? this.tabs.get(pane.tabId)?.title ?? null, + connected: false, + writable: false, + lastOutputAt: pane.lastOutputAt, + preview: '', + resumable: true, + agentIdentity: pane.agent, + ...this.terminalExecutionHostField(null, pane.worktreeId) + } + } + protected buildTerminalSummary( leaf: RuntimeLeafRecord, worktreesById: Map, diff --git a/src/main/runtime/resumable-slept-pane-listing.test.ts b/src/main/runtime/resumable-slept-pane-listing.test.ts new file mode 100644 index 000000000000..bf3870830fd4 --- /dev/null +++ b/src/main/runtime/resumable-slept-pane-listing.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { collectResumableSleptPanes } from './resumable-slept-pane-listing' + +const LEAF = '22222222-2222-4222-8222-222222222222' +const PANE_KEY = `tab-1:${LEAF}` + +function record(overrides: Partial = {}): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: '', + state: 'done', + capturedAt: 1, + updatedAt: 42, + terminalTitle: 'coordinator', + origin: 'worktree-sleep', + ...overrides + } +} + +function session(...records: SleepingAgentSessionRecord[]): WorkspaceSessionState { + return { + sleepingAgentSessionsByPaneKey: Object.fromEntries(records.map((r) => [r.paneKey, r])) + } as unknown as WorkspaceSessionState +} + +const anyWorktree = { + targetWorktreeId: null, + matchesTargetWorktree: (a: string, b: string) => a === b +} + +describe('collectResumableSleptPanes', () => { + it('reports a slept pane with its tab, leaf and agent', () => { + expect(collectResumableSleptPanes([session(record())], anyWorktree)).toEqual([ + { + paneKey: PANE_KEY, + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: LEAF, + title: 'coordinator', + agent: 'claude', + lastOutputAt: 42 + } + ]) + }) + + it('includes a quit capture, which is also resumable', () => { + expect( + collectResumableSleptPanes([session(record({ origin: 'quit' }))], anyWorktree) + ).toHaveLength(1) + }) + + it('excludes a live resume anchor, which describes a pane that never went away', () => { + expect(collectResumableSleptPanes([session(record({ origin: 'live' }))], anyWorktree)).toEqual( + [] + ) + }) + + it('excludes a legacy originless record', () => { + const legacy = record() + delete legacy.origin + expect(collectResumableSleptPanes([session(legacy)], anyWorktree)).toEqual([]) + }) + + it('excludes a record whose provider session cannot build a resume command', () => { + expect( + collectResumableSleptPanes( + [session(record({ providerSession: { key: 'conversation_id', id: 'x' } }))], + anyWorktree + ) + ).toEqual([]) + }) + + it('filters to the requested worktree', () => { + const other = record({ paneKey: `tab-2:${LEAF}`, tabId: 'tab-2', worktreeId: 'wt-2' }) + expect( + collectResumableSleptPanes([session(record(), other)], { + targetWorktreeId: 'wt-2', + matchesTargetWorktree: (a, b) => a === b + }).map((pane) => pane.worktreeId) + ).toEqual(['wt-2']) + }) + + it('searches every host partition', () => { + const remote = record({ paneKey: `tab-9:${LEAF}`, tabId: 'tab-9', worktreeId: 'wt-ssh' }) + expect( + collectResumableSleptPanes([session(), null, session(remote)], anyWorktree) + ).toHaveLength(1) + }) + + it('falls back to the pane key tab when the record has no tab id', () => { + const noTab = record() + delete noTab.tabId + expect(collectResumableSleptPanes([session(noTab)], anyWorktree)[0]?.tabId).toBe('tab-1') + }) +}) diff --git a/src/main/runtime/resumable-slept-pane-listing.ts b/src/main/runtime/resumable-slept-pane-listing.ts new file mode 100644 index 000000000000..49e5bca983e7 --- /dev/null +++ b/src/main/runtime/resumable-slept-pane-listing.ts @@ -0,0 +1,67 @@ +import { + getAgentResumeArgv, + isDurableSleepingCapture, + type ResumableTuiAgent +} from '../../shared/agent-session-resume' +import { parsePaneKey } from '../../shared/stable-pane-id' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +/** + * A pane whose process has exited and whose resume record can bring it back. + * + * Kept out of the execution-host liveness vocabulary on purpose: this says the + * pane is resumable, never that a process is or is not running on a host + * (docs/reference/ssh-execution-boundary.md). + */ +export type ResumableSleptPane = { + paneKey: string + worktreeId: string + tabId: string + leafId: string + title: string | null + agent: ResumableTuiAgent + lastOutputAt: number | null +} + +/** + * Slept panes across every host partition, so a sender surveying agents sees the + * sleeping ones instead of concluding nothing is there. + * + * Only durable captures qualify: an `origin: 'live'` row is the resume anchor a + * still-running pane keeps after each turn, not a pane that went away. + */ +export function collectResumableSleptPanes( + sessions: Iterable, + options: { + targetWorktreeId: string | null + matchesTargetWorktree: (worktreeId: string, targetWorktreeId: string) => boolean + } +): ResumableSleptPane[] { + const byPaneKey = new Map() + for (const session of sessions) { + for (const record of Object.values(session?.sleepingAgentSessionsByPaneKey ?? {})) { + const parsed = parsePaneKey(record.paneKey) + const tabId = record.tabId ?? parsed?.tabId + if ( + !parsed || + !tabId || + !isDurableSleepingCapture(record) || + !getAgentResumeArgv(record.agent, record.providerSession) || + (options.targetWorktreeId !== null && + !options.matchesTargetWorktree(record.worktreeId, options.targetWorktreeId)) + ) { + continue + } + byPaneKey.set(record.paneKey, { + paneKey: record.paneKey, + worktreeId: record.worktreeId, + tabId, + leafId: parsed.leafId, + title: record.terminalTitle ?? null, + agent: record.agent, + lastOutputAt: record.updatedAt + }) + } + } + return [...byPaneKey.values()] +} diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts new file mode 100644 index 000000000000..a590dc535403 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts @@ -0,0 +1,96 @@ +/** + * Addressing a slept agent must be accepted and queued, not refused with the + * same `terminal_not_found` a handle that never existed gets. + */ +import { describe, expect, it } from 'vitest' +import type { OrchestrationDb } from '../../orchestration/db' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { resolveBareOrchestrationRecipient } from './orchestration-recipient-routing' + +const HANDLE = 'term_slept' +const PANE_KEY = 'tab-1:leaf-1' + +function runtimeWith( + sleptPane: { paneKey: string; autoWakes: boolean } | null +): OrcaRuntimeService { + return { + getLiveTerminalPaneKey: () => null, + getResumableSleptRecipientPane: () => sleptPane + } as unknown as OrcaRuntimeService +} + +function dbWith(overrides: Partial = {}): OrchestrationDb { + return { + getCurrentRunForPane: () => undefined, + getActiveDispatchMailboxOwners: () => [], + getRunMailboxOwnerIdsForHandle: () => [], + ...overrides + } as unknown as OrchestrationDb +} + +describe('sending to a slept recipient', () => { + it('routes to the run its slept coordinator pane owns', () => { + const resolution = resolveBareOrchestrationRecipient({ + runtime: runtimeWith({ paneKey: PANE_KEY, autoWakes: true }), + db: dbWith({ + getCurrentRunForPane: ((paneKey: string) => + paneKey === PANE_KEY ? { id: 'run-1' } : undefined) as never + }), + handle: HANDLE + }) + expect(resolution).toMatchObject({ ok: true, to: 'run:run-1', runId: 'run-1' }) + expect(resolution.warning).toMatchObject({ code: 'recipient_asleep' }) + expect(resolution.warning?.message).toContain('will be woken') + }) + + it('tells the sender a deliberately slept pane is never woken automatically', () => { + const resolution = resolveBareOrchestrationRecipient({ + runtime: runtimeWith({ paneKey: PANE_KEY, autoWakes: false }), + db: dbWith({ getCurrentRunForPane: (() => ({ id: 'run-1' })) as never }), + handle: HANDLE + }) + expect(resolution.warning?.message).toContain('next opened') + expect(resolution.ok).toBe(true) + }) + + it('accepts a slept pane with no run as a queued terminal mailbox', () => { + const resolution = resolveBareOrchestrationRecipient({ + runtime: runtimeWith({ paneKey: PANE_KEY, autoWakes: true }), + db: dbWith(), + handle: HANDLE + }) + expect(resolution).toMatchObject({ ok: true, to: HANDLE }) + expect(resolution.warning?.code).toBe('recipient_asleep') + }) + + it('still refuses a handle that resolves to no pane at all', () => { + const resolution = resolveBareOrchestrationRecipient({ + runtime: runtimeWith(null), + db: dbWith(), + handle: 'term_gone' + }) + expect(resolution).toMatchObject({ ok: false, code: 'terminal_not_found' }) + }) + + it('leaves a live recipient unannotated', () => { + const runtime = { + getLiveTerminalPaneKey: () => PANE_KEY, + getResumableSleptRecipientPane: () => { + throw new Error('must not consult sleeping records for a live pane') + } + } as unknown as OrcaRuntimeService + const resolution = resolveBareOrchestrationRecipient({ + runtime, + db: dbWith({ getCurrentRunForPane: (() => ({ id: 'run-1' })) as never }), + handle: HANDLE + }) + expect(resolution).toEqual({ ok: true, to: 'run:run-1', runId: 'run-1' }) + }) + + it('tolerates a runtime that predates the slept-recipient lookup', () => { + const runtime = { getLiveTerminalPaneKey: () => null } as unknown as OrcaRuntimeService + expect( + resolveBareOrchestrationRecipient({ runtime, db: dbWith(), handle: HANDLE }) + ).toMatchObject({ ok: false, code: 'terminal_not_found' }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts index 6966f083e487..56e2d4d149f6 100644 --- a/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts @@ -29,6 +29,7 @@ export type SendRecipientWarning = { | 'recipient_unreachable' | 'recipient_ambiguous' | 'recipient_run_mismatch' + | 'recipient_asleep' recipient: string message: string } @@ -56,11 +57,19 @@ export function resolveBareOrchestrationRecipient(params: { legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null }): BareRecipientResolution { const { runtime, db, handle } = params - const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined + const livePaneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined + // Why: a slept pane's process is gone but its mailbox is not. Resolving through + // its resume record turns `terminal_not_found` — the same answer a handle that + // was never real gets — into an accepted, queued send. + const sleptPane = livePaneKey ? null : (runtime.getResumableSleptRecipientPane?.(handle) ?? null) + const asleep = sleptPane ? asleepWarning(handle, sleptPane.autoWakes) : undefined + const paneKey = livePaneKey ?? sleptPane?.paneKey const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined if (boundRun) { const mismatch = runMismatch(handle, boundRun.id, params.explicitRunId) - return mismatch ?? { ok: true, to: `run:${boundRun.id}`, runId: boundRun.id } + return ( + mismatch ?? withWarning({ ok: true, to: `run:${boundRun.id}`, runId: boundRun.id }, asleep) + ) } const dispatches = db.getActiveDispatchMailboxOwners(handle, paneKey) @@ -73,7 +82,10 @@ export function resolveBareOrchestrationRecipient(params: { } if (dispatch) { const mismatch = runMismatch(handle, dispatch.run_id, params.explicitRunId) - return mismatch ?? { ok: true, to: `dispatch:${dispatch.id}`, runId: dispatch.run_id } + return ( + mismatch ?? + withWarning({ ok: true, to: `dispatch:${dispatch.id}`, runId: dispatch.run_id }, asleep) + ) } const ownerRunIds = db.getRunMailboxOwnerIdsForHandle(handle, params.legacyAdoptedMailboxOwner) @@ -86,7 +98,10 @@ export function resolveBareOrchestrationRecipient(params: { } if (selectedRunId) { const mismatch = runMismatch(handle, selectedRunId, params.explicitRunId) - return mismatch ?? { ok: true, to: `run:${selectedRunId}`, runId: selectedRunId } + return ( + mismatch ?? + withWarning({ ok: true, to: `run:${selectedRunId}`, runId: selectedRunId }, asleep) + ) } if (paneKey) { @@ -94,7 +109,7 @@ export function resolveBareOrchestrationRecipient(params: { ok: true, to: handle, runId: params.senderRunId, - warning: { + warning: asleep ?? { code: 'legacy_terminal_recipient', recipient: handle, message: `${handle} is a live terminal-only mailbox. Delivery is not durable after that terminal closes; prefer run: or dispatch:.` @@ -111,6 +126,23 @@ export function resolveBareOrchestrationRecipient(params: { } } +function asleepWarning(handle: string, autoWakes: boolean): SendRecipientWarning { + return { + code: 'recipient_asleep', + recipient: handle, + message: autoWakes + ? `${handle} is asleep. The message is queued and the pane will be woken to read it.` + : `${handle} was slept deliberately and is never woken automatically. The message is queued and will be read when that tab is next opened.` + } +} + +function withWarning( + resolution: Extract, + warning: SendRecipientWarning | undefined +): BareRecipientResolution { + return warning ? { ...resolution, warning } : resolution +} + function selectDispatch( dispatches: DispatchContextRow[], explicitRunId: string | undefined diff --git a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts index 72c8939cb6c3..43f9361defa6 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-legacy-subscribe-snapshot.ts @@ -53,7 +53,8 @@ export async function publishLegacyBinaryInitialSnapshot( const mountRequested = missingHeadlessStateBeforeMobileFit && serialized?.source !== 'renderer' && - (rendererMountRequestedBeforePty || runtime.requestRendererTerminalTabMount(params.terminal)) + (rendererMountRequestedBeforePty || + runtime.requestRendererTerminalTabMount(params.terminal, 'client-subscribe')) if (missingHeadlessStateBeforeMobileFit && mountRequested) { // Why: an idle legacy PTY emits no later byte, so wait for a settle proving this remount completed before replaying its screen. const mountWaitController = new AbortController() diff --git a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts index b920cf05c501..c95d2f9c89fb 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-multiplex-subscribe-resolution.ts @@ -59,7 +59,7 @@ export function resolveMultiplexSubscribePty( } // Why: a never-mounted tab has no graph leaf to await; mounting the exact tab attaches its PTY without activating the worktree. - runtime.requestRendererTerminalTabMount(request.terminal) + runtime.requestRendererTerminalTabMount(request.terminal, 'client-subscribe') const waitController = new AbortController() const pendingControllers = pendingPtyWaitControllers.get(request.streamId) ?? new Set() pendingControllers.add(waitController) diff --git a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts index 778cd0220681..4f98c3dda6e5 100644 --- a/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts @@ -124,7 +124,10 @@ describe('terminal multiplex RPC', () => { ) sendDesktopMultiplexSubscribe(harness.handlers) await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled()) - expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith( + 'terminal-1', + 'client-subscribe' + ) expect( harness.binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) @@ -594,7 +597,10 @@ describe('terminal multiplex RPC', () => { await dispatchPromise // Widened gate: a desktop client must mount + await its late PTY, not skip // straight to the bare scrollback path the way it did under the mobile-only gate. - expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(runtime.requestRendererTerminalTabMount).toHaveBeenCalledWith( + 'terminal-1', + 'client-subscribe' + ) expect(runtime.waitForLeafPtyId).toHaveBeenCalledWith('terminal-1', 10_000, undefined) expect(messages.map((msg) => JSON.parse(msg).result?.type)).toEqual(['subscribed', 'end']) }) diff --git a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts index 3562f9cc3c49..b8c76fa2e36c 100644 --- a/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-blank-mount.test.ts @@ -115,7 +115,7 @@ describe('terminal.subscribe blank-tab background mount', () => { ) await vi.waitFor(() => - expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1') + expect(requestRendererTerminalTabMount).toHaveBeenCalledWith('terminal-1', 'client-subscribe') ) expect(callOrder).toEqual(['subscribe-data', 'request-mount']) diff --git a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts new file mode 100644 index 000000000000..5d689b748689 --- /dev/null +++ b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts @@ -0,0 +1,163 @@ +/** + * A slept pane must read as asleep-but-resumable, not as absent. + * + * `connected` keeps its execution-host meaning throughout — the process really + * did exit — so the new fact rides on a separate `resumable` marker. + */ +import { describe, expect, it } from 'vitest' +import type { RuntimeTerminalSummary } from '../../shared/runtime-types' +import type { ResumableSleptPane } from './resumable-slept-pane-listing' +import { RuntimeTerminalList } from './runtime-terminal-list' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { ResolvedWorktree } from './runtime-worktree-path-identity' + +const WORKTREE: ResolvedWorktree = { + id: 'wt-1', + path: '/tmp/wt-1', + branch: 'main' +} as unknown as ResolvedWorktree + +const SLEPT_PANE: ResumableSleptPane = { + paneKey: 'tab-slept:leaf-slept', + worktreeId: 'wt-1', + tabId: 'tab-slept', + leafId: 'leaf-slept', + title: 'coordinator', + agent: 'claude', + lastOutputAt: 42 +} + +function leaf(overrides: Partial = {}): RuntimeLeafRecord { + return { + tabId: 'tab-live', + leafId: 'leaf-live', + worktreeId: 'wt-1', + ptyId: 'pty-live', + connected: true, + ...overrides + } as unknown as RuntimeLeafRecord +} + +function pty(overrides: Partial = {}): RuntimePtyWorktreeRecord { + return { + ptyId: 'pty-live', + worktreeId: 'wt-1', + connected: true, + paneKey: 'tab-live:leaf-live', + ...overrides + } as unknown as RuntimePtyWorktreeRecord +} + +function summaryFor(source: { tabId: string; leafId: string }): RuntimeTerminalSummary { + return { + handle: `term_${source.tabId}`, + ptyId: null, + worktreeId: 'wt-1', + worktreePath: '/tmp/wt-1', + branch: 'main', + tabId: source.tabId, + leafId: source.leafId, + title: null, + connected: false, + writable: false, + lastOutputAt: null, + preview: '' + } +} + +function makeList(args: { + leaves?: RuntimeLeafRecord[] + ptys?: RuntimePtyWorktreeRecord[] + sleptPanes?: ResumableSleptPane[] +}) { + const sleptPaneCalls: (string | null)[] = [] + const list = new RuntimeTerminalList({ + getGraphEpoch: () => 1, + assertGraphEpoch: () => undefined, + getExplicitWorktreeId: () => 'wt-1', + getResolvedCache: () => null, + buildWorktreeFromId: () => WORKTREE, + resolveWorktree: () => Promise.resolve(WORKTREE), + listKnownWorktrees: () => [WORKTREE], + getWorktreeMap: () => Promise.resolve(new Map([[WORKTREE.id, WORKTREE]])), + refreshPtys: () => + Promise.resolve({ livePtyIds: ['pty-live'], allLivePtyIds: new Set(['pty-live']) } as never), + getPtys: () => args.ptys ?? [], + getLeaves: () => args.leaves ?? [], + buildLeafSummary: (source) => ({ + ...summaryFor(source), + ptyId: source.ptyId, + connected: source.ptyId !== null + }), + buildPtySummary: (source) => summaryFor({ tabId: source.tabId ?? 'pty', leafId: 'pty' }), + listResumableSleptPanes: (targetWorktreeId) => { + sleptPaneCalls.push(targetWorktreeId) + return args.sleptPanes ?? [] + }, + buildSleptPaneSummary: (pane) => ({ + ...summaryFor(pane), + handle: `term_${pane.paneKey}`, + title: pane.title, + resumable: true, + agentIdentity: pane.agent + }), + getSnapshots: () => new Map(), + getTabTitle: () => null, + getTopologyRevision: () => 1, + buildHostScope: () => ({ hostIds: [], omittedHostIds: [] }) + }) + return { list, sleptPaneCalls } +} + +describe('terminal list with slept panes', () => { + it('lists a slept pane whose tab left the renderer graph', async () => { + const { list } = makeList({ leaves: [leaf()], ptys: [pty()], sleptPanes: [SLEPT_PANE] }) + const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) + const slept = result.terminals.find((terminal) => terminal.tabId === 'tab-slept') + expect(slept).toMatchObject({ + connected: false, + resumable: true, + title: 'coordinator', + agentIdentity: 'claude' + }) + expect(result.totalCount).toBe(2) + }) + + it('keeps a PTY-less leaf visible when a resume record claims it', async () => { + const sleptLeaf = leaf({ tabId: 'tab-slept', leafId: 'leaf-slept', ptyId: null }) + const { list } = makeList({ + leaves: [leaf(), sleptLeaf], + ptys: [pty()], + sleptPanes: [SLEPT_PANE] + }) + const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) + const rows = result.terminals.filter((terminal) => terminal.tabId === 'tab-slept') + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ connected: false, resumable: true }) + }) + + it('still drops a PTY-less leaf that has no resume record', async () => { + const orphanLeaf = leaf({ tabId: 'tab-pending', leafId: 'leaf-pending', ptyId: null }) + const { list } = makeList({ leaves: [leaf(), orphanLeaf], ptys: [pty()] }) + const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) + expect(result.terminals.map((terminal) => terminal.tabId)).toEqual(['tab-live']) + }) + + it('never offers a slept pane to a liveness-required listing', async () => { + const { list, sleptPaneCalls } = makeList({ ptys: [pty()], sleptPanes: [SLEPT_PANE] }) + const result = await list.list('wt-1', 10, { + requireFreshPtyLiveness: true, + includeVisualLayouts: false + }) + expect(sleptPaneCalls).toEqual([]) + expect(result.terminals.some((terminal) => terminal.resumable)).toBe(false) + }) + + it('does not duplicate a pane that a live PTY row already covers', async () => { + const stalePane = { ...SLEPT_PANE, paneKey: 'tab-live:leaf-live' } + const { list } = makeList({ ptys: [pty()], sleptPanes: [stalePane] }) + const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) + expect(result.terminals).toHaveLength(1) + expect(result.terminals[0].resumable).toBeUndefined() + }) +}) diff --git a/src/main/runtime/runtime-terminal-list.ts b/src/main/runtime/runtime-terminal-list.ts index 3e3795541378..702f53de1d52 100644 --- a/src/main/runtime/runtime-terminal-list.ts +++ b/src/main/runtime/runtime-terminal-list.ts @@ -6,6 +6,7 @@ import type { import type { ExecutionHostId } from '../../shared/execution-host' import type { PtyControllerInventory } from './runtime-pty-controller-contract' import type { ResolvedWorktreeSnapshot } from './runtime-resolved-worktree-cache' +import type { ResumableSleptPane } from './resumable-slept-pane-listing' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { buildRuntimeTerminalVisualLayouts } from './runtime-terminal-visual-layout' import { @@ -37,6 +38,11 @@ type RuntimeTerminalListDependencies = { pty: RuntimePtyWorktreeRecord, worktrees: Map ): RuntimeTerminalSummary + listResumableSleptPanes(targetWorktreeId: string | null): readonly ResumableSleptPane[] + buildSleptPaneSummary( + pane: ResumableSleptPane, + worktrees: Map + ): RuntimeTerminalSummary getSnapshots(): ReadonlyMap getTabTitle(tabId: string): string | null getTopologyRevision(worktreeId: string): number @@ -115,6 +121,13 @@ export class RuntimeTerminalList { ) const terminals: RuntimeTerminalSummary[] = [] const leafPtyIds = new Set() + // Why: a slept pane has no PTY, so a listing that demands proven-fresh + // liveness (the hibernation sweep) must not see one as a candidate. + const sleptPanes = opts.requireFreshPtyLiveness + ? [] + : this.deps.listResumableSleptPanes(targetId) + const sleptPanesByPaneKey = new Map(sleptPanes.map((pane) => [pane.paneKey, pane])) + const listedPaneKeys = new Set() if (graphEpoch !== null) { for (const leaf of this.deps.getLeaves()) { if (targetId && leaf.worktreeId !== targetId) { @@ -123,13 +136,21 @@ export class RuntimeTerminalList { if (opts.requireFreshPtyLiveness && (!leaf.ptyId || !refreshedPtyIds?.has(leaf.ptyId))) { continue } - if (!leaf.ptyId && liveWorktreeIds.has(leaf.worktreeId)) { + const sleptPane = leaf.ptyId + ? undefined + : sleptPanesByPaneKey.get(`${leaf.tabId}:${leaf.leafId}`) + // Why: a PTY-less leaf in a worktree that still has live PTYs is normally + // a surface waiting to bind. A resume record makes it a slept pane, which + // must stay visible instead of reading as never having existed. + if (!leaf.ptyId && !sleptPane && liveWorktreeIds.has(leaf.worktreeId)) { continue } if (leaf.ptyId) { leafPtyIds.add(leaf.ptyId) } - terminals.push(this.deps.buildLeafSummary(leaf, worktreesById, provenLivePtyIds)) + listedPaneKeys.add(`${leaf.tabId}:${leaf.leafId}`) + const summary = this.deps.buildLeafSummary(leaf, worktreesById, provenLivePtyIds) + terminals.push(sleptPane ? { ...summary, resumable: true } : summary) } } for (const pty of ptys) { @@ -142,8 +163,18 @@ export class RuntimeTerminalList { if (targetId && pty.worktreeId !== targetId) { continue } + if (pty.paneKey) { + listedPaneKeys.add(pty.paneKey) + } terminals.push(this.deps.buildPtySummary(pty, worktreesById)) } + for (const pane of sleptPanes) { + // Why: a slept pane's tab is unmounted, so its leaf left the renderer graph + // entirely. Synthesize the row from the resume record persistence still holds. + if (!listedPaneKeys.has(pane.paneKey)) { + terminals.push(this.deps.buildSleptPaneSummary(pane, worktreesById)) + } + } const requestedHandles = opts.handles ? new Set(opts.handles) : null const matching = requestedHandles ? terminals.filter((terminal) => requestedHandles.has(terminal.handle)) diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index e5887ac86268..90a8b5b196b9 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -4,12 +4,15 @@ import { agentProviderSessionsEqual, getAgentResumeArgv, isResumableTuiAgent, + isDurableSleepingCapture, type SleepingAgentLaunchConfig, type SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { findTabForAgentEntry } from './agent-status-pane-key-tab-binding' +export { isDurableSleepingCapture } + export function copyLaunchConfig(config: SleepingAgentLaunchConfig): SleepingAgentLaunchConfig { return { ...(config.agentCommand ? { agentCommand: config.agentCommand } : {}), @@ -92,12 +95,6 @@ export function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): } } -// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit -// sleep or quit capture is the pane's only resume handle once its live row is gone. -export function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { - return record.origin === 'worktree-sleep' || record.origin === 'quit' -} - // Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead // turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the // user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 27b9ab4df280..06fb9f06834b 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -73,6 +73,12 @@ export type SleepingAgentSessionRecord = { restoreOnTabOpenOnly?: boolean } +// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit +// sleep or quit capture is the pane's only resume handle once its live row is gone. +export function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { + return record.origin === 'worktree-sleep' || record.origin === 'quit' +} + /** * Whether a background actor — inbound orchestration mail — may respawn this * slept pane without anyone asking. diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index a75a2256bdb5..3ae5dd2721b8 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -34,6 +34,14 @@ export type RuntimeTerminalSummary = { exitCause?: TerminalExitCause /** Absent when the host predates the field or could not name the execution host. */ executionHostId?: ExecutionHostId + /** + * The pane's process is gone but a sleeping-agent resume record can bring it + * back. Deliberately NOT part of the execution-host liveness vocabulary + * (`live`/`unverifiable`/`exited`, see docs/reference/ssh-execution-boundary.md): + * `connected: false` already states the process exited, and this says only that + * the pane is resumable. Absent from hosts that predate the field. + */ + resumable?: boolean } export type RuntimeTerminalVisualTerminalNode = { From 1b45ebf7435d78219f8e0a68d05261f39e4e89ed Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 23:11:56 -0700 Subject: [PATCH 03/29] fix: guard synthetic handles, keep session listing off the repo inventory --- .../orca-runtime-wait-for-leaf-pty-id.ts | 10 +- .../runtime-terminal-list-slept-panes.test.ts | 8 +- .../runtime-workspace-session-controller.ts | 17 +-- .../lib/agent-hibernation-pane-eligibility.ts | 8 +- ...ibernation-planner-supervised-runs.test.ts | 120 ++++++++++++++++++ .../src/lib/agent-hibernation-planner.ts | 5 + .../agent-hibernation-supervised-runs.test.ts | 61 +++++++++ .../lib/agent-hibernation-supervised-runs.ts | 41 ++++++ .../pane-agent-identity-inventory.test.ts | 2 +- 9 files changed, 258 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts create mode 100644 src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts create mode 100644 src/renderer/src/lib/agent-hibernation-supervised-runs.ts diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 095f84c66e0e..97bb3955e04a 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -10,7 +10,7 @@ import { isInboundMessageTabMount, type TerminalTabMountIntent } from '../../shared/terminal-tab-mount-intent' -import { makePaneKey } from '../../shared/stable-pane-id' +import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { findSleepingAgentSessionRecord } from './sleeping-pane-record-lookup' import { resolveSleepingPaneWakeTarget } from './orchestration/sleeping-pane-wake-target' import { SleepingPaneWakeScheduler } from './orchestration/sleeping-pane-wake-scheduler' @@ -85,11 +85,15 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP if (!record?.worktreeId) { return false } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId return this.requestRendererTerminalTabMountForPane({ worktreeId: record.worktreeId, - tabId: record.tabId.startsWith('pty:') ? undefined : record.tabId, + tabId, ptyId: record.ptyId ?? undefined, - paneKey: record.leafId ? makePaneKey(record.tabId, record.leafId) : undefined, + // Why: synthetic pty-form handles carry no real pane identity, and + // makePaneKey rejects their ids outright. + paneKey: + tabId && isTerminalLeafId(record.leafId) ? makePaneKey(tabId, record.leafId) : undefined, intent }) } diff --git a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts index 5d689b748689..f7d8f32be634 100644 --- a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts +++ b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts @@ -133,7 +133,13 @@ describe('terminal list with slept panes', () => { const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) const rows = result.terminals.filter((terminal) => terminal.tabId === 'tab-slept') expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ connected: false, resumable: true }) + // The leaf row, not the synthesized one: it carries the pane's real title, + // preview and last-output time that persistence alone cannot supply. + expect(rows[0]).toMatchObject({ + handle: 'term_tab-slept', + connected: false, + resumable: true + }) }) it('still drops a PTY-less leaf that has no resume record', async () => { diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 0a105813c5fd..0d4a7826defc 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -110,19 +110,20 @@ export class RuntimeWorkspaceSessionController { } /** Every persisted host partition. Callers that key off pane identity — which - * carries no host — must search all of them, not just 'local'. */ + * carries no host — must search all of them, not just 'local'. + * + * Why persisted ids only, not the repo list: reading repos would drag the + * worktree inventory into hot poll paths that must never resolve it (#9343), + * and a host with no persisted partition has nothing to find anyway. */ listSessions(): WorkspaceSessionState[] { const store = this.deps.getStore() if (!store) { return [] } - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const repo of store.getRepos?.() ?? []) { - hostIds.add(getRepoExecutionHostId(repo)) - } - for (const hostId of store.getWorkspaceSessionHostIds?.() ?? []) { - hostIds.add(hostId) - } + const hostIds = new Set([ + LOCAL_EXECUTION_HOST_ID, + ...(store.getWorkspaceSessionHostIds?.() ?? []) + ]) return [...hostIds].flatMap((hostId) => { const session = store.getWorkspaceSession?.(hostId) return session ? [session] : [] diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 839209ea809e..c99cdb6a8e37 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -9,6 +9,7 @@ import { isLiveResumeAnchorForCompletedAgent } from './live-resume-anchor-record' import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' +import { isSupervisingUnsettledDispatch } from './agent-hibernation-supervised-runs' export type EligiblePane = { paneKey: string @@ -66,6 +67,7 @@ export function getEligiblePane(args: { ptyBindingFirstSeenAtByPaneKey: Record boundaryResolvedAtByPaneKey: Record mobileLockedPtyIds: Set + supervisorLeafIdsWithUnsettledDispatch: ReadonlySet now: number idleMs: number }): EligiblePane | null { @@ -79,7 +81,8 @@ export function getEligiblePane(args: { foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey, boundaryResolvedAtByPaneKey, - mobileLockedPtyIds + mobileLockedPtyIds, + supervisorLeafIdsWithUnsettledDispatch } = args const sleepingRecord = sleepingAgentSessionsByPaneKey[entry.paneKey] // Why: a completed turn leaves the TUI alive and resumable, so every resumable @@ -95,6 +98,9 @@ export function getEligiblePane(args: { entry.interrupted === true || Boolean(entry.subagents?.length) || hasUnsettledOrUnknownDispatch(entry) || + // Why: this pane supervises a worker that has not reported back. Sleeping it + // is what the wake path exists to recover from; don't create the case. + isSupervisingUnsettledDispatch(entry.paneKey, supervisorLeafIdsWithUnsettledDispatch) || (sleepingRecord && !hasOnlyLiveResumeAnchor) || // Why: a fenced worker must never be auto-relaunched; killing it would also // erase the fence, since the capture does not copy it. diff --git a/src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts b/src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts new file mode 100644 index 000000000000..d58816c3c629 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts @@ -0,0 +1,120 @@ +/** + * The cheap half of the obligation guard: never sleep a pane whose dispatched + * worker has not reported back. Belt-and-braces — correctness rests on the + * wake-on-message path, so a gap here is no longer a silent deadlock. + */ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import { + DEFAULT_AGENT_HIBERNATION_IDLE_MS, + planAgentHibernationCandidates, + type AgentHibernationPlannerSnapshot +} from './agent-hibernation-planner' + +const NOW = 2_000_000 +const OLD = NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1 +const LEAF = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' + +function tab(id = 'tab-1', worktreeId = 'wt-bg'): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function layout(leafId = LEAF, ptyId = 'pty-1'): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function entry(overrides: Partial = {}): AgentStatusEntry { + const paneKey = overrides.paneKey ?? `tab-1:${LEAF}` + return { + state: 'done', + prompt: 'make it so', + updatedAt: OLD, + stateStartedAt: OLD, + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-bg', + agentType: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + stateHistory: [], + ...overrides + } +} + +function snapshotWith( + agentStatusByPaneKey: Record +): AgentHibernationPlannerSnapshot { + return { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS + }, + activeWorktreeId: 'wt-active', + foregroundTerminalTabIds: [], + tabsByWorktree: { 'wt-bg': [tab()] }, + terminalLayoutsByTabId: { 'tab-1': layout() }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + mobileLockedPtyIds: [], + agentStatusByPaneKey, + sleepingAgentSessionsByPaneKey: {}, + lastTerminalInputAtByPaneKey: {}, + foregroundTerminalLastSeenAtByTabId: {}, + now: NOW + } +} + +function workerOf( + coordinator: AgentStatusEntry, + dispatchStatus: 'dispatched' | 'completed' +): AgentStatusEntry { + return { + ...entry({ paneKey: `tab-2:${OTHER_LEAF}`, tabId: 'tab-2' }), + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus, + parentPaneKey: coordinator.paneKey + } + } +} + +function plannedPaneKeys(input: AgentHibernationPlannerSnapshot): string[] { + return planAgentHibernationCandidates(input).map((candidate) => candidate.paneKey) +} + +describe('agent sleep planner and supervised runs', () => { + it('refuses to sleep a coordinator whose dispatched worker has not reported back', () => { + const coordinator = entry() + const worker = workerOf(coordinator, 'dispatched') + const input = snapshotWith({ + [coordinator.paneKey]: coordinator, + [worker.paneKey]: worker + }) + expect(plannedPaneKeys(input)).toEqual([]) + }) + + it('sleeps the same coordinator once its worker settles', () => { + const coordinator = entry() + const worker = workerOf(coordinator, 'completed') + const input = snapshotWith({ + [coordinator.paneKey]: coordinator, + [worker.paneKey]: worker + }) + expect(plannedPaneKeys(input)).toEqual([coordinator.paneKey]) + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 410fb393043a..a8b7e37f82c1 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -1,5 +1,6 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { getSupervisorLeafIdsWithUnsettledDispatch } from './agent-hibernation-supervised-runs' import { getEligiblePane, getEntryTabId, @@ -110,6 +111,9 @@ export function planAgentHibernationCandidates( snapshot.runtimeLivenessRequiredWorktreeIds ?? [] ) const agentEntriesByTabId = getAgentEntriesByTabId(snapshot.agentStatusByPaneKey) + const supervisorLeafIdsWithUnsettledDispatch = getSupervisorLeafIdsWithUnsettledDispatch( + snapshot.agentStatusByPaneKey + ) const candidates: AgentHibernationCandidate[] = [] for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) { // Why: the tab on screen is `foregroundTerminalTabIds` below, and a tab just left is held by @@ -151,6 +155,7 @@ export function planAgentHibernationCandidates( ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, boundaryResolvedAtByPaneKey: snapshot.boundaryResolvedAtByPaneKey ?? {}, mobileLockedPtyIds, + supervisorLeafIdsWithUnsettledDispatch, now: snapshot.now, idleMs }) diff --git a/src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts b/src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts new file mode 100644 index 000000000000..cbf153bcf7c0 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { + getSupervisorLeafIdsWithUnsettledDispatch, + isSupervisingUnsettledDispatch +} from './agent-hibernation-supervised-runs' + +const COORDINATOR_LEAF = '33333333-3333-4333-8333-333333333333' +const COORDINATOR_PANE = `tab-coord:${COORDINATOR_LEAF}` + +function worker( + dispatchStatus: AgentStatusEntry['orchestration'] extends infer T + ? T extends { dispatchStatus?: infer S } + ? S + : never + : never, + parentPaneKey: string | null = COORDINATOR_PANE +): AgentStatusEntry { + return { + paneKey: 'tab-worker:leaf-worker', + state: 'working', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus, + ...(parentPaneKey ? { parentPaneKey } : {}) + } + } as unknown as AgentStatusEntry +} + +describe('supervisors with unsettled dispatches', () => { + it.each(['pending', 'dispatched'] as const)('collects a supervisor of a %s worker', (status) => { + const supervisors = getSupervisorLeafIdsWithUnsettledDispatch({ w: worker(status) }) + expect(isSupervisingUnsettledDispatch(COORDINATOR_PANE, supervisors)).toBe(true) + }) + + it.each(['completed', 'failed', 'circuit_broken'] as const)('ignores a %s worker', (status) => { + const supervisors = getSupervisorLeafIdsWithUnsettledDispatch({ w: worker(status) }) + expect(isSupervisingUnsettledDispatch(COORDINATOR_PANE, supervisors)).toBe(false) + }) + + it('matches a supervisor whose tab was reminted', () => { + const supervisors = getSupervisorLeafIdsWithUnsettledDispatch({ w: worker('dispatched') }) + expect(isSupervisingUnsettledDispatch(`tab-new:${COORDINATOR_LEAF}`, supervisors)).toBe(true) + }) + + it('ignores a worker whose supervisor pane is unknown', () => { + expect(getSupervisorLeafIdsWithUnsettledDispatch({ w: worker('dispatched', null) }).size).toBe( + 0 + ) + }) + + it('ignores entries with no orchestration context', () => { + expect( + getSupervisorLeafIdsWithUnsettledDispatch({ + a: { paneKey: 'tab:leaf', state: 'done' } as unknown as AgentStatusEntry, + b: undefined + }).size + ).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-supervised-runs.ts b/src/renderer/src/lib/agent-hibernation-supervised-runs.ts new file mode 100644 index 000000000000..3f7ea6183206 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-supervised-runs.ts @@ -0,0 +1,41 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +const UNSETTLED_DISPATCH_STATUSES = new Set(['pending', 'dispatched']) + +/** + * Panes supervising a worker whose Dispatch has not settled. + * + * Belt-and-braces only: the wake path is what makes a slept supervisor safe, so + * an obligation this misses is no longer a silent deadlock. It avoids the common + * case ever needing a wake, and it reads the supervisor identity each worker + * already publishes rather than enumerating obligation kinds. + * + * Keyed by stable leaf id: a pane key's tab half is reminted on break-out. + */ +export function getSupervisorLeafIdsWithUnsettledDispatch( + agentStatusByPaneKey: Record +): Set { + const leafIds = new Set() + for (const entry of Object.values(agentStatusByPaneKey)) { + const orchestration = entry?.orchestration + if (!orchestration || !UNSETTLED_DISPATCH_STATUSES.has(orchestration.dispatchStatus ?? '')) { + continue + } + const supervisorLeafId = orchestration.parentPaneKey + ? parsePaneKey(orchestration.parentPaneKey)?.leafId + : undefined + if (supervisorLeafId) { + leafIds.add(supervisorLeafId) + } + } + return leafIds +} + +export function isSupervisingUnsettledDispatch( + paneKey: string, + supervisorLeafIds: ReadonlySet +): boolean { + const leafId = parsePaneKey(paneKey)?.leafId + return leafId !== undefined && supervisorLeafIds.has(leafId) +} diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index ee868bfcc160..cd7c914ce09d 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -402,7 +402,7 @@ const DIRECT_SINGLE_SOURCE_SURFACES: readonly { marker: 'resolveLeafCloseCopyKind' }, { - path: 'src/main/runtime/orchestration/mailbox-pointer-delivery.ts', + path: 'src/main/runtime/orchestration/mailbox-pointer-stage.ts', classification: 'action-consumer', marker: 'isCursorAgentTitle' }, From 24d2ea911492fc954f69dc1dbf5c2a71a9094737 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 23:13:00 -0700 Subject: [PATCH 04/29] chore: drop the unrelated pnpm-lock churn a local toolchain run introduced --- pnpm-lock.yaml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f5d3b3b3cfd..a045833577f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ importers: .: configDependencies: {} packageManagerDependencies: - '@pnpm/exe': - specifier: 12.0.0 - version: 12.0.0 pnpm: specifier: 12.0.0 version: 12.0.0 @@ -59,11 +56,6 @@ packages: cpu: [x64] os: [win32] - '@pnpm/exe@12.0.0': - resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} - engines: {node: '>=18.*'} - hasBin: true - pnpm@12.0.0: resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} engines: {node: '>=18.*'} @@ -95,17 +87,6 @@ snapshots: '@pnpm/exe.win32-x64@12.0.0': optional: true - '@pnpm/exe@12.0.0': - optionalDependencies: - '@pnpm/exe.darwin-arm64': 12.0.0 - '@pnpm/exe.darwin-x64': 12.0.0 - '@pnpm/exe.linux-arm64': 12.0.0 - '@pnpm/exe.linux-arm64-musl': 12.0.0 - '@pnpm/exe.linux-x64': 12.0.0 - '@pnpm/exe.linux-x64-musl': 12.0.0 - '@pnpm/exe.win32-arm64': 12.0.0 - '@pnpm/exe.win32-x64': 12.0.0 - pnpm@12.0.0: optionalDependencies: '@pnpm/exe.darwin-arm64': 12.0.0 From 11feb016a57423d3944fe69d6479d36ee603c21a Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 13:33:11 -0700 Subject: [PATCH 05/29] fix(orchestration): wake a slept pane whose handle still resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listing slept panes as resumable re-registers their terminal handle, so `resolveTerminalHandle` now answers for a pane with no process and `getLiveLeafForHandle` returns its PTY-less leaf instead of throwing. Both wake branches were therefore skipped, and delivery fell to the idle-status check — which reads the same for a slept pane as for a busy one — and returned silently. The send promised a wake ("recipient_asleep") that never came, for bare `term_` handles and for `run:` mailboxes alike, since a run resolves through its coordinator handle into that same exit. Treat "no PTY" as wake evidence before the status check: no idle edge is ever coming from a pane with no process. A live-but-busy pane still waits for its idle edge, and `resolveSleepingPaneWakeTarget` remains the wake authority, so a `restoreOnTabOpenOnly` pane is still never auto-woken. The unit tests each half already had could not catch this: listing knows nothing about delivery, and the delivery test hands its dependency a handle that does not resolve. The new runtime-level test exercises both halves together — list the slept pane, address the handle the listing minted, and assert the wake IPC, then the delivery once the pane is back and idle. --- .../orchestration/mailbox-pointer-delivery.ts | 8 + .../mailbox-sleeping-recipient-wake.test.ts | 32 ++- src/main/runtime/slept-pane-mail-wake.test.ts | 216 ++++++++++++++++++ 3 files changed, 253 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/slept-pane-mail-wake.test.ts diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index 934f34f9c355..d983e64da13d 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -47,6 +47,14 @@ export class OrchestrationMailboxPointerDelivery { expect(requestSleepingRecipientWake).toHaveBeenCalledWith(MAILBOX) }) + it('requests a wake when the owning handle resolves to a leaf with no process', () => { + // The exit that a listable slept pane takes: the handle still resolves and + // the leaf still exists, so neither give-up branch above fires. + const { delivery, requestSleepingRecipientWake } = makeDelivery({ + resolveTerminalHandle: () => 'term_slept', + getLiveLeafForHandle: () => ({ ...liveLeaf(), ptyId: null, writable: false }) + }) + delivery.deliverForHandle(MAILBOX) + expect(requestSleepingRecipientWake).toHaveBeenCalledWith(MAILBOX) + }) + + it('does not request a wake for a live pane that is merely busy', () => { + const { delivery, requestSleepingRecipientWake } = makeDelivery({ + resolveTerminalHandle: () => 'term_busy', + getLiveLeafForHandle: () => ({ + ...liveLeaf(), + lastAgentStatus: 'working', + lastAgentStatusObservedLive: true + }) + }) + delivery.deliverForHandle(MAILBOX) + expect(requestSleepingRecipientWake).not.toHaveBeenCalled() + }) + it('does not request a wake while the recipient is live', () => { const { delivery, requestSleepingRecipientWake } = makeDelivery({ resolveTerminalHandle: () => 'term_live', diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts new file mode 100644 index 000000000000..bdc64d392dd0 --- /dev/null +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + InMemoryOrchestrationMessages, + TEST_WORKTREE_ID, + makeRuntimeStoreWithWorkspaceSession, + setInMemoryOrchestrationMessages +} from './orca-runtime-test-fixtures.spec' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' + +// The two halves of agent sleep-wake, exercised TOGETHER: a slept pane that is +// listable and addressable by handle must still wake when mail arrives for it. +// Each half passes in isolation — listing knows nothing about delivery, and the +// delivery unit test hands its dependency a handle that does not resolve — so +// only a runtime-level case can catch a listable pane taking a silent exit. +// +// Lives in a `.test.ts`: config/vitest.config.ts includes only `*.test.ts`, so a +// case placed in orca-runtime-tests/*.spec.ts would never run in CI. + +const TAB_ID = 'tab-slept' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` + +function sleepingRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: PANE_KEY, + tabId: TAB_ID, + worktreeId: TEST_WORKTREE_ID, + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: 'coordinate the fleet', + state: 'done', + capturedAt: 1, + updatedAt: 2, + terminalTitle: 'coordinator', + origin: 'worktree-sleep', + ...overrides + } +} + +function sleptSession(record: SleepingAgentSessionRecord): WorkspaceSessionState { + return { + ...getDefaultWorkspaceSession(), + activeWorktreeId: TEST_WORKTREE_ID, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } +} + +/** A pane whose agent auto-slept: the leaf survives in the renderer graph with no + * PTY, and persistence holds its resume record. */ +async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ + runtime: InstanceType + db: InMemoryOrchestrationMessages + handle: string + resumable: boolean | undefined + connected: boolean + tabMountSends: unknown[][] + write: ReturnType + remountWithPty: (ptyId: string) => void +}> { + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(sleptSession(record)) + const runtime = new OrcaRuntimeService(runtimeStore as never) + const db = new InMemoryOrchestrationMessages() + setInMemoryOrchestrationMessages(runtime, db) + const write = vi.fn().mockReturnValue(true) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + } as never) + runtime.attachWindow(1) + const syncGraph = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: TAB_ID, + worktreeId: TEST_WORKTREE_ID, + title: 'coordinator', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: TAB_ID, + worktreeId: TEST_WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId, + paneTitle: null + } + ] + } as never) + } + syncGraph(null) + + const tabMountSends: unknown[][] = [] + vi.spyOn( + runtime as unknown as { getAuthoritativeWindow: () => unknown }, + 'getAuthoritativeWindow' + ).mockReturnValue({ + webContents: { + send: (...args: unknown[]) => { + if (args[0] === 'terminal:requestTabMount') { + tabMountSends.push(args) + } + } + } + }) + + // The sender's own view of the pane: this is where the handle comes from, and + // listing it is what registers the handle the send then addresses. + const { terminals } = await runtime.listTerminals() + const row = terminals.find((terminal) => terminal.tabId === TAB_ID) + expect(row, 'the slept pane must be listed at all').toBeDefined() + return { + runtime, + db, + handle: row!.handle, + resumable: row!.resumable, + connected: row!.connected, + tabMountSends, + write, + // What the renderer does with `terminal:requestTabMount`: the tab remounts and + // its agent cold-restores into a fresh PTY that then reports a finished turn. + remountWithPty: (ptyId: string) => { + syncGraph(ptyId) + runtime.onPtyData(ptyId, '\x1b]0;Claude working\x07', 100) + runtime.onPtyData(ptyId, '\x1b]0;Claude done\x07', 101) + } + } +} + +describe('mail addressed to a listed slept pane', () => { + it('wakes the pane the listing just made addressable', async () => { + const { runtime, db, handle, resumable, connected, tabMountSends } = + await sleptPaneRuntime(sleepingRecord()) + // Part 1: the pane is addressable — asleep, not absent. + expect({ connected, resumable }).toEqual({ connected: false, resumable: true }) + + db.insertMessage({ from: 'term_sender', to: handle, subject: 'wake up', type: 'status' }) + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + await Promise.resolve() + + // Part 3: the send promised a wake, so one must actually be asked for. + expect(tabMountSends).toHaveLength(1) + expect(tabMountSends[0]?.[1]).toMatchObject({ + worktreeId: TEST_WORKTREE_ID, + tabId: TAB_ID + }) + db.close() + }) + + it('still refuses to wake a pane the user slept deliberately', async () => { + const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime( + sleepingRecord({ restoreOnTabOpenOnly: true }) + ) + db.insertMessage({ from: 'term_sender', to: handle, subject: 'stay asleep', type: 'status' }) + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + await Promise.resolve() + + expect(tabMountSends).toEqual([]) + db.close() + }) + + it('wakes a slept coordinator addressed through its run mailbox', async () => { + const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + + expect(tabMountSends).toHaveLength(1) + expect(tabMountSends[0]?.[1]).toMatchObject({ tabId: TAB_ID }) + db.close() + }) + + it('delivers the queued run mail once the woken pane is back and idle', async () => { + const { runtime, db, handle, tabMountSends, write, remountWithPty } = + await sleptPaneRuntime(sleepingRecord()) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + expect(tabMountSends).toHaveLength(1) + + // The wake is only half the promise: the mail must actually reach the agent. + remountWithPty('pty-woken') + await Promise.resolve() + + expect(write).toHaveBeenCalledWith( + 'pty-woken', + expect.stringContaining('You have 1 orchestration message') + ) + expect(message.delivered_at).toEqual(expect.any(String)) + db.close() + }) +}) From 148b09ac58336ab64a1092a65eb43711f1679775 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 15:45:44 -0700 Subject: [PATCH 06/29] fix(orchestration): wake a slept pane whose tab is still mounted Mail arriving for an agent-slept pane resolved a wake and sent terminal:requestTabMount, but a pane agent sleep kills in a running app still has its tab mounted, so the renderer refused the mount by design and nothing happened. The mounted case needs the in-place armed cold restore instead. That path was itself unreachable: its listener subscribed to 'orca:wake-hibernated-agents-worktree' while the dispatcher fired 'orca-wake-hibernated-agents-worktree' (286c21005b0), so the event has never been received on any build. The listener now uses the shared constant and lives in its own module. Also gate the wake on unread mail. Kill-time delivery sweeps for an empty mailbox otherwise woke the pane Orca had just put to sleep - observed live at 21:39:35 slept, 21:39:37 self-woken - which made agent sleep self-defeating as soon as waking started working. Verified end to end in a live instance with a real agent: the pane sleeps and stays asleep while idle, a send wakes it, and the agent cold restores with its prior turn intact. --- .../orca-runtime-test-fixtures.spec.ts | 10 +++ .../orca-runtime-wait-for-leaf-pty-id.ts | 12 +++ src/main/runtime/slept-pane-mail-wake.test.ts | 14 ++++ .../use-terminal-pane-lifecycle.ts | 34 +++----- .../wake-hibernated-agents-listener.test.ts | 83 +++++++++++++++++++ .../wake-hibernated-agents-listener.ts | 50 +++++++++++ src/renderer/src/constants/terminal.ts | 3 + .../terminal-presentation-ipc-bridge.ts | 13 ++- .../src/lib/mobile-terminal-tab-mount.test.ts | 40 ++++++++- .../src/lib/mobile-terminal-tab-mount.ts | 31 +++++-- ...wake-sleeping-agents-in-background.test.ts | 56 ++++++++++++- .../lib/wake-sleeping-agents-in-background.ts | 33 +++++++- 12 files changed, 342 insertions(+), 37 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts create mode 100644 src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts diff --git a/src/main/runtime/orca-runtime-test-fixtures.spec.ts b/src/main/runtime/orca-runtime-test-fixtures.spec.ts index 9bd1726b038e..a4643c38d09f 100644 --- a/src/main/runtime/orca-runtime-test-fixtures.spec.ts +++ b/src/main/runtime/orca-runtime-test-fixtures.spec.ts @@ -283,6 +283,16 @@ class InMemoryOrchestrationMessages { ] } + getUnreadDirectMessageTypes(toHandle: string): MessageType[] { + return [ + ...new Set( + this.messages + .filter((message) => message.to_handle === toHandle && message.read === 0) + .map((message) => message.type) + ) + ].sort() + } + setActiveCoordinatorRun(run: { coordinator_handle: string } | null): void { this.activeCoordinatorRun = run } diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 97bb3955e04a..ddad7a9a5cc1 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -172,6 +172,18 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP if (!db) { return } + // Why the mail check: delivery also runs from sweeps that visit empty + // mailboxes — pty retirement redrives and restored-mailbox repoints fire + // right when hibernation kills the pane. Without proof someone is owed + // mail, the kill itself would schedule the wake that undoes it (observed + // live: pane slept and self-woke two seconds later). Unread is the + // evidence; delivered-but-unread still counts because a pointer staged to + // a dying pane is exactly the mail a wake must rescue. An answerless query + // wakes anyway: "could not look" must not read as "mailbox empty". + const unreadTypes = db.getUnreadDirectMessageTypes?.(mailboxHandle) + if (unreadTypes !== undefined && unreadTypes.length === 0) { + return + } const resolution = resolveSleepingPaneWakeTarget(mailboxHandle, { getRunCoordinatorPaneKey: (runId) => db.getRun?.(runId)?.coordinator_pane_key ?? undefined, getDispatchAssigneePaneKey: (dispatchId) => diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index bdc64d392dd0..2fbb67c3e99b 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -156,6 +156,20 @@ describe('mail addressed to a listed slept pane', () => { db.close() }) + it('does not wake a pane from a delivery sweep with nothing in the mailbox', async () => { + // Why: pty retirement redrives and restored-mailbox repoints run delivery + // for empty mailboxes at the exact moment hibernation kills the pane. + // Observed live: without the unread-mail gate, the kill itself scheduled a + // wake and the pane respawned two seconds after sleeping. + const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) + runtime.deliverPendingMessagesForHandle(handle) + await Promise.resolve() + await Promise.resolve() + + expect(tabMountSends).toEqual([]) + db.close() + }) + it('still refuses to wake a pane the user slept deliberately', async () => { const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime( sleepingRecord({ restoreOnTabOpenOnly: true }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index d5f39d6fa8ac..f0f4117149b0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { IDisposable } from '@xterm/xterm' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' +import { installWakeHibernatedAgentsListener } from './wake-hibernated-agents-listener' import { applyTerminalAppearance } from './terminal-appearance' import { installMouseHideWhileTyping } from './mouse-hide-while-typing' import { @@ -52,26 +53,15 @@ export function useTerminalPaneLifecycle(deps: UseTerminalPaneLifecycleDeps): vo const systemPrefersDarkRef = refs.systemPrefersDarkRef systemPrefersDarkRef.current = deps.systemPrefersDark - useEffect(() => { - const onWakeHibernatedAgents = (event: Event): void => { - const detail = (event as CustomEvent<{ worktreeId: string; wokenClaimKeys?: Set }>) - .detail - if (!detail || detail.worktreeId !== deps.worktreeId) { - return - } - for (const panePtyBinding of deps.panePtyBindingsRef.current.values()) { - const claimKey = (panePtyBinding as IDisposableWithWake).wakeHibernatedAgentIfArmed?.( - detail.wokenClaimKeys - ) - if (claimKey) { - detail.wokenClaimKeys?.add(claimKey) - } - } - } - window.addEventListener('orca:wake-hibernated-agents-worktree', onWakeHibernatedAgents) - return () => - window.removeEventListener('orca:wake-hibernated-agents-worktree', onWakeHibernatedAgents) - }, [deps.worktreeId, deps.panePtyBindingsRef]) + useEffect( + () => + installWakeHibernatedAgentsListener({ + worktreeId: deps.worktreeId, + tabId: deps.tabId, + getPanePtyBindings: () => deps.panePtyBindingsRef.current.values() + }), + [deps.worktreeId, deps.tabId, deps.panePtyBindingsRef] + ) useEffect(() => { const previousIsVisible = getPreviousVisibleForTerminalPane({ @@ -194,10 +184,6 @@ export function useTerminalPaneLifecycle(deps: UseTerminalPaneLifecycleDeps): vo }, [deps.settings?.terminalMouseHideWhileTyping, deps.managerRef, refs.mouseHideDisposablesRef]) } -type IDisposableWithWake = IDisposable & { - wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null -} - type IDisposableWithVisibility = IDisposable & { syncProcessTracking?: () => void noteVisibilityResume?: () => void diff --git a/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts new file mode 100644 index 000000000000..9a5a315a12c7 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts @@ -0,0 +1,83 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type WakeHibernatedAgentsWorktreeDetail +} from '@/constants/terminal' +import { installWakeHibernatedAgentsListener } from './wake-hibernated-agents-listener' + +function dispatchWake(detail: WakeHibernatedAgentsWorktreeDetail): void { + window.dispatchEvent( + new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { + detail + }) + ) +} + +describe('installWakeHibernatedAgentsListener', () => { + const cleanups: (() => void)[] = [] + afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.() + } + }) + + function install(binding: { + dispose?: () => void + wakeHibernatedAgentIfArmed?: (claimed?: Set) => string | null + }): void { + cleanups.push( + installWakeHibernatedAgentsListener({ + worktreeId: 'wt-1', + tabId: 'tab-a', + getPanePtyBindings: () => [{ dispose: () => {}, ...binding }] + }) + ) + } + + // Why this test exists: the dispatcher uses the shared event constant, and a + // refactor once re-registered the listener under an inlined near-miss string + // ('orca:wake…' for 'orca-wake…'), silently disconnecting every in-place wake. + it('receives a wake dispatched under the shared event constant', () => { + const wake = vi.fn(() => null) + install({ wakeHibernatedAgentIfArmed: wake }) + dispatchWake({ worktreeId: 'wt-1' }) + expect(wake).toHaveBeenCalledOnce() + }) + + it('collects the consumed claim key into the shared collector', () => { + install({ wakeHibernatedAgentIfArmed: () => 'claim-1' }) + const wokenClaimKeys = new Set() + dispatchWake({ worktreeId: 'wt-1', wokenClaimKeys }) + expect([...wokenClaimKeys]).toEqual(['claim-1']) + }) + + it('ignores wakes for another worktree', () => { + const wake = vi.fn(() => null) + install({ wakeHibernatedAgentIfArmed: wake }) + dispatchWake({ worktreeId: 'wt-other' }) + expect(wake).not.toHaveBeenCalled() + }) + + it('ignores a tab-scoped wake naming a different tab', () => { + const wake = vi.fn(() => null) + install({ wakeHibernatedAgentIfArmed: wake }) + dispatchWake({ worktreeId: 'wt-1', tabId: 'tab-other' }) + expect(wake).not.toHaveBeenCalled() + }) + + it('consumes a tab-scoped wake naming its own tab', () => { + const wake = vi.fn(() => null) + install({ wakeHibernatedAgentIfArmed: wake }) + dispatchWake({ worktreeId: 'wt-1', tabId: 'tab-a' }) + expect(wake).toHaveBeenCalledOnce() + }) + + it('stops listening after cleanup', () => { + const wake = vi.fn(() => null) + install({ wakeHibernatedAgentIfArmed: wake }) + cleanups.pop()?.() + dispatchWake({ worktreeId: 'wt-1' }) + expect(wake).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts new file mode 100644 index 000000000000..bcb9d85826ee --- /dev/null +++ b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts @@ -0,0 +1,50 @@ +import type { IDisposable } from '@xterm/xterm' +import { + WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, + type WakeHibernatedAgentsWorktreeDetail +} from '@/constants/terminal' + +type PaneBindingWithWake = IDisposable & { + wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null +} + +type WakeHibernatedAgentsListenerDeps = { + worktreeId: string + tabId: string + getPanePtyBindings: () => Iterable +} + +/** + * Registers the in-place hibernation wake listener for one mounted terminal + * tab. Both the mobile worktree-open wake and the mail-driven slept-pane wake + * dispatch this event; the listener and every dispatcher must share the event + * constant — an inlined string silently severs them (that exact regression + * shipped once as a colon-for-dash typo). Returns the cleanup, usable directly + * as a useEffect body. + */ +export function installWakeHibernatedAgentsListener( + deps: WakeHibernatedAgentsListenerDeps +): () => void { + const onWakeHibernatedAgents = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.worktreeId !== deps.worktreeId) { + return + } + // Why: a mail-driven wake targets one slept tab; an unscoped detail keeps + // the mobile whole-worktree wake semantics. + if (detail.tabId && detail.tabId !== deps.tabId) { + return + } + for (const panePtyBinding of deps.getPanePtyBindings()) { + const claimKey = (panePtyBinding as PaneBindingWithWake).wakeHibernatedAgentIfArmed?.( + detail.wokenClaimKeys + ) + if (claimKey) { + detail.wokenClaimKeys?.add(claimKey) + } + } + } + window.addEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) + return () => + window.removeEventListener(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, onWakeHibernatedAgents) +} diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index d417a7c88fa0..8218d33d6636 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -85,6 +85,9 @@ export type BackgroundMountTerminalWorktreeDetail = { export type WakeHibernatedAgentsWorktreeDetail = { worktreeId: string + /** Present on a mail-driven wake: only the named tab's panes may consume it, + * so one inbound message cannot respawn every slept agent in the worktree. */ + tabId?: string /** Mutable collector: mounted panes that consume (or latch) the in-place * hibernation wake add their provider-session claim keys here so the * dispatcher's follow-up generic resume skips those sessions instead of diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 47835ab529d6..a950caba1973 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -1,6 +1,7 @@ import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { hasRegisteredRuntimeTerminalTab } from '@/runtime/sync-runtime-graph' -import { planMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { resolveMobileTerminalTabMount } from '@/lib/mobile-terminal-tab-mount' +import { wakeMountedSleptPaneInPlace } from '@/lib/wake-sleeping-agents-in-background' import { resolveTerminalTabPtyOwnership } from '@/lib/terminal-tab-for-pty-id' import { SPLIT_TERMINAL_PANE_EVENT } from '@/constants/terminal' import type { SplitTerminalPaneDetail } from '@/constants/terminal' @@ -252,7 +253,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v return } // Why: synthetic pty handles need persisted-tab resolution; a miss must not mount every saved tab in a hidden worktree. - const mount = planMobileTerminalTabMount( + const resolution = resolveMobileTerminalTabMount( useAppStore.getState(), { worktreeId, @@ -264,8 +265,12 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v hasRegisteredRuntimeTerminalTab(tabId, targetWorktreeId) } ) - if (mount) { - requestBackgroundTerminalWorktreeMount(mount) + if (resolution?.kind === 'mount') { + requestBackgroundTerminalWorktreeMount(resolution.detail) + } else if (resolution?.kind === 'already-mounted') { + // Why: a slept pane whose tab is still mounted cannot be woken by a + // mount — its pane holds an armed in-place cold-restore instead. + wakeMountedSleptPaneInPlace(worktreeId, resolution.tabId) } }) ) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index 01da9791eb07..f4ea46f61b02 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' -import { planMobileTerminalTabMount } from './mobile-terminal-tab-mount' +import { + planMobileTerminalTabMount, + resolveMobileTerminalTabMount +} from './mobile-terminal-tab-mount' import type { TerminalTabPtyOwnershipState } from './terminal-tab-for-pty-id' function state(tabCount = 1): TerminalTabPtyOwnershipState { @@ -101,3 +104,38 @@ describe('planMobileTerminalTabMount', () => { expect(isTabMounted).toHaveBeenCalledWith('tab-0', 'wt') }) }) + +describe('resolveMobileTerminalTabMount', () => { + // Why the distinction matters: a slept pane whose tab is still mounted cannot + // be woken by a mount — the caller must fire the in-place wake instead, so + // "already mounted" must be distinguishable from "tab does not resolve". + it('reports an already-mounted tab instead of collapsing it into null', () => { + expect( + resolveMobileTerminalTabMount( + state(), + { worktreeId: 'wt', tabId: 'tab-0' }, + { isTabMounted: () => true } + ) + ).toEqual({ kind: 'already-mounted', tabId: 'tab-0' }) + }) + + it('plans a mount for an unmounted resolvable tab', () => { + expect( + resolveMobileTerminalTabMount( + state(), + { worktreeId: 'wt', tabId: 'tab-0' }, + { isTabMounted: () => false } + ) + ).toEqual({ kind: 'mount', detail: { worktreeId: 'wt', tabIds: ['tab-0'] } }) + }) + + it('returns null when the tab does not resolve at all', () => { + expect( + resolveMobileTerminalTabMount( + state(), + { worktreeId: 'wt', tabId: 'tab-missing' }, + { isTabMounted: () => true } + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts index 602066842008..84e0bd457b4d 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -14,12 +14,17 @@ type MobileTerminalTabMountOptions = { isTabMounted?: (tabId: string, worktreeId?: string) => boolean } +export type MobileTerminalTabMountResolution = + | { kind: 'mount'; detail: BackgroundMountTerminalWorktreeDetail } + | { kind: 'already-mounted'; tabId: string } + | null + /** Why: exact-tab planning prevents a stale ptyId from mounting every saved xterm (#8597). */ -export function planMobileTerminalTabMount( +export function resolveMobileTerminalTabMount( state: TerminalTabPtyOwnershipState, request: MobileTerminalTabMountRequest, options: MobileTerminalTabMountOptions = {} -): BackgroundMountTerminalWorktreeDetail | null { +): MobileTerminalTabMountResolution { if (!request.worktreeId) { return null } @@ -35,9 +40,23 @@ export function planMobileTerminalTabMount( : request.ptyId ? resolveTerminalTabIdForPtyId(state, request.worktreeId, request.ptyId) : null + if (!tabId) { + return null + } // Why: replaying the background-mount event for a live pane restarts its - // three-second hidden measurement window on every mobile reconnect. - return tabId && !options.isTabMounted?.(tabId, request.worktreeId) - ? { worktreeId: request.worktreeId, tabIds: [tabId] } - : null + // three-second hidden measurement window on every mobile reconnect. The + // caller still needs to know the tab resolved: a slept pane whose tab is + // still mounted is woken in place, not by mounting (its mount is a no-op). + return options.isTabMounted?.(tabId, request.worktreeId) + ? { kind: 'already-mounted', tabId } + : { kind: 'mount', detail: { worktreeId: request.worktreeId, tabIds: [tabId] } } +} + +export function planMobileTerminalTabMount( + state: TerminalTabPtyOwnershipState, + request: MobileTerminalTabMountRequest, + options: MobileTerminalTabMountOptions = {} +): BackgroundMountTerminalWorktreeDetail | null { + const resolution = resolveMobileTerminalTabMount(state, request, options) + return resolution?.kind === 'mount' ? resolution.detail : null } diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts index 40281b4a2443..3101e9c5bfa7 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -33,7 +33,18 @@ vi.mock('./sleeping-agent-pane-ownership', () => ({ `${record.worktreeId}\0${record.agent ?? 'agent'}\0${record.providerSession?.key ?? 'session_id'}\0${record.providerSession?.id ?? 'session'}` })) -let sleepingRecords: Record = {} +let sleepingRecords: Record< + string, + { + worktreeId: string + paneKey: string + tabId?: string + origin?: string + state?: string + restoreOnTabOpenOnly?: boolean + interrupted?: boolean + } +> = {} let terminalTabsByWorktree: Record = {} const clearSleepingAgentSessionsByPaneKey = vi.fn((paneKeys: readonly string[]) => { for (const paneKey of paneKeys) { @@ -52,6 +63,7 @@ vi.mock('@/store', () => ({ import { createBackgroundSleepingAgentWakeDispatcher, + wakeMountedSleptPaneInPlace, wakeSleepingAgentsForWorktreeInBackground } from './wake-sleeping-agents-in-background' @@ -412,3 +424,45 @@ describe('wakeSleepingAgentsForWorktreeInBackground', () => { expect(isPassiveSpy).not.toHaveBeenCalled() }) }) + +describe('wakeMountedSleptPaneInPlace', () => { + const autoSleptRecord = { + worktreeId: 'wt-1', + paneKey: 'tab-a:leaf-1', + tabId: 'tab-a', + origin: 'worktree-sleep', + state: 'done' + } + + it('dispatches a tab-scoped in-place wake for an auto-slept pane', () => { + sleepingRecords = { k1: autoSleptRecord } + const rec = recordEvents() + + expect(wakeMountedSleptPaneInPlace('wt-1', 'tab-a')).toBe(true) + + rec.stop() + expect(rec.events).toEqual(['wake:wt-1']) + expect(rec.wakeDetails[0]?.tabId).toBe('tab-a') + expect(rec.wakeDetails[0]?.wokenClaimKeys).toBeInstanceOf(Set) + }) + + it('never wakes a pane the user slept deliberately', () => { + sleepingRecords = { k1: { ...autoSleptRecord, restoreOnTabOpenOnly: true } } + const rec = recordEvents() + + expect(wakeMountedSleptPaneInPlace('wt-1', 'tab-a')).toBe(false) + + rec.stop() + expect(rec.events).toEqual([]) + }) + + it('does nothing for a tab with no sleeping record', () => { + sleepingRecords = { k1: { ...autoSleptRecord, tabId: 'tab-other', paneKey: 'tab-other:leaf' } } + const rec = recordEvents() + + expect(wakeMountedSleptPaneInPlace('wt-1', 'tab-a')).toBe(false) + + rec.stop() + expect(rec.events).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts index df22b07f209b..bcb9aa1bffc9 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -4,7 +4,10 @@ import { } from '@/constants/terminal' import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount' import { useAppStore } from '@/store' -import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { + mayBackgroundWakeSleepingAgentSession, + type SleepingAgentSessionRecord +} from '../../../shared/agent-session-resume' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' import { @@ -83,6 +86,34 @@ function dispatchBackgroundMount(worktreeId: string, tabIds: readonly string[] | requestBackgroundTerminalWorktreeMount({ worktreeId, ...(tabIds ? { tabIds } : {}) }) } +/** + * In-place wake for a slept pane whose tab is still mounted — the case a + * background mount cannot reach, because mounting an already-mounted tab is a + * no-op. The mounted pane holds an armed cold-restore (`hibernatedWakeTarget`) + * that normally only a reveal consumes; this fires the same wake event the + * mobile worktree-open path uses, scoped to one tab so an inbound message + * cannot respawn the whole worktree. Gated on the same fail-closed predicate + * main's wake request uses, so a pane the user slept is never woken by mail. + */ +export function wakeMountedSleptPaneInPlace(worktreeId: string, tabId: string): boolean { + const state = useAppStore.getState() + const eligible = Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => + record.worktreeId === worktreeId && + getSleepingRecordTabId(record) === tabId && + mayBackgroundWakeSleepingAgentSession(record) + ) + if (!eligible) { + return false + } + window.dispatchEvent( + new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { + detail: { worktreeId, tabId, wokenClaimKeys: new Set() } + }) + ) + return true +} + function getCanonicalPassiveWakeRecords( records: readonly SleepingAgentSessionRecord[], alreadyClaimed: ReadonlySet From 959116fe4ce5a1ab3756eec640895972892a3123 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 16:27:00 -0700 Subject: [PATCH 07/29] fix(orchestration): harden slept-agent mail wake --- ...rca-runtime-bind-pty-incarnation-handle.ts | 13 ++- .../orca-runtime-stop-requested-pty-ids.ts | 3 +- .../runtime/orca-runtime-sync-window-graph.ts | 36 +++++- .../orca-runtime-wait-for-leaf-pty-id.ts | 13 ++- ...runtime-write-orchestration-pointer-pty.ts | 5 +- .../sleeping-pane-wake-scheduler.test.ts | 60 +++++++--- .../sleeping-pane-wake-scheduler.ts | 107 +++++++++++++----- .../sleeping-pane-wake-target.test.ts | 31 ++++- .../sleeping-pane-wake-target.ts | 15 ++- ...hestration-recipient-routing-slept.test.ts | 17 ++- .../orchestration-recipient-routing.ts | 12 +- .../runtime-terminal-list-slept-panes.test.ts | 55 +++++++-- src/main/runtime/runtime-terminal-list.ts | 43 +++++-- src/main/runtime/slept-pane-mail-wake.test.ts | 46 +++++++- ...erminal-mobile-subscribe-tab-mount.test.ts | 84 ++++++++++++++ .../ui-bridge-terminal-and-session-tabs.ts | 9 +- src/preload/api/ui-command-event-api.ts | 7 +- .../pty-connection/pane-pty-binding.ts | 1 + .../session-reconcile-dispose.ts | 1 + .../wake-hibernated-agents-listener.test.ts | 33 ++++++ .../wake-hibernated-agents-listener.ts | 9 +- src/renderer/src/constants/terminal.ts | 2 + .../terminal-presentation-ipc-bridge.ts | 4 +- ...wake-sleeping-agents-in-background.test.ts | 18 +++ .../lib/wake-sleeping-agents-in-background.ts | 27 +++-- 25 files changed, 551 insertions(+), 100 deletions(-) diff --git a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts index 69f9be4ba858..518d9ca43dba 100644 --- a/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts +++ b/src/main/runtime/orca-runtime-bind-pty-incarnation-handle.ts @@ -161,11 +161,20 @@ export class OrcaRuntimeWithBindPtyIncarnationHandle extends OrcaRuntimeWithBuil protected adoptFirstPtyForLeafHandle( leafKey: string, ptyId: string | null, - ptyGeneration: number + ptyGeneration: number, + incomingOwnerLeafKey: string | null ): boolean { const handle = this.handleByLeafKey.get(leafKey) const record = handle ? this.handles.get(handle) : null - if (!handle || !record || record.ptyId !== null || ptyId === null) { + // A malformed graph may name the same PTY on multiple leaves. Never let a + // pending leaf handle become an alias for an ambiguously owned process. + if ( + !handle || + !record || + record.ptyId !== null || + ptyId === null || + incomingOwnerLeafKey !== leafKey + ) { return false } this.handles.set(handle, { ...record, ptyId, ptyGeneration }) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index d284aa10c621..55848b38abcb 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -125,7 +125,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId this.buildTerminalSummary(leaf, worktrees, livePtyIds), buildPtySummary: (pty, worktrees) => this.buildPtyTerminalSummary(pty, worktrees), listResumableSleptPanes: (targetWorktreeId) => this.listResumableSleptPanes(targetWorktreeId), - buildSleptPaneSummary: (pane, worktrees) => this.buildSleptPaneTerminalSummary(pane, worktrees), + buildSleptPaneSummary: (pane, worktrees, resolvedWorktree) => + this.buildSleptPaneTerminalSummary(pane, worktrees, resolvedWorktree), getSnapshots: () => this.mobileSessionTabsByWorktree, getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getTopologyRevision: (worktreeId) => this.getTerminalTopologyRevision(worktreeId), diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 29409ceea013..38da206518ce 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -88,6 +88,23 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow // Why: renderer reloads can briefly republish the same leaf with no ptyId; // keep live CLI handles usable while the UI graph rebuilds. const preserveLivePtysDuringReload = this.graphStatus === 'reloading' + const incomingPtyOwnerByPtyId = new Map() + for (const leaf of lifecycleLeaves) { + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const existing = this.leaves.get(leafKey) + const ptyId = + preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId + ? existing.ptyId + : leaf.ptyId + if (!ptyId) { + continue + } + const priorOwner = incomingPtyOwnerByPtyId.get(ptyId) + incomingPtyOwnerByPtyId.set( + ptyId, + priorOwner === undefined || priorOwner === leafKey ? leafKey : null + ) + } for (const leaf of lifecycleLeaves) { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const existing = this.leaves.get(leafKey) @@ -145,7 +162,13 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow // Why: mobile can subscribe while the pane is waiting for its first PTY. // Keep that handle usable after the recovery mount binds it. const adoptedFirstPty = - existing.ptyId === null && this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + existing.ptyId === null && + this.adoptFirstPtyForLeafHandle( + leafKey, + ptyId, + ptyGeneration, + ptyId ? (incomingPtyOwnerByPtyId.get(ptyId) ?? null) : null + ) if (!adoptedFirstPty) { this.invalidateLeafHandle(leafKey) } @@ -153,7 +176,16 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow // Why: a slept pane's handle is minted from its resume record with no leaf // behind it. Adopt the PTY the wake produced so the handle a sender // addressed keeps resolving instead of being orphaned by a remint. - this.adoptFirstPtyForLeafHandle(leafKey, ptyId, ptyGeneration) + if ( + !this.adoptFirstPtyForLeafHandle( + leafKey, + ptyId, + ptyGeneration, + incomingPtyOwnerByPtyId.get(ptyId) ?? null + ) + ) { + this.invalidateLeafHandle(leafKey) + } } } diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index ddad7a9a5cc1..15f0b0e08b98 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -123,7 +123,8 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP this.getAuthoritativeWindow().webContents.send('terminal:requestTabMount', { worktreeId: args.worktreeId, ...(args.tabId ? { tabId: args.tabId } : {}), - ...(args.ptyId ? { ptyId: args.ptyId } : {}) + ...(args.ptyId ? { ptyId: args.ptyId } : {}), + ...(args.paneKey ? { paneKey: args.paneKey } : {}) }) return true } catch { @@ -155,14 +156,22 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP protected mayBackgroundWakeSleepingPane(paneKey: string): boolean { const record = this.findSleepingAgentRecordForPane(paneKey) - return !record || mayBackgroundWakeSleepingAgentSession(record) + return Boolean(record && mayBackgroundWakeSleepingAgentSession(record)) } protected readonly sleepingPaneWakes = new SleepingPaneWakeScheduler({ wake: (request) => + !this.mayBackgroundWakeSleepingPane(request.paneKey) || this.requestRendererTerminalTabMountForPane({ ...request, intent: 'inbound-message' }) }) + markGraphReady(windowId: number): void { + super.markGraphReady(windowId) + if (windowId === this.authoritativeWindowId && this.graphStatus === 'ready') { + this.sleepingPaneWakes.retryPending() + } + } + /** * Mail landed for a mailbox whose pane has no process. The message arriving IS * the evidence the pane is owed something, so every type wakes — no allowlist. diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index b02c76f65cac..a609bd8fb324 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -143,9 +143,10 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith * new fact, so nothing reading liveness changes (ssh-execution-boundary.md). */ protected buildSleptPaneTerminalSummary( pane: ResumableSleptPane, - worktreesById: Map + worktreesById: Map, + resolvedWorktree?: ResolvedWorktree ): RuntimeTerminalSummary { - const worktree = worktreesById.get(pane.worktreeId) + const worktree = resolvedWorktree ?? worktreesById.get(pane.worktreeId) return { handle: this.issueResumableSleptPaneHandle(pane), ptyId: null, diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts index 69c19297ec63..91c193efd43d 100644 --- a/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts +++ b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.test.ts @@ -1,26 +1,25 @@ import { describe, expect, it, vi } from 'vitest' import { - SLEEPING_PANE_WAKE_QUEUE_LIMIT, SLEEPING_PANE_WAKE_SPACING_MS, SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS, SleepingPaneWakeScheduler, type SleepingPaneWakeRequest } from './sleeping-pane-wake-scheduler' -type Timer = { run: () => void; delayMs: number } +type Timer = { run: () => void; delayMs: number; runAt: number } -function harness(options: { wake?: (request: SleepingPaneWakeRequest) => void } = {}) { +function harness(options: { wake?: (request: SleepingPaneWakeRequest) => boolean } = {}) { let now = 1_000_000 const woken: SleepingPaneWakeRequest[] = [] const timers: Timer[] = [] const scheduler = new SleepingPaneWakeScheduler({ wake: (request) => { woken.push(request) - options.wake?.(request) + return options.wake?.(request) ?? true }, now: () => now, schedule: (run, delayMs) => { - timers.push({ run, delayMs }) + timers.push({ run, delayMs, runAt: now + delayMs }) return timers.length as unknown as ReturnType }, cancel: () => undefined @@ -33,7 +32,9 @@ function harness(options: { wake?: (request: SleepingPaneWakeRequest) => void } now += ms }, fireTimers: () => { - const pending = timers.splice(0) + const pending = timers.filter((timer) => timer.runAt <= now) + const future = timers.filter((timer) => timer.runAt > now) + timers.splice(0, timers.length, ...future) for (const timer of pending) { timer.run() } @@ -68,13 +69,42 @@ describe('SleepingPaneWakeScheduler', () => { expect(h.woken).toHaveLength(2) }) - it('does not retry a wake that threw', () => { + it('self-prunes successful suppression state after the TTL', () => { + const h = harness() + h.scheduler.request(paneRequest('a')) + h.advance(SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS) + h.fireTimers() + expect(h.timers).toEqual([]) + }) + + it('automatically retries one failed wake without suppressing it as successful', () => { + let attempts = 0 const h = harness({ wake: () => { - throw new Error('no authoritative window') + attempts += 1 + return attempts > 1 } }) - expect(() => h.scheduler.request(paneRequest('a'))).not.toThrow() + expect(h.scheduler.request(paneRequest('a'))).toBe('queued') + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(attempts).toBe(2) + expect(h.scheduler.request(paneRequest('a'))).toBe('suppressed') + }) + + it('parks a repeated failure until renderer readiness retries it', () => { + let available = false + const h = harness({ wake: () => available }) + expect(h.scheduler.request(paneRequest('a'))).toBe('queued') + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(h.woken).toHaveLength(2) + + available = true + h.scheduler.retryPending() + h.advance(SLEEPING_PANE_WAKE_SPACING_MS) + h.fireTimers() + expect(h.woken).toHaveLength(3) expect(h.scheduler.request(paneRequest('a'))).toBe('suppressed') }) @@ -92,7 +122,6 @@ describe('SleepingPaneWakeScheduler', () => { h.advance(SLEEPING_PANE_WAKE_SPACING_MS) h.fireTimers() expect(h.woken.map((request) => request.paneKey)).toEqual(['a', 'b', 'c']) - expect(h.timers).toHaveLength(0) }) it('queues each pane once', () => { @@ -105,13 +134,13 @@ describe('SleepingPaneWakeScheduler', () => { expect(h.woken.map((request) => request.paneKey)).toEqual(['a', 'b']) }) - it('drops overflow past the queue limit rather than growing without bound', () => { + it('retains one queued wake per distinct slept pane instead of dropping accepted mail', () => { const h = harness() h.scheduler.request(paneRequest('head')) - for (let i = 0; i < SLEEPING_PANE_WAKE_QUEUE_LIMIT; i += 1) { + for (let i = 0; i < 65; i += 1) { expect(h.scheduler.request(paneRequest(`pane-${i}`))).toBe('queued') } - expect(h.scheduler.request(paneRequest('overflow'))).toBe('dropped') + expect(h.scheduler.request(paneRequest('pane-64'))).toBe('suppressed') }) it('stops scheduling after dispose', () => { @@ -129,7 +158,10 @@ describe('SleepingPaneWakeScheduler', () => { try { const woken: string[] = [] const scheduler = new SleepingPaneWakeScheduler({ - wake: (request) => woken.push(request.paneKey) + wake: (request) => { + woken.push(request.paneKey) + return true + } }) scheduler.request(paneRequest('a')) scheduler.request(paneRequest('b')) diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts index f0e4bb3a74d7..293a5aeed7cb 100644 --- a/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts +++ b/src/main/runtime/orchestration/sleeping-pane-wake-scheduler.ts @@ -3,8 +3,8 @@ * * Two independent bounds, both caller-side — the mount door keeps no bookkeeping * of its own, mirroring the close-intent guard (`web-session-close-intent.ts`): - * - per-pane suppression, so a wake that fails (no window, record already gone) - * is not retried in a loop by the next message; + * - per-pane suppression after a successful request, so repeat mail cannot + * restart a wake already in progress; * - a minimum spacing between wakes, so one `@all` broadcast spreads its wakes * instead of respawning a workspace at once. Queued wakes still happen. */ @@ -21,9 +21,6 @@ export const SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS = export const SLEEPING_PANE_WAKE_SPACING_MS = 1_500 -/** Bounds a broadcast's queue; overflow stays durable mail, woken by the next arrival. */ -export const SLEEPING_PANE_WAKE_QUEUE_LIMIT = 64 - export type SleepingPaneWakeRequest = { paneKey: string worktreeId: string @@ -31,13 +28,13 @@ export type SleepingPaneWakeRequest = { ptyId?: string } -export type SleepingPaneWakeOutcome = 'requested' | 'queued' | 'suppressed' | 'dropped' +export type SleepingPaneWakeOutcome = 'requested' | 'queued' | 'suppressed' /** Node returns a Timeout, jsdom/browser a number; neither is unref-able for sure. */ type SleepingPaneWakeTimer = { unref?: () => void } | number type SleepingPaneWakeSchedulerDependencies = { - wake: (request: SleepingPaneWakeRequest) => void + wake: (request: SleepingPaneWakeRequest) => boolean now?: () => number schedule?: (run: () => void, delayMs: number) => SleepingPaneWakeTimer cancel?: (timer: SleepingPaneWakeTimer) => void @@ -45,9 +42,12 @@ type SleepingPaneWakeSchedulerDependencies = { export class SleepingPaneWakeScheduler { private readonly requestedAtByPaneKey = new Map() - private readonly queue = new Map() - private lastWakeAt = Number.NEGATIVE_INFINITY + // One row per slept pane, independent of message volume. + private readonly queue = new Map() + private readonly failed = new Map() + private lastAttemptAt = Number.NEGATIVE_INFINITY private drainTimer: SleepingPaneWakeTimer | null = null + private pruneTimer: SleepingPaneWakeTimer | null = null constructor(private readonly deps: SleepingPaneWakeSchedulerDependencies) {} @@ -57,44 +57,68 @@ export class SleepingPaneWakeScheduler { if (this.requestedAtByPaneKey.has(request.paneKey) || this.queue.has(request.paneKey)) { return 'suppressed' } - if (now - this.lastWakeAt >= SLEEPING_PANE_WAKE_SPACING_MS && this.queue.size === 0) { - this.fire(request, now) - return 'requested' - } - if (this.queue.size >= SLEEPING_PANE_WAKE_QUEUE_LIMIT) { - return 'dropped' + // A new arrival is an event-driven retry for a request parked after its one + // automatic retry. Keep the newest coordinates in case the tab reminted. + this.failed.delete(request.paneKey) + if (now - this.lastAttemptAt >= SLEEPING_PANE_WAKE_SPACING_MS && this.queue.size === 0) { + if (this.fire(request, now)) { + return 'requested' + } + this.queue.set(request.paneKey, { request, failures: 1 }) + this.scheduleDrain(now) + return 'queued' } - this.queue.set(request.paneKey, request) + this.queue.set(request.paneKey, { request, failures: 0 }) this.scheduleDrain(now) return 'queued' } + /** Retry parked failures when renderer readiness provides new wake capacity. */ + retryPending(): void { + for (const [paneKey, request] of this.failed) { + if (!this.requestedAtByPaneKey.has(paneKey) && !this.queue.has(paneKey)) { + this.queue.set(paneKey, { request, failures: 0 }) + } + } + this.failed.clear() + if (this.queue.size > 0) { + this.scheduleDrain(this.now()) + } + } + dispose(): void { if (this.drainTimer !== null) { ;(this.deps.cancel ?? clearTimeout)(this.drainTimer as ReturnType) this.drainTimer = null } + if (this.pruneTimer !== null) { + ;(this.deps.cancel ?? clearTimeout)(this.pruneTimer as ReturnType) + this.pruneTimer = null + } this.queue.clear() + this.failed.clear() this.requestedAtByPaneKey.clear() } - private fire(request: SleepingPaneWakeRequest, now: number): void { - // Stamp before the call: a wake that throws must still be suppressed, or a - // chatty run retries it on every message. - this.requestedAtByPaneKey.set(request.paneKey, now) - this.lastWakeAt = now + private fire(request: SleepingPaneWakeRequest, now: number): boolean { + this.lastAttemptAt = now try { - this.deps.wake(request) + if (!this.deps.wake(request)) { + return false + } } catch { - // The message stays durable; the tab-open path still delivers it. + return false } + this.requestedAtByPaneKey.set(request.paneKey, now) + this.scheduleSuppressionPrune(now) + return true } private scheduleDrain(now: number): void { if (this.drainTimer !== null) { return } - const delay = Math.max(0, this.lastWakeAt + SLEEPING_PANE_WAKE_SPACING_MS - now) + const delay = Math.max(0, this.lastAttemptAt + SLEEPING_PANE_WAKE_SPACING_MS - now) const timer = (this.deps.schedule ?? setTimeout)(() => { this.drainTimer = null this.drain() @@ -112,9 +136,17 @@ export class SleepingPaneWakeScheduler { if (next.done) { return } - const [paneKey, request] = next.value + const [paneKey, queued] = next.value this.queue.delete(paneKey) - this.fire(request, now) + if (!this.fire(queued.request, now)) { + if (queued.failures === 0) { + this.queue.set(paneKey, { request: queued.request, failures: 1 }) + } else { + // Do not poll forever while no renderer exists. A later graph-ready + // edge or message arrival calls retryPending/request respectively. + this.failed.set(paneKey, queued.request) + } + } if (this.queue.size > 0) { this.scheduleDrain(now) } @@ -128,6 +160,29 @@ export class SleepingPaneWakeScheduler { } } + private scheduleSuppressionPrune(now: number): void { + if (this.pruneTimer !== null) { + return + } + const firstRequestedAt = this.requestedAtByPaneKey.values().next().value + if (firstRequestedAt === undefined) { + return + } + const delay = Math.max(0, firstRequestedAt + SLEEPING_PANE_WAKE_SUPPRESSION_TTL_MS - now) + const timer = (this.deps.schedule ?? setTimeout)(() => { + this.pruneTimer = null + const currentNow = this.now() + this.pruneSuppressions(currentNow) + if (this.requestedAtByPaneKey.size > 0) { + this.scheduleSuppressionPrune(currentNow) + } + }, delay) + this.pruneTimer = timer + if (typeof timer !== 'number') { + timer.unref?.() + } + } + private now(): number { return (this.deps.now ?? Date.now)() } diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts b/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts index 859616f63b7d..da0fa9a1d068 100644 --- a/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts +++ b/src/main/runtime/orchestration/sleeping-pane-wake-target.test.ts @@ -107,7 +107,7 @@ describe('resolveSleepingPaneWakeTarget', () => { ).toEqual({ ok: false, reason: 'user-slept' }) }) - it('omits an absent tab id rather than sending an empty one', () => { + it('recovers the current tab from the pane key when persistence has no tab id', () => { const record = autoSleptRecord() delete record.tabId const resolution = resolveSleepingPaneWakeTarget( @@ -116,7 +116,34 @@ describe('resolveSleepingPaneWakeTarget', () => { ) expect(resolution).toEqual({ ok: true, - request: { paneKey: PANE_KEY, worktreeId: 'wt-1' } + request: { paneKey: PANE_KEY, worktreeId: 'wt-1', tabId: 'tab-1' } + }) + }) + + it('uses the resolved reminted tab instead of a stale persisted tab id', () => { + const remintedPaneKey = PANE_KEY.replace('tab-1', 'tab-reminted') + const resolution = resolveSleepingPaneWakeTarget( + 'term_abc', + lookups({ + getPaneKeyForHandle: () => remintedPaneKey, + getSleepingRecord: () => autoSleptRecord({ tabId: 'tab-obsolete' }) + }) + ) + expect(resolution).toEqual({ + ok: true, + request: { paneKey: remintedPaneKey, worktreeId: 'wt-1', tabId: 'tab-reminted' } }) }) + + it('refuses a sleeping record with no routable tab identity', () => { + expect( + resolveSleepingPaneWakeTarget( + 'term_abc', + lookups({ + getPaneKeyForHandle: () => 'legacy-unparseable', + getSleepingRecord: () => autoSleptRecord() + }) + ) + ).toEqual({ ok: false, reason: 'no-tab-identity' }) + }) }) diff --git a/src/main/runtime/orchestration/sleeping-pane-wake-target.ts b/src/main/runtime/orchestration/sleeping-pane-wake-target.ts index 3a2bd7a52da8..9c91033e97c1 100644 --- a/src/main/runtime/orchestration/sleeping-pane-wake-target.ts +++ b/src/main/runtime/orchestration/sleeping-pane-wake-target.ts @@ -2,6 +2,7 @@ import { mayBackgroundWakeSleepingAgentSession, type SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { parsePaneKey } from '../../../shared/stable-pane-id' import type { SleepingPaneWakeRequest } from './sleeping-pane-wake-scheduler' export type SleepingPaneWakeLookups = { @@ -14,7 +15,11 @@ export type SleepingPaneWakeLookups = { getSleepingRecord: (paneKey: string) => SleepingAgentSessionRecord | undefined } -export type SleepingPaneWakeRefusal = 'no-pane-identity' | 'not-slept' | 'user-slept' +export type SleepingPaneWakeRefusal = + | 'no-pane-identity' + | 'no-tab-identity' + | 'not-slept' + | 'user-slept' export type SleepingPaneWakeResolution = | { ok: true; request: SleepingPaneWakeRequest } @@ -51,12 +56,18 @@ export function resolveSleepingPaneWakeTarget( if (!mayBackgroundWakeSleepingAgentSession(record)) { return { ok: false, reason: 'user-slept' } } + // The resolved pane key is the routing authority and may carry a reminted tab + // newer than persistence. It also recovers legacy records with no tabId. + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + return { ok: false, reason: 'no-tab-identity' } + } return { ok: true, request: { paneKey, worktreeId: record.worktreeId, - ...(record.tabId ? { tabId: record.tabId } : {}) + tabId } } } diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts index a590dc535403..eafc8f6f5af2 100644 --- a/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts @@ -1,6 +1,6 @@ /** - * Addressing a slept agent must be accepted and queued, not refused with the - * same `terminal_not_found` a handle that never existed gets. + * A slept agent stays addressable through its durable Run/Dispatch mailbox; + * a terminal-only address must fail because no resumed reader can consume it. */ import { describe, expect, it } from 'vitest' import type { OrchestrationDb } from '../../orchestration/db' @@ -53,14 +53,21 @@ describe('sending to a slept recipient', () => { expect(resolution.ok).toBe(true) }) - it('accepts a slept pane with no run as a queued terminal mailbox', () => { + it('refuses a slept terminal-only mailbox that no resumed agent can read', () => { const resolution = resolveBareOrchestrationRecipient({ runtime: runtimeWith({ paneKey: PANE_KEY, autoWakes: true }), db: dbWith(), handle: HANDLE }) - expect(resolution).toMatchObject({ ok: true, to: HANDLE }) - expect(resolution.warning?.code).toBe('recipient_asleep') + expect(resolution).toMatchObject({ + ok: false, + code: 'terminal_not_found', + warning: { code: 'recipient_unreachable' } + }) + if (resolution.ok) { + throw new Error('expected slept terminal-only recipient to be rejected') + } + expect(resolution.message).toContain('no durable Run/Dispatch mailbox') }) it('still refuses a handle that resolves to no pane at all', () => { diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts index 56e2d4d149f6..62bf5720c881 100644 --- a/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing.ts @@ -104,7 +104,7 @@ export function resolveBareOrchestrationRecipient(params: { ) } - if (paneKey) { + if (livePaneKey) { return { ok: true, to: handle, @@ -117,6 +117,16 @@ export function resolveBareOrchestrationRecipient(params: { } } + if (sleptPane) { + const message = `${handle} is asleep but has no durable Run/Dispatch mailbox. Use a canonical run: or dispatch: address.` + return { + ok: false, + code: 'terminal_not_found', + message, + warning: { code: 'recipient_unreachable', recipient: handle, message } + } + } + const message = `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` return { ok: false, diff --git a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts index f7d8f32be634..b462b836ca1b 100644 --- a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts +++ b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts @@ -17,11 +17,14 @@ const WORKTREE: ResolvedWorktree = { branch: 'main' } as unknown as ResolvedWorktree +const SLEPT_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const LIVE_LEAF_ID = '22222222-2222-4222-8222-222222222222' + const SLEPT_PANE: ResumableSleptPane = { - paneKey: 'tab-slept:leaf-slept', + paneKey: `tab-slept:${SLEPT_LEAF_ID}`, worktreeId: 'wt-1', tabId: 'tab-slept', - leafId: 'leaf-slept', + leafId: SLEPT_LEAF_ID, title: 'coordinator', agent: 'claude', lastOutputAt: 42 @@ -30,7 +33,7 @@ const SLEPT_PANE: ResumableSleptPane = { function leaf(overrides: Partial = {}): RuntimeLeafRecord { return { tabId: 'tab-live', - leafId: 'leaf-live', + leafId: LIVE_LEAF_ID, worktreeId: 'wt-1', ptyId: 'pty-live', connected: true, @@ -43,7 +46,7 @@ function pty(overrides: Partial = {}): RuntimePtyWorkt ptyId: 'pty-live', worktreeId: 'wt-1', connected: true, - paneKey: 'tab-live:leaf-live', + paneKey: `tab-live:${LIVE_LEAF_ID}`, ...overrides } as unknown as RuntimePtyWorktreeRecord } @@ -69,6 +72,7 @@ function makeList(args: { leaves?: RuntimeLeafRecord[] ptys?: RuntimePtyWorktreeRecord[] sleptPanes?: ResumableSleptPane[] + worktree?: ResolvedWorktree }) { const sleptPaneCalls: (string | null)[] = [] const list = new RuntimeTerminalList({ @@ -79,7 +83,10 @@ function makeList(args: { buildWorktreeFromId: () => WORKTREE, resolveWorktree: () => Promise.resolve(WORKTREE), listKnownWorktrees: () => [WORKTREE], - getWorktreeMap: () => Promise.resolve(new Map([[WORKTREE.id, WORKTREE]])), + getWorktreeMap: () => { + const worktree = args.worktree ?? WORKTREE + return Promise.resolve(new Map([[worktree.id, worktree]])) + }, refreshPtys: () => Promise.resolve({ livePtyIds: ['pty-live'], allLivePtyIds: new Set(['pty-live']) } as never), getPtys: () => args.ptys ?? [], @@ -94,9 +101,11 @@ function makeList(args: { sleptPaneCalls.push(targetWorktreeId) return args.sleptPanes ?? [] }, - buildSleptPaneSummary: (pane) => ({ + buildSleptPaneSummary: (pane, _worktrees, resolvedWorktree) => ({ ...summaryFor(pane), handle: `term_${pane.paneKey}`, + worktreePath: resolvedWorktree?.path ?? '', + branch: resolvedWorktree?.branch ?? '', title: pane.title, resumable: true, agentIdentity: pane.agent @@ -124,7 +133,7 @@ describe('terminal list with slept panes', () => { }) it('keeps a PTY-less leaf visible when a resume record claims it', async () => { - const sleptLeaf = leaf({ tabId: 'tab-slept', leafId: 'leaf-slept', ptyId: null }) + const sleptLeaf = leaf({ tabId: 'tab-slept', leafId: SLEPT_LEAF_ID, ptyId: null }) const { list } = makeList({ leaves: [leaf(), sleptLeaf], ptys: [pty()], @@ -160,10 +169,40 @@ describe('terminal list with slept panes', () => { }) it('does not duplicate a pane that a live PTY row already covers', async () => { - const stalePane = { ...SLEPT_PANE, paneKey: 'tab-live:leaf-live' } + const stalePane = { + ...SLEPT_PANE, + paneKey: `tab-live:${LIVE_LEAF_ID}`, + leafId: LIVE_LEAF_ID + } const { list } = makeList({ ptys: [pty()], sleptPanes: [stalePane] }) const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) expect(result.terminals).toHaveLength(1) expect(result.terminals[0].resumable).toBeUndefined() }) + + it('does not duplicate a slept leaf after its tab id is reminted', async () => { + const remintedLeaf = leaf({ tabId: 'tab-reminted', leafId: SLEPT_LEAF_ID, ptyId: null }) + const { list } = makeList({ + leaves: [leaf(), remintedLeaf], + ptys: [pty()], + sleptPanes: [SLEPT_PANE] + }) + const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) + expect(result.terminals.filter((terminal) => terminal.leafId === SLEPT_LEAF_ID)).toHaveLength(1) + }) + + it('resolves slept-pane metadata through equivalent worktree identity', async () => { + const resolved = { + ...WORKTREE, + id: 'repo-1::/tmp/wt-1', + path: '/tmp/wt-1', + branch: 'feature' + } as ResolvedWorktree + const pane = { ...SLEPT_PANE, worktreeId: 'repo-1::/tmp//wt-1/' } + const { list } = makeList({ sleptPanes: [pane], worktree: resolved }) + + const result = await list.list(undefined, 10, { includeVisualLayouts: false }) + + expect(result.terminals[0]).toMatchObject({ worktreePath: '/tmp/wt-1', branch: 'feature' }) + }) }) diff --git a/src/main/runtime/runtime-terminal-list.ts b/src/main/runtime/runtime-terminal-list.ts index 702f53de1d52..2b873c498e9e 100644 --- a/src/main/runtime/runtime-terminal-list.ts +++ b/src/main/runtime/runtime-terminal-list.ts @@ -10,9 +10,16 @@ import type { ResumableSleptPane } from './resumable-slept-pane-listing' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { buildRuntimeTerminalVisualLayouts } from './runtime-terminal-visual-layout' import { + createIncrementalResolvedWorktreeLookup, includeTargetResolvedWorktree, + runtimeWorktreeLookupKey, type ResolvedWorktree } from './runtime-worktree-path-identity' +import { parsePaneKey } from '../../shared/stable-pane-id' + +function stablePaneLeafKey(worktreeId: string, leafId: string): string { + return `${runtimeWorktreeLookupKey(worktreeId)}\0${leafId}` +} type RuntimeTerminalListDependencies = { getGraphEpoch(): number | null @@ -41,7 +48,8 @@ type RuntimeTerminalListDependencies = { listResumableSleptPanes(targetWorktreeId: string | null): readonly ResumableSleptPane[] buildSleptPaneSummary( pane: ResumableSleptPane, - worktrees: Map + worktrees: Map, + resolvedWorktree: ResolvedWorktree | undefined ): RuntimeTerminalSummary getSnapshots(): ReadonlyMap getTabTitle(tabId: string): string | null @@ -126,8 +134,10 @@ export class RuntimeTerminalList { const sleptPanes = opts.requireFreshPtyLiveness ? [] : this.deps.listResumableSleptPanes(targetId) - const sleptPanesByPaneKey = new Map(sleptPanes.map((pane) => [pane.paneKey, pane])) - const listedPaneKeys = new Set() + const sleptPanesByLeaf = new Map( + sleptPanes.map((pane) => [stablePaneLeafKey(pane.worktreeId, pane.leafId), pane]) + ) + const listedLeaves = new Set() if (graphEpoch !== null) { for (const leaf of this.deps.getLeaves()) { if (targetId && leaf.worktreeId !== targetId) { @@ -136,9 +146,8 @@ export class RuntimeTerminalList { if (opts.requireFreshPtyLiveness && (!leaf.ptyId || !refreshedPtyIds?.has(leaf.ptyId))) { continue } - const sleptPane = leaf.ptyId - ? undefined - : sleptPanesByPaneKey.get(`${leaf.tabId}:${leaf.leafId}`) + const leafIdentity = stablePaneLeafKey(leaf.worktreeId, leaf.leafId) + const sleptPane = leaf.ptyId ? undefined : sleptPanesByLeaf.get(leafIdentity) // Why: a PTY-less leaf in a worktree that still has live PTYs is normally // a surface waiting to bind. A resume record makes it a slept pane, which // must stay visible instead of reading as never having existed. @@ -148,7 +157,7 @@ export class RuntimeTerminalList { if (leaf.ptyId) { leafPtyIds.add(leaf.ptyId) } - listedPaneKeys.add(`${leaf.tabId}:${leaf.leafId}`) + listedLeaves.add(leafIdentity) const summary = this.deps.buildLeafSummary(leaf, worktreesById, provenLivePtyIds) terminals.push(sleptPane ? { ...summary, resumable: true } : summary) } @@ -164,15 +173,27 @@ export class RuntimeTerminalList { continue } if (pty.paneKey) { - listedPaneKeys.add(pty.paneKey) + const leafId = parsePaneKey(pty.paneKey)?.leafId + if (leafId) { + listedLeaves.add(stablePaneLeafKey(pty.worktreeId, leafId)) + } } terminals.push(this.deps.buildPtySummary(pty, worktreesById)) } - for (const pane of sleptPanes) { + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup([ + ...worktreesById.values() + ]) + for (const pane of sleptPanesByLeaf.values()) { // Why: a slept pane's tab is unmounted, so its leaf left the renderer graph // entirely. Synthesize the row from the resume record persistence still holds. - if (!listedPaneKeys.has(pane.paneKey)) { - terminals.push(this.deps.buildSleptPaneSummary(pane, worktreesById)) + if (!listedLeaves.has(stablePaneLeafKey(pane.worktreeId, pane.leafId))) { + terminals.push( + this.deps.buildSleptPaneSummary( + pane, + worktreesById, + findResolvedWorktree(pane.worktreeId) + ) + ) } } const requestedHandles = opts.handles ? new Set(opts.handles) : null diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 2fbb67c3e99b..774e56065a34 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -61,6 +61,7 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ tabMountSends: unknown[][] write: ReturnType remountWithPty: (ptyId: string) => void + setRendererAvailable: (available: boolean) => void }> { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(sleptSession(record)) const runtime = new OrcaRuntimeService(runtimeStore as never) @@ -99,14 +100,20 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ syncGraph(null) const tabMountSends: unknown[][] = [] + let rendererAvailable = true vi.spyOn( runtime as unknown as { getAuthoritativeWindow: () => unknown }, 'getAuthoritativeWindow' - ).mockReturnValue({ - webContents: { - send: (...args: unknown[]) => { - if (args[0] === 'terminal:requestTabMount') { - tabMountSends.push(args) + ).mockImplementation(() => { + if (!rendererAvailable) { + throw new Error('renderer unavailable') + } + return { + webContents: { + send: (...args: unknown[]) => { + if (args[0] === 'terminal:requestTabMount') { + tabMountSends.push(args) + } } } } @@ -125,6 +132,9 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ connected: row!.connected, tabMountSends, write, + setRendererAvailable: (available) => { + rendererAvailable = available + }, // What the renderer does with `terminal:requestTabMount`: the tab remounts and // its agent cold-restores into a fresh PTY that then reports a finished turn. remountWithPty: (ptyId: string) => { @@ -136,6 +146,29 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ } describe('mail addressed to a listed slept pane', () => { + it('retries a parked wake when the renderer graph becomes ready', async () => { + vi.useFakeTimers() + try { + const { runtime, db, handle, tabMountSends, setRendererAvailable } = + await sleptPaneRuntime(sleepingRecord()) + setRendererAvailable(false) + db.insertMessage({ from: 'term_sender', to: handle, subject: 'wake up', type: 'status' }) + runtime.notifyMessageArrived(handle, 'status') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1_500) + expect(tabMountSends).toEqual([]) + + setRendererAvailable(true) + runtime.markGraphReady(1) + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + it('wakes the pane the listing just made addressable', async () => { const { runtime, db, handle, resumable, connected, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) @@ -151,7 +184,8 @@ describe('mail addressed to a listed slept pane', () => { expect(tabMountSends).toHaveLength(1) expect(tabMountSends[0]?.[1]).toMatchObject({ worktreeId: TEST_WORKTREE_ID, - tabId: TAB_ID + tabId: TAB_ID, + paneKey: PANE_KEY }) db.close() }) diff --git a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts index d84283e9181a..aa7d2bc315eb 100644 --- a/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts +++ b/src/main/runtime/terminal-mobile-subscribe-tab-mount.test.ts @@ -47,6 +47,90 @@ function seedRuntime(seeds: HandleSeed[]): { } describe('mobile terminal subscribe tab mount', () => { + it('includes stable pane identity when requesting a real pane mount', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const { runtime, send } = seedRuntime([]) + const internals = runtime as unknown as RuntimeInternals + internals.handles.set('h1', { + handle: 'h1', + worktreeId: 'wt-1', + tabId: 'tab-1', + ptyId: null, + runtimeId: 'rt-test', + rendererGraphEpoch: 1, + leafId, + ptyGeneration: 1 + }) + + runtime.requestRendererTerminalTabMount('h1') + + expect(send).toHaveBeenCalledWith('terminal:requestTabMount', { + worktreeId: 'wt-1', + tabId: 'tab-1', + paneKey: `tab-1:${leafId}` + }) + }) + + it('never adopts a first PTY that the incoming graph assigns to two leaves', async () => { + const targetLeafId = '11111111-1111-4111-8111-111111111111' + const ownerLeafId = '22222222-2222-4222-8222-222222222222' + const runtime = new OrcaRuntimeService() + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: 'Terminal', + activeLeafId: targetLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: targetLeafId, + paneRuntimeId: 1, + ptyId: null + } + ] + }) + const internals = runtime as unknown as RuntimeInternals + const pendingLeaf = internals.leaves.values().next().value + const pendingHandle = internals.issueHandle(pendingLeaf) + + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: 'Terminal', + activeLeafId: ownerLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: targetLeafId, + paneRuntimeId: 1, + ptyId: 'pty-shared' + }, + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: ownerLeafId, + paneRuntimeId: 2, + ptyId: 'pty-shared' + } + ] + }) + + await expect(runtime.readTerminal(pendingHandle)).rejects.toThrow('terminal_handle_stale') + }) + it('keeps a waiting real-tab handle usable when the mount binds its first PTY', async () => { const runtime = new OrcaRuntimeService() const syncGraph = (ptyId: string | null): void => { diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index eaff9e8847a8..a15bb4d26ba2 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -79,11 +79,16 @@ export const uiTerminalAndSessionTabsApi = { return () => ipcRenderer.removeListener('terminal:requestTabCreate', listener) }, onRequestTerminalTabMount: ( - callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + callback: (data: { + worktreeId: string + tabId?: string + ptyId?: string + paneKey?: string + }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { worktreeId: string; tabId?: string; ptyId?: string } + data: { worktreeId: string; tabId?: string; ptyId?: string; paneKey?: string } ) => callback(data) ipcRenderer.on('terminal:requestTabMount', listener) return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 0876104e471a..6b684bf26c6c 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -168,7 +168,12 @@ export type UiCommandEventApi = { callback: (data: RuntimeTerminalCreateRequestPayload) => void ) => () => void onRequestTerminalTabMount: ( - callback: (data: { worktreeId: string; tabId?: string; ptyId?: string }) => void + callback: (data: { + worktreeId: string + tabId?: string + ptyId?: string + paneKey?: string + }) => void ) => () => void replyTerminalCreate: (reply: TerminalTabCreateReply) => void onSplitTerminal: ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts index 86f9523324c5..b87388655c09 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-binding.ts @@ -2,6 +2,7 @@ import type { IDisposable } from '@xterm/xterm' import type { HasPty } from '../terminal-dead-session-reconcile' export type PanePtyBinding = IDisposable & { + paneKey: string syncProcessTracking: () => void noteVisibilityResume: () => void reassertPtySizeAfterWindowWake: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts index 88f7b7cad01d..be8383e21ad7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/session-reconcile-dispose.ts @@ -92,6 +92,7 @@ export function installSessionReconcileDispose(session: ConnectPanePtySession): } return { + paneKey: session.cacheKey, syncProcessTracking() { session.agentCompletionCoordinator.startProcessTracking() // Why: the hidden-delivery gate must follow every pane visibility flip. diff --git a/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts index 9a5a315a12c7..756541472dbb 100644 --- a/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts +++ b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.test.ts @@ -23,6 +23,7 @@ describe('installWakeHibernatedAgentsListener', () => { }) function install(binding: { + paneKey?: string dispose?: () => void wakeHibernatedAgentIfArmed?: (claimed?: Set) => string | null }): void { @@ -73,6 +74,38 @@ describe('installWakeHibernatedAgentsListener', () => { expect(wake).toHaveBeenCalledOnce() }) + it('wakes only the addressed pane in a split tab', () => { + const firstWake = vi.fn(() => null) + const siblingWake = vi.fn(() => null) + cleanups.push( + installWakeHibernatedAgentsListener({ + worktreeId: 'wt-1', + tabId: 'tab-a', + getPanePtyBindings: () => [ + { + dispose: () => {}, + paneKey: 'tab-a:11111111-1111-4111-8111-111111111111', + wakeHibernatedAgentIfArmed: firstWake + }, + { + dispose: () => {}, + paneKey: 'tab-a:22222222-2222-4222-8222-222222222222', + wakeHibernatedAgentIfArmed: siblingWake + } + ] + }) + ) + + dispatchWake({ + worktreeId: 'wt-1', + tabId: 'tab-a', + paneKey: 'tab-a:11111111-1111-4111-8111-111111111111' + }) + + expect(firstWake).toHaveBeenCalledOnce() + expect(siblingWake).not.toHaveBeenCalled() + }) + it('stops listening after cleanup', () => { const wake = vi.fn(() => null) install({ wakeHibernatedAgentIfArmed: wake }) diff --git a/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts index bcb9d85826ee..6c7a1a770942 100644 --- a/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts +++ b/src/renderer/src/components/terminal-pane/wake-hibernated-agents-listener.ts @@ -5,6 +5,7 @@ import { } from '@/constants/terminal' type PaneBindingWithWake = IDisposable & { + paneKey?: string wakeHibernatedAgentIfArmed?: (claimedProviderSessions?: Set) => string | null } @@ -36,9 +37,11 @@ export function installWakeHibernatedAgentsListener( return } for (const panePtyBinding of deps.getPanePtyBindings()) { - const claimKey = (panePtyBinding as PaneBindingWithWake).wakeHibernatedAgentIfArmed?.( - detail.wokenClaimKeys - ) + const binding = panePtyBinding as PaneBindingWithWake + if (detail.paneKey && binding.paneKey !== detail.paneKey) { + continue + } + const claimKey = binding.wakeHibernatedAgentIfArmed?.(detail.wokenClaimKeys) if (claimKey) { detail.wokenClaimKeys?.add(claimKey) } diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 8218d33d6636..0d24d49417c1 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -88,6 +88,8 @@ export type WakeHibernatedAgentsWorktreeDetail = { /** Present on a mail-driven wake: only the named tab's panes may consume it, * so one inbound message cannot respawn every slept agent in the worktree. */ tabId?: string + /** Present on a split-pane mail wake: only this stable pane may consume it. */ + paneKey?: string /** Mutable collector: mounted panes that consume (or latch) the in-place * hibernation wake add their provider-session claim keys here so the * dispatcher's follow-up generic resume skips those sessions instead of diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index a950caba1973..2feaa530c66b 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -248,7 +248,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v // Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840). unsubs.push( - window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId }) => { + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId, paneKey }) => { if (!worktreeId) { return } @@ -270,7 +270,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v } else if (resolution?.kind === 'already-mounted') { // Why: a slept pane whose tab is still mounted cannot be woken by a // mount — its pane holds an armed in-place cold-restore instead. - wakeMountedSleptPaneInPlace(worktreeId, resolution.tabId) + wakeMountedSleptPaneInPlace(worktreeId, resolution.tabId, paneKey) } }) ) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts index 3101e9c5bfa7..1237aada5545 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -446,6 +446,24 @@ describe('wakeMountedSleptPaneInPlace', () => { expect(rec.wakeDetails[0]?.wokenClaimKeys).toBeInstanceOf(Set) }) + it('matches a reminted tab by stable leaf identity and preserves the pane target', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + sleepingRecords = { + k1: { + ...autoSleptRecord, + paneKey: `tab-obsolete:${leafId}`, + tabId: 'tab-obsolete' + } + } + const rec = recordEvents() + const paneKey = `tab-reminted:${leafId}` + + expect(wakeMountedSleptPaneInPlace('wt-1', 'tab-reminted', paneKey)).toBe(true) + + rec.stop() + expect(rec.wakeDetails[0]).toMatchObject({ tabId: 'tab-reminted', paneKey }) + }) + it('never wakes a pane the user slept deliberately', () => { sleepingRecords = { k1: { ...autoSleptRecord, restoreOnTabOpenOnly: true } } const rec = recordEvents() diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts index bcb9aa1bffc9..de3802d36205 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -95,20 +95,31 @@ function dispatchBackgroundMount(worktreeId: string, tabIds: readonly string[] | * cannot respawn the whole worktree. Gated on the same fail-closed predicate * main's wake request uses, so a pane the user slept is never woken by mail. */ -export function wakeMountedSleptPaneInPlace(worktreeId: string, tabId: string): boolean { +export function wakeMountedSleptPaneInPlace( + worktreeId: string, + tabId: string, + paneKey?: string +): boolean { const state = useAppStore.getState() - const eligible = Object.values(state.sleepingAgentSessionsByPaneKey).some( - (record) => - record.worktreeId === worktreeId && - getSleepingRecordTabId(record) === tabId && - mayBackgroundWakeSleepingAgentSession(record) - ) + const targetLeafId = paneKey ? parsePaneKey(paneKey)?.leafId : undefined + const eligible = Object.values(state.sleepingAgentSessionsByPaneKey).some((record) => { + if (record.worktreeId !== worktreeId || !mayBackgroundWakeSleepingAgentSession(record)) { + return false + } + if (!paneKey) { + return getSleepingRecordTabId(record) === tabId + } + return ( + record.paneKey === paneKey || + (targetLeafId !== undefined && parsePaneKey(record.paneKey)?.leafId === targetLeafId) + ) + }) if (!eligible) { return false } window.dispatchEvent( new CustomEvent(WAKE_HIBERNATED_AGENTS_WORKTREE_EVENT, { - detail: { worktreeId, tabId, wokenClaimKeys: new Set() } + detail: { worktreeId, tabId, ...(paneKey ? { paneKey } : {}), wokenClaimKeys: new Set() } }) ) return true From fae2accf64ef0d6bbd8209c44c2c997d04844e45 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 16:41:55 -0700 Subject: [PATCH 08/29] fix(orchestration): preserve slept agent wake recovery --- .../orca-runtime-wait-for-leaf-pty-id.ts | 5 + src/main/runtime/slept-pane-mail-wake.test.ts | 28 +++++ ...nnection-cold-restore-agent-resume.test.ts | 119 ++++++++++++++++++ .../pty-connection/sleeping-record-access.ts | 29 ++++- .../agent-status-manual-sleep-capture.test.ts | 22 ++++ .../slices/agent-status-sleeping-records.ts | 7 ++ 6 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 15f0b0e08b98..460d948a60b6 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -110,6 +110,11 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP if (!args.worktreeId || (!args.tabId && !args.ptyId)) { return false } + // Why: webContents.send can accept the event while a renderer reload has no + // graph/listener to consume it; report failure so graph-ready redrives mail wakes. + if (isInboundMessageTabMount(args.intent) && this.graphStatus !== 'ready') { + return false + } // Why: opening a tab is the documented wake gesture for a pane the user slept // (#11598), so only an inbound message may be refused for one. if ( diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 774e56065a34..b873e745d3ab 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -169,6 +169,34 @@ describe('mail addressed to a listed slept pane', () => { } }) + it('does not lose a wake sent while the renderer graph is reloading', async () => { + vi.useFakeTimers() + try { + const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + expect(runtime.markRendererReloading(1)).not.toBeNull() + db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toEqual([]) + + runtime.markGraphReady(1) + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + it('wakes the pane the listing just made addressable', async () => { const { runtime, db, handle, resumable, connected, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index 35b37855238b..0c51d5465fcc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -402,6 +402,125 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + it('resumes from a sleeping record after the same stable leaf moves to a reminted tab', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: 'fresh-pty', + coldRestore: { scrollback: 'cold-payload', cwd: '/tmp/wt-1' } + } + } + return 'fresh-pty' + }) + transportFactoryQueue.push(transport) + const oldPaneKey = makePaneKey('tab-obsolete', LEAF_1) + const currentPaneKey = makePaneKey('tab-reminted', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-reminted', ptyId: 'lost-pty' }] + }, + ptyIdsByTabId: { + 'tab-reminted': ['lost-pty'] + }, + terminalLayoutsByTabId: { + 'tab-reminted': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'lost-pty' } + } + }, + settings: { + ...mockStoreState.settings, + agentCmdOverrides: {} + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [oldPaneKey]: { + paneKey: oldPaneKey, + tabId: 'tab-obsolete', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-reminted' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + tabId: 'tab-reminted', + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 70)) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'lost-pty', + command: + "codex '--dangerously-bypass-approvals-and-sandbox' 'resume' 'codex-session-reminted'", + resumeProviderSession: { key: 'session_id', id: 'codex-session-reminted' }, + env: expect.objectContaining({ + ORCA_PANE_KEY: currentPaneKey, + ORCA_TAB_ID: 'tab-reminted' + }) + }) + ) + expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(oldPaneKey) + }) + + it('does not guess between conflicting sleeping sessions for a reminted stable leaf', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + const firstPaneKey = makePaneKey('tab-obsolete-a', LEAF_1) + const secondPaneKey = makePaneKey('tab-obsolete-b', LEAF_1) + const sleepingRecord = (paneKey: string, tabId: string, sessionId: string) => ({ + paneKey, + tabId, + worktreeId: 'wt-1', + agent: 'codex' as const, + providerSession: { key: 'session_id' as const, id: sessionId }, + prompt: 'finish the task', + state: 'working' as const, + capturedAt: 1, + updatedAt: 1 + }) + mockStoreState = { + ...mockStoreState, + sleepingAgentSessionsByPaneKey: { + [firstPaneKey]: sleepingRecord(firstPaneKey, 'tab-obsolete-a', 'session-a'), + [secondPaneKey]: sleepingRecord(secondPaneKey, 'tab-obsolete-b', 'session-b') + } + } as StoreState + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) as never + ) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledWith( + expect.not.objectContaining({ command: expect.stringContaining('resume') }) + ) + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + }) + it('marks the pane as freshly started when main declined an unverifiable resume', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 0777dead4310..988c408e6b0f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -1,7 +1,7 @@ import { useAppStore } from '@/store' import type { PtyConnectResult } from '../pty-transport' import { createBrowserUuid } from '@/lib/browser-uuid' -import { parseLegacyNumericPaneKey } from '../../../../../shared/stable-pane-id' +import { parseLegacyNumericPaneKey, parsePaneKey } from '../../../../../shared/stable-pane-id' import { getProviderSessionClaimKey } from '@/lib/sleeping-agent-pane-ownership' import { agentProviderSessionsEqual, @@ -27,6 +27,33 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi if (stableRecord) { return { paneKey: session.cacheKey, record: stableRecord } } + const currentStablePane = parsePaneKey(session.cacheKey) + if (currentStablePane) { + const stableMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( + ([paneKey, record]) => { + const candidate = parsePaneKey(paneKey) + return ( + candidate?.leafId === currentStablePane.leafId && + record.worktreeId === session.deps.worktreeId && + (!record.tabId || record.tabId === candidate.tabId) + ) + } + ) + const providerSessionKeys = new Set( + stableMatches.map(([, record]) => getProviderSessionClaimKey(record)) + ) + if (providerSessionKeys.size === 1) { + const selected = stableMatches + .slice() + .sort(([, a], [, b]) => a.capturedAt - b.capturedAt || a.updatedAt - b.updatedAt)[0] + if (selected) { + const [paneKey, record] = selected + return { paneKey, record } + } + } else if (stableMatches.length > 0) { + return null + } + } const legacyMatches = Object.entries(state.sleepingAgentSessionsByPaneKey).filter( ([paneKey, record]) => { const legacy = parseLegacyNumericPaneKey(paneKey) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts index f1deb30370f9..1a5886adc0e7 100644 --- a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts @@ -141,6 +141,28 @@ describe('manual sleep agent session capture', () => { expect(records['tab-1:working'].restoreOnTabOpenOnly).toBeUndefined() }) + it('marks a retained auto-hibernation record for tab-open-only restore on manual sleep', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + sleepingAgentSessionsByPaneKey: { + 'tab-1:done': makeSleepingRecord({ + paneKey: 'tab-1:done', + state: 'done', + origin: 'worktree-sleep' + }) + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:done'].restoreOnTabOpenOnly).toBe( + true + ) + }) + it('carries a blocked legacy-orchestration-worker flag onto the replacement record', () => { vi.useFakeTimers() vi.setSystemTime(NOW) diff --git a/src/renderer/src/store/slices/agent-status-sleeping-records.ts b/src/renderer/src/store/slices/agent-status-sleeping-records.ts index 90a8b5b196b9..3db8ee5fcc5b 100644 --- a/src/renderer/src/store/slices/agent-status-sleeping-records.ts +++ b/src/renderer/src/store/slices/agent-status-sleeping-records.ts @@ -137,6 +137,13 @@ export function removeSleepingRecordsReplacedByManualWorktreeSleep( // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the // pane was never woken, so it has no live status row to rebuild it from (#11598). if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { + if (record.state === 'done' && record.restoreOnTabOpenOnly !== true) { + if (next === records) { + next = { ...records } + } + next[paneKey] = { ...record, restoreOnTabOpenOnly: true } + changed = true + } continue } if (next === records) { From 00137468ba5d59b587273155c2974e90065cf948 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 16:57:28 -0700 Subject: [PATCH 09/29] fix(orchestration): redrive wakes after graph recovery --- ...orca-runtime-has-terminals-for-worktree.ts | 2 ++ .../orca-runtime-wait-for-leaf-pty-id.ts | 3 +- src/main/runtime/slept-pane-mail-wake.test.ts | 32 +++++++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts index 77082530ed2e..ba5a7c2940f9 100644 --- a/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts +++ b/src/main/runtime/orca-runtime-has-terminals-for-worktree.ts @@ -98,6 +98,7 @@ export class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopE if (restoresPublishedInventory) { this.markSessionTabsInventoryPublished() } + this.retrySleepingPaneWakesWhenGraphReady(windowId) return true } this.graphReloadLifecycle.begin(windowId) @@ -116,6 +117,7 @@ export class OrcaRuntimeWithHasTerminalsForWorktree extends OrcaRuntimeWithStopE this.graphStatus = 'ready' this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() + this.retrySleepingPaneWakesWhenGraphReady(windowId) } markGraphReloadFailed( diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index 460d948a60b6..de082c3abde0 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -170,8 +170,7 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP this.requestRendererTerminalTabMountForPane({ ...request, intent: 'inbound-message' }) }) - markGraphReady(windowId: number): void { - super.markGraphReady(windowId) + protected retrySleepingPaneWakesWhenGraphReady(windowId: number): void { if (windowId === this.authoritativeWindowId && this.graphStatus === 'ready') { this.sleepingPaneWakes.retryPending() } diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index b873e745d3ab..5279cfc79b40 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -181,8 +181,7 @@ describe('mail addressed to a listed slept pane', () => { subject: 'worker done', type: 'worker_done' }) - runtime.notifyMessageArrived('run:run_test', 'worker_done') - await Promise.resolve() + runtime.requestSleepingRecipientWake('run:run_test') await vi.advanceTimersByTimeAsync(1_500) expect(tabMountSends).toEqual([]) @@ -197,6 +196,35 @@ describe('mail addressed to a listed slept pane', () => { } }) + it('redrives a parked wake when a renderer graph reload is cancelled', async () => { + vi.useFakeTimers() + try { + const { runtime, db, handle, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const fence = runtime.markRendererReloading(1) + if (!fence) { + throw new Error('expected renderer reload fence') + } + db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.requestSleepingRecipientWake('run:run_test') + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toEqual([]) + expect(runtime.markRendererReloadCancelled(1, fence)).toBe(true) + await vi.advanceTimersByTimeAsync(1_500) + + expect(tabMountSends).toHaveLength(1) + db.close() + } finally { + vi.useRealTimers() + } + }) + it('wakes the pane the listing just made addressable', async () => { const { runtime, db, handle, resumable, connected, tabMountSends } = await sleptPaneRuntime(sleepingRecord()) From 990e8bd35f7406096e3ed0133a2b68833c85029e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:09:15 -0400 Subject: [PATCH 10/29] Fix status bar runtime target render loop (#18685) --- ...rtsStatusSegment.render-stability.test.tsx | 89 +++++++++++++++++++ .../runtime/use-worktree-runtime-target.ts | 6 +- 2 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx diff --git a/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx b/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx new file mode 100644 index 000000000000..d6d4ad951840 --- /dev/null +++ b/src/renderer/src/components/status-bar/PortsStatusSegment.render-stability.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/components/ui/popover', () => ({ + Popover: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: React.ReactNode }) => <>{children}, + PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('@/components/SelectedTextCopyMenu', () => ({ + SelectedTextCopyMenu: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +vi.mock('./ports-status-popover-rows', () => ({ + PortRow: () =>
, + WorkspaceGroupRows: () =>
+})) + +vi.mock('@/lib/react-error-boundary-reporting', () => ({ + reportReactErrorBoundaryCrash: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, options?: Record) => + options + ? fallback.replace(/{{(\w+)}}/g, (_match, name: string) => String(options[name] ?? '')) + : fallback +})) + +import { RecoverableRenderErrorBoundary } from '../error-boundaries/RecoverableRenderErrorBoundary' +import { useAppStore } from '@/store' +import { PortsStatusSegment } from './PortsStatusSegment' + +describe('PortsStatusSegment render stability', () => { + let container: HTMLDivElement + let root: Root + let consoleError: ReturnType + + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + consoleError.mockRestore() + }) + + it('keeps the status controls mounted when the runtime owner is local', () => { + act(() => { + root.render( + + + + ) + }) + + const underlyingException = consoleError.mock.calls.find( + ([message, error]) => + message === '[oracle.status-bar] render crash contained by boundary' && + error instanceof Error && + error.message.includes('Maximum update depth exceeded') + ) + + expect.soft(container.textContent).not.toContain('The status bar hit an error.') + expect.soft(underlyingException).toBeUndefined() + expect(container.querySelector('button[aria-label^="Ports, 0 workspace"]')).not.toBeNull() + }) +}) diff --git a/src/renderer/src/runtime/use-worktree-runtime-target.ts b/src/renderer/src/runtime/use-worktree-runtime-target.ts index 25bd9099e90b..22ef4ed5c4b2 100644 --- a/src/renderer/src/runtime/use-worktree-runtime-target.ts +++ b/src/renderer/src/runtime/use-worktree-runtime-target.ts @@ -1,3 +1,4 @@ +import { useMemo } from 'react' import { useAppStore } from '@/store' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { runtimeTargetForExecutionHostId, type RuntimeClientTarget } from './runtime-client-target' @@ -10,7 +11,6 @@ import { runtimeTargetForExecutionHostId, type RuntimeClientTarget } from './run export function useWorktreeRuntimeTarget( worktreeId: string | null | undefined ): RuntimeClientTarget | null { - return useAppStore((state) => - runtimeTargetForExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId)) - ) + const executionHostId = useAppStore((state) => getExecutionHostIdForWorktree(state, worktreeId)) + return useMemo(() => runtimeTargetForExecutionHostId(executionHostId), [executionHostId]) } From a793f51f3f5e7a87b504f1e867fe0ef6d8be2b30 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 19:33:03 -0700 Subject: [PATCH 11/29] fix(orchestration): deliver mail after Codex wake --- ...a-runtime-get-terminal-interactive-wait.ts | 48 ++++ .../orca-runtime-stop-requested-pty-ids.ts | 2 + .../mailbox-pointer-candidates.ts | 36 +++ .../orchestration/mailbox-pointer-delivery.ts | 142 ++++++++---- .../orchestration/mailbox-pointer-stage.ts | 31 ++- .../orchestration/mailbox-pointer-state.ts | 16 +- .../orchestration/mailbox-pointer-submit.ts | 35 ++- .../mailbox-sleeping-recipient-wake.test.ts | 2 + .../mailbox-statusless-codex-delivery.test.ts | 206 ++++++++++++++++++ ...lbox-statusless-codex-proof-coordinator.ts | 64 ++++++ .../mailbox-statusless-idle-proof.ts | 26 +++ src/main/runtime/slept-pane-mail-wake.test.ts | 77 +++++++ 12 files changed, 636 insertions(+), 49 deletions(-) create mode 100644 src/main/runtime/orchestration/mailbox-pointer-candidates.ts create mode 100644 src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts create mode 100644 src/main/runtime/orchestration/mailbox-statusless-codex-proof-coordinator.ts create mode 100644 src/main/runtime/orchestration/mailbox-statusless-idle-proof.ts diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index f428404de900..f720d4a1d8b3 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -132,6 +132,54 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` } + protected async proveStatuslessCodexIdle( + handle: string, + expectedPtyId: string + ): Promise { + const before = this.getStatuslessCodexIdleProofCandidate(handle, expectedPtyId) + if (!before) { + return null + } + let wait + try { + wait = await this.waitForTerminal(handle, { condition: 'tui-idle' }) + } catch { + return null + } + if (!wait.satisfied || wait.status !== 'running') { + return null + } + const after = this.getStatuslessCodexIdleProofCandidate(handle, expectedPtyId) + return after?.processIncarnation === before.processIncarnation ? after.processIncarnation : null + } + + private getStatuslessCodexIdleProofCandidate( + handle: string, + expectedPtyId: string + ): { processIncarnation: string } | null { + try { + const { leaf } = this.getLiveLeafForHandle(handle) + const pty = this.ptysById.get(expectedPtyId) + if ( + leaf.ptyId !== expectedPtyId || + !leaf.connected || + !leaf.writable || + leaf.lastAgentStatus !== null || + leaf.lastAgentStatusObservedLive || + !pty?.connected || + pty.launchAgent !== 'codex' || + pty.lastAgentStatus !== null || + pty.lastAgentStatusObservedLive + ) { + return null + } + const processIncarnation = this.getTerminalProcessIncarnation(handle) + return processIncarnation ? { processIncarnation } : null + } catch { + return null + } + } + getExactWorkerProviderSession( handle: string, observedAfter: number diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 55848b38abcb..00d558f25818 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -201,7 +201,9 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), getTabTitle: (tabId) => this.tabs.get(tabId)?.title, getTerminalHandleForLeafKey: (leafKey) => this.handleByLeafKey.get(leafKey), + getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle), isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId), + proveStatuslessCodexIdle: (handle, ptyId) => this.proveStatuslessCodexIdle(handle, ptyId), redriveMailbox: (mailboxHandle, reservedTypes) => this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes), requestSleepingRecipientWake: (mailboxHandle) => diff --git a/src/main/runtime/orchestration/mailbox-pointer-candidates.ts b/src/main/runtime/orchestration/mailbox-pointer-candidates.ts new file mode 100644 index 000000000000..a2ae0db39f6b --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-pointer-candidates.ts @@ -0,0 +1,36 @@ +import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type OrchestrationDb } from './db' +import { + hasUnfilteredOrchestrationWaiter, + messageTypeHasOrchestrationWaiter, + type OrchestrationMessageWaiter +} from './mailbox-pointer-eligibility' + +export function getOrchestrationMailboxPointerCandidates< + TWaiter extends OrchestrationMessageWaiter +>( + db: OrchestrationDb, + mailboxHandle: string, + waiters: ReadonlySet | undefined, + reservedTypes?: ReadonlySet +) { + if (hasUnfilteredOrchestrationWaiter(waiters)) { + return [] + } + const excludedTypes = new Set(reservedTypes) + for (const waiter of waiters ?? []) { + for (const type of waiter.typeFilter ?? []) { + excludedTypes.add(type) + } + } + return db + .getUndeliveredUnreadMessages(mailboxHandle, undefined, { + excludeTypes: [...excludedTypes], + limit: ORCHESTRATION_DELIVERY_BATCH_LIMIT + }) + .filter( + (message) => + !reservedTypes?.has(message.type) && + !messageTypeHasOrchestrationWaiter(waiters, message.type) + ) + .slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT) +} diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index d983e64da13d..3f830f37c63e 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -1,15 +1,15 @@ -import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type OrchestrationDb } from './db' +import type { OrchestrationDb } from './db' import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' -import { - hasUnfilteredOrchestrationWaiter, - messageTypeHasOrchestrationWaiter, - type OrchestrationMessageWaiter -} from './mailbox-pointer-eligibility' +import type { OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import { getOrchestrationMailboxPointerCandidates } from './mailbox-pointer-candidates' +import { OrchestrationMailboxStatuslessCodexProofCoordinator } from './mailbox-statusless-codex-proof-coordinator' +import { isStatuslessIdleProofCurrent } from './mailbox-statusless-idle-proof' import { stageOrchestrationMailboxPointer } from './mailbox-pointer-stage' import { OrchestrationMailboxPointerState, - type OrchestrationMailboxDeliveryFlight + type OrchestrationMailboxDeliveryFlight, + type OrchestrationStatuslessIdleProof } from './mailbox-pointer-state' export type { OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' @@ -24,7 +24,9 @@ type PointerDeliveryDependencies = { getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined getTabTitle: (tabId: string) => string | null | undefined getTerminalHandleForLeafKey: (leafKey: string) => string | undefined + getTerminalProcessIncarnation: (terminalHandle: string) => string | null isLeafPtyProvenAbsent: (ptyId: string) => Promise + proveStatuslessCodexIdle?: (terminalHandle: string, ptyId: string) => Promise redriveMailbox: (mailboxHandle: string, reservedTypes?: ReadonlySet) => void /** Ask for an auto-slept recipient to be woken. Optional so hosts that predate * the wake path keep today's silent give-up. */ @@ -34,7 +36,11 @@ type PointerDeliveryDependencies = { export class OrchestrationMailboxPointerDelivery { private readonly state = new OrchestrationMailboxPointerState() - constructor(private readonly deps: PointerDeliveryDependencies) {} + private readonly statuslessCodexProofs: OrchestrationMailboxStatuslessCodexProofCoordinator + + constructor(private readonly deps: PointerDeliveryDependencies) { + this.statuslessCodexProofs = new OrchestrationMailboxStatuslessCodexProofCoordinator(deps) + } deliverForHandle(handle: string, reservedTypes?: ReadonlySet): void { const terminalHandle = this.deps.deliveryTarget.resolveTerminalHandle(handle) @@ -55,13 +61,45 @@ export class OrchestrationMailboxPointerDelivery { + const currentMailbox = this.deps.mailboxOwner.resolve(currentLeaf, handle) + if (currentMailbox === mailboxHandle) { + this.deliver(currentLeaf, { + mailboxHandle, + reservedTypes, + statuslessIdleProof + }) + } + } + ) } catch { // Persisted mail remains available to explicit check or a later idle edge. this.deps.requestSleepingRecipientWake?.(handle) @@ -74,6 +112,7 @@ export class OrchestrationMailboxPointerDelivery skipAbsenceProbe?: boolean + statuslessIdleProof?: OrchestrationStatuslessIdleProof } ): void { const db = this.deps.getDb() @@ -84,11 +123,27 @@ export class OrchestrationMailboxPointerDelivery - !options.reservedTypes?.has(message.type) && - !messageTypeHasOrchestrationWaiter(waiters, message.type) - ) - .slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT) + const unread = getOrchestrationMailboxPointerCandidates( + db, + mailboxHandle, + this.deps.getMessageWaiters(mailboxHandle), + options.reservedTypes + ) if (unread.length === 0 || !leaf.writable || !leaf.ptyId) { return } @@ -140,7 +180,7 @@ export class OrchestrationMailboxPointerDelivery - this.redeliverAfterProbe(probedLeaf, ptyId, probedMailbox) + this.redeliverAfterProbe(probedLeaf, ptyId, probedMailbox, options.statuslessIdleProof) ) ) { return @@ -154,6 +194,7 @@ export class OrchestrationMailboxPointerDelivery this.settle(settledPtyId, settledFlight), redrive: (redriveMailbox, force) => this.redrive(redriveMailbox, force) }, - { leaf, mailboxHandle, unread, newestSequence } + { + leaf, + mailboxHandle, + unread, + newestSequence, + statuslessIdleProof: options.statuslessIdleProof + } ) } @@ -171,6 +218,7 @@ export class OrchestrationMailboxPointerDelivery = { getLeafKey: (tabId: string, leafId: string) => string getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined getTabTitle: (tabId: string) => string | null | undefined + getTerminalProcessIncarnation: (terminalHandle: string) => string | null isLeafPtyProvenAbsent: (ptyId: string) => Promise requestSleepingRecipientWake?: (mailboxHandle: string) => void writePty: (ptyId: string, data: string) => boolean | Promise @@ -35,6 +38,7 @@ type PointerStageInput = { mailboxHandle: string unread: readonly { id: string; type: string; sequence: number }[] newestSequence: number + statuslessIdleProof?: OrchestrationStatuslessIdleProof } /** Write the pointer text into the recipient's composer, then arm its submit. */ @@ -43,7 +47,15 @@ export function stageOrchestrationMailboxPointer( if (!accepted || !deps.state.isCurrentFlight(ptyId, flight)) { return } + const currentLeaf = deps.getLeaf(deps.getLeafKey(leaf.tabId, leaf.leafId)) + if ( + input.statuslessIdleProof && + (!currentLeaf || + !isStatuslessIdleProofCurrent( + currentLeaf, + input.statuslessIdleProof, + deps.getTerminalProcessIncarnation + )) + ) { + return + } const db = deps.getDb() if ( !db || @@ -111,7 +135,8 @@ function finishPointerWrite( messages: unread, newestSequence, ptyId, - flight + flight, + statuslessIdleProof: input.statuslessIdleProof }), POINTER_SUBMIT_DELAY_MS ) diff --git a/src/main/runtime/orchestration/mailbox-pointer-state.ts b/src/main/runtime/orchestration/mailbox-pointer-state.ts index b0f4330b3f89..2d79a1abf546 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-state.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-state.ts @@ -5,9 +5,16 @@ export type OrchestrationMailboxDeliveryFlight = { stagedMessageIds: string[] } +export type OrchestrationStatuslessIdleProof = { + ptyId: string + terminalHandle: string + processIncarnation: string +} + export type ParkedOrchestrationMailboxDelivery = { leaf: OrchestrationMailboxLeaf reservedTypes?: ReadonlySet + statuslessIdleProof?: OrchestrationStatuslessIdleProof } export class OrchestrationMailboxPointerState { @@ -54,7 +61,8 @@ export class OrchestrationMailboxPointerState { ptyId: string, mailboxHandle: string, leaf: OrchestrationMailboxLeaf, - reservedTypes?: ReadonlySet + reservedTypes?: ReadonlySet, + statuslessIdleProof?: OrchestrationStatuslessIdleProof ): void { const parked = this.parkedDeliveriesByPtyId.get(ptyId) ?? new Map() const priorEntry = parked.get(mailboxHandle) @@ -66,7 +74,11 @@ export class OrchestrationMailboxPointerState { } else if (prior && current) { merged = new Set([...prior, ...current]) } - parked.set(mailboxHandle, { leaf, reservedTypes: merged }) + parked.set(mailboxHandle, { + leaf, + reservedTypes: merged, + statuslessIdleProof + }) this.parkedDeliveriesByPtyId.set(ptyId, parked) } diff --git a/src/main/runtime/orchestration/mailbox-pointer-submit.ts b/src/main/runtime/orchestration/mailbox-pointer-submit.ts index 8043376c37f4..50373a48591e 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-submit.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-submit.ts @@ -4,9 +4,14 @@ import { type OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import { + isStatuslessIdleProofCurrent, + isStatuslessIdleProofProcessCurrent +} from './mailbox-statusless-idle-proof' import type { OrchestrationMailboxDeliveryFlight, - OrchestrationMailboxPointerState + OrchestrationMailboxPointerState, + OrchestrationStatuslessIdleProof } from './mailbox-pointer-state' type PointerSubmitDependencies = { @@ -15,6 +20,7 @@ type PointerSubmitDependencies = { getDb: () => OrchestrationDb | null getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined getLeafKey: (tabId: string, leafId: string) => string + getTerminalProcessIncarnation: (terminalHandle: string) => string | null getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined isLeafPtyProvenAbsent: (ptyId: string) => Promise requestSleepingRecipientWake?: (mailboxHandle: string) => void @@ -32,6 +38,7 @@ export function submitOrchestrationMailboxPointer( + deps: PointerSubmitDependencies, + leaf: OrchestrationMailboxLeaf, + proof: OrchestrationStatuslessIdleProof | undefined +): boolean { + if (!proof) { + // Once staged, working is queue-safe; idle-only strands Orca-owned text in the composer. + return ( + leaf.lastAgentStatusObservedLive && + (leaf.lastAgentStatus === 'idle' || leaf.lastAgentStatus === 'working') + ) + } + return isStatuslessIdleProofCurrent(leaf, proof, deps.getTerminalProcessIncarnation) +} diff --git a/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts b/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts index b551be0ce8f5..8dbf97602a46 100644 --- a/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts +++ b/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts @@ -50,6 +50,7 @@ function makeDelivery(overrides: { getMessageWaiters: () => undefined, getTabTitle: () => null, getTerminalHandleForLeafKey: () => undefined, + getTerminalProcessIncarnation: () => null, isLeafPtyProvenAbsent: () => Promise.resolve(false), redriveMailbox: () => undefined, requestSleepingRecipientWake, @@ -120,6 +121,7 @@ describe('mail delivery to a pane with no process', () => { getDb: () => ({ markAsUndelivered }) as unknown as OrchestrationDb, getLeaf: () => undefined, getLeafKey: (tabId, leafId) => `${tabId}:${leafId}`, + getTerminalProcessIncarnation: () => null, getMessageWaiters: () => undefined, isLeafPtyProvenAbsent: () => Promise.resolve(true), requestSleepingRecipientWake, diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts new file mode 100644 index 000000000000..e7b9d90c110c --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { InMemoryOrchestrationMessages, deferred } from '../orca-runtime-test-fixtures.spec' +import type { OrchestrationDb } from './db' +import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' +import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import { OrchestrationMailboxPointerDelivery } from './mailbox-pointer-delivery' + +const MAILBOX = 'run:run-1' +const TERMINAL_HANDLE = 'term-1' + +function statuslessLeaf(): OrchestrationMailboxLeaf { + return { + tabId: 'tab-1', + leafId: 'leaf-1', + ptyId: 'pty-1', + writable: true, + lastAgentStatus: null, + lastAgentStatusObservedLive: false, + lastOscTitle: null + } +} + +function makeHarness( + proveStatuslessCodexIdle: (terminalHandle: string, ptyId: string) => Promise, + options: { withMessage?: boolean } = {} +) { + let leaf = statuslessLeaf() + let processIncarnation = 'pty-1:incarnation-1' + const db = new InMemoryOrchestrationMessages() + if (options.withMessage !== false) { + db.insertMessage({ from: 'term-sender', to: MAILBOX, subject: 'wake work' }) + } + const writePty = vi.fn().mockReturnValue(true) + const delivery = new OrchestrationMailboxPointerDelivery({ + mailboxOwner: { resolve: () => MAILBOX } as unknown as OrchestrationMailboxOwner, + deliveryTarget: { + resolveTerminalHandle: () => TERMINAL_HANDLE, + deferForAbsenceProbe: () => false + } as unknown as OrchestrationMailboxDeliveryTarget, + getDb: () => db as unknown as OrchestrationDb, + getLeaf: () => leaf, + getLeafKey: (tabId, leafId) => `${tabId}:${leafId}`, + getLiveLeafForHandle: () => leaf, + getMessageWaiters: () => undefined, + getTabTitle: () => null, + getTerminalHandleForLeafKey: () => TERMINAL_HANDLE, + getTerminalProcessIncarnation: () => processIncarnation, + isLeafPtyProvenAbsent: () => Promise.resolve(false), + proveStatuslessCodexIdle, + redriveMailbox: () => undefined, + writePty + }) + return { + db, + delivery, + getLeaf: () => leaf, + setLeaf: (next: OrchestrationMailboxLeaf) => { + leaf = next + }, + setProcessIncarnation: (next: string) => { + processIncarnation = next + }, + writePty + } +} + +describe('statusless Codex mailbox pointer delivery', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('submits after the current Codex process is proven idle', async () => { + const proveStatuslessCodexIdle = vi.fn().mockResolvedValue('pty-1:incarnation-1') + const { db, delivery, writePty } = makeHarness(proveStatuslessCodexIdle) + + delivery.deliverForHandle(MAILBOX) + await vi.waitFor(() => expect(writePty).toHaveBeenCalledTimes(1)) + await vi.advanceTimersByTimeAsync(500) + + expect(proveStatuslessCodexIdle).toHaveBeenCalledWith(TERMINAL_HANDLE, 'pty-1') + expect(writePty.mock.calls[0]?.[1]).toContain('You have 1 orchestration message') + expect(writePty).toHaveBeenCalledWith('pty-1', '\r') + expect(db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toEqual(expect.any(String)) + }) + + it.each(['working', 'permission'] as const)( + 'does not prove or inject while a live %s status is authoritative', + async (status) => { + const proveStatuslessCodexIdle = vi.fn().mockResolvedValue('pty-1:incarnation-1') + const harness = makeHarness(proveStatuslessCodexIdle) + harness.setLeaf({ + ...harness.getLeaf(), + lastAgentStatus: status, + lastAgentStatusObservedLive: true + }) + + harness.delivery.deliverForHandle(MAILBOX) + await Promise.resolve() + + expect(proveStatuslessCodexIdle).not.toHaveBeenCalled() + expect(harness.writePty).not.toHaveBeenCalled() + } + ) + + it('does not start an idle proof when the mailbox has no pointer candidate', () => { + const proveStatuslessCodexIdle = vi.fn().mockResolvedValue('pty-1:incarnation-1') + const harness = makeHarness(proveStatuslessCodexIdle, { withMessage: false }) + + harness.delivery.deliverForHandle(MAILBOX) + + expect(proveStatuslessCodexIdle).not.toHaveBeenCalled() + }) + + it('rejects a proof when the pane is rebound to another PTY', async () => { + const proof = deferred() + const harness = makeHarness(() => proof.promise) + + harness.delivery.deliverForHandle(MAILBOX) + harness.setLeaf({ ...harness.getLeaf(), ptyId: 'pty-2' }) + proof.resolve('pty-1:incarnation-1') + await Promise.resolve() + await Promise.resolve() + + expect(harness.writePty).not.toHaveBeenCalled() + }) + + it('rejects a proof when the PTY process incarnation is replaced', async () => { + const proof = deferred() + const harness = makeHarness(() => proof.promise) + + harness.delivery.deliverForHandle(MAILBOX) + harness.setProcessIncarnation('pty-1:incarnation-2') + proof.resolve('pty-1:incarnation-1') + await Promise.resolve() + await Promise.resolve() + + expect(harness.writePty).not.toHaveBeenCalled() + }) + + it.each(['working', 'permission'] as const)( + 'does not submit when live %s status contradicts the completed proof', + async (status) => { + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1')) + + harness.delivery.deliverForHandle(MAILBOX) + await vi.waitFor(() => expect(harness.writePty).toHaveBeenCalledTimes(1)) + harness.setLeaf({ + ...harness.getLeaf(), + lastAgentStatus: status, + lastAgentStatusObservedLive: true + }) + await vi.advanceTimersByTimeAsync(500) + + expect(harness.writePty).not.toHaveBeenCalledWith('pty-1', '\r') + } + ) + + it('requeues staged mail when the process is replaced before submit', async () => { + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1')) + + harness.delivery.deliverForHandle(MAILBOX) + await vi.waitFor(() => expect(harness.writePty).toHaveBeenCalledTimes(1)) + harness.setProcessIncarnation('pty-1:incarnation-2') + await vi.advanceTimersByTimeAsync(500) + + expect(harness.writePty).not.toHaveBeenCalledWith('pty-1', '\r') + expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toBeNull() + }) + + it('deduplicates concurrent idle proofs for the same PTY', async () => { + const proof = deferred() + const proveStatuslessCodexIdle = vi.fn(() => proof.promise) + const harness = makeHarness(proveStatuslessCodexIdle) + + harness.delivery.deliverForHandle(MAILBOX) + harness.delivery.deliverForHandle(MAILBOX) + + expect(proveStatuslessCodexIdle).toHaveBeenCalledTimes(1) + proof.resolve('pty-1:incarnation-1') + await vi.waitFor(() => expect(harness.writePty).toHaveBeenCalledTimes(1)) + }) + + it('does not let a retired proof block a recycled PTY incarnation', async () => { + const retiredProof = deferred() + const proveStatuslessCodexIdle = vi + .fn() + .mockImplementationOnce(() => retiredProof.promise) + .mockResolvedValueOnce('pty-1:incarnation-2') + const harness = makeHarness(proveStatuslessCodexIdle) + + harness.delivery.deliverForHandle(MAILBOX) + harness.delivery.retirePty('pty-1') + harness.setProcessIncarnation('pty-1:incarnation-2') + harness.delivery.deliverForHandle(MAILBOX) + + await vi.waitFor(() => expect(harness.writePty).toHaveBeenCalledTimes(1)) + expect(proveStatuslessCodexIdle).toHaveBeenCalledTimes(2) + retiredProof.resolve('pty-1:incarnation-1') + await Promise.resolve() + expect(harness.writePty).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-proof-coordinator.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-proof-coordinator.ts new file mode 100644 index 000000000000..25fda54fc7a7 --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-proof-coordinator.ts @@ -0,0 +1,64 @@ +import type { OrchestrationMailboxLeaf } from './mailbox-owner' +import { isStatuslessIdleProofCurrent } from './mailbox-statusless-idle-proof' +import type { OrchestrationStatuslessIdleProof } from './mailbox-pointer-state' + +type StatuslessCodexProofDependencies = { + getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined + getLeafKey: (tabId: string, leafId: string) => string + getTerminalProcessIncarnation: (terminalHandle: string) => string | null + proveStatuslessCodexIdle?: (terminalHandle: string, ptyId: string) => Promise +} + +export class OrchestrationMailboxStatuslessCodexProofCoordinator { + private readonly proofsByPtyId = new Map>() + + constructor(private readonly deps: StatuslessCodexProofDependencies) {} + + retirePty(ptyId: string): void { + this.proofsByPtyId.delete(ptyId) + } + + runWhenProven( + terminalHandle: string, + leaf: OrchestrationMailboxLeaf, + onProven: ( + currentLeaf: OrchestrationMailboxLeaf, + proof: OrchestrationStatuslessIdleProof + ) => void + ): void { + const prove = this.deps.proveStatuslessCodexIdle + const ptyId = leaf.ptyId + if (!prove || !ptyId) { + return + } + let proofPromise = this.proofsByPtyId.get(ptyId) + if (!proofPromise) { + try { + proofPromise = prove(terminalHandle, ptyId).catch(() => null) + } catch { + return + } + this.proofsByPtyId.set(ptyId, proofPromise) + void proofPromise.finally(() => { + if (this.proofsByPtyId.get(ptyId) === proofPromise) { + this.proofsByPtyId.delete(ptyId) + } + }) + } + void proofPromise + .then((processIncarnation) => { + if (!processIncarnation) { + return + } + const currentLeaf = this.deps.getLeaf(this.deps.getLeafKey(leaf.tabId, leaf.leafId)) + const proof = { ptyId, terminalHandle, processIncarnation } + if ( + currentLeaf?.writable && + isStatuslessIdleProofCurrent(currentLeaf, proof, this.deps.getTerminalProcessIncarnation) + ) { + onProven(currentLeaf, proof) + } + }) + .catch(() => undefined) + } +} diff --git a/src/main/runtime/orchestration/mailbox-statusless-idle-proof.ts b/src/main/runtime/orchestration/mailbox-statusless-idle-proof.ts new file mode 100644 index 000000000000..382fc09fb27b --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-statusless-idle-proof.ts @@ -0,0 +1,26 @@ +import type { OrchestrationMailboxLeaf } from './mailbox-owner' +import type { OrchestrationStatuslessIdleProof } from './mailbox-pointer-state' + +export function isStatuslessIdleProofCurrent( + leaf: OrchestrationMailboxLeaf, + proof: OrchestrationStatuslessIdleProof, + getTerminalProcessIncarnation: (terminalHandle: string) => string | null +): boolean { + if (!isStatuslessIdleProofProcessCurrent(leaf, proof, getTerminalProcessIncarnation)) { + return false + } + return leaf.lastAgentStatusObservedLive + ? leaf.lastAgentStatus === 'idle' + : leaf.lastAgentStatus === null +} + +export function isStatuslessIdleProofProcessCurrent( + leaf: OrchestrationMailboxLeaf, + proof: OrchestrationStatuslessIdleProof, + getTerminalProcessIncarnation: (terminalHandle: string) => string | null +): boolean { + return ( + leaf.ptyId === proof.ptyId && + getTerminalProcessIncarnation(proof.terminalHandle) === proof.processIncarnation + ) +} diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 5279cfc79b40..e0a0ff28aedc 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -61,6 +61,7 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ tabMountSends: unknown[][] write: ReturnType remountWithPty: (ptyId: string) => void + remountStatuslessCodex: (ptyId: string) => void setRendererAvailable: (available: boolean) => void }> { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(sleptSession(record)) @@ -141,6 +142,27 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ syncGraph(ptyId) runtime.onPtyData(ptyId, '\x1b]0;Claude working\x07', 100) runtime.onPtyData(ptyId, '\x1b]0;Claude done\x07', 101) + }, + remountStatuslessCodex: (ptyId: string) => { + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'codex-reattach-incarnation', + providerReattachLaunchIdentity: { + incarnationId: 'codex-reattach-incarnation', + launchAgent: 'codex' + } + }) + syncGraph(ptyId) + runtime.onPtyData( + ptyId, + [ + ' >_ OpenAI Codex (v0.132.0)\n', + ' model: gpt-5.5 high /model to change\n', + ' directory: ~/orca/workspaces/orca/impl-agent-sleep-wake\n' + ].join(''), + 100 + ) } } } @@ -317,4 +339,59 @@ describe('mail addressed to a listed slept pane', () => { expect(message.delivered_at).toEqual(expect.any(String)) db.close() }) + + it('delivers queued run mail after a statusless Codex provider reattach', async () => { + vi.useFakeTimers() + try { + const { runtime, db, handle, write, remountStatuslessCodex } = await sleptPaneRuntime( + sleepingRecord({ agent: 'codex' }) + ) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + + remountStatuslessCodex('pty-codex-woken') + const runtimeState = runtime as unknown as { + leaves: Map< + string, + { leafId: string; lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } + > + ptysById: Map< + string, + { lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } + > + } + expect( + [...runtimeState.leaves.values()].find((leaf) => leaf.leafId === LEAF_ID) + ).toMatchObject({ + lastAgentStatus: null, + lastAgentStatusObservedLive: false + }) + expect(runtimeState.ptysById.get('pty-codex-woken')).toMatchObject({ + lastAgentStatus: null, + lastAgentStatusObservedLive: false + }) + runtime.deliverPendingMessagesForHandle('run:run_test') + await vi.waitFor(() => + expect(write).toHaveBeenCalledWith( + 'pty-codex-woken', + expect.stringContaining('You have 1 orchestration message') + ) + ) + await vi.advanceTimersByTimeAsync(500) + + expect(write).toHaveBeenCalledWith('pty-codex-woken', '\r') + expect(message.delivered_at).toEqual(expect.any(String)) + db.close() + } finally { + vi.useRealTimers() + } + }) }) From 31aeb74aa087467f660eab0a1ee2cad1e44496c4 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 20:12:07 -0700 Subject: [PATCH 12/29] fix(orchestration): redrive mail on Codex reattach --- .../runtime/orca-runtime-sync-window-graph.ts | 28 ++++++++++++++----- src/main/runtime/slept-pane-mail-wake.test.ts | 4 ++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 38da206518ce..98b67fc2dbca 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -303,19 +303,33 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow } for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) - const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) + const previousLeaf = previousLeaves.get(leafKey) + const becameWritable = + leaf.writable && + (!graphWasReady || previousLeaf?.ptyId !== leaf.ptyId || !previousLeaf.writable) if ( this._orchestrationDb && leaf.lastAgentStatus === 'idle' && leaf.lastAgentStatusObservedLive && - leaf.writable && - (!graphWasReady || - previousLeaf?.ptyId !== leaf.ptyId || - !previousLeaf.writable || - previousLeaf.lastAgentStatus !== 'idle' || - !previousLeaf.lastAgentStatusObservedLive) + (becameWritable || + previousLeaf?.lastAgentStatus !== 'idle' || + !previousLeaf?.lastAgentStatusObservedLive) ) { this.deliverPendingMessagesForLeaf(leaf) + } else if ( + this._orchestrationDb && + becameWritable && + leaf.ptyId && + this.ptysById.get(leaf.ptyId)?.launchAgent === 'codex' && + leaf.lastAgentStatus === null && + !leaf.lastAgentStatusObservedLive + ) { + // Why: Codex reattach has no status edge, so the writable transition starts its bounded idle proof. + const handle = this.handleByLeafKey.get(leafKey) + if (handle) { + this.deliverPendingMessagesForHandle(handle) + } } } diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index e0a0ff28aedc..41f700573c9c 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -357,7 +357,10 @@ describe('mail addressed to a listed slept pane', () => { await Promise.resolve() await Promise.resolve() + const graphDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') remountStatuslessCodex('pty-codex-woken') + expect(graphDelivery).toHaveBeenCalledWith(handle) + await vi.advanceTimersByTimeAsync(2_000) const runtimeState = runtime as unknown as { leaves: Map< string, @@ -378,7 +381,6 @@ describe('mail addressed to a listed slept pane', () => { lastAgentStatus: null, lastAgentStatusObservedLive: false }) - runtime.deliverPendingMessagesForHandle('run:run_test') await vi.waitFor(() => expect(write).toHaveBeenCalledWith( 'pty-codex-woken', From 2122edbc7e1c4c31a65cbf1f7a5f519325705a25 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 20:45:19 -0700 Subject: [PATCH 13/29] fix(orchestration): redrive mail when Codex identity arrives --- src/main/runtime/orca-runtime-register-pty.ts | 20 +++++++++++++++++++ src/main/runtime/slept-pane-mail-wake.test.ts | 8 +++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 410798a329d4..36db9419193d 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -80,6 +80,7 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand ...(binding && paneKey ? { tabId: binding.tabId, paneKey } : {}), ...(binding?.incarnationId ? { incarnationId: binding.incarnationId } : {}) }) + const priorLaunchAgent = pty.launchAgent const agentLaunchAuthority = binding?.agentLaunchAuthority if ( agentLaunchAuthority && @@ -128,6 +129,25 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand if (binding && paneKey) { this.ensurePtyBackedMobileSurfaceForRendererTab(worktreeId, binding.tabId) } + if ( + this._orchestrationDb && + priorLaunchAgent !== 'codex' && + pty.launchAgent === 'codex' && + binding && + paneKey + ) { + const leafKey = this.getLeafKey(binding.tabId, binding.leafId) + const leaf = this.leaves.get(leafKey) + if ( + leaf?.ptyId === ptyId && + leaf.writable && + leaf.lastAgentStatus === null && + !leaf.lastAgentStatusObservedLive + ) { + // Why: graph publication can precede provider-owned reattach identity, so registration must redrive that ordering. + this.deliverPendingMessagesForHandle(this.issueHandle(leaf)) + } + } } assertPtyRegistrationAllowed(ptyId: string, incarnationId?: PtyIncarnationId): void { diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 41f700573c9c..ae4fac667c05 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -144,6 +144,9 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ runtime.onPtyData(ptyId, '\x1b]0;Claude done\x07', 101) }, remountStatuslessCodex: (ptyId: string) => { + // Production can publish the writable renderer graph before main receives + // the provider-owned reattach identity for that PTY. + syncGraph(ptyId) runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID, @@ -153,7 +156,6 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ launchAgent: 'codex' } }) - syncGraph(ptyId) runtime.onPtyData( ptyId, [ @@ -357,9 +359,9 @@ describe('mail addressed to a listed slept pane', () => { await Promise.resolve() await Promise.resolve() - const graphDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') + const reattachDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') remountStatuslessCodex('pty-codex-woken') - expect(graphDelivery).toHaveBeenCalledWith(handle) + expect(reattachDelivery).toHaveBeenCalledWith(handle) await vi.advanceTimersByTimeAsync(2_000) const runtimeState = runtime as unknown as { leaves: Map< From 2f0f06f9b3aafc9d82893cb19c122803c03d22bc Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 21:44:21 -0700 Subject: [PATCH 14/29] fix(orchestration): verify Codex pointer submission --- .../orca-runtime-stop-requested-pty-ids.ts | 16 ++ .../orchestration/mailbox-pointer-delivery.ts | 5 + .../orchestration/mailbox-pointer-stage.ts | 29 ++++ .../mailbox-statusless-codex-delivery.test.ts | 73 +++++++- .../mailbox-statusless-codex-submit.ts | 160 ++++++++++++++++++ src/main/runtime/slept-pane-mail-wake.test.ts | 72 +++++++- 6 files changed, 347 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 00d558f25818..f09236e7cad9 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -208,6 +208,22 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes), requestSleepingRecipientWake: (mailboxHandle) => this.requestSleepingRecipientWake(mailboxHandle), + submitStatuslessCodexPointer: async (handle, expectedPtyId, prompt, beforeWrite) => { + await this.sendTerminalAgentPrompt(handle, prompt, { + beforeWrite: async (ptyId) => { + await beforeWrite(ptyId) + if ( + ptyId !== expectedPtyId || + !(await this.isTerminalRunningSettledPromptAgent(handle)) || + this.ptysById.get(ptyId)?.foregroundAgent !== 'codex' + ) { + throw new Error('orchestration_pointer_target_changed') + } + await beforeWrite(ptyId) + }, + suffixFailureError: 'orchestration_pointer_target_changed' + }) + }, writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data) }) diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index 3f830f37c63e..201cc61aa78c 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -6,6 +6,7 @@ import { getOrchestrationMailboxPointerCandidates } from './mailbox-pointer-cand import { OrchestrationMailboxStatuslessCodexProofCoordinator } from './mailbox-statusless-codex-proof-coordinator' import { isStatuslessIdleProofCurrent } from './mailbox-statusless-idle-proof' import { stageOrchestrationMailboxPointer } from './mailbox-pointer-stage' +import type { SubmitStatuslessCodexPointer } from './mailbox-statusless-codex-submit' import { OrchestrationMailboxPointerState, type OrchestrationMailboxDeliveryFlight, @@ -31,6 +32,7 @@ type PointerDeliveryDependencies = { /** Ask for an auto-slept recipient to be woken. Optional so hosts that predate * the wake path keep today's silent give-up. */ requestSleepingRecipientWake?: (mailboxHandle: string) => void + submitStatuslessCodexPointer?: SubmitStatuslessCodexPointer writePty: (ptyId: string, data: string) => boolean | Promise } @@ -199,6 +201,9 @@ export class OrchestrationMailboxPointerDelivery this.settle(settledPtyId, settledFlight), redrive: (redriveMailbox, force) => this.redrive(redriveMailbox, force) diff --git a/src/main/runtime/orchestration/mailbox-pointer-stage.ts b/src/main/runtime/orchestration/mailbox-pointer-stage.ts index bd658ef92e67..c8d47cf8d47d 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-stage.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-stage.ts @@ -13,6 +13,10 @@ import type { OrchestrationStatuslessIdleProof } from './mailbox-pointer-state' import { submitOrchestrationMailboxPointer } from './mailbox-pointer-submit' +import { + submitStatuslessCodexMailboxPointer, + type SubmitStatuslessCodexPointer +} from './mailbox-statusless-codex-submit' /** Delay between the pointer text landing in the composer and the submit keystroke. */ const POINTER_SUBMIT_DELAY_MS = 500 @@ -28,6 +32,7 @@ type PointerStageDependencies = { getTerminalProcessIncarnation: (terminalHandle: string) => string | null isLeafPtyProvenAbsent: (ptyId: string) => Promise requestSleepingRecipientWake?: (mailboxHandle: string) => void + submitStatuslessCodexPointer?: SubmitStatuslessCodexPointer writePty: (ptyId: string, data: string) => boolean | Promise settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void redrive: (mailboxHandle: string, force?: boolean) => void @@ -58,6 +63,30 @@ export function stageOrchestrationMailboxPointer Promise, - options: { withMessage?: boolean } = {} + options: { + withMessage?: boolean + submitStatuslessCodexPointer?: SubmitStatuslessCodexPointer + } = {} ) { let leaf = statuslessLeaf() let processIncarnation = 'pty-1:incarnation-1' @@ -48,6 +52,9 @@ function makeHarness( isLeafPtyProvenAbsent: () => Promise.resolve(false), proveStatuslessCodexIdle, redriveMailbox: () => undefined, + ...(options.submitStatuslessCodexPointer + ? { submitStatuslessCodexPointer: options.submitStatuslessCodexPointer } + : {}), writePty }) return { @@ -159,6 +166,70 @@ describe('statusless Codex mailbox pointer delivery', () => { } ) + it.each(['working', 'permission'] as const)( + 'guards structured submission from a live %s transition', + async (status) => { + const startWrite = deferred() + const finished = deferred() + const wrote = vi.fn() + const submitStatuslessCodexPointer = vi.fn( + async (_handle: string, ptyId: string, _prompt: string, beforeWrite) => { + try { + await startWrite.promise + await beforeWrite(ptyId) + wrote() + } finally { + finished.resolve() + } + } + ) + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1'), { + submitStatuslessCodexPointer + }) + + harness.delivery.deliverForHandle(MAILBOX) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(1)) + harness.setLeaf({ + ...harness.getLeaf(), + lastAgentStatus: status, + lastAgentStatusObservedLive: true + }) + startWrite.resolve() + await finished.promise + await Promise.resolve() + + expect(wrote).not.toHaveBeenCalled() + expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toBeNull() + expect(harness.writePty).not.toHaveBeenCalled() + } + ) + + it('keeps mail undelivered when structured submission observes no turn effect', async () => { + const finished = deferred() + const submitStatuslessCodexPointer = vi.fn( + async (_handle: string, ptyId: string, _prompt: string, beforeWrite) => { + try { + await beforeWrite(ptyId) + await beforeWrite(ptyId) + throw new Error('agent_prompt_stalled') + } finally { + finished.resolve() + } + } + ) + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1'), { + submitStatuslessCodexPointer + }) + + harness.delivery.deliverForHandle(MAILBOX) + await finished.promise + await Promise.resolve() + + expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(1) + expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toBeNull() + expect(harness.writePty).not.toHaveBeenCalled() + }) + it('requeues staged mail when the process is replaced before submit', async () => { const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1')) diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts new file mode 100644 index 000000000000..50fefe537749 --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts @@ -0,0 +1,160 @@ +import { formatMessagePointer } from './formatter' +import type { OrchestrationDb } from './db' +import { + shouldReleaseOrchestrationPointer, + type OrchestrationMessageWaiter +} from './mailbox-pointer-eligibility' +import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' +import { isStatuslessIdleProofProcessCurrent } from './mailbox-statusless-idle-proof' +import type { + OrchestrationMailboxDeliveryFlight, + OrchestrationMailboxPointerState, + OrchestrationStatuslessIdleProof +} from './mailbox-pointer-state' + +export type SubmitStatuslessCodexPointer = ( + terminalHandle: string, + ptyId: string, + prompt: string, + beforeWrite: (ptyId: string) => void | Promise +) => Promise + +type StatuslessCodexSubmitDependencies = { + mailboxOwner: OrchestrationMailboxOwner + state: OrchestrationMailboxPointerState + getDb: () => OrchestrationDb | null + getLeaf: (leafKey: string) => OrchestrationMailboxLeaf | undefined + getLeafKey: (tabId: string, leafId: string) => string + getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined + getTerminalProcessIncarnation: (terminalHandle: string) => string | null + submitStatuslessCodexPointer: SubmitStatuslessCodexPointer + settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void + redrive: (mailboxHandle: string, force?: boolean) => void +} + +type StatuslessCodexSubmitInput = { + leaf: OrchestrationMailboxLeaf + mailboxHandle: string + unread: readonly { id: string; type: string; sequence: number }[] + newestSequence: number + statuslessIdleProof: OrchestrationStatuslessIdleProof +} + +type TargetState = 'current' | 'blocked' | 'invalid' | 'released' + +/** Submit a proven statusless Codex pointer through the guarded agent-prompt path. */ +export function submitStatuslessCodexMailboxPointer( + deps: StatuslessCodexSubmitDependencies, + input: StatuslessCodexSubmitInput +): void { + const ptyId = input.leaf.ptyId + if (!ptyId) { + return + } + const flight = deps.state.beginFlight(ptyId) + deps.state.setWatermark( + input.mailboxHandle, + input.newestSequence, + ptyId, + deps.getLeafKey(input.leaf.tabId, input.leaf.leafId) + ) + let submitted = false + let clearAndRedrive = false + let releaseWithoutRedrive = false + let finalizeReservation = true + void Promise.resolve() + .then(() => + deps.submitStatuslessCodexPointer( + input.statuslessIdleProof.terminalHandle, + ptyId, + formatMessagePointer(input.unread.length, input.mailboxHandle), + (writePtyId) => { + if (writePtyId !== ptyId || targetState(deps, input, ptyId, flight) !== 'current') { + throw new Error('orchestration_pointer_target_changed') + } + } + ) + ) + .then(() => { + if (!deps.state.isCurrentFlight(ptyId, flight)) { + finalizeReservation = false + return + } + const state = targetState(deps, input, ptyId, flight, true) + if (state === 'invalid') { + clearAndRedrive = true + } else if (state === 'released') { + releaseWithoutRedrive = true + } else if (state === 'current') { + const messageIds = input.unread.map((message) => message.id) + deps.getDb()?.markAsDelivered(messageIds) + flight.stagedMessageIds = messageIds + submitted = true + } + }) + .catch(() => { + if (!deps.state.isCurrentFlight(ptyId, flight)) { + finalizeReservation = false + return + } + const state = targetState(deps, input, ptyId, flight) + clearAndRedrive = state === 'invalid' + releaseWithoutRedrive = state === 'released' + }) + .finally(() => { + let released = false + if (finalizeReservation) { + released = + submitted || clearAndRedrive || releaseWithoutRedrive + ? deps.state.clearWatermark(input.mailboxHandle, input.newestSequence, ptyId) + : deps.state.deactivateWatermark(input.mailboxHandle, input.newestSequence, ptyId) + } + deps.settle(ptyId, flight) + if (released && clearAndRedrive) { + deps.redrive(input.mailboxHandle, true) + } + }) +} + +function targetState( + deps: StatuslessCodexSubmitDependencies, + input: StatuslessCodexSubmitInput, + ptyId: string, + flight: OrchestrationMailboxDeliveryFlight, + allowActiveStatus = false +): TargetState { + if (!deps.state.isCurrentFlight(ptyId, flight)) { + return 'invalid' + } + const leaf = deps.getLeaf(deps.getLeafKey(input.leaf.tabId, input.leaf.leafId)) + if ( + !leaf || + leaf.ptyId !== ptyId || + !leaf.writable || + deps.mailboxOwner.resolve(leaf) !== input.mailboxHandle || + !isStatuslessIdleProofProcessCurrent( + leaf, + input.statuslessIdleProof, + deps.getTerminalProcessIncarnation + ) + ) { + return 'invalid' + } + if ( + !allowActiveStatus && + (leaf.lastAgentStatus === 'working' || leaf.lastAgentStatus === 'permission') + ) { + return 'blocked' + } + if ( + shouldReleaseOrchestrationPointer( + deps.getDb(), + input.mailboxHandle, + input.unread, + deps.getMessageWaiters(input.mailboxHandle) + ) + ) { + return 'released' + } + return 'current' +} diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index ae4fac667c05..ac612a4b9194 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' +import { AGENT_PROMPT_BRACKETED_PASTE_START } from '../../shared/agent-prompt-injection' import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { @@ -62,6 +63,7 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ write: ReturnType remountWithPty: (ptyId: string) => void remountStatuslessCodex: (ptyId: string) => void + setForegroundProcess: (process: string | null) => void setRendererAvailable: (available: boolean) => void }> { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(sleptSession(record)) @@ -69,10 +71,11 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ const db = new InMemoryOrchestrationMessages() setInMemoryOrchestrationMessages(runtime, db) const write = vi.fn().mockReturnValue(true) + let foregroundProcess: string | null = null runtime.setPtyController({ write, kill: vi.fn(), - getForegroundProcess: async () => null + getForegroundProcess: async () => foregroundProcess } as never) runtime.attachWindow(1) const syncGraph = (ptyId: string | null): void => { @@ -133,6 +136,9 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ connected: row!.connected, tabMountSends, write, + setForegroundProcess: (process) => { + foregroundProcess = process + }, setRendererAvailable: (available) => { rendererAvailable = available }, @@ -345,9 +351,8 @@ describe('mail addressed to a listed slept pane', () => { it('delivers queued run mail after a statusless Codex provider reattach', async () => { vi.useFakeTimers() try { - const { runtime, db, handle, write, remountStatuslessCodex } = await sleptPaneRuntime( - sleepingRecord({ agent: 'codex' }) - ) + const { runtime, db, handle, write, remountStatuslessCodex, setForegroundProcess } = + await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) const message = db.insertMessage({ from: 'term_worker', @@ -360,9 +365,19 @@ describe('mail addressed to a listed slept pane', () => { await Promise.resolve() const reattachDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') + setForegroundProcess('codex') + write.mockImplementation((ptyId: string, data: string) => { + if (data.startsWith(AGENT_PROMPT_BRACKETED_PASTE_START)) { + // The resumed TUI can emit a live but unclassified title after the + // original statusless-idle proof and before the submit write. + runtime.onPtyData(ptyId, '\x1b]0;workspace\x07\x1b[?25h', 101) + } else if (data === '\r') { + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 102) + } + return true + }) remountStatuslessCodex('pty-codex-woken') expect(reattachDelivery).toHaveBeenCalledWith(handle) - await vi.advanceTimersByTimeAsync(2_000) const runtimeState = runtime as unknown as { leaves: Map< string, @@ -383,13 +398,15 @@ describe('mail addressed to a listed slept pane', () => { lastAgentStatus: null, lastAgentStatusObservedLive: false }) + await vi.advanceTimersByTimeAsync(12_000) await vi.waitFor(() => expect(write).toHaveBeenCalledWith( 'pty-codex-woken', - expect.stringContaining('You have 1 orchestration message') + expect.stringContaining( + `${AGENT_PROMPT_BRACKETED_PASTE_START}\nYou have 1 orchestration message` + ) ) ) - await vi.advanceTimersByTimeAsync(500) expect(write).toHaveBeenCalledWith('pty-codex-woken', '\r') expect(message.delivered_at).toEqual(expect.any(String)) @@ -398,4 +415,45 @@ describe('mail addressed to a listed slept pane', () => { vi.useRealTimers() } }) + + it('does not submit a statusless Codex pointer after the child exits to a shell', async () => { + vi.useFakeTimers() + try { + const { runtime, db, handle, write, remountStatuslessCodex, setForegroundProcess } = + await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + + setForegroundProcess('codex') + write.mockImplementation((ptyId: string, data: string) => { + if (data.startsWith(AGENT_PROMPT_BRACKETED_PASTE_START)) { + setForegroundProcess('zsh') + runtime.onPtyData(ptyId, '\x1b]0;workspace\x07\x1b[?25h', 101) + } + return true + }) + remountStatuslessCodex('pty-codex-woken') + await vi.advanceTimersByTimeAsync(12_000) + + expect(write).toHaveBeenCalledWith( + 'pty-codex-woken', + expect.stringContaining( + `${AGENT_PROMPT_BRACKETED_PASTE_START}\nYou have 1 orchestration message` + ) + ) + expect(write).not.toHaveBeenCalledWith('pty-codex-woken', '\r') + expect(message.delivered_at).toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) }) From 5bd79009d8195121b1882952e92ee61d1a33acf5 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 22:26:21 -0700 Subject: [PATCH 15/29] fix(orchestration): deliver wake mail to resumed Codex --- .../ipc/pty-runtime-kill-and-exit.test.ts | 5 ++ src/main/ipc/pty/runtime/controller.ts | 3 ++ src/main/ipc/pty/runtime/operations.ts | 8 +++ .../orca-runtime-stop-requested-pty-ids.ts | 52 +++++++++++++----- .../runtime-pty-controller-contract.ts | 2 + src/main/runtime/slept-pane-mail-wake.test.ts | 53 ++++++++++++++++--- 6 files changed, 103 insertions(+), 20 deletions(-) diff --git a/src/main/ipc/pty-runtime-kill-and-exit.test.ts b/src/main/ipc/pty-runtime-kill-and-exit.test.ts index 2200b2b6043d..e0d20094d03b 100644 --- a/src/main/ipc/pty-runtime-kill-and-exit.test.ts +++ b/src/main/ipc/pty-runtime-kill-and-exit.test.ts @@ -70,8 +70,10 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { confirmForegroundProcess: (ptyId: string) => Promise + supportsForegroundProcessConfirmation: (ptyId: string) => boolean } + expect(controller.supportsForegroundProcessConfirmation('remote-pty')).toBe(true) await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex') expect(confirmForegroundProcess).toHaveBeenCalledOnce() expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty') @@ -155,8 +157,11 @@ describe('registerPtyHandlers', () => { registerPtyHandlers(mainWindow as never, runtime as never) const controller = runtime.setPtyController.mock.calls[0]?.[0] as { confirmForegroundProcess: (ptyId: string) => Promise + supportsForegroundProcessConfirmation: (ptyId: string) => boolean } + expect(controller.supportsForegroundProcessConfirmation('unsupported-pty')).toBe(false) + expect(controller.supportsForegroundProcessConfirmation('missing-pty')).toBe(false) await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull() await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull() deletePtyOwnership('unsupported-pty') diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index 1d74d41df31e..357997b68349 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -24,6 +24,7 @@ import { probePtyLivenessFromRuntimeController, resizePtyFromRuntimeController, serializeProviderBufferFromRuntimeController, + supportsForegroundProcessConfirmationFromRuntimeController, waitForRendererSerializerFromRuntimeController, writePtyAgentSessionProofFromRuntimeController, writePtyFromRuntimeController @@ -62,6 +63,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getForegroundProcess: (ptyId) => getForegroundProcessFromRuntimeController(ptyId), inspectProcess: (ptyId, options) => inspectProcessFromRuntimeController(ptyId, options), confirmForegroundProcess: (ptyId) => confirmForegroundProcessFromRuntimeController(ptyId), + supportsForegroundProcessConfirmation: (ptyId) => + supportsForegroundProcessConfirmationFromRuntimeController(ptyId), confirmShellForeground: (ptyId) => confirmShellForegroundFromRuntimeController(ptyId), getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index daab96101e65..5936295b3453 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -133,6 +133,14 @@ export async function confirmForegroundProcessFromRuntimeController(ptyId: strin } } +export function supportsForegroundProcessConfirmationFromRuntimeController(ptyId: string): boolean { + try { + return getProviderForPty(ptyId).confirmForegroundProcess !== undefined + } catch { + return false + } +} + export async function confirmShellForegroundFromRuntimeController(ptyId: string) { try { return (await getProviderForPty(ptyId).confirmShellForeground?.(ptyId)) ?? false diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index f09236e7cad9..8c4fd1a1e55e 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -30,6 +30,8 @@ import { RuntimeMessageWaiters } from './runtime-message-waiters' import type { RuntimeSkillCommands } from './runtime-skill-command-surface' import { RuntimeTerminalStreamConsumers } from './runtime-terminal-stream-consumers' import type { RecentPtyOutputBuffer } from './recent-pty-output-buffer' +import { buildAgentPromptPasteBytes } from '../../shared/agent-prompt-injection' +import { recognizeAgentProcess } from '../../shared/agent-process-recognition' export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId { protected readonly stopRequestedPtyIds = new Set() @@ -209,19 +211,24 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId requestSleepingRecipientWake: (mailboxHandle) => this.requestSleepingRecipientWake(mailboxHandle), submitStatuslessCodexPointer: async (handle, expectedPtyId, prompt, beforeWrite) => { - await this.sendTerminalAgentPrompt(handle, prompt, { - beforeWrite: async (ptyId) => { - await beforeWrite(ptyId) - if ( - ptyId !== expectedPtyId || - !(await this.isTerminalRunningSettledPromptAgent(handle)) || - this.ptysById.get(ptyId)?.foregroundAgent !== 'codex' - ) { - throw new Error('orchestration_pointer_target_changed') - } - await beforeWrite(ptyId) - }, - suffixFailureError: 'orchestration_pointer_target_changed' + const generation = this.getPtyLifecycleGeneration(expectedPtyId) + const pastePayload = buildAgentPromptPasteBytes(prompt) + await this.serializeAgentPromptSubmission(expectedPtyId, generation, async () => { + this.assertLiveTerminalHandleTargetsPty(handle, expectedPtyId) + this.assertAgentPromptGeneration(expectedPtyId, generation) + await this.writeTerminalAgentPrompt(handle, expectedPtyId, generation, pastePayload, { + beforeWrite: async (ptyId) => { + await beforeWrite(ptyId) + if ( + ptyId !== expectedPtyId || + !(await this.isStatuslessCodexPointerForegroundCurrent(ptyId)) + ) { + throw new Error('orchestration_pointer_target_changed') + } + await beforeWrite(ptyId) + }, + suffixFailureError: 'orchestration_pointer_target_changed' + }) }) }, writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data) @@ -259,4 +266,23 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId protected recentPtyOutputById = new Map() protected setupCompletionTokenByPtyId = new Map() + + private async isStatuslessCodexPointerForegroundCurrent(ptyId: string): Promise { + const controller = this.ptyController + if (!controller) { + return false + } + try { + const supportsConfirmation = + controller.confirmForegroundProcess !== undefined && + (controller.supportsForegroundProcessConfirmation?.(ptyId) ?? true) + const process = supportsConfirmation + ? await controller.confirmForegroundProcess(ptyId) + : await controller.getForegroundProcess(ptyId) + const agent = recognizeAgentProcess(process)?.agent + return controller === this.ptyController && agent === 'codex' + } catch { + return false + } + } } diff --git a/src/main/runtime/runtime-pty-controller-contract.ts b/src/main/runtime/runtime-pty-controller-contract.ts index 194d0bb665c7..d0f18aad490d 100644 --- a/src/main/runtime/runtime-pty-controller-contract.ts +++ b/src/main/runtime/runtime-pty-controller-contract.ts @@ -112,6 +112,8 @@ export type RuntimePtyController = { options?: { expectedIncarnationId?: PtyIncarnationId } ): Promise confirmForegroundProcess?(ptyId: string): Promise + /** Distinguishes unsupported confirmation from a supported fresh read returning no agent. */ + supportsForegroundProcessConfirmation?(ptyId: string): boolean confirmShellForeground?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index ac612a4b9194..8a5eb42eac36 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -64,6 +64,8 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ remountWithPty: (ptyId: string) => void remountStatuslessCodex: (ptyId: string) => void setForegroundProcess: (process: string | null) => void + setConfirmedForegroundProcess: (process: string | null | undefined) => void + setForegroundConfirmationSupported: (supported: boolean) => void setRendererAvailable: (available: boolean) => void }> { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(sleptSession(record)) @@ -72,10 +74,15 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ setInMemoryOrchestrationMessages(runtime, db) const write = vi.fn().mockReturnValue(true) let foregroundProcess: string | null = null + let confirmedForegroundProcess: string | null | undefined + let foregroundConfirmationSupported = true runtime.setPtyController({ write, kill: vi.fn(), - getForegroundProcess: async () => foregroundProcess + getForegroundProcess: async () => foregroundProcess, + confirmForegroundProcess: async () => + confirmedForegroundProcess === undefined ? foregroundProcess : confirmedForegroundProcess, + supportsForegroundProcessConfirmation: () => foregroundConfirmationSupported } as never) runtime.attachWindow(1) const syncGraph = (ptyId: string | null): void => { @@ -139,6 +146,12 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ setForegroundProcess: (process) => { foregroundProcess = process }, + setConfirmedForegroundProcess: (process) => { + confirmedForegroundProcess = process + }, + setForegroundConfirmationSupported: (supported) => { + foregroundConfirmationSupported = supported + }, setRendererAvailable: (available) => { rendererAvailable = available }, @@ -348,11 +361,19 @@ describe('mail addressed to a listed slept pane', () => { db.close() }) - it('delivers queued run mail after a statusless Codex provider reattach', async () => { + it('delivers after a statusless Codex reattach without fresh foreground confirmation', async () => { vi.useFakeTimers() try { - const { runtime, db, handle, write, remountStatuslessCodex, setForegroundProcess } = - await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) + const { + runtime, + db, + handle, + write, + remountStatuslessCodex, + setForegroundProcess, + setConfirmedForegroundProcess, + setForegroundConfirmationSupported + } = await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) const message = db.insertMessage({ from: 'term_worker', @@ -365,7 +386,15 @@ describe('mail addressed to a listed slept pane', () => { await Promise.resolve() const reattachDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') + const highLevelPrompt = vi + .spyOn(runtime, 'sendTerminalAgentPrompt') + .mockRejectedValue(new Error('statusless Codex has no hook tui-idle edge')) + const settledPromptReadiness = vi + .spyOn(runtime, 'isTerminalRunningSettledPromptAgent') + .mockResolvedValue(false) setForegroundProcess('codex') + setConfirmedForegroundProcess(null) + setForegroundConfirmationSupported(false) write.mockImplementation((ptyId: string, data: string) => { if (data.startsWith(AGENT_PROMPT_BRACKETED_PASTE_START)) { // The resumed TUI can emit a live but unclassified title after the @@ -409,6 +438,8 @@ describe('mail addressed to a listed slept pane', () => { ) expect(write).toHaveBeenCalledWith('pty-codex-woken', '\r') + expect(highLevelPrompt).not.toHaveBeenCalled() + expect(settledPromptReadiness).not.toHaveBeenCalled() expect(message.delivered_at).toEqual(expect.any(String)) db.close() } finally { @@ -419,8 +450,15 @@ describe('mail addressed to a listed slept pane', () => { it('does not submit a statusless Codex pointer after the child exits to a shell', async () => { vi.useFakeTimers() try { - const { runtime, db, handle, write, remountStatuslessCodex, setForegroundProcess } = - await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) + const { + runtime, + db, + handle, + write, + remountStatuslessCodex, + setForegroundProcess, + setConfirmedForegroundProcess + } = await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) const message = db.insertMessage({ from: 'term_worker', @@ -433,9 +471,10 @@ describe('mail addressed to a listed slept pane', () => { await Promise.resolve() setForegroundProcess('codex') + setConfirmedForegroundProcess('codex') write.mockImplementation((ptyId: string, data: string) => { if (data.startsWith(AGENT_PROMPT_BRACKETED_PASTE_START)) { - setForegroundProcess('zsh') + setConfirmedForegroundProcess('zsh') runtime.onPtyData(ptyId, '\x1b]0;workspace\x07\x1b[?25h', 101) } return true From 70ea5388b7409c0af82d24c5d68e215faa328951 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Fri, 4 Sep 2026 23:20:56 -0700 Subject: [PATCH 16/29] fix(orchestration): scope fresh foreground confirmation --- ...nt-foreground-process-confirmation.test.ts | 282 ++++++++++++++++++ .../agent-foreground-process.test.ts | 160 ---------- .../providers/agent-foreground-process.ts | 3 +- src/main/runtime/slept-pane-mail-wake.test.ts | 177 ++++++----- src/shared/process-table-snapshot-reader.ts | 64 ++-- 5 files changed, 426 insertions(+), 260 deletions(-) create mode 100644 src/main/providers/agent-foreground-process-confirmation.test.ts diff --git a/src/main/providers/agent-foreground-process-confirmation.test.ts b/src/main/providers/agent-foreground-process-confirmation.test.ts new file mode 100644 index 000000000000..d83f7bbb98b9 --- /dev/null +++ b/src/main/providers/agent-foreground-process-confirmation.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +const getAllProcessesMock = vi.fn() + +import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { + confirmShellForegroundProcess, + resolveAgentForegroundProcess, + resolveAgentForegroundProcessWithAvailability +} from './agent-foreground-process' + +describe('agent foreground confirmation', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + execFileMock.mockReset() + getAllProcessesMock.mockReset() + resetProcessTableSnapshotForTests() + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses: getAllProcessesMock + })) + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + }) + + afterEach(() => { + __setWindowsProcessTreeLoaderForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + function mockPs(stdout: string): void { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { stdout, stderr: '' }) + } + ) + } + + it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { + mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + }) + + it('fresh-confirms Codex from its PTY when a whole-host scan would time out', async () => { + execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + if (args[0] === '-p') { + callback(null, { + stdout: '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n', + stderr: '' + }) + return + } + if (args[0] === '-t') { + callback(null, { + stdout: [ + '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l', + '101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 node /opt/homebrew/bin/codex', + '102 101 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 /opt/homebrew/lib/node_modules/@openai/codex/vendor/aarch64-apple-darwin/bin/codex' + ].join('\n'), + stderr: '' + }) + return + } + callback(new Error('whole-host ps timed out'), { stdout: '', stderr: '' }) + }) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: true, processName: 'codex' }) + expect(execFileMock.mock.calls.map((call) => call[1]?.[0])).toEqual(['-p', '-t']) + }) + + it('rejects a PTY capture when its root process changes between samples', async () => { + execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: + args[0] === '-p' + ? '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n' + : [ + '100 99 100 101 S ttys060 Fri Sep 4 15:53:35 2026 -/bin/zsh -l', + '101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 codex' + ].join('\n'), + stderr: '' + }) + }) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + expect(execFileMock.mock.calls.map((call) => call[1]?.[0])).toEqual(['-p', '-t']) + }) + + it('rejects a malformed row in a PTY-scoped confirmation capture', async () => { + execFileMock.mockImplementation((_cmd: string, args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(null, { + stdout: + args[0] === '-p' + ? '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l\n' + : [ + '100 99 100 101 S ttys060 Fri Sep 4 15:53:34 2026 -/bin/zsh -l', + 'malformed foreground row', + '101 100 101 101 S+ ttys060 Fri Sep 4 15:53:35 2026 codex' + ].join('\n'), + stderr: '' + }) + }) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + }) + + it('treats failed POSIX scans as unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect( + resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) + ).resolves.toEqual({ available: false, processName: 'zsh' }) + await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({ + available: false, + processName: 'zsh' + }) + await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') + }) + + it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { + mockPs(['100 99 Ss+ "/bin/zsh" -l', '101 100 S+ /bin/bash'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) + }) + + it('uses spawned-shell identity instead of a lagging foreground child label', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l'].join('\n')) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S+ -zsh', + '102 101 S+ (zsh)', + '103 102 S+ (sed)', + '104 102 R+ (git)' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) + }) + + it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { + mockPs( + [ + '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', + '101 100 S -zsh', + '102 101 S+ agent-tui', + '103 102 S+ /bin/zsh -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { + mockPs( + [ + '100 99 Ss /bin/zsh -l', + '101 100 S+ /usr/local/bin/agent-tui', + '102 101 S+ /bin/bash -i' + ].join('\n') + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('rejects shell ownership while a stopped TUI remains resumable', async () => { + mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 T /usr/local/bin/agent-tui'].join('\n')) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { + // Why pinned: the WSL job object holds only wsl.exe, so a looser + // isShellProcess would confirm ownership regardless of distro-side state. + await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails open when fresh shell ownership inspection is unavailable', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { + const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void + callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) + } + ) + + await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) + }) + + it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) + ).resolves.toBe(true) + expect(getAllProcessesMock).not.toHaveBeenCalled() + }) + + it('rejects Windows shell ownership with a child or unavailable membership', async () => { + Object.defineProperty(process, 'platform', { value: 'win32' }) + + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => new Set([100, 101]) + }) + ).resolves.toBe(false) + await expect( + confirmShellForegroundProcess(100, 'powershell.exe', { + readWindowsPtyJobProcessIds: async () => null + }) + ).resolves.toBe(false) + }) + + it('does not report Claude print-mode hook descendants as foreground agents', async () => { + mockPs( + [ + '100 99 Ss bash -i', + '101 100 S+ claude --print --model haiku Analyze this conversation and determine next work' + ].join('\n') + ) + + await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') + }) + + it('does not report a stopped agent after the shell regains foreground', async () => { + mockPs( + ['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join( + '\n' + ) + ) + + await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') + }) + + it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => { + // No '+' marker at all (e.g. a detached/daemon descendant tree) — the + // recognized agent may still be the best available signal. + mockPs( + ['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join( + '\n' + ) + ) + + await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') + }) +}) diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index fb883d2166cb..64c472966c34 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -13,7 +13,6 @@ const getAllProcessesMock = vi.fn() import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { - confirmShellForegroundProcess, resolveAgentForegroundProcess, resolveAgentForegroundProcessWithAvailability } from './agent-foreground-process' @@ -194,165 +193,6 @@ describe('resolveAgentForegroundProcess', () => { ).resolves.toBe('omp') }) - it('treats a fresh POSIX snapshot missing the PTY root as unavailable', async () => { - mockPs('101 999 S+ node /Users/dev/.nvm/versions/node/bin/codex') - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) - ).resolves.toEqual({ available: false, processName: 'zsh' }) - }) - - it('treats failed POSIX scans as unavailable', async () => { - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) - } - ) - - await expect( - resolveAgentForegroundProcessWithAvailability(100, 'zsh', { fresh: true }) - ).resolves.toEqual({ available: false, processName: 'zsh' }) - await expect(resolveAgentForegroundProcessWithAvailability(100, 'zsh')).resolves.toEqual({ - available: false, - processName: 'zsh' - }) - await expect(resolveAgentForegroundProcess(100, 'zsh')).resolves.toBe('zsh') - }) - - it('confirms a quoted login shell only when its fresh PTY tree contains shells', async () => { - mockPs(['100 99 Ss+ "/bin/zsh" -l', '101 100 S+ /bin/bash'].join('\n')) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(true) - }) - - it('uses spawned-shell identity instead of a lagging foreground child label', async () => { - mockPs(['100 99 Ss+ /bin/zsh -l'].join('\n')) - - await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) - }) - - it('confirms the spawned shell behind a login wrapper while prompt hooks run', async () => { - mockPs( - [ - '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', - '101 100 S+ -zsh', - '102 101 S+ (zsh)', - '103 102 S+ (sed)', - '104 102 R+ (git)' - ].join('\n') - ) - - await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(true) - }) - - it('rejects a foreground nested shell while the spawned shell remains suspended', async () => { - mockPs( - [ - '100 99 Ss /usr/bin/login -pfl developer /bin/zsh', - '101 100 S -zsh', - '102 101 S+ agent-tui', - '103 102 S+ /bin/zsh -i' - ].join('\n') - ) - - await expect(confirmShellForegroundProcess(100, '/bin/zsh')).resolves.toBe(false) - }) - - it('rejects shell ownership while a TUI and its nested shell remain in the PTY tree', async () => { - mockPs( - [ - '100 99 Ss /bin/zsh -l', - '101 100 S+ /usr/local/bin/agent-tui', - '102 101 S+ /bin/bash -i' - ].join('\n') - ) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) - }) - - it('rejects shell ownership while a stopped TUI remains resumable', async () => { - mockPs(['100 99 Ss+ /bin/zsh -l', '101 100 T /usr/local/bin/agent-tui'].join('\n')) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) - }) - - it('refuses WSL shells: wsl.exe is not a provable shell identity', async () => { - // Why pinned: the WSL job object holds only wsl.exe, so a looser - // isShellProcess would confirm ownership regardless of distro-side state. - await expect(confirmShellForegroundProcess(100, 'wsl.exe')).resolves.toBe(false) - expect(execFileMock).not.toHaveBeenCalled() - }) - - it('fails open when fresh shell ownership inspection is unavailable', async () => { - execFileMock.mockImplementation( - (_cmd: string, _args: string[], _opts: unknown, cb: unknown) => { - const callback = cb as (err: unknown, result: { stdout: string; stderr: string }) => void - callback(new Error('ps unavailable'), { stdout: '', stderr: '' }) - } - ) - - await expect(confirmShellForegroundProcess(100, 'zsh')).resolves.toBe(false) - }) - - it('confirms a Windows shell from fresh root-only ConPTY membership', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - const readWindowsPtyJobProcessIds = vi.fn(async () => new Set([100])) - - await expect( - confirmShellForegroundProcess(100, 'powershell.exe', { readWindowsPtyJobProcessIds }) - ).resolves.toBe(true) - expect(getAllProcessesMock).not.toHaveBeenCalled() - }) - - it('rejects Windows shell ownership with a child or unavailable membership', async () => { - Object.defineProperty(process, 'platform', { value: 'win32' }) - - await expect( - confirmShellForegroundProcess(100, 'powershell.exe', { - readWindowsPtyJobProcessIds: async () => new Set([100, 101]) - }) - ).resolves.toBe(false) - await expect( - confirmShellForegroundProcess(100, 'powershell.exe', { - readWindowsPtyJobProcessIds: async () => null - }) - ).resolves.toBe(false) - }) - - it('does not report Claude print-mode hook descendants as foreground agents', async () => { - mockPs( - [ - '100 99 Ss bash -i', - '101 100 S+ claude --print --model haiku Analyze this conversation and determine next work' - ].join('\n') - ) - - await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') - }) - - it('does not report a stopped agent after the shell regains foreground', async () => { - mockPs( - ['100 99 Ss+ bash -i', '101 100 T node /Users/dev/.nvm/versions/node/bin/codex'].join( - '\n' - ) - ) - - await expect(resolveAgentForegroundProcess(100, 'bash')).resolves.toBe('bash') - }) - - it('falls back to recognized descendants when no process in the PTY tree holds foreground', async () => { - // No '+' marker at all (e.g. a detached/daemon descendant tree) — the - // recognized agent may still be the best available signal. - mockPs( - ['100 99 Ss bash -i', '101 100 S node /Users/dev/.nvm/versions/node/bin/codex'].join( - '\n' - ) - ) - - await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('codex') - }) - it('recognizes Windows wrapper-launched agents from descendant command lines', async () => { Object.defineProperty(process, 'platform', { value: 'win32' }) mockWindowsRows() diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index 7fface8941ed..208ba3a7530b 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -2,6 +2,7 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import type { ProcessTableRow } from '../../shared/process-table-snapshot' import { + getFreshPtyProcessTableSnapshot, getFreshProcessTableSnapshot, getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' @@ -149,7 +150,7 @@ export async function resolveAgentForegroundProcessWithAvailability( try { const rows = options.fresh - ? await getFreshProcessTableSnapshot() + ? await getFreshPtyProcessTableSnapshot(shellPid) : await getProcessTableSnapshot() if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) { return { available: false, processName: fallbackProcess } diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 8a5eb42eac36..60dca2715b32 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -361,91 +361,108 @@ describe('mail addressed to a listed slept pane', () => { db.close() }) - it('delivers after a statusless Codex reattach without fresh foreground confirmation', async () => { - vi.useFakeTimers() - try { - const { - runtime, - db, - handle, - write, - remountStatuslessCodex, - setForegroundProcess, - setConfirmedForegroundProcess, - setForegroundConfirmationSupported - } = await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) - db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) - const message = db.insertMessage({ - from: 'term_worker', - to: 'run:run_test', - subject: 'worker done', - type: 'worker_done' - }) - runtime.notifyMessageArrived('run:run_test', 'worker_done') - await Promise.resolve() - await Promise.resolve() - - const reattachDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') - const highLevelPrompt = vi - .spyOn(runtime, 'sendTerminalAgentPrompt') - .mockRejectedValue(new Error('statusless Codex has no hook tui-idle edge')) - const settledPromptReadiness = vi - .spyOn(runtime, 'isTerminalRunningSettledPromptAgent') - .mockResolvedValue(false) - setForegroundProcess('codex') - setConfirmedForegroundProcess(null) - setForegroundConfirmationSupported(false) - write.mockImplementation((ptyId: string, data: string) => { - if (data.startsWith(AGENT_PROMPT_BRACKETED_PASTE_START)) { - // The resumed TUI can emit a live but unclassified title after the - // original statusless-idle proof and before the submit write. - runtime.onPtyData(ptyId, '\x1b]0;workspace\x07\x1b[?25h', 101) - } else if (data === '\r') { - runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 102) + it.each([ + { + caseName: 'with fresh local foreground confirmation', + foregroundProcess: 'zsh', + confirmedForegroundProcess: + '/opt/homebrew/lib/node_modules/@openai/codex/vendor/aarch64-apple-darwin/bin/codex', + foregroundConfirmationSupported: true + }, + { + caseName: 'through an SSH provider without foreground confirmation', + foregroundProcess: 'codex', + confirmedForegroundProcess: null, + foregroundConfirmationSupported: false + } + ])( + 'delivers after a statusless Codex reattach $caseName', + async ({ foregroundProcess, confirmedForegroundProcess, foregroundConfirmationSupported }) => { + vi.useFakeTimers() + try { + const { + runtime, + db, + handle, + write, + remountStatuslessCodex, + setForegroundProcess, + setConfirmedForegroundProcess, + setForegroundConfirmationSupported + } = await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + + const reattachDelivery = vi.spyOn(runtime, 'deliverPendingMessagesForHandle') + const highLevelPrompt = vi + .spyOn(runtime, 'sendTerminalAgentPrompt') + .mockRejectedValue(new Error('statusless Codex has no hook tui-idle edge')) + const settledPromptReadiness = vi + .spyOn(runtime, 'isTerminalRunningSettledPromptAgent') + .mockResolvedValue(false) + setForegroundProcess(foregroundProcess) + setConfirmedForegroundProcess(confirmedForegroundProcess) + setForegroundConfirmationSupported(foregroundConfirmationSupported) + write.mockImplementation((ptyId: string, data: string) => { + if (data.startsWith(AGENT_PROMPT_BRACKETED_PASTE_START)) { + // The resumed TUI can emit a live but unclassified title after the + // original statusless-idle proof and before the submit write. + runtime.onPtyData(ptyId, '\x1b]0;workspace\x07\x1b[?25h', 101) + } else if (data === '\r') { + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 102) + } + return true + }) + remountStatuslessCodex('pty-codex-woken') + expect(reattachDelivery).toHaveBeenCalledWith(handle) + const runtimeState = runtime as unknown as { + leaves: Map< + string, + { leafId: string; lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } + > + ptysById: Map< + string, + { lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } + > } - return true - }) - remountStatuslessCodex('pty-codex-woken') - expect(reattachDelivery).toHaveBeenCalledWith(handle) - const runtimeState = runtime as unknown as { - leaves: Map< - string, - { leafId: string; lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } - > - ptysById: Map< - string, - { lastAgentStatus: string | null; lastAgentStatusObservedLive: boolean } - > - } - expect( - [...runtimeState.leaves.values()].find((leaf) => leaf.leafId === LEAF_ID) - ).toMatchObject({ - lastAgentStatus: null, - lastAgentStatusObservedLive: false - }) - expect(runtimeState.ptysById.get('pty-codex-woken')).toMatchObject({ - lastAgentStatus: null, - lastAgentStatusObservedLive: false - }) - await vi.advanceTimersByTimeAsync(12_000) - await vi.waitFor(() => - expect(write).toHaveBeenCalledWith( - 'pty-codex-woken', - expect.stringContaining( - `${AGENT_PROMPT_BRACKETED_PASTE_START}\nYou have 1 orchestration message` + expect( + [...runtimeState.leaves.values()].find((leaf) => leaf.leafId === LEAF_ID) + ).toMatchObject({ + lastAgentStatus: null, + lastAgentStatusObservedLive: false + }) + expect(runtimeState.ptysById.get('pty-codex-woken')).toMatchObject({ + lastAgentStatus: null, + lastAgentStatusObservedLive: false + }) + await vi.advanceTimersByTimeAsync(12_000) + await vi.waitFor(() => + expect(write).toHaveBeenCalledWith( + 'pty-codex-woken', + expect.stringContaining( + `${AGENT_PROMPT_BRACKETED_PASTE_START}\nYou have 1 orchestration message` + ) ) ) - ) - expect(write).toHaveBeenCalledWith('pty-codex-woken', '\r') - expect(highLevelPrompt).not.toHaveBeenCalled() - expect(settledPromptReadiness).not.toHaveBeenCalled() - expect(message.delivered_at).toEqual(expect.any(String)) - db.close() - } finally { - vi.useRealTimers() + expect(write).toHaveBeenCalledWith('pty-codex-woken', '\r') + expect(highLevelPrompt).not.toHaveBeenCalled() + expect(settledPromptReadiness).not.toHaveBeenCalled() + expect(message.delivered_at).toEqual(expect.any(String)) + db.close() + } finally { + vi.useRealTimers() + } } - }) + ) it('does not submit a statusless Codex pointer after the child exits to a shell', async () => { vi.useFakeTimers() diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index 8e1655d400da..e89260b397a7 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -130,6 +130,25 @@ type ProcessTableCapture = { strict: () => ProcessTableRow[] } +async function captureProcessTable(args: readonly string[]): Promise { + let stdout: string + try { + ;({ stdout } = await execFile('ps', [...args], { + encoding: 'utf-8', + timeout: PS_TIMEOUT_MS, + maxBuffer: PS_MAX_BUFFER_BYTES + })) + } catch (error) { + if ((error as { code?: unknown } | null)?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { + throw new ProcessTableCaptureError('capture_truncated') + } + throw error + } + const baseCapture = createProcessTableCapture(assertWholeCapture(stdout)) + const startTimesByPid = await readLinuxProcessStartTimes(baseCapture.lenient()) + return createProcessTableCapture(stdout, startTimesByPid, process.platform === 'linux') +} + function applyProcessStartTimes( rows: ProcessTableRow[], startTimesByPid: ReadonlyMap | undefined, @@ -235,25 +254,7 @@ async function readLinuxProcessStartTimes( } const processTableReader = createProcessTableSnapshotReader({ - runPs: async () => { - let stdout: string - try { - ;({ stdout } = await execFile('ps', [...PS_ARGS], { - encoding: 'utf-8', - timeout: PS_TIMEOUT_MS, - maxBuffer: PS_MAX_BUFFER_BYTES - })) - } catch (error) { - // A ceiling hit is truncation, not absence: name it in the domain vocabulary. - if ((error as { code?: unknown } | null)?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { - throw new ProcessTableCaptureError('capture_truncated') - } - throw error - } - const baseCapture = createProcessTableCapture(assertWholeCapture(stdout)) - const startTimesByPid = await readLinuxProcessStartTimes(baseCapture.lenient()) - return createProcessTableCapture(stdout, startTimesByPid, process.platform === 'linux') - }, + runPs: () => captureProcessTable(PS_ARGS), now: () => Date.now() }) @@ -265,6 +266,31 @@ export async function getFreshProcessTableSnapshot(): Promise return (await processTableReader.getFreshSnapshot()).lenient() } +/** Fresh POSIX process evidence scoped to one PTY instead of the whole host. */ +export async function getFreshPtyProcessTableSnapshot(rootPid: number): Promise { + if (process.platform === 'win32') { + return getFreshProcessTableSnapshot() + } + const rootRows = ( + await captureProcessTable(['-p', String(rootPid), '-o', PS_ARGS[1] ?? '']) + ).strict() + const root = rootRows.find((row) => row.pid === rootPid) + if (!root?.tty || root.tty === '?' || root.tty === '??' || root.tty === '-') { + return getFreshProcessTableSnapshot() + } + const ttyRows = (await captureProcessTable(['-t', root.tty, '-o', PS_ARGS[1] ?? ''])).strict() + const currentRoot = ttyRows.find((row) => row.pid === rootPid) + if ( + !root.startTime || + !currentRoot?.startTime || + currentRoot.startTime !== root.startTime || + currentRoot.tty !== root.tty + ) { + throw new ProcessTableCaptureError('pty_root_changed') + } + return ttyRows +} + export async function getStrictProcessTableSnapshot(): Promise { return (await processTableReader.getSnapshot()).strict() } From 76feffa1ec3d124b9e426bd60c44966ac11aa2ba Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 00:30:12 -0700 Subject: [PATCH 17/29] fix(orchestration): retry resumed Codex mail delivery --- src/main/runtime/orca-runtime-on-pty-data.ts | 2 + .../orchestration/mailbox-pointer-delivery.ts | 38 ++++++-- .../orchestration/mailbox-pointer-stage.ts | 11 ++- .../mailbox-statusless-codex-delivery.test.ts | 51 +++++++++- .../mailbox-statusless-codex-redrive.ts | 95 +++++++++++++++++++ .../mailbox-statusless-codex-submit.ts | 11 ++- src/main/runtime/slept-pane-mail-wake.test.ts | 76 ++++++++++++++- 7 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/orchestration/mailbox-statusless-codex-redrive.ts diff --git a/src/main/runtime/orca-runtime-on-pty-data.ts b/src/main/runtime/orca-runtime-on-pty-data.ts index 5d27da94c145..8d6024dc759d 100644 --- a/src/main/runtime/orca-runtime-on-pty-data.ts +++ b/src/main/runtime/orca-runtime-on-pty-data.ts @@ -256,6 +256,8 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution ...(cwdChanged && cwd !== null ? { cwd } : {}), ...(sourceRanges && sourceRanges.length > 0 ? { sourceRanges } : {}) })) + // A resumed agent can publish its restored screen before it owns the PTY foreground. + this.orchestrationMailboxPointerDelivery.redriveAfterPtyOutput(ptyId) return outputSequence } } diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index 201cc61aa78c..ca5e0fae48a3 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -4,6 +4,7 @@ import type { OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' import { getOrchestrationMailboxPointerCandidates } from './mailbox-pointer-candidates' import { OrchestrationMailboxStatuslessCodexProofCoordinator } from './mailbox-statusless-codex-proof-coordinator' +import { OrchestrationMailboxStatuslessCodexRedrive } from './mailbox-statusless-codex-redrive' import { isStatuslessIdleProofCurrent } from './mailbox-statusless-idle-proof' import { stageOrchestrationMailboxPointer } from './mailbox-pointer-stage' import type { SubmitStatuslessCodexPointer } from './mailbox-statusless-codex-submit' @@ -39,9 +40,13 @@ type PointerDeliveryDependencies = { export class OrchestrationMailboxPointerDelivery { private readonly state = new OrchestrationMailboxPointerState() private readonly statuslessCodexProofs: OrchestrationMailboxStatuslessCodexProofCoordinator + private readonly statuslessCodexRedrives: OrchestrationMailboxStatuslessCodexRedrive constructor(private readonly deps: PointerDeliveryDependencies) { this.statuslessCodexProofs = new OrchestrationMailboxStatuslessCodexProofCoordinator(deps) + this.statuslessCodexRedrives = new OrchestrationMailboxStatuslessCodexRedrive((mailboxHandle) => + this.redrive(mailboxHandle, true) + ) } deliverForHandle(handle: string, reservedTypes?: ReadonlySet): void { @@ -122,7 +127,7 @@ export class OrchestrationMailboxPointerDelivery this.statuslessCodexRedrives.schedule(ptyId, mailboxHandle, sequence), + clearDeferredOutputRedrive: ( + ptyId: string, + mailboxHandle: string, + sequence: number + ) => this.statuslessCodexRedrives.clear(ptyId, mailboxHandle, sequence) + } : {}), writePty: this.deps.writePty, settle: (settledPtyId, settledFlight) => this.settle(settledPtyId, settledFlight), @@ -224,6 +241,7 @@ export class OrchestrationMailboxPointerDelivery = { isLeafPtyProvenAbsent: (ptyId: string) => Promise requestSleepingRecipientWake?: (mailboxHandle: string) => void submitStatuslessCodexPointer?: SubmitStatuslessCodexPointer + deferRedriveUntilPtyOutput?: (ptyId: string, mailboxHandle: string, sequence: number) => boolean + clearDeferredOutputRedrive?: (ptyId: string, mailboxHandle: string, sequence: number) => void writePty: (ptyId: string, data: string) => boolean | Promise settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void redrive: (mailboxHandle: string, force?: boolean) => void @@ -63,7 +65,12 @@ export function stageOrchestrationMailboxPointer + const redriveMailbox = vi.fn((mailboxHandle: string) => { + delivery.deliverForHandle(mailboxHandle) + }) + delivery = new OrchestrationMailboxPointerDelivery({ mailboxOwner: { resolve: () => MAILBOX } as unknown as OrchestrationMailboxOwner, deliveryTarget: { resolveTerminalHandle: () => TERMINAL_HANDLE, @@ -51,7 +55,7 @@ function makeHarness( getTerminalProcessIncarnation: () => processIncarnation, isLeafPtyProvenAbsent: () => Promise.resolve(false), proveStatuslessCodexIdle, - redriveMailbox: () => undefined, + redriveMailbox, ...(options.submitStatuslessCodexPointer ? { submitStatuslessCodexPointer: options.submitStatuslessCodexPointer } : {}), @@ -67,6 +71,7 @@ function makeHarness( setProcessIncarnation: (next: string) => { processIncarnation = next }, + redriveMailbox, writePty } } @@ -230,6 +235,48 @@ describe('statusless Codex mailbox pointer delivery', () => { expect(harness.writePty).not.toHaveBeenCalled() }) + it('retries once when ready output arrived before foreground ownership settled', async () => { + const firstSubmission = deferred() + const submitStatuslessCodexPointer = vi + .fn() + .mockImplementationOnce(async () => firstSubmission.promise) + .mockResolvedValueOnce() + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1'), { + submitStatuslessCodexPointer + }) + + harness.delivery.deliverForHandle(MAILBOX) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(1)) + harness.delivery.redriveAfterPtyOutput('pty-1') + firstSubmission.reject(new Error('foreground_not_ready')) + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(2)) + + expect(harness.redriveMailbox).toHaveBeenCalledTimes(1) + expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toEqual(expect.any(String)) + }) + + it('bounds a permanent structured submission failure to one retry', async () => { + const submitStatuslessCodexPointer = vi + .fn() + .mockRejectedValue(new Error('foreground_not_ready')) + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1'), { + submitStatuslessCodexPointer + }) + + harness.delivery.deliverForHandle(MAILBOX) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(1)) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(2)) + harness.delivery.redriveAfterPtyOutput('pty-1') + await vi.advanceTimersByTimeAsync(10_000) + + expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(2) + expect(harness.redriveMailbox).toHaveBeenCalledTimes(1) + expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toBeNull() + }) + it('requeues staged mail when the process is replaced before submit', async () => { const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1')) diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-redrive.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-redrive.ts new file mode 100644 index 000000000000..ec170ad3645a --- /dev/null +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-redrive.ts @@ -0,0 +1,95 @@ +const REDRIVE_FALLBACK_MS = 1_000 + +type DeferredRedrive = { + sequence: number + armed: boolean + timer: ReturnType | null +} + +export class OrchestrationMailboxStatuslessCodexRedrive { + private readonly redrivesByPtyId = new Map>() + private readonly armedPtyIds = new Set() + + constructor(private readonly redrive: (mailboxHandle: string) => void) {} + + schedule(ptyId: string, mailboxHandle: string, sequence: number): boolean { + const deferred = this.redrivesByPtyId.get(ptyId) ?? new Map() + const existing = deferred.get(mailboxHandle) + if (existing && existing.sequence >= sequence) { + return false + } + if (existing?.timer) { + clearTimeout(existing.timer) + } + const retry = { sequence, armed: true, timer: null as ReturnType | null } + retry.timer = setTimeout(() => { + const current = this.redrivesByPtyId.get(ptyId)?.get(mailboxHandle) + if (current === retry && retry.armed) { + this.consume(ptyId, mailboxHandle, retry) + } + }, REDRIVE_FALLBACK_MS) + retry.timer.unref?.() + deferred.set(mailboxHandle, retry) + this.redrivesByPtyId.set(ptyId, deferred) + this.armedPtyIds.add(ptyId) + return true + } + + clear(ptyId: string, mailboxHandle: string, sequence: number): void { + const deferred = this.redrivesByPtyId.get(ptyId) + const retry = deferred?.get(mailboxHandle) + if (!deferred || !retry || retry.sequence > sequence) { + return + } + if (retry.timer) { + clearTimeout(retry.timer) + } + deferred.delete(mailboxHandle) + if (deferred.size === 0) { + this.redrivesByPtyId.delete(ptyId) + this.armedPtyIds.delete(ptyId) + } + } + + retirePty(ptyId: string): void { + for (const retry of this.redrivesByPtyId.get(ptyId)?.values() ?? []) { + if (retry.timer) { + clearTimeout(retry.timer) + } + } + this.redrivesByPtyId.delete(ptyId) + this.armedPtyIds.delete(ptyId) + } + + handlePtyOutput(ptyId: string): void { + if (!this.armedPtyIds.delete(ptyId)) { + return + } + const deferred = this.redrivesByPtyId.get(ptyId) + for (const [mailboxHandle, retry] of deferred ?? []) { + if (retry.armed) { + this.consume(ptyId, mailboxHandle, retry) + } + } + } + + private consume(ptyId: string, mailboxHandle: string, retry: DeferredRedrive): void { + retry.armed = false + if (retry.timer) { + clearTimeout(retry.timer) + retry.timer = null + } + this.refreshArmedPty(ptyId) + this.redrive(mailboxHandle) + } + + private refreshArmedPty(ptyId: string): void { + for (const retry of this.redrivesByPtyId.get(ptyId)?.values() ?? []) { + if (retry.armed) { + this.armedPtyIds.add(ptyId) + return + } + } + this.armedPtyIds.delete(ptyId) + } +} diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts index 50fefe537749..951b1d4f3046 100644 --- a/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts @@ -28,6 +28,8 @@ type StatuslessCodexSubmitDependencies ReadonlySet | undefined getTerminalProcessIncarnation: (terminalHandle: string) => string | null submitStatuslessCodexPointer: SubmitStatuslessCodexPointer + deferRedriveUntilPtyOutput: (ptyId: string, mailboxHandle: string, sequence: number) => boolean + clearDeferredOutputRedrive: (ptyId: string, mailboxHandle: string, sequence: number) => void settle: (ptyId: string, flight: OrchestrationMailboxDeliveryFlight) => void redrive: (mailboxHandle: string, force?: boolean) => void } @@ -61,6 +63,7 @@ export function submitStatuslessCodexMailboxPointer @@ -100,15 +103,21 @@ export function submitStatuslessCodexMailboxPointer { let released = false if (finalizeReservation) { released = - submitted || clearAndRedrive || releaseWithoutRedrive + submitted || clearAndRedrive || releaseWithoutRedrive || deferredUntilOutput ? deps.state.clearWatermark(input.mailboxHandle, input.newestSequence, ptyId) : deps.state.deactivateWatermark(input.mailboxHandle, input.newestSequence, ptyId) } + if (submitted) { + deps.clearDeferredOutputRedrive(ptyId, input.mailboxHandle, input.newestSequence) + } deps.settle(ptyId, flight) if (released && clearAndRedrive) { deps.redrive(input.mailboxHandle, true) diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 60dca2715b32..4eed5a3328b4 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -6,6 +6,7 @@ import type { WorkspaceSessionState } from '../../shared/workspace-session-state import { InMemoryOrchestrationMessages, TEST_WORKTREE_ID, + deferred, makeRuntimeStoreWithWorkspaceSession, setInMemoryOrchestrationMessages } from './orca-runtime-test-fixtures.spec' @@ -24,6 +25,12 @@ const TAB_ID = 'tab-slept' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +async function flushMicrotasks(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + function sleepingRecord( overrides: Partial = {} ): SleepingAgentSessionRecord { @@ -61,6 +68,7 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ connected: boolean tabMountSends: unknown[][] write: ReturnType + confirmForegroundProcess: ReturnType remountWithPty: (ptyId: string) => void remountStatuslessCodex: (ptyId: string) => void setForegroundProcess: (process: string | null) => void @@ -76,12 +84,14 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ let foregroundProcess: string | null = null let confirmedForegroundProcess: string | null | undefined let foregroundConfirmationSupported = true + const confirmForegroundProcess = vi.fn(async () => + confirmedForegroundProcess === undefined ? foregroundProcess : confirmedForegroundProcess + ) runtime.setPtyController({ write, kill: vi.fn(), getForegroundProcess: async () => foregroundProcess, - confirmForegroundProcess: async () => - confirmedForegroundProcess === undefined ? foregroundProcess : confirmedForegroundProcess, + confirmForegroundProcess, supportsForegroundProcessConfirmation: () => foregroundConfirmationSupported } as never) runtime.attachWindow(1) @@ -143,6 +153,7 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ connected: row!.connected, tabMountSends, write, + confirmForegroundProcess, setForegroundProcess: (process) => { foregroundProcess = process }, @@ -512,4 +523,65 @@ describe('mail addressed to a listed slept pane', () => { vi.useRealTimers() } }) + + it('retries after a resumed Codex takes foreground just after its restored screen appears', async () => { + vi.useFakeTimers() + try { + const { + runtime, + db, + handle, + write, + confirmForegroundProcess, + remountStatuslessCodex, + setForegroundProcess + } = await sleptPaneRuntime(sleepingRecord({ agent: 'codex' })) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + + const firstForegroundConfirmation = deferred() + confirmForegroundProcess + .mockImplementationOnce(() => firstForegroundConfirmation.promise) + .mockResolvedValue('codex') + setForegroundProcess('codex') + write.mockImplementation((ptyId: string, data: string) => { + if (data === '\r') { + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 102) + } + return true + }) + remountStatuslessCodex('pty-codex-woken') + await vi.advanceTimersByTimeAsync(2_000) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledTimes(1)) + runtime.onPtyData('pty-codex-woken', 'restored output\n', 101) + firstForegroundConfirmation.resolve('zsh') + await flushMicrotasks() + expect(write).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(12_000) + expect(confirmForegroundProcess).toHaveBeenCalledTimes(3) + await vi.waitFor(() => + expect(write).toHaveBeenCalledWith( + 'pty-codex-woken', + expect.stringContaining( + `${AGENT_PROMPT_BRACKETED_PASTE_START}\nYou have 1 orchestration message` + ) + ) + ) + + expect(write).toHaveBeenCalledWith('pty-codex-woken', '\r') + expect(message.delivered_at).toEqual(expect.any(String)) + db.close() + } finally { + vi.useRealTimers() + } + }) }) From 188b87bce92629b78fb8a56be651a16e15e159df Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 00:37:41 -0700 Subject: [PATCH 18/29] fix(orchestration): avoid retrying accepted Codex prompts --- src/main/runtime/orca-runtime-stop-requested-pty-ids.ts | 9 ++++++++- .../runtime/orca-runtime-write-terminal-agent-prompt.ts | 2 ++ .../mailbox-statusless-codex-delivery.test.ts | 6 ++++-- .../orchestration/mailbox-statusless-codex-submit.ts | 8 +++++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 8c4fd1a1e55e..52aa8ae87302 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -210,7 +210,13 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId this.deliverPendingMessagesForHandle(mailboxHandle, reservedTypes), requestSleepingRecipientWake: (mailboxHandle) => this.requestSleepingRecipientWake(mailboxHandle), - submitStatuslessCodexPointer: async (handle, expectedPtyId, prompt, beforeWrite) => { + submitStatuslessCodexPointer: async ( + handle, + expectedPtyId, + prompt, + beforeWrite, + afterWrite + ) => { const generation = this.getPtyLifecycleGeneration(expectedPtyId) const pastePayload = buildAgentPromptPasteBytes(prompt) await this.serializeAgentPromptSubmission(expectedPtyId, generation, async () => { @@ -227,6 +233,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId } await beforeWrite(ptyId) }, + afterWrite, suffixFailureError: 'orchestration_pointer_target_changed' }) }) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index a2c40a08bdc4..be6e8189db89 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -52,6 +52,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, pastePayload)) { throw new Error('terminal_not_writable') } + await options.afterWrite?.(ptyId) } catch (error) { renderGate?.dispose() throw error @@ -89,6 +90,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } + await options.afterWrite?.(ptyId) await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts index 88eea59357c3..a355e08baabd 100644 --- a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts @@ -209,12 +209,13 @@ describe('statusless Codex mailbox pointer delivery', () => { } ) - it('keeps mail undelivered when structured submission observes no turn effect', async () => { + it('does not retry after prompt bytes are accepted but no turn effect is observed', async () => { const finished = deferred() const submitStatuslessCodexPointer = vi.fn( - async (_handle: string, ptyId: string, _prompt: string, beforeWrite) => { + async (_handle: string, ptyId: string, _prompt: string, beforeWrite, afterWrite) => { try { await beforeWrite(ptyId) + await afterWrite(ptyId) await beforeWrite(ptyId) throw new Error('agent_prompt_stalled') } finally { @@ -229,6 +230,7 @@ describe('statusless Codex mailbox pointer delivery', () => { harness.delivery.deliverForHandle(MAILBOX) await finished.promise await Promise.resolve() + await vi.advanceTimersByTimeAsync(10_000) expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(1) expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toBeNull() diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts index 951b1d4f3046..eff4358cb95f 100644 --- a/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-submit.ts @@ -16,7 +16,8 @@ export type SubmitStatuslessCodexPointer = ( terminalHandle: string, ptyId: string, prompt: string, - beforeWrite: (ptyId: string) => void | Promise + beforeWrite: (ptyId: string) => void | Promise, + afterWrite: (ptyId: string) => void | Promise ) => Promise type StatuslessCodexSubmitDependencies = { @@ -64,6 +65,7 @@ export function submitStatuslessCodexMailboxPointer @@ -75,6 +77,9 @@ export function submitStatuslessCodexMailboxPointer { + promptWriteAccepted = true } ) ) @@ -105,6 +110,7 @@ export function submitStatuslessCodexMailboxPointer { From f529dee47f5ea30072728abfe5669cd48740aa36 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 00:39:29 -0700 Subject: [PATCH 19/29] perf(runtime): preserve ordinary prompt scheduling --- .../runtime/orca-runtime-write-terminal-agent-prompt.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index be6e8189db89..5cb95bc8a4c6 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -52,7 +52,9 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, pastePayload)) { throw new Error('terminal_not_writable') } - await options.afterWrite?.(ptyId) + if (options.afterWrite) { + await options.afterWrite(ptyId) + } } catch (error) { renderGate?.dispose() throw error @@ -90,7 +92,9 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT)) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } - await options.afterWrite?.(ptyId) + if (options.afterWrite) { + await options.afterWrite(ptyId) + } await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), From e0431071048bc136bc7bacb4fe69bb3abf91de1d Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 01:26:09 -0700 Subject: [PATCH 20/29] fix(orchestration): redrive resumed run by stable pane --- .../orchestration/mailbox-delivery-target.ts | 12 +++-- .../mailbox-statusless-codex-delivery.test.ts | 49 +++++++++++++++++-- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/orchestration/mailbox-delivery-target.ts b/src/main/runtime/orchestration/mailbox-delivery-target.ts index 3e47b020ff04..b8ba47614c11 100644 --- a/src/main/runtime/orchestration/mailbox-delivery-target.ts +++ b/src/main/runtime/orchestration/mailbox-delivery-target.ts @@ -22,15 +22,17 @@ export class OrchestrationMailboxDeliveryTarget { const db = this.deps.getDb() const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : '' const dispatchId = handle.startsWith('dispatch:') ? handle.slice('dispatch:'.length) : '' + const run = runId ? db?.getRun(runId) : undefined const dispatch = dispatchId ? db?.getDispatchContextById?.(dispatchId) : undefined const remote = dispatchId && !dispatch ? db?.getRemoteDispatchAttachment?.(dispatchId) : undefined - const paneKey = dispatch?.assignee_pane_key ?? remote?.pane_key + const paneKey = run?.coordinator_pane_key ?? dispatch?.assignee_pane_key ?? remote?.pane_key + const paneHandle = paneKey ? this.deps.getTerminalHandleForPaneKey(paneKey) : null const ownerHandle = runId - ? db?.getRun(runId)?.coordinator_handle - : ((paneKey ? this.deps.getTerminalHandleForPaneKey(paneKey) : null) ?? - dispatch?.assignee_handle ?? - remote?.terminal_handle) + ? paneHandle && this.deps.hasTerminalHandle(paneHandle) + ? paneHandle + : run?.coordinator_handle + : (paneHandle ?? dispatch?.assignee_handle ?? remote?.terminal_handle) return ownerHandle && this.deps.hasTerminalHandle(ownerHandle) ? ownerHandle : null } diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts index a355e08baabd..a5028deb83e4 100644 --- a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts @@ -1,13 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { InMemoryOrchestrationMessages, deferred } from '../orca-runtime-test-fixtures.spec' import type { OrchestrationDb } from './db' -import type { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' +import { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' import type { OrchestrationMailboxLeaf, OrchestrationMailboxOwner } from './mailbox-owner' import { OrchestrationMailboxPointerDelivery } from './mailbox-pointer-delivery' import type { SubmitStatuslessCodexPointer } from './mailbox-statusless-codex-submit' const MAILBOX = 'run:run-1' const TERMINAL_HANDLE = 'term-1' +const STALE_TERMINAL_HANDLE = 'term-slept' +const PANE_KEY = 'tab-1:leaf-1' function statuslessLeaf(): OrchestrationMailboxLeaf { return { @@ -25,6 +27,7 @@ function makeHarness( proveStatuslessCodexIdle: (terminalHandle: string, ptyId: string) => Promise, options: { withMessage?: boolean + useRemintedRunTarget?: boolean submitStatuslessCodexPointer?: SubmitStatuslessCodexPointer } = {} ) { @@ -34,17 +37,34 @@ function makeHarness( if (options.withMessage !== false) { db.insertMessage({ from: 'term-sender', to: MAILBOX, subject: 'wake work' }) } + if (options.useRemintedRunTarget) { + db.setRun({ + id: 'run-1', + coordinator_handle: STALE_TERMINAL_HANDLE, + coordinator_pane_key: PANE_KEY + }) + } const writePty = vi.fn().mockReturnValue(true) let delivery: OrchestrationMailboxPointerDelivery const redriveMailbox = vi.fn((mailboxHandle: string) => { delivery.deliverForHandle(mailboxHandle) }) + const deliveryTarget = options.useRemintedRunTarget + ? new OrchestrationMailboxDeliveryTarget({ + getDb: () => db as unknown as OrchestrationDb, + getTerminalHandleForPaneKey: (paneKey) => (paneKey === PANE_KEY ? TERMINAL_HANDLE : null), + hasTerminalHandle: (handle) => handle === TERMINAL_HANDLE, + canProbePtyLiveness: () => false, + controllerKnowsPtyIsLive: () => false, + isLeafPtyProvenAbsent: () => Promise.resolve(false) + }) + : ({ + resolveTerminalHandle: () => TERMINAL_HANDLE, + deferForAbsenceProbe: () => false + } as unknown as OrchestrationMailboxDeliveryTarget) delivery = new OrchestrationMailboxPointerDelivery({ mailboxOwner: { resolve: () => MAILBOX } as unknown as OrchestrationMailboxOwner, - deliveryTarget: { - resolveTerminalHandle: () => TERMINAL_HANDLE, - deferForAbsenceProbe: () => false - } as unknown as OrchestrationMailboxDeliveryTarget, + deliveryTarget, getDb: () => db as unknown as OrchestrationDb, getLeaf: () => leaf, getLeafKey: (tabId, leafId) => `${tabId}:${leafId}`, @@ -259,6 +279,25 @@ describe('statusless Codex mailbox pointer delivery', () => { expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toEqual(expect.any(String)) }) + it('redrives a resumed run through its stable pane after the terminal handle remints', async () => { + const submitStatuslessCodexPointer = vi + .fn() + .mockRejectedValueOnce(new Error('foreground_not_ready')) + .mockResolvedValueOnce() + const harness = makeHarness(() => Promise.resolve('pty-1:incarnation-1'), { + submitStatuslessCodexPointer, + useRemintedRunTarget: true + }) + + harness.delivery.deliverForHandle(TERMINAL_HANDLE) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(1)) + await vi.advanceTimersByTimeAsync(1_000) + await vi.waitFor(() => expect(submitStatuslessCodexPointer).toHaveBeenCalledTimes(2)) + + expect(harness.redriveMailbox).toHaveBeenCalledWith(MAILBOX, undefined) + expect(harness.db.getUnreadMessages(MAILBOX)[0]?.delivered_at).toEqual(expect.any(String)) + }) + it('bounds a permanent structured submission failure to one retry', async () => { const submitStatuslessCodexPointer = vi .fn() From a78f3758d5bc49c2877e4d8f6b68dcddec896b78 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 01:32:02 -0700 Subject: [PATCH 21/29] refactor(processes): extract Linux start-time capture --- src/shared/linux-process-start-times.ts | 38 +++++++++++++++++++++ src/shared/process-table-snapshot-reader.ts | 38 +-------------------- 2 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 src/shared/linux-process-start-times.ts diff --git a/src/shared/linux-process-start-times.ts b/src/shared/linux-process-start-times.ts new file mode 100644 index 000000000000..534b10547ce5 --- /dev/null +++ b/src/shared/linux-process-start-times.ts @@ -0,0 +1,38 @@ +import { readFile } from 'node:fs/promises' +import type { ProcessTableRow } from './process-table-snapshot' + +/** Read Linux's stable PID start-time ticks without spawning another process. */ +export async function readLinuxProcessStartTimes( + rows: readonly ProcessTableRow[] +): Promise | undefined> { + if (process.platform !== 'linux') { + return undefined + } + const candidates = rows.filter((row) => row.tty !== undefined && row.tty !== '?') + const starts = await Promise.all( + candidates.map(async (row) => { + try { + const stat = await readFile(`/proc/${row.pid}/stat`, 'utf8') + const closingParen = stat.lastIndexOf(')') + if (closingParen === -1) { + return null + } + const tail = stat + .slice(closingParen + 1) + .trim() + .split(/\s+/) + const startTime = tail[19] + return startTime ? ([row.pid, startTime] as const) : null + } catch { + return null + } + }) + ) + const result = new Map() + for (const entry of starts) { + if (entry) { + result.set(entry[0], entry[1]) + } + } + return result +} diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index e89260b397a7..7a3187df978c 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -1,6 +1,6 @@ import { execFile as execFileCb } from 'node:child_process' -import { readFile } from 'node:fs/promises' import { promisify } from 'node:util' +import { readLinuxProcessStartTimes } from './linux-process-start-times' import { PS_ARGS, PS_MAX_BUFFER_BYTES, @@ -217,42 +217,6 @@ function assertWholeCapture(stdout: string): string { return stdout } -/** Read Linux's stable PID start-time ticks without spawning another process. */ -async function readLinuxProcessStartTimes( - rows: readonly ProcessTableRow[] -): Promise | undefined> { - if (process.platform !== 'linux') { - return undefined - } - const candidates = rows.filter((row) => row.tty !== undefined && row.tty !== '?') - const starts = await Promise.all( - candidates.map(async (row) => { - try { - const stat = await readFile(`/proc/${row.pid}/stat`, 'utf8') - const closingParen = stat.lastIndexOf(')') - if (closingParen === -1) { - return null - } - const tail = stat - .slice(closingParen + 1) - .trim() - .split(/\s+/) - const startTime = tail[19] - return startTime ? ([row.pid, startTime] as const) : null - } catch { - return null - } - }) - ) - const result = new Map() - for (const entry of starts) { - if (entry) { - result.set(entry[0], entry[1]) - } - } - return result -} - const processTableReader = createProcessTableSnapshotReader({ runPs: () => captureProcessTable(PS_ARGS), now: () => Date.now() From c939764f7d0a010033915ea4e9cc5102eca51d33 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 02:19:14 -0700 Subject: [PATCH 22/29] fix(renderer): keep process cadence timing browser-safe --- ...gent-inspection-cadence-batching-benchmark.mjs | 2 +- .../agent-completion-poll-interval.test.ts | 15 ++++++++++++++- .../agent-completion-poll-interval.ts | 2 +- src/shared/process-table-snapshot-reader.ts | 8 +++----- src/shared/process-table-snapshot-timing.ts | 2 ++ 5 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 src/shared/process-table-snapshot-timing.ts diff --git a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs index 128627676c43..5ececab32984 100644 --- a/config/scripts/agent-inspection-cadence-batching-benchmark.mjs +++ b/config/scripts/agent-inspection-cadence-batching-benchmark.mjs @@ -57,7 +57,7 @@ const { POLL_TIER_INTERVAL_MS } = await import( path.join(ROOT, 'src/renderer/src/components/terminal-pane/agent-completion-poll-cadence.ts') ) const { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } = await import( - path.join(ROOT, 'src/shared/process-table-snapshot-reader.ts') + path.join(ROOT, 'src/shared/process-table-snapshot-timing.ts') ) // Pre-change: independent ±10% jitter per pane, re-rolled on every reschedule. diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts index fffbb94de908..fb85fb0f212a 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.test.ts @@ -1,5 +1,6 @@ +import { build } from 'esbuild' import { describe, expect, it } from 'vitest' -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-reader' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-timing' import { POLL_TIER_INTERVAL_MS } from './agent-completion-poll-cadence' import { nextCadenceInspectionDelayMs } from './agent-completion-poll-interval' @@ -14,6 +15,18 @@ describe('nextCadenceInspectionDelayMs', () => { now }) + it('keeps its production import graph browser-only', async () => { + await expect( + build({ + bundle: true, + entryPoints: [`${import.meta.dirname}/agent-completion-poll-interval.ts`], + logLevel: 'silent', + platform: 'browser', + write: false + }) + ).resolves.toMatchObject({ errors: [] }) + }) + it('walks panes that scheduled at different moments onto one shared deadline', () => { // Why this matters: the inspection queue collapses shared-observation tasks enqueued in the // same tick onto one process-table capture, so a shared deadline is one `ps` for all panes. diff --git a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts index a8058bc2ac54..79c1c6bd439b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-poll-interval.ts @@ -1,4 +1,4 @@ -import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-reader' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from '../../../../shared/process-table-snapshot-timing' /** * Picks the delay until a pane's next cadence inspection. diff --git a/src/shared/process-table-snapshot-reader.ts b/src/shared/process-table-snapshot-reader.ts index 53496b359ee0..764fb89f2055 100644 --- a/src/shared/process-table-snapshot-reader.ts +++ b/src/shared/process-table-snapshot-reader.ts @@ -2,6 +2,7 @@ import { execFile as execFileCb } from 'node:child_process' import { promisify } from 'node:util' import { readLinuxProcessStartTimes } from './linux-process-start-times' import { withEvidenceBudget } from './process-table-evidence-budget' +import { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from './process-table-snapshot-timing' import { PS_ARGS, PS_MAX_BUFFER_BYTES, @@ -17,6 +18,7 @@ export { PROCESS_TABLE_EVIDENCE_BUDGET_MS, withEvidenceBudget } from './process-table-evidence-budget' +export { PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS } from './process-table-snapshot-timing' const execFile = promisify(execFileCb) @@ -26,7 +28,7 @@ const execFile = promisify(execFileCb) // whole subsystem answered "unverifiable" about a table it could read. This keeps a wedged // `ps` bounded while staying out of reach of a host that is merely busy. export const PS_TIMEOUT_MS = 15_000 -const DEFAULT_SNAPSHOT_TTL_MS = 500 +const DEFAULT_SNAPSHOT_TTL_MS = PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS type Snapshot = { value: T; capturedAtMs: number; completedAtMs: number } @@ -282,10 +284,6 @@ export async function getStrictProcessTableSnapshotWithAge(): Promise<{ return { rows: snapshot.value.strict(), capturedAgeMs: snapshot.capturedAgeMs } } -/** How much older than its own await a TTL-cached capture may be, on top of the capture's own - * duration. Reported ages carry both, so this alone is not the staleness bound. */ -export const PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS = DEFAULT_SNAPSHOT_TTL_MS - export function resetProcessTableSnapshotForTests(): void { processTableReader.reset() } diff --git a/src/shared/process-table-snapshot-timing.ts b/src/shared/process-table-snapshot-timing.ts new file mode 100644 index 000000000000..eaa3621728c6 --- /dev/null +++ b/src/shared/process-table-snapshot-timing.ts @@ -0,0 +1,2 @@ +/** How long a completed shared process-table snapshot remains reusable, and the maximum cadence pull-forward. */ +export const PROCESS_TABLE_SNAPSHOT_MAX_STALENESS_MS = 500 From 2293df1878a2eceed25d03650f2072f451979812 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 03:44:44 -0700 Subject: [PATCH 23/29] fix orchestration mail wake for resumed Codex --- .../orca-runtime-apply-tracked-pty-title.ts | 10 ++++ ...a-runtime-get-terminal-interactive-wait.ts | 4 +- src/main/runtime/orca-runtime-register-pty.ts | 7 +-- .../runtime/orca-runtime-sync-window-graph.ts | 3 +- .../orchestration/mailbox-pointer-delivery.ts | 2 +- .../mailbox-statusless-idle-proof.ts | 7 +-- src/main/runtime/slept-pane-mail-wake.test.ts | 47 ++++++++++++------- 7 files changed, 48 insertions(+), 32 deletions(-) diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 63e2cbeb0f81..7d0ae629ce28 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -115,6 +115,16 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper // exactly #12536. Waiter semantics stay transition-only above. if (agentStatus === 'idle' && (prevStatus !== 'idle' || !prevObservedLive)) { this.deliverPendingMessagesForLeaf(leaf) + } else if ( + prevStatus === 'working' && + agentStatus === null && + (pty?.launchAgent === 'codex' || pty?.foregroundAgent === 'codex') + ) { + // Codex clears its spinner to a plain cwd title at the ready composer. + const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId)) + if (handle) { + this.deliverPendingMessagesForHandle(handle) + } } } return ptyRecordChanged diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index f720d4a1d8b3..a4f6b5b6e767 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -165,11 +165,9 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd !leaf.connected || !leaf.writable || leaf.lastAgentStatus !== null || - leaf.lastAgentStatusObservedLive || !pty?.connected || pty.launchAgent !== 'codex' || - pty.lastAgentStatus !== null || - pty.lastAgentStatusObservedLive + pty.lastAgentStatus !== null ) { return null } diff --git a/src/main/runtime/orca-runtime-register-pty.ts b/src/main/runtime/orca-runtime-register-pty.ts index 36db9419193d..ae8ee0eda72b 100644 --- a/src/main/runtime/orca-runtime-register-pty.ts +++ b/src/main/runtime/orca-runtime-register-pty.ts @@ -138,12 +138,7 @@ export class OrcaRuntimeWithRegisterPty extends OrcaRuntimeWithInvalidateAllHand ) { const leafKey = this.getLeafKey(binding.tabId, binding.leafId) const leaf = this.leaves.get(leafKey) - if ( - leaf?.ptyId === ptyId && - leaf.writable && - leaf.lastAgentStatus === null && - !leaf.lastAgentStatusObservedLive - ) { + if (leaf?.ptyId === ptyId && leaf.writable && leaf.lastAgentStatus === null) { // Why: graph publication can precede provider-owned reattach identity, so registration must redrive that ordering. this.deliverPendingMessagesForHandle(this.issueHandle(leaf)) } diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index 98b67fc2dbca..ff109248783e 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -322,8 +322,7 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow becameWritable && leaf.ptyId && this.ptysById.get(leaf.ptyId)?.launchAgent === 'codex' && - leaf.lastAgentStatus === null && - !leaf.lastAgentStatusObservedLive + leaf.lastAgentStatus === null ) { // Why: Codex reattach has no status edge, so the writable transition starts its bounded idle proof. const handle = this.handleByLeafKey.get(leafKey) diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index ca5e0fae48a3..20a8417fa324 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -75,7 +75,7 @@ export class OrchestrationMailboxPointerDelivery confirmForegroundProcess: ReturnType remountWithPty: (ptyId: string) => void - remountStatuslessCodex: (ptyId: string) => void + remountStatuslessCodex: (ptyId: string, graphBeforeRegistration: boolean) => void setForegroundProcess: (process: string | null) => void setConfirmedForegroundProcess: (process: string | null | undefined) => void setForegroundConfirmationSupported: (supported: boolean) => void @@ -173,10 +173,12 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ runtime.onPtyData(ptyId, '\x1b]0;Claude working\x07', 100) runtime.onPtyData(ptyId, '\x1b]0;Claude done\x07', 101) }, - remountStatuslessCodex: (ptyId: string) => { - // Production can publish the writable renderer graph before main receives - // the provider-owned reattach identity for that PTY. - syncGraph(ptyId) + remountStatuslessCodex: (ptyId: string, graphBeforeRegistration: boolean) => { + // Reattach registration and renderer graph publication race independently. + if (graphBeforeRegistration) { + syncGraph(ptyId) + } + runtime.onPtyData(ptyId, '\x1b]0;\u280b fixture\x07', 99) runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { tabId: TAB_ID, leafId: LEAF_ID, @@ -191,10 +193,14 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ [ ' >_ OpenAI Codex (v0.132.0)\n', ' model: gpt-5.5 high /model to change\n', - ' directory: ~/orca/workspaces/orca/impl-agent-sleep-wake\n' + ' directory: ~/orca/workspaces/orca/impl-agent-sleep-wake\n', + '\x1b]0;fixture\x07' ].join(''), 100 ) + if (!graphBeforeRegistration) { + syncGraph(ptyId) + } } } } @@ -374,21 +380,28 @@ describe('mail addressed to a listed slept pane', () => { it.each([ { - caseName: 'with fresh local foreground confirmation', + caseName: 'after graph publication with fresh local foreground confirmation', foregroundProcess: 'zsh', confirmedForegroundProcess: '/opt/homebrew/lib/node_modules/@openai/codex/vendor/aarch64-apple-darwin/bin/codex', - foregroundConfirmationSupported: true + foregroundConfirmationSupported: true, + graphBeforeRegistration: true }, { - caseName: 'through an SSH provider without foreground confirmation', + caseName: 'before graph publication through an SSH provider without foreground confirmation', foregroundProcess: 'codex', confirmedForegroundProcess: null, - foregroundConfirmationSupported: false + foregroundConfirmationSupported: false, + graphBeforeRegistration: false } ])( - 'delivers after a statusless Codex reattach $caseName', - async ({ foregroundProcess, confirmedForegroundProcess, foregroundConfirmationSupported }) => { + 'delivers after a statusless Codex reattach clears its working spinner $caseName', + async ({ + foregroundProcess, + confirmedForegroundProcess, + foregroundConfirmationSupported, + graphBeforeRegistration + }) => { vi.useFakeTimers() try { const { @@ -432,7 +445,7 @@ describe('mail addressed to a listed slept pane', () => { } return true }) - remountStatuslessCodex('pty-codex-woken') + remountStatuslessCodex('pty-codex-woken', graphBeforeRegistration) expect(reattachDelivery).toHaveBeenCalledWith(handle) const runtimeState = runtime as unknown as { leaves: Map< @@ -448,11 +461,11 @@ describe('mail addressed to a listed slept pane', () => { [...runtimeState.leaves.values()].find((leaf) => leaf.leafId === LEAF_ID) ).toMatchObject({ lastAgentStatus: null, - lastAgentStatusObservedLive: false + lastAgentStatusObservedLive: true }) expect(runtimeState.ptysById.get('pty-codex-woken')).toMatchObject({ lastAgentStatus: null, - lastAgentStatusObservedLive: false + lastAgentStatusObservedLive: true }) await vi.advanceTimersByTimeAsync(12_000) await vi.waitFor(() => @@ -507,7 +520,7 @@ describe('mail addressed to a listed slept pane', () => { } return true }) - remountStatuslessCodex('pty-codex-woken') + remountStatuslessCodex('pty-codex-woken', true) await vi.advanceTimersByTimeAsync(12_000) expect(write).toHaveBeenCalledWith( @@ -558,7 +571,7 @@ describe('mail addressed to a listed slept pane', () => { } return true }) - remountStatuslessCodex('pty-codex-woken') + remountStatuslessCodex('pty-codex-woken', true) await vi.advanceTimersByTimeAsync(2_000) await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledTimes(1)) runtime.onPtyData('pty-codex-woken', 'restored output\n', 101) From de55469a865264604d3d680a6bbbcde21482615c Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 05:04:19 -0700 Subject: [PATCH 24/29] fix terminal wake scope for sleeping split panes --- .../orca-runtime-wait-for-leaf-pty-id.ts | 3 +- src/main/runtime/slept-pane-mail-wake.test.ts | 3 +- .../ui-bridge-terminal-and-session-tabs.ts | 10 +- src/preload/api/ui-command-event-api.ts | 2 + .../TerminalLegacyTerminalPanes.tsx | 11 +- .../TerminalSplitWorkspaceSurfaces.tsx | 4 + .../TerminalWorktreeSplitSurface.tsx | 3 + .../components/terminal-cold-activation.ts | 9 ++ .../terminal-pane/TerminalOverlaySlot.tsx | 3 + ...TerminalPaneOverlayLayer.react185.test.tsx | 21 +++- .../TerminalPaneOverlayLayer.tsx | 6 + ...nnection-cold-restore-agent-resume.test.ts | 108 +++++++++++++++++- .../terminal-pane/pty-connection-types.ts | 2 + .../deferred-mail-scoped-cold-restore.ts | 26 +++++ .../pty-connection/deferred-session-attach.ts | 6 + ...-cold-park-pre-gate-loop.react185.test.tsx | 43 ++++++- .../terminal-pane-lifecycle-types.ts | 1 + .../terminal-pane/terminal-pane-pty-deps.ts | 1 + .../terminal-pane/terminal-pane-types.ts | 2 + .../use-terminal-pane-foundation.ts | 2 + .../use-terminal-pane-lifecycle-stage.ts | 2 + .../background-terminal-cold-restore-scope.ts | 88 ++++++++++++++ ...background-terminal-worktree-mount.test.ts | 74 ++++++++++++ .../background-terminal-worktree-mount.ts | 22 +++- .../use-terminal-parking-foundation.ts | 9 ++ src/renderer/src/constants/terminal.ts | 3 + .../terminal-presentation-ipc-bridge.ts | 6 +- .../src/lib/mobile-terminal-tab-mount.test.ts | 54 +++++++++ .../src/lib/mobile-terminal-tab-mount.ts | 26 ++++- 29 files changed, 535 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/deferred-mail-scoped-cold-restore.ts create mode 100644 src/renderer/src/components/terminal/background-terminal-cold-restore-scope.ts diff --git a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts index de082c3abde0..4fd24b5e88a6 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -129,7 +129,8 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP worktreeId: args.worktreeId, ...(args.tabId ? { tabId: args.tabId } : {}), ...(args.ptyId ? { ptyId: args.ptyId } : {}), - ...(args.paneKey ? { paneKey: args.paneKey } : {}) + ...(args.paneKey ? { paneKey: args.paneKey } : {}), + ...(args.intent ? { intent: args.intent } : {}) }) return true } catch { diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 12dd37253b35..ac76074706f2 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -301,7 +301,8 @@ describe('mail addressed to a listed slept pane', () => { expect(tabMountSends[0]?.[1]).toMatchObject({ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, - paneKey: PANE_KEY + paneKey: PANE_KEY, + intent: 'inbound-message' }) db.close() }) diff --git a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts index a15bb4d26ba2..c79aadcea10c 100644 --- a/src/preload/api/ui-bridge-terminal-and-session-tabs.ts +++ b/src/preload/api/ui-bridge-terminal-and-session-tabs.ts @@ -1,6 +1,7 @@ import { ipcRenderer } from 'electron' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import type { TerminalTabCreateReply } from '../../shared/terminal-reveal-identity' +import type { TerminalTabMountIntent } from '../../shared/terminal-tab-mount-intent' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -84,11 +85,18 @@ export const uiTerminalAndSessionTabsApi = { tabId?: string ptyId?: string paneKey?: string + intent?: TerminalTabMountIntent }) => void ): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, - data: { worktreeId: string; tabId?: string; ptyId?: string; paneKey?: string } + data: { + worktreeId: string + tabId?: string + ptyId?: string + paneKey?: string + intent?: TerminalTabMountIntent + } ) => callback(data) ipcRenderer.on('terminal:requestTabMount', listener) return () => ipcRenderer.removeListener('terminal:requestTabMount', listener) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 6b684bf26c6c..44efde730d12 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -14,6 +14,7 @@ import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' +import type { TerminalTabMountIntent } from '../../shared/terminal-tab-mount-intent' import type { RuntimeMobileSessionTabMove, RuntimeTerminalCreateRequestPayload, @@ -173,6 +174,7 @@ export type UiCommandEventApi = { tabId?: string ptyId?: string paneKey?: string + intent?: TerminalTabMountIntent }) => void ) => () => void replyTerminalCreate: (reply: TerminalTabCreateReply) => void diff --git a/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx b/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx index f095b77e0e17..8b2c565e7e2f 100644 --- a/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx +++ b/src/renderer/src/components/TerminalLegacyTerminalPanes.tsx @@ -1,7 +1,10 @@ import { createPortal } from 'react-dom' import TerminalPane from './terminal-pane/TerminalPane' import { findActivityTerminalPortal } from './activity/activity-terminal-portal' -import { shouldMountBackgroundWorktreeTab } from './terminal/background-terminal-worktree-mount' +import { + getBackgroundMountColdRestorePaneKeys, + shouldMountBackgroundWorktreeTab +} from './terminal/background-terminal-worktree-mount' import type { TerminalController } from './use-terminal-controller' export function TerminalLegacyTerminalPanes({ @@ -14,6 +17,7 @@ export function TerminalLegacyTerminalPanes({ activeTabType, activeView, activityTerminalPortals, + backgroundMountColdRestorePaneKeysRef, backgroundMountTabIdsByWorktreeRef, effectiveParkedTerminalWorktreeIds, evictionExemptTerminalTabIds, @@ -91,6 +95,11 @@ export function TerminalLegacyTerminalPanes({ isVisible={isActiveTerminalTab || isActivityPortalTab} isWorktreeActive={isVisible || isActivityPortalTab} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} + coldRestorePaneKeys={getBackgroundMountColdRestorePaneKeys( + backgroundMountColdRestorePaneKeysRef.current, + workspace.id, + tab.id + )} onPtyExit={(ptyId, exitCode) => handlePtyExit(tab.id, ptyId, exitCode)} onCloseTab={() => handleCloseTab(tab.id)} /> diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index 5c3145360f65..3d645fbe8cb7 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -13,6 +13,7 @@ export function TerminalSplitWorkspaceSurfaces({ activeView, activityTerminalPortals, anyMountedWorktreeHasLayout, + backgroundMountColdRestorePaneKeysRef, backgroundMountTabIdsByWorktreeRef, effectiveActiveLayout, effectiveParkedTerminalWorktreeIds, @@ -69,6 +70,9 @@ export function TerminalSplitWorkspaceSurfaces({ backgroundMountTabIds={ backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } + backgroundMountColdRestorePaneKeys={ + backgroundMountColdRestorePaneKeysRef.current.get(workspace.id) ?? null + } activationDeferredMountTabIds={ activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } diff --git a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx index e41b1d04091e..56d88466b36a 100644 --- a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx +++ b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx @@ -27,6 +27,7 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ isForceParked, activityTerminalPortals, backgroundMountTabIds, + backgroundMountColdRestorePaneKeys, activationDeferredMountTabIds }: { worktreeId: string @@ -39,6 +40,7 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ isForceParked: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] backgroundMountTabIds: ReadonlySet | null + backgroundMountColdRestorePaneKeys: ReadonlyMap> | null activationDeferredMountTabIds: ReadonlySet | null }): React.JSX.Element { const browserPageIds = useWorktreeBrowserPageIds(worktreeId) @@ -75,6 +77,7 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} backgroundMountTabIds={backgroundMountTabIds} + backgroundMountColdRestorePaneKeys={backgroundMountColdRestorePaneKeys} activationDeferredMountTabIds={activationDeferredMountTabIds} /> [tab.id, tab])) planColdActivationTabDeferral({ @@ -151,10 +155,15 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio tabsByWorktree, activationDeferredMountTabIdsByWorktreeRef.current ) + pruneBackgroundMountColdRestorePaneRestrictions( + backgroundMountColdRestorePaneKeysRef.current, + tabsByWorktree + ) for (const id of mountedWorktreeIdsRef.current) { if (!workspaceSurfaceIdSet.has(id)) { mountedWorktreeIdsRef.current.delete(id) backgroundMountTabIdsByWorktreeRef.current.delete(id) + backgroundMountColdRestorePaneKeysRef.current.delete(id) activationDeferredMountTabIdsByWorktreeRef.current.delete(id) } } diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx index 7c3807ced2f7..2e5bcc22c7bc 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -43,6 +43,7 @@ type TerminalOverlaySlotProps = { isVisible: boolean isActive: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null + coldRestorePaneKeys: ReadonlySet | undefined onFocusOwningGroup: ((groupId: string) => void) | undefined consumeSuppressedPtyExit: (ptyId: string) => boolean leaveWorktreeIfEmpty: () => void @@ -59,6 +60,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ isVisible, isActive, activityTerminalPortal, + coldRestorePaneKeys, onFocusOwningGroup, consumeSuppressedPtyExit, leaveWorktreeIfEmpty @@ -228,6 +230,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ isVisible={isVisible || activityTerminalPortal !== null} isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null} isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} + coldRestorePaneKeys={coldRestorePaneKeys} onPtyExit={(ptyId, exitCode) => { if (consumeSuppressedPtyExit(ptyId)) { return diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx index 0a7b3a99fb3e..d4d20370d4ab 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.react185.test.tsx @@ -6,10 +6,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let terminalPaneRenderCount = 0 -let terminalPaneProps: { onPtyExit?: (ptyId: string, exitCode?: number) => void } | null = null +let terminalPaneProps: { + coldRestorePaneKeys?: ReadonlySet + onPtyExit?: (ptyId: string, exitCode?: number) => void +} | null = null const markUnverifiedPtyLoss = vi.fn() vi.mock('./TerminalPane', () => ({ - default: (props: { onPtyExit?: (ptyId: string, exitCode?: number) => void }) => { + default: (props: { + coldRestorePaneKeys?: ReadonlySet + onPtyExit?: (ptyId: string, exitCode?: number) => void + }) => { terminalPaneProps = props terminalPaneRenderCount += 1 return null @@ -63,7 +69,7 @@ class CapturingResizeObserver { disconnect(): void {} } -function renderSlot(): void { +function renderSlot(coldRestorePaneKeys?: ReadonlySet): void { root = createRoot(container) act(() => { root.render( @@ -78,6 +84,7 @@ function renderSlot(): void { isVisible isActive activityTerminalPortal={null} + coldRestorePaneKeys={coldRestorePaneKeys} onFocusOwningGroup={vi.fn()} consumeSuppressedPtyExit={() => false} leaveWorktreeIfEmpty={vi.fn()} @@ -116,6 +123,14 @@ afterEach(() => { }) describe('TerminalPaneOverlayLayer fallback measure<->fit loop (React #185)', () => { + it('passes the grouped-tab cold restore scope to TerminalPane', () => { + const paneKeys = new Set(['tab-react185:11111111-1111-4111-8111-111111111111']) + + renderSlot(paneKeys) + + expect(terminalPaneProps?.coldRestorePaneKeys).toBe(paneKeys) + }) + it('keeps the tab when the host reports an unverified PTY loss', () => { renderSlot() diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 2cb20783f53e..d2a138f9895e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -32,6 +32,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ shouldMeasureHiddenWorktree = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, backgroundMountTabIds = null, + backgroundMountColdRestorePaneKeys = null, activationDeferredMountTabIds = null }: { worktreeId: string @@ -44,6 +45,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ activityTerminalPortals?: ActivityTerminalPortalTarget[] /** Targeted mounts connect only these terminal tabs. */ backgroundMountTabIds?: ReadonlySet | null + /** Mail-scoped cold restore leaves, keyed by terminal tab. */ + backgroundMountColdRestorePaneKeys?: ReadonlyMap> | null /** Cold-activation deferred tabs receive immediate parked watcher coverage. */ activationDeferredMountTabIds?: ReadonlySet | null }): React.JSX.Element | null { @@ -160,6 +163,9 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ isVisible={isVisible} isActive={isActive} activityTerminalPortal={activityTerminalPortal} + coldRestorePaneKeys={ + backgroundMountColdRestorePaneKeys?.get(terminalTab.id) ?? undefined + } onFocusOwningGroup={focusOwningGroup} consumeSuppressedPtyExit={consumeSuppressedPtyExit} leaveWorktreeIfEmpty={leaveWorktreeIfEmpty} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index 0c51d5465fcc..de4625f3bbac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -6,6 +6,7 @@ import { flushAsyncTicks } from './pty-connection-test-async' import { UUID_RE } from './pty-connection-test-constants' import { LEAF_1, + LEAF_2, createMockTransport, createPane, createManager @@ -189,7 +190,8 @@ describe('connectPanePty', () => { const manager = createManager(1) const deps = createDeps({ restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' }, + coldRestorePaneKeys: new Set([paneKey]) }) connectPanePty(pane as never, manager as never, deps as never) @@ -402,6 +404,110 @@ describe('connectPanePty', () => { expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) }) + it('leaves an unaddressed sleeping split sibling inert during a mail-scoped mount', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + const addressedPaneKey = makePaneKey('tab-1', LEAF_1) + const siblingPaneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-sibling-pty' }] + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [siblingPaneKey]: { + paneKey: siblingPaneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'sibling-session' }, + prompt: 'stay asleep', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + const binding = connectPanePty( + createPane(2) as never, + createManager(2) as never, + createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'lost-sibling-pty' }, + coldRestorePaneKeys: new Set([addressedPaneKey]), + isVisibleRef: { current: false } + }) as never + ) as unknown as { + wakeHibernatedAgentIfArmed: (claims?: Set) => string | null + } + await flushAsyncTicks(20) + await new Promise((resolve) => setTimeout(resolve, 300)) + + expect(transport.connect).not.toHaveBeenCalled() + expect(transport.attach).not.toHaveBeenCalled() + expect(mockStoreState.clearSleepingAgentSession).not.toHaveBeenCalled() + + const claims = new Set() + expect(binding.wakeHibernatedAgentIfArmed(claims)).not.toBeNull() + await flushAsyncTicks(20) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining("resume' 'sibling-session'"), + env: expect.objectContaining({ ORCA_PANE_KEY: siblingPaneKey }) + }) + ) + }) + + it('resumes a scoped sibling when user reveal wins the deferred-attach race', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport(null) + transportFactoryQueue.push(transport) + const addressedPaneKey = makePaneKey('tab-1', LEAF_1) + const siblingPaneKey = makePaneKey('tab-1', LEAF_2) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'lost-sibling-pty' }] + }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [siblingPaneKey]: { + paneKey: siblingPaneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'sibling-session' }, + prompt: 'wake on reveal', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + } + } as StoreState + + connectPanePty( + createPane(2) as never, + createManager(2) as never, + createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'lost-sibling-pty' }, + coldRestorePaneKeys: new Set([addressedPaneKey]), + isVisibleRef: { current: true } + }) as never + ) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.stringContaining("resume' 'sibling-session'"), + env: expect.objectContaining({ ORCA_PANE_KEY: siblingPaneKey }) + }) + ) + }) + it('resumes from a sleeping record after the same stable leaf moves to a reminted tab', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('fresh-pty') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 9775b3d8278b..26f4099ff4e1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -67,6 +67,8 @@ export type PtyConnectionDeps = { startup?: PtyPaneStartup restoredLeafId?: string | null restoredPtyIdByLeafId?: Record + /** Mount-time mail scope; sleeping siblings outside this set remain inert. */ + coldRestorePaneKeys?: ReadonlySet /** Park intent sampled at render time, before the host disposes the tab's * watchers; consumed once the restored layout has been replayed. */ mountFollowsTerminalPark: boolean diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-mail-scoped-cold-restore.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-mail-scoped-cold-restore.ts new file mode 100644 index 000000000000..f1b9cac552d0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-mail-scoped-cold-restore.ts @@ -0,0 +1,26 @@ +import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { useAppStore } from '@/store' + +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +export function deferUnaddressedMailScopedColdRestore(session: ConnectPanePtySession): boolean { + const sleepingRecordEntry = session.getSleepingRecordForPane(useAppStore.getState()) + if ( + !sleepingRecordEntry || + !session.deps.coldRestorePaneKeys || + session.deps.coldRestorePaneKeys.has(session.cacheKey) + ) { + return false + } + + session.hibernatedWakeTarget = { + ptyId: session.deps.restoredPtyIdByLeafId?.[session.pane.leafId] ?? '', + record: sleepingRecordEntry.record + } + if (session.deps.isVisibleRef.current) { + // A reveal can beat the deferred attach frame and observe no armed target. + queueMicrotask(() => session.consumeHibernatedAgentWake()) + } + scheduleRuntimeGraphSync() + return true +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts index 1b05fa09e603..8bfef9b71952 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-attach.ts @@ -10,6 +10,7 @@ import { toProcessExitStartup } from './process-exit-startup' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { deferUnaddressedMailScopedColdRestore } from './deferred-mail-scoped-cold-restore' import { runDeferredSessionReattachChoice } from './deferred-session-reattach-choice' import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' @@ -18,6 +19,11 @@ export function runDeferredSessionAttach(session: ConnectPanePtySession): void { !session.disposed && session.deps.paneTransportsRef.current.get(session.pane.id) === session.transport + // Restore the split layout without reviving sleeping siblings that did not receive mail. + if (deferUnaddressedMailScopedColdRestore(session)) { + return + } + // Why: trigger the deferred SSH connect per-tab (not per-target) so multiple tabs for one target reattach independently. // Must run before session-id resolution: the SSH provider isn't registered until connect succeeds. if (session.connectionId) { diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx index 5f4db2d38788..812edb74a335 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx @@ -14,6 +14,10 @@ const harness = vi.hoisted(() => ({ coverageCalls: 0, syncCalls: 0, slotRenders: 0, + slotProps: [] as { + terminalTabId: string + coldRestorePaneKeys?: ReadonlySet + }[], renderedParkedSets: [] as string[][] })) @@ -55,8 +59,15 @@ vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({ })) vi.mock('./TerminalOverlaySlot', () => ({ - TerminalOverlaySlot: () => { + TerminalOverlaySlot: (props: { + terminalTabId: string + coldRestorePaneKeys?: ReadonlySet + }) => { harness.slotRenders += 1 + harness.slotProps.push({ + terminalTabId: props.terminalTabId, + coldRestorePaneKeys: props.coldRestorePaneKeys + }) return null } })) @@ -152,7 +163,11 @@ function unifiedTerminalTab(id: string): Tab { } } -function renderProductionLayer(root: Root, coldParkTerminalPanes = true): unknown { +function renderProductionLayer( + root: Root, + coldParkTerminalPanes = true, + backgroundMountColdRestorePaneKeys?: ReadonlyMap> +): unknown { const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) let thrown: unknown = null try { @@ -164,6 +179,7 @@ function renderProductionLayer(root: Root, coldParkTerminalPanes = true): unknow worktreePath="cold-park-pre-gate" isWorktreeActive={false} coldParkTerminalPanes={coldParkTerminalPanes} + backgroundMountColdRestorePaneKeys={backgroundMountColdRestorePaneKeys} /> ) @@ -184,6 +200,7 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { harness.coverageCalls = 0 harness.syncCalls = 0 harness.slotRenders = 0 + harness.slotProps.length = 0 harness.renderedParkedSets.length = 0 const tabs = TAB_IDS.map(terminalTab) const unifiedTabs = TAB_IDS.map(unifiedTerminalTab) @@ -328,6 +345,28 @@ describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { expect(harness.renderedParkedSets.at(-1)).toEqual([...TAB_IDS]) }) + it('passes mail-scoped cold restore leaves to the matching grouped terminal tab', () => { + root = createRoot(container) + const paneKeys = new Set(['pane-addressed']) + const thrown = renderProductionLayer(root, false, new Map([[TAB_IDS[1], paneKeys] as const])) + + expect(thrown).toBeNull() + const firstTabRenders = harness.slotProps.filter( + ({ terminalTabId }) => terminalTabId === TAB_IDS[0] + ) + const addressedTabRenders = harness.slotProps.filter( + ({ terminalTabId }) => terminalTabId === TAB_IDS[1] + ) + expect(firstTabRenders.length).toBeGreaterThan(0) + expect( + firstTabRenders.every(({ coldRestorePaneKeys }) => coldRestorePaneKeys === undefined) + ).toBe(true) + expect(addressedTabRenders.length).toBeGreaterThan(0) + expect( + addressedTabRenders.every(({ coldRestorePaneKeys }) => coldRestorePaneKeys === paneKeys) + ).toBe(true) + }) + it('settles equivalent publications after memo cache discard', () => { harness.discardMemoCaches = true root = createRoot(container) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts index f074064154b7..5497c407d69b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-lifecycle-types.ts @@ -44,6 +44,7 @@ export type UseTerminalPaneLifecycleDeps = { issueCommandSplit?: TerminalPaneIssueCommandSplit | null isActive: boolean isVisible: boolean + coldRestorePaneKeys?: ReadonlySet systemPrefersDark: boolean settings: GlobalSettings | null | undefined settingsRef: React.RefObject diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts index a89875179d69..a437420b7757 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pty-deps.ts @@ -44,6 +44,7 @@ export function createTerminalPanePtyDeps(args: { worktreeId: deps.worktreeId, cwd: startupCwd, startup: startupWithSetupSplitWait, + coldRestorePaneKeys: deps.coldRestorePaneKeys, mountFollowsTerminalPark, paneTransportsRef: deps.paneTransportsRef, paneMode2031Ref: deps.paneMode2031Ref, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-types.ts b/src/renderer/src/components/terminal-pane/terminal-pane-types.ts index 11561e2742d9..d7eed121d0a2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-types.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-types.ts @@ -7,6 +7,8 @@ export type TerminalPaneProps = { isWorktreeActive?: boolean // Activity portals can isolate one split without changing expanded state or persistence. isolatedPaneKey?: string | null + /** Present only while mail background-mounts an unmounted split tab. */ + coldRestorePaneKeys?: ReadonlySet // Why: one-off command terminals keep split shortcuts but hide the prominent header button. showSplitButton?: boolean onPtyExit: (ptyId: string, exitCode?: number) => void diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts index 804dd1bfac9b..bcfc31832923 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-foundation.ts @@ -36,6 +36,7 @@ export function useTerminalPaneFoundation( isVisible = true, isWorktreeActive = isVisible, isolatedPaneKey = null, + coldRestorePaneKeys, showSplitButton = true, onPtyExit, onCloseTab @@ -158,6 +159,7 @@ export function useTerminalPaneFoundation( isVisible, isWorktreeActive, isolatedPaneKey, + coldRestorePaneKeys, showSplitButton, onPtyExit, onCloseTab, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts index def270f7abf6..48be4accdcf4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle-stage.ts @@ -11,6 +11,7 @@ export function useTerminalPaneLifecycleStage(controller: TerminalPaneCloseContr clearTerminalPaneUnread, clearTerminalTabUnread, clearWorktreeUnread, + coldRestorePaneKeys, containerRef, cwd, dispatchNotification, @@ -83,6 +84,7 @@ export function useTerminalPaneLifecycleStage(controller: TerminalPaneCloseContr issueCommandSplit, isActive, isVisible: isRendererVisible, + coldRestorePaneKeys, systemPrefersDark, settings, settingsRef, diff --git a/src/renderer/src/components/terminal/background-terminal-cold-restore-scope.ts b/src/renderer/src/components/terminal/background-terminal-cold-restore-scope.ts new file mode 100644 index 000000000000..bedad7ac03f5 --- /dev/null +++ b/src/renderer/src/components/terminal/background-terminal-cold-restore-scope.ts @@ -0,0 +1,88 @@ +import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' + +export type BackgroundMountColdRestorePaneRestrictions = Map< + string, + ReadonlyMap> +> + +export function mergeBackgroundMountColdRestorePaneScopes( + existing: BackgroundMountTerminalWorktreeDetail, + incoming: BackgroundMountTerminalWorktreeDetail, + tabIds: readonly string[] +): BackgroundMountTerminalWorktreeDetail['coldRestorePaneKeysByTabId'] { + const merged: Record = {} + for (const tabId of tabIds) { + const existingRequested = existing.tabIds?.includes(tabId) === true + const incomingRequested = incoming.tabIds?.includes(tabId) === true + const existingKeys = existing.coldRestorePaneKeysByTabId?.[tabId] + const incomingKeys = incoming.coldRestorePaneKeysByTabId?.[tabId] + // An unscoped request for the same tab dominates a mail-scoped one. + if ((existingRequested && !existingKeys) || (incomingRequested && !incomingKeys)) { + continue + } + const paneKeys = [...new Set([...(existingKeys ?? []), ...(incomingKeys ?? [])])] + if (paneKeys.length > 0) { + merged[tabId] = paneKeys + } + } + return Object.keys(merged).length > 0 ? merged : undefined +} + +export function applyBackgroundMountColdRestorePaneRestriction( + restrictions: BackgroundMountColdRestorePaneRestrictions, + detail: BackgroundMountTerminalWorktreeDetail +): void { + const { worktreeId, tabIds, coldRestorePaneKeysByTabId } = detail + const existing = restrictions.get(worktreeId) + if (!tabIds) { + restrictions.delete(worktreeId) + return + } + const next = new Map(existing ?? []) + for (const tabId of tabIds) { + const paneKeys = coldRestorePaneKeysByTabId?.[tabId] + if (!paneKeys) { + next.delete(tabId) + continue + } + next.set(tabId, new Set([...(next.get(tabId) ?? []), ...paneKeys])) + } + if (next.size > 0) { + restrictions.set(worktreeId, next) + } else { + restrictions.delete(worktreeId) + } +} + +export function getBackgroundMountColdRestorePaneKeys( + restrictions: BackgroundMountColdRestorePaneRestrictions, + worktreeId: string, + tabId: string +): ReadonlySet | undefined { + return restrictions.get(worktreeId)?.get(tabId) +} + +export function pruneBackgroundMountColdRestorePaneRestrictions( + restrictions: BackgroundMountColdRestorePaneRestrictions, + tabsByWorktree: Record +): void { + for (const [worktreeId, byTabId] of restrictions) { + const liveTabIds = new Set((tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + let hasClosedTab = false + for (const tabId of byTabId.keys()) { + if (!liveTabIds.has(tabId)) { + hasClosedTab = true + break + } + } + if (!hasClosedTab) { + continue + } + const retained = new Map([...byTabId].filter(([tabId]) => liveTabIds.has(tabId))) + if (retained.size > 0) { + restrictions.set(worktreeId, retained) + } else { + restrictions.delete(worktreeId) + } + } +} diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts index 9cf64002290c..1dc5a7685277 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts @@ -9,12 +9,15 @@ import { import { COLD_ACTIVATION_TAB_DEFER_THRESHOLD, addBackgroundMountedTerminalWorktree, + applyBackgroundMountColdRestorePaneRestriction, applyBackgroundMountTabRestriction, canDeferColdActivationTabsForHost, canMountTerminalWorkspaceForStartup, collectDeferredMountTabIds, hasRequestedBackgroundTerminalWorktreeMount, + getBackgroundMountColdRestorePaneKeys, planColdActivationTabDeferral, + pruneBackgroundMountColdRestorePaneRestrictions, pruneClosedBackgroundMountTabs, requestBackgroundTerminalWorktreeMount, revealActivationDeferredTabs, @@ -80,6 +83,77 @@ describe('background terminal mount request registry', () => { requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1' }) expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([{ worktreeId: 'wt-1' }]) }) + + it('unions mail pane scopes without narrowing an unscoped request for the same tab', () => { + takeAllPendingBackgroundTerminalWorktreeMounts() + requestBackgroundTerminalWorktreeMount({ + worktreeId: 'wt-1', + tabIds: ['tab-1'], + coldRestorePaneKeysByTabId: { 'tab-1': ['tab-1:leaf-1'] } + }) + requestBackgroundTerminalWorktreeMount({ + worktreeId: 'wt-1', + tabIds: ['tab-1', 'tab-2'], + coldRestorePaneKeysByTabId: { + 'tab-1': ['tab-1:leaf-2'], + 'tab-2': ['tab-2:leaf-1'] + } + }) + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([ + { + worktreeId: 'wt-1', + tabIds: ['tab-1', 'tab-2'], + coldRestorePaneKeysByTabId: { + 'tab-1': ['tab-1:leaf-1', 'tab-1:leaf-2'], + 'tab-2': ['tab-2:leaf-1'] + } + } + ]) + + requestBackgroundTerminalWorktreeMount({ + worktreeId: 'wt-1', + tabIds: ['tab-1'], + coldRestorePaneKeysByTabId: { 'tab-1': ['tab-1:leaf-1'] } + }) + requestBackgroundTerminalWorktreeMount({ worktreeId: 'wt-1', tabIds: ['tab-1'] }) + expect(takeAllPendingBackgroundTerminalWorktreeMounts()).toEqual([ + { worktreeId: 'wt-1', tabIds: ['tab-1'] } + ]) + }) +}) + +describe('background mount cold-restore pane restriction', () => { + it('keeps split-tab mail wakes pane-scoped and lets an unscoped mount lift the gate', () => { + const restrictions = new Map>>() + applyBackgroundMountColdRestorePaneRestriction(restrictions, { + worktreeId: 'wt-1', + tabIds: ['tab-1'], + coldRestorePaneKeysByTabId: { 'tab-1': ['tab-1:leaf-1'] } + }) + expect(getBackgroundMountColdRestorePaneKeys(restrictions, 'wt-1', 'tab-1')).toEqual( + new Set(['tab-1:leaf-1']) + ) + + applyBackgroundMountColdRestorePaneRestriction(restrictions, { + worktreeId: 'wt-1', + tabIds: ['tab-1'] + }) + expect(getBackgroundMountColdRestorePaneKeys(restrictions, 'wt-1', 'tab-1')).toBeUndefined() + }) + + it('preserves the per-worktree map identity when every restricted tab remains live', () => { + const byTabId = new Map([ + ['tab-1', new Set(['tab-1:leaf-1'])], + ['tab-2', new Set(['tab-2:leaf-1'])] + ]) + const restrictions = new Map([['wt-1', byTabId]]) + + pruneBackgroundMountColdRestorePaneRestrictions(restrictions, { + 'wt-1': [{ id: 'tab-1' }, { id: 'tab-2' }] + }) + + expect(restrictions.get('wt-1')).toBe(byTabId) + }) }) describe('addBackgroundMountedTerminalWorktree', () => { diff --git a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts index 7433a559fed7..b8df4ba1f519 100644 --- a/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts +++ b/src/renderer/src/components/terminal/background-terminal-worktree-mount.ts @@ -3,6 +3,14 @@ import { type BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' import { parseExecutionHostId } from '../../../../shared/execution-host' +import { mergeBackgroundMountColdRestorePaneScopes } from './background-terminal-cold-restore-scope' + +export { + applyBackgroundMountColdRestorePaneRestriction, + getBackgroundMountColdRestorePaneKeys, + pruneBackgroundMountColdRestorePaneRestrictions, + type BackgroundMountColdRestorePaneRestrictions +} from './background-terminal-cold-restore-scope' const pendingMounts = new Map() const requestListeners = new Set<() => void>() @@ -13,7 +21,10 @@ function mergePendingMount(detail: BackgroundMountTerminalWorktreeDetail): void if (!existing) { pendingMounts.set(detail.worktreeId, { worktreeId: detail.worktreeId, - ...(detail.tabIds !== undefined ? { tabIds: [...new Set(detail.tabIds)] } : {}) + ...(detail.tabIds !== undefined ? { tabIds: [...new Set(detail.tabIds)] } : {}), + ...(detail.coldRestorePaneKeysByTabId + ? { coldRestorePaneKeysByTabId: detail.coldRestorePaneKeysByTabId } + : {}) }) return } @@ -21,9 +32,16 @@ function mergePendingMount(detail: BackgroundMountTerminalWorktreeDetail): void pendingMounts.set(detail.worktreeId, { worktreeId: detail.worktreeId }) return } + const tabIds = [...new Set([...existing.tabIds, ...detail.tabIds])] + const coldRestorePaneKeysByTabId = mergeBackgroundMountColdRestorePaneScopes( + existing, + detail, + tabIds + ) pendingMounts.set(detail.worktreeId, { worktreeId: detail.worktreeId, - tabIds: [...new Set([...existing.tabIds, ...detail.tabIds])] + tabIds, + ...(coldRestorePaneKeysByTabId ? { coldRestorePaneKeysByTabId } : {}) }) } diff --git a/src/renderer/src/components/use-terminal-parking-foundation.ts b/src/renderer/src/components/use-terminal-parking-foundation.ts index 634813d70be7..7e780f4b7857 100644 --- a/src/renderer/src/components/use-terminal-parking-foundation.ts +++ b/src/renderer/src/components/use-terminal-parking-foundation.ts @@ -7,6 +7,7 @@ import { import { useActiveTerminalRepair } from './terminal/use-active-terminal-repair' import { scheduleBackgroundTerminalWorktreeMeasure } from './terminal/background-terminal-worktree-visibility' import { + applyBackgroundMountColdRestorePaneRestriction, applyBackgroundMountTabRestriction, revealActivationDeferredTabs, takeAllPendingBackgroundTerminalWorktreeMounts, @@ -66,6 +67,9 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont >(() => new Set()) const forceParkedCaptureDoneRef = useRef(new Set()) const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) + const backgroundMountColdRestorePaneKeysRef = useRef( + new Map>>() + ) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) @@ -74,6 +78,10 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current const applyBackgroundMount = (detail: BackgroundMountTerminalWorktreeDetail): void => { const worktreeId = detail.worktreeId + applyBackgroundMountColdRestorePaneRestriction( + backgroundMountColdRestorePaneKeysRef.current, + detail + ) applyBackgroundMountTabRestriction( backgroundMountTabIdsByWorktreeRef.current, mountedWorktreeIdsRef.current, @@ -162,6 +170,7 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont setEvictionExemptTerminalTabIds, forceParkedCaptureDoneRef, backgroundMountTabIdsByWorktreeRef, + backgroundMountColdRestorePaneKeysRef, activationDeferredMountTabIdsByWorktreeRef, lastActivationWorktreeIdRef } diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 0d24d49417c1..fd04dc26ad0d 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -81,6 +81,9 @@ export type BackgroundMountTerminalWorktreeDetail = { * which tabs they need. Omitted → whole-worktree mount (legacy dispatch * sites); a real activation always lifts the restriction. */ tabIds?: readonly string[] + /** Mail wake only: pane identities allowed to cold-restore within each + * mounted split tab. Omitted keeps user-open and mobile mounts unscoped. */ + coldRestorePaneKeysByTabId?: Readonly> } export type WakeHibernatedAgentsWorktreeDetail = { diff --git a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts index 2feaa530c66b..0317dfb1edba 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-presentation-ipc-bridge.ts @@ -248,7 +248,7 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v // Why: background-mount a mobile-subscribed tab's PTY without navigating the desktop (STA-1840). unsubs.push( - window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId, paneKey }) => { + window.api.ui.onRequestTerminalTabMount(({ worktreeId, tabId, ptyId, paneKey, intent }) => { if (!worktreeId) { return } @@ -258,7 +258,9 @@ export function registerTerminalPresentationIpcBridge(unsubs: (() => void)[]): v { worktreeId, ...(tabId ? { tabId } : {}), - ...(ptyId ? { ptyId } : {}) + ...(ptyId ? { ptyId } : {}), + ...(paneKey ? { paneKey } : {}), + ...(intent ? { intent } : {}) }, { isTabMounted: (tabId, targetWorktreeId) => diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index f4ea46f61b02..49af563ec372 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -6,6 +6,8 @@ import { } from './mobile-terminal-tab-mount' import type { TerminalTabPtyOwnershipState } from './terminal-tab-for-pty-id' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + function state(tabCount = 1): TerminalTabPtyOwnershipState { return { tabsByWorktree: { @@ -129,6 +131,58 @@ describe('resolveMobileTerminalTabMount', () => { ).toEqual({ kind: 'mount', detail: { worktreeId: 'wt', tabIds: ['tab-0'] } }) }) + it('scopes an inbound-message mount to its addressed split leaf', () => { + expect( + resolveMobileTerminalTabMount( + state(), + { + worktreeId: 'wt', + tabId: 'tab-0', + paneKey: `tab-0:${LEAF_ID}`, + intent: 'inbound-message' + }, + { isTabMounted: () => false } + ) + ).toEqual({ + kind: 'mount', + detail: { + worktreeId: 'wt', + tabIds: ['tab-0'], + coldRestorePaneKeysByTabId: { 'tab-0': [`tab-0:${LEAF_ID}`] } + } + }) + }) + + it('keeps client-subscribe mounts unscoped even when they carry pane identity', () => { + expect( + resolveMobileTerminalTabMount( + state(), + { + worktreeId: 'wt', + tabId: 'tab-0', + paneKey: `tab-0:${LEAF_ID}`, + intent: 'client-subscribe' + }, + { isTabMounted: () => false } + ) + ).toEqual({ kind: 'mount', detail: { worktreeId: 'wt', tabIds: ['tab-0'] } }) + }) + + it('does not widen malformed inbound pane identity into an unscoped mount', () => { + expect( + resolveMobileTerminalTabMount( + state(), + { + worktreeId: 'wt', + tabId: 'tab-0', + paneKey: 'tab-0:not-a-stable-leaf', + intent: 'inbound-message' + }, + { isTabMounted: () => false } + ) + ).toBeNull() + }) + it('returns null when the tab does not resolve at all', () => { expect( resolveMobileTerminalTabMount( diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts index 84e0bd457b4d..02236f198613 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -1,4 +1,9 @@ import type { BackgroundMountTerminalWorktreeDetail } from '@/constants/terminal' +import { + isInboundMessageTabMount, + type TerminalTabMountIntent +} from '../../../shared/terminal-tab-mount-intent' +import { makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' import { resolveTerminalTabIdForPtyId, type TerminalTabPtyOwnershipState @@ -8,6 +13,8 @@ export type MobileTerminalTabMountRequest = { worktreeId: string tabId?: string ptyId?: string + paneKey?: string + intent?: TerminalTabMountIntent } type MobileTerminalTabMountOptions = { @@ -43,13 +50,30 @@ export function resolveMobileTerminalTabMount( if (!tabId) { return null } + const requestedPane = request.paneKey ? parsePaneKey(request.paneKey) : null + if (isInboundMessageTabMount(request.intent) && request.paneKey && !requestedPane) { + return null + } // Why: replaying the background-mount event for a live pane restarts its // three-second hidden measurement window on every mobile reconnect. The // caller still needs to know the tab resolved: a slept pane whose tab is // still mounted is woken in place, not by mounting (its mount is a no-op). return options.isTabMounted?.(tabId, request.worktreeId) ? { kind: 'already-mounted', tabId } - : { kind: 'mount', detail: { worktreeId: request.worktreeId, tabIds: [tabId] } } + : { + kind: 'mount', + detail: { + worktreeId: request.worktreeId, + tabIds: [tabId], + ...(isInboundMessageTabMount(request.intent) && requestedPane + ? { + coldRestorePaneKeysByTabId: { + [tabId]: [makePaneKey(tabId, requestedPane.leafId)] + } + } + : {}) + } + } } export function planMobileTerminalTabMount( From d3662d154a01b2963af47472430a7320ff70cabe Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 06:33:04 -0700 Subject: [PATCH 25/29] fix: preserve agent authority when moving terminal panes --- ...nal-pane-mount-events-existing-pty.test.ts | 105 ++++++++++++++++ .../terminal-pane-mount-events.ts | 10 ++ ...minal-split-existing-pty-authority.test.ts | 116 ++++++++++++++++++ .../terminal-split-existing-pty-authority.ts | 52 ++++++++ 4 files changed, 283 insertions(+) create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts new file mode 100644 index 000000000000..b26f024bc52c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts @@ -0,0 +1,105 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const SOURCE_TAB_ID = 'tab-source' +const TARGET_TAB_ID = 'tab-target' +const WORKTREE_ID = 'folder:split-authority' +const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TARGET_SOURCE_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const TARGET_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const PTY_ID = 'pty-existing' + +const harness = vi.hoisted(() => ({ + state: null as unknown as { + tabsByWorktree: Record + terminalLayoutsByTabId: Record } | undefined> + transferAgentPaneAuthority: ReturnType + } +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => harness.state } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + consumePendingWebRuntimeSplitMirrorTelemetry: () => false +})) + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync: vi.fn() +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ + closeTerminalTab: vi.fn() +})) + +vi.mock('./terminal-pane-lifecycle-primitives', () => ({ + recordRuntimeCreatedTerminalPaneSplit: vi.fn(), + splitPaneWithOneShotStartup: vi.fn() +})) + +import { dispatchTerminalPaneSplitRequest } from './terminal-pane-split-request-routing' +import { installTerminalPaneMountEvents } from './terminal-pane-mount-events' + +describe('terminal pane mount existing PTY split', () => { + let dispose: (() => void) | null = null + + beforeEach(() => { + harness.state = { + tabsByWorktree: { + [WORKTREE_ID]: [{ id: SOURCE_TAB_ID }, { id: TARGET_TAB_ID }] + }, + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: { ptyIdsByLeafId: { [SOURCE_LEAF_ID]: PTY_ID } }, + [TARGET_TAB_ID]: {} + }, + transferAgentPaneAuthority: vi.fn() + } + }) + + afterEach(() => { + dispose?.() + dispose = null + }) + + it('transfers agent authority when a successful split adopts another tab PTY', () => { + const manager = { + getNumericIdForLeaf: vi.fn((leafId: string) => (leafId === TARGET_SOURCE_LEAF_ID ? 7 : null)), + splitPane: vi.fn(() => ({ leafId: TARGET_LEAF_ID })) + } + dispose = installTerminalPaneMountEvents({ + manager: manager as never, + deps: { + tabId: TARGET_TAB_ID, + worktreeId: WORKTREE_ID, + isActive: true, + managerRef: { current: manager } as never, + persistLayoutSnapshot: vi.fn(), + syncCanExpandState: vi.fn(), + queueResizeAll: vi.fn() + }, + ptyDeps: {} as never + }) + + dispatchTerminalPaneSplitRequest({ + tabId: TARGET_TAB_ID, + worktreeId: WORKTREE_ID, + paneRuntimeId: -1, + direction: 'vertical', + sourceLeafId: TARGET_SOURCE_LEAF_ID, + newLeafId: TARGET_LEAF_ID, + ptyId: PTY_ID + }) + + expect(manager.splitPane).toHaveBeenCalledWith(7, 'vertical', { + leafId: TARGET_LEAF_ID, + ptyId: PTY_ID + }) + expect(harness.state.transferAgentPaneAuthority).toHaveBeenCalledWith({ + fromPaneKey: `${SOURCE_TAB_ID}:${SOURCE_LEAF_ID}`, + toPaneKey: `${TARGET_TAB_ID}:${TARGET_LEAF_ID}`, + ptyId: PTY_ID + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events.ts index a3f9efc4918a..a5c07733d117 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events.ts @@ -15,6 +15,7 @@ import { resolveTerminalPaneSplitSourceId } from './terminal-pane-split-request-routing' import type { PtyConnectionDeps } from './pty-connection-types' +import { transferExistingPtyAgentPaneAuthority } from './terminal-split-existing-pty-authority' export function installTerminalPaneMountEvents(args: { manager: PaneManager @@ -69,6 +70,15 @@ export function installTerminalPaneMountEvents(args: { direction: detail.direction, telemetrySuppressed }) + if (createdPane && detail.ptyId) { + transferExistingPtyAgentPaneAuthority({ + state: useAppStore.getState(), + worktreeId: deps.worktreeId, + tabId: deps.tabId, + leafId: createdPane.leafId, + ptyId: detail.ptyId + }) + } } } ) diff --git a/src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.test.ts b/src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.test.ts new file mode 100644 index 000000000000..ae05c4dad11e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { createTestStore, makeTab, seedStore } from '@/store/slices/store-test-helpers' +import { transferExistingPtyAgentPaneAuthority } from './terminal-split-existing-pty-authority' + +const WORKTREE_ID = 'folder:split-authority' +const SOURCE_TAB_ID = 'tab-source' +const TARGET_TAB_ID = 'tab-target' +const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const TARGET_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const OTHER_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const PTY_ID = 'pty-existing' + +function createAuthorityStore() { + const store = createTestStore() + seedStore(store, { + tabsByWorktree: { + [WORKTREE_ID]: [ + makeTab({ id: SOURCE_TAB_ID, worktreeId: WORKTREE_ID, ptyId: PTY_ID }), + makeTab({ id: TARGET_TAB_ID, worktreeId: WORKTREE_ID, ptyId: PTY_ID }) + ] + }, + terminalLayoutsByTabId: { + [SOURCE_TAB_ID]: { + root: { type: 'leaf', leafId: SOURCE_LEAF_ID }, + activeLeafId: SOURCE_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [SOURCE_LEAF_ID]: PTY_ID } + }, + [TARGET_TAB_ID]: { + root: { type: 'leaf', leafId: TARGET_LEAF_ID }, + activeLeafId: TARGET_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [TARGET_LEAF_ID]: PTY_ID } + } + }, + ptyIdsByTabId: { + [SOURCE_TAB_ID]: [PTY_ID], + [TARGET_TAB_ID]: [PTY_ID] + } + }) + return store +} + +describe('existing PTY split agent authority', () => { + it('keeps a completed resumable agent after the original tab retires', () => { + const store = createAuthorityStore() + const sourcePaneKey = makePaneKey(SOURCE_TAB_ID, SOURCE_LEAF_ID) + const targetPaneKey = makePaneKey(TARGET_TAB_ID, TARGET_LEAF_ID) + const providerSession = { key: 'session_id' as const, id: 'codex-session' } + store + .getState() + .setAgentStatus( + sourcePaneKey, + { state: 'done', prompt: 'phase one', agentType: 'codex' }, + 'Codex', + undefined, + { tabId: SOURCE_TAB_ID, worktreeId: WORKTREE_ID }, + { providerSession } + ) + + expect( + transferExistingPtyAgentPaneAuthority({ + state: store.getState(), + worktreeId: WORKTREE_ID, + tabId: TARGET_TAB_ID, + leafId: TARGET_LEAF_ID, + ptyId: PTY_ID + }) + ).toBe(true) + store.getState().closeTab(SOURCE_TAB_ID, { + captureRecentlyClosed: false, + localPtyTeardownOwnedExternally: true + }) + + expect(store.getState().agentStatusByPaneKey[sourcePaneKey]).toBeUndefined() + expect(store.getState().agentStatusByPaneKey[targetPaneKey]).toMatchObject({ + paneKey: targetPaneKey, + tabId: TARGET_TAB_ID, + state: 'done', + agentType: 'codex', + providerSession + }) + }) + + it('does not guess when two source leaves claim the existing PTY', () => { + const store = createAuthorityStore() + store.setState({ + terminalLayoutsByTabId: { + ...store.getState().terminalLayoutsByTabId, + 'tab-other': { + root: { type: 'leaf', leafId: OTHER_LEAF_ID }, + activeLeafId: OTHER_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [OTHER_LEAF_ID]: PTY_ID } + } + }, + tabsByWorktree: { + [WORKTREE_ID]: [ + ...(store.getState().tabsByWorktree[WORKTREE_ID] ?? []), + makeTab({ id: 'tab-other', worktreeId: WORKTREE_ID, ptyId: PTY_ID }) + ] + } + }) + + expect( + transferExistingPtyAgentPaneAuthority({ + state: store.getState(), + worktreeId: WORKTREE_ID, + tabId: TARGET_TAB_ID, + leafId: TARGET_LEAF_ID, + ptyId: PTY_ID + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.ts b/src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.ts new file mode 100644 index 000000000000..86f6d99917cb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-split-existing-pty-authority.ts @@ -0,0 +1,52 @@ +import type { AppState } from '@/store' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' + +type ExistingPtyAuthorityStore = Pick< + AppState, + 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'transferAgentPaneAuthority' +> + +export function transferExistingPtyAgentPaneAuthority(args: { + leafId: string + ptyId: string + state: ExistingPtyAuthorityStore + tabId: string + worktreeId: string +}): boolean { + if (!isTerminalLeafId(args.leafId)) { + return false + } + const workspaceTabIds = new Set( + (args.state.tabsByWorktree[args.worktreeId] ?? []).map((tab) => tab.id) + ) + if (!workspaceTabIds.has(args.tabId)) { + return false + } + + let sourcePaneKey: string | null = null + for (const candidateTabId of workspaceTabIds) { + if (candidateTabId === args.tabId) { + continue + } + const bindings = args.state.terminalLayoutsByTabId[candidateTabId]?.ptyIdsByLeafId ?? {} + for (const [candidateLeafId, candidatePtyId] of Object.entries(bindings)) { + if (candidatePtyId !== args.ptyId) { + continue + } + if (!isTerminalLeafId(candidateLeafId) || sourcePaneKey !== null) { + return false + } + sourcePaneKey = makePaneKey(candidateTabId, candidateLeafId) + } + } + if (!sourcePaneKey) { + return false + } + + args.state.transferAgentPaneAuthority({ + fromPaneKey: sourcePaneKey, + toPaneKey: makePaneKey(args.tabId, args.leafId), + ptyId: args.ptyId + }) + return true +} From 9953541b8465504115515448026966481a64c7ba Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 07:28:16 -0700 Subject: [PATCH 26/29] fix(agent-hibernation): restore status after cold resume --- ...nnection-cold-restore-agent-resume.test.ts | 16 +- ...ection-cold-restore-resume-command.test.ts | 28 ++- .../pty-connection-test-store-fixtures.ts | 36 +++ .../pty-connection-test-store-state.ts | 3 +- .../pty-connection/apply-reattach-payload.ts | 5 +- .../deferred-cold-restore-and-snapshot.ts | 19 +- .../pty-connection/fresh-spawn-start.ts | 11 +- .../hibernated-agent-status-resume.test.ts | 211 ++++++++++++++++++ .../hibernated-agent-status-resume.ts | 89 ++++++++ 9 files changed, 402 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts index de4625f3bbac..ef1be1af507d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-agent-resume.test.ts @@ -12,7 +12,10 @@ import { createManager } from './pty-connection-test-pane-fixtures' import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + createCompletedCodexRetainedAgent, + createInitialStoreState +} from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import type { MockTransport } from './pty-connection-test-pane-fixtures' import { @@ -654,6 +657,14 @@ describe('connectPanePty', () => { agentCmdOverrides: {} }, agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: { + [paneKey]: createCompletedCodexRetainedAgent({ + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + ptyId: 'lost-pty' + }) + }, sleepingAgentSessionsByPaneKey: { [paneKey]: { paneKey, @@ -662,7 +673,7 @@ describe('connectPanePty', () => { agent: 'codex', providerSession: { key: 'session_id', id: 'codex-session-1' }, prompt: 'finish the task', - state: 'working', + state: 'done', capturedAt: 1, updatedAt: 1 } @@ -682,6 +693,7 @@ describe('connectPanePty', () => { expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledTimes(1) expect(deps.onShowSessionRestoredBanner).toHaveBeenCalledWith(1, 'resume-unavailable') + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() }) it('resumes from an unambiguous legacy sleeping record when cold-restoring a preserved pane', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts index 89bcc7254d19..413c63df8348 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-resume-command.test.ts @@ -13,7 +13,10 @@ import { type MockTransport } from './pty-connection-test-pane-fixtures' import { buildPaneConnectionDeps } from './pty-connection-test-deps' -import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + createCompletedCodexRetainedAgent, + createInitialStoreState +} from './pty-connection-test-store-fixtures' import type { StoreState } from './pty-connection-test-store-state' import { installTerminalTestGlobals, @@ -282,6 +285,14 @@ describe('connectPanePty', () => { ...mockStoreState.settings, agentCmdOverrides: {} }, + retainedAgentsByPaneKey: { + [paneKey]: createCompletedCodexRetainedAgent({ + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + ptyId: 'restored-session' + }) + }, sleepingAgentSessionsByPaneKey: { [paneKey]: { paneKey, @@ -333,6 +344,21 @@ describe('connectPanePty', () => { expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalledWith(2, 'restored-session') expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'fresh-resume-pty') expect(mockStoreState.clearSleepingAgentSession).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + paneKey, + expect.objectContaining({ + state: 'done', + agentType: 'codex', + prompt: 'finish the task' + }), + undefined, + { stateStartedAt: 1 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + expect.objectContaining({ + providerSession: { key: 'session_id', id: 'codex-session-1' }, + launchToken: expect.stringMatching(new RegExp(`^${UUID_RE}$`)) + }) + ) expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(paneKey, 1) } finally { globalThis.setTimeout = originalSetTimeout diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index 8ad0335179c7..e3e3c15164e0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -1,8 +1,44 @@ import { vi } from 'vitest' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import type { RetainedAgentEntry } from '@/store/slices/agent-status-contract' import { LEAF_1 } from './pty-connection-test-pane-fixtures' import type { StoreState } from './pty-connection-test-store-state' +export function createCompletedCodexRetainedAgent(args: { + paneKey: string + tabId: string + worktreeId: string + ptyId: string +}): RetainedAgentEntry { + return { + entry: { + paneKey: args.paneKey, + tabId: args.tabId, + worktreeId: args.worktreeId, + state: 'done', + prompt: 'finish the task', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [], + agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' } + }, + worktreeId: args.worktreeId, + tab: { + id: args.tabId, + ptyId: args.ptyId, + worktreeId: args.worktreeId, + title: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + agentType: 'codex', + startedAt: 1 + } +} + export function createInitialStoreState(getState: () => StoreState): StoreState { return { activeWorktreeId: 'wt-1', diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index 5ba2747d8ff1..3c348611043d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -3,6 +3,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import type { RetainedAgentEntry } from '@/store/slices/agent-status-contract' export type StoreState = { activeWorktreeId: string | null @@ -98,7 +99,7 @@ export type StoreState = { consumePendingSnapshot: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record - retainedAgentsByPaneKey: Record + retainedAgentsByPaneKey: Record paneForegroundAgentByPaneKey: Record sleepingAgentSessionsByPaneKey: Record suppressedPtyExitIds: Record diff --git a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts index cb0806900afe..b31aced6bb68 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/apply-reattach-payload.ts @@ -285,7 +285,10 @@ export function createReattachPayloadHandlers( } else if (preparedStartup?.hasSleepingRecord) { session.showSessionRestoredBanner() } - session.clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + session.clearSleepingRecordAfterColdRestoreSpawn( + preparedStartup, + ctx.connectResult.agentResumeUnavailable !== true + ) } // Why: cold-restore spawned a fresh shell; reset mode bytes a crashed TUI (e.g. Claude's \e[?1004h) left in scrollback that no live TUI now consumes. session.writeReplayData(POST_REPLAY_MODE_RESET) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index 6c3a0628f797..cb6da3137f57 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -18,6 +18,7 @@ import { bindReplayDataDrain } from './replay-data-drain' import { bindStartFreshSpawn } from './fresh-spawn-start' import { bindFreshSpawnFollowReset } from './fresh-spawn-follow-reset' +import { restoreCompletedAgentStatusAfterColdResume } from './hibernated-agent-status-resume' export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySession): void { session.applyColdRestoreAgentResumeStartup = ( @@ -36,13 +37,21 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio return true } session.clearSleepingRecordAfterColdRestoreSpawn = ( - startup: ColdRestoreAgentResumeStartup | null + startup: ColdRestoreAgentResumeStartup | null, + resumeConfirmed = true ): void => { if (startup && !startup.useLiveEntry && startup.sleepingRecordEntry) { - session.clearSleepingRecordProviderDuplicates( - useAppStore.getState(), - startup.sleepingRecordEntry - ) + const state = useAppStore.getState() + session.clearSleepingRecordProviderDuplicates(state, startup.sleepingRecordEntry) + if (resumeConfirmed) { + restoreCompletedAgentStatusAfterColdResume({ + leafId: session.pane.leafId, + paneKey: session.cacheKey, + tabId: session.deps.tabId, + state, + startup + }) + } } } session.mergeStartupEnvWithPaneIdentity = ( diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index aefc798bb871..e670f82945db 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -232,18 +232,17 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { foreground: shouldWritePtyOutputForeground(session.deps.isVisibleRef.current) }) } - if ( - spawnedPtyId && - typeof spawnedPtyId === 'object' && - spawnedPtyId.agentResumeUnavailable - ) { + const resumeUnavailable = Boolean( + spawnedPtyId && typeof spawnedPtyId === 'object' && spawnedPtyId.agentResumeUnavailable + ) + if (resumeUnavailable) { // Why: main dropped the resume argv, so this pane is a NEW session — // the plain restored banner would claim the old one came back. session.showSessionRestoredBanner('resume-unavailable') } else if (coldRestoreOverride?.hasSleepingRecord) { session.showSessionRestoredBanner() } - session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride) + session.clearSleepingRecordAfterColdRestoreSpawn(coldRestoreOverride, !resumeUnavailable) } else if ( session.paneStartup?.launchConfig || (startupOverride && 'launchConfig' in startupOverride) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.test.ts new file mode 100644 index 000000000000..b20a70781740 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { createTestStore, makeTab } from '@/store/slices/store-test-helpers' +import { planAgentHibernationCandidates } from '@/lib/agent-hibernation-planner' + +import { restoreCompletedAgentStatusAfterColdResume } from './hibernated-agent-status-resume' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `tab-1:${LEAF_ID}` +const MOVED_PANE_KEY = `tab-2:${LEAF_ID}` +const WORKTREE_ID = 'folder-workspace' +const RESUMED_AT = 2_000_000 +const PROVIDER_SESSION = { key: 'session_id' as const, id: 'codex-session-1' } +const LAUNCH_CONFIG = { agentCommand: 'codex', agentArgs: '', agentEnv: {} } + +function seedHibernatedCompletion(store: ReturnType): void { + const completedEntry = { + state: 'done' as const, + prompt: 'finish the task', + updatedAt: 1_000, + stateStartedAt: 900, + agentType: 'codex', + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + tabId: 'tab-1', + terminalTitle: 'Codex', + stateHistory: [], + providerSession: PROVIDER_SESSION, + lastAssistantMessage: 'done' + } + store.setState({ + activeWorktreeId: 'foreground-workspace', + tabsByWorktree: { + [WORKTREE_ID]: [makeTab({ id: 'tab-1', worktreeId: WORKTREE_ID, ptyId: 'resumed-pty' })] + }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'resumed-pty' } + } + }, + ptyIdsByTabId: { 'tab-1': ['resumed-pty'] }, + settings: { experimentalAgentHibernation: true, agentHibernationIdleMs: 60_000 }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: { + [PANE_KEY]: { + entry: completedEntry, + worktreeId: WORKTREE_ID, + tab: makeTab({ id: 'tab-1', worktreeId: WORKTREE_ID }), + agentType: 'codex', + startedAt: 900 + } + }, + sleepingAgentSessionsByPaneKey: { + [PANE_KEY]: { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: PROVIDER_SESSION, + prompt: 'finish the task', + state: 'done', + capturedAt: 1_100, + updatedAt: 1_000, + launchConfig: LAUNCH_CONFIG, + origin: 'worktree-sleep' + } + } + } as unknown as Partial) +} + +describe('hibernated agent status resume', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('restores exact completed-provider status so a mounted cold resume can sleep again', () => { + vi.useFakeTimers() + vi.setSystemTime(RESUMED_AT) + const store = createTestStore() + seedHibernatedCompletion(store) + const sleepingRecordEntry = { + paneKey: PANE_KEY, + record: store.getState().sleepingAgentSessionsByPaneKey[PANE_KEY]! + } + const resumeState = store.getState() + resumeState.clearSleepingAgentSession(PANE_KEY) + + expect( + restoreCompletedAgentStatusAfterColdResume({ + leafId: LEAF_ID, + paneKey: PANE_KEY, + tabId: 'tab-1', + state: resumeState, + startup: { + agent: 'codex', + command: "codex resume 'codex-session-1'", + launchConfig: LAUNCH_CONFIG, + launchToken: 'resume-token', + resumeProviderSession: PROVIDER_SESSION, + useLiveEntry: false, + hasSleepingRecord: true, + sleepingRecordEntry + } + }) + ).toBe(true) + + const resumed = store.getState() + expect(resumed.agentStatusByPaneKey[PANE_KEY]).toMatchObject({ + state: 'done', + agentType: 'codex', + providerSession: PROVIDER_SESSION, + stateStartedAt: 900 + }) + expect(resumed.retainedAgentsByPaneKey[PANE_KEY]).toBeUndefined() + expect(resumed.sleepingAgentSessionsByPaneKey[PANE_KEY]).toMatchObject({ + origin: 'live', + providerSession: PROVIDER_SESSION, + launchConfig: LAUNCH_CONFIG + }) + + const candidates = planAgentHibernationCandidates({ + settings: resumed.settings, + activeWorktreeId: resumed.activeWorktreeId, + foregroundTerminalTabIds: [], + tabsByWorktree: resumed.tabsByWorktree, + terminalLayoutsByTabId: resumed.terminalLayoutsByTabId, + ptyIdsByTabId: resumed.ptyIdsByTabId, + mobileLockedPtyIds: [], + agentStatusByPaneKey: resumed.agentStatusByPaneKey, + sleepingAgentSessionsByPaneKey: resumed.sleepingAgentSessionsByPaneKey, + lastTerminalInputAtByPaneKey: {}, + foregroundTerminalLastSeenAtByTabId: {}, + ptyBindingFirstSeenAtByPaneKey: { [PANE_KEY]: RESUMED_AT }, + boundaryResolvedAtByPaneKey: {}, + now: RESUMED_AT + 60_001 + }) + expect(candidates.map((candidate) => candidate.paneKey)).toEqual([PANE_KEY]) + }) + + it('fails closed when retained completion belongs to another provider session', () => { + const store = createTestStore() + seedHibernatedCompletion(store) + const state = store.getState() + const sleeping = state.sleepingAgentSessionsByPaneKey[PANE_KEY]! + + expect( + restoreCompletedAgentStatusAfterColdResume({ + leafId: LEAF_ID, + paneKey: PANE_KEY, + tabId: 'tab-1', + state, + startup: { + agent: 'codex', + command: 'codex resume wrong-session', + launchConfig: LAUNCH_CONFIG, + launchToken: 'resume-token', + resumeProviderSession: { key: 'session_id', id: 'wrong-session' }, + useLiveEntry: false, + hasSleepingRecord: true, + sleepingRecordEntry: { + paneKey: PANE_KEY, + record: { + ...sleeping, + providerSession: { key: 'session_id', id: 'wrong-session' } + } + } + } + }) + ).toBe(false) + expect(store.getState().agentStatusByPaneKey[PANE_KEY]).toBeUndefined() + }) + + it('removes retained completion under the prior pane key after alias recovery', () => { + const store = createTestStore() + seedHibernatedCompletion(store) + const state = store.getState() + const sleepingRecordEntry = { + paneKey: PANE_KEY, + record: state.sleepingAgentSessionsByPaneKey[PANE_KEY]! + } + state.clearSleepingAgentSession(PANE_KEY) + + expect( + restoreCompletedAgentStatusAfterColdResume({ + leafId: LEAF_ID, + paneKey: MOVED_PANE_KEY, + tabId: 'tab-2', + state, + startup: { + agent: 'codex', + command: "codex resume 'codex-session-1'", + launchConfig: LAUNCH_CONFIG, + launchToken: 'resume-token', + resumeProviderSession: PROVIDER_SESSION, + useLiveEntry: false, + hasSleepingRecord: true, + sleepingRecordEntry + } + }) + ).toBe(true) + + expect(store.getState().agentStatusByPaneKey[MOVED_PANE_KEY]).toMatchObject({ + state: 'done', + providerSession: PROVIDER_SESSION + }) + expect(store.getState().retainedAgentsByPaneKey[PANE_KEY]).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.ts b/src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.ts new file mode 100644 index 000000000000..ea787b50f15f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/hibernated-agent-status-resume.ts @@ -0,0 +1,89 @@ +import type { AppState } from '@/store' +import { isPassiveCompletedHibernationEvidence } from '@/lib/sleeping-agent-pane-ownership' +import { agentProviderSessionsEqual } from '../../../../../shared/agent-session-resume' +import { pickParsedAgentStatusPayload } from '../../../../../shared/agent-status-types' + +import type { ColdRestoreAgentResumeStartup } from './fresh-spawn-types' + +type HibernatedAgentStatusResumeState = Pick< + AppState, + | 'dismissRetainedAgent' + | 'registerAgentLaunchConfig' + | 'retainedAgentsByPaneKey' + | 'setAgentStatus' +> + +export function restoreCompletedAgentStatusAfterColdResume(args: { + leafId: string + paneKey: string + tabId: string + state: HibernatedAgentStatusResumeState + startup: ColdRestoreAgentResumeStartup | null +}): boolean { + const startup = args.startup + const sleeping = startup?.sleepingRecordEntry + if ( + !startup || + !sleeping || + startup.useLiveEntry || + startup.agent !== sleeping.record.agent || + !agentProviderSessionsEqual( + sleeping.record.agent, + startup.resumeProviderSession, + sleeping.record.providerSession + ) || + !isPassiveCompletedHibernationEvidence(sleeping.record) + ) { + return false + } + const retained = args.state.retainedAgentsByPaneKey[sleeping.paneKey] + const entry = retained?.entry + if ( + !entry || + entry.state !== 'done' || + entry.interrupted === true || + retained.worktreeId !== sleeping.record.worktreeId || + entry.agentType !== sleeping.record.agent || + !entry.providerSession || + !agentProviderSessionsEqual( + sleeping.record.agent, + entry.providerSession, + sleeping.record.providerSession + ) + ) { + return false + } + + args.state.registerAgentLaunchConfig(args.paneKey, startup.launchConfig, { + agentType: sleeping.record.agent, + launchToken: startup.launchToken, + tabId: args.tabId, + leafId: args.leafId, + providerSession: sleeping.record.providerSession + }) + args.state.setAgentStatus( + args.paneKey, + { + ...pickParsedAgentStatusPayload(entry), + ...(entry.orchestration ? { orchestration: entry.orchestration } : {}), + ...(entry.promptInteractionKey ? { promptInteractionKey: entry.promptInteractionKey } : {}) + }, + entry.terminalTitle ?? sleeping.record.terminalTitle, + { stateStartedAt: entry.stateStartedAt }, + { + tabId: args.tabId, + worktreeId: sleeping.record.worktreeId, + ...(sleeping.record.connectionId !== undefined + ? { connectionId: sleeping.record.connectionId } + : {}) + }, + { + providerSession: sleeping.record.providerSession, + launchToken: startup.launchToken + } + ) + if (sleeping.paneKey !== args.paneKey) { + args.state.dismissRetainedAgent(sleeping.paneKey) + } + return true +} From 85fccd2c18f99e9e9fea744f20ff597b5bfa97ab Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Sat, 5 Sep 2026 08:10:33 -0700 Subject: [PATCH 27/29] test(terminal): include cold restore scope ref --- .../src/components/terminal-workspace-surface-ids.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx index 770b1cfc47b0..f6d26b6620a7 100644 --- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -84,6 +84,7 @@ describe('workspace surface ids', () => { activeWorktreeDeferralHostId: null, activityTerminalPortals: [], backgroundMountTabIdsByWorktreeRef: { current: new Map() }, + backgroundMountColdRestorePaneKeysRef: { current: new Map() }, groupsByWorktree: {}, hydrationSucceeded: false, lastActivationWorktreeIdRef: { current: null }, From 7395593e8c07873bfacd44e8088fdb30623c04d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:53:03 -0700 Subject: [PATCH 28/29] test(orchestration): repair merged sleep-wake fixtures --- ...untime-test-orchestration-messages.spec.ts | 4 ++ ...estration-mailbox-filtered-waiters.test.ts | 7 +++ .../mailbox-sleeping-recipient-wake.test.ts | 2 +- .../mailbox-statusless-codex-delivery.test.ts | 2 +- ...hestration-recipient-routing-slept.test.ts | 2 +- .../runtime-terminal-list-slept-panes.test.ts | 2 +- src/main/runtime/slept-pane-mail-wake.test.ts | 63 ++++++++++++------- ...nal-pane-mount-events-existing-pty.test.ts | 2 +- .../terminal-workspace-surface-ids.test.tsx | 2 +- ...cold-activation-deferral-stranding.test.ts | 13 +++- 10 files changed, 68 insertions(+), 31 deletions(-) diff --git a/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts b/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts index ae049c5c5c9b..3c1bb086d034 100644 --- a/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts +++ b/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts @@ -241,6 +241,10 @@ export class InMemoryOrchestrationMessages { return [] } + retainReplacedWorkerTerminalResources(): number { + return 0 + } + hasUndeliveredDirectMessageForRun(runId: string, directHandle: string): boolean { return this.messages.some( (message) => diff --git a/src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts b/src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts index 6807347b9a1c..0638d60d0283 100644 --- a/src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts +++ b/src/main/runtime/orchestration-mailbox-filtered-waiters.test.ts @@ -21,6 +21,10 @@ vi.mock('electron', () => ({ webContents: { fromId: vi.fn(() => null) } })) +async function flushScheduledReconciliation(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + describe('orchestration mailbox filtered waiters', () => { afterEach(() => { vi.useRealTimers() @@ -58,6 +62,7 @@ describe('orchestration mailbox filtered waiters', () => { expect(next.messages).toEqual( expect.arrayContaining([expect.objectContaining({ id: question.id })]) ) + await flushScheduledReconciliation() db.close() }) @@ -99,6 +104,7 @@ describe('orchestration mailbox filtered waiters', () => { expect(next.messages).toEqual( expect.arrayContaining([expect.objectContaining({ id: question.id })]) ) + await flushScheduledReconciliation() db.close() }) @@ -133,6 +139,7 @@ describe('orchestration mailbox filtered waiters', () => { types: 'question' }) expect(next.messages).toEqual([expect.objectContaining({ id: question.id })]) + await flushScheduledReconciliation() db.close() }) }) diff --git a/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts b/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts index 843a7685d88d..69170b7d413c 100644 --- a/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts +++ b/src/main/runtime/orchestration/mailbox-sleeping-recipient-wake.test.ts @@ -18,7 +18,7 @@ import { WRITE_ACCEPTED } from '../../../shared/pty-write-settlement' const MAILBOX = 'run:run-1' -function mailboxFixture(value: object): T { +function mailboxFixture(value: unknown): T { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each dependency double implements every member reached by the isolated delivery path under test. return value as T } diff --git a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts index 72ff5f89126c..2cfe1e0d1117 100644 --- a/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts +++ b/src/main/runtime/orchestration/mailbox-statusless-codex-delivery.test.ts @@ -12,7 +12,7 @@ const TERMINAL_HANDLE = 'term-1' const STALE_TERMINAL_HANDLE = 'term-slept' const PANE_KEY = 'tab-1:leaf-1' -function statuslessDeliveryFixture(value: object): T { +function statuslessDeliveryFixture(value: unknown): T { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness supplies every dependency the statusless delivery scenarios exercise. return value as T } diff --git a/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts b/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts index f3be52e213ed..aa2797a493d8 100644 --- a/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-recipient-routing-slept.test.ts @@ -10,7 +10,7 @@ import { resolveBareOrchestrationRecipient } from './orchestration/messaging/rec const HANDLE = 'term_slept' const PANE_KEY = 'tab-1:leaf-1' -function routingFixture(value: object): T { +function routingFixture(value: unknown): T { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recipient routing reads only the runtime and database lookup methods supplied by each fixture. return value as T } diff --git a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts index e55a2b291005..ea944faa7b47 100644 --- a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts +++ b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts @@ -11,7 +11,7 @@ import { RuntimeTerminalList } from './runtime-terminal-list' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { ResolvedWorktree } from './runtime-worktree-path-identity' -function terminalListFixture(value: object): T { +function terminalListFixture(value: unknown): T { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: RuntimeTerminalList reads only the explicitly populated fields in these focused fixtures. return value as T } diff --git a/src/main/runtime/slept-pane-mail-wake.test.ts b/src/main/runtime/slept-pane-mail-wake.test.ts index 90f5888d7430..162a03ea365b 100644 --- a/src/main/runtime/slept-pane-mail-wake.test.ts +++ b/src/main/runtime/slept-pane-mail-wake.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' import { AGENT_PROMPT_BRACKETED_PASTE_START } from '../../shared/agent-prompt-injection' import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume' +import { WRITE_ACCEPTED } from '../../shared/pty-write-settlement' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { InMemoryOrchestrationMessages, @@ -85,7 +86,7 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ const runtime = new OrcaRuntimeService(sleepWakeFixture(runtimeStore)) const db = new InMemoryOrchestrationMessages() setInMemoryOrchestrationMessages(runtime, db) - const write = vi.fn().mockReturnValue(true) + const write = vi.fn().mockReturnValue(WRITE_ACCEPTED) let foregroundProcess: string | null = null let confirmedForegroundProcess: string | null | undefined let foregroundConfirmationSupported = true @@ -95,6 +96,10 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ runtime.setPtyController( sleepWakeFixture({ write, + writeWithSettlement: (ptyId: string, data: string) => { + write(ptyId, data) + return WRITE_ACCEPTED + }, kill: vi.fn(), getForegroundProcess: async () => foregroundProcess, confirmForegroundProcess, @@ -179,6 +184,11 @@ async function sleptPaneRuntime(record: SleepingAgentSessionRecord): Promise<{ // What the renderer does with `terminal:requestTabMount`: the tab remounts and // its agent cold-restores into a fresh PTY that then reports a finished turn. remountWithPty: (ptyId: string) => { + runtime.registerPty(ptyId, TEST_WORKTREE_ID, null, { + tabId: TAB_ID, + leafId: LEAF_ID, + incarnationId: 'claude-reattach-incarnation' + }) syncGraph(ptyId) runtime.onPtyData(ptyId, '\x1b]0;Claude working\x07', 100) runtime.onPtyData(ptyId, '\x1b]0;Claude done\x07', 101) @@ -363,30 +373,37 @@ describe('mail addressed to a listed slept pane', () => { }) it('delivers the queued run mail once the woken pane is back and idle', async () => { - const { runtime, db, handle, tabMountSends, write, remountWithPty } = - await sleptPaneRuntime(sleepingRecord()) - db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) - const message = db.insertMessage({ - from: 'term_worker', - to: 'run:run_test', - subject: 'worker done', - type: 'worker_done' - }) - runtime.notifyMessageArrived('run:run_test', 'worker_done') - await Promise.resolve() - await Promise.resolve() - expect(tabMountSends).toHaveLength(1) + vi.useFakeTimers() + try { + const { runtime, db, handle, tabMountSends, write, remountWithPty } = + await sleptPaneRuntime(sleepingRecord()) + db.setRun({ id: 'run_test', coordinator_handle: handle, coordinator_pane_key: PANE_KEY }) + const message = db.insertMessage({ + from: 'term_worker', + to: 'run:run_test', + subject: 'worker done', + type: 'worker_done' + }) + runtime.notifyMessageArrived('run:run_test', 'worker_done') + await Promise.resolve() + await Promise.resolve() + expect(tabMountSends).toHaveLength(1) - // The wake is only half the promise: the mail must actually reach the agent. - remountWithPty('pty-woken') - await Promise.resolve() + // The wake is only half the promise: the mail must actually reach the agent. + remountWithPty('pty-woken') + await flushMicrotasks() - expect(write).toHaveBeenCalledWith( - 'pty-woken', - expect.stringContaining('You have 1 orchestration message') - ) - expect(message.delivered_at).toEqual(expect.any(String)) - db.close() + expect(write).toHaveBeenCalledWith( + 'pty-woken', + expect.stringContaining('You have 1 orchestration message') + ) + await vi.advanceTimersByTimeAsync(500) + expect(write).toHaveBeenCalledWith('pty-woken', '\r') + expect(message.delivered_at).toEqual(expect.any(String)) + db.close() + } finally { + vi.useRealTimers() + } }) it.each([ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts index 13f0a9e62f1e..6494c249d031 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-existing-pty.test.ts @@ -24,7 +24,7 @@ const harness = vi.hoisted(() => ({ } satisfies HarnessState })) -function mountEventFixture(value: object): T { +function mountEventFixture(value: unknown): T { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each partial pane fixture implements every member reached by installTerminalPaneMountEvents in this test. return value as T } diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx index da39189592e8..51da78373af9 100644 --- a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -14,7 +14,7 @@ import type { TerminalParkingFoundation } from './use-terminal-parking-foundatio const initialState = useAppStore.getInitialState() -function terminalParkingFixture(value: object): T { +function terminalParkingFixture(value: unknown): T { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these fixtures populate every TerminalParkingFoundation field read by the isolated cold-activation and parking-pass functions. return value as T } diff --git a/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts b/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts index 89737f429916..4cfeb6fbd37f 100644 --- a/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts +++ b/src/renderer/src/components/terminal/cold-activation-deferral-stranding.test.ts @@ -46,6 +46,11 @@ const initialState = useAppStore.getInitialState() const originalRequestIdle = globalThis.requestIdleCallback const originalCancelIdle = globalThis.cancelIdleCallback +function parkingFoundationFixture(value: unknown): T { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the harness supplies every TerminalParkingFoundation member reached by cold activation and deferred admission. + return value as T +} + function terminalTab(id: string, ptyId: string): TerminalTab { return { id, @@ -80,12 +85,15 @@ type HarnessProps = { worktreeId: string | null; gateOpen: boolean } /** Mirrors use-terminal-controller.ts:30-32: cold activation during render, then admission. */ function useStrandingHarness(props: HarnessProps) { const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) + const backgroundMountColdRestorePaneKeysRef = useRef( + new Map>>() + ) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) const mountedWorktreeIdsRef = useRef(new Set()) const activationDeferralPlanRevisionRef = useRef(0) const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) - const foundation = { + const foundation = parkingFoundationFixture({ activationDeferralPlanRevisionRef, activationDeferredMountTabIdsByWorktreeRef, activeGroupIdByWorktree: {}, @@ -94,6 +102,7 @@ function useStrandingHarness(props: HarnessProps) { activeWorktreeDeferralHostId: 'local', activityTerminalPortals: [], backgroundMountRevision, + backgroundMountColdRestorePaneKeysRef, backgroundMountTabIdsByWorktreeRef, groupsByWorktree: {}, hydrationSucceeded: props.gateOpen, @@ -111,7 +120,7 @@ function useStrandingHarness(props: HarnessProps) { workspaceSessionReady: props.gateOpen, workspaceSurfaceIds: SURFACE_IDS, workspaceSurfaceIdSet: new Set(SURFACE_IDS) - } as unknown as TerminalParkingFoundation + }) const coldActivation = Object.assign(foundation, applyTerminalColdActivation(foundation)) useActivationDeferredTabAdmission(coldActivation) return { activationDeferredMountTabIdsByWorktreeRef, backgroundMountTabIdsByWorktreeRef } From 9851cc96bce3bbafc0278cd50a4f04e635c5e97b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:26:37 -0700 Subject: [PATCH 29/29] refactor(renderer): drop dead planMobileTerminalTabMount wrapper resolveMobileTerminalTabMount took over its last production caller; the wrapper survived only for its own tests. Retarget those tests at the resolver and delete it. --- .../src/lib/mobile-terminal-tab-mount.test.ts | 52 +++++++------------ .../src/lib/mobile-terminal-tab-mount.ts | 9 ---- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts index 49af563ec372..65c8ed25b4b4 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' -import { - planMobileTerminalTabMount, - resolveMobileTerminalTabMount -} from './mobile-terminal-tab-mount' +import { resolveMobileTerminalTabMount } from './mobile-terminal-tab-mount' import type { TerminalTabPtyOwnershipState } from './terminal-tab-for-pty-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -21,24 +18,23 @@ function state(tabCount = 1): TerminalTabPtyOwnershipState { } } -describe('planMobileTerminalTabMount', () => { +describe('resolveMobileTerminalTabMount', () => { it('keeps real-tab requests targeted to exactly one tab', () => { - expect(planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({ - worktreeId: 'wt', - tabIds: ['tab-0'] + expect(resolveMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' })).toEqual({ + kind: 'mount', + detail: { worktreeId: 'wt', tabIds: ['tab-0'] } }) }) it('resolves synthetic handles to exactly one owning tab at workspace scale', () => { - expect(planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ - worktreeId: 'wt', - tabIds: ['tab-173'] - }) + expect( + resolveMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@173' }) + ).toEqual({ kind: 'mount', detail: { worktreeId: 'wt', tabIds: ['tab-173'] } }) }) it('does not mount the whole worktree when a stale pty id has no owner', () => { expect( - planMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) + resolveMobileTerminalTabMount(state(200), { worktreeId: 'wt', ptyId: 'wt@@missing' }) ).toBeNull() }) @@ -51,7 +47,7 @@ describe('planMobileTerminalTabMount', () => { ptyIdsByLeafId: { leaf: 'wt@@173' } } - expect(planMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toBeNull() + expect(resolveMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toBeNull() }) it('mounts the tab whose pane is mounted when a stale layout row also claims the pty', () => { @@ -64,9 +60,9 @@ describe('planMobileTerminalTabMount', () => { ptyIdsByLeafId: { leaf: 'wt@@173' } } - expect(planMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ - worktreeId: 'wt', - tabIds: ['tab-173'] + expect(resolveMobileTerminalTabMount(s, { worktreeId: 'wt', ptyId: 'wt@@173' })).toEqual({ + kind: 'mount', + detail: { worktreeId: 'wt', tabIds: ['tab-173'] } }) }) @@ -74,7 +70,7 @@ describe('planMobileTerminalTabMount', () => { const isTabMounted = vi.fn() expect( - planMobileTerminalTabMount( + resolveMobileTerminalTabMount( state(200), { worktreeId: 'wt', tabId: 'tab-missing' }, { isTabMounted } @@ -87,12 +83,12 @@ describe('planMobileTerminalTabMount', () => { const isTabMounted = vi.fn().mockReturnValue(true) expect( - planMobileTerminalTabMount( + resolveMobileTerminalTabMount( state(200), { worktreeId: 'wt', ptyId: 'wt@@173' }, { isTabMounted } ) - ).toBeNull() + ).toEqual({ kind: 'already-mounted', tabId: 'tab-173' }) expect(isTabMounted).toHaveBeenCalledTimes(1) expect(isTabMounted).toHaveBeenCalledWith('tab-173', 'wt') }) @@ -101,13 +97,11 @@ describe('planMobileTerminalTabMount', () => { const isTabMounted = vi.fn(() => false) expect( - planMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' }, { isTabMounted }) - ).toEqual({ worktreeId: 'wt', tabIds: ['tab-0'] }) + resolveMobileTerminalTabMount(state(), { worktreeId: 'wt', tabId: 'tab-0' }, { isTabMounted }) + ).toEqual({ kind: 'mount', detail: { worktreeId: 'wt', tabIds: ['tab-0'] } }) expect(isTabMounted).toHaveBeenCalledWith('tab-0', 'wt') }) -}) -describe('resolveMobileTerminalTabMount', () => { // Why the distinction matters: a slept pane whose tab is still mounted cannot // be woken by a mount — the caller must fire the in-place wake instead, so // "already mounted" must be distinguishable from "tab does not resolve". @@ -121,16 +115,6 @@ describe('resolveMobileTerminalTabMount', () => { ).toEqual({ kind: 'already-mounted', tabId: 'tab-0' }) }) - it('plans a mount for an unmounted resolvable tab', () => { - expect( - resolveMobileTerminalTabMount( - state(), - { worktreeId: 'wt', tabId: 'tab-0' }, - { isTabMounted: () => false } - ) - ).toEqual({ kind: 'mount', detail: { worktreeId: 'wt', tabIds: ['tab-0'] } }) - }) - it('scopes an inbound-message mount to its addressed split leaf', () => { expect( resolveMobileTerminalTabMount( diff --git a/src/renderer/src/lib/mobile-terminal-tab-mount.ts b/src/renderer/src/lib/mobile-terminal-tab-mount.ts index 02236f198613..d6671f36963e 100644 --- a/src/renderer/src/lib/mobile-terminal-tab-mount.ts +++ b/src/renderer/src/lib/mobile-terminal-tab-mount.ts @@ -75,12 +75,3 @@ export function resolveMobileTerminalTabMount( } } } - -export function planMobileTerminalTabMount( - state: TerminalTabPtyOwnershipState, - request: MobileTerminalTabMountRequest, - options: MobileTerminalTabMountOptions = {} -): BackgroundMountTerminalWorktreeDetail | null { - const resolution = resolveMobileTerminalTabMount(state, request, options) - return resolution?.kind === 'mount' ? resolution.detail : null -}