From 89991fffbb187218e1ebe77cd762dc668418536f Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 22:51:45 -0700 Subject: [PATCH] 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 e61a2e48b76..040c6061942 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 397d7ba1d1e..d284aa10c62 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 32835e78f8f..095f84c66e0 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 58fd52ebc6c..b02c76f65ca 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 00000000000..bf3870830fd --- /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 00000000000..49e5bca983e --- /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 00000000000..a590dc53540 --- /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 6966f083e48..56e2d4d149f 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 72c8939cb6c..43f9361defa 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 b920cf05c50..c95d2f9c89f 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 778cd022068..4f98c3dda6e 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 3562f9cc3c4..b8c76fa2e36 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 00000000000..5d689b74868 --- /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 3e379554137..702f53de1d5 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 e5887ac8626..90a8b5b196b 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 27b9ab4df28..06fb9f06834 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 a75a2256bdb..3ae5dd2721b 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 = {