feat(terminals): list slept panes as resumable and let orchestration address them

This commit is contained in:
Merge Sim
2026-09-03 22:51:45 -07:00
parent af8e4922ad
commit 89991fffbb
17 changed files with 600 additions and 20 deletions
+1 -1
View File
@@ -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: <empty>'}`
`${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: <empty>'}`
)
.join('\n\n')
const visualLayout = formatTerminalVisualLayouts(result.visualLayouts)
@@ -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),
@@ -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)
@@ -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<string, ResolvedWorktree>
): 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<string, ResolvedWorktree>,
@@ -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> = {}): 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')
})
})
@@ -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<WorkspaceSessionState | null | undefined>,
options: {
targetWorktreeId: string | null
matchesTargetWorktree: (worktreeId: string, targetWorktreeId: string) => boolean
}
): ResumableSleptPane[] {
const byPaneKey = new Map<string, ResumableSleptPane>()
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()]
}
@@ -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> = {}): 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' })
})
})
@@ -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:<id> or dispatch:<id>.`
@@ -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<BareRecipientResolution, { ok: true }>,
warning: SendRecipientWarning | undefined
): BareRecipientResolution {
return warning ? { ...resolution, warning } : resolution
}
function selectDispatch(
dispatches: DispatchContextRow[],
explicitRunId: string | undefined
@@ -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()
@@ -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)
@@ -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'])
})
@@ -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'])
@@ -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> = {}): 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> = {}): 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()
})
})
+33 -2
View File
@@ -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<string, ResolvedWorktree>
): RuntimeTerminalSummary
listResumableSleptPanes(targetWorktreeId: string | null): readonly ResumableSleptPane[]
buildSleptPaneSummary(
pane: ResumableSleptPane,
worktrees: Map<string, ResolvedWorktree>
): RuntimeTerminalSummary
getSnapshots(): ReadonlyMap<string, RuntimeMobileSessionTabsSnapshot>
getTabTitle(tabId: string): string | null
getTopologyRevision(worktreeId: string): number
@@ -115,6 +121,13 @@ export class RuntimeTerminalList {
)
const terminals: RuntimeTerminalSummary[] = []
const leafPtyIds = new Set<string>()
// 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<string>()
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))
@@ -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
+6
View File
@@ -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.
+8
View File
@@ -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 = {