mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
feat(terminals): list slept panes as resumable and let orchestration address them
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user