diff --git a/config/mobile-ci.vitest.config.mjs b/config/mobile-ci.vitest.config.mjs index f7b05927e96..9786d553bc5 100644 --- a/config/mobile-ci.vitest.config.mjs +++ b/config/mobile-ci.vitest.config.mjs @@ -3,6 +3,7 @@ import original from '../mobile/vitest.config.ts' // Keep real process, native, and runtime-specific contracts on Node. const nodeFiles = [ + 'src/components/WorktreeAgentList.runtime-lineage.test.tsx', 'scripts/check-tests-typecheck-incremental-cache.test.ts', 'src/tasks/mobile-agent-launch-architecture.test.ts', 'src/tasks/agent-launch-mobile-replay.test.ts', diff --git a/mobile/scripts/check-tests-typecheck-ratchet.mjs b/mobile/scripts/check-tests-typecheck-ratchet.mjs index c4da37c5469..c810dfbe832 100644 --- a/mobile/scripts/check-tests-typecheck-ratchet.mjs +++ b/mobile/scripts/check-tests-typecheck-ratchet.mjs @@ -13,6 +13,10 @@ const ERROR_LINE = /^(\S.*?)\(\d+,\d+\): error TS\d+:/ // Require every on-disk test in program except these explicit exclusions: error-only diff cannot see excluded/shadowed tests. export const TESTS_OUTSIDE_PROGRAM = new Map([ + [ + 'src/components/WorktreeAgentList.runtime-lineage.test.tsx', + 'Node-side: renders the mobile consumer of real desktop SQLite assignment projection, against Node rather than RN libs' + ], [ 'src/session/mobile-native-command-discovery.test.tsx', 'Node-side: imports the desktop ACP command discovery source, checked against @types/node rather than RN libs' diff --git a/mobile/src/components/WorktreeAgentList.runtime-lineage.test.tsx b/mobile/src/components/WorktreeAgentList.runtime-lineage.test.tsx new file mode 100644 index 00000000000..ec3f367296e --- /dev/null +++ b/mobile/src/components/WorktreeAgentList.runtime-lineage.test.tsx @@ -0,0 +1,83 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { WorktreeAgentSummary } from './WorktreeAgentSummary' +import { WorktreeAgentList } from './WorktreeAgentList' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight' +})) +vi.mock('./AgentStateDot', () => ({ AgentStateDot: () => null })) +vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null })) + +it('renders production-built canonical assignments as nested rows and groups only roots', async () => { + const clock = vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000) + const { publicLineageFixture, canonicalChatRow, LINEAGE_PARENT, LINEAGE_CHILD } = + await import('../../../src/main/runtime/runtime-agent-lineage.test-fixture') + const { flattenAgentRowLineage } = await import('../worktree/agent-row-lineage') + const f = publicLineageFixture() + let tree: ReactTestRenderer | undefined + try { + const agents = f.build().agents + expect(flattenAgentRowLineage(agents).map((node) => [node.row.paneKey, node.depth])).toEqual([ + [canonicalChatRow(LINEAGE_PARENT), 0], + [canonicalChatRow(LINEAGE_CHILD), 1] + ]) + await act(async () => { + tree = create( + createElement(WorktreeAgentList, { + agents, + now: 1_800_000_000_000, + unvisited: false, + statusLive: true + }) + ) + }) + expect( + tree?.root + .findAll((node) => node.props.agent !== undefined) + .map((node) => [node.props.agent.paneKey, node.props.depth]) + ).toEqual([ + [canonicalChatRow(LINEAGE_PARENT), 0], + [canonicalChatRow(LINEAGE_CHILD), 1] + ]) + expect(tree?.root.findAllByType(WorktreeAgentSummary)).toHaveLength(0) + expect(agents[1].taskTitle).toBe('Build the worker result') + expect(JSON.stringify(tree?.toJSON())).toContain('Coordinate work') + expect(JSON.stringify(tree?.toJSON())).toContain('Implement work') + expect(JSON.stringify(tree?.toJSON())).not.toContain('2 agents') + expect(JSON.stringify(tree?.toJSON())).not.toContain(f.privateHandle) + expect(JSON.stringify(tree?.toJSON())).not.toContain(f.privatePane) + const orphan = { + ...agents[1], + paneKey: 'missing-parent-worker', + parentPaneKey: 'missing-parent' + } + await act(async () => + tree?.update( + createElement(WorktreeAgentList, { + agents: [...agents, orphan], + now: 1_800_000_000_000, + unvisited: false + }) + ) + ) + expect( + tree?.root + .findByType(WorktreeAgentSummary) + .props.agents.map((row: RuntimeWorktreeAgentRow) => row.paneKey) + ).toEqual([canonicalChatRow(LINEAGE_PARENT), orphan.paneKey]) + } finally { + await act(async () => tree?.unmount()) + f.db.close() + clock.mockRestore() + } +}) diff --git a/mobile/tsconfig.test.json b/mobile/tsconfig.test.json index b2eb7cb44eb..f8e44802b17 100644 --- a/mobile/tsconfig.test.json +++ b/mobile/tsconfig.test.json @@ -10,6 +10,7 @@ // are React Native's, where `setTimeout` answers a number rather than a NodeJS.Timeout, so // pulling that graph in reports ~280 errors about the desktop rather than about mobile. Their // own runtime coverage is vitest, which runs them under Node. + "src/components/WorktreeAgentList.runtime-lineage.test.tsx", "src/session/mobile-native-command-discovery.test.tsx", "src/tasks/agent-launch-mobile-replay.test.ts", "src/test-support/rpc-recording/recorded-request-params.test.ts", diff --git a/src/cli/index-omitted-host-scope-selectors.test.ts b/src/cli/index-omitted-host-scope-selectors.test.ts index 4a7842df97a..d1dacb5c639 100644 --- a/src/cli/index-omitted-host-scope-selectors.test.ts +++ b/src/cli/index-omitted-host-scope-selectors.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, it, vi } from 'vitest' +import type * as ChildProcess from 'node:child_process' const { callMock, @@ -35,16 +36,38 @@ vi.mock('./runtime/environments', () => ({ resolveEnvironment: vi.fn() })) -vi.mock('child_process', async () => { +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() const { createChildProcessModuleMock } = await import('./index-test-harness.js') - return createChildProcessModuleMock(spawnMock) + return { ...actual, ...createChildProcessModuleMock(spawnMock) } }) import { main } from './index' +import type { + RuntimeAgentLineageTestModule, + RuntimeAgentLineageCatalogTestModule +} from '../shared/runtime-agent-lineage-consumer.test-fixture' import { COMMAND_SPECS } from './specs' import { okFixture, queueFixtures } from './test-fixtures' import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness' +let lineage: RuntimeAgentLineageTestModule +let catalog: RuntimeAgentLineageCatalogTestModule + +// The CLI's Node16 program needs a hook; each test still creates its own DB and runtime. +beforeAll(async () => { + lineage = ( + await vi.importActual<{ default: RuntimeAgentLineageTestModule }>( + '../main/runtime/runtime-agent-lineage.test-fixture' + ) + ).default + catalog = ( + await vi.importActual<{ default: RuntimeAgentLineageCatalogTestModule }>( + '../main/runtime/runtime-agent-lineage-catalog.test-fixture' + ) + ).default +}) + const TERMINAL_ROW = { handle: 'term_1', ptyId: 'pty-1', @@ -71,6 +94,64 @@ describe('omittedHostIds selector annotation', () => { spawnMock }) + it.each(['repo::/workspace', 'folder:lineage'])( + 'prints canonical production-built lineage through actual CLI and worktree RPC for %s', + async (worktreeId) => { + const { publicLineageFixture, canonicalChatRow, LINEAGE_PARENT, LINEAGE_CHILD } = lineage + const f = publicLineageFixture(worktreeId) + try { + const dispatcher = catalog.createLineageCatalogDispatcher(f.build) + const resultOf = (response: unknown) => JSON.parse(JSON.stringify(response)).result + const request = (params = {}) => + dispatcher.dispatch({ + id: 'req_worktree_ps', + authToken: 'test', + method: 'worktree.ps', + params + }) + const legacy = await request() + expect(legacy).toMatchObject({ + ok: true, + result: { + worktrees: [ + { + agents: [ + { parentPaneKey: null }, + { + parentPaneKey: canonicalChatRow(LINEAGE_PARENT), + taskTitle: 'Build the worker result' + } + ] + } + ] + } + }) + callMock.mockResolvedValueOnce(legacy) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + await main(['worktree', 'ps', '--json'], '/workspace') + const printed = JSON.parse(String(log.mock.calls[0]?.[0])) + expect(printed.result.worktrees[0].agents[1].paneKey).toBe(canonicalChatRow(LINEAGE_CHILD)) + expect(JSON.stringify(printed)).not.toContain(f.privateHandle) + expect(JSON.stringify(printed)).not.toContain(f.privatePane) + const first = resultOf(await request({ afterSnapshotId: null })) + expect(resultOf(await request({ afterSnapshotId: first.snapshotId }))).toMatchObject({ + unchanged: true + }) + f.db.db + .prepare( + 'UPDATE runs SET coordinator_orca_session_id = NULL, coordinator_orca_session_id_generation = NULL WHERE id = ?' + ) + .run(f.run.id) + expect(resultOf(await request({ afterSnapshotId: first.snapshotId })).snapshotId).not.toBe( + first.snapshotId + ) + log.mockRestore() + } finally { + f.db.close() + } + } + ) + it('marks a stale runtime host that no caller can select', async () => { pairRuntimeEnvironment(listEnvironmentsMock, 'env-paired', 'm4air') queueFixtures( diff --git a/src/main/runtime/construction/runtime-service-state.ts b/src/main/runtime/construction/runtime-service-state.ts index 93a443a0030..129cb27011a 100644 --- a/src/main/runtime/construction/runtime-service-state.ts +++ b/src/main/runtime/construction/runtime-service-state.ts @@ -44,6 +44,9 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { mainListsEveryPane } from '../terminal/surface/main-terminal-surfaces' import { TUI_IDLE_QUIESCENCE_MS } from '../orca-runtime-postlude' import { screenClearsBlockedTail } from '../blocked-tail-screen-clear' +import { conversationRecordReader } from '../orchestration/structured-session-lineage' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { peekOpenedAgentSessionRecordStore } from '../agent-session-record-store-slot' export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId { protected readonly stopRequestedPtyIds = new Set() @@ -124,7 +127,12 @@ export abstract class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWith getHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), getPaneKey: (handle) => this.getPaneKeyForTerminalHandle(handle), getDispatchAuthority: (handle) => this.getOrchestrationDispatchAuthority(handle), - getAgentStatusSnapshot: () => this.getOrchestrationFleetAgentStatusSnapshot() + getAgentStatusSnapshot: () => this.getOrchestrationFleetAgentStatusSnapshot(), + getSessionRecords: () => { + const store = + getStructuredAgentSessionHost()?.deps.store ?? peekOpenedAgentSessionRecordStore() + return store ? conversationRecordReader(store) : null + } }) protected readonly terminalList = new RuntimeTerminalList({ diff --git a/src/main/runtime/dispatch-predicate-visits.test-fixture.ts b/src/main/runtime/dispatch-predicate-visits.test-fixture.ts new file mode 100644 index 00000000000..f7dc47f5024 --- /dev/null +++ b/src/main/runtime/dispatch-predicate-visits.test-fixture.ts @@ -0,0 +1,138 @@ +import { DatabaseSync } from 'node:sqlite' +import { vi } from 'vitest' +import type SyncDatabase from '../sqlite/sync-database' +import type { SqliteBindings } from '../sqlite/sqlite-statement' +import type { OrchestrationDb } from './orchestration/db' + +// SQL predicate visits complement range plans/probe counts; they are not total SQLite VM steps. +export function installSqlVisitCounters(counters: Record number>) { + const initialized = new WeakSet() + const prepare = DatabaseSync.prototype.prepare + return vi.spyOn(DatabaseSync.prototype, 'prepare').mockImplementation(function ( + this: DatabaseSync, + sql + ) { + if (!initialized.has(this)) { + for (const [name, visit] of Object.entries(counters)) { + this.function(name, {}, (_rowid: unknown) => visit()) + } + initialized.add(this) + } + return prepare.call(this, sql) + }) +} + +export function traceDispatchPredicateVisits( + db: SyncDatabase, + functions: { candidate: string; latest?: string; observation?: string } +) { + const prepare = db.prepare.bind(db) + const executions: { query: string; parameters: SqliteBindings; rows: number }[] = [] + const observed = new WeakSet() + const trace = vi.spyOn(db, 'prepare').mockImplementation((query) => { + let instrumented = query.replaceAll( + 'WHERE assignee_orca_session_id IS NOT NULL', + `WHERE assignee_orca_session_id IS NOT NULL AND ${functions.candidate}(rowid)` + ) + if (functions.latest && query.includes('rowid AS n')) { + instrumented = instrumented.replace( + 'ORDER BY rowid DESC LIMIT 1', + `AND ${functions.latest}(rowid) ORDER BY rowid DESC LIMIT 1` + ) + } + if (functions.observation) { + instrumented = instrumented.replace( + 'WHERE dispatch_id IN', + `WHERE ${functions.observation}(rowid) AND dispatch_id IN` + ) + } + const statement = prepare(instrumented) + if (!observed.has(statement)) { + observed.add(statement) + const all = statement.all.bind(statement) + const get = statement.get.bind(statement) + vi.spyOn(statement, 'all').mockImplementation((...args) => { + const rows = all(...args) + executions.push({ query, parameters: args, rows: rows.length }) + return rows + }) + vi.spyOn(statement, 'get').mockImplementation((...args) => { + const row = get(...args) + executions.push({ query, parameters: args, rows: row ? 1 : 0 }) + return row + }) + } + return statement + }) + return Object.assign(trace, { executions }) +} + +export function latestStatementMetrics( + trace: ReturnType, + native: ReturnType +) { + const latest = (query: string) => query.includes('rowid AS n FROM dispatch_contexts') + const executions = trace.executions.filter(({ query }) => latest(query)) + return { + cachedPrepareCalls: trace.mock.calls.filter(([query]) => latest(query)).length, + executedStatements: executions.length, + nativeCompilations: native.mock.calls.filter(([query]) => latest(query)).length, + addressEntries: executions.reduce((sum, { parameters }) => { + const addresses: unknown = + typeof parameters[0] === 'string' ? JSON.parse(parameters[0]) : null + if (!Array.isArray(addresses) || !addresses.every((value) => typeof value === 'string')) { + throw new Error('Expected actual JSON address batch') + } + return sum + addresses.length + }, 0), + returnedVerificationRows: executions.reduce((sum, { rows }) => sum + rows, 0) + } +} + +export function dispatchQueryPlans( + db: SyncDatabase, + trace: ReturnType +) { + const queries = new Map( + trace.executions + .filter( + ({ query }) => + query.includes('idx_dispatch_session_presentation') || + query.includes('rowid AS n FROM dispatch_contexts') + ) + .map(({ query, parameters }) => [query, parameters]) + ) + return [...queries].map(([query, parameters]) => ({ + query, + parameters, + plan: db.prepare(`EXPLAIN QUERY PLAN ${query}`).all(...parameters) + })) +} + +export function seedCoarsePresentationDispatches( + db: OrchestrationDb, + runId: string, + count: number, + now: number, + prefix = 'coarse' +) { + if (!count) { + return + } + const task = db.createTask({ runId, spec: 'coarse-only history' }) + db.db + .prepare(`INSERT INTO dispatch_contexts + (id, run_id, task_id, assignee_handle, assignee_orca_session_id, status, completed_at) + WITH RECURSIVE seq(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM seq WHERE n < ?) + SELECT ? || n, ?, ?, CASE WHEN n % 2 = 1 THEN ? || n WHEN n % 4 = 0 THEN '' ELSE NULL END, + ? || n, 'completed', ? FROM seq`) + .run( + count, + `${prefix}-id-`, + runId, + task.id, + `${prefix}-handle-`, + `${prefix}-session-`, + new Date(now - 7_200_000).toISOString() + ) +} diff --git a/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts b/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts index 1184f667d8e..7a2f1407b3b 100644 --- a/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts +++ b/src/main/runtime/orca-runtime-test-orchestration-messages.spec.ts @@ -227,6 +227,10 @@ export class InMemoryOrchestrationMessages { return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey) } + listSessionDispatchesForPresentation(): never[] { + return [] + } + listWorkerTerminalReleaseBacklog(): never[] { return [] } diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index a177004bcc7..6ad2123f22d 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -215,6 +215,7 @@ function makePostRevealWorkerRecoveryHarness( ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This recovery fixture reaches only the listed planner, terminal settlement and authority lookup methods. runtime.setOrchestrationDb({ + listSessionDispatchesForPresentation: () => [], reconcileMissingWorkerTerminal: vi.fn(), getActiveDispatchForTerminal: () => undefined, listLegacyWorkerTerminalRecoveryRows: () => [ diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts index 6e8d9bb57da..1b49011b761 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-04.spec.ts @@ -1,3 +1,10 @@ +import { + makeRuntimeOrchestrationDb, + dispatchContextRow, + coordinatorRunRow, + taskLineageRow, + runRow +} from '../runtime-orchestration-db.test-fixture' import { describe, expect, it, vi } from 'vitest' import { MOCK_GIT_WORKTREES, @@ -208,15 +215,20 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(runtimeStore as never) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn(() => ({ + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => + dispatchContextRow({ task_id: 'task-1' - })), - getActiveCoordinatorRun: vi.fn(() => ({ + }) + ) + vi.spyOn(orchestrationDb, 'getActiveCoordinatorRun').mockImplementation(() => + coordinatorRunRow({ id: 'run-1', coordinator_handle: coordinatorHandle - })) - } as never) + }) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ @@ -310,44 +322,54 @@ describe('OrcaRuntimeService', () => { const coordinatorPaneKey = makePaneKey('tab-coordinator', coordinatorLeafId) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn((handle: string) => + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation( + (handle: string) => handle === workerHandle - ? { + ? dispatchContextRow({ id: 'ctx-1', run_id: 'run-1', task_id: 'task-1', assignee_handle: workerHandle, status: 'dispatched' - } + }) : undefined - ), - getLatestDispatchForTerminal: vi.fn((handle: string) => + ) + vi.spyOn(orchestrationDb, 'getLatestDispatchForTerminal').mockImplementation( + (handle: string) => handle === workerHandle - ? { + ? dispatchContextRow({ id: 'ctx-done', run_id: 'run-1', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', completed_at: new Date(Date.now()).toISOString() - } + }) : undefined - ), - getTask: vi.fn(() => ({ + ) + vi.spyOn(orchestrationDb, 'getTask').mockImplementation(() => + taskLineageRow({ id: 'task-1', run_id: 'run-1', task_title: 'Dispatch prompt work', display_name: 'Review dispatch prompts and make worker labels distinct', spec: 'Review dispatch prompts\n\nand make worker labels distinct', created_by_terminal_handle: coordinatorHandle - })), - getRun: vi.fn(() => ({ + }) + ) + vi.spyOn(orchestrationDb, 'getRun').mockImplementation(() => + runRow({ id: 'run-1', coordinator_handle: coordinatorHandle, + coordinator_orca_session_id: null, + coordinator_orca_session_id_generation: null, + consumer_generation: 0, legacy: 0 - })) - } as never) + }) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) const result = runtime.syncWindowGraph(1, { @@ -410,32 +432,38 @@ describe('OrcaRuntimeService', () => { const workerPaneKey = makePaneKey('tab-worker', workerLeafId) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') - const getActiveCoordinatorRun = vi.fn(() => ({ - id: 'run-legacy-unrelated', - coordinator_handle: coordinatorHandle - })) - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn((handle: string) => + const getActiveCoordinatorRun = vi.fn(() => + coordinatorRunRow({ + id: 'run-legacy-unrelated', + coordinator_handle: coordinatorHandle + }) + ) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation( + (handle: string) => handle === workerHandle - ? { + ? dispatchContextRow({ id: 'ctx-missing-run', run_id: dispatchRunId, task_id: 'task-missing-run', assignee_handle: workerHandle, status: 'dispatched' - } + }) : undefined - ), - getLatestDispatchForTerminal: vi.fn(() => undefined), - getTask: vi.fn(() => ({ + ) + vi.spyOn(orchestrationDb, 'getLatestDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getTask').mockImplementation(() => + taskLineageRow({ id: 'task-missing-run', run_id: taskRunId, spec: 'modern task without proven Run', created_by_terminal_handle: coordinatorHandle - })), - getRun: vi.fn(() => undefined), - getActiveCoordinatorRun - } as never) + }) + ) + vi.spyOn(orchestrationDb, 'getRun').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getActiveCoordinatorRun').mockImplementation(getActiveCoordinatorRun) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) const result = runtime.syncWindowGraph(1, { diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-05.spec.ts index a7114a2ab2f..805fd415782 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-05.spec.ts @@ -1,3 +1,10 @@ +import { + makeRuntimeOrchestrationDb, + dispatchContextRow, + taskLineageRow, + runRow, + coordinatorRunRow +} from '../runtime-orchestration-db.test-fixture' import { describe, expect, it, vi } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, @@ -460,31 +467,40 @@ describe('OrcaRuntimeService', () => { const coordinatorPaneKey = makePaneKey('tab-coordinator', coordinatorLeafId) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator') - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn(() => undefined), - getLatestDispatchForTerminal: vi.fn((handle: string) => + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getLatestDispatchForTerminal').mockImplementation( + (handle: string) => handle === workerHandle - ? { + ? dispatchContextRow({ id: 'ctx-done', run_id: 'run-1', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', completed_at: new Date(Date.now()).toISOString() - } + }) : undefined - ), - getTask: vi.fn(() => ({ + ) + vi.spyOn(orchestrationDb, 'getTask').mockImplementation(() => + taskLineageRow({ id: 'task-done', run_id: 'run-1', created_by_terminal_handle: coordinatorHandle - })), - getRun: vi.fn(() => ({ + }) + ) + vi.spyOn(orchestrationDb, 'getRun').mockImplementation(() => + runRow({ id: 'run-1', coordinator_handle: coordinatorHandle, + coordinator_orca_session_id: null, + coordinator_orca_session_id_generation: null, + consumer_generation: 0, legacy: 0 - })) - } as never) + }) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) const result = runtime.syncWindowGraph(1, { @@ -538,27 +554,33 @@ describe('OrcaRuntimeService', () => { const workerLeafId = '55555555-5555-4555-8555-555555555555' const workerPaneKey = makePaneKey('tab-worker', workerLeafId) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn(() => undefined), - getLatestDispatchForTerminal: vi.fn((handle: string) => + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getLatestDispatchForTerminal').mockImplementation( + (handle: string) => handle === workerHandle - ? { + ? dispatchContextRow({ id: 'ctx-done', task_id: 'task-done', assignee_handle: workerHandle, status: 'completed', completed_at: new Date(Date.now()).toISOString() - } + }) : undefined - ), - getTask: vi.fn(() => ({ + ) + vi.spyOn(orchestrationDb, 'getTask').mockImplementation(() => + taskLineageRow({ id: 'task-done' - })), - getActiveCoordinatorRun: vi.fn(() => ({ + }) + ) + vi.spyOn(orchestrationDb, 'getActiveCoordinatorRun').mockImplementation(() => + coordinatorRunRow({ id: 'run-unrelated', coordinator_handle: 'term_unrelated' - })) - } as never) + }) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) const result = runtime.syncWindowGraph(1, { diff --git a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts index 1470ff8cf21..dc637b9372c 100644 --- a/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/lineage-and-scan-cache-part-06.spec.ts @@ -1,3 +1,9 @@ +import { + makeRuntimeOrchestrationDb, + dispatchContextRow, + taskLineageRow, + coordinatorRunRow +} from '../runtime-orchestration-db.test-fixture' import { describe, expect, it, vi } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, @@ -28,21 +34,28 @@ describe('OrcaRuntimeService', () => { const workerLeafId = '66666666-6666-4666-8666-666666666666' const workerPaneKey = makePaneKey('tab-worker', workerLeafId) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') - const getActiveCoordinatorRun = vi.fn(() => ({ - id: 'run-unrelated', - coordinator_handle: 'term_unrelated' - })) - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn(() => undefined), - getLatestDispatchForTerminal: vi.fn(() => ({ + const getActiveCoordinatorRun = vi.fn(() => + coordinatorRunRow({ + id: 'run-unrelated', + coordinator_handle: 'term_unrelated' + }) + ) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getLatestDispatchForTerminal').mockImplementation(() => + dispatchContextRow({ id: 'ctx-settled', task_id: 'task-settled', assignee_handle: workerHandle, status: dispatchStatus, completed_at: new Date(Date.now()).toISOString() - })), + }) + ) + vi.spyOn(orchestrationDb, 'getActiveCoordinatorRun').mockImplementation( getActiveCoordinatorRun - } as never) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) const result = runtime.syncWindowGraph(1, { @@ -80,20 +93,22 @@ describe('OrcaRuntimeService', () => { const runtime = new OrcaRuntimeService(store) const workerLeafId = '77777777-7777-4777-8777-777777777777' const workerHandle = runtime.preAllocateHandleForPty('pty-worker') - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn(() => undefined), - getLatestDispatchForTerminal: vi.fn((handle: string) => + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getLatestDispatchForTerminal').mockImplementation( + (handle: string) => handle === workerHandle - ? { + ? dispatchContextRow({ id: 'ctx-stale', task_id: 'task-stale', assignee_handle: workerHandle, status: 'completed', completed_at: new Date(Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1).toISOString() - } + }) : undefined - ) - } as never) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) const result = runtime.syncWindowGraph(1, { @@ -257,13 +272,16 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const workerHandle = runtime.preAllocateHandleForPty('pty-worker') - runtime.setOrchestrationDb({ - getDispatchContext: vi.fn(() => ({ + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getDispatchContext').mockImplementation(() => + dispatchContextRow({ task_id: 'task_abc123', assignee_handle: workerHandle, status: 'completed' - })) - } as never) + }) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ @@ -355,13 +373,16 @@ describe('OrcaRuntimeService', () => { } const runtime = new OrcaRuntimeService(runtimeStore as never) const parentHandle = runtime.preAllocateHandleForPty('pty-parent') - runtime.setOrchestrationDb({ - getDispatchContext: vi.fn(() => undefined), - getTask: vi.fn(() => ({ + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getDispatchContext').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'getTask').mockImplementation(() => + taskLineageRow({ id: 'task_creator123', created_by_terminal_handle: parentHandle - })) - } as never) + }) + ) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ diff --git a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts index 2783a95a980..652b5c1c4e2 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-summaries.spec.ts @@ -1,6 +1,11 @@ +import { makeRuntimeOrchestrationDb } from '../runtime-orchestration-db.test-fixture' import { describe, expect, it, vi } from 'vitest' import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' -import { OrcaRuntimeService, listWorktrees } from '../orca-runtime-test-mocks.spec' +import { + OrcaRuntimeService, + createRootDispatch, + listWorktrees +} from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, TEST_FOLDER_PROJECT_GROUP_ID, @@ -456,26 +461,20 @@ describe('OrcaRuntimeService', () => { ] }) const workerHandle = runtime.preAllocateHandleForPty('pty-1') - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: vi.fn((handle: string) => - handle === workerHandle - ? { - id: 'ctx-1', - task_id: 'task-1', - assignee_handle: workerHandle, - status: 'dispatched' - } - : undefined - ), - getLatestDispatchForTerminal: vi.fn(() => undefined), - getTask: vi.fn(() => ({ - id: 'task-1', - task_title: 'Dispatch prompt work', - display_name: 'Review dispatch prompts and make worker labels distinct', - spec: 'Review dispatch prompts\n\nand make worker labels distinct' - })), - getActiveCoordinatorRun: vi.fn(() => undefined) - } as never) + const orchestrationDb = makeRuntimeOrchestrationDb() + const run = orchestrationDb.createRun({ + objective: 'Hook summary projection', + coordinatorHandle: null, + coordinatorPaneKey: null + }) + const task = orchestrationDb.createTask({ + runId: run.id, + taskTitle: 'Dispatch prompt work', + displayName: 'Review dispatch prompts and make worker labels distinct', + spec: 'Review dispatch prompts\n\nand make worker labels distinct' + }) + createRootDispatch(orchestrationDb, task.id, workerHandle, paneKey) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(1) runtime.syncWindowGraph(1, { tabs: [ diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts index 6e6bfaa0ef8..532f4df06a4 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-02.spec.ts @@ -1,3 +1,7 @@ +import { + makeRuntimeOrchestrationDb, + dispatchContextRow +} from '../runtime-orchestration-db.test-fixture' import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService, @@ -9,7 +13,6 @@ import { setTerminalViewAttributes, tmpdir } from '../orca-runtime-test-mocks.spec' -import type { OrchestrationDb } from '../orchestration/db' import { getTerminalViewerColors } from '../terminal-view-attribute-store' import type { TerminalViewAttributes, @@ -112,14 +115,15 @@ describe('OrcaRuntimeService', () => { retireAgentHookCompatibilityAuthority: retireAuthority } ) - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: (handle: string) => + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation( + (handle: string) => handle === 'term_ssh_retained' - ? { id: 'dispatch-ssh', task_id: 'task-ssh', status: 'dispatched' } - : undefined, - failDispatch, - getActiveCoordinatorRun: () => undefined - } as unknown as OrchestrationDb) + ? dispatchContextRow({ id: 'dispatch-ssh', task_id: 'task-ssh', status: 'dispatched' }) + : undefined + ) + vi.spyOn(orchestrationDb, 'failDispatch').mockImplementation(failDispatch) + runtime.setOrchestrationDb(orchestrationDb) runtime.attachWindow(TEST_WINDOW_ID) runtime.syncWindowGraph(TEST_WINDOW_ID, { tabs: [ diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts index 79ffa8813ad..c69bf570dfb 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-02.spec.ts @@ -1,3 +1,4 @@ +import { makeRuntimeOrchestrationDb } from '../runtime-orchestration-db.test-fixture' import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' @@ -6,7 +7,6 @@ import { getDefaultWorkspaceSession, makePaneKey } from '../orca-runtime-test-mocks.spec' -import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -469,23 +469,24 @@ describe('OrcaRuntimeService', () => { : null } ) - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: () => undefined, - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-legacy', - task_id: 'task-legacy', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_legacy', - assignee_pane_key: workerPaneKey, - process_incarnation: `pty-legacy:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_WORKTREE_ID, - agent_terminal_handle: 'term_legacy' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-legacy', + task_id: 'task-legacy', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_legacy', + assignee_pane_key: workerPaneKey, + process_incarnation: `pty-legacy:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_WORKTREE_ID, + agent_terminal_handle: 'term_legacy' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) const write = vi.fn(() => true) const kill = vi.fn(() => true) const READY_SCREEN = diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts index e2250a93fc0..d3fd663a60f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-03.spec.ts @@ -1,8 +1,8 @@ +import { makeRuntimeOrchestrationDb } from '../runtime-orchestration-db.test-fixture' import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { OrcaRuntimeService, getDefaultWorkspaceSession } from '../orca-runtime-test-mocks.spec' -import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -46,23 +46,24 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - getActiveDispatchForTerminal: () => undefined, - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-reveal-retry', - task_id: 'task-reveal-retry', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_reveal_retry', - assignee_pane_key: workerPaneKey, - process_incarnation: `pty-reveal-retry:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_WORKTREE_ID, - agent_terminal_handle: 'term_reveal_retry' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'getActiveDispatchForTerminal').mockImplementation(() => undefined) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-reveal-retry', + task_id: 'task-reveal-retry', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_reveal_retry', + assignee_pane_key: workerPaneKey, + process_incarnation: `pty-reveal-retry:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_WORKTREE_ID, + agent_terminal_handle: 'term_reveal_retry' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -461,22 +462,23 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-persistence-failure', - task_id: 'task-persistence-failure', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_persistence_failure', - assignee_pane_key: workerPaneKey, - process_incarnation: `pty-persistence-failure:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_WORKTREE_ID, - agent_terminal_handle: 'term_persistence_failure' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-persistence-failure', + task_id: 'task-persistence-failure', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_persistence_failure', + assignee_pane_key: workerPaneKey, + process_incarnation: `pty-persistence-failure:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_WORKTREE_ID, + agent_terminal_handle: 'term_persistence_failure' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts index ca20cbb0ac2..54527e355af 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-04.spec.ts @@ -1,3 +1,4 @@ +import { makeRuntimeOrchestrationDb } from '../runtime-orchestration-db.test-fixture' import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { @@ -279,6 +280,7 @@ describe('OrcaRuntimeService', () => { ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated recovery path reaches only the supplied planner and missing-terminal settlement methods. runtime.setOrchestrationDb({ + listSessionDispatchesForPresentation: () => [], reconcileMissingWorkerTerminal: vi.fn(), listLegacyWorkerTerminalRecoveryRows: () => [ { @@ -390,22 +392,23 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-inventory-unavailable', - task_id: 'task-inventory-unavailable', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_inventory_unavailable', - assignee_pane_key: workerPaneKey, - process_incarnation: `pty-inventory-unavailable:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_WORKTREE_ID, - agent_terminal_handle: 'term_inventory_unavailable' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-inventory-unavailable', + task_id: 'task-inventory-unavailable', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_inventory_unavailable', + assignee_pane_key: workerPaneKey, + process_incarnation: `pty-inventory-unavailable:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_WORKTREE_ID, + agent_terminal_handle: 'term_inventory_unavailable' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) const listProcesses = vi .fn() .mockRejectedValueOnce(new Error('local provider unavailable')) diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts index b13db8acaa3..1431f06eb56 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-05.spec.ts @@ -1,3 +1,4 @@ +import { makeRuntimeOrchestrationDb } from '../runtime-orchestration-db.test-fixture' import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { @@ -79,6 +80,7 @@ describe('OrcaRuntimeService', () => { ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This isolated recovery path reaches only the supplied planner and missing-terminal settlement methods. runtime.setOrchestrationDb({ + listSessionDispatchesForPresentation: () => [], reconcileMissingWorkerTerminal: vi.fn(), listLegacyWorkerTerminalRecoveryRows: () => cases.map(({ name, leafId, terminalHandle }) => ({ @@ -201,22 +203,23 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-folder', - task_id: 'task-folder', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_folder', - assignee_pane_key: workerPaneKey, - process_incarnation: `pty-folder-legacy:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_FOLDER_WORKSPACE_KEY, - agent_terminal_handle: 'term_folder' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-folder', + task_id: 'task-folder', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_folder', + assignee_pane_key: workerPaneKey, + process_incarnation: `pty-folder-legacy:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_FOLDER_WORKSPACE_KEY, + agent_terminal_handle: 'term_folder' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) const listProcesses = vi.fn(async () => [ { id: 'pty-folder-legacy', @@ -329,22 +332,23 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-ssh-folder', - task_id: 'task-ssh-folder', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_ssh_folder', - assignee_pane_key: workerPaneKey, - process_incarnation: `${ptyId}:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_FOLDER_WORKSPACE_KEY, - agent_terminal_handle: 'term_ssh_folder' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-ssh-folder', + task_id: 'task-ssh-folder', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_ssh_folder', + assignee_pane_key: workerPaneKey, + process_incarnation: `${ptyId}:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_FOLDER_WORKSPACE_KEY, + agent_terminal_handle: 'term_ssh_folder' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) const listProcesses = vi.fn(async () => [ { id: ptyId, diff --git a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts index 433fd1496f6..19859db558f 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-output-and-worker-recovery-part-06.spec.ts @@ -1,3 +1,4 @@ +import { makeRuntimeOrchestrationDb } from '../runtime-orchestration-db.test-fixture' import { withDurableRuntimeStore } from '../runtime-durable-store-fixture' import { describe, expect, it, vi } from 'vitest' import { @@ -5,7 +6,6 @@ import { getDefaultWorkspaceSession, setPlatform } from '../orca-runtime-test-mocks.spec' -import type { OrchestrationDb } from '../orchestration/db' import type { WorkspaceSessionState } from '../orca-runtime-test-mocks.spec' import { HEADLESS_LEAF_ID, @@ -95,22 +95,23 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-ssh', - task_id: 'task-ssh', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_ssh_legacy', - assignee_pane_key: workerPaneKey, - process_incarnation: `${ptyId}:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_WORKTREE_ID, - agent_terminal_handle: 'term_ssh_legacy' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-ssh', + task_id: 'task-ssh', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_ssh_legacy', + assignee_pane_key: workerPaneKey, + process_incarnation: `${ptyId}:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_WORKTREE_ID, + agent_terminal_handle: 'term_ssh_legacy' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) runtime.setPtyController({ write: vi.fn(() => true), kill: vi.fn(() => true), @@ -228,22 +229,23 @@ describe('OrcaRuntimeService', () => { undefined, { canRecoverPersistentLocalPtys: () => true } ) - runtime.setOrchestrationDb({ - listLegacyWorkerTerminalRecoveryRows: () => [ - { - dispatch_id: 'dispatch-wsl', - task_id: 'task-wsl', - dispatch_status: 'completed', - contract_version: 0, - assignee_handle: 'term_wsl_legacy', - assignee_pane_key: workerPaneKey, - process_incarnation: `pty-wsl-legacy:${incarnationId}`, - worker_state: 'ready', - worktree_id: TEST_WORKTREE_ID, - agent_terminal_handle: 'term_wsl_legacy' - } - ] - } as unknown as OrchestrationDb) + const orchestrationDb = makeRuntimeOrchestrationDb() + vi.spyOn(orchestrationDb, 'listSessionDispatchesForPresentation').mockImplementation(() => []) + vi.spyOn(orchestrationDb, 'listLegacyWorkerTerminalRecoveryRows').mockImplementation(() => [ + { + dispatch_id: 'dispatch-wsl', + task_id: 'task-wsl', + dispatch_status: 'completed', + contract_version: 0, + assignee_handle: 'term_wsl_legacy', + assignee_pane_key: workerPaneKey, + process_incarnation: `pty-wsl-legacy:${incarnationId}`, + worker_state: 'ready', + worktree_id: TEST_WORKTREE_ID, + agent_terminal_handle: 'term_wsl_legacy' + } + ]) + runtime.setOrchestrationDb(orchestrationDb) // Declare the scope parameter so mock.calls retains its type alongside the runtime deadline. const listProcesses = vi.fn(async (_connectionId?: string | null) => [ { diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts index 4f4fdd79b0d..aeeae762eea 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts @@ -1,3 +1,4 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../../shared/stable-pane-id' import type { DispatchContextRow } from '../../types' import { @@ -24,6 +25,122 @@ const ACTIVE_DISPATCH_BY_PANE_SUFFIX_SQL = `SELECT ${DISPATCH_CONTEXT_COLUMN_LIS ORDER BY rowid DESC LIMIT 1` const LATEST_DISPATCH_BY_HANDLE_SQL = `SELECT ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1` +function presentationAddressKey(column: string, address: string | null): string { + return `${column}:${address}` +} + +/** Preserve the terminal projection's exact ISO/SQLite recency contract, including future dates. */ +export function isRecentPresentationDispatch(dispatch: DispatchContextRow, now: number): boolean { + if (!dispatch.completed_at || dispatch.status === 'pending' || dispatch.status === 'dispatched') { + return false + } + const completedAt = Date.parse( + dispatch.completed_at.includes('T') + ? dispatch.completed_at + : `${dispatch.completed_at.replace(' ', 'T')}Z` + ) + return Number.isFinite(completedAt) && now - completedAt <= AGENT_STATUS_STALE_AFTER_MS +} + +/** Active first, otherwise latest per address; an older eligible completion never resurfaces. */ +export function listSessionDispatchesForPresentation( + this: OrchestrationDb, + now: number +): { dispatch: DispatchContextRow; order: number }[] { + const lowerBound = new Date(now - AGENT_STATUS_STALE_AFTER_MS - 86_400_000).toISOString() + const rawCandidates = this.db + .prepare(` + SELECT rowid AS presentation_order, ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + INDEXED BY idx_dispatch_session_presentation + WHERE assignee_orca_session_id IS NOT NULL AND status IN ('pending', 'dispatched') + UNION ALL + SELECT rowid AS presentation_order, ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts + INDEXED BY idx_dispatch_session_presentation + WHERE assignee_orca_session_id IS NOT NULL + AND status IN ('completed', 'failed', 'circuit_broken') AND completed_at >= ? + `) + .all(lowerBound) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The complete schema-pinned Dispatch projection restores the persisted-row contract; the default integer wrapper normalizes rowid to a safe number. + const candidates = rawCandidates as (DispatchContextRow & { presentation_order: number })[] + const newestActiveOrderBySession = new Map() + const selected = new Map() + for (const candidate of candidates) { + // Include handled rows even when a newer non-public row later suppresses their address. + if (candidate.status === 'pending' || candidate.status === 'dispatched') { + const session = candidate.assignee_orca_session_id + if (session) { + newestActiveOrderBySession.set( + session, + Math.max(newestActiveOrderBySession.get(session) ?? 0, candidate.presentation_order) + ) + } + } + const address = presentationAddressKey( + candidate.assignee_handle ? 'assignee_handle' : 'assignee_orca_session_id', + candidate.assignee_handle || candidate.assignee_orca_session_id + ) + const previous = selected.get(address) + const active = candidate.status === 'pending' || candidate.status === 'dispatched' + const previousActive = previous?.status === 'pending' || previous?.status === 'dispatched' + if ( + !previous || + (active && !previousActive) || + (active === previousActive && candidate.presentation_order > previous.presentation_order) + ) { + selected.set(address, candidate) + } + } + const eligible = [...selected].filter( + ([, candidate]) => + candidate.status === 'pending' || + candidate.status === 'dispatched' || + isRecentPresentationDispatch(candidate, now) + ) + const latestOrderByAddress = new Map() + for (const [column, active] of [ + ['assignee_handle', true], + ['assignee_handle', false], + ['assignee_orca_session_id', false] + ] as const) { + const addresses = eligible + .filter( + ([, candidate]) => + Boolean(candidate.assignee_handle) === (column === 'assignee_handle') && + (candidate.status === 'pending' || candidate.status === 'dispatched') === active + ) + .map(([, candidate]) => candidate[column]) + if (addresses.length === 0) { + continue + } + // Batch native calls, retaining one equality-index seek per selected address. + const latest = this.db + .prepare(`SELECT requested.value AS address, (SELECT rowid AS n FROM dispatch_contexts + WHERE ${column} = requested.value + ${active ? "AND status IN ('pending', 'dispatched')" : ''} + ORDER BY rowid DESC LIMIT 1) AS n FROM json_each(?) requested`) + .all(JSON.stringify(addresses)) + for (const row of latest) { + if (typeof row.address === 'string' && typeof row.n === 'number') { + latestOrderByAddress.set(presentationAddressKey(column, row.address), row.n) + } + } + } + const result: { dispatch: DispatchContextRow; order: number }[] = [] + for (const [address, candidate] of eligible) { + const { presentation_order, ...dispatch } = candidate + const active = dispatch.status === 'pending' || dispatch.status === 'dispatched' + const latest = + active && !candidate.assignee_handle + ? newestActiveOrderBySession.get(candidate.assignee_orca_session_id ?? '') + : latestOrderByAddress.get(address) + if (latest !== presentation_order) { + continue + } + result.push({ dispatch, order: presentation_order }) + } + return result +} + export function getActiveDispatchForTerminal( this: OrchestrationDb, handle: string, @@ -168,6 +285,7 @@ export type DispatchLookupMethods = { getActiveDispatchMailboxOwners: typeof getActiveDispatchMailboxOwners isDispatchMessageSender: typeof isDispatchMessageSender findActiveDispatchForAssignee: typeof findActiveDispatchForAssignee + listSessionDispatchesForPresentation: typeof listSessionDispatchesForPresentation getLatestDispatchForTerminal: typeof getLatestDispatchForTerminal } @@ -179,6 +297,7 @@ export function attachDispatchLookup(ctor: { prototype: object }): void { getActiveDispatchMailboxOwners, isDispatchMessageSender, findActiveDispatchForAssignee, - getLatestDispatchForTerminal + getLatestDispatchForTerminal, + listSessionDispatchesForPresentation }) } diff --git a/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts b/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts index 6730976196d..5c44b3cd746 100644 --- a/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts +++ b/src/main/runtime/orchestration/db/hot-path-statement-compilation.test.ts @@ -103,7 +103,8 @@ function buildProjection(db: OrchestrationDb): RuntimeAgentOrchestrationProjecti processIncarnation: 'inc_1' } as OrchestrationCompatibilityTerminalAuthority) : null, - getAgentStatusSnapshot: () => [] + getAgentStatusSnapshot: () => [], + getSessionRecords: () => null }) } diff --git a/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts b/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts index bbf0785d6ee..897ca307e5e 100644 --- a/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts +++ b/src/main/runtime/orchestration/db/remaining-dispatch-statement-compilation.test.ts @@ -62,6 +62,7 @@ function fixture(count = 1, version: 'fresh' | 'v41' = 'fresh') { ${DROP_REMOVED_WORK_TRIGGERS_SQL} DROP INDEX idx_runs_coordinator_orca_session_id; DROP INDEX idx_dispatch_assignee_orca_session_id; + DROP INDEX idx_dispatch_session_presentation; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; DROP TRIGGER trg_messages_route_coordinator_mail; @@ -92,7 +93,19 @@ function fixture(count = 1, version: 'fresh' | 'v41' = 'fresh') { .map((row) => row.name) .toSorted() ).toEqual(DISPATCH_CONTEXT_COLUMNS.toSorted()) + const priorVersion = writer.db.pragma('user_version', { simple: true }) + expect( + writer.db + .prepare("SELECT name FROM sqlite_master WHERE name = 'idx_dispatch_session_presentation'") + .get() + ).toBeDefined() const reader = new OrchestrationDb(path) + expect(reader.db.pragma('user_version', { simple: true })).toBe(priorVersion) + expect( + reader.db + .prepare("SELECT name FROM sqlite_master WHERE name = 'idx_dispatch_session_presentation'") + .get() + ).toBeDefined() databases.push(reader) return { writer, reader, run, entries } } diff --git a/src/main/runtime/orchestration/db/schema/schema-column-probes.ts b/src/main/runtime/orchestration/db/schema/schema-column-probes.ts index c408135b4f2..7b87b5f6edd 100644 --- a/src/main/runtime/orchestration/db/schema/schema-column-probes.ts +++ b/src/main/runtime/orchestration/db/schema/schema-column-probes.ts @@ -7,6 +7,14 @@ export function hasColumn(this: OrchestrationDb, table: string, column: string): } export function createMailboxDeliveryIndexesIfPossible(this: OrchestrationDb): void { + if ( + ['assignee_orca_session_id', 'status', 'completed_at'].every((column) => + this.hasColumn('dispatch_contexts', column) + ) + ) { + this.db.exec(`CREATE INDEX IF NOT EXISTS idx_dispatch_session_presentation + ON dispatch_contexts(status, completed_at) WHERE assignee_orca_session_id IS NOT NULL`) + } if (this.hasColumn('deliveries', 'mailbox_handle')) { this.db.exec(OUTSTANDING_MAILBOX_INDEX_SQL) } diff --git a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts index e312bd3e2eb..d25744e63e6 100644 --- a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts @@ -136,6 +136,7 @@ function stripOrcaSessionSchema(path: string, version: number): void { raw.exec(` ${DROP_REMOVED_WORK_TRIGGERS_SQL} DROP INDEX idx_runs_coordinator_orca_session_id; + DROP INDEX IF EXISTS idx_dispatch_session_presentation; DROP INDEX idx_dispatch_assignee_orca_session_id; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; diff --git a/src/main/runtime/orchestration/structured-session-lineage.ts b/src/main/runtime/orchestration/structured-session-lineage.ts index d1b5a6e4695..5c87456247b 100644 --- a/src/main/runtime/orchestration/structured-session-lineage.ts +++ b/src/main/runtime/orchestration/structured-session-lineage.ts @@ -2,6 +2,10 @@ import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../shared/structured-agent-session-projection' import { structuredWorkerHostScope } from '../structured-worker-identity' export type AgentSessionRecordReader = { @@ -56,6 +60,7 @@ export function resolveLineageRunningSession( sessionId: string ): LineageRunningSession { const lineage = [sessionId] + const visited = new Set(lineage) if (!store) { return { kind: 'unverifiable', @@ -86,13 +91,14 @@ export function resolveLineageRunningSession( ? { kind: 'here', ...running } : { kind: 'other-host', ...running } } - if (lineage.includes(next)) { + if (visited.has(next)) { return { kind: 'unverifiable', reason: `The /clear lineage of structured session ${sessionId} loops back on itself.`, lineage } } + visited.add(next) lineage.push(next) current = next } @@ -106,3 +112,45 @@ export function lineageLiveSession( const running = resolveLineageRunningSession(store, sessionId) return running.kind === 'unverifiable' ? null : running.record } + +/** Canonical host status keys; this build-local resolver shares successful suffixes and failed verdicts. */ +export function createCanonicalSessionPaneResolver( + store: AgentSessionRecordReader | null +): (sessionId: string) => string { + const resolved = new Map() + return (sessionId) => { + let current = sessionId + const path = new Set() + let destination: string | null = null + for (;;) { + if (resolved.has(current)) { + destination = resolved.get(current) ?? null + break + } + if (path.has(current)) { + break + } + path.add(current) + let record: AgentSessionConversationView | null + try { + record = store?.getRecord(current) ?? null + } catch { + record = null + } + if (!record) { + break + } + const next = clearedInto(record) + if (!next) { + destination = current + break + } + current = next + } + for (const member of path) { + resolved.set(member, destination) + } + const id = destination ?? sessionId + return structuredAgentSessionPaneKey(structuredAgentSessionTabId(id), id) + } +} diff --git a/src/main/runtime/runtime-agent-lineage-catalog.test-fixture.ts b/src/main/runtime/runtime-agent-lineage-catalog.test-fixture.ts new file mode 100644 index 00000000000..105317b0486 --- /dev/null +++ b/src/main/runtime/runtime-agent-lineage-catalog.test-fixture.ts @@ -0,0 +1,19 @@ +import { vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { RpcDispatcher } from './rpc/dispatcher' +import { WORKTREE_CATALOG_METHODS } from './rpc/methods/worktree-catalog-methods' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import type { RuntimeAgentLineageCatalogTestModule } from '../../shared/runtime-agent-lineage-consumer.test-fixture' + +export function createLineageCatalogDispatcher(build: () => RuntimeWorktreePsSummary) { + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'getRuntimeId').mockReturnValue('lineage-test') + vi.spyOn(runtime, 'getWorktreePs').mockImplementation(async () => ({ + worktrees: [build()], + totalCount: 1, + truncated: false + })) + return new RpcDispatcher({ runtime, methods: WORKTREE_CATALOG_METHODS }) +} + +export default { createLineageCatalogDispatcher } satisfies RuntimeAgentLineageCatalogTestModule diff --git a/src/main/runtime/runtime-agent-lineage.test-fixture.ts b/src/main/runtime/runtime-agent-lineage.test-fixture.ts new file mode 100644 index 00000000000..19e9ad08a10 --- /dev/null +++ b/src/main/runtime/runtime-agent-lineage.test-fixture.ts @@ -0,0 +1,128 @@ +import { OrchestrationDb } from './orchestration/db' +import type { RuntimeAgentLineageTestModule } from '../../shared/runtime-agent-lineage-consumer.test-fixture' +import { RuntimeAgentOrchestrationProjection } from './runtime-agent-orchestration-projection' +import { attachRuntimeWorktreeAgentRows } from './runtime-worktree-agent-rows' +import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' +import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from './structured-worker-identity' + +export const LINEAGE_PARENT = testOrcaSessionId('ca1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +export const LINEAGE_CHILD = testOrcaSessionId('ce3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') +export const canonicalChatRow = (id: string): string => + structuredAgentSessionPaneKey(structuredAgentSessionTabId(id), id) + +/** Production assignment projection and host row attachment; no preassembled agent output. */ +export function publicLineageFixture(worktreeId = 'repo::/workspace') { + const db = new OrchestrationDb(':memory:') + const privateHandle = mintStructuredWorkerHandle() + const privatePane = mintStructuredWorkerPaneKey(LINEAGE_CHILD) + const run = db.createRun({ + objective: 'lineage', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: LINEAGE_PARENT + }) + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'Build the worker result' }).id, + assigneeHandle: privateHandle, + assigneePaneKey: privatePane, + processIncarnation: structuredWorkerProcessIncarnation(LINEAGE_CHILD), + creator: { kind: 'system' }, + maxDepth: 99 + }) + const projection = new RuntimeAgentOrchestrationProjection({ + getDb: () => db, + getLeaves: () => [], + getPtys: () => [], + getSessionRecords: () => null, + paneOf: () => ({ tabId: null, paneKey: null, recordedAtGraphSequence: 0 }), + issueLeafHandle: () => '', + issuePtyHandle: () => '', + makePaneKey: () => '', + getWorktreeId: () => null, + getHandleForPaneKey: () => null, + getPaneKey: () => null, + getDispatchAuthority: () => null, + getAgentStatusSnapshot: () => [] + }) + const updatedAt = Date.now() + const build = (): RuntimeWorktreePsSummary => { + const row: RuntimeWorktreePsSummary = { + worktreeId, + workspaceKind: worktreeId.startsWith('folder:') ? 'folder-workspace' : 'git', + repoId: 'repo', + repo: 'repo', + path: '/workspace', + branch: 'main', + isArchived: false, + isMainWorktree: true, + hasHostSidebarActivity: false, + parentWorktreeId: null, + childWorktreeIds: [], + displayName: 'Workspace', + workspaceStatus: '', + sortOrder: 0, + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + comment: '', + isPinned: false, + isActive: false, + unread: false, + liveTerminalCount: 0, + hasAttachedPty: false, + lastOutputAt: null, + preview: '', + status: 'inactive', + agents: [] + } + const sources = new Map( + [LINEAGE_PARENT, LINEAGE_CHILD].map((id) => [ + canonicalChatRow(id), + { + paneKey: canonicalChatRow(id), + connectionId: null, + worktreeId, + state: 'working', + agentType: 'codex', + prompt: id === LINEAGE_PARENT ? 'Coordinate work' : 'Implement work', + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: 1, + updatedAt, + structuredHost: 'owned' + } + ]) + ) + const summaries = new Map([[worktreeId, row]]) + attachRuntimeWorktreeAgentRows({ + summaries, + rowSources: sources, + terminalTitleEvidenceByWorktreeId: new Map(), + orchestrationByPaneKey: projection.buildByPaneKey(), + getSummary: (id) => summaries.get(id) ?? null + }) + return row + } + return { db, projection, build, privateHandle, privatePane, run } +} + +export default { + publicLineageFixture, + canonicalChatRow, + LINEAGE_PARENT, + LINEAGE_CHILD +} satisfies RuntimeAgentLineageTestModule diff --git a/src/main/runtime/runtime-agent-orchestration-benchmark.test-fixture.ts b/src/main/runtime/runtime-agent-orchestration-benchmark.test-fixture.ts new file mode 100644 index 00000000000..6ae41034e3d --- /dev/null +++ b/src/main/runtime/runtime-agent-orchestration-benchmark.test-fixture.ts @@ -0,0 +1,240 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { hostname, tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import { OrchestrationDb } from './orchestration/db' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import { + installSqlVisitCounters, + traceDispatchPredicateVisits, + latestStatementMetrics, + dispatchQueryPlans, + seedCoarsePresentationDispatches +} from './dispatch-predicate-visits.test-fixture' + +export function benchmarkPresentationCorpus( + corpus: { + nullAddress: boolean + history: number + terminalHistory: number + attentionFacts: number + parentSessionId: OrcaSessionId + }, + build: ( + db: OrchestrationDb + ) => Record | undefined +) { + const { nullAddress, history, terminalHistory, attentionFacts, parentSessionId } = corpus + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() + const now = Date.parse('2026-10-27T12:00:00Z') + const clock = vi.spyOn(Date, 'now').mockReturnValue(now) + const directory = mkdtempSync(join(tmpdir(), 'orca-lineage-benchmark-')) + const path = join(directory, 'orchestration.sqlite') + let db = new OrchestrationDb(path) + let native: ReturnType | undefined + let trace: ReturnType | undefined + try { + const run = db.createRun({ + objective: 'benchmark', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: parentSessionId + }) + const expected = new Map() + for (let i = 0; i < 20; i++) { + const session = testOrcaSessionId(`00000000-0000-4000-8000-${String(i).padStart(12, '0')}`) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'active' }).id, + assigneeHandle: formatOrcaSessionAddress(session), + creator: { kind: 'system' }, + maxDepth: 99 + }) + expected.set( + structuredAgentSessionPaneKey(structuredAgentSessionTabId(session), session), + dispatch.id + ) + } + if (nullAddress) { + db.db.exec( + "UPDATE dispatch_contexts SET assignee_handle = NULL, assignee_pane_key = NULL WHERE status IN ('pending', 'dispatched')" + ) + } + const task = db.createTask({ runId: run.id, spec: 'history' }) + const insert = db.db.prepare(`INSERT INTO dispatch_contexts + (id, run_id, task_id, assignee_handle, assignee_orca_session_id, status, completed_at) + WITH RECURSIVE seq(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM seq WHERE n < ?) + SELECT ? || n, ?, ?, CASE WHEN ? THEN printf('orca_session_id:00000000-0000-4000-8000-%012d', (n-1) % 20) ELSE 'terminal-address-' || n END, + CASE WHEN ? THEN CASE WHEN ? THEN printf('00000000-0000-4000-8000-%012d', (n-1) % 20) ELSE 'session-' || n END ELSE NULL END, + 'completed', '2020-01-01T00:00:00Z' FROM seq`) + insert.run(history, 'session-history-', run.id, task.id, 1, 1, nullAddress ? 1 : 0) + if (terminalHistory) { + insert.run(terminalHistory, 'terminal-history-', run.id, task.id, 0, 0, 0) + } + const active = db.db + .prepare( + "SELECT id, task_id FROM dispatch_contexts WHERE status IN ('pending', 'dispatched') ORDER BY rowid" + ) + .all() + expect(active.map((row) => row.id)).toEqual([...expected.values()]) + const insertFact = db.db.prepare( + `INSERT INTO attempt_observation_facts(id, dispatch_id, task_id, sequence, authority_id, authority_clock, facet, payload, home_received_at) VALUES (?, ?, ?, ?, 'home', 'home', 'process_turn', '{"process":"running","turn":"working"}', ?)` + ) + for (let i = 0; i < attentionFacts; i++) { + const row = active[i % active.length] + if (typeof row?.id !== 'string' || typeof row.task_id !== 'string') { + throw new Error('Missing benchmark Dispatch') + } + insertFact.run( + `observation-${i}`, + row.id, + row.task_id, + Math.floor(i / active.length), + 1_800_000_000_000 + i + ) + } + const sqlite = db.db.prepare('SELECT sqlite_version() AS version').get()?.version + db.db.exec('DROP INDEX idx_dispatch_session_presentation') + const first = performance.now() + db.createMailboxDeliveryIndexesIfPossible() + const indexHelperMs = performance.now() - first + db.db.exec('DROP INDEX idx_dispatch_session_presentation') + db.close() + const openStarted = performance.now() + db = new OrchestrationDb(path) + const reopenWithIndexCreationMs = performance.now() - openStarted + let candidateVisits = 0, + latestVisits = 0, + observationVisits = 0 + native = installSqlVisitCounters({ + lineage_bench_visit: () => { + candidateVisits++ + return 1 + }, + lineage_latest_visit: () => { + latestVisits++ + return 1 + }, + lineage_observation_visit: () => { + observationVisits++ + return 1 + } + }) + trace = traceDispatchPredicateVisits(db.db, { + candidate: 'lineage_bench_visit', + latest: 'lineage_latest_visit', + observation: 'lineage_observation_visit' + }) + let plans: ReturnType | undefined + let previousCoarse = 0 + const sweep = + !nullAddress && history === 10_000 && terminalHistory === 0 && attentionFacts === 0 + for (const coarseOnly of sweep ? [0, 1_000, 10_000] : [0]) { + seedCoarsePresentationDispatches( + db, + run.id, + coarseOnly - previousCoarse, + now, + `coarse-${coarseOnly}` + ) + previousCoarse = coarseOnly + trace.mockClear() + trace.executions.length = 0 + native.mockClear() + candidateVisits = 0 + latestVisits = 0 + observationVisits = 0 + const start = performance.now() + const output = build(db) + const buildMs = performance.now() - start + const production = { + cachedPrepareCalls: trace.mock.calls.length, + executedStatements: trace.executions.length, + nativeCompilations: native.mock.calls.length, + latest: latestStatementMetrics(trace, native), + candidateVisits, + latestVisits, + observationVisits + } + expect(production.latest).toEqual({ + cachedPrepareCalls: nullAddress ? 0 : 1, + executedStatements: nullAddress ? 0 : 1, + nativeCompilations: nullAddress || coarseOnly ? 0 : 1, + addressEntries: nullAddress ? 0 : 20, + returnedVerificationRows: nullAddress ? 0 : 20 + }) + expect( + trace.executions.filter( + ({ query }) => + query.includes('rowid AS n') && + query.includes('assignee_orca_session_id') && + query.includes("AND status IN ('pending', 'dispatched')") + ) + ).toHaveLength(0) + expect(Object.keys(output ?? {}).sort()).toEqual([...expected.keys()].sort()) + for (const [key, dispatchId] of expected) { + expect(output?.[key]).toMatchObject({ + dispatchId, + parentPaneKey: structuredAgentSessionPaneKey( + structuredAgentSessionTabId(parentSessionId), + parentSessionId + ) + }) + } + expect(candidateVisits).toBe(20 + coarseOnly) + expect(latestVisits).toBe(nullAddress ? 0 : 20) + expect(observationVisits).toBe(attentionFacts) + if (!plans) { + plans = dispatchQueryPlans(db.db, trace) + } + const planText = JSON.stringify(plans) + expect(planText).not.toMatch(/SCAN dispatch_contexts|USE TEMP B-TREE/) + expect(planText).toContain('idx_dispatch_session_presentation (status=? AND completed_at>?)') + if (!nullAddress) { + expect(planText).toContain('idx_dispatch_active_assignee_handle (assignee_handle=?)') + } + console.log( + 'LINEAGE_BENCH_ENVIRONMENT_EVIDENCE', + JSON.stringify({ + host: hostname(), + node: process.version, + sqlite, + platform: process.platform, + arch: process.arch, + checkout: process.cwd(), + revision, + timingScope: 'instrumented production build; includes predicate/tracing overhead', + corpus, + coarseOnly, + Q: 20 + coarseOnly, + E: nullAddress ? 0 : 20, + B: nullAddress ? 0 : 1, + cachePhase: coarseOnly + ? 'warm statements after coarse-only inserts' + : 'first instrumented build after constructor reopen', + indexHelperMs, + reopenWithIndexCreationMs, + buildMs, + production, + diagnostic: { + cachedPrepareCalls: trace.mock.calls.length - production.cachedPrepareCalls, + executedStatements: trace.executions.length - production.executedStatements, + nativeCompilations: native.mock.calls.length - production.nativeCompilations + }, + plans + }) + ) + } + } finally { + trace?.mockRestore() + native?.mockRestore() + clock.mockRestore() + db.close() + rmSync(directory, { recursive: true, force: true }) + } +} diff --git a/src/main/runtime/runtime-agent-orchestration-projection.test.ts b/src/main/runtime/runtime-agent-orchestration-projection.test.ts new file mode 100644 index 00000000000..4b12e182578 --- /dev/null +++ b/src/main/runtime/runtime-agent-orchestration-projection.test.ts @@ -0,0 +1,778 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { closeTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + openAgentSessionRecordStoreOnce, + releaseAgentSessionRecordStore +} from './agent-session-record-store-slot' +import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' +import { OrcaRuntimeService } from './orca-runtime' +import './rpc/unused-default-rpc-methods.test-fixture' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionConversationView } from '../../shared/agent-session-conversation-view' +import { + createSessionCallerHarness, + orchestrationRequest, + resultOf, + idOf, + SESSION_X, + SESSION_Y, + sessionRecord +} from './rpc/orchestration-session-caller-test-fixture' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import type { AgentSessionRecordReader } from './orchestration/structured-session-lineage' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../shared/structured-agent-session-projection' +import { RuntimeAgentOrchestrationProjection } from './runtime-agent-orchestration-projection' +import { + installSqlVisitCounters, + latestStatementMetrics, + dispatchQueryPlans, + seedCoarsePresentationDispatches, + traceDispatchPredicateVisits +} from './dispatch-predicate-visits.test-fixture' +import { benchmarkPresentationCorpus } from './runtime-agent-orchestration-benchmark.test-fixture' +import { MIRROR_LEAVES_STEP_1 } from './runtime-leaf-record-projection.test-fixture' +import type { RuntimeLeafRecord } from './runtime-terminal-state-records' +import { OrchestrationDb } from './orchestration/db' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from './structured-worker-identity' + +const COORDINATOR_CHAT = testOrcaSessionId('1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13') +const WORKER_CHAT = testOrcaSessionId('9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48') +const TERMINAL_WORKER_HANDLE = 'term_worker' +const TERMINAL_WORKER_PANE = 'tab_w:11111111-1111-4111-8111-111111111111' + +afterEach(() => vi.restoreAllMocks()) + +function chatRow(sessionId: string): string { + return structuredAgentSessionPaneKey(structuredAgentSessionTabId(sessionId), sessionId) +} + +describe('RuntimeAgentOrchestrationProjection chat lineage', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + hostRef.current = null + vi.restoreAllMocks() + }) + + it('nests chat and terminal workers under the chat that dispatched them', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'chat run', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: COORDINATOR_CHAT + }) + const chatWorker = { + handle: mintStructuredWorkerHandle(), + sessionId: WORKER_CHAT, + paneKey: mintStructuredWorkerPaneKey(WORKER_CHAT) + } + const creator = { kind: 'session', orcaSessionId: COORDINATOR_CHAT } as const + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'chat worker' }).id, + assigneeHandle: chatWorker.handle, + assigneePaneKey: chatWorker.paneKey, + processIncarnation: structuredWorkerProcessIncarnation(WORKER_CHAT), + creator, + maxDepth: Number.MAX_SAFE_INTEGER + }) + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'terminal worker' }).id, + assigneeHandle: TERMINAL_WORKER_HANDLE, + assigneePaneKey: TERMINAL_WORKER_PANE, + creator, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const leaf: RuntimeLeafRecord = { ...MIRROR_LEAVES_STEP_1[0], ptyId: 'pty_w' } + const projection = new RuntimeAgentOrchestrationProjection({ + getDb: () => db, + getLeaves: () => [leaf], + getPtys: () => [], + paneOf: () => ({ tabId: null, paneKey: null, recordedAtGraphSequence: 0 }), + issueLeafHandle: () => TERMINAL_WORKER_HANDLE, + issuePtyHandle: () => '', + makePaneKey: () => TERMINAL_WORKER_PANE, + getWorktreeId: () => null, + getHandleForPaneKey: () => null, + getPaneKey: () => null, + getDispatchAuthority: () => null, + getAgentStatusSnapshot: () => [], + getSessionRecords: () => null + }) + + const contexts = projection.buildByPaneKey() + + expect(contexts?.[chatRow(WORKER_CHAT)]?.parentPaneKey).toBe(chatRow(COORDINATOR_CHAT)) + expect(contexts?.[TERMINAL_WORKER_PANE]?.parentPaneKey).toBe(chatRow(COORDINATOR_CHAT)) + // The minted pane key authorizes the worker's mailbox; it must never reach a renderer. + expect(JSON.stringify(contexts)).not.toContain(chatWorker.paneKey) + expect(JSON.stringify(contexts)).not.toContain(chatWorker.handle) + }) +}) + +function projectionFor(db: OrchestrationDb, records: AgentSessionRecordReader | null = null) { + return new RuntimeAgentOrchestrationProjection({ + getDb: () => db, + getLeaves: () => [], + getPtys: () => [], + paneOf: () => ({ tabId: null, paneKey: null, recordedAtGraphSequence: 0 }), + issueLeafHandle: () => '', + issuePtyHandle: () => '', + makePaneKey: () => '', + getWorktreeId: () => null, + getHandleForPaneKey: () => null, + getPaneKey: () => null, + getDispatchAuthority: () => null, + getAgentStatusSnapshot: () => [], + getSessionRecords: () => records + }) +} + +it('publishes ordinary RPC assignments without registering display authority and honors current Run generations', async () => { + const h = createSessionCallerHarness(hostRef) + try { + const runId = idOf( + resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'public lineage' }, + { sessionId: SESSION_X } + ) + ) + ).run + ) + const task = h.db.createTask({ runId, spec: 'existing chat task' }) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.dispatch', + { task: task.id, to: formatOrcaSessionAddress(SESSION_Y) }, + { sessionId: SESSION_X } + ) + ) + const dispatchId = idOf(resultOf(response).dispatch) + const projection = h.runtime['agentOrchestrationProjection'] + expect(projection.buildByPaneKey()?.[chatRow(SESSION_Y)]).toMatchObject({ + dispatchId, + parentPaneKey: chatRow(SESSION_X), + taskTitle: 'existing chat task' + }) + h.db.db + .prepare('UPDATE runs SET coordinator_orca_session_id = ? WHERE id = ?') + .run(COORDINATOR_CHAT, runId) + expect(projection.buildByPaneKey()?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe( + chatRow(SESSION_X) + ) + h.db.db + .prepare('UPDATE runs SET coordinator_orca_session_id = ? WHERE id = ?') + .run(SESSION_X, runId) + // A callback does not have to populate mailbox authority before the public relationship exists. + h.runtime.onStructuredSessionStatusForMail({ sessionId: SESSION_Y, status: 'attention' }) + expect(projection.buildByPaneKey()?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe( + chatRow(SESSION_X) + ) + h.db.db + .prepare('UPDATE dispatch_contexts SET creator_orca_session_id = NULL WHERE id = ?') + .run(dispatchId) + const terminal = 'offline:11111111-1111-4111-8111-111111111111' + h.db.db + .prepare( + 'UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, consumer_generation = consumer_generation + 1 WHERE id = ?' + ) + .run('term_old_writer', terminal, runId) + expect(projection.buildByPaneKey()?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe(terminal) + h.db.db + .prepare( + 'UPDATE runs SET coordinator_orca_session_id_generation = consumer_generation WHERE id = ?' + ) + .run(runId) + expect(projection.buildByPaneKey()?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe( + chatRow(SESSION_X) + ) + h.db.db + .prepare( + 'UPDATE runs SET coordinator_orca_session_id_generation = NULL, coordinator_handle = ?, coordinator_pane_key = ? WHERE id = ?' + ) + .run(mintStructuredWorkerHandle(), mintStructuredWorkerPaneKey(SESSION_X), runId) + expect(projection.buildByPaneKey()?.[chatRow(SESSION_Y)]?.parentPaneKey).toBeUndefined() + expect(JSON.stringify(projection.buildByPaneKey())).not.toContain('structworker_') + } finally { + h.close() + } +}) + +it.each([20, 200])( + 'shares forward lineage and DB/attention work across %i workers; each build rederives facts', + (n) => { + const now = Date.parse('2026-10-27T12:00:00Z') + vi.spyOn(Date, 'now').mockReturnValue(now) + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'shared ancestry', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION_X + }) + for (let i = 0; i < n; i++) { + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'shared task' }).id, + assigneeHandle: `ordinary-${i}`, + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: 9999 + }) + db.db + .prepare( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE assignee_handle = ?' + ) + .run(`worker-${i}`, `ordinary-${i}`) + } + seedCoarsePresentationDispatches(db, run.id, 2 * n, now) + const historyTask = db.createTask({ runId: run.id, spec: 'newer inactive handle history' }) + const insertHistory = db.db.prepare(`INSERT INTO dispatch_contexts + (id, run_id, task_id, assignee_handle, status, completed_at) + WITH RECURSIVE seq(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM seq WHERE n < ?) + SELECT ? || n, ?, ?, ?, 'completed', '2020-01-01T00:00:00Z' FROM seq`) + for (let i = 0; i < n; i++) { + insertHistory.run(n, `expired-handled-${i}-`, run.id, historyTask.id, `ordinary-${i}`) + } + let endpoint = n + const read = vi.fn((id: string): ReturnType | null => { + const record = sessionRecord(id) + const index = id === SESSION_X ? 0 : id.startsWith('edge-') ? Number(id.slice(5)) : endpoint + return agentSessionConversationView( + index < endpoint + ? { + ...record, + conversationCommand: { + command: 'clear', + runtimeFence: record.lease.runtimeFence, + operationId: 'clear', + callerKey: 'test', + phase: 'committed', + state: 'completed', + replacementSessionId: `edge-${index + 1}` + } + } + : record + ) + }) + const projection = projectionFor(db, { getRecord: read, listRecords: () => [] }) + const tasks = vi.spyOn(db, 'getTask') + const runs = vi.spyOn(db, 'getRun') + const batch = vi.spyOn(db, 'getWorkerAttentionFactsForDispatches') + const individual = vi.spyOn(db, 'getWorkerAttentionFacts') + const roster = vi.spyOn(db, 'listSessionDispatchesForPresentation') + let candidateVisits = 0 + let latestVisits = 0 + const native = installSqlVisitCounters({ + lineage_candidate_visit: () => { + candidateVisits++ + return 1 + }, + lineage_latest_visit: () => { + latestVisits++ + return 1 + } + }) + const sql = traceDispatchPredicateVisits(db.db, { + candidate: 'lineage_candidate_visit', + latest: 'lineage_latest_visit' + }) + const output = projection.buildByPaneKey() + expect(Object.keys(output ?? {})).toHaveLength(n) + expect( + Object.values(output ?? {}).every( + (context) => context.parentPaneKey === chatRow(`edge-${n}`) + ) + ).toBe(true) + expect(read).toHaveBeenCalledTimes(n + n + 1) + expect(tasks).toHaveBeenCalledTimes(n) + expect(runs).toHaveBeenCalledTimes(1) + expect(roster).toHaveBeenCalledTimes(1) + expect(candidateVisits).toBe(3 * n) + expect(latestVisits).toBe(n) + expect(latestStatementMetrics(sql, native)).toEqual({ + cachedPrepareCalls: 1, + executedStatements: 1, + nativeCompilations: 1, + addressEntries: n, + returnedVerificationRows: n + }) + expect( + sql.mock.calls.filter(([query]) => query.includes('d.id AS dispatch_id')) + ).toHaveLength(1) + expect( + sql.mock.calls.filter(([query]) => query.includes('FROM attempt_observation_facts')) + ).toHaveLength(1) + expect(batch).toHaveBeenCalledTimes(1) + expect(individual).not.toHaveBeenCalled() + endpoint++ + read.mockClear() + expect( + Object.values(projection.buildByPaneKey() ?? {}).every( + (context) => context.parentPaneKey === chatRow(`edge-${endpoint}`) + ) + ).toBe(true) + expect(read.mock.calls.length).toBeLessThanOrEqual(n + endpoint + 1) + expect(latestStatementMetrics(sql, native)).toEqual({ + cachedPrepareCalls: 2, + executedStatements: 2, + nativeCompilations: 1, + addressEntries: 2 * n, + returnedVerificationRows: 2 * n + }) + expect(candidateVisits).toBe(6 * n) + expect(latestVisits).toBe(2 * n) + const plans = dispatchQueryPlans(db.db, sql) + expect(JSON.stringify(plans)).toContain( + 'idx_dispatch_active_assignee_handle (assignee_handle=?)' + ) + expect(JSON.stringify(plans)).not.toMatch(/SCAN dispatch_contexts|USE TEMP B-TREE/) + for (const failure of ['missing', 'error', 'cycle', 'prepared']) { + read.mockImplementation((id) => { + if (failure === 'error') { + throw new Error('unreadable') + } + if (failure === 'missing') { + return null + } + const record = sessionRecord(id) + return agentSessionConversationView({ + ...record, + conversationCommand: { + command: 'clear', + runtimeFence: record.lease.runtimeFence, + operationId: 'cycle', + callerKey: 'test', + phase: failure === 'prepared' ? 'prepared' : 'committed', + state: 'completed', + replacementSessionId: SESSION_X + } + }) + }) + expect( + Object.values(projection.buildByPaneKey() ?? {}).every( + (context) => context.parentPaneKey === chatRow(SESSION_X) + ) + ).toBe(true) + } + db.db + .prepare( + "UPDATE dispatch_contexts SET status = 'completed', completed_at = ? WHERE status IN ('pending', 'dispatched')" + ) + .run(new Date(now - 7_200_000).toISOString()) + sql.mockClear() + sql.executions.length = 0 + native.mockClear() + candidateVisits = 0 + latestVisits = 0 + read.mockClear() + expect(projection.buildByPaneKey()).toBeUndefined() + expect(candidateVisits).toBe(3 * n) + expect(latestVisits).toBe(0) + expect(read).not.toHaveBeenCalled() + expect(latestStatementMetrics(sql, native)).toEqual({ + cachedPrepareCalls: 0, + executedStatements: 0, + nativeCompilations: 0, + addressEntries: 0, + returnedVerificationRows: 0 + }) + } finally { + db.close() + } + } +) + +it.each([20, 200])( + 'derives newest active null-address sessions from one complete batch at %i identities', + (n) => { + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'null-address history', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION_X + }) + const task = db.createTask({ runId: run.id, spec: 'null-address selection' }) + const insert = db.db.prepare(`INSERT INTO dispatch_contexts + (id, run_id, task_id, assignee_orca_session_id, status, completed_at) + WITH RECURSIVE seq(n) AS (VALUES(1) UNION ALL SELECT n+1 FROM seq WHERE n < ?) + SELECT ? || n, ?, ?, ?, 'completed', '2020-01-01T00:00:00Z' FROM seq`) + for (let i = 0; i < n; i++) { + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'active selection' }).id, + assigneeHandle: `old-${i}`, + creator: { kind: 'system' }, + maxDepth: 99 + }) + db.db + .prepare( + 'UPDATE dispatch_contexts SET assignee_handle = NULL, assignee_pane_key = NULL, assignee_orca_session_id = ? WHERE id = ?' + ) + .run(`null-session-${i}`, dispatch.id) + insert.run(n, `expired-${i}-`, run.id, task.id, `null-session-${i}`) + } + let candidates = 0 + installSqlVisitCounters({ + null_candidate_visit: () => { + candidates++ + return 1 + } + }) + const trace = traceDispatchPredicateVisits(db.db, { candidate: 'null_candidate_visit' }) + const roster = vi.spyOn(db, 'listSessionDispatchesForPresentation') + const projection = projectionFor(db) + const output = projection.buildByPaneKey() ?? {} + expect(Object.keys(output)).toHaveLength(n) + for (let i = 0; i < n; i++) { + expect(output[chatRow(`null-session-${i}`)]?.parentPaneKey).toBe(chatRow(SESSION_X)) + } + expect(roster).toHaveBeenCalledTimes(1) + expect(candidates).toBe(n) + expect( + trace.mock.calls.filter( + ([q]) => + q.includes('rowid AS n') && + q.includes('assignee_orca_session_id') && + q.includes("AND status IN ('pending', 'dispatched')") + ) + ).toHaveLength(0) + expect( + trace.mock.calls.filter(([q]) => q.includes('idx_dispatch_session_presentation')) + ).toHaveLength(1) + trace.mockRestore() + // The newer handled active row contributes to the session maximum even if its address is suppressed. + const handled = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'active selection' }).id, + assigneeHandle: 'new-handled', + creator: { kind: 'system' }, + maxDepth: 99 + }) + db.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run('null-session-0', handled.id) + const newer = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'active selection' }).id, + assigneeHandle: 'newer-suppression', + creator: { kind: 'system' }, + maxDepth: 99 + }) + db.db + .prepare('UPDATE dispatch_contexts SET assignee_handle = ? WHERE id = ?') + .run('new-handled', newer.id) + const suppressed = projection.buildByPaneKey() ?? {} + expect(suppressed[chatRow('null-session-0')]).toBeUndefined() + expect(Object.keys(suppressed)).toHaveLength(n - 1) + } finally { + db.close() + } + } +) + +it('selects indexed active/latest per address with exact ISO/SQLite recency and deterministic public collisions', () => { + const db = new OrchestrationDb(':memory:') + const now = Date.parse('2026-10-27T00:00:10.000Z') + try { + const emptyRoster = vi.spyOn(db, 'listSessionDispatchesForPresentation') + const emptyRead = vi.fn(() => null) + expect( + projectionFor(db, { getRecord: emptyRead, listRecords: () => [] }).buildByPaneKey() + ).toBeUndefined() + expect(emptyRoster).not.toHaveBeenCalled() + expect(emptyRead).not.toHaveBeenCalled() + const version = db.db.pragma('user_version', { simple: true }) + const run = db.createRun({ + objective: 'selection', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'c:11111111-1111-4111-8111-111111111111' + }) + let counter = 0 + const add = ( + handle: string | null, + session: string | null, + status: string, + completed: string | null + ) => { + const row = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'selection' }).id, + assigneeHandle: `${handle}-${counter++}`, + creator: { kind: 'system' }, + maxDepth: 99 + }) + db.db + .prepare( + 'UPDATE dispatch_contexts SET assignee_handle = ?, assignee_orca_session_id = ?, status = ?, completed_at = ? WHERE id = ?' + ) + .run(handle, session, status, completed, row.id) + return row.id + } + add('suppressed', SESSION_X, 'completed', '2026-10-27T00:00:00Z') + add('suppressed', null, 'failed', null) + add('active-hidden', 'hidden', 'pending', null) + add('active-hidden', null, 'pending', null) + add('newer-expired', 'expired-hidden', 'completed', '2026-10-27T00:00:00Z') + add('newer-expired', 'expired-hidden', 'completed', '2020-01-01T00:00:00Z') + add('newer-invalid', 'invalid-hidden', 'completed', '2026-10-27T00:00:00Z') + add('newer-invalid', 'invalid-hidden', 'completed', 'zzzz') + const unhandledRecent = add('', 'session-recent', 'completed', '2026-10-27T00:00:00Z') + const collisionHandle = add( + 'shared-address', + 'handle-namespace-session', + 'completed', + '2026-10-27T00:00:00Z' + ) + const collisionSession = add(null, 'shared-address', 'completed', '2026-10-27T00:00:00Z') + const nullRecent = add(null, 'null-recent', 'completed', '2026-10-27T00:00:00Z') + const boundary = add( + 'exact-boundary', + 'boundary', + 'completed', + new Date(now - 1_800_000).toISOString() + ) + add('stale-boundary', 'stale-boundary', 'completed', new Date(now - 1_800_001).toISOString()) + add(null, 'suppressed-session', 'completed', '2026-10-27T00:00:00Z') + add('suppressed-session-handle', 'suppressed-session', 'completed', '2026-10-27T00:00:00Z') + add('suppressed-session-handle', null, 'completed', '2026-10-27T00:00:00Z') + const sqlite = add('sqlite', SESSION_Y, 'completed', '2026-10-26 23:59:59') + add('expired', 'expired', 'completed', '2020-01-01T00:00:00Z') + add('invalid', 'invalid', 'completed', 'zzzz') + add('missing', 'missing', 'failed', null) + const future = add('future', 'future', 'failed', '2099-01-01T00:00:00Z') + const active = add('active', SESSION_Y, 'dispatched', null) + add('same-address', 'active-old', 'pending', null) + const newest = add('same-address', 'active-new', 'pending', null) + const native = installSqlVisitCounters({ + selection_candidate: () => 1, + selection_latest: () => 1 + }) + const trace = traceDispatchPredicateVisits(db.db, { + candidate: 'selection_candidate', + latest: 'selection_latest' + }) + expect(db.listSessionDispatchesForPresentation(now).map((row) => row.dispatch.id)).toEqual([ + active, + newest, + sqlite, + boundary, + unhandledRecent, + collisionHandle, + collisionSession, + nullRecent, + future + ]) + const metrics = latestStatementMetrics(trace, native) + expect(metrics.cachedPrepareCalls).toBe(3) + expect(metrics.executedStatements).toBe(3) + expect(metrics.nativeCompilations).toBe(3) + expect(metrics.addressEntries).toBe(metrics.returnedVerificationRows) + const latestExecutions = trace.executions.filter(({ query }) => + query.includes('rowid AS n FROM dispatch_contexts') + ) + expect(new Set(latestExecutions.map(({ query }) => query)).size).toBe(3) + expect(latestExecutions.map(({ parameters }) => JSON.parse(String(parameters[0])))).toEqual([ + ['active', 'active-hidden', 'same-address'], + [ + 'sqlite', + 'exact-boundary', + 'suppressed', + 'newer-expired', + 'shared-address', + 'suppressed-session-handle', + 'future' + ], + ['session-recent', 'shared-address', 'null-recent', 'suppressed-session'] + ]) + const plans = dispatchQueryPlans(db.db, trace) + const planText = JSON.stringify(plans) + for (const index of [ + 'idx_dispatch_active_assignee_handle (assignee_handle=?)', + 'idx_dispatch_assignee_handle (assignee_handle=?)', + 'idx_dispatch_assignee_orca_session_id (assignee_orca_session_id=?)', + 'idx_dispatch_session_presentation (status=? AND completed_at>?)' + ]) { + expect(planText).toContain(index) + } + expect(planText).not.toMatch(/SCAN dispatch_contexts|USE TEMP B-TREE/) + trace.mockRestore() + add('', 'session-recent', 'completed', 'zzzz') + expect( + db + .listSessionDispatchesForPresentation(now) + .some((row) => row.dispatch.id === unhandledRecent) + ).toBe(false) + const noHandle = add('null-handle', 'null-address', 'dispatched', null) + db.db.prepare('UPDATE dispatch_contexts SET assignee_handle = NULL WHERE id = ?').run(noHandle) + expect( + db.listSessionDispatchesForPresentation(now).some((row) => row.dispatch.id === noHandle) + ).toBe(true) + const columns = db.db.prepare('PRAGMA index_info(idx_dispatch_session_presentation)').all() + expect(columns).toMatchObject([{ name: 'status' }, { name: 'completed_at' }]) + db.createMailboxDeliveryIndexesIfPossible() + expect(db.db.pragma('user_version', { simple: true })).toBe(version) + const plan = JSON.stringify( + db.db + .prepare( + "EXPLAIN QUERY PLAN SELECT rowid FROM dispatch_contexts INDEXED BY idx_dispatch_session_presentation WHERE assignee_orca_session_id IS NOT NULL AND status = 'completed' AND completed_at >= ?" + ) + .all('2026-10-26') + ) + expect(plan).toContain('idx_dispatch_session_presentation (status=? AND completed_at>?)') + expect(projectionFor(db).buildByPaneKey()?.[chatRow(SESSION_Y)]?.dispatchId).toBe(active) + const latestActive = add('cleared-address', SESSION_X, 'pending', null) + add('newer-recent-address', SESSION_Y, 'completed', '2099-01-01T00:00:00Z') + const read = (id: string) => { + const record = sessionRecord(id) + return agentSessionConversationView( + id === SESSION_X + ? { + ...record, + conversationCommand: { + command: 'clear', + runtimeFence: record.lease.runtimeFence, + operationId: 'destination', + callerKey: 'test', + phase: 'committed', + state: 'completed', + replacementSessionId: SESSION_Y + } + } + : record + ) + } + const sharedDestination = projectionFor(db, { + getRecord: read, + listRecords: () => [] + }).buildByPaneKey() + expect(sharedDestination?.[chatRow(SESSION_Y)]?.dispatchId).toBe(latestActive) + expect(sharedDestination?.[chatRow(SESSION_X)]).toBeUndefined() + } finally { + db.close() + } +}) + +it.skipIf(process.env.ORCA_LINEAGE_BENCH !== '1')( + 'benchmarks retained-history ranges and first derived-index creation (admitted machines only)', + () => { + for (const nullAddress of [false, true]) { + for (const history of [10_000, 100_000]) { + for (const terminalHistory of [0, 100_000]) { + for (const attentionFacts of [0, 2_000]) { + benchmarkPresentationCorpus( + { nullAddress, history, terminalHistory, attentionFacts, parentSessionId: SESSION_X }, + (db) => projectionFor(db).buildByPaneKey() + ) + } + } + } + } + } +) + +it('reads reopened/backfilled assignments and committed clear facts from the already opened store before host installation', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-lineage-contract-')) + const root = sessionRecord(SESSION_X) + const successor = COORDINATOR_CHAT + await seedTestAgentSessionRecordStore(directory, { + records: [ + { + ...root, + conversationCommand: { + command: 'clear', + runtimeFence: root.lease.runtimeFence, + operationId: 'clear', + callerKey: 'test', + phase: 'committed', + state: 'completed', + replacementSessionId: successor + } + }, + sessionRecord(successor), + sessionRecord(SESSION_Y) + ], + sessionTabs: [ + { tabId: 'reserved-successor', sessionId: successor }, + { tabId: 'reserved-worker', sessionId: SESSION_Y } + ] + }) + closeTestJournalHostDatabase(directory) + const path = join(directory, 'orchestration.sqlite') + let db = new OrchestrationDb(path) + let opened = false + try { + const run = db.createRun({ + objective: 'cold public display', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION_X + }) + const privateHandles: string[] = [] + const privatePanes: string[] = [] + for (const id of [SESSION_Y, successor]) { + const handle = mintStructuredWorkerHandle() + const pane = mintStructuredWorkerPaneKey(id) + privateHandles.push(handle) + privatePanes.push(pane) + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'persisted worker' }).id, + assigneeHandle: handle, + assigneePaneKey: pane, + processIncarnation: structuredWorkerProcessIncarnation(id), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: 99 + }) + } + db.db.exec('UPDATE dispatch_contexts SET assignee_orca_session_id = NULL') + db.close() + db = new OrchestrationDb(path) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + hostRef.current = null + const before = runtime['agentOrchestrationProjection'].buildByPaneKey() + expect(before?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe(chatRow(SESSION_X)) + await openAgentSessionRecordStoreOnce({ + stateDirectory: directory, + hostId: 'local', + logger: recordingStructuredAgentSessionLogger().logger + }) + opened = true + const result = runtime['agentOrchestrationProjection'].buildByPaneKey() + expect(result?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe(chatRow(successor)) + expect(result?.[chatRow(successor)]?.parentPaneKey).toBeUndefined() + for (const privateValue of [...privateHandles, ...privatePanes]) { + expect(JSON.stringify(result)).not.toContain(privateValue) + } + for (const status of ['idle', 'working', 'attention'] as const) { + runtime.onStructuredSessionStatusForMail({ sessionId: SESSION_Y, status }) + expect( + runtime['agentOrchestrationProjection'].buildByPaneKey()?.[chatRow(SESSION_Y)] + ?.parentPaneKey + ).toBe(chatRow(successor)) + } + } finally { + if (opened) { + ;(await releaseAgentSessionRecordStore())?.journalDatabase.close() + } + db.close() + rmSync(directory, { recursive: true, force: true }) + } +}) diff --git a/src/main/runtime/runtime-agent-orchestration-projection.ts b/src/main/runtime/runtime-agent-orchestration-projection.ts index e76def78154..c474055c27f 100644 --- a/src/main/runtime/runtime-agent-orchestration-projection.ts +++ b/src/main/runtime/runtime-agent-orchestration-projection.ts @@ -1,7 +1,4 @@ -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusOrchestrationContext -} from '../../shared/agent-status-types' +import type { AgentStatusOrchestrationContext } from '../../shared/agent-status-types' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { buildOrchestrationTaskDisplayMetadata } from '../../shared/orchestration-task-display' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -13,9 +10,14 @@ import { projectWorkerAttentionContext } from './orchestration/worker-attention-context' import { worktreeIdsEqual } from '../../shared/worktree/id' +import type { AgentSessionRecordReader } from './orchestration/structured-session-lineage' +import { createOrchestrationProjectionScope } from './runtime-agent-orchestration-scope' +import { isRecentPresentationDispatch } from './orchestration/db/dispatch-context/dispatch-lookup' +import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' +import type { DispatchContextRow } from './orchestration/types' import type { PtyPaneOf } from './terminal/surface/pty-pane-query' -type RuntimeAgentOrchestrationDependencies = { +export type RuntimeAgentOrchestrationDependencies = { getDb(): OrchestrationDb | null getLeaves(): Iterable getPtys(): Iterable @@ -28,6 +30,7 @@ type RuntimeAgentOrchestrationDependencies = { getPaneKey(handle: string): string | null getDispatchAuthority(handle: string): OrchestrationCompatibilityTerminalAuthority | null getAgentStatusSnapshot(): readonly FleetAgentStatusEvidence[] + getSessionRecords(): AgentSessionRecordReader | null } export class RuntimeAgentOrchestrationProjection { @@ -38,6 +41,7 @@ export class RuntimeAgentOrchestrationProjection { if (!db || db.hasAnyDispatchContexts?.() === false) { return undefined } + const scope = createOrchestrationProjectionScope(db, this.deps) const contexts: Record = {} const evidenceByPaneKey = new Map( this.deps.getAgentStatusSnapshot().map((evidence) => [evidence.activity.paneKey, evidence]) @@ -52,11 +56,16 @@ export class RuntimeAgentOrchestrationProjection { const handle = this.deps.issueLeafHandle(leaf) queriedHandles.add(handle) const paneKey = this.deps.makePaneKey(leaf) - const context = this.getForHandle(handle, db, { - paneKey, - evidence: evidenceByPaneKey.get(paneKey), - deferAttention: batchAttention - }) + const context = this.getForHandle( + handle, + db, + { + paneKey, + evidence: evidenceByPaneKey.get(paneKey), + deferAttention: batchAttention + }, + scope + ) if (context) { contexts[paneKey] = context } @@ -71,21 +80,41 @@ export class RuntimeAgentOrchestrationProjection { continue } queriedHandles.add(handle) - const context = this.getForHandle(handle, db, { - paneKey, - evidence: evidenceByPaneKey.get(paneKey), - deferAttention: batchAttention - }) + const context = this.getForHandle( + handle, + db, + { + paneKey, + evidence: evidenceByPaneKey.get(paneKey), + deferAttention: batchAttention + }, + scope + ) if (context) { contexts[paneKey] = context } } + for (const [paneKey, { dispatch }] of scope.sessionDispatches()) { + if (!contexts[paneKey]) { + contexts[paneKey] = this.projectDispatch( + dispatch, + dispatch.assignee_handle ?? '', + db, + { + paneKey, + evidence: evidenceByPaneKey.get(paneKey), + deferAttention: batchAttention + }, + scope + ) + } + } const entries = Object.entries(contexts) if (entries.length === 0) { return undefined } if (batchAttention) { - const now = Date.now() + const now = scope.now const factsByDispatch = db.getWorkerAttentionFactsForDispatches( entries.map(([, context]) => context.dispatchId), now @@ -116,15 +145,27 @@ export class RuntimeAgentOrchestrationProjection { paneKey?: string evidence?: FleetAgentStatusEvidence deferAttention?: boolean - } = {} + } = {}, + scope = createOrchestrationProjectionScope(db, this.deps) ): AgentStatusOrchestrationContext | undefined { - const { paneKey, evidence, deferAttention = false } = options + const { paneKey } = options const dispatch = - db?.getActiveDispatchForTerminal?.(handle, paneKey) ?? this.getRecent(handle, db) + db?.getActiveDispatchForTerminal?.(handle, paneKey) ?? this.getRecent(handle, db, scope.now) if (!dispatch) { return undefined } - const task = db?.getTask?.(dispatch.task_id, dispatch.run_id) + return this.projectDispatch(dispatch, handle, db, options, scope) + } + + private projectDispatch( + dispatch: DispatchContextRow, + handle: string, + db: OrchestrationDb | null, + options: { paneKey?: string; evidence?: FleetAgentStatusEvidence; deferAttention?: boolean }, + scope: ReturnType + ): AgentStatusOrchestrationContext { + const { paneKey, evidence, deferAttention = false } = options + const task = scope.task(dispatch.task_id, dispatch.run_id) const display = typeof task?.spec === 'string' ? buildOrchestrationTaskDisplayMetadata({ @@ -134,7 +175,7 @@ export class RuntimeAgentOrchestrationProjection { }) : { taskTitle: '', displayName: '' } const owningRun = - task?.run_id && task.run_id === dispatch.run_id ? db?.getRun?.(dispatch.run_id) : undefined + task?.run_id && task.run_id === dispatch.run_id ? scope.run(dispatch.run_id) : undefined const runCoordinatorHandle = owningRun?.coordinator_handle ?? undefined const legacyActiveRun = owningRun?.legacy === 1 && (dispatch.status === 'pending' || dispatch.status === 'dispatched') @@ -170,7 +211,7 @@ export class RuntimeAgentOrchestrationProjection { currentCreatorPane && storedCreatorPane.leafId === currentCreatorPane.leafId)) ) - const paneRun = creatorPaneKey ? db?.getCurrentRunForPane?.(creatorPaneKey) : undefined + const paneRun = creatorPaneKey ? scope.pane(creatorPaneKey) : undefined const sameRunCreatorDispatch = Boolean( task?.creator_dispatch_id && task.creator_dispatch_run_id === owningRun?.id && @@ -194,28 +235,48 @@ export class RuntimeAgentOrchestrationProjection { sameCreatorPane ? (creatorPaneHandle ?? undefined) : undefined - const coordinator = this.resolveLivePane( - coordinatorHandle, - owningRun?.legacy === 0 ? owningRun.coordinator_pane_key : null - ) - const creator = currentCreatorHandle - ? { - handle: currentCreatorHandle, - paneKey: this.deps.getPaneKey(currentCreatorHandle) ?? undefined - } - : creatorLineageInRun - ? this.resolveLivePane(creatorPaneHandle, creatorPaneKey ?? null) - : undefined + const selfSession = dispatch.assignee_orca_session_id + const selfPane = selfSession ? scope.resolve(selfSession) : paneKey + const coordinatorSession = + owningRun?.legacy === 0 ? currentRunCoordinatorOrcaSessionId(owningRun) : null + // Why: a chat party nests by its visible row; its handle and minted pane key match no row. + const coordinator: { handle?: string; paneKey?: string } = coordinatorSession + ? { paneKey: scope.resolve(coordinatorSession) } + : scope.resolveTerminal( + coordinatorHandle, + owningRun?.legacy === 0 ? owningRun.coordinator_pane_key : null + ) + const creatorSession = + dispatch.creator_orca_session_id && dispatch.creator_orca_session_id !== selfSession + ? dispatch.creator_orca_session_id + : null + const creator: { handle?: string; paneKey?: string } | undefined = creatorSession + ? { paneKey: scope.resolve(creatorSession) } + : currentCreatorHandle + ? scope.resolveTerminal(currentCreatorHandle, creatorAuthority?.paneKey ?? null) + : creatorLineageInRun + ? scope.resolveTerminal(creatorPaneHandle, creatorPaneKey ?? null) + : undefined const coordinatorIsSelf = coordinator.handle === handle || + (coordinatorSession !== null && coordinatorSession === selfSession) || (paneKey !== undefined && coordinator.paneKey !== undefined && coordinator.paneKey === paneKey) // Why: a creator whose pane is gone still has a coordinator to nest under. - const parent = creator?.handle ? creator : coordinatorIsSelf ? {} : coordinator + const parent = + (creator?.handle || (creatorSession && creator?.paneKey)) && + (!selfPane || creator?.paneKey !== selfPane) && + creator?.handle !== handle + ? creator + : coordinatorIsSelf + ? {} + : coordinator.paneKey === selfPane + ? {} + : coordinator const attention = !deferAttention && db && typeof db.getWorkerAttentionFacts === 'function' - ? buildWorkerAttentionContext({ db, dispatch, task, evidence }) + ? buildWorkerAttentionContext({ db, dispatch, task, evidence, now: scope.now }) : undefined return { taskId: dispatch.task_id, @@ -231,43 +292,8 @@ export class RuntimeAgentOrchestrationProjection { } } - /** Never publish stale process-local handles; re-resolve the durable pane key, retaining it while offline so restored rows can nest again. */ - private resolveLivePane( - storedHandle: string | null | undefined, - storedPaneKey: string | null - ): { handle?: string; paneKey?: string } { - if (storedHandle && this.deps.getWorktreeId(storedHandle) !== null) { - return { - handle: storedHandle, - paneKey: this.deps.getPaneKey(storedHandle) ?? storedPaneKey ?? undefined - } - } - if (!storedPaneKey) { - return {} - } - const liveHandle = this.deps.getHandleForPaneKey(storedPaneKey) - if (!liveHandle) { - return { paneKey: storedPaneKey } - } - return { handle: liveHandle, paneKey: this.deps.getPaneKey(liveHandle) ?? storedPaneKey } - } - - private getRecent(handle: string, db: OrchestrationDb | null) { + private getRecent(handle: string, db: OrchestrationDb | null, now: number) { const dispatch = db?.getLatestDispatchForTerminal?.(handle) - if ( - !dispatch?.completed_at || - dispatch.status === 'pending' || - dispatch.status === 'dispatched' - ) { - return undefined - } - const completedAt = Date.parse( - dispatch.completed_at.includes('T') - ? dispatch.completed_at - : `${dispatch.completed_at.replace(' ', 'T')}Z` - ) - return Number.isFinite(completedAt) && Date.now() - completedAt <= AGENT_STATUS_STALE_AFTER_MS - ? dispatch - : undefined + return dispatch && isRecentPresentationDispatch(dispatch, now) ? dispatch : undefined } } diff --git a/src/main/runtime/runtime-agent-orchestration-scope.ts b/src/main/runtime/runtime-agent-orchestration-scope.ts new file mode 100644 index 00000000000..31be7d7b40a --- /dev/null +++ b/src/main/runtime/runtime-agent-orchestration-scope.ts @@ -0,0 +1,97 @@ +import type { OrchestrationDb } from './orchestration/db' +import type { RuntimeAgentOrchestrationDependencies } from './runtime-agent-orchestration-projection' +import { createCanonicalSessionPaneResolver } from './orchestration/structured-session-lineage' +import { isStructuredWorkerHandle } from '../../shared/structured-worker-handle' + +export function createOrchestrationProjectionScope( + db: OrchestrationDb | null, + deps: RuntimeAgentOrchestrationDependencies +) { + const tasks = new Map>() + const runs = new Map>() + const panes = new Map>() + const now = Date.now() + const resolve = createCanonicalSessionPaneResolver(deps.getSessionRecords()) + return { + now, + resolve, + sessionDispatches: () => { + const destinations = new Map< + string, + ReturnType[number] + >() + for (const candidate of db?.listSessionDispatchesForPresentation(now) ?? []) { + if (!candidate.dispatch.assignee_orca_session_id) { + continue + } + const paneKey = resolve(candidate.dispatch.assignee_orca_session_id) + const previous = destinations.get(paneKey) + const active = + candidate.dispatch.status === 'pending' || candidate.dispatch.status === 'dispatched' + const previousActive = + previous?.dispatch.status === 'pending' || previous?.dispatch.status === 'dispatched' + if ( + !previous || + (active && !previousActive) || + (active === previousActive && candidate.order > previous.order) + ) { + destinations.set(paneKey, candidate) + } + } + return destinations + }, + resolveTerminal: (storedHandle: string | null | undefined, storedPaneKey: string | null) => + resolveLivePane(deps, storedHandle, storedPaneKey), + task: (id: string, run: string) => { + const key = `${run}:${id}` + if (!tasks.has(key)) { + tasks.set(key, db?.getTask?.(id, run)) + } + return tasks.get(key) + }, + run: (id: string) => { + if (!runs.has(id)) { + runs.set(id, db?.getRun?.(id)) + } + return runs.get(id) + }, + pane: (id: string) => { + if (!panes.has(id)) { + panes.set(id, db?.getCurrentRunForPane?.(id)) + } + return panes.get(id) + } + } +} + +/** Never publish stale process-local handles; re-resolve the durable pane key, retaining it while offline so restored rows can nest again. */ +function resolveLivePane( + deps: RuntimeAgentOrchestrationDependencies, + storedHandle: string | null | undefined, + storedPaneKey: string | null +): { handle?: string; paneKey?: string } { + // Structured mailbox panes are credentials, including stale rows with no current public proof. + if (isStructuredWorkerHandle(storedHandle)) { + return {} + } + if (storedHandle && deps.getWorktreeId(storedHandle) !== null) { + return { + handle: storedHandle, + paneKey: deps.getPaneKey(storedHandle) ?? storedPaneKey ?? undefined + } + } + if (storedPaneKey?.startsWith('structured-agent-session-')) { + return {} + } + if (!storedPaneKey) { + return {} + } + const liveHandle = deps.getHandleForPaneKey(storedPaneKey) + if (isStructuredWorkerHandle(liveHandle)) { + return {} + } + if (!liveHandle) { + return { paneKey: storedPaneKey } + } + return { handle: liveHandle, paneKey: deps.getPaneKey(liveHandle) ?? storedPaneKey } +} diff --git a/src/main/runtime/runtime-orchestration-db.test-fixture.ts b/src/main/runtime/runtime-orchestration-db.test-fixture.ts new file mode 100644 index 00000000000..ee8a518910b --- /dev/null +++ b/src/main/runtime/runtime-orchestration-db.test-fixture.ts @@ -0,0 +1,111 @@ +import { afterEach, vi } from 'vitest' +import { OrchestrationDb } from './orchestration/db' +import type { TaskRuntimeLineageRow } from './orchestration/db/run-list-page' +import type { CoordinatorRun, DispatchContextRow, RunRow } from './orchestration/types' + +const databases = new Set() +const createdAt = '2026-10-27T12:00:00Z' + +afterEach(() => { + for (const db of databases) { + db.close() + } + databases.clear() +}) + +export function makeRuntimeOrchestrationDb(): OrchestrationDb { + const db = new OrchestrationDb(':memory:') + databases.add(db) + // These scenarios project supplied terminal contexts even without persisted Dispatches. + vi.spyOn(db, 'hasAnyDispatchContexts').mockReturnValue(true) + return db +} + +export function dispatchContextRow(fields: Partial): DispatchContextRow { + return { + id: 'fixture-dispatch', + run_id: 'fixture-run', + task_id: 'fixture-task', + contract_version: 0, + launch_token_hash: null, + assignee_handle: null, + assignee_pane_key: null, + assignee_orca_session_id: null, + capability_hash: null, + process_incarnation: null, + capability_revoked_at: null, + retry_of_dispatch_id: null, + creator_dispatch_id: null, + creator_handle: null, + creator_pane_key: null, + creator_orca_session_id: null, + host_scope: null, + status: 'dispatched', + failure_count: 0, + last_failure: null, + termination_reason: null, + depth: 1, + consumer_generation: 0, + dispatched_at: createdAt, + completed_at: null, + created_at: createdAt, + last_heartbeat_at: null, + ...fields + } +} + +export function taskLineageRow(fields: Partial): TaskRuntimeLineageRow { + return { + id: 'fixture-task', + run_id: 'fixture-run', + parent_id: null, + created_by_terminal_handle: null, + created_by_pane_key: null, + created_by_process_incarnation: null, + created_by_run_generation: null, + task_title: null, + display_name: null, + spec: '', + status: 'ready', + deps: '[]', + result: null, + created_at: createdAt, + completed_at: null, + creator_dispatch_id: null, + creator_dispatch_run_id: null, + creator_dispatch_pane_key: null, + creator_dispatch_process_incarnation: null, + ...fields + } +} + +export function runRow(fields: Partial): RunRow { + return { + id: 'fixture-run', + objective: '', + home_database: 'this_database', + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: null, + coordinator_orca_session_id_generation: null, + consumer_generation: 0, + legacy: 0, + created_at: createdAt, + updated_at: createdAt, + ...fields + } +} + +export function coordinatorRunRow(fields: Partial): CoordinatorRun { + return { + id: 'fixture-coordinator', + spec: '', + status: 'running', + coordinator_handle: 'term_fixture', + poll_interval_ms: 2000, + created_at: createdAt, + completed_at: null, + scheduler_lost_at: null, + ...fields + } +} diff --git a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts index 354f4edd855..bafa06103a3 100644 --- a/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal-send-stale-leaf-liveness.test.ts @@ -418,6 +418,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { }) }, db: { + listSessionDispatchesForPresentation: () => [], // Mirrors the real query: `read = 0 AND delivered_at IS NULL`. getUndeliveredUnreadMessages: (handle: string) => rows.filter((row) => row.to_handle === handle && row.read === 0 && !row.delivered_at), diff --git a/src/renderer/src/components/activity/activity-event-builder-sources.ts b/src/renderer/src/components/activity/activity-event-builder-sources.ts index 19394166226..8ec07a07a55 100644 --- a/src/renderer/src/components/activity/activity-event-builder-sources.ts +++ b/src/renderer/src/components/activity/activity-event-builder-sources.ts @@ -1,3 +1,5 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' +import { parseAgentStatusPaneIdentity } from '../../../../shared/agent-status-worktree-attribution' import { migrationUnsupportedToAgentStatusEntry } from '../../../../shared/migration-unsupported-agent-entry' import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' @@ -75,7 +77,14 @@ export function appendUnsupportedAndRetainedEvents(context: { } for (const [paneKey, retained] of Object.entries(args.retainedAgentsByPaneKey)) { - if (!parsePaneKey(paneKey)) { + if ( + !parseAgentStatusPaneIdentity( + paneKey, + args.unifiedTabsByWorktree + ? getStructuredAgentSessionTabsByPaneKey(args.unifiedTabsByWorktree).get(paneKey) + : undefined + ) + ) { continue } const owner = resolveOwner( diff --git a/src/renderer/src/components/activity/activity-event-builder.ts b/src/renderer/src/components/activity/activity-event-builder.ts index fb1b5e649fb..ba60b09dd39 100644 --- a/src/renderer/src/components/activity/activity-event-builder.ts +++ b/src/renderer/src/components/activity/activity-event-builder.ts @@ -1,3 +1,5 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' +import { parseAgentStatusPaneIdentity } from '../../../../shared/agent-status-worktree-attribution' import { freshActivityLiveAgentState } from './activity-event-state' import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract' import type { @@ -8,7 +10,6 @@ import type { import type { ExecutionHostId } from '../../../../shared/execution-host' import type { StructuredChatTabNames } from '@/runtime/structured-chat-tab-name' import type { Repo } from '../../../../shared/repo-types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -83,7 +84,12 @@ export function buildActivityEvents( } for (const [paneKey, entry] of Object.entries(args.agentStatusByPaneKey)) { - const parsed = parsePaneKey(paneKey) + const parsed = parseAgentStatusPaneIdentity( + paneKey, + args.unifiedTabsByWorktree + ? getStructuredAgentSessionTabsByPaneKey(args.unifiedTabsByWorktree).get(paneKey) + : undefined + ) if (!parsed) { continue } diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 43a32d6ad13..130437fd530 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -1,3 +1,4 @@ +import { selectVisibleAgentOrchestration } from '@/runtime/visible-agent-orchestration' import { useDeferredValue, useMemo, useRef } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' @@ -111,7 +112,7 @@ export function useAgentPaneThreads(args: { const storeData = useAppStore( useShallow((s) => ({ agentStatusByPaneKey: s.agentStatusByPaneKey, - runtimeAgentOrchestrationByPaneKey: s.runtimeAgentOrchestrationByPaneKey, + runtimeAgentOrchestrationByPaneKey: selectVisibleAgentOrchestration(s), migrationUnsupportedByPtyId: s.migrationUnsupportedByPtyId, retainedAgentsByPaneKey: s.retainedAgentsByPaneKey, tabsByWorktree: s.tabsByWorktree, diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index d939b51a2c5..e607b2c2fcf 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -70,7 +70,14 @@ export type DashboardSnapshotState = Pick< Partial< DashboardCardTerminalInputState & DashboardLaunchDetectionState & - Pick + Pick< + AppState, + | 'runtimeEnvironments' + | 'sshTargetLabels' + | 'unifiedTabsByWorktree' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' + > > export type DashboardSnapshotInput = DashboardSnapshotState & { chatNames: StructuredChatTabNames } diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts index c318a5de42b..87b5f98529b 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts @@ -133,10 +133,10 @@ describe('agent bucket count input gate', () => { expect(second.agentStatusEpoch).toBe(1) }) - it('carries every slice the counts read, and settings pinned to null', () => { + it('carries canonical contexts and every row-owner input the counts read', () => { const state = storeState() const inputs = selectAgentBucketCountState(state) - expect(inputs.settings).toBeNull() + expect(inputs.settings).toBe(state.settings) for (const key of [ 'repos', 'worktreesByRepo', diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index f0802621109..c92e9850cc5 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -6,7 +6,11 @@ import { buildDashboardBucketCounts, createDashboardBucketCountsCache } from './build-dashboard-bucket-counts' -import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' export type AgentBucketCounts = Record @@ -28,10 +32,16 @@ export type AgentBucketCountState = Pick< | 'folderWorkspaces' | 'acknowledgedAgentsByPaneKey' | 'agentStatusEpoch' -> & { - // Counts never render generated conversation names; keep this path off settings subscriptions. - settings: null -} + | 'settings' + | 'detectedWorktreesByRepo' + | 'projectGroups' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'activeWorktreeId' + | 'activeWorkspaceExecutionHostId' + | 'runtimeEnvironments' + | 'runtimeEnvironmentCatalogHydrated' + | 'removedRuntimeEnvironmentIds' +> // Compare slice identities directly: useShallow allocates entries/Maps per store write. This pure state projection shares one allocation-free unchanged path across consumers. let previousState: AgentBucketCountState | null = null @@ -59,7 +69,17 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState previous.paneForegroundAgentByPaneKey === s.paneForegroundAgentByPaneKey && selectFolderWorkspaces(previous) === selectFolderWorkspaces(s) && previous.acknowledgedAgentsByPaneKey === s.acknowledgedAgentsByPaneKey && - previous.agentStatusEpoch === s.agentStatusEpoch + previous.agentStatusEpoch === s.agentStatusEpoch && + previous.settings === s.settings && + selectDetectedWorktreesByRepo(previous) === selectDetectedWorktreesByRepo(s) && + previous.projectGroups === s.projectGroups && + previous.restoredRuntimeHostIdByWorkspaceSessionKey === + s.restoredRuntimeHostIdByWorkspaceSessionKey && + previous.activeWorktreeId === s.activeWorktreeId && + previous.activeWorkspaceExecutionHostId === s.activeWorkspaceExecutionHostId && + previous.runtimeEnvironments === s.runtimeEnvironments && + previous.runtimeEnvironmentCatalogHydrated === s.runtimeEnvironmentCatalogHydrated && + previous.removedRuntimeEnvironmentIds === s.removedRuntimeEnvironmentIds ) { return previous } @@ -79,7 +99,15 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState folderWorkspaces: selectFolderWorkspaces(s), acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch, - settings: null + settings: s.settings, + detectedWorktreesByRepo: selectDetectedWorktreesByRepo(s), + projectGroups: s.projectGroups, + restoredRuntimeHostIdByWorkspaceSessionKey: s.restoredRuntimeHostIdByWorkspaceSessionKey, + activeWorktreeId: s.activeWorktreeId, + activeWorkspaceExecutionHostId: s.activeWorkspaceExecutionHostId, + runtimeEnvironments: s.runtimeEnvironments, + runtimeEnvironmentCatalogHydrated: s.runtimeEnvironmentCatalogHydrated, + removedRuntimeEnvironmentIds: s.removedRuntimeEnvironmentIds } return previousState } diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 347adab3b8d..be48c17d121 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -79,6 +79,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { buildDashboardSnapshot( { chatNames, + activeWorktreeId: launchRouteStore.activeWorktreeId, + activeWorkspaceExecutionHostId: launchRouteStore.activeWorkspaceExecutionHostId, repos, worktreesByRepo, tabsByWorktree, diff --git a/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts b/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts index 00e07d8e728..6bd0aee71ce 100644 --- a/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts +++ b/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts @@ -1,3 +1,4 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' import type { AgentStatusEntry, AgentStatusOrchestrationContext, @@ -146,6 +147,9 @@ export function selectWorktreeAgentRowsCached(args: { const rows = applyAgentRowLineage( buildWorktreeAgentRows({ tabs: tabs ?? [], + representedTabs: state.unifiedTabsByWorktree + ? getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree) + : undefined, entries, retained, runtimePaneTitlesByTabId: paneTitlesByTabId, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts index 188babc270f..15ebacd1cf4 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-tabs.ts @@ -1,3 +1,4 @@ +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' import type { Tab } from '../../../../shared/tab-types' @@ -28,3 +29,21 @@ export function getStructuredAgentSessionTabs( structuredTabsByUnifiedTabsSnapshot.set(unifiedTabsByWorktree, tabs) return tabs } + +const paneTabs = new WeakMap, ReadonlyMap>() +export function getStructuredAgentSessionTabsByPaneKey( + tabs: Record +): ReadonlyMap { + const cached = paneTabs.get(tabs) + if (cached) { + return cached + } + const result = new Map( + getStructuredAgentSessionTabs(tabs).map((tab) => [ + structuredAgentSessionPaneKey(tab.id, tab.entityId), + tab + ]) + ) + paneTabs.set(tabs, result) + return result +} diff --git a/src/renderer/src/components/sidebar/WorkerLineage.fixture.tsx b/src/renderer/src/components/sidebar/WorkerLineage.fixture.tsx new file mode 100644 index 00000000000..204a79182ae --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkerLineage.fixture.tsx @@ -0,0 +1,61 @@ +import { defineFixture } from '../../component-fixtures/component-fixture' +import WorktreeCardAgents from './WorktreeCardAgents' +import { applyAgentRowLineage } from '../dashboard/agent-row-lineage' +import { buildWorktreeAgentRows } from './worktree-agent-rows' +import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' +import type { Tab } from '../../../../shared/tab-types' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' + +const now = 1_792_983_600_000 +const tabs: Tab[] = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222' +].map((entityId, index) => ({ + id: index === 0 ? 'reserved-chat-tab' : 'worker-chat-tab:history-1', + entityId, + contentType: 'agent-session', + agentSessionAgent: 'codex', + worktreeId: 'fixture-workspace', + groupId: 'fixture-group', + executionHostId: 'local', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: index, + createdAt: now +})) +const [parent, child] = tabs.map((tab) => structuredAgentSessionPaneKey(tab.id, tab.entityId)) +const entries: AgentStatusEntry[] = [parent, child].map((paneKey, index) => ({ + paneKey, + worktreeId: 'fixture-workspace', + agentType: 'codex', + state: 'working', + prompt: index === 0 ? 'Coordinate the release checks' : 'Verify the worker result', + updatedAt: now, + stateStartedAt: now, + stateHistory: [], + ...(index === 1 + ? { orchestration: { taskId: 'task', dispatchId: 'dispatch', parentPaneKey: parent } } + : {}) +})) +const rows = applyAgentRowLineage( + buildWorktreeAgentRows({ + tabs: [], + representedTabs: new Map( + tabs.map((tab) => [structuredAgentSessionPaneKey(tab.id, tab.entityId), tab]) + ), + entries, + retained: [], + now + }) +) + +export const nestedChatWorker = defineFixture({ + width: 380, + store: { agentActivityDisplayMode: 'compact' }, + render: () => ( +
+ +
+ ) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx index 2fbdf69f29c..48c74a2684e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.affiliate-list-mode.test.tsx @@ -46,6 +46,7 @@ vi.mock('@/store', () => { sshConnectionStates: new Map(), sshTargetLabels: new Map(), tabsByWorktree: {}, + unifiedTabsByWorktree: {}, updateWorktreeMeta, workspacePortScan: null, worktreeCardProperties diff --git a/src/renderer/src/components/sidebar/mirrored-terminal-lineage.test-fixture.tsx b/src/renderer/src/components/sidebar/mirrored-terminal-lineage.test-fixture.tsx new file mode 100644 index 00000000000..cc10b98f5b2 --- /dev/null +++ b/src/renderer/src/components/sidebar/mirrored-terminal-lineage.test-fixture.tsx @@ -0,0 +1,356 @@ +import { act } from 'react' +import { expect, vi } from 'vitest' +import type { RuntimeAgentLineageTestModule } from '../../../../shared/runtime-agent-lineage-consumer.test-fixture' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { makeTab } from '@/store/slices/store-test-helpers' + +// Load the native graph during collection; fixture state stays per test. +const { + default: { publicLineageFixture, canonicalChatRow, LINEAGE_PARENT, LINEAGE_CHILD } +} = await vi.importActual<{ default: RuntimeAgentLineageTestModule }>( + '../../../../main/runtime/runtime-agent-lineage.test-fixture' +) + +const LEAF = '11111111-1111-4111-8111-111111111111' + +export async function verifyMirroredTerminalLineage( + kind: 'default' | 'reserved' | 'collision', + { + patch, + entry, + mount, + getRows + }: { + patch: (update: Partial) => void + entry: (key: string, worktree: string) => AgentStatusEntry + mount: () => void + getRows: () => DashboardAgentRow[] + } +): Promise { + const { makeUnifiedTab, makeWorktree, TEST_REPO } = + await import('@/store/slices/store-test-helpers') + const { selectDashboardOrchestration } = + await import('@/components/dashboard/dashboard-orchestration-selection') + const { selectWorktreeAgentRowsCached } = + await import('@/components/dashboard/worktree-agent-rows-cache') + const { buildActivityEvents } = await import('@/components/activity/activity-event-builder') + const { buildMirroredAgentTabs } = + await import('@/runtime/web-session-tabs-sync/terminal-surfaces') + const { buildMirroredAgentStatusPatch } = + await import('@/runtime/web-session-tabs-sync/agent-status-patch') + const { buildTerminalUnifiedTab } = await import('@/runtime/web-session-tabs-sync/tab-builders') + const { toRemoteRuntimePtyId } = + await import('../../../../shared/remote-runtime/remote-runtime-pty-id') + const { toWebTerminalSurfaceTabId } = await import('../../../../shared/terminal-surface-id') + const { projectStatus } = + await import('@/components/native-chat/structured-agent-session-status-projection') + const { structuredAgentSessionPaneKey: pane, structuredAgentSessionTabId: tabId } = + await import('../../../../shared/structured-agent-session-projection') + const { selectVisibleAgentOrchestration } = await import('@/runtime/visible-agent-orchestration') + const worktree = makeWorktree({ id: 'folder-a', repoId: TEST_REPO.id }) + patch({ repos: [TEST_REPO], worktreesByRepo: { [TEST_REPO.id]: [worktree] } }) + const f = publicLineageFixture('folder-a') + try { + const context = f.projection.buildByPaneKey()?.[canonicalChatRow(LINEAGE_CHILD)] + if (!context) { + throw new Error('Missing production context') + } + const native = buildMirroredAgentTabs( + { + worktree: 'folder-a', + publicationEpoch: 'test', + snapshotVersion: 1, + activeGroupId: 'g', + activeTabId: null, + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: 'host-parent', + title: 'Parent', + sessionId: LINEAGE_PARENT, + replacesSessionId: 'previous', + agent: 'codex', + isActive: false + } + ] + }, + 'runtime:paired', + new Map(), + 'g', + 0, + kind === 'default' + ? [] + : [ + makeUnifiedTab({ + id: kind === 'reserved' ? 'reserved-parent' : tabId(LINEAGE_PARENT), + entityId: kind === 'reserved' ? 'previous' : 'occupied', + worktreeId: 'folder-a', + groupId: 'g', + contentType: 'agent-session' + }) + ], + Date.now() + )[0].unifiedTab + if (!isStructuredTab(native)) { + throw new Error('Expected the mirrored native session tab') + } + const actualParent = pane(native.id, LINEAGE_PARENT) + const tab = makeTab({ + id: toWebTerminalSurfaceTabId('host-terminal'), + worktreeId: 'folder-a', + ptyId: toRemoteRuntimePtyId('terminal', 'paired') + }) + const worker = makePaneKey(tab.id, LEAF) + const terminal = buildTerminalUnifiedTab(tab, 'g', 'paired') + patch({ + tabsByWorktree: { 'folder-a': [tab] }, + unifiedTabsByWorktree: {}, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {} + }) + const produced = buildMirroredAgentStatusPatch( + useAppStore.getState(), + [], + [ + { + type: 'terminal', + id: 'surface', + title: 'Worker', + parentTabId: 'host-terminal', + leafId: LEAF, + terminal: 'terminal', + status: 'ready', + isActive: false, + agentStatus: { + ...entry(makePaneKey('host-terminal', LEAF), 'folder-a'), + orchestration: context + } + } + ], + [], + 'paired', + 'folder-a', + new Set(), + Date.now() + ) + if (!produced) { + throw new Error('Missing mirrored terminal status') + } + patch(produced) + expect(useAppStore.getState().agentStatusByPaneKey[worker].connectionId).toBe('paired') + // Status before graph: the missing parent stays rooted, then tab hydration alone joins it. + expect(selectVisibleAgentOrchestration(useAppStore.getState())[worker]?.parentPaneKey).not.toBe( + actualParent + ) + patch({ unifiedTabsByWorktree: { 'folder-a': [native, terminal] } }) + act(() => + useAppStore.getState().transactAgentStatuses((transaction) => + projectStatus( + transaction, + native, + { + sessionId: LINEAGE_PARENT, + workspaceId: 'folder-a', + agent: 'codex', + status: 'working', + latestPrompt: 'Parent', + updatedAt: Date.now(), + hostExecutionOwned: true + }, + 'live', + null + ) + ) + ) + mount() + const assertNested = (parent = actualParent) => { + expect(getRows().map((row) => row.paneKey)).toEqual([parent, worker]) + expect(getRows().map((row) => row.lineage?.depth)).toEqual([0, 1]) + expect(getRows()[1].entry.orchestration?.parentPaneKey).toBe(parent) + expect( + useAppStore.getState().agentStatusByPaneKey[worker]?.orchestration?.parentPaneKey ?? + context.parentPaneKey + ).toBe(canonicalChatRow(LINEAGE_PARENT)) + const state = useAppStore.getState() + const orchestration = + selectDashboardOrchestration(state, [{ worktree }]).singletonOrchestration ?? {} + const dashboard = selectWorktreeAgentRowsCached({ + state, + worktreeId: 'folder-a', + orchestration, + now: Date.now(), + generation: 1 + }) + expect(dashboard.map((row) => [row.paneKey, row.lineage.depth])).toEqual([ + [parent, 0], + [worker, 1] + ]) + const activity = buildActivityEvents({ + ...state, + chatNames: new Map(), + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[TEST_REPO.id, TEST_REPO]]), + runtimeAgentOrchestrationByPaneKey: selectVisibleAgentOrchestration(state), + now: Date.now() + }) + expect(activity.paneEntryByPaneKey[worker]?.orchestration?.parentPaneKey).toBe(parent) + expect(activity.paneEntryByPaneKey[parent]).toBeDefined() + } + assertNested() + // A host-supplied legacy connection spelling does not override actual producer proof. + patch({ + agentStatusByPaneKey: { + ...useAppStore.getState().agentStatusByPaneKey, + [worker]: { + ...useAppStore.getState().agentStatusByPaneKey[worker], + connectionId: 'ssh-legacy' + } + } + }) + assertNested() + const canonical = useAppStore.getState().agentStatusByPaneKey[worker] + const epoch = useAppStore.getState().agentStatusEpoch + // Graph before parent hydration; matching task/Dispatch producer remains valid with nonempty connectionId. + const parentEntry = useAppStore.getState().agentStatusByPaneKey[actualParent] + patch({ + unifiedTabsByWorktree: { 'folder-a': [terminal] }, + agentStatusByPaneKey: { [worker]: canonical } + }) + act(() => + useAppStore + .getState() + .setRuntimeAgentOrchestrationByPaneKey( + { [worker]: structuredClone(context) }, + 'runtime:paired' + ) + ) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + patch({ + unifiedTabsByWorktree: { 'folder-a': [native, terminal] }, + agentStatusByPaneKey: { [worker]: canonical, [actualParent]: parentEntry } + }) + assertNested() + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey({})) + // PTY-only hydration is an independent cache input; disagreeing tab/PTY facts must not join. + patch({ + unifiedTabsByWorktree: { 'folder-a': [native] }, + tabsByWorktree: { 'folder-a': [{ ...tab, ptyId: null }] } + }) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + patch({ ptyIdsByTabId: { [tab.id]: [tab.ptyId ?? ''] } }) + assertNested() + patch({ + unifiedTabsByWorktree: { + 'folder-a': [native, { ...terminal, executionHostId: 'runtime:other' }] + } + }) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + patch({ + unifiedTabsByWorktree: { + 'folder-a': [{ ...native, executionHostId: 'runtime:other' }, terminal] + }, + ptyIdsByTabId: {} + }) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + patch({ unifiedTabsByWorktree: { 'folder-a': [native, terminal] } }) + assertNested() + // Retained snapshot proves the runtime after the current terminal tab/PTY/unified row disappears. + act(() => + useAppStore.getState().retainAgents([ + { + entry: { ...canonical, state: 'done' }, + worktreeId: 'folder-a', + tab, + agentType: 'claude', + startedAt: Date.now() + } + ]) + ) + patch({ + agentStatusByPaneKey: { + [actualParent]: useAppStore.getState().agentStatusByPaneKey[actualParent] + }, + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'folder-a': [native] }, + ptyIdsByTabId: {} + }) + assertNested() + patch({ unifiedTabsByWorktree: { 'folder-a': [{ ...native, id: 'remapped-parent' }] } }) + // The parent status arrives independently; canonical retained context did not change or need an RPC. + patch({ + agentStatusByPaneKey: { + [pane('remapped-parent', LINEAGE_PARENT)]: { + ...useAppStore.getState().agentStatusByPaneKey[actualParent], + paneKey: pane('remapped-parent', LINEAGE_PARENT), + tabId: 'remapped-parent' + } + } + }) + assertNested(pane('remapped-parent', LINEAGE_PARENT)) + expect( + useAppStore.getState().retainedAgentsByPaneKey[worker].entry.orchestration?.parentPaneKey + ).toBe(canonicalChatRow(LINEAGE_PARENT)) + expect(useAppStore.getState().agentStatusEpoch).toBeGreaterThanOrEqual(epoch) + // Removing the only retained PTY proof cannot infer a runtime from its connection spelling. + patch({ + retainedAgentsByPaneKey: { + [worker]: { + ...useAppStore.getState().retainedAgentsByPaneKey[worker], + tab: { ...tab, ptyId: null } + } + } + }) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + expect(new Set(getRows().map((row) => row.paneKey)).size).toBe(getRows().length) + // Matching graph context identifies its producer even when terminal routing names another host. + const renamedParent = pane('remapped-parent', LINEAGE_PARENT) + patch({ + retainedAgentsByPaneKey: {}, + tabsByWorktree: { 'folder-a': [tab] }, + unifiedTabsByWorktree: { + 'folder-a': [{ ...native, id: 'remapped-parent', executionHostId: 'local' }, terminal] + }, + agentStatusByPaneKey: { + [renamedParent]: useAppStore.getState().agentStatusByPaneKey[renamedParent], + [worker]: canonical + } + }) + act(() => + useAppStore + .getState() + .setRuntimeAgentOrchestrationByPaneKey({ [worker]: structuredClone(context) }) + ) + assertNested(renamedParent) + act(() => + useAppStore + .getState() + .setRuntimeAgentOrchestrationByPaneKey( + { [worker]: structuredClone(context) }, + 'runtime:other' + ) + ) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey({})) + patch({ + tabsByWorktree: { 'folder-a': [{ ...tab, ptyId: 'remote:%zz@@terminal' }] }, + unifiedTabsByWorktree: { + 'folder-a': [{ ...native, id: 'remapped-parent', executionHostId: 'local' }] + }, + agentStatusByPaneKey: { + ...useAppStore.getState().agentStatusByPaneKey, + [worker]: { ...canonical, connectionId: undefined } + } + }) + expect(getRows().find((row) => row.paneKey === worker)?.lineage?.depth).toBe(0) + } finally { + f.db.close() + } +} diff --git a/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx b/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx index 3b502328587..995524e6750 100644 --- a/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx +++ b/src/renderer/src/components/sidebar/use-worktree-agent-rows-subscriptions.test.tsx @@ -13,9 +13,19 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { useWorktreeAgentRows } from './useWorktreeAgentRows' +import { verifyMirroredTerminalLineage } from './mirrored-terminal-lineage.test-fixture' +import type { RuntimeAgentLineageTestModule } from '../../../../shared/runtime-agent-lineage-consumer.test-fixture' +import { isStructuredTab } from '../../../../shared/structured-agent-session-tab' Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true) +// Load the native graph during collection; fixture state stays per test. +const { + default: { publicLineageFixture, canonicalChatRow, LINEAGE_PARENT, LINEAGE_CHILD } +} = await vi.importActual<{ default: RuntimeAgentLineageTestModule }>( + '../../../../main/runtime/runtime-agent-lineage.test-fixture' +) + const LEAF = '11111111-1111-4111-8111-111111111111' const KEY_A = makePaneKey('tab-a', LEAF) const KEY_B = makePaneKey('tab-b', LEAF) @@ -220,3 +230,535 @@ describe('mounted worktree agent row subscriptions', () => { } }) }) + +it.each([20, 200])( + 'shares canonical receiving/alias work and preserves unaffected mounted rows at %i worktrees', + async (n) => { + const { structuredAgentSessionPaneKey: pane, structuredAgentSessionTabId: tabId } = + await import('../../../../shared/structured-agent-session-projection') + const { makeUnifiedTab } = await import('@/store/slices/store-test-helpers') + const { visibleOrchestrationWorkForTest } = + await import('@/runtime/visible-agent-orchestration') + const { _getWorktreeAgentOrchestrationIndexBuildCountForTest: indexBuilds } = + await import('./worktree-agent-orchestration-index') + const live: AppState['agentStatusByPaneKey'] = {} + const tabs: AppState['unifiedTabsByWorktree'] = {} + const raw: AppState['runtimeAgentOrchestrationByPaneKey'] = {} + const observed = new Map() + const counts = new Map() + for (let i = 0; i < n; i++) { + const worktreeId = `lineage-${i}` + tabs[worktreeId] = ['parent', 'child'].map((role) => + makeUnifiedTab({ + id: `${role}-actual-${i}`, + entityId: `${role}-${i}`, + groupId: 'group', + worktreeId, + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: i === n - 1 ? 'runtime:other' : 'local' + }) + ) + for (const tab of tabs[worktreeId]) { + live[pane(tab.id, tab.entityId)] = { + ...entry(pane(tab.id, tab.entityId), worktreeId), + agentType: 'codex', + tabId: tab.id + } + } + raw[pane(tabId(`child-${i}`), `child-${i}`)] = { + taskId: `task-${i}`, + dispatchId: `dispatch-${i}`, + dispatchStatus: 'dispatched', + parentPaneKey: pane(tabId(`parent-${i}`), `parent-${i}`) + } + } + patch({ + tabsByWorktree: {}, + unifiedTabsByWorktree: tabs, + agentStatusByPaneKey: live, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + }) + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey(raw)) + function ManyProbe({ index }: { index: number }) { + observed.set(index, useWorktreeAgentRows(`lineage-${index}`)) + counts.set(index, (counts.get(index) ?? 0) + 1) + return null + } + act(() => + root.render(Array.from({ length: n }, (_, index) => )) + ) + const initial = new Map(observed) + counts.clear() + const beforeEqual = visibleOrchestrationWorkForTest() + const canonicalBefore = useAppStore.getState().runtimeAgentOrchestrationByPaneKey + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey(structuredClone(raw))) + expect(useAppStore.getState().runtimeAgentOrchestrationByPaneKey).toBe(canonicalBefore) + expect(counts.size).toBe(0) + expect(visibleOrchestrationWorkForTest()).toEqual(beforeEqual) + const changedKey = pane(tabId('child-0'), 'child-0') + for (const change of [ + { taskTitle: 'A real new title' }, + { dispatchStatus: 'completed' as const }, + { attention: { categories: ['input' as const], requiresAction: true } }, + { parentPaneKey: 'missing-parent' } + ]) { + const before = visibleOrchestrationWorkForTest() + const beforeIndex = indexBuilds() + counts.clear() + act(() => + useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey({ + ...useAppStore.getState().runtimeAgentOrchestrationByPaneKey, + [changedKey]: { ...raw[changedKey], ...change } + }) + ) + expect(counts.get(0)).toBe(1) + expect( + observed.get(0)?.find((row) => row.paneKey === pane('child-actual-0', 'child-0'))?.entry + .orchestration + ).toMatchObject(change) + for (let i = 1; i < n; i++) { + expect(counts.has(i)).toBe(false) + expect(observed.get(i)).toBe(initial.get(i)) + } + const after = visibleOrchestrationWorkForTest() + expect(after.builds - before.builds).toBeLessThanOrEqual(1) + expect(after.aliasBuilds).toBe(before.aliasBuilds) + expect(indexBuilds() - beforeIndex).toBeLessThanOrEqual(1) + expect(after.visits - before.visits).toBeLessThanOrEqual(n * 14) + } + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey(raw)) + // Releasing one relationship preserves unrelated arrays even while its embedded completion remains. + counts.clear() + const remaining = { ...useAppStore.getState().runtimeAgentOrchestrationByPaneKey } + delete remaining[changedKey] + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey(remaining)) + for (let i = 1; i < n; i++) { + expect(observed.get(i)).toBe(initial.get(i)) + } + const epoch = useAppStore.getState().agentStatusEpoch + const beforeAlias = visibleOrchestrationWorkForTest() + patch({ + unifiedTabsByWorktree: { + ...tabs, + 'lineage-0': tabs['lineage-0'].map((tab) => + tab.entityId === 'parent-0' ? { ...tab, id: 'remapped-parent' } : tab + ) + } + }) + expect( + observed.get(0)?.find((row) => row.paneKey === pane('child-actual-0', 'child-0'))?.entry + .orchestration?.parentPaneKey + ).toBe(pane('remapped-parent', 'parent-0')) + expect(useAppStore.getState().agentStatusEpoch).toBe(epoch) + for (let i = 1; i < n; i++) { + expect(observed.get(i)).toBe(initial.get(i)) + } + expect(visibleOrchestrationWorkForTest().builds - beforeAlias.builds).toBe(1) + // A host change cannot attach a local worker to an identically named foreign parent. + patch({ + unifiedTabsByWorktree: { + ...tabs, + 'lineage-0': tabs['lineage-0'].map((tab) => + tab.entityId === 'parent-0' ? { ...tab, executionHostId: 'runtime:other' } : tab + ) + } + }) + expect( + observed.get(0)?.find((row) => row.paneKey === pane('child-actual-0', 'child-0'))?.entry + .orchestration?.parentPaneKey + ).not.toBe(pane('parent-actual-0', 'parent-0')) + for (let i = 1; i < n; i++) { + expect(observed.get(i)).toBe(initial.get(i)) + } + counts.clear() + const foreignRow = pane(`child-actual-${n - 1}`, `child-${n - 1}`) + const beforeForeign = visibleOrchestrationWorkForTest() + const beforeForeignIndex = indexBuilds() + const { projectStatus } = + await import('@/components/native-chat/structured-agent-session-status-projection') + const foreignTab = tabs[`lineage-${n - 1}`][1] + if (!isStructuredTab(foreignTab)) { + throw new Error('Expected the native session tab') + } + act(() => + useAppStore.getState().transactAgentStatuses((transaction) => + projectStatus( + transaction, + foreignTab, + { + sessionId: foreignTab.entityId, + workspaceId: foreignTab.worktreeId, + agent: 'codex', + status: 'working', + latestPrompt: 'Another host update', + updatedAt: Date.now() + 1, + hostExecutionOwned: true + }, + 'live', + null + ) + ) + ) + expect(counts.get(n - 1)).toBe(1) + expect(observed.get(n - 1)?.find((row) => row.paneKey === foreignRow)?.entry.prompt).toBe( + 'Another host update' + ) + for (let i = 0; i < n - 1; i++) { + expect(counts.has(i)).toBe(false) + } + const afterForeign = visibleOrchestrationWorkForTest() + expect(afterForeign.builds - beforeForeign.builds).toBeLessThanOrEqual(1) + expect(afterForeign.aliasBuilds).toBe(beforeForeign.aliasBuilds) + expect(indexBuilds() - beforeForeignIndex).toBeLessThanOrEqual(1) + expect(afterForeign.visits - beforeForeign.visits).toBeLessThanOrEqual(n * 14) + } +) + +it('joins production mirrored custom/collision rows locally and keeps canonical initial, graph, retained and consumer contexts', async () => { + const { makeUnifiedTab, makeWorktree, TEST_REPO } = + await import('@/store/slices/store-test-helpers') + const { buildMirroredAgentTabs } = + await import('@/runtime/web-session-tabs-sync/terminal-surfaces') + const { projectStatus } = + await import('@/components/native-chat/structured-agent-session-status-projection') + const { getStructuredAgentSessionTabs } = + await import('@/components/native-chat/structured-agent-session-tabs') + const { selectVisibleAgentOrchestration } = await import('@/runtime/visible-agent-orchestration') + const { structuredAgentSessionPaneKey: pane, structuredAgentSessionTabId: tabId } = + await import('../../../../shared/structured-agent-session-projection') + const { selectWorktreeAgentActivitySummary } = await import('./worktree-agent-activity-summary') + const { selectDashboardOrchestration } = + await import('@/components/dashboard/dashboard-orchestration-selection') + const { selectWorktreeAgentRowsCached } = + await import('@/components/dashboard/worktree-agent-rows-cache') + const { buildActivityEvents } = await import('@/components/activity/activity-event-builder') + const f = publicLineageFixture('folder-a') + try { + const predecessor = makeUnifiedTab({ + id: 'reserved-parent', + entityId: 'old-parent', + groupId: 'g', + worktreeId: 'folder-a', + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: 'local' + }) + const occupied = makeUnifiedTab({ + id: tabId(LINEAGE_CHILD), + entityId: 'unrelated', + groupId: 'g', + worktreeId: 'folder-a' + }) + const mirrored = buildMirroredAgentTabs( + { + worktree: 'folder-a', + publicationEpoch: 'test', + snapshotVersion: 1, + activeGroupId: 'g', + activeTabId: null, + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: 'host-parent', + title: 'Parent', + sessionId: LINEAGE_PARENT, + replacesSessionId: 'old-parent', + agent: 'codex', + isActive: false + }, + { + type: 'agent-session', + id: 'host-child', + title: 'Child', + sessionId: LINEAGE_CHILD, + agent: 'codex', + isActive: false + } + ] + }, + 'local', + new Map(), + 'g', + 0, + [predecessor, occupied], + Date.now() + ).map((item) => item.unifiedTab) + expect(mirrored[0].id).toBe('reserved-parent') + expect(mirrored[1].id).toContain(':history-1') + const raw = f.projection.buildByPaneKey() ?? {} + patch({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {} + }) + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey(raw)) + const canonical = useAppStore.getState().runtimeAgentOrchestrationByPaneKey + patch({ unifiedTabsByWorktree: { 'folder-a': mirrored } }) + const actualParent = pane(mirrored[0].id, LINEAGE_PARENT) + const actualChild = pane(mirrored[1].id, LINEAGE_CHILD) + expect(selectVisibleAgentOrchestration(useAppStore.getState())[actualChild].parentPaneKey).toBe( + actualParent + ) + const emitStatuses = () => + act(() => + useAppStore.getState().transactAgentStatuses((transaction) => { + for (const tab of getStructuredAgentSessionTabs( + useAppStore.getState().unifiedTabsByWorktree + )) { + projectStatus( + transaction, + tab, + { + sessionId: tab.entityId, + workspaceId: tab.worktreeId, + agent: 'codex', + status: 'working', + latestPrompt: tab.label, + updatedAt: Date.now(), + hostExecutionOwned: true + }, + 'live', + null + ) + } + }) + ) + emitStatuses() + expect( + useAppStore.getState().agentStatusByPaneKey[actualChild].orchestration?.parentPaneKey + ).toBe(canonicalChatRow(LINEAGE_PARENT)) + expect(useAppStore.getState().runtimeAgentOrchestrationByPaneKey).toBe(canonical) + act(() => root.render()) + expect(rows.map((row) => row.paneKey)).toEqual([actualParent, actualChild]) + expect(rows[1].entry.orchestration?.parentPaneKey).toBe(actualParent) + const worktree = makeWorktree({ id: 'folder-a', repoId: TEST_REPO.id }) + patch({ repos: [TEST_REPO], worktreesByRepo: { [TEST_REPO.id]: [worktree] } }) + const state = useAppStore.getState() + const singleton = selectDashboardOrchestration(state, [{ worktree }]).singletonOrchestration + const batch = selectDashboardOrchestration(state, [ + { worktree }, + { worktree: { ...worktree, id: 'other' } } + ]).orchestrationByWorktree?.get('folder-a') + expect(batch).toBe(singleton) + const dashboard = selectWorktreeAgentRowsCached({ + state, + worktreeId: 'folder-a', + orchestration: singleton ?? {}, + now: Date.now(), + generation: 1 + }) + expect(dashboard.map((row) => row.lineage?.depth)).toEqual([0, 1]) + expect(selectWorktreeAgentActivitySummary(state, 'folder-a').hasLiveWorking).toBe(true) + const activity = buildActivityEvents({ + ...state, + chatNames: new Map(), + tabsByWorktree: state.tabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + worktreeMap: new Map([[worktree.id, worktree]]), + repoMap: new Map([[TEST_REPO.id, TEST_REPO]]), + runtimeAgentOrchestrationByPaneKey: selectVisibleAgentOrchestration(state), + now: Date.now() + }) + expect(activity.paneEntryByPaneKey[actualChild]?.orchestration?.parentPaneKey).toBe( + actualParent + ) + expect(activity.paneEntryByPaneKey[actualParent]).toBeDefined() + const { getWorkspaceCleanupAgentEvidence } = + await import('@/store/slices/workspace-cleanup-agent-evidence') + const { makeCandidate } = await import('@/store/slices/workspace-cleanup-slice-test-harness') + expect([ + ...getWorkspaceCleanupAgentEvidence(state, [ + makeCandidate({ worktreeId: 'folder-a', executionHostId: 'local' }) + ]).values() + ]).toEqual(['live']) + const { buildDashboardSnapshot } = + await import('@/components/dashboard/build-dashboard-snapshot') + const snapshot = buildDashboardSnapshot({ ...state, chatNames: new Map() }, Date.now()) + expect(JSON.stringify(snapshot)).toContain(actualChild) + expect(JSON.stringify(snapshot)).toContain(actualParent) + + // Payload-before-graph and a retained terminal child's embedded parent use that same read-side join. + act(() => useAppStore.getState().setRuntimeAgentOrchestrationByPaneKey({})) + expect(selectVisibleAgentOrchestration(useAppStore.getState())[actualChild].parentPaneKey).toBe( + actualParent + ) + const terminal = { + ...entry(KEY_B, 'folder-a'), + state: 'done' as const, + orchestration: raw[canonicalChatRow(LINEAGE_CHILD)] + } + act(() => + useAppStore.getState().retainAgents([ + { + entry: terminal, + worktreeId: 'folder-a', + tab: makeTab({ id: 'tab-b', worktreeId: 'folder-a' }), + agentType: 'codex', + startedAt: Date.now() + } + ]) + ) + expect(selectVisibleAgentOrchestration(useAppStore.getState())[KEY_B].parentPaneKey).toBe( + actualParent + ) + // Alias rederivation is immediate and writes neither canonical storage nor an epoch. + const epoch = useAppStore.getState().agentStatusEpoch + patch({ + unifiedTabsByWorktree: { + 'folder-a': mirrored.map((tab, index) => (index === 0 ? { ...tab, id: 'new-parent' } : tab)) + } + }) + expect(selectVisibleAgentOrchestration(useAppStore.getState())[actualChild].parentPaneKey).toBe( + pane('new-parent', LINEAGE_PARENT) + ) + expect(useAppStore.getState().agentStatusEpoch).toBe(epoch) + patch({ + unifiedTabsByWorktree: { + 'folder-a': mirrored.map((tab, index) => + index === 0 ? { ...tab, executionHostId: 'runtime:foreign' } : tab + ) + } + }) + expect( + selectVisibleAgentOrchestration(useAppStore.getState())[actualChild]?.parentPaneKey + ).not.toBe(actualParent) + } finally { + f.db.close() + } +}) + +it.each(['default', 'reserved', 'collision'] as const)( + 'joins mirrored live/retained terminal contexts with positive provenance and a %s parent', + async (kind) => { + await verifyMirroredTerminalLineage(kind, { + patch, + entry, + mount: () => act(() => root.render()), + getRows: () => rows + }) + } +) + +it('settles the production graph subscription/scheduler/publication/receiver loop with at most one feedback flight', async () => { + const { useRuntimeGraphSync } = await import('@/app-shell/use-runtime-graph-sync') + const { setRuntimeGraphSyncEnabled, setRuntimeGraphStoreStateGetter } = + await import('@/runtime/sync-runtime-graph') + const { graphState } = await import('@/runtime/sync-runtime-graph/graph-state') + const { makeUnifiedTab } = await import('@/store/slices/store-test-helpers') + const { selectVisibleAgentOrchestration } = await import('@/runtime/visible-agent-orchestration') + const { structuredAgentSessionPaneKey: pane } = + await import('../../../../shared/structured-agent-session-projection') + const f = publicLineageFixture('folder-a') + const response = { + runtimeId: 'lineage', + rendererGraphEpoch: 1, + graphStatus: 'ready' as const, + authoritativeWindowId: null, + liveTabCount: 0, + liveLeafCount: 0, + agentOrchestrationByPaneKey: f.projection.buildByPaneKey() + } + let release: (() => void) | undefined + let fail = false + let hold = true + const previousApi = window.api + const sync = vi.fn(async () => { + if (hold) { + await new Promise((resolve) => { + release = resolve + }) + } + if (fail) { + throw new Error('expected transport failure') + } + return structuredClone(response) + }) + Reflect.set(window, 'api', { + ...previousApi, + runtime: { ...previousApi?.runtime, syncWindowGraph: sync } + }) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + function SyncProbe() { + useRuntimeGraphSync() + return null + } + const flush = () => + act(async () => { + await vi.advanceTimersByTimeAsync(20) + }) + try { + patch({ + workspaceSessionReady: true, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, + agentStatusByPaneKey: { + [canonicalChatRow(LINEAGE_CHILD)]: entry(canonicalChatRow(LINEAGE_CHILD), 'folder-a') + } + }) + act(() => root.render()) + await flush() + expect(sync).toHaveBeenCalledTimes(1) + const parent = makeUnifiedTab({ + id: 'late-parent', + entityId: LINEAGE_PARENT, + groupId: 'g', + worktreeId: 'folder-a', + contentType: 'agent-session', + agentSessionAgent: 'codex', + executionHostId: 'local' + }) + patch({ unifiedTabsByWorktree: { 'folder-a': [parent] } }) + hold = false + await act(async () => { + release?.() + await Promise.resolve() + }) + await flush() + expect(sync).toHaveBeenCalledTimes(2) + const stable = useAppStore.getState() + expect( + selectVisibleAgentOrchestration(stable)[canonicalChatRow(LINEAGE_CHILD)].parentPaneKey + ).toBe(pane(parent.id, LINEAGE_PARENT)) + await flush() + expect(sync).toHaveBeenCalledTimes(2) + expect(useAppStore.getState().runtimeAgentOrchestrationByPaneKey).toBe( + stable.runtimeAgentOrchestrationByPaneKey + ) + expect(useAppStore.getState().agentStatusByPaneKey).toBe(stable.agentStatusByPaneKey) + expect(useAppStore.getState().agentStatusEpoch).toBe(stable.agentStatusEpoch) + fail = true + patch({ unifiedTabsByWorktree: { 'folder-a': [{ ...parent, label: 'legitimate wake' }] } }) + await flush() + expect(sync).toHaveBeenCalledTimes(3) + expect(graphState.syncInFlight).toBe(false) + await flush() + expect(sync).toHaveBeenCalledTimes(3) + setRuntimeGraphSyncEnabled(false) + patch({ unifiedTabsByWorktree: { 'folder-a': [{ ...parent, id: 'disabled-local-alias' }] } }) + expect( + selectVisibleAgentOrchestration(useAppStore.getState())[canonicalChatRow(LINEAGE_CHILD)] + .parentPaneKey + ).toBe(pane('disabled-local-alias', LINEAGE_PARENT)) + await flush() + expect(sync).toHaveBeenCalledTimes(3) + fail = false + setRuntimeGraphSyncEnabled(true) + await flush() + expect(sync).toHaveBeenCalledTimes(4) + await flush() + expect(sync).toHaveBeenCalledTimes(4) + } finally { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + Reflect.set(window, 'api', previousApi) + error.mockRestore() + f.db.close() + } +}) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index e54c1a78c20..030eeb3ca7b 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -1,3 +1,4 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' import { useCallback, useMemo, useSyncExternalStore } from 'react' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' @@ -117,6 +118,9 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa return applyAgentRowLineage( buildWorktreeAgentRows({ tabs: tabs ?? EMPTY_TABS, + representedTabs: getStructuredAgentSessionTabsByPaneKey( + useAppStore.getState().unifiedTabsByWorktree + ), entries, retained, runtimePaneTitlesByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 44f022357e9..37c65e59981 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -64,6 +64,33 @@ describe('selectWorktreeAgentActivitySummary', () => { vi.restoreAllMocks() }) + it('attributes migration-blocked UUID and legacy terminal panes without affecting other worktrees', () => { + for (const paneKey of [makePaneKey('tab-1', LEAF_ID), 'tab-1:0', undefined, 'malformed']) { + const state: AgentActivityInput = { + tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, + agentStatusEpoch: 0, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + runtimeAgentOrchestrationByPaneKey: {}, + migrationUnsupportedByPtyId: { + legacy: { + ptyId: 'legacy', + paneKey, + worktreeId: 'repo::/wt-1', + tabId: 'tab-1', + reason: 'legacy-numeric-pane-key', + source: 'local', + updatedAt: 1_000 + } + } + } + expect(selectWorktreeAgentActivitySummary(state, 'repo::/wt-1').hasPermission).toBe( + paneKey !== undefined && paneKey !== 'malformed' + ) + expect(selectWorktreeAgentActivitySummary(state, 'other').hasPermission).toBe(false) + } + }) + it('builds one cached agent summary index for multiple worktree lookups', () => { const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(2_000) const firstPaneKey = makePaneKey('tab-1', LEAF_ID) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index d07ed32bc39..c09caeb7fab 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -1,3 +1,8 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' +import { + selectVisibleAgentOrchestration, + type VisibleOrchestrationState +} from '@/runtime/visible-agent-orchestration' import type { AppState } from '@/store' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import { migrationUnsupportedToAgentStatusEntry } from '../../../../shared/migration-unsupported-agent-entry' @@ -56,10 +61,11 @@ export type AgentActivityInput = Pick< | 'agentStatusByPaneKey' | 'migrationUnsupportedByPtyId' | 'retainedAgentsByPaneKey' -> & { - tabsByWorktree: AgentActivityTabsByWorktree - runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] -} +> & + VisibleOrchestrationState & { + tabsByWorktree: AgentActivityTabsByWorktree + runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] + } type AgentActivityCache = { tabsByWorktree: AgentActivityTabsByWorktree @@ -82,7 +88,7 @@ export function selectWorktreeAgentActivitySummary( function getWorktreeAgentActivitySummaries( state: AgentActivityInput ): Map { - const runtimeAgentOrchestrationByPaneKey = state.runtimeAgentOrchestrationByPaneKey + const runtimeAgentOrchestrationByPaneKey = selectVisibleAgentOrchestration(state) if ( agentActivityCache && agentActivityCache.tabsByWorktree === state.tabsByWorktree && @@ -114,7 +120,12 @@ function getWorktreeAgentActivitySummaries( const now = Date.now() for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { - const paneIdentity = parseAgentStatusPaneIdentity(paneKey) + const paneIdentity = parseAgentStatusPaneIdentity( + paneKey, + state.unifiedTabsByWorktree + ? getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree).get(paneKey) + : undefined + ) if (!paneIdentity) { continue } diff --git a/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts b/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts index df4f21cfd12..6bc041ea477 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-live-index-patch.ts @@ -1,7 +1,10 @@ -import { resolveAgentStatusWorktreeId } from '../../../../shared/agent-status-worktree-attribution' +import type { Tab } from '../../../../shared/tab-types' +import { + parseAgentStatusPaneIdentity, + resolveAgentStatusWorktreeId +} from '../../../../shared/agent-status-worktree-attribution' import type { AppState } from '@/store/types' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' export type LiveEntriesByWorktreeCache = { @@ -24,9 +27,10 @@ export function recordLiveEntriesFullRebuild(): void { export function liveEntryWorktreeId( paneKey: string, entry: AgentStatusEntry, - tabIdToWorktreeId: Map + tabIdToWorktreeId: Map, + representedTabs?: ReadonlyMap ): string | undefined { - const parsed = parsePaneKey(paneKey) + const parsed = parseAgentStatusPaneIdentity(paneKey, representedTabs?.get(paneKey)) if (!parsed) { return undefined } @@ -42,7 +46,8 @@ export function liveEntryWorktreeId( export function patchLiveEntriesByWorktree( cache: LiveEntriesByWorktreeCache, agentStatusByPaneKey: AppState['agentStatusByPaneKey'], - tabIdToWorktreeId: Map + tabIdToWorktreeId: Map, + representedTabs?: ReadonlyMap ): Map | null { const previousMap = cache.agentStatusByPaneKey const changed: { paneKey: string; entry: AgentStatusEntry }[] = [] @@ -76,7 +81,7 @@ export function patchLiveEntriesByWorktree( const entriesByWorktree = new Map(cache.entriesByWorktree) const clonedBuckets = new Set() for (const { paneKey, entry } of changed) { - const worktreeId = liveEntryWorktreeId(paneKey, entry, tabIdToWorktreeId) + const worktreeId = liveEntryWorktreeId(paneKey, entry, tabIdToWorktreeId, representedTabs) if (!worktreeId) { continue } diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts index 41bfb22e804..6dbdbf3e932 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.ts @@ -1,3 +1,8 @@ +import { + selectVisibleAgentOrchestration, + representedOrchestrationWorktrees, + type VisibleOrchestrationState +} from '@/runtime/visible-agent-orchestration' import type { AppState } from '@/store/types' import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' @@ -8,7 +13,8 @@ type OrchestrationIndexState = Pick< | 'retainedAgentsByPaneKey' | 'runtimeAgentOrchestrationByPaneKey' | 'tabsByWorktree' -> +> & + VisibleOrchestrationState type RuntimeOrchestrationRecord = Record @@ -26,6 +32,7 @@ type PaneWorktreeProjectionCache = { runtimeSource: OrchestrationIndexState['runtimeAgentOrchestrationByPaneKey'] liveSource: OrchestrationIndexState['agentStatusByPaneKey'] retainedSource: OrchestrationIndexState['retainedAgentsByPaneKey'] + representedSource: OrchestrationIndexState['unifiedTabsByWorktree'] paneWorktreeIds: readonly (string | undefined)[] } @@ -76,27 +83,37 @@ function projectPaneWorktreeIds( runtimeSource: OrchestrationIndexState['runtimeAgentOrchestrationByPaneKey'], runtimeEntries: readonly [string, AgentStatusOrchestrationContext][], agentStatusByPaneKey: OrchestrationIndexState['agentStatusByPaneKey'], - retainedAgentsByPaneKey: OrchestrationIndexState['retainedAgentsByPaneKey'] + retainedAgentsByPaneKey: OrchestrationIndexState['retainedAgentsByPaneKey'], + state: OrchestrationIndexState ): readonly (string | undefined)[] { // Cache by map identity so all cards share one attribution scan per publication. if ( paneWorktreeProjectionCache?.runtimeSource === runtimeSource && paneWorktreeProjectionCache.liveSource === agentStatusByPaneKey && - paneWorktreeProjectionCache.retainedSource === retainedAgentsByPaneKey + paneWorktreeProjectionCache.retainedSource === retainedAgentsByPaneKey && + paneWorktreeProjectionCache.representedSource === state.unifiedTabsByWorktree ) { return paneWorktreeProjectionCache.paneWorktreeIds } const paneWorktreeIds: (string | undefined)[] = [] - for (const [paneKey] of runtimeEntries) { + const represented = state.unifiedTabsByWorktree + ? representedOrchestrationWorktrees(state) + : new Map() + for (const [paneKey, context] of runtimeEntries) { paneWorktreeIds.push( agentStatusByPaneKey[paneKey]?.worktreeId, - retainedAgentsByPaneKey[paneKey]?.worktreeId + retainedAgentsByPaneKey[paneKey]?.worktreeId, + represented.get(paneKey), + represented.size > 0 && context.parentPaneKey + ? represented.get(context.parentPaneKey) + : undefined ) } paneWorktreeProjectionCache = { runtimeSource, liveSource: agentStatusByPaneKey, retainedSource: retainedAgentsByPaneKey, + representedSource: state.unifiedTabsByWorktree, paneWorktreeIds } return paneWorktreeIds @@ -195,7 +212,15 @@ function buildIndex( } const liveWorktreeId = paneWorktreeIds[projectionCursor] const retainedWorktreeId = paneWorktreeIds[projectionCursor + 1] - projectionCursor += 2 + const representedWorktreeId = paneWorktreeIds[projectionCursor + 2] + const parentWorktreeId = paneWorktreeIds[projectionCursor + 3] + if (representedWorktreeId) { + targets.add(representedWorktreeId) + } + if (parentWorktreeId) { + targets.add(parentWorktreeId) + } + projectionCursor += 4 if (typeof liveWorktreeId === 'string') { targets.add(liveWorktreeId) } @@ -227,8 +252,7 @@ function buildIndex( export function selectWorktreeAgentOrchestrationIndex( state: OrchestrationIndexState ): ReadonlyMap { - const runtimeAgentOrchestrationByPaneKey = - state.runtimeAgentOrchestrationByPaneKey ?? EMPTY_SOURCE + const runtimeAgentOrchestrationByPaneKey = selectVisibleAgentOrchestration(state) // Cache context entries separately so unrelated live/retained churn cannot force enumeration. if (runtimeEntriesCache?.source !== runtimeAgentOrchestrationByPaneKey) { runtimeEntriesCache = { @@ -251,7 +275,8 @@ export function selectWorktreeAgentOrchestrationIndex( runtimeAgentOrchestrationByPaneKey, runtimeEntries, state.agentStatusByPaneKey ?? EMPTY_SOURCE, - state.retainedAgentsByPaneKey ?? EMPTY_SOURCE + state.retainedAgentsByPaneKey ?? EMPTY_SOURCE, + state ) if ( orchestrationIndexCache?.runtimeSource === runtimeAgentOrchestrationByPaneKey && diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts index 18ec88e6208..72237e08cba 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-fallback-tab.ts @@ -1,5 +1,6 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { parseAgentStatusPaneIdentity } from '../../../../shared/agent-status-worktree-attribution' +import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): number { @@ -8,9 +9,10 @@ export function effectiveWorktreeAgentRowStartedAt(entry: AgentStatusEntry): num export function tabFromWorktreeAttributedStatusEntry( entry: AgentStatusEntry, - effectiveStartedAt: number + effectiveStartedAt: number, + representedTab?: Tab ): TerminalTab | null { - const parsed = parsePaneKey(entry.paneKey) + const parsed = parseAgentStatusPaneIdentity(entry.paneKey, representedTab) if (!parsed || !entry.worktreeId) { return null } diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts b/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts index abf5df6c5dc..f959da82fb1 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-orchestration.ts @@ -3,21 +3,7 @@ import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' -function orchestrationContextsEqual( - a: AgentStatusOrchestrationContext, - b: AgentStatusOrchestrationContext -): boolean { - return ( - a.taskId === b.taskId && - a.dispatchId === b.dispatchId && - a.taskTitle === b.taskTitle && - a.displayName === b.displayName && - a.parentTerminalHandle === b.parentTerminalHandle && - a.parentPaneKey === b.parentPaneKey && - a.coordinatorHandle === b.coordinatorHandle && - a.orchestrationRunId === b.orchestrationRunId - ) -} +import { orchestrationContextsEqual } from '@/store/slices/agent-status/agent-status-orchestration-context' export function entryWithRuntimeOrchestration( entry: AgentStatusEntry, diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 7828bc62464..94938dfaf84 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -1,3 +1,4 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract' import type { AppState } from '@/store/types' import type { @@ -129,6 +130,9 @@ function getLiveEntriesByWorktree(state: WorktreeAgentRowsState): Map() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - const worktreeId = liveEntryWorktreeId(paneKey, entry, tabIdToWorktreeId) + const worktreeId = liveEntryWorktreeId(paneKey, entry, tabIdToWorktreeId, represented) if (!worktreeId) { continue } diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 6f46eb6e06f..31977d6b14a 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -1,3 +1,5 @@ +import type { Tab } from '../../../../shared/tab-types' +import { parseAgentStatusPaneIdentity } from '../../../../shared/agent-status-worktree-attribution' import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract' @@ -138,6 +140,7 @@ function markCompletedWorkerParentPaneKeysSeen(args: { export function buildWorktreeAgentRows(args: { tabs: TerminalTab[] + representedTabs?: ReadonlyMap entries: AgentStatusEntry[] retained: RetainedAgentEntry[] runtimePaneTitlesByTabId?: Record> @@ -153,7 +156,10 @@ export function buildWorktreeAgentRows(args: { const entriesByTabId = new Map() for (const entry of args.entries) { - const parsed = parsePaneKey(entry.paneKey) + const parsed = parseAgentStatusPaneIdentity( + entry.paneKey, + args.representedTabs?.get(entry.paneKey) + ) if (!parsed) { continue } @@ -211,7 +217,11 @@ export function buildWorktreeAgentRows(args: { } const rowEntry = entryWithRuntimeOrchestration(entry, args.runtimeAgentOrchestrationByPaneKey) const startedAt = effectiveWorktreeAgentRowStartedAt(rowEntry) - const tab = tabFromWorktreeAttributedStatusEntry(rowEntry, startedAt) + const tab = tabFromWorktreeAttributedStatusEntry( + rowEntry, + startedAt, + args.representedTabs?.get(rowEntry.paneKey) + ) if (!tab) { continue } diff --git a/src/renderer/src/runtime/structured-agent-session-owner.ts b/src/renderer/src/runtime/structured-agent-session-owner.ts index 4fac831d58c..353bcbfbb5d 100644 --- a/src/renderer/src/runtime/structured-agent-session-owner.ts +++ b/src/renderer/src/runtime/structured-agent-session-owner.ts @@ -1,3 +1,8 @@ +import { + selectDetectedWorktreesByRepo, + selectFolderWorkspaces, + selectWorktreesByRepo +} from '@/store/workspace-record-selectors' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId, @@ -7,12 +12,6 @@ import { import type { Tab } from '../../../shared/tab-types' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' -import type { AppState } from '@/store/types' -import { - selectDetectedWorktreesByRepo, - selectFolderWorkspaces, - selectWorktreesByRepo -} from '@/store/workspace-record-selectors' import { isTwinWorkspace, namedHostForOwnerRef, resolveOwner } from '@/lib/resolve-owner' import { authorityKey } from '../../../shared/host-authority' import { LOCAL_STRUCTURED_SESSION_OWNER } from './local-structured-session-owner' @@ -92,7 +91,9 @@ export function legacyStructuredChatRenameOwner( } /** Catalog inputs independent of the user's current workspace selection. */ -export function structuredAgentSessionCatalogOwnerInputs(state: AppState): unknown[] { +export function structuredAgentSessionCatalogOwnerInputs( + state: WorktreeRuntimeOwnerState +): unknown[] { return [ state.repos, selectWorktreesByRepo(state), @@ -105,7 +106,7 @@ export function structuredAgentSessionCatalogOwnerInputs(state: AppState): unkno } /** Inputs to the legacy workspace-owner resolver, compared before scanning chat tabs. */ -export function structuredAgentSessionOwnerInputs(state: AppState): unknown[] { +export function structuredAgentSessionOwnerInputs(state: WorktreeRuntimeOwnerState): unknown[] { return [ ...structuredAgentSessionCatalogOwnerInputs(state), state.settings, diff --git a/src/renderer/src/runtime/visible-agent-orchestration.ts b/src/renderer/src/runtime/visible-agent-orchestration.ts new file mode 100644 index 00000000000..f1a0b634a71 --- /dev/null +++ b/src/renderer/src/runtime/visible-agent-orchestration.ts @@ -0,0 +1,284 @@ +import type { AppState } from '@/store/types' +import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state' +import { getStructuredAgentSessionTabs } from '@/components/native-chat/structured-agent-session-tabs' +import { LOCAL_EXECUTION_HOST_ID, toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { + isRemoteRuntimePtyId, + parseRemoteRuntimePtyId +} from '../../../shared/remote-runtime/remote-runtime-pty-id' +import { parseAgentStatusPaneIdentity } from '../../../shared/agent-status-worktree-attribution' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../shared/structured-agent-session-projection' +import type { AgentStatusOrchestrationContext } from '../../../shared/agent-status-types' +import { orchestrationContextsEqual } from '@/store/slices/agent-status/agent-status-orchestration-context' +import { + resolveStructuredAgentSessionOwner, + structuredAgentSessionOwnerForTab, + structuredAgentSessionOwnerInputs +} from './structured-agent-session-owner' + +type Contexts = Record +export type VisibleOrchestrationState = WorktreeRuntimeOwnerState & + Partial< + Pick< + AppState, + | 'unifiedTabsByWorktree' + | 'agentStatusByPaneKey' + | 'retainedAgentsByPaneKey' + | 'runtimeAgentOrchestrationByPaneKey' + | 'ptyIdsByTabId' + > + > & { + tabsByWorktree?: Record + } +const EMPTY: Record = {} +const producerByContext = new WeakMap() +export function orchestrationProducer(context: AgentStatusOrchestrationContext): string { + return producerByContext.get(context) ?? LOCAL_EXECUTION_HOST_ID +} +export function recordOrchestrationProducer( + context: AgentStatusOrchestrationContext, + host: string +): void { + producerByContext.set(context, host) +} +type RowIdentities = { + inputs: unknown[] + aliases: Map + canonical: Map + worktrees: Map +} +let rowCache: RowIdentities | undefined +let cache: { inputs: unknown[]; contexts: Contexts } | undefined +let builds = 0 +let aliasBuilds = 0 +let visits = 0 +export function visibleOrchestrationWorkForTest(): { + builds: number + aliasBuilds: number + visits: number +} { + return { builds, aliasBuilds, visits } +} +const aliasKey = (host: string, key: string) => `${host}\0${key}` + +function rowIdentities(state: VisibleOrchestrationState): RowIdentities { + const inputs = [state.unifiedTabsByWorktree, ...structuredAgentSessionOwnerInputs(state)] + if (rowCache && inputs.every((value, index) => value === rowCache?.inputs[index])) { + return rowCache + } + aliasBuilds++ + const aliases = new Map() + const worktrees = new Map() + const canonical = new Map() + for (const tab of getStructuredAgentSessionTabs(state.unifiedTabsByWorktree ?? EMPTY)) { + visits++ + if (!tab.entityId) { + continue + } + const key = structuredAgentSessionPaneKey( + structuredAgentSessionTabId(tab.entityId), + tab.entityId + ) + const actual = structuredAgentSessionPaneKey(tab.id, tab.entityId) + const host = structuredAgentSessionOwnerForTab(state, tab) + canonical.set(actual, { key, host }) + worktrees.set(actual, tab.worktreeId) + if (host && !aliases.has(aliasKey(host, key))) { + aliases.set(aliasKey(host, key), actual) + } + } + rowCache = { inputs, aliases, canonical, worktrees } + return rowCache +} + +let terminalCache: + | { inputs: unknown[]; hosts: Map; ptys: Map } + | undefined +function terminalProvenance(state: VisibleOrchestrationState) { + const inputs = [ + state.unifiedTabsByWorktree, + state.tabsByWorktree, + state.ptyIdsByTabId, + state.retainedAgentsByPaneKey + ] + if (terminalCache && inputs.every((value, index) => value === terminalCache?.inputs[index])) { + return terminalCache + } + const hosts = new Map() + const ptys = new Map() + const merge = (map: Map, key: string, host: string | null) => { + map.set(key, map.has(key) && map.get(key) !== host ? null : host) + } + const ptyHost = (id?: string | null): string | null | undefined => { + if (!id) { + return undefined + } + const remote = parseRemoteRuntimePtyId(id) + return remote?.environmentId + ? toRuntimeExecutionHostId(remote.environmentId) + : isRemoteRuntimePtyId(id) + ? null + : undefined + } + for (const [tab, ids] of Object.entries(state.ptyIdsByTabId ?? EMPTY)) { + for (const id of ids) { + visits++ + const host = ptyHost(id) + if (host !== undefined) { + merge(ptys, tab, host) + } + } + } + for (const tabs of Object.values(state.unifiedTabsByWorktree ?? EMPTY)) { + for (const tab of tabs) { + visits++ + if (tab.contentType === 'terminal' && tab.executionHostId) { + merge(hosts, aliasKey(tab.worktreeId, tab.entityId), tab.executionHostId) + } + } + } + for (const [worktree, tabs] of Object.entries(state.tabsByWorktree ?? EMPTY)) { + for (const tab of tabs) { + visits++ + const host = ptyHost(tab.ptyId) + if (host !== undefined) { + merge(hosts, aliasKey(worktree, tab.id), host) + } + } + } + for (const retained of Object.values(state.retainedAgentsByPaneKey ?? EMPTY)) { + visits++ + const host = ptyHost(retained.tab.ptyId) + if (host !== undefined) { + merge(hosts, aliasKey(retained.worktreeId, retained.tab.id), host) + } + } + terminalCache = { inputs, hosts, ptys } + return terminalCache +} + +function projection(state: VisibleOrchestrationState): NonNullable { + const rows = rowIdentities(state) + const terminals = terminalProvenance(state) + const inputs = [ + state.runtimeAgentOrchestrationByPaneKey, + state.agentStatusByPaneKey, + state.retainedAgentsByPaneKey, + terminals, + rows + ] + if (cache && inputs.every((value, index) => value === cache?.inputs[index])) { + return cache + } + builds++ + const { aliases, canonical } = rows + const contexts: Contexts = Object.fromEntries([]) + const visibleKey = (key: string, host: string | null) => { + if (!host) { + return canonical.has(key) ? aliasKey('unknown', key) : key + } + const alias = aliases.get(aliasKey(host, key)) + // An identically spelled row from another runtime cannot satisfy this relationship. + return ( + alias ?? (canonical.has(key) && canonical.get(key)?.host !== host ? aliasKey(host, key) : key) + ) + } + const adapt = (key: string, context: AgentStatusOrchestrationContext, host: string | null) => { + visits++ + const actual = visibleKey(key, host) + const parent = context.parentPaneKey ? visibleKey(context.parentPaneKey, host) : undefined + const next = parent === context.parentPaneKey ? context : { ...context, parentPaneKey: parent } + const previous = cache?.contexts[actual] + contexts[actual] = + next !== context && previous && orchestrationContextsEqual(previous, next) ? previous : next + } + for (const [key, context] of Object.entries(state.runtimeAgentOrchestrationByPaneKey ?? EMPTY)) { + adapt(key, context, producerByContext.get(context) ?? LOCAL_EXECUTION_HOST_ID) + } + const embedded = ( + entry: AppState['agentStatusByPaneKey'][string], + worktreeId = entry.worktreeId + ) => { + visits++ + if (!entry?.orchestration) { + return + } + const row = canonical.get(entry.paneKey) + const runtime = state.runtimeAgentOrchestrationByPaneKey?.[row?.key ?? entry.paneKey] + if ( + runtime && + (!row || row.host === orchestrationProducer(runtime)) && + runtime.taskId === entry.orchestration.taskId && + runtime.dispatchId === entry.orchestration.dispatchId + ) { + // Matching graph facts already carry the context's producer, independently of execution routing. + return + } + const tabId = parseAgentStatusPaneIdentity(entry.paneKey)?.tabId + const terminalKey = worktreeId && tabId ? aliasKey(worktreeId, tabId) : null + const tabHost = terminalKey ? terminals.hosts.get(terminalKey) : undefined + const ptyHost = tabId ? terminals.ptys.get(tabId) : undefined + const terminalHost = + tabHost === undefined + ? ptyHost + : ptyHost === undefined || tabHost === ptyHost + ? tabHost + : null + const host = row + ? row.host + : terminalHost !== undefined + ? terminalHost + : !entry.connectionId && worktreeId + ? resolveStructuredAgentSessionOwner(state, worktreeId) + : null + if ( + entry.orchestration.parentPaneKey && + visibleKey(entry.orchestration.parentPaneKey, host) !== entry.orchestration.parentPaneKey + ) { + adapt(entry.paneKey, entry.orchestration, host) + } + } + for (const entry of Object.values(state.agentStatusByPaneKey ?? EMPTY)) { + embedded(entry) + } + for (const retained of Object.values(state.retainedAgentsByPaneKey ?? EMPTY)) { + embedded(retained.entry, retained.worktreeId) + } + const previous = cache?.contexts + const equal = + previous && + Object.keys(previous).length === Object.keys(contexts).length && + Object.keys(contexts).every((key) => previous[key] === contexts[key]) + cache = { inputs, contexts: equal ? previous : contexts } + return cache +} + +/** Canonical contexts stay in storage; actual rows and parent aliases are a shared read-only view. */ +export function selectVisibleAgentOrchestration(state: VisibleOrchestrationState): Contexts { + if (!state.unifiedTabsByWorktree) { + return state.runtimeAgentOrchestrationByPaneKey ?? EMPTY + } + return projection(state).contexts +} + +/** The same row identity joins graph enrichment and first status paint, without translating stored parents. */ +export function canonicalOrchestrationForRow( + state: VisibleOrchestrationState, + paneKey: string +): AgentStatusOrchestrationContext | undefined { + const row = rowIdentities(state).canonical.get(paneKey) + const context = state.runtimeAgentOrchestrationByPaneKey?.[row?.key ?? paneKey] + return context && + (!row || row.host === (producerByContext.get(context) ?? LOCAL_EXECUTION_HOST_ID)) + ? context + : undefined +} + +export function representedOrchestrationWorktrees( + state: VisibleOrchestrationState +): ReadonlyMap { + return rowIdentities(state).worktrees +} diff --git a/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts b/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts index dd4ee18d24a..a14709b6ba5 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-live-entry-builder.ts @@ -1,3 +1,4 @@ +import { canonicalOrchestrationForRow } from '@/runtime/visible-agent-orchestration' import { sameAgentStatusTerminalOwner } from '../../../../../shared/agent-status-terminal-owner' import type { AppState } from '../../types' import { resolveAgentStatusLiveEntryMainAgent } from './agent-status-live-entry-main-agent' @@ -141,7 +142,7 @@ export function buildAgentStatusLiveEntry( ) { return { entry: null, reason: 'suppressed-inherited-terminal' } } - const runtimeOrchestration = state.runtimeAgentOrchestrationByPaneKey[paneKey] + const runtimeOrchestration = canonicalOrchestrationForRow(state, paneKey) const runtimeMergedOrchestration = runtimeOrchestration ? mergeCurrentOrchestrationContext(existing?.orchestration, runtimeOrchestration) : undefined diff --git a/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts index 4f6def6ecbc..44060095a2b 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-orchestration-actions.ts @@ -1,3 +1,9 @@ +import { + canonicalOrchestrationForRow, + recordOrchestrationProducer, + orchestrationProducer +} from '@/runtime/visible-agent-orchestration' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host' import type { AgentStatusEntry, AgentStatusOrchestrationContext @@ -10,7 +16,7 @@ import { } from '../../../../../shared/agent-row-primary-text' import { mergeCurrentOrchestrationContext, - orchestrationMapsEqual + orchestrationContextsEqual } from './agent-status-orchestration-context' export function createAgentStatusOrchestrationActions( @@ -19,20 +25,42 @@ export function createAgentStatusOrchestrationActions( const { get, set } = runtime return { setRuntimeAgentOrchestrationByPaneKey: ( - entries: Record + entries: Record, + producerHost = LOCAL_EXECUTION_HOST_ID ) => { const generatedTitleUpdates: AgentStatusEntry[] = [] set((s, changedPaneKeys) => { - const runtimeMapChanged = !orchestrationMapsEqual( - s.runtimeAgentOrchestrationByPaneKey, - entries - ) + const previous = s.runtimeAgentOrchestrationByPaneKey + const reconciled: Record = Object.fromEntries([]) + let runtimeMapChanged = Object.keys(previous).length !== Object.keys(entries).length + for (const [key, context] of Object.entries(entries)) { + const old = previous[key] + const value = + old && + orchestrationProducer(old) === producerHost && + orchestrationContextsEqual(old, context) + ? old + : context + reconciled[key] = value + recordOrchestrationProducer(value, producerHost) + if (value !== old) { + runtimeMapChanged = true + } + } + const canonicalState = { + ...s, + runtimeAgentOrchestrationByPaneKey: runtimeMapChanged ? reconciled : previous + } let nextLive = s.agentStatusByPaneKey let liveChanged = false let nextRetained = s.retainedAgentsByPaneKey let retainedChanged = false - for (const [paneKey, runtimeOrchestration] of Object.entries(entries)) { + for (const paneKey of new Set([...Object.keys(nextLive), ...Object.keys(nextRetained)])) { + const runtimeOrchestration = canonicalOrchestrationForRow(canonicalState, paneKey) + if (!runtimeOrchestration) { + continue + } const liveEntry = nextLive[paneKey] if (liveEntry) { const merged = mergeCurrentOrchestrationContext( @@ -84,7 +112,7 @@ export function createAgentStatusOrchestrationActions( } return { - ...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: entries } : {}), + ...(runtimeMapChanged ? { runtimeAgentOrchestrationByPaneKey: reconciled } : {}), ...(liveChanged ? { agentStatusByPaneKey: nextLive } : {}), ...(retainedChanged ? { retainedAgentsByPaneKey: nextRetained } : {}), ...(liveChanged ? { agentStatusEpoch: s.agentStatusEpoch + 1 } : {}) diff --git a/src/renderer/src/store/slices/agent-status/agent-status-retention-actions.ts b/src/renderer/src/store/slices/agent-status/agent-status-retention-actions.ts index 737a7231551..d726f48bba4 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-retention-actions.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-retention-actions.ts @@ -1,3 +1,4 @@ +import { canonicalOrchestrationForRow } from '@/runtime/visible-agent-orchestration' import type { RetainedAgentEntry } from './agent-status-contract' import type { AgentStatusSlice } from './agent-status-slice-contract' import type { AgentStatusRuntime } from './agent-status-runtime' @@ -34,7 +35,7 @@ export function createAgentStatusRetentionActions( } const next = { ...s.retainedAgentsByPaneKey } for (const retained of entries) { - const runtimeOrchestration = s.runtimeAgentOrchestrationByPaneKey[retained.entry.paneKey] + const runtimeOrchestration = canonicalOrchestrationForRow(s, retained.entry.paneKey) const mergedOrchestration = runtimeOrchestration ? mergeCurrentOrchestrationContext(retained.entry.orchestration, runtimeOrchestration) : retained.entry.orchestration diff --git a/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts b/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts index b5b3b8e6697..13c1787564d 100644 --- a/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts +++ b/src/renderer/src/store/slices/agent-status/agent-status-slice-contract.ts @@ -121,7 +121,8 @@ export type AgentStatusSlice = { clearAgentLaunchConfig: (paneKey: string) => void setRuntimeAgentOrchestrationByPaneKey: ( - entries: Record + entries: Record, + producerHost?: string ) => void setMigrationUnsupportedPty: (entry: MigrationUnsupportedPtyEntry) => void diff --git a/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts b/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts index 0895cc26aa8..7bd68312de5 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-agent-evidence.ts @@ -1,3 +1,4 @@ +import { getStructuredAgentSessionTabsByPaneKey } from '@/components/native-chat/structured-agent-session-tabs' import type { AppState } from '../types' import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' import { parsePaneKey } from '../../../../shared/stable-pane-id' @@ -68,6 +69,9 @@ export function getWorkspaceCleanupAgentEvidence( for (const [worktreeId, worktreeCandidates] of candidatesByWorktreeId) { const rows = buildWorktreeAgentRows({ tabs: state.tabsByWorktree[worktreeId] ?? [], + representedTabs: state.unifiedTabsByWorktree + ? getStructuredAgentSessionTabsByPaneKey(state.unifiedTabsByWorktree) + : undefined, entries: selectLiveAgentStatusEntriesForWorktree(state, worktreeId), retained: selectRetainedAgentEntriesForWorktree(state, worktreeId), runtimePaneTitlesByTabId: state.runtimePaneTitlesByTabId, diff --git a/src/shared/agent-status-worktree-attribution.ts b/src/shared/agent-status-worktree-attribution.ts index 06369550faa..8bb57311873 100644 --- a/src/shared/agent-status-worktree-attribution.ts +++ b/src/shared/agent-status-worktree-attribution.ts @@ -1,3 +1,5 @@ +import type { Tab } from './tab-types' +import { structuredAgentSessionPaneKey } from './structured-agent-session-projection' import type { AgentStatusEntry, AgentStatusOrchestrationContext } from './agent-status-types' import { isWebTerminalSurfaceTabId } from './terminal-surface-id' import { parseLegacyNumericPaneKey, parsePaneKey } from './stable-pane-id' @@ -5,11 +7,18 @@ import { parseLegacyNumericPaneKey, parsePaneKey } from './stable-pane-id' export type AgentStatusPaneIdentity = { tabId: string; paneId: string } export function parseAgentStatusPaneIdentity( - paneKey: string | undefined + paneKey: string | undefined, + representedTab?: Pick ): AgentStatusPaneIdentity | null { if (!paneKey) { return null } + if ( + representedTab?.contentType === 'agent-session' && + structuredAgentSessionPaneKey(representedTab.id, representedTab.entityId) === paneKey + ) { + return { tabId: representedTab.id, paneId: paneKey.slice(representedTab.id.length + 1) } + } const parsed = parsePaneKey(paneKey) if (parsed) { return { tabId: parsed.tabId, paneId: parsed.leafId } diff --git a/src/shared/runtime-agent-lineage-consumer.test-fixture.ts b/src/shared/runtime-agent-lineage-consumer.test-fixture.ts new file mode 100644 index 00000000000..ef7febdedde --- /dev/null +++ b/src/shared/runtime-agent-lineage-consumer.test-fixture.ts @@ -0,0 +1,35 @@ +import type { AgentStatusOrchestrationContext } from './agent-status-types' +import type { OrcaSessionId } from './orca-session-address' +import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import type { RuntimeWorktreePsSummary } from './runtime-types' + +// Keep desktop-only imports in the native fixture; its export checks this consumer contract. +export type RuntimeAgentLineageTestModule = { + LINEAGE_PARENT: OrcaSessionId + LINEAGE_CHILD: OrcaSessionId + canonicalChatRow(id: string): string + publicLineageFixture(worktreeId?: string): { + db: { + db: { prepare(sql: string): { run(...values: string[]): unknown } } + close(): void + } + projection: { + buildByPaneKey(): Record | undefined + } + build(): RuntimeWorktreePsSummary + privateHandle: string + privatePane: string + run: { id: string } + } +} + +export type RuntimeAgentLineageCatalogTestModule = { + createLineageCatalogDispatcher(build: () => RuntimeWorktreePsSummary): { + dispatch(request: { + id: string + authToken: string + method: string + params?: Record + }): Promise> + } +}