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 095f84c66e0..97bb3955e04 100644 --- a/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts +++ b/src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts @@ -10,7 +10,7 @@ import { isInboundMessageTabMount, type TerminalTabMountIntent } from '../../shared/terminal-tab-mount-intent' -import { makePaneKey } from '../../shared/stable-pane-id' +import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { findSleepingAgentSessionRecord } from './sleeping-pane-record-lookup' import { resolveSleepingPaneWakeTarget } from './orchestration/sleeping-pane-wake-target' import { SleepingPaneWakeScheduler } from './orchestration/sleeping-pane-wake-scheduler' @@ -85,11 +85,15 @@ export class OrcaRuntimeWithWaitForLeafPtyId extends OrcaRuntimeWithRestoreLiveP if (!record?.worktreeId) { return false } + const tabId = record.tabId.startsWith('pty:') ? undefined : record.tabId return this.requestRendererTerminalTabMountForPane({ worktreeId: record.worktreeId, - tabId: record.tabId.startsWith('pty:') ? undefined : record.tabId, + tabId, ptyId: record.ptyId ?? undefined, - paneKey: record.leafId ? makePaneKey(record.tabId, record.leafId) : undefined, + // Why: synthetic pty-form handles carry no real pane identity, and + // makePaneKey rejects their ids outright. + paneKey: + tabId && isTerminalLeafId(record.leafId) ? makePaneKey(tabId, record.leafId) : undefined, intent }) } diff --git a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts index 5d689b74868..f7d8f32be63 100644 --- a/src/main/runtime/runtime-terminal-list-slept-panes.test.ts +++ b/src/main/runtime/runtime-terminal-list-slept-panes.test.ts @@ -133,7 +133,13 @@ describe('terminal list with slept panes', () => { const result = await list.list('wt-1', 10, { includeVisualLayouts: false }) const rows = result.terminals.filter((terminal) => terminal.tabId === 'tab-slept') expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ connected: false, resumable: true }) + // The leaf row, not the synthesized one: it carries the pane's real title, + // preview and last-output time that persistence alone cannot supply. + expect(rows[0]).toMatchObject({ + handle: 'term_tab-slept', + connected: false, + resumable: true + }) }) it('still drops a PTY-less leaf that has no resume record', async () => { diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index 0a105813c5f..0d4a7826def 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -110,19 +110,20 @@ export class RuntimeWorkspaceSessionController { } /** Every persisted host partition. Callers that key off pane identity — which - * carries no host — must search all of them, not just 'local'. */ + * carries no host — must search all of them, not just 'local'. + * + * Why persisted ids only, not the repo list: reading repos would drag the + * worktree inventory into hot poll paths that must never resolve it (#9343), + * and a host with no persisted partition has nothing to find anyway. */ listSessions(): WorkspaceSessionState[] { const store = this.deps.getStore() if (!store) { return [] } - const hostIds = new Set([LOCAL_EXECUTION_HOST_ID]) - for (const repo of store.getRepos?.() ?? []) { - hostIds.add(getRepoExecutionHostId(repo)) - } - for (const hostId of store.getWorkspaceSessionHostIds?.() ?? []) { - hostIds.add(hostId) - } + const hostIds = new Set([ + LOCAL_EXECUTION_HOST_ID, + ...(store.getWorkspaceSessionHostIds?.() ?? []) + ]) return [...hostIds].flatMap((hostId) => { const session = store.getWorkspaceSession?.(hostId) return session ? [session] : [] diff --git a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts index 839209ea809..c99cdb6a8e3 100644 --- a/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts +++ b/src/renderer/src/lib/agent-hibernation-pane-eligibility.ts @@ -9,6 +9,7 @@ import { isLiveResumeAnchorForCompletedAgent } from './live-resume-anchor-record' import type { AgentHibernationPlannerSnapshot } from './agent-hibernation-planner-snapshot' +import { isSupervisingUnsettledDispatch } from './agent-hibernation-supervised-runs' export type EligiblePane = { paneKey: string @@ -66,6 +67,7 @@ export function getEligiblePane(args: { ptyBindingFirstSeenAtByPaneKey: Record boundaryResolvedAtByPaneKey: Record mobileLockedPtyIds: Set + supervisorLeafIdsWithUnsettledDispatch: ReadonlySet now: number idleMs: number }): EligiblePane | null { @@ -79,7 +81,8 @@ export function getEligiblePane(args: { foregroundTerminalLastSeenAtByTabId, ptyBindingFirstSeenAtByPaneKey, boundaryResolvedAtByPaneKey, - mobileLockedPtyIds + mobileLockedPtyIds, + supervisorLeafIdsWithUnsettledDispatch } = args const sleepingRecord = sleepingAgentSessionsByPaneKey[entry.paneKey] // Why: a completed turn leaves the TUI alive and resumable, so every resumable @@ -95,6 +98,9 @@ export function getEligiblePane(args: { entry.interrupted === true || Boolean(entry.subagents?.length) || hasUnsettledOrUnknownDispatch(entry) || + // Why: this pane supervises a worker that has not reported back. Sleeping it + // is what the wake path exists to recover from; don't create the case. + isSupervisingUnsettledDispatch(entry.paneKey, supervisorLeafIdsWithUnsettledDispatch) || (sleepingRecord && !hasOnlyLiveResumeAnchor) || // Why: a fenced worker must never be auto-relaunched; killing it would also // erase the fence, since the capture does not copy it. diff --git a/src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts b/src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts new file mode 100644 index 00000000000..d58816c3c62 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-planner-supervised-runs.test.ts @@ -0,0 +1,120 @@ +/** + * The cheap half of the obligation guard: never sleep a pane whose dispatched + * worker has not reported back. Belt-and-braces — correctness rests on the + * wake-on-message path, so a gap here is no longer a silent deadlock. + */ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' +import { + DEFAULT_AGENT_HIBERNATION_IDLE_MS, + planAgentHibernationCandidates, + type AgentHibernationPlannerSnapshot +} from './agent-hibernation-planner' + +const NOW = 2_000_000 +const OLD = NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1 +const LEAF = '11111111-1111-4111-8111-111111111111' +const OTHER_LEAF = '22222222-2222-4222-8222-222222222222' + +function tab(id = 'tab-1', worktreeId = 'wt-bg'): TerminalTab { + return { + id, + ptyId: null, + worktreeId, + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function layout(leafId = LEAF, ptyId = 'pty-1'): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function entry(overrides: Partial = {}): AgentStatusEntry { + const paneKey = overrides.paneKey ?? `tab-1:${LEAF}` + return { + state: 'done', + prompt: 'make it so', + updatedAt: OLD, + stateStartedAt: OLD, + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-bg', + agentType: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + stateHistory: [], + ...overrides + } +} + +function snapshotWith( + agentStatusByPaneKey: Record +): AgentHibernationPlannerSnapshot { + return { + settings: { + experimentalAgentHibernation: true, + agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS + }, + activeWorktreeId: 'wt-active', + foregroundTerminalTabIds: [], + tabsByWorktree: { 'wt-bg': [tab()] }, + terminalLayoutsByTabId: { 'tab-1': layout() }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + mobileLockedPtyIds: [], + agentStatusByPaneKey, + sleepingAgentSessionsByPaneKey: {}, + lastTerminalInputAtByPaneKey: {}, + foregroundTerminalLastSeenAtByTabId: {}, + now: NOW + } +} + +function workerOf( + coordinator: AgentStatusEntry, + dispatchStatus: 'dispatched' | 'completed' +): AgentStatusEntry { + return { + ...entry({ paneKey: `tab-2:${OTHER_LEAF}`, tabId: 'tab-2' }), + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus, + parentPaneKey: coordinator.paneKey + } + } +} + +function plannedPaneKeys(input: AgentHibernationPlannerSnapshot): string[] { + return planAgentHibernationCandidates(input).map((candidate) => candidate.paneKey) +} + +describe('agent sleep planner and supervised runs', () => { + it('refuses to sleep a coordinator whose dispatched worker has not reported back', () => { + const coordinator = entry() + const worker = workerOf(coordinator, 'dispatched') + const input = snapshotWith({ + [coordinator.paneKey]: coordinator, + [worker.paneKey]: worker + }) + expect(plannedPaneKeys(input)).toEqual([]) + }) + + it('sleeps the same coordinator once its worker settles', () => { + const coordinator = entry() + const worker = workerOf(coordinator, 'completed') + const input = snapshotWith({ + [coordinator.paneKey]: coordinator, + [worker.paneKey]: worker + }) + expect(plannedPaneKeys(input)).toEqual([coordinator.paneKey]) + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 410fb393043..a8b7e37f82c 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -1,5 +1,6 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { getSupervisorLeafIdsWithUnsettledDispatch } from './agent-hibernation-supervised-runs' import { getEligiblePane, getEntryTabId, @@ -110,6 +111,9 @@ export function planAgentHibernationCandidates( snapshot.runtimeLivenessRequiredWorktreeIds ?? [] ) const agentEntriesByTabId = getAgentEntriesByTabId(snapshot.agentStatusByPaneKey) + const supervisorLeafIdsWithUnsettledDispatch = getSupervisorLeafIdsWithUnsettledDispatch( + snapshot.agentStatusByPaneKey + ) const candidates: AgentHibernationCandidate[] = [] for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) { // Why: the tab on screen is `foregroundTerminalTabIds` below, and a tab just left is held by @@ -151,6 +155,7 @@ export function planAgentHibernationCandidates( ptyBindingFirstSeenAtByPaneKey: snapshot.ptyBindingFirstSeenAtByPaneKey ?? {}, boundaryResolvedAtByPaneKey: snapshot.boundaryResolvedAtByPaneKey ?? {}, mobileLockedPtyIds, + supervisorLeafIdsWithUnsettledDispatch, now: snapshot.now, idleMs }) diff --git a/src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts b/src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts new file mode 100644 index 00000000000..cbf153bcf7c --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-supervised-runs.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { + getSupervisorLeafIdsWithUnsettledDispatch, + isSupervisingUnsettledDispatch +} from './agent-hibernation-supervised-runs' + +const COORDINATOR_LEAF = '33333333-3333-4333-8333-333333333333' +const COORDINATOR_PANE = `tab-coord:${COORDINATOR_LEAF}` + +function worker( + dispatchStatus: AgentStatusEntry['orchestration'] extends infer T + ? T extends { dispatchStatus?: infer S } + ? S + : never + : never, + parentPaneKey: string | null = COORDINATOR_PANE +): AgentStatusEntry { + return { + paneKey: 'tab-worker:leaf-worker', + state: 'working', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus, + ...(parentPaneKey ? { parentPaneKey } : {}) + } + } as unknown as AgentStatusEntry +} + +describe('supervisors with unsettled dispatches', () => { + it.each(['pending', 'dispatched'] as const)('collects a supervisor of a %s worker', (status) => { + const supervisors = getSupervisorLeafIdsWithUnsettledDispatch({ w: worker(status) }) + expect(isSupervisingUnsettledDispatch(COORDINATOR_PANE, supervisors)).toBe(true) + }) + + it.each(['completed', 'failed', 'circuit_broken'] as const)('ignores a %s worker', (status) => { + const supervisors = getSupervisorLeafIdsWithUnsettledDispatch({ w: worker(status) }) + expect(isSupervisingUnsettledDispatch(COORDINATOR_PANE, supervisors)).toBe(false) + }) + + it('matches a supervisor whose tab was reminted', () => { + const supervisors = getSupervisorLeafIdsWithUnsettledDispatch({ w: worker('dispatched') }) + expect(isSupervisingUnsettledDispatch(`tab-new:${COORDINATOR_LEAF}`, supervisors)).toBe(true) + }) + + it('ignores a worker whose supervisor pane is unknown', () => { + expect(getSupervisorLeafIdsWithUnsettledDispatch({ w: worker('dispatched', null) }).size).toBe( + 0 + ) + }) + + it('ignores entries with no orchestration context', () => { + expect( + getSupervisorLeafIdsWithUnsettledDispatch({ + a: { paneKey: 'tab:leaf', state: 'done' } as unknown as AgentStatusEntry, + b: undefined + }).size + ).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-supervised-runs.ts b/src/renderer/src/lib/agent-hibernation-supervised-runs.ts new file mode 100644 index 00000000000..3f7ea618320 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-supervised-runs.ts @@ -0,0 +1,41 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +const UNSETTLED_DISPATCH_STATUSES = new Set(['pending', 'dispatched']) + +/** + * Panes supervising a worker whose Dispatch has not settled. + * + * Belt-and-braces only: the wake path is what makes a slept supervisor safe, so + * an obligation this misses is no longer a silent deadlock. It avoids the common + * case ever needing a wake, and it reads the supervisor identity each worker + * already publishes rather than enumerating obligation kinds. + * + * Keyed by stable leaf id: a pane key's tab half is reminted on break-out. + */ +export function getSupervisorLeafIdsWithUnsettledDispatch( + agentStatusByPaneKey: Record +): Set { + const leafIds = new Set() + for (const entry of Object.values(agentStatusByPaneKey)) { + const orchestration = entry?.orchestration + if (!orchestration || !UNSETTLED_DISPATCH_STATUSES.has(orchestration.dispatchStatus ?? '')) { + continue + } + const supervisorLeafId = orchestration.parentPaneKey + ? parsePaneKey(orchestration.parentPaneKey)?.leafId + : undefined + if (supervisorLeafId) { + leafIds.add(supervisorLeafId) + } + } + return leafIds +} + +export function isSupervisingUnsettledDispatch( + paneKey: string, + supervisorLeafIds: ReadonlySet +): boolean { + const leafId = parsePaneKey(paneKey)?.leafId + return leafId !== undefined && supervisorLeafIds.has(leafId) +} diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index ee868bfcc16..cd7c914ce09 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -402,7 +402,7 @@ const DIRECT_SINGLE_SOURCE_SURFACES: readonly { marker: 'resolveLeafCloseCopyKind' }, { - path: 'src/main/runtime/orchestration/mailbox-pointer-delivery.ts', + path: 'src/main/runtime/orchestration/mailbox-pointer-stage.ts', classification: 'action-consumer', marker: 'isCursorAgentTitle' },