mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
fix: guard synthetic handles, keep session listing off the repo inventory
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<ExecutionHostId>([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<ExecutionHostId>([
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
...(store.getWorkspaceSessionHostIds?.() ?? [])
|
||||
])
|
||||
return [...hostIds].flatMap((hostId) => {
|
||||
const session = store.getWorkspaceSession?.(hostId)
|
||||
return session ? [session] : []
|
||||
|
||||
@@ -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<string, number | undefined>
|
||||
boundaryResolvedAtByPaneKey: Record<string, number | undefined>
|
||||
mobileLockedPtyIds: Set<string>
|
||||
supervisorLeafIdsWithUnsettledDispatch: ReadonlySet<string>
|
||||
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.
|
||||
|
||||
@@ -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> = {}): 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<string, AgentStatusEntry>
|
||||
): 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])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, AgentStatusEntry | undefined>
|
||||
): Set<string> {
|
||||
const leafIds = new Set<string>()
|
||||
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<string>
|
||||
): boolean {
|
||||
const leafId = parsePaneKey(paneKey)?.leafId
|
||||
return leafId !== undefined && supervisorLeafIds.has(leafId)
|
||||
}
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user