From cbb21bd2a89d8a3cdd86441cbe9a5795899e0b36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:35:30 -0800 Subject: [PATCH 001/106] Stop rebuilding unused dashboard launch choices (#40666) * perf(dashboard): count launch workspaces during enumeration * perf: remove unused dashboard launch projection * fix: scope history catalog retries to changed hosts * test: use complete history resume fixtures and prune removed baseline * test: complete history preview timestamp fixture --- config/workspace-record-read-baseline.txt | 1 - .../dashboard/build-dashboard-snapshot.ts | 18 --- .../dashboard-chat-program-acquisition.ts | 41 ----- .../dashboard-worktree-launch-options.ts | 151 ------------------ .../dashboard/useDashboardPopoutBridge.ts | 25 +-- .../dashboard/useLiveDashboardSnapshot.ts | 40 +---- .../ai-vault/panel/AiVaultPanel.tsx | 2 + .../use-ai-vault-resume-catalogs.test.tsx | 148 +++++++++++++++++ .../use-ai-vault-resume-catalogs.ts | 89 +++++++++++ 9 files changed, 250 insertions(+), 265 deletions(-) delete mode 100644 src/renderer/src/components/dashboard/dashboard-chat-program-acquisition.ts delete mode 100644 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.ts diff --git a/config/workspace-record-read-baseline.txt b/config/workspace-record-read-baseline.txt index 8734401d303..418a6c1e1ad 100644 --- a/config/workspace-record-read-baseline.txt +++ b/config/workspace-record-read-baseline.txt @@ -8,7 +8,6 @@ 4 src/renderer/src/components/dashboard/build-dashboard-bucket-counts.ts # P5b-2 must clear: optional state fields; memo compare goes stale when absent 3 src/renderer/src/components/dashboard/dashboard-card-terminal-input.ts # P5b-2 must clear: optional state fields default to empty 1 src/renderer/src/components/dashboard/dashboard-snapshot-workspaces.ts # P5b-2 must clear: optional state field defaults to empty -1 src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts # P5b-2 must clear: optional state field defaults to empty 4 src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts # P5b-2 must clear: change detection on optional fields never fires when absent 2 src/renderer/src/components/dashboard/useRetainedAgents.ts # reads a passed-in copy 1 src/renderer/src/components/editor/markdown/documents/markdown-document-worktree-path-selector.ts # P5b-2 must clear: optional state field diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts index bd8c099b15d..3bcaa52e3a1 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.ts @@ -1,6 +1,5 @@ import { agentVerdictDisplayMark } from '../../../../shared/agent-status/lifecycle/agent-main-agent-verdict' import type { StructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-tab-name' -import type { AgentLaunchRouteView } from '@/lib/agent-session/launch/routing/agent-launch-route-input' import type { AppState } from '@/store/types' import { DASHBOARD_MAX_MAP_WORKSPACES, @@ -42,10 +41,6 @@ import { rowConversationName, rowTask } from './dashboard-card-labels' -import { - buildDashboardWorktreeLaunchOptions, - type DashboardLaunchDetectionState -} from './dashboard-worktree-launch-options' import { buildDashboardSnapshotFilterOptions } from './dashboard-snapshot-filter-options' import { groupSubagentsByParentPaneKey } from './dashboard-subagent-cards' import { selectDashboardOrchestration } from './dashboard-orchestration-selection' @@ -71,7 +66,6 @@ export type DashboardSnapshotState = Pick< DashboardCardContextState & Partial< DashboardCardTerminalInputState & - DashboardLaunchDetectionState & Pick< AppState, | 'runtimeEnvironments' @@ -89,7 +83,6 @@ export function buildDashboardSnapshot( state: DashboardSnapshotInput, now: number, options: { - launchRouteStore?: AgentLaunchRouteView includeCardDetails?: boolean includeFilterOptions?: boolean /** Cache only row derivation; review/host/status inputs still require fresh card assembly. */ @@ -274,17 +267,6 @@ export function buildDashboardSnapshot( ...(workspaces ? { workspaces } : {}), showIdle, filterOptions, - // Count-only callers offer no launcher; skip host-detection lookups for sidebar rebuilds. - ...(includeCardDetails - ? { - launchableAgentsByWorktreeId: buildDashboardWorktreeLaunchOptions( - state, - cards, - workspaces, - options.launchRouteStore - ) - } - : {}), repoIconsByRepoId } } diff --git a/src/renderer/src/components/dashboard/dashboard-chat-program-acquisition.ts b/src/renderer/src/components/dashboard/dashboard-chat-program-acquisition.ts deleted file mode 100644 index a6f29976a82..00000000000 --- a/src/renderer/src/components/dashboard/dashboard-chat-program-acquisition.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AppState } from '@/store/types' -import { holdChatProgramFacts } from '@/lib/agent-session/launch/inventory/host-chat-program-acquisition' -import { resolveChatProgramContext } from '@/lib/agent-session/launch/inventory/host-chat-program' -import { - selectAgentLaunchRouteView, - worktreeLaunchWorkspace -} from '@/lib/agent-session/launch/routing/agent-launch-route-input' -import { DASHBOARD_MAX_LAUNCH_WORKTREES } from '../../../../shared/dashboard-snapshot' -import { collectActiveDashboardWorkspaces } from './dashboard-snapshot-workspaces' - -/** Publishers reconcile one hold per represented execution resource before building payloads. */ -export function createDashboardChatProgramHolds() { - const held = new Map void>() - return { - reconcile(state: AppState): void { - const next = new Set() - const routeStore = selectAgentLaunchRouteView(state) - const shown = collectActiveDashboardWorkspaces(state, false) - for (const { worktree } of shown.slice(0, DASHBOARD_MAX_LAUNCH_WORKTREES)) { - const context = resolveChatProgramContext(routeStore, worktreeLaunchWorkspace(worktree.id)) - const key = context.revision - next.add(key) - if (!held.has(key)) { - // A new hold can notify synchronously and re-enter this reconcile: reserve its key first. - held.set(key, () => {}) - held.set(key, holdChatProgramFacts(context).release) - } - } - for (const [key, release] of held) { - if (!next.has(key)) { - release() - held.delete(key) - } - } - }, - release(): void { - held.forEach((release) => release()) - held.clear() - } - } -} diff --git a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts b/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts deleted file mode 100644 index 7e6e7dab48b..00000000000 --- a/src/renderer/src/components/dashboard/dashboard-worktree-launch-options.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { AppState } from '@/store/types' -import { parseExecutionHostId } from '../../../../shared/execution-host' -import { - DASHBOARD_MAX_LAUNCH_WORKTREES, - type DashboardCard, - type DashboardWorkspace -} from '../../../../shared/dashboard-snapshot' -import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config' -import { - filterEnabledTuiAgents, - TUI_AGENT_AUTO_PICK_ORDER -} from '../../../../shared/agent-launch/catalog/tui-agent-selection' -import type { TuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' -import { getRuntimeAgentInventoryKey } from '@/store/slices/agent-inventory/runtime-agent-inventory-key' -import { - worktreeLaunchWorkspace, - type AgentLaunchRouteView -} from '@/lib/agent-session/launch/routing/agent-launch-route-input' -import { agentsOfferedForLaunch } from '@/lib/agent-session/launch/inventory/host-chat-program' -import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' - -export type DashboardLaunchDetectionState = Pick< - AppState, - | 'detectedAgentIds' - | 'folderWorkspaces' - | 'projectGroups' - | 'remoteDetectedAgentIds' - | 'runtimeDetectedAgentIds' -> - -type DashboardLaunchOptionState = Pick & - Partial - -type DashboardLaunchCatalog = { - foldersById: Map - groupsById: Map - reposById: Map - worktreesByRepoAndId: Map> -} - -function buildDashboardLaunchCatalog(state: DashboardLaunchOptionState): DashboardLaunchCatalog { - return { - foldersById: new Map((state.folderWorkspaces ?? []).map((folder) => [folder.id, folder])), - groupsById: new Map((state.projectGroups ?? []).map((group) => [group.id, group])), - reposById: new Map((state.repos ?? []).map((repo) => [repo.id, repo])), - worktreesByRepoAndId: new Map( - Object.entries(selectWorktreesByRepo(state) ?? {}).map(([repoId, worktrees]) => [ - repoId, - new Map(worktrees.map((worktree) => [worktree.id, worktree])) - ]) - ) - } -} - -// Why the host-default fallback: a card can render before any launch surface probed its workspace. -function runtimeAgentsForWorkspace( - state: DashboardLaunchOptionState, - environmentId: string, - worktreeId: string -): readonly TuiAgent[] { - const agents = state.runtimeDetectedAgentIds - return ( - agents?.[getRuntimeAgentInventoryKey(environmentId, worktreeId)] ?? - agents?.[environmentId] ?? - [] - ) -} - -function detectedAgentsForWorktree( - state: DashboardLaunchOptionState, - worktreeId: string, - repoId: string, - catalog: DashboardLaunchCatalog -): readonly TuiAgent[] { - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - const folder = catalog.foldersById.get(workspaceScope.folderWorkspaceId) - const group = folder ? catalog.groupsById.get(folder.projectGroupId) : undefined - const host = parseExecutionHostId(group?.executionHostId) - if (host?.kind === 'runtime') { - return runtimeAgentsForWorkspace(state, host.environmentId, worktreeId) - } - const connectionId = folder?.connectionId ?? group?.connectionId - return connectionId ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) : [] - } - - const worktree = catalog.worktreesByRepoAndId.get(repoId)?.get(worktreeId) - const repo = catalog.reposById.get(worktree?.repoId ?? repoId) - const host = parseExecutionHostId(worktree?.hostId ?? repo?.executionHostId) - if (host?.kind === 'runtime') { - return runtimeAgentsForWorkspace(state, host.environmentId, worktreeId) - } - const connectionId = host?.kind === 'ssh' ? host.targetId : repo?.connectionId - return connectionId - ? (state.remoteDetectedAgentIds?.[connectionId] ?? []) - : (state.detectedAgentIds ?? []) -} - -/** Host-detected choices, agents that open as a chat there, and providers proven to run there. */ -export function buildDashboardWorktreeLaunchOptions( - state: DashboardLaunchOptionState, - cards: readonly DashboardCard[], - workspaces: readonly DashboardWorkspace[] = [], - launchRouteStore?: AgentLaunchRouteView -): Record { - const catalog = buildDashboardLaunchCatalog(state) - const cardsByWorktreeId = new Map() - const repoIdByWorktreeId = new Map( - workspaces.map((workspace) => [workspace.worktreeId, workspace.repoId]) - ) - for (const card of cards) { - repoIdByWorktreeId.set(card.worktreeId, card.repoId) - const existing = cardsByWorktreeId.get(card.worktreeId) - if (existing) { - existing.push(card) - } else { - cardsByWorktreeId.set(card.worktreeId, [card]) - } - } - - const result: Record = {} - for (const [worktreeId, repoId] of repoIdByWorktreeId) { - // Bound launch options before validation would reject the entire snapshot. - if (Object.keys(result).length >= DASHBOARD_MAX_LAUNCH_WORKTREES) { - break - } - const worktreeCards = cardsByWorktreeId.get(worktreeId) ?? [] - const detected = detectedAgentsForWorktree(state, worktreeId, repoId, catalog) - const available = new Set( - launchRouteStore - ? agentsOfferedForLaunch(launchRouteStore, worktreeLaunchWorkspace(worktreeId), detected) - : detected - ) - for (const card of worktreeCards) { - if (isTuiAgent(card.agentType)) { - available.add(card.agentType) - } - } - const enabled = filterEnabledTuiAgents( - TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => available.has(agent)), - state.settings?.disabledTuiAgents - ) - const preferred = state.settings?.defaultTuiAgent - result[worktreeId] = - preferred && preferred !== 'blank' && enabled.includes(preferred) - ? [preferred, ...enabled.filter((agent) => agent !== preferred)] - : enabled - } - return result -} diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 73539bed721..1bd53b4ff5a 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -1,4 +1,3 @@ -import { createDashboardChatProgramHolds } from './dashboard-chat-program-acquisition' import { useEffect } from 'react' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' @@ -6,12 +5,6 @@ import { subscribeStructuredChatTabNames, getStructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-name-observation' -import { subscribeChatProgramFacts } from '@/lib/agent-session/launch/inventory/host-chat-program' -import { - type AgentLaunchRouteView, - agentLaunchRouteInputsChanged, - selectAgentLaunchRouteView -} from '@/lib/agent-session/launch/routing/agent-launch-route-input' import { revealDashboardAgent } from './reveal-dashboard-agent' import type { RepoIcon } from '../../../../shared/repo-icon' import { buildDashboardSnapshot, type DashboardSnapshotState } from './build-dashboard-snapshot' @@ -36,16 +29,15 @@ export function repoIconsUnchanged( return nextIds.every((id) => id in previous && next[id] === previous[id]) } -type DashboardSnapshotWatchState = DashboardSnapshotState & - Pick & - AgentLaunchRouteView +type DashboardSnapshotWatchState = DashboardSnapshotState & Pick export function dashboardSnapshotInputsChanged( state: DashboardSnapshotWatchState, previousState: DashboardSnapshotWatchState ): boolean { return ( - agentLaunchRouteInputsChanged(state, previousState) || + state.activeWorktreeId !== previousState.activeWorktreeId || + state.activeWorkspaceExecutionHostId !== previousState.activeWorkspaceExecutionHostId || state.repos !== previousState.repos || selectWorktreesByRepo(state) !== selectWorktreesByRepo(previousState) || state.tabsByWorktree !== previousState.tabsByWorktree || @@ -62,9 +54,6 @@ export function dashboardSnapshotInputsChanged( // Why: settings controls idle visibility and generated conversation names. state.settings !== previousState.settings || state.workspaceStatuses !== previousState.workspaceStatuses || - state.detectedAgentIds !== previousState.detectedAgentIds || - state.remoteDetectedAgentIds !== previousState.remoteDetectedAgentIds || - state.runtimeDetectedAgentIds !== previousState.runtimeDetectedAgentIds || // Relay live hooks directly; rebuild snapshots for host-profile changes too, since idle boards may have no later event to correct stale byte routing. state.sshConnectionStates !== previousState.sshConnectionStates || state.sshStateByEnvironment !== previousState.sshStateByEnvironment || @@ -92,12 +81,9 @@ export function watchSnapshotInputs(onChanged: () => void): () => void { onChanged() } }) - // Launch options also read host chat facts held outside the store. - const unsubscribeChatFacts = subscribeChatProgramFacts(onChanged) return () => { stopNames() unsubscribeStore() - unsubscribeChatFacts() } } @@ -128,7 +114,6 @@ export function useDashboardPopoutBridge(enabled: boolean): void { } let open = false let disposed = false - const chatOwners = createDashboardChatProgramHolds() let unsubscribeStore: (() => void) | null = null let trailingTimer: ReturnType | null = null let lastPublishAt = 0 @@ -139,12 +124,10 @@ export function useDashboardPopoutBridge(enabled: boolean): void { const publishNow = (withIcons: boolean): void => { lastPublishAt = Date.now() const state = useAppStore.getState() - chatOwners.reconcile(state) const snapshot = buildDashboardSnapshot( { ...state, chatNames: getStructuredChatTabNames() }, lastPublishAt, { - launchRouteStore: selectAgentLaunchRouteView(state), rowsCache, rowsGeneration: state.agentStatusEpoch } @@ -210,7 +193,6 @@ export function useDashboardPopoutBridge(enabled: boolean): void { } publishNow(true) } else { - chatOwners.release() unsubscribeStore?.() unsubscribeStore = null if (trailingTimer) { @@ -236,7 +218,6 @@ export function useDashboardPopoutBridge(enabled: boolean): void { return () => { disposed = true - chatOwners.release() offOpenChanged?.() offRequested?.() unsubscribeStore?.() diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 548b3ed1f8a..481ae3e6d2d 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -1,13 +1,9 @@ -import { watchSnapshotInputs } from './useDashboardPopoutBridge' -import { createDashboardChatProgramHolds } from './dashboard-chat-program-acquisition' -import { useEffect, useMemo, useRef } from 'react' +import { useMemo, useRef } from 'react' import { useAppStore } from '@/store' import { useStructuredChatTabNames } from '@/runtime/native-chat/naming/structured-chat-name-observation' import type { DashboardSnapshot } from '../../../../shared/dashboard-snapshot' import { buildDashboardSnapshot } from './build-dashboard-snapshot' -import { useChatProgramFactsVersion } from '@/lib/agent-session/launch/inventory/host-chat-program-acquisition' import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' -import { selectAgentLaunchRouteView } from '@/lib/agent-session/launch/routing/agent-launch-route-input' import { selectDetectedWorktreesByRepo, selectFolderWorkspaces, @@ -53,25 +49,11 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) const removedRuntimeEnvironmentIds = useAppStore((s) => s.removedRuntimeEnvironmentIds) const paneForegroundAgentByPaneKey = useAppStore((s) => s.paneForegroundAgentByPaneKey) - const detectedAgentIds = useAppStore((s) => s.detectedAgentIds) - const remoteDetectedAgentIds = useAppStore((s) => s.remoteDetectedAgentIds) - const runtimeDetectedAgentIds = useAppStore((s) => s.runtimeDetectedAgentIds) // Freshness can change buckets without map writes; the epoch invalidates stale-decay memos. const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const chatNames = useStructuredChatTabNames() - // Why: launch options read only this view, so any route input change rebuilds them. - const launchRouteStore = useAppStore(selectAgentLaunchRouteView) - useEffect(() => { - const holds = createDashboardChatProgramHolds() - const reconcile = (): void => holds.reconcile(useAppStore.getState()) - const stopWatching = watchSnapshotInputs(reconcile) - reconcile() - return () => { - stopWatching() - holds.release() - } - }, []) - const chatProgramFactsVersion = useChatProgramFactsVersion() + const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) + const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId) return useMemo( // Read the clock when the freshness epoch invalidates this memo, matching row derivation. @@ -79,8 +61,8 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { buildDashboardSnapshot( { chatNames, - activeWorktreeId: launchRouteStore.activeWorktreeId, - activeWorkspaceExecutionHostId: launchRouteStore.activeWorkspaceExecutionHostId, + activeWorktreeId, + activeWorkspaceExecutionHostId, repos, worktreesByRepo, tabsByWorktree, @@ -109,15 +91,12 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, - detectedAgentIds, - remoteDetectedAgentIds, - runtimeDetectedAgentIds, // Read launch identity non-reactively because Shift+Enter routing does not depend on it; match the bridge gate. agentLaunchConfigByPaneKey: useAppStore.getState().agentLaunchConfigByPaneKey }, Date.now(), // Reuse unchanged worktree rows while assembling cards fresh from current review/host/status slices. - { rowsCache: rowsCacheRef.current, rowsGeneration: agentStatusEpoch, launchRouteStore } + { rowsCache: rowsCacheRef.current, rowsGeneration: agentStatusEpoch } ), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -149,12 +128,9 @@ export function useLiveDashboardSnapshot(): DashboardSnapshot { runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds, paneForegroundAgentByPaneKey, - detectedAgentIds, - remoteDetectedAgentIds, - runtimeDetectedAgentIds, chatNames, - chatProgramFactsVersion, - launchRouteStore, + activeWorktreeId, + activeWorkspaceExecutionHostId, agentStatusEpoch ] ) diff --git a/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx index c7cb212c32f..b45c616e831 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault/panel/AiVaultPanel.tsx @@ -38,6 +38,7 @@ import { useAiVaultPanelSearch } from '../search/use-ai-vault-search' import { useAiVaultPanelSearchSessions } from '../search/ai-vault-search-session' import { aiVaultSearchScopeIdentity } from '../search/ai-vault-search-scope-identity' import { AiVaultPanelSearch } from '../search/AiVaultPanelSearch' +import { useAiVaultResumeCatalogs } from '../session-actions/use-ai-vault-resume-catalogs' import { useAiVaultPanelSessionResume } from '../session-actions/use-ai-vault-panel-session-resume' import { AiVaultScanProgressStrip } from '../scanning/AiVaultScanProgressStrip' import { useAiVaultScanProgress } from '../scanning/ai-vault-scan-progress-store' @@ -108,6 +109,7 @@ export default function AiVaultPanel({ } = usePersistedAiVaultViewOptions(initialAgents) const { executionHostScope, onExecutionHostScopeChange, hostScopeOptions } = useAiVaultPanelHostScope(activeWorktreeId ?? null, resumeTargetState) + useAiVaultResumeCatalogs(executionHostScope) const activeWorktreePath = activeWorktree?.path ?? null // Why: AI Vault ownership is cwd-based, so we must consider live worktrees across all repos. const activeWorktreePaths = useMemo( diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.test.tsx new file mode 100644 index 00000000000..6eee266dc24 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/hosts/capabilities/local-runtime-capabilities' +import { resetHostStructuredAgentsForTests } from '@/runtime/native-chat/agent-catalogs/host-structured-agents' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../../../shared/agent-session/agent-session-runtime-capabilities' +import { + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY +} from '../../../../../../shared/agent-session/structured-agent-session-surface-capabilities' +import type { AiVaultSession } from '../../../../../../shared/ai-vault/contracts/ai-vault-types' +import { makeRepo, makeWorktree } from '../../../worktree-jump/worktree-jump-palette-test-fixtures' +import { createGlobalSettingsFixture } from '../../../../../../shared/global-settings-test-fixture' +import { AiVaultSessionSurfaceSwitchMenuItems } from '../session-list/AiVaultSessionSurfaceSwitchMenuItems' +import { useAiVaultPanelSessionResume } from './use-ai-vault-panel-session-resume' +import { useAiVaultResumeCatalogs } from './use-ai-vault-resume-catalogs' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/rpc/calls/runtime-rpc-call', () => ({ callRuntimeRpc: rpc })) + +const initial = useAppStore.getInitialState() +const localWorkspace = makeWorktree('local-workspace', 'Local', { path: '/repo/local' }) +const pairedWorkspace = makeWorktree('paired-workspace', 'Old server', { + repoId: 'paired-repo', + path: '/srv/remote', + hostId: 'runtime:old-server' +}) +const session: AiVaultSession = { + id: 'local:opencode:session-1', + executionHostId: 'local', + agent: 'opencode', + sessionId: 'session-1', + title: 'Task', + cwd: '/repo/local', + branch: null, + model: null, + filePath: '/home/test/.local/share/opencode/session/session-1.json', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-11-09T12:00:00Z', + messageCount: 2, + totalTokens: 0, + previewMessages: [{ role: 'user', text: 'Continue this task', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'opencode --session session-1', + subagent: null +} +const sessions = [session] +const worktrees = [localWorkspace, pairedWorkspace] + +function HistoryActions({ + acquire, + onResume +}: { + acquire: boolean + onResume: (id: string) => void +}) { + const state = useAppStore.getState() + const resume = useAiVaultPanelSessionResume({ + sessions, + repos: state.repos, + allWorktrees: worktrees, + workspaceId: 'paired-workspace', + resumeTargetState: state + }) + const target = resume.getSessionResumeInChat(session) + return ( + <> + {acquire ? : null} + + Session actions + + onResume(target.workspaceId) : undefined} + /> + + + + ) +} + +function HistoryCatalog() { + useAiVaultResumeCatalogs('local') + return null +} + +beforeEach(() => { + resetHostStructuredAgentsForTests() + setLocalRuntimeCapabilitiesForTests([ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY + ]) + useAppStore.setState({ + ...initial, + settings: createGlobalSettingsFixture({ + agentDefaultSurface: 'terminal', + nativeChatProviders: {} + }), + repos: [ + { ...makeRepo(), path: '/repo', displayName: 'Local' }, + { ...makeRepo(), id: 'paired-repo', path: '/srv', executionHostId: 'runtime:old-server' } + ], + worktreesByRepo: { 'repo-1': [localWorkspace], 'paired-repo': [pairedWorkspace] }, + activeWorktreeId: 'paired-workspace', + activeWorkspaceExecutionHostId: 'runtime:old-server' + }) +}) + +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) + resetHostStructuredAgentsForTests() + setLocalRuntimeCapabilitiesForTests(null) + vi.clearAllMocks() +}) + +it('offers cold local History resume in its own workspace while an old paired workspace is active', async () => { + let answer!: (value: unknown) => void + rpc.mockImplementation( + () => + new Promise((resolve) => { + answer = resolve + }) + ) + const onResume = vi.fn() + const view = render() + expect(screen.queryByRole('menuitem', { name: 'Resume in New Chat' })).toBeNull() + + view.rerender() + await act(async () => { + await Promise.resolve() + answer({ agents: [{ agent: 'opencode', capabilities: {} }] }) + }) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Resume in New Chat' })) + expect(onResume).toHaveBeenCalledWith('local-workspace') +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.ts b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.ts new file mode 100644 index 00000000000..1477979d07b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault/session-actions/use-ai-vault-resume-catalogs.ts @@ -0,0 +1,89 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store/index' +import type { AppState } from '@/store/types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + LOCAL_EXECUTION_HOST_ID, + parseRoutableExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostScope +} from '../../../../../../shared/execution-host' +import { lastVerifiedRuntimeStatus } from '../../../../../../shared/remote-runtime/host-status/runtime-host-status' +import { loadHostStructuredAgents } from '@/runtime/native-chat/agent-catalogs/host-structured-agents' +import { + ensureLocalRuntimeCapabilities, + subscribeLocalRuntimeCapabilitiesKnown +} from '@/runtime/hosts/capabilities/local-runtime-capabilities' + +/** Resume eligibility needs the recording host's catalog even when new agents default to terminals. */ +export function useAiVaultResumeCatalogs(scope: ExecutionHostScope): void { + // oxlint-disable-next-line react-doctor/effect-needs-cleanup -- Cleanup disposes both conditional subscriptions and blocks pending capability delivery. + useEffect(() => { + let disposed = false + const host = parseRoutableExecutionHostId(scope) + const all = scope === ALL_EXECUTION_HOSTS_SCOPE + const local = all || host?.kind === 'local' + const loadLocal = (capabilities: readonly string[] | null): void => { + if (!disposed) { + void loadHostStructuredAgents(LOCAL_EXECUTION_HOST_ID, capabilities, null) + } + } + const stopLocal = local ? subscribeLocalRuntimeCapabilitiesKnown(loadLocal) : undefined + if (local) { + void ensureLocalRuntimeCapabilities().then(loadLocal) + } + const loadRuntime = (environmentId: string): void => { + const status = lastVerifiedRuntimeStatus( + useAppStore.getState().runtimeStatusByEnvironmentId.get(environmentId) + ) + if (!disposed && status) { + void loadHostStructuredAgents( + toRuntimeExecutionHostId(environmentId), + status.capabilities, + status.runtimeId + ) + } + } + const loadRuntimes = (previous?: AppState): void => { + if (all) { + const state = useAppStore.getState() + const previousIds = + previous && state.runtimeEnvironments !== previous.runtimeEnvironments + ? new Set(previous.runtimeEnvironments.map((environment) => environment.id)) + : undefined + for (const environment of state.runtimeEnvironments) { + if ( + !previous || + (previousIds && !previousIds.has(environment.id)) || + state.runtimeStatusByEnvironmentId.get(environment.id) !== + previous.runtimeStatusByEnvironmentId.get(environment.id) + ) { + loadRuntime(environment.id) + } + } + } else if (host?.kind === 'runtime') { + loadRuntime(host.environmentId) + } + } + const stopStore = + all || host?.kind === 'runtime' + ? useAppStore.subscribe((state, previous) => { + const changed = all + ? state.runtimeEnvironments !== previous.runtimeEnvironments || + state.runtimeStatusByEnvironmentId !== previous.runtimeStatusByEnvironmentId + : host?.kind === 'runtime' && + state.runtimeStatusByEnvironmentId.get(host.environmentId) !== + previous.runtimeStatusByEnvironmentId.get(host.environmentId) + if (changed) { + loadRuntimes(previous) + } + }) + : undefined + loadRuntimes() + return () => { + disposed = true + stopLocal?.() + stopStore?.() + } + }, [scope]) +} From 7d52a73873df946fb5329e448f667aae10c8da1c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:37:06 -0800 Subject: [PATCH 002/106] Update paired workspace snapshots without copying unrelated records (#40553) * Update paired layout snapshots by workspace * Retain immutable paired snapshot values at intake --- ...remote-layout-snapshot-persistence.test.ts | 2 +- .../remote-layout-snapshot-persistence.ts | 84 +++++++++++++++---- .../remote-layout-snapshot-recorder.test.ts | 13 ++- .../remote-layout-snapshot-recorder.ts | 21 +++-- 4 files changed, 95 insertions(+), 25 deletions(-) diff --git a/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.test.ts b/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.test.ts index 9b80228c4c3..560749b1c21 100644 --- a/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.test.ts +++ b/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.test.ts @@ -153,5 +153,5 @@ it('keeps a title-only change without arming a save, and writes it with the next // Another domain's save writes only the domains marked dirty, which must include this one. store.updateSettings({ opencodeSessionCookie: 'cookie' }) store.flushOrThrow() - expect(open().getRemoteLayoutSnapshot('env')?.workspaces[workspace.worktree]).toEqual(retitled) + expect(open().getRemoteLayoutSnapshots().env?.workspaces[workspace.worktree]).toEqual(retitled) }) diff --git a/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.ts b/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.ts index 04560570a45..45186efd4dd 100644 --- a/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.ts +++ b/src/main/remote-layout-snapshot/remote-layout-snapshot-persistence.ts @@ -1,4 +1,7 @@ -import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot' +import type { + RemoteLayoutSnapshot, + RemoteLayoutSnapshotWorkspace +} from '../../shared/remote-layout-snapshot' import type { StoreRuntimeState } from '../persistence/loading-store/store-runtime-state' import type { WriteSchedulingOperations } from '../persistence/loading-store/write-scheduling' import { scheduleSave } from '../persistence/loading-store/write-scheduling' @@ -11,6 +14,28 @@ type RemoteLayoutSnapshotPersistenceContext = { scheduling: WriteSchedulingOperations } +function writeSnapshotEntry(entries: Record, id: string, value: T): void { + // Admitted string identities must remain own keys even when named __proto__. + Object.defineProperty(entries, id, { + value, + configurable: true, + enumerable: true, + writable: true + }) +} + +function saveRemoteLayoutSnapshots( + { runtime, scheduling }: RemoteLayoutSnapshotPersistenceContext, + save: 'now' | 'later' +): void { + if (save === 'later') { + runtime.dirtyProfileStateDomains?.add('remoteLayoutSnapshots') + return + } + // Save only this domain without waking workspace-session listeners. + scheduleSave(scheduling, ['remoteLayoutSnapshots']) +} + export class RemoteLayoutSnapshotPersistence { readonly [remoteLayoutSnapshotPersistenceContext]: RemoteLayoutSnapshotPersistenceContext @@ -18,10 +43,18 @@ export class RemoteLayoutSnapshotPersistence { this[remoteLayoutSnapshotPersistenceContext] = { runtime, scheduling } } - getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined { - return this[remoteLayoutSnapshotPersistenceContext].runtime.state.remoteLayoutSnapshots?.[ - environmentId - ] + /** Recorder-only lookup; workspace values are replaced, never mutated. */ + getRemoteLayoutSnapshotWorkspace( + environmentId: string, + worktreeId: string + ): RemoteLayoutSnapshotWorkspace | undefined { + const snapshots = + this[remoteLayoutSnapshotPersistenceContext].runtime.state.remoteLayoutSnapshots + const snapshot = + snapshots && Object.hasOwn(snapshots, environmentId) ? snapshots[environmentId] : undefined + return snapshot && Object.hasOwn(snapshot.workspaces, worktreeId) + ? snapshot.workspaces[worktreeId] + : undefined } getRemoteLayoutSnapshots(): Record { @@ -31,7 +64,7 @@ export class RemoteLayoutSnapshotPersistence { return Object.fromEntries( Object.entries(stored).map(([environmentId, { workspaces }]) => [ environmentId, - { workspaces } + { workspaces: structuredClone(workspaces) } ]) ) } @@ -42,19 +75,36 @@ export class RemoteLayoutSnapshotPersistence { snapshot: RemoteLayoutSnapshot | null, save: 'now' | 'later' ): void { - const { runtime, scheduling } = this[remoteLayoutSnapshotPersistenceContext] - const { [environmentId]: previous, ...others } = runtime.state.remoteLayoutSnapshots ?? {} - if (snapshot === null && previous === undefined) { + const context = this[remoteLayoutSnapshotPersistenceContext] + const snapshots = (context.runtime.state.remoteLayoutSnapshots ??= {}) + if (snapshot === null && !Object.hasOwn(snapshots, environmentId)) { return } - runtime.state.remoteLayoutSnapshots = snapshot - ? { ...others, [environmentId]: snapshot } - : others - if (save === 'later') { - runtime.dirtyProfileStateDomains?.add('remoteLayoutSnapshots') - return + if (snapshot) { + writeSnapshotEntry(snapshots, environmentId, snapshot) + } else { + delete snapshots[environmentId] } - // Save only this domain without waking workspace-session listeners. - scheduleSave(scheduling, ['remoteLayoutSnapshots']) + saveRemoteLayoutSnapshots(context, save) + } + + setRemoteLayoutSnapshotWorkspace( + environmentId: string, + worktreeId: string, + workspace: RemoteLayoutSnapshotWorkspace | null, + save: 'now' | 'later' + ): void { + const context = this[remoteLayoutSnapshotPersistenceContext] + const snapshots = (context.runtime.state.remoteLayoutSnapshots ??= {}) + const workspaces = Object.hasOwn(snapshots, environmentId) + ? snapshots[environmentId].workspaces + : {} + if (workspace) { + writeSnapshotEntry(workspaces, worktreeId, workspace) + } else { + delete workspaces[worktreeId] + } + writeSnapshotEntry(snapshots, environmentId, { workspaces }) + saveRemoteLayoutSnapshots(context, save) } } diff --git a/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.test.ts b/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.test.ts index 516d53f820b..20f8ac06316 100644 --- a/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.test.ts +++ b/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.test.ts @@ -13,7 +13,18 @@ function memoryStore() { const snapshots = new Map() const saves: ('now' | 'later')[] = [] const store: RemoteLayoutSnapshotStore = { - getRemoteLayoutSnapshot: (environmentId) => snapshots.get(environmentId), + getRemoteLayoutSnapshotWorkspace: (environmentId, worktreeId) => + snapshots.get(environmentId)?.workspaces[worktreeId], + setRemoteLayoutSnapshotWorkspace: (environmentId, worktreeId, workspace, save) => { + saves.push(save) + const snapshot = snapshots.get(environmentId) ?? { workspaces: {} } + if (workspace) { + snapshot.workspaces[worktreeId] = workspace + } else { + delete snapshot.workspaces[worktreeId] + } + snapshots.set(environmentId, snapshot) + }, setRemoteLayoutSnapshot: (environmentId, snapshot, save) => { saves.push(save) if (snapshot) { diff --git a/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.ts b/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.ts index 36bec681970..78c3e040212 100644 --- a/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.ts +++ b/src/main/remote-layout-snapshot/remote-layout-snapshot-recorder.ts @@ -8,7 +8,16 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-session-contracts' export type RemoteLayoutSnapshotStore = { - getRemoteLayoutSnapshot(environmentId: string): RemoteLayoutSnapshot | undefined + getRemoteLayoutSnapshotWorkspace( + environmentId: string, + worktreeId: string + ): RemoteLayoutSnapshotWorkspace | undefined + setRemoteLayoutSnapshotWorkspace( + environmentId: string, + worktreeId: string, + workspace: RemoteLayoutSnapshotWorkspace | null, + save: 'now' | 'later' + ): void setRemoteLayoutSnapshot( environmentId: string, snapshot: RemoteLayoutSnapshot | null, @@ -77,16 +86,16 @@ export function recordRemoteLayoutFrame( return } const { type: _type, removed, ...result } = frame - const { [frame.worktree]: previous, ...others } = - store.getRemoteLayoutSnapshot(environmentId)?.workspaces ?? {} + const previous = store.getRemoteLayoutSnapshotWorkspace(environmentId, frame.worktree) if (removed) { - store.setRemoteLayoutSnapshot(environmentId, { workspaces: others }, 'now') + store.setRemoteLayoutSnapshotWorkspace(environmentId, frame.worktree, null, 'now') return } const workspace = displayed(result) - store.setRemoteLayoutSnapshot( + store.setRemoteLayoutSnapshotWorkspace( environmentId, - { workspaces: { ...others, [frame.worktree]: workspace } }, + frame.worktree, + workspace, structure(previous) === structure(workspace) ? 'later' : 'now' ) } From 714242cdcea312e17f4c54b6903f6770a8140938 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:44:32 -0800 Subject: [PATCH 003/106] Release terminal warning and editor discard history when owners retire (#40725) * Retire terminal notices and pending discard state with their lifetime * Preserve the DOM window in terminal notice lifecycle fixtures * test: exercise fresh terminal eligibility and stale edit output * Release notice contributions at accepted terminal retirement * Keep terminal notice delivery independent of store initialization * Assert the public workspace removal failure result * Keep buffered terminal retirement within delivery modules * Initialize canonical terminal maps in workspace slice fixtures * Expose terminal lifecycle fixture actions after commit --- .../document/useIpynbDocumentEditing.test.tsx | 30 +- .../document/useIpynbDocumentEditing.ts | 11 +- .../useRichMarkdownPendingSerialization.ts | 11 +- .../instance/RichMarkdownEditor.tsx | 28 +- .../rich-markdown-editor-config.test.ts | 2 +- .../instance/rich-markdown-editor-config.ts | 9 +- .../useRichMarkdownProgrammaticSync.test.ts | 41 +- .../useRichMarkdownProgrammaticSync.ts | 18 +- ...card-editor-file-changes.save-race.test.ts | 35 +- .../save/flush/discard-editor-file-changes.ts | 9 +- .../editor/save/flush/editor-discard-epoch.ts | 56 +- .../save/flush/editor-stale-view-edit.ts | 15 +- .../editor/save/queue/editor-save-queue.ts | 6 +- .../editor/save/queue/editor-save-slots.ts | 9 +- .../save/queue/editor-save-writer-view.ts | 4 +- .../transforms/editor-format-on-save.test.ts | 4 +- .../editor-save-transform-pipeline.ts | 4 +- .../close/closed-terminal-leaf-notice.ts | 18 +- .../close/layout-owner-pane-close-command.ts | 4 + .../close/retire-unbound-ipc-terminal-pane.ts | 11 + .../close/use-terminal-pane-close-actions.ts | 17 +- .../mounting/terminal-pane-closed-handler.ts | 12 + .../mounting/terminal-pane-mount-cleanup.ts | 4 + .../terminal-pane-notice-retirement.ts | 52 ++ .../parked-terminal-tab-coverage.ts | 5 + .../terminal-parked-pty-watcher.ts | 15 +- .../terminal-parked-watcher-registry.ts | 19 + .../ClaudeOldTerminalBanner.tsx | 43 +- .../CodexSharedServerBanner.tsx | 44 +- .../codex-shared-server-banner.test.tsx | 511 +++++++++++++++++- .../terminal-notice-pane.test-fixture.tsx | 136 +++++ .../terminal-notice-lifetime.ts | 89 +++ .../pty-connection/lifecycle/pane-pty-exit.ts | 9 + .../reattach/reattach-result-handler.ts | 5 +- .../spawn/main-pane-resume-banner.ts | 40 +- .../startup/main-pane-resume-banner.test.ts | 92 +++- .../pty-transport/delivery/pty-dispatcher.ts | 8 +- .../delivery/pty-exit-delivery.ts | 25 +- .../pty-transport/delivery/pty-notice-exit.ts | 66 +++ .../delivery/pty-pre-handler-buffer.test.ts | 12 +- .../delivery/pty-pre-handler-buffer.ts | 4 +- .../pty-transport/ipc/ipc-pty-attach.ts | 6 + .../pty-transport/ipc/ipc-pty-connect.ts | 7 + .../ipc/ipc-pty-session-handlers.ts | 16 +- .../terminal/terminal-tab-actions.ts | 12 + src/renderer/src/store/index.ts | 5 + .../listing/fetched-worktree-merge.ts | 19 +- .../session/worktree-slice-lookups.ts | 6 + .../removed-worktree-renderer-teardown.ts | 2 + .../teardown/worktree-state-before-removal.ts | 8 +- .../worktrees/worktrees-slice-test-harness.ts | 4 + .../terminals/terminal-notice-removal.ts | 117 ++++ .../src/store/terminals/terminal-tab-close.ts | 25 +- 53 files changed, 1586 insertions(+), 174 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/mounting/terminal-pane-notice-retirement.ts create mode 100644 src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/terminal-notice-pane.test-fixture.tsx create mode 100644 src/renderer/src/components/terminal-pane/provider-notices/terminal-notice-lifetime.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-notice-exit.ts create mode 100644 src/renderer/src/store/terminals/terminal-notice-removal.ts diff --git a/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.test.tsx b/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.test.tsx index 57c9d27e0ad..5c2f50535af 100644 --- a/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.test.tsx +++ b/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.test.tsx @@ -9,11 +9,11 @@ import { useIpynbDocumentEditing } from './useIpynbDocumentEditing' import { EMPTY_EDITOR_BUFFER_VIEW_INDEX as NO_SHARING } from '@/store/slices/editor/buffers/editor-buffer-views' import type * as StaleViewEdit from '../../save/flush/editor-stale-view-edit' import { notifyStaleViewEditRejected } from '../../save/flush/editor-stale-view-edit' +import { discardEditorBuffer } from '../../save/flush/discard-editor-file-changes' vi.mock('../../save/flush/editor-stale-view-edit', async (importOriginal) => ({ ...(await importOriginal()), - notifyStaleViewEditRejected: vi.fn(), - staleViewEditEpoch: vi.fn(() => 0) + notifyStaleViewEditRejected: vi.fn() })) function renderSplitPanes(initialContent: string) { @@ -129,6 +129,32 @@ describe('notebook document editing lifecycle', () => { expect(Object.hasOwn(hook.result.current.sourceDrafts, 'a')).toBe(false) }) + it('drops pending discarded cells and accepts typing begun afterwards', async () => { + const content = notebookContent() + const committed: string[] = [] + const hook = renderHook(() => + useIpynbDocumentEditing({ + content, + fileId: 'discard-notebook', + notebook: parseIpynb(content), + onContentChange: (text) => committed.push(text), + onDirtyStateHint: vi.fn(), + onDeactivateEditor: vi.fn() + }) + ) + await act(async () => { + hook.result.current.updateCellSource(0, 'discarded draft') + const release = await discardEditorBuffer('discard-notebook') + release() + }) + expect(parseIpynb(committed.at(-1)!).cells[0]?.source).toBe('a') + act(() => { + hook.result.current.updateCellSource(0, 'new draft') + flushPendingEditorBuffer(NO_SHARING, 'discard-notebook') + }) + expect(parseIpynb(committed.at(-1)!).cells[0]?.source).toBe('new draft') + }) + it('does not republish unchanged text when flushed with no pending typing', () => { const onContentChange = vi.fn() const content = notebookContent() diff --git a/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.ts b/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.ts index 97f2e138b56..68a64fc32c2 100644 --- a/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.ts +++ b/src/renderer/src/components/editor/notebook/document/useIpynbDocumentEditing.ts @@ -1,3 +1,4 @@ +import type { EditorDiscardGeneration } from '../../save/flush/editor-discard-epoch' import { useCallback, useEffect, @@ -10,7 +11,7 @@ import { registerPendingEditorFlush } from '../../save/flush/editor-pending-flus import { focusLeftView, notifyStaleViewEditRejected, - staleViewEditEpoch + captureViewEditGeneration } from '../../save/flush/editor-stale-view-edit' import { deleteIpynbCell, @@ -84,7 +85,7 @@ export function useIpynbDocumentEditing({ const sourceCommitTimerRef = useRef | null>(null) // Why: each drafted cell's source when typing began; a draft onto a cell changed elsewhere is refused. const draftBasesRef = useRef>({}) - const draftEpochRef = useRef(0) + const draftGenerationRef = useRef(null) const parsedContentRef = useRef(content) const structuralFrameIdsRef = useRef([]) useLayoutEffect(() => { @@ -118,7 +119,7 @@ export function useIpynbDocumentEditing({ return contentRef.current } const cells = latestCells() ?? [] - const discarded = staleViewEditEpoch(fileId) !== draftEpochRef.current + const discarded = draftGenerationRef.current?.discarded === true const rebase = discarded ? { updates: [], dropped: Object.keys(drafts), lostTyping: false } : rebaseIpynbSourceDrafts(cells, drafts, draftBasesRef.current) @@ -135,7 +136,7 @@ export function useIpynbDocumentEditing({ sourceDraftsRef.current = nextDrafts setSourceDrafts(nextDrafts) if (rebase.lostTyping) { - notifyStaleViewEditRejected(fileId, draftEpochRef.current) + notifyStaleViewEditRejected(draftGenerationRef.current) } } draftBasesRef.current = nextBases @@ -228,7 +229,7 @@ export function useIpynbDocumentEditing({ } const key = getIpynbCellKey(cell, index) if (sourceCommitTimerRef.current === null) { - draftEpochRef.current = staleViewEditEpoch(fileId) + draftGenerationRef.current = captureViewEditGeneration(fileId) } if (!Object.hasOwn(draftBasesRef.current, key)) { draftBasesRef.current = { diff --git a/src/renderer/src/components/editor/rich-markdown/document/useRichMarkdownPendingSerialization.ts b/src/renderer/src/components/editor/rich-markdown/document/useRichMarkdownPendingSerialization.ts index 0b5b011be2e..eb154c8ddb7 100644 --- a/src/renderer/src/components/editor/rich-markdown/document/useRichMarkdownPendingSerialization.ts +++ b/src/renderer/src/components/editor/rich-markdown/document/useRichMarkdownPendingSerialization.ts @@ -1,3 +1,4 @@ +import type { EditorDiscardGeneration } from '../../save/flush/editor-discard-epoch' import { useCallback, useEffect, @@ -8,7 +9,7 @@ import { } from 'react' import type { Editor } from '@tiptap/react' import { registerPendingEditorFlush } from '../../save/flush/editor-pending-flush' -import { focusLeftView, staleViewEditEpoch } from '../../save/flush/editor-stale-view-edit' +import { focusLeftView } from '../../save/flush/editor-stale-view-edit' import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit' type PendingSerializationParams = { @@ -32,15 +33,15 @@ export function useRichMarkdownPendingSerialization({ reconcileRoundTripRef }: PendingSerializationParams) { const serializeTimerRef = useRef(null) - // Why: the discard epoch when the pending edit began; a Discard since then drops it silently. - const pendingEditEpochRef = useRef(0) + // Why: the discard generation when the pending edit began; a Discard since then drops it silently. + const pendingEditGenerationRef = useRef(null) const flushPendingSerialization = useCallback((): boolean => { if (serializeTimerRef.current === null) { return false } window.clearTimeout(serializeTimerRef.current) serializeTimerRef.current = null - if (pendingEditEpochRef.current !== staleViewEditEpoch(fileId)) { + if (pendingEditGenerationRef.current?.discarded === true) { // Why: a Discard since typing began owns the text and dirty flag now. return true } @@ -91,7 +92,7 @@ export function useRichMarkdownPendingSerialization({ return { serializeTimerRef, - pendingEditEpochRef, + pendingEditGenerationRef, flushPendingSerialization, flushOnFocusLeave } diff --git a/src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownEditor.tsx index bb612b1978d..f1cf8b0f3b6 100644 --- a/src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/rich-markdown/instance/RichMarkdownEditor.tsx @@ -144,16 +144,20 @@ export default function RichMarkdownEditor({ worktreeRoot }) - const { serializeTimerRef, pendingEditEpochRef, flushPendingSerialization, flushOnFocusLeave } = - useRichMarkdownPendingSerialization({ - fileId, - editorRef, - lastCommittedMarkdownRef, - originalSourceRef, - baseCanonicalRef, - onContentChangeRef, - reconcileRoundTripRef - }) + const { + serializeTimerRef, + pendingEditGenerationRef, + flushPendingSerialization, + flushOnFocusLeave + } = useRichMarkdownPendingSerialization({ + fileId, + editorRef, + lastCommittedMarkdownRef, + originalSourceRef, + baseCanonicalRef, + onContentChangeRef, + reconcileRoundTripRef + }) const editor = useRichMarkdownEditorInstance({ codec, @@ -191,7 +195,7 @@ export default function RichMarkdownEditor({ cancelAutoFocusRef, fileId, serializeTimerRef, - pendingEditEpochRef, + pendingEditGenerationRef, isInitializingRef, isApplyingProgrammaticUpdateRef, markdownCommentsRef: review.markdownCommentsRef, @@ -279,7 +283,7 @@ export default function RichMarkdownEditor({ rootRef, runtimeTarget, serializeTimerRef, - pendingEditEpochRef, + pendingEditGenerationRef, slashMenuSetter: menu.setSlashMenu, worktreeId, worktreeRoot diff --git a/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.test.ts index e305c9504b8..b8593e1e2f2 100644 --- a/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.test.ts @@ -71,7 +71,7 @@ function createConfigParams(overrides: Partial = {}): Editor cancelAutoFocusRef: ref(null), fileId: 'file-a', serializeTimerRef: ref(null), - pendingEditEpochRef: ref(0), + pendingEditGenerationRef: ref({ discarded: false }), isInitializingRef: ref(false), isApplyingProgrammaticUpdateRef: ref(false), markdownCommentsRef: ref([]), diff --git a/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.ts index b3833891fc5..0c8b8898de8 100644 --- a/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown/instance/rich-markdown-editor-config.ts @@ -1,3 +1,4 @@ +import type { EditorDiscardGeneration } from '../../save/flush/editor-discard-epoch' import { handleRichMarkdownViewportReveal } from '../viewport/rich-markdown-viewport' import type { Editor, UseEditorOptions } from '@tiptap/react' import type { EditorView } from '@tiptap/pm/view' @@ -24,7 +25,7 @@ import { type ActivateMarkdownLink } from '../links/rich-markdown-editor-click-routing' import { createRichMarkdownKeyHandler } from '../commands/rich-markdown-key-handler' -import { staleViewEditEpoch } from '../../save/flush/editor-stale-view-edit' +import { captureViewEditGeneration } from '../../save/flush/editor-stale-view-edit' import { createRichMarkdownImageResolverContext, setRichMarkdownImageResolverContext @@ -77,8 +78,8 @@ export type EditorConfigParams = { cancelAutoFocusRef: MutableRefObject<(() => void) | null> fileId: string serializeTimerRef: MutableRefObject - /** Discard epoch when the pending serialization began. */ - pendingEditEpochRef: MutableRefObject + /** Discard generation when the pending serialization began. */ + pendingEditGenerationRef: MutableRefObject isInitializingRef: MutableRefObject isApplyingProgrammaticUpdateRef: MutableRefObject markdownCommentsRef: MutableRefObject @@ -274,7 +275,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE if (serializeTimerRef.current !== null) { window.clearTimeout(serializeTimerRef.current) } else { - params.pendingEditEpochRef.current = staleViewEditEpoch(params.fileId) + params.pendingEditGenerationRef.current = captureViewEditGeneration(params.fileId) } serializeTimerRef.current = window.setTimeout(() => { params.flushPendingSerialization() diff --git a/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.test.ts b/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.test.ts index cd4c319527b..d497fd493b5 100644 --- a/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.test.ts +++ b/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { Editor } from '@tiptap/core' +import { toast } from 'sonner' import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync' import { createRichMarkdownExtensions } from '../schema/rich-markdown-extensions' import { @@ -13,11 +14,8 @@ import { type RichMarkdownHtmlSuperscriptLinkContext } from '../citations/rich-markdown-html-superscript-link-context' import { encodeRawMarkdownHtmlForRichEditor } from '../raw-source/raw-markdown-html' -import { notifyStaleViewEditRejected } from '../../save/flush/editor-stale-view-edit' - -vi.mock('../../save/flush/editor-stale-view-edit', () => ({ - notifyStaleViewEditRejected: vi.fn(), - staleViewEditEpoch: vi.fn(() => 0) +vi.mock('sonner', () => ({ + toast: { error: vi.fn() } })) function buildEditor( @@ -39,7 +37,7 @@ function buildEditor( } describe('useRichMarkdownProgrammaticSync stale pending edit', () => { - function renderPane(paneText: string, pendingCommit: (() => void) | null) { + function renderPane(paneText: string, pendingCommit: boolean) { const codec = createRichMarkdownEditorCodec() const editor = buildEditor( codec, @@ -54,10 +52,13 @@ describe('useRichMarkdownProgrammaticSync stale pending edit', () => { const lastCommittedMarkdownRef = { current: 'base' } const originalSourceRef = { current: 'base' } const baseCanonicalRef = { current: 'base' } - const pendingEditEpochRef = { current: 0 } - const onContentChangeRef = { current: vi.fn<(content: string) => void>() } + const pendingEditGenerationRef = { current: { discarded: false } } + const published: string[] = [] + const onContentChangeRef = { current: (content: string) => published.push(content) } const serializeTimerRef = { - current: pendingCommit ? window.setTimeout(pendingCommit, 300) : null + current: pendingCommit + ? window.setTimeout(() => onContentChangeRef.current(paneText), 300) + : null } const hook = renderHook( ({ content }: { content: string }) => @@ -77,34 +78,30 @@ describe('useRichMarkdownProgrammaticSync stale pending edit', () => { rootRef: { current: null }, runtimeTarget: { kind: 'local' }, serializeTimerRef, - pendingEditEpochRef, + pendingEditGenerationRef, slashMenuSetter: vi.fn(), worktreeId: 'w1', worktreeRoot: null }), { initialProps: { content: 'base' } } ) - return { editor, hook, serializeTimerRef, onContentChangeRef } + return { editor, hook, published } } it('rejects a split pane pending edit once the other pane replaced the shared text', () => { vi.useFakeTimers() - vi.mocked(notifyStaleViewEditRejected).mockClear() - const commitPaneB = vi.fn() - const { editor, hook, serializeTimerRef, onContentChangeRef } = renderPane( - 'pane b', - commitPaneB - ) + vi.mocked(toast.error).mockClear() + const { editor, hook, published } = renderPane('pane b', true) try { hook.rerender({ content: 'pane a' }) vi.advanceTimersByTime(300) - expect(commitPaneB).not.toHaveBeenCalled() - expect(serializeTimerRef.current).toBeNull() - expect(notifyStaleViewEditRejected).toHaveBeenCalledExactlyOnceWith('f1', 0) + expect(toast.error).toHaveBeenCalledWith(expect.any(String), { + id: 'editor-stale-view-edit' + }) expect(editor.getMarkdown()).toBe('pane a') // Republished so the dirty flag this pane's typing raised is recomputed. - expect(onContentChangeRef.current).toHaveBeenCalledWith('pane a') + expect(published).toEqual(['pane a']) } finally { editor.destroy() vi.useRealTimers() @@ -147,7 +144,7 @@ describe('useRichMarkdownProgrammaticSync undo history', () => { rootRef: { current: null }, runtimeTarget: { kind: 'local' }, serializeTimerRef: { current: null }, - pendingEditEpochRef: { current: 0 }, + pendingEditGenerationRef: { current: { discarded: false } }, slashMenuSetter: vi.fn(), worktreeId: 'w1', worktreeRoot: null diff --git a/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.ts b/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.ts index 209081bc07e..577202e69d9 100644 --- a/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.ts +++ b/src/renderer/src/components/editor/rich-markdown/instance/useRichMarkdownProgrammaticSync.ts @@ -1,3 +1,4 @@ +import type { EditorDiscardGeneration } from '../../save/flush/editor-discard-epoch' import { useEffect } from 'react' import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { Editor } from '@tiptap/react' @@ -13,10 +14,7 @@ import { } from '../images/rich-markdown-image-context' import type { RuntimeClientTarget } from '../../../../../../shared/runtime-client-target' import type { RichMarkdownEditorCodec } from '../document/rich-markdown-source-transport' -import { - notifyStaleViewEditRejected, - staleViewEditEpoch -} from '../../save/flush/editor-stale-view-edit' +import { notifyStaleViewEditRejected } from '../../save/flush/editor-stale-view-edit' type RichMarkdownProgrammaticSyncOptions = { codec: RichMarkdownEditorCodec @@ -36,7 +34,7 @@ type RichMarkdownProgrammaticSyncOptions = { runtimeTarget: RuntimeClientTarget | null /** Pending debounced commit, based on the text this view last synced. */ serializeTimerRef: MutableRefObject - pendingEditEpochRef: MutableRefObject + pendingEditGenerationRef: MutableRefObject slashMenuSetter: Dispatch> worktreeId: string worktreeRoot: string | null @@ -65,7 +63,7 @@ export function useRichMarkdownProgrammaticSync({ rootRef, runtimeTarget, serializeTimerRef, - pendingEditEpochRef, + pendingEditGenerationRef, slashMenuSetter, worktreeId, worktreeRoot @@ -117,8 +115,8 @@ export function useRichMarkdownProgrammaticSync({ if (!editor) { return } - const editEpoch = pendingEditEpochRef.current - const discarded = staleViewEditEpoch(fileId) !== editEpoch + const editGeneration = pendingEditGenerationRef.current + const discarded = editGeneration?.discarded === true const pendingTimer = serializeTimerRef.current if (content === lastCommittedMarkdownRef.current && !(pendingTimer !== null && discarded)) { return @@ -128,7 +126,7 @@ export function useRichMarkdownProgrammaticSync({ window.clearTimeout(pendingTimer) serializeTimerRef.current = null if (!discarded && !matchesSharedText) { - notifyStaleViewEditRejected(fileId, editEpoch) + notifyStaleViewEditRejected(editGeneration) } } if (matchesSharedText) { @@ -169,7 +167,7 @@ export function useRichMarkdownProgrammaticSync({ isApplyingProgrammaticUpdateRef, lastCommittedMarkdownRef, onContentChangeRef, - pendingEditEpochRef, + pendingEditGenerationRef, originalSourceRef, baseCanonicalRef, rootRef, diff --git a/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.save-race.test.ts b/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.save-race.test.ts index b74dd8fd23d..5115e8936b1 100644 --- a/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.save-race.test.ts +++ b/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.save-race.test.ts @@ -26,12 +26,15 @@ vi.mock('@/lib/workspace/ownership/connection-context', () => ({ getConnectionIdForFile: () => undefined })) // Hold the remote capability/status round trip before bytes are sent. -const hostCheck = vi.hoisted((): { wait: Promise | null } => ({ wait: null })) +const hostCheck = vi.hoisted((): { wait: Promise | null; arrived?: () => void } => ({ + wait: null +})) vi.mock('@/runtime/files/mutations/runtime-file-mutation-client', async (importOriginal) => { const actual = await importOriginal() return { ...actual, writeRuntimeFile: async (...args: Parameters) => { + hostCheck.arrived?.() await hostCheck.wait return actual.writeRuntimeFile(...args) } @@ -100,6 +103,7 @@ describe("Don't Save against running and queued saves", () => { afterEach(() => { hostCheck.wait = null + hostCheck.arrived = undefined detach() vi.useRealTimers() __clearSelfWriteRegistryForTests() @@ -161,6 +165,35 @@ describe("Don't Save against running and queued saves", () => { expect(written()).toEqual(['a']) }) + it('never sends a stale Overwrite after discard and reopening the same file', async () => { + const waiting = gate() + const arrived = gate() + hostCheck.wait = waiting.wait + hostCheck.arrived = arrived.open + edit('discarded draft') + const saving = requestEditorFileSave({ fileId: PATH, overwrite: true }) + await vi.advanceTimersByTimeAsync(0) + await arrived.wait + const discarding = discardEditorBuffer(PATH) + store.getState().closeFile(PATH) + store.getState().openFile({ + filePath: PATH, + relativePath: 'a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + withLoadedBaselines(store, createFakeEditorDisk({ [PATH]: '' })) + edit('new draft') + hostCheck.wait = null + waiting.open() + const [, release] = await Promise.all([saving, discarding]) + release() + expect(disk.files.get(PATH)).toBe('') + await requestEditorFileSave({ fileId: PATH }) + expect(disk.files.get(PATH)).toBe('new draft') + }) + it('a save asked for after the discard still writes', async () => { edit('a') const release = await discardEditorBuffer(PATH) diff --git a/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts b/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts index 01258099529..d225765cdc1 100644 --- a/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts +++ b/src/renderer/src/components/editor/save/flush/discard-editor-file-changes.ts @@ -1,9 +1,10 @@ import { useAppStore } from '@/store/index' import { editorBufferIdOfView, + editorBufferSiblingViewIds, isLastEditorBufferView } from '@/store/slices/editor/buffers/editor-buffer-views' -import { bumpEditorDiscardEpoch } from './editor-discard-epoch' +import { discardEditorGenerations } from './editor-discard-epoch' import { requestEditorSaveQuiesce } from '../../autosave/editor-autosave' import { flushPendingEditorBuffer } from './editor-pending-flush' @@ -14,7 +15,11 @@ export async function discardEditorBuffer(fileId: string): Promise<() => void> { return release } // Fence pending edits before flushing so intentional discards stay silent. - bumpEditorDiscardEpoch(editorBufferIdOfView(useAppStore.getState(), fileId)) + const before = useAppStore.getState() + discardEditorGenerations( + editorBufferIdOfView(before, fileId), + editorBufferSiblingViewIds(before, fileId) + ) try { flushPendingEditorBuffer(useAppStore.getState(), fileId) } catch (error) { diff --git a/src/renderer/src/components/editor/save/flush/editor-discard-epoch.ts b/src/renderer/src/components/editor/save/flush/editor-discard-epoch.ts index 0cac8e7f125..43e2c3244c3 100644 --- a/src/renderer/src/components/editor/save/flush/editor-discard-epoch.ts +++ b/src/renderer/src/components/editor/save/flush/editor-discard-epoch.ts @@ -1,13 +1,55 @@ -// Keyed by buffer record. Dirty state can be republished after a discard, so saves compare this. -const epochs = new Map() +export type EditorDiscardGeneration = { discarded: boolean } -export function editorDiscardEpoch(recordId: string): number { - return epochs.get(recordId) ?? 0 +// Pending work owns the generation; these weak lookups only let Don't Save reach it. +function generationLookup() { + const generations = new Map>() + const finalized = new FinalizationRegistry<{ + key: string + reference: WeakRef + }>(({ key, reference }) => { + if (generations.get(key) === reference) { + generations.delete(key) + } + }) + return { + capture(key: string): EditorDiscardGeneration { + const current = generations.get(key)?.deref() + if (current) { + return current + } + const generation = { discarded: false } + const reference = new WeakRef(generation) + generations.set(key, reference) + finalized.register(generation, { key, reference }) + return generation + }, + discard(key: string): void { + const current = generations.get(key)?.deref() + if (current) { + current.discarded = true + } + generations.delete(key) + } + } } -/** "Don't Save": every save requested before this writes nothing. */ -export function bumpEditorDiscardEpoch(recordId: string): void { - epochs.set(recordId, editorDiscardEpoch(recordId) + 1) +const saves = generationLookup() +const viewEdits = generationLookup() + +export function captureEditorDiscardGeneration(recordId: string): EditorDiscardGeneration { + return saves.capture(recordId) +} + +export function captureEditorViewEditGeneration(viewId: string): EditorDiscardGeneration { + return viewEdits.capture(viewId) +} + +/** "Don't Save": every operation captured before this writes nothing. */ +export function discardEditorGenerations(recordId: string, viewIds: readonly string[]): void { + saves.discard(recordId) + for (const viewId of viewIds) { + viewEdits.discard(viewId) + } } /** Thrown before sending a save that a discard voided while it waited on the host. */ diff --git a/src/renderer/src/components/editor/save/flush/editor-stale-view-edit.ts b/src/renderer/src/components/editor/save/flush/editor-stale-view-edit.ts index 9cb3d672b04..762c83a4a23 100644 --- a/src/renderer/src/components/editor/save/flush/editor-stale-view-edit.ts +++ b/src/renderer/src/components/editor/save/flush/editor-stale-view-edit.ts @@ -1,12 +1,13 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store/index' -import { editorBufferIdOfView } from '@/store/slices/editor/buffers/editor-buffer-views' -import { editorDiscardEpoch } from './editor-discard-epoch' +import { + captureEditorViewEditGeneration, + type EditorDiscardGeneration +} from './editor-discard-epoch' /** Snapshot when a delayed view edit starts; a Discard since then drops the edit silently. */ -export function staleViewEditEpoch(fileId: string): number { - return editorDiscardEpoch(editorBufferIdOfView(useAppStore.getState(), fileId)) +export function captureViewEditGeneration(fileId: string): EditorDiscardGeneration { + return captureEditorViewEditGeneration(fileId) } /** Focus moved outside the view, e.g. into another view of the same file, so its delayed edit must commit now. */ @@ -19,8 +20,8 @@ export function focusLeftView(event: { } /** A delayed view edit was dropped because the shared text changed after the edit began. */ -export function notifyStaleViewEditRejected(fileId: string, editEpoch: number): void { - if (staleViewEditEpoch(fileId) !== editEpoch) { +export function notifyStaleViewEditRejected(generation: EditorDiscardGeneration | null): void { + if (generation?.discarded) { return } toast.error( diff --git a/src/renderer/src/components/editor/save/queue/editor-save-queue.ts b/src/renderer/src/components/editor/save/queue/editor-save-queue.ts index 282f898454b..b2376882b6c 100644 --- a/src/renderer/src/components/editor/save/queue/editor-save-queue.ts +++ b/src/renderer/src/components/editor/save/queue/editor-save-queue.ts @@ -47,7 +47,7 @@ import { captureEditorSaveRequest } from './editor-save-writer-view' import { createEditorSaveSlots, type EditorSaveRequest } from './editor-save-slots' -import { EditorSaveDiscardedError, editorDiscardEpoch } from '../flush/editor-discard-epoch' +import { EditorSaveDiscardedError } from '../flush/editor-discard-epoch' import { hasEditorBytesDraft, saveEditorBytesDraft } from '../bytes/editor-bytes-documents' import { insertFinalNewlineBeforeSave } from '../transforms/editor-final-newline' @@ -103,8 +103,8 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { } const nothingToWrite = trigger === 'autosave' ? 'failed' : 'written' - // Check the queued record’s epoch before flushing discarded text; rejoined views may now have a different epoch. - const discarded = (): boolean => editorDiscardEpoch(request.recordId) !== request.discardEpoch + // Check the queued record’s generation before flushing discarded text; rejoined views may now have a different generation. + const discarded = (): boolean => request.discardGeneration.discarded if (discarded()) { return nothingToWrite } diff --git a/src/renderer/src/components/editor/save/queue/editor-save-slots.ts b/src/renderer/src/components/editor/save/queue/editor-save-slots.ts index 7b796312202..27adb95e21a 100644 --- a/src/renderer/src/components/editor/save/queue/editor-save-slots.ts +++ b/src/renderer/src/components/editor/save/queue/editor-save-slots.ts @@ -1,3 +1,4 @@ +import type { EditorDiscardGeneration } from '../flush/editor-discard-epoch' import type { acquireEditorFileOperation } from '@/lib/editor/file-operations/editor-file-operation-owner' import type { LocalFileAccess } from '../../../../../../shared/file-access/local-file-access' import type { EditorSaveResult, EditorSaveTrigger } from './editor-save-disk-precondition' @@ -19,10 +20,10 @@ export type EditorSaveRequest = { requestedDirty: boolean /** Preserve the saving view's kind if a different view takes over. */ requestedFromCodeView: boolean - /** The record the request was queued under; `discardEpoch` is its epoch then. */ + /** The record the request was queued under; `discardGeneration` fences its pending work. */ recordId: string /** A discard since the request voids it, whatever the trigger. */ - discardEpoch: number + discardGeneration: EditorDiscardGeneration } type SaveSlot = { @@ -56,8 +57,8 @@ export function createEditorSaveSlots( if (slot?.queued) { // Queued saves read live text, so subsequent requests can join the same write. const { request } = slot.queued - if (request.discardEpoch !== incoming.discardEpoch) { - // A discard invalidates the old request; retain the new request’s epoch and intent. + if (request.discardGeneration !== incoming.discardGeneration) { + // A discard invalidates the old request; retain the new request’s generation and intent. Object.assign(request, incoming) return slot.queued.promise } diff --git a/src/renderer/src/components/editor/save/queue/editor-save-writer-view.ts b/src/renderer/src/components/editor/save/queue/editor-save-writer-view.ts index 0e7e2edd4a5..285e73f4683 100644 --- a/src/renderer/src/components/editor/save/queue/editor-save-writer-view.ts +++ b/src/renderer/src/components/editor/save/queue/editor-save-writer-view.ts @@ -12,7 +12,7 @@ import { } from '@/store/slices/editor/buffers/editor-buffer-views' import { isCodeViewTab, type EditorSaveTrigger } from './editor-save-disk-precondition' import { canAutoSaveOpenFile } from '../../autosave/editor-autosave' -import { editorDiscardEpoch } from '../flush/editor-discard-epoch' +import { captureEditorDiscardGeneration } from '../flush/editor-discard-epoch' export function sameEditorSaveResource(state: AppState, source: OpenFile, view: OpenFile): boolean { const key = editorSaveResourceKey(source, state) @@ -129,6 +129,6 @@ export function captureEditorSaveRequest( requestedDirty: state.openFiles.some((tab) => tab.id === captured.id && tab.isDirty), requestedFromCodeView: isCodeViewTab(state, captured), recordId, - discardEpoch: editorDiscardEpoch(recordId) + discardGeneration: captureEditorDiscardGeneration(recordId) } } diff --git a/src/renderer/src/components/editor/save/transforms/editor-format-on-save.test.ts b/src/renderer/src/components/editor/save/transforms/editor-format-on-save.test.ts index 34d8f382841..93930f49129 100644 --- a/src/renderer/src/components/editor/save/transforms/editor-format-on-save.test.ts +++ b/src/renderer/src/components/editor/save/transforms/editor-format-on-save.test.ts @@ -14,7 +14,7 @@ import { __clearSelfWriteRegistryForTests } from '../../external-changes/editor- import { registerPendingEditorFlush } from '../flush/editor-pending-flush' import { usePluginPanelsStore } from '@/store/plugins/catalog/plugin-contribution-catalog' -import { bumpEditorDiscardEpoch, EditorSaveDiscardedError } from '../flush/editor-discard-epoch' +import { discardEditorGenerations, EditorSaveDiscardedError } from '../flush/editor-discard-epoch' import { editorBufferIdOfView } from '@/store/slices/editor/buffers/editor-buffer-views' vi.mock('@/lib/workspace/ownership/connection-context', () => ({ @@ -156,7 +156,7 @@ describe('format on the guarded save path', () => { const saving = queue.queueSave(file(), 'one') const rejection = expect(saving).rejects.toBeInstanceOf(EditorSaveDiscardedError) await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledOnce()) - bumpEditorDiscardEpoch(editorBufferIdOfView(store.getState(), file().id)) + discardEditorGenerations(editorBufferIdOfView(store.getState(), file().id), [file().id]) finish({ kind: 'formatted', text: 'ONE' }) await rejection expect(disk.files.get('/repo/a.ts')).toBe('') diff --git a/src/renderer/src/components/editor/save/transforms/editor-save-transform-pipeline.ts b/src/renderer/src/components/editor/save/transforms/editor-save-transform-pipeline.ts index 53516666e4e..2bfae4253aa 100644 --- a/src/renderer/src/components/editor/save/transforms/editor-save-transform-pipeline.ts +++ b/src/renderer/src/components/editor/save/transforms/editor-save-transform-pipeline.ts @@ -1,7 +1,7 @@ import type { OpenFile } from '@/store/slices/editor/types/open-file' import type { AppStoreApi } from '../queue/editor-save-queue-contract' import type { EditorSaveRequest } from '../queue/editor-save-slots' -import { EditorSaveDiscardedError, editorDiscardEpoch } from '../flush/editor-discard-epoch' +import { EditorSaveDiscardedError } from '../flush/editor-discard-epoch' import { editorFormatOnSave } from './editor-format-on-save' import { editorSavePluginHook } from './editor-save-plugin-hook' @@ -23,7 +23,7 @@ export function editorSaveTransform( text, () => { operation.assertAuthorityCurrent() - if (editorDiscardEpoch(request.recordId) !== request.discardEpoch) { + if (request.discardGeneration.discarded) { throw new EditorSaveDiscardedError() } } diff --git a/src/renderer/src/components/terminal-pane/layout/close/closed-terminal-leaf-notice.ts b/src/renderer/src/components/terminal-pane/layout/close/closed-terminal-leaf-notice.ts index 7c32233e608..a58bbc58049 100644 --- a/src/renderer/src/components/terminal-pane/layout/close/closed-terminal-leaf-notice.ts +++ b/src/renderer/src/components/terminal-pane/layout/close/closed-terminal-leaf-notice.ts @@ -1,6 +1,10 @@ +import { captureTerminalPaneNoticeRetirement } from '../../mounting/terminal-pane-notice-retirement' import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal' import { collapseParkedTerminalLeaf } from '../../parking/output-watchers/terminal-parked-pty-watcher' -import { isTerminalTabParked } from '../../parking/output-watchers/terminal-parked-watcher-registry' +import { + capturedPanesByTabId, + isTerminalTabParked +} from '../../parking/output-watchers/terminal-parked-watcher-registry' import type { TerminalTabCloseIntent } from '../../../terminal/terminal-tab-close-intent' /** Remove main-closed leaves idempotently so this notice and local exit handling can arrive in either order. */ @@ -9,6 +13,18 @@ export function applyClosedTerminalLeafNotice( leafId: string, lastPaneCloseReason: TerminalTabCloseIntent ): void { + const captured = capturedPanesByTabId.get(tabId) + const pane = captured?.panes.find((candidate) => candidate.leafId === leafId) + if (pane?.noticeLifetime) { + const retireNotice = captureTerminalPaneNoticeRetirement({ + tabId, + leafId, + worktreeId: captured!.worktreeId, + lifetime: pane.noticeLifetime + }) + delete pane.noticeLifetime + retireNotice() + } if (isTerminalTabParked(tabId)) { collapseParkedTerminalLeaf(tabId, leafId) return diff --git a/src/renderer/src/components/terminal-pane/layout/close/layout-owner-pane-close-command.ts b/src/renderer/src/components/terminal-pane/layout/close/layout-owner-pane-close-command.ts index 4f460b3e1d7..8928d32e45c 100644 --- a/src/renderer/src/components/terminal-pane/layout/close/layout-owner-pane-close-command.ts +++ b/src/renderer/src/components/terminal-pane/layout/close/layout-owner-pane-close-command.ts @@ -14,6 +14,7 @@ function sendPaneCloseToLayoutOwner( lastPane: () => void /** Close locally if main already removed the pane or no frame stream can deliver its removal. */ closeHere: () => void + accepted?: () => void } ): boolean { const { tabId, leafId } = pane @@ -23,6 +24,7 @@ function sendPaneCloseToLayoutOwner( { type: 'closePane', tabId, leafId }, { apply: (result) => { + on.accepted?.() // Already gone from main's copy (a workspace it lacks): no frame will drop it. if (result.alreadyClosed || !layoutFramesFlowing()) { forget() @@ -53,12 +55,14 @@ export function sendUserPaneCloseToLayoutOwner( getManager: () => PaneManager | null closeTab: () => void closePaneLocally: (paneId: number, committedLeafId?: string) => void + onAcceptedClose?: () => void } ): boolean { const { tabId, leafId, paneId } = pane return sendPaneCloseToLayoutOwner( { worktreeId: pane.worktreeId, tabId, leafId }, { + accepted: tab.onAcceptedClose, asToday: () => tab.closePaneLocally(paneId), lastPane: () => closeAfterLastPaneRefusal( diff --git a/src/renderer/src/components/terminal-pane/layout/close/retire-unbound-ipc-terminal-pane.ts b/src/renderer/src/components/terminal-pane/layout/close/retire-unbound-ipc-terminal-pane.ts index c100b0f45d5..9f3ebf911a5 100644 --- a/src/renderer/src/components/terminal-pane/layout/close/retire-unbound-ipc-terminal-pane.ts +++ b/src/renderer/src/components/terminal-pane/layout/close/retire-unbound-ipc-terminal-pane.ts @@ -1,3 +1,4 @@ +import { captureTerminalPaneNoticeRetirement } from '../../mounting/terminal-pane-notice-retirement' import { buildTerminalTabRetirementPlan, getTerminalPtyOwnershipIdentity @@ -33,6 +34,14 @@ export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement if (hasOtherOwner(leafId)) { return } + const retireNotice = captureTerminalPaneNoticeRetirement({ + tabId, + leafId, + worktreeId: plan.worktreeId, + ptyId, + transport, + getTransports: args.getTransports + }) const retirementPlan = { ...plan, ptyIds: [ptyId], @@ -59,9 +68,11 @@ export function retireUnboundIpcTerminalPane(args: UnboundTerminalPaneRetirement // A replacement can own even this same leaf by the time the reply arrives. if (!hasOtherOwner()) { requestRetirement() + retireNotice() } return true } }) requestRetirement() + retireNotice() } diff --git a/src/renderer/src/components/terminal-pane/layout/close/use-terminal-pane-close-actions.ts b/src/renderer/src/components/terminal-pane/layout/close/use-terminal-pane-close-actions.ts index afa1fe390e3..8667d0bf644 100644 --- a/src/renderer/src/components/terminal-pane/layout/close/use-terminal-pane-close-actions.ts +++ b/src/renderer/src/components/terminal-pane/layout/close/use-terminal-pane-close-actions.ts @@ -1,3 +1,4 @@ +import { captureTerminalPaneNoticeRetirement } from '../../mounting/terminal-pane-notice-retirement' import { useTerminalSelectionSearch } from '../../search/use-terminal-selection-search' import { useCallback, useEffect, useRef } from 'react' import { onActiveTerminalPaneCloseRequest } from './request-active-terminal-pane-close' @@ -111,6 +112,19 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr return } const leafId = manager.getLeafId(paneId) + const transport = paneTransportsRef.current.get(paneId) + const retireNotice = leafId + ? captureTerminalPaneNoticeRetirement({ + tabId, + worktreeId, + leafId, + transport, + ptyId: + transport?.getPtyId() ?? + useAppStore.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId], + getTransports: () => paneTransportsRef.current + }) + : undefined // Switch point: main closes the pane first; the window removes it when main's frame does. if ( ORCA_LAYOUT_OWNER_IS_MAIN && @@ -118,6 +132,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr sendUserPaneCloseToLayoutOwner( { worktreeId, tabId, leafId, paneId }, { + onAcceptedClose: retireNotice, getManager: () => managerRef.current, closeTab: () => onCloseTab(reason), closePaneLocally: (id, committedLeafId) => closePaneLocally(id, committedLeafId, reason) @@ -128,7 +143,7 @@ export function useTerminalPaneCloseActions(controller: TerminalPaneBindingContr } closePaneLocally(paneId, undefined, reason) }, - [closePaneLocally, onCloseTab, tabId, worktreeId, managerRef] + [closePaneLocally, onCloseTab, tabId, worktreeId, managerRef, paneTransportsRef] ) const getCloseDialogCopyKind = useCallback( (paneId: number) => resolveLeafCloseCopyKind(tabId, managerRef.current?.getLeafId(paneId)), diff --git a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-closed-handler.ts b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-closed-handler.ts index 36803b29faf..8ba4505dbe3 100644 --- a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-closed-handler.ts +++ b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-closed-handler.ts @@ -1,3 +1,4 @@ +import { captureTerminalPaneNoticeRetirement } from './terminal-pane-notice-retirement' import { useAppStore } from '@/store/index' import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph/graph-sync-scheduler' @@ -118,6 +119,16 @@ export function createTerminalPaneClosedHandler( if (isDetachedToTab) { transport.detach?.({ preserveExitObserver: false }) } else { + const retireNotice = leafId + ? captureTerminalPaneNoticeRetirement({ + tabId, + worktreeId, + leafId, + ptyId: closedPtyId, + transport, + getTransports: () => paneTransportsRef.current + }) + : undefined const ptyId = suppressIntentionalPaneCloseExit( transport, useAppStore.getState().suppressPtyExit @@ -133,6 +144,7 @@ export function createTerminalPaneClosedHandler( clearTabPtyId(tabId, ptyId) } transport.destroy?.() + retireNotice?.() } paneTransportsRef.current.delete(paneId) } diff --git a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-mount-cleanup.ts b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-mount-cleanup.ts index d239d53b463..82e9b2a3725 100644 --- a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-mount-cleanup.ts +++ b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-mount-cleanup.ts @@ -1,3 +1,4 @@ +import { getTerminalNoticeLifetime } from '../provider-notices/terminal-notice-lifetime' import { captureNotificationTransportOwner } from '@/attention/notification-subject-owner' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { TerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-refs' @@ -78,7 +79,10 @@ export function cleanupTerminalPaneMount(args: { const binding = panePtyBindingsRef.current.get(pane.id) as | { isUntouchedFreshSpawnPty?: (candidate: string) => boolean } | undefined + const noticePtyId = + ptyId ?? currentStore.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[pane.leafId] return { + noticeLifetime: noticePtyId ? getTerminalNoticeLifetime(noticePtyId) : undefined, ptyId, workspaceOwner: captureNotificationTransportOwner(paneTransportsRef.current.get(pane.id)), paneId: pane.id, diff --git a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-notice-retirement.ts b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-notice-retirement.ts new file mode 100644 index 00000000000..2deb0ecb369 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-notice-retirement.ts @@ -0,0 +1,52 @@ +import { useAppStore } from '@/store/index' +import { getTerminalPtyOwnershipIdentity } from '@/store/terminals/terminal-tab-retirement' +import { + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime, + type TerminalNoticeLifetime +} from '../provider-notices/terminal-notice-lifetime' +import type { PtyTransport } from '../pty-transport/pty-transport-types' +import { terminalPaneHasOtherOwner } from './terminal-pane-retirement-ownership' + +/** Capture before a final close can erase the binding or await main's frame. */ +export function captureTerminalPaneNoticeRetirement(args: { + tabId: string + leafId: string + worktreeId: string | null + ptyId?: string | null + lifetime?: TerminalNoticeLifetime + transport?: PtyTransport + getTransports?: () => ReadonlyMap +}): () => void { + const lifetime = args.lifetime ?? (args.ptyId ? getTerminalNoticeLifetime(args.ptyId) : undefined) + if (!lifetime || lifetime.retired) { + return () => {} + } + const identity = getTerminalPtyOwnershipIdentity( + useAppStore.getState(), + lifetime.ptyId, + args.worktreeId + ) + return () => { + if (lifetime.retired) { + return + } + const siblings = new Map( + [...(args.getTransports?.() ?? [])].filter(([, transport]) => transport !== args.transport) + ) + if ( + !terminalPaneHasOtherOwner( + { + getState: useAppStore.getState, + tabId: args.tabId, + getTransports: () => siblings + }, + identity, + args.worktreeId, + args.leafId + ) + ) { + retireTerminalNoticeLifetime(lifetime) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts index 82b8d877268..2b454663188 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts @@ -1,3 +1,4 @@ +import { isTerminalNoticeLifetimeCurrent } from '../../provider-notices/terminal-notice-lifetime' import { useAppStore } from '@/store/index' import { sleepingAgentRecordIndex } from '@/store/terminals/sleeping-agent-record-index' import { isTerminalLeafId } from '../../../../../../shared/workspace-layout/stable-pane-id' @@ -184,6 +185,10 @@ function resolveParkedTerminalPaneCandidates( ? { ...pane, paneId: prior.paneId, + ...(prior.noticeLifetime?.ptyId === pane.ptyId && + isTerminalNoticeLifetimeCurrent(prior.noticeLifetime) + ? { noticeLifetime: prior.noticeLifetime } + : {}), ...(prior.ptyId === pane.ptyId && prior.workspaceOwner ? { workspaceOwner: prior.workspaceOwner } : {}), diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts index a437fdf42e6..6c9f0fce236 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-pty-watcher.ts @@ -1,3 +1,5 @@ +import { getTerminalNoticeLifetime } from '../../provider-notices/terminal-notice-lifetime' +import { prepareAcceptedTerminalNoticeExit } from '../../pty-transport/delivery/pty-notice-exit' import { isTerminalLeafId } from '../../../../../../shared/workspace-layout/stable-pane-id' import { isProvenProcessExit } from '../../../../../../shared/terminal/pty/lifecycle/terminal-exit-cause' import { isRemoteRuntimePtyId } from '../../../../../../shared/remote-runtime/remote-runtime-pty-id' @@ -60,6 +62,7 @@ export function startParkedPtyWatcher(args: { ) { return } + const notice = getTerminalNoticeLifetime(ptyId) const handlePtyExit = (code: number, { hadPrimary }: { hadPrimary: boolean }): void => { // Replacement waits for a future connect, but the dead process’s working title must clear now. useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) @@ -74,6 +77,7 @@ export function startParkedPtyWatcher(args: { if (!provenExit) { useAppStore.getState().markUnverifiedPtyLoss(tab.id) } + const retireNotice = provenExit ? prepareAcceptedTerminalNoticeExit(notice) : () => {} // Detach removed the primary observer; this sidecar must preserve sleep state/layout and keep the buffered-exit tombstone against stale watcher re-registration. if (!hadPrimary && isSleepPreservedParkedPtyExit(ptyId)) { entry.disposersByPtyId.get(ptyId)?.() @@ -89,6 +93,7 @@ export function startParkedPtyWatcher(args: { } return } + retireNotice() if (entry.disposersByPtyId.size > 1) { discardPreHandlerPtyState(ptyId) collapseParkedExitedLeaf(tab.id, ptyId) @@ -166,17 +171,20 @@ export function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ?? Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0] if (leafId) { - collapseParkedTerminalLeaf(tabId, leafId, ptyId) + const retireNotice = prepareAcceptedTerminalNoticeExit(getTerminalNoticeLifetime(ptyId)) + if (collapseParkedTerminalLeaf(tabId, leafId, ptyId)) { + retireNotice() + } } } /** Removes one leaf from a parked tab's stored layout; a no-op once the leaf is gone. */ -export function collapseParkedTerminalLeaf(tabId: string, leafId: string, ptyId?: string): void { +export function collapseParkedTerminalLeaf(tabId: string, leafId: string, ptyId?: string): boolean { const state = useAppStore.getState() const layout = state.terminalLayoutsByTabId[tabId] const detached = detachTerminalLayoutLeaf(layout, leafId) if (!detached) { - return + return false } const worktreeId = getTerminalTabOwnerWorktreeId(state.tabsByWorktree, tabId) const terminalTab = worktreeId @@ -197,4 +205,5 @@ export function collapseParkedTerminalLeaf(tabId: string, leafId: string, ptyId? tabId, resolveTabTitleAfterPaneClose(state.runtimePaneTitlesByTabId[tabId] ?? {}, activePaneId) ) + return true } diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-watcher-registry.ts index 23641d38685..6d3f29e0710 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-watcher-registry.ts @@ -1,3 +1,7 @@ +import { + isTerminalNoticeLifetimeCurrent, + type TerminalNoticeLifetime +} from '../../provider-notices/terminal-notice-lifetime' /** Store-free registry/disposal lets shutdown slices stop watchers synchronously without re-entering store initialization through the watcher lifecycle import. */ import { WorkspaceParkedTerminalRegistry } from './workspace-parked-terminal-registry' import { invalidateTerminalParkingInputs } from '../terminal-parking-input-events' @@ -11,6 +15,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constant import { releaseTerminalScrollIntentKey } from '../../../../lib/pane-manager/terminal-scroll-intent-key-store' export type ParkedTerminalPaneCapture = { + noticeLifetime?: TerminalNoticeLifetime workspaceOwner?: NotificationWorkspaceOwner ptyId: string | null /** PaneManager numeric pane id the live pane used for runtime titles. */ @@ -108,6 +113,20 @@ export function captureParkedTerminalPaneCandidates( worktreeId: string, panes: ParkedTerminalPaneCapture[] ): void { + const previous = new Map( + capturedPanesByTabId.get(tabId)?.panes.map((pane) => [pane.leafId, pane]) + ) + for (const pane of panes) { + const prior = previous.get(pane.leafId)?.noticeLifetime + if ( + !pane.noticeLifetime && + prior && + prior.ptyId === pane.ptyId && + isTerminalNoticeLifetimeCurrent(prior) + ) { + pane.noticeLifetime = prior + } + } capturedPanesByTabId.set(tabId, { worktreeId, panes }) } diff --git a/src/renderer/src/components/terminal-pane/provider-notices/claude-account-shell/ClaudeOldTerminalBanner.tsx b/src/renderer/src/components/terminal-pane/provider-notices/claude-account-shell/ClaudeOldTerminalBanner.tsx index 08f7c906dbc..37d2b7206af 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/claude-account-shell/ClaudeOldTerminalBanner.tsx +++ b/src/renderer/src/components/terminal-pane/provider-notices/claude-account-shell/ClaudeOldTerminalBanner.tsx @@ -1,3 +1,8 @@ +import { + getTerminalNoticeLifetime, + isTerminalNoticeLifetimeCurrent, + type TerminalNoticeLifetime +} from '../terminal-notice-lifetime' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' @@ -13,9 +18,6 @@ import { PaneTopWarningBanner } from '../../appearance/pane-top-banner/PaneTopWarningBanner' -// Why module scope: a pane remounts on tab switches, and Dismiss must hold for the app session. -const dismissedPtyIds = new Set() - // System default already matches new terminals; key explicit selections so account switches recheck old shells. function selectedClaudeAccountKey(state: AppState): string | null { const settings = state.settings @@ -31,13 +33,17 @@ function selectedClaudeAccountKey(state: AppState): string | null { function useOpenedBeforeClaudeAccounts( ptyId: string, tabId: string, - selectionKey: string | null + selectionKey: string | null, + lifetime: TerminalNoticeLifetime | undefined ): boolean { - const [openedBefore, setOpenedBefore] = useState(false) + const [answer, setAnswer] = useState<{ + lifetime: TerminalNoticeLifetime + openedBefore: boolean + }>() useEffect(() => { const target = selectionKey ? paneClaudeTarget(tabId) : null // Why hidden without a target: only the pane's own runtime says which account it runs. - if (!target) { + if (!target || !lifetime) { return } let cancelled = false @@ -45,16 +51,16 @@ function useOpenedBeforeClaudeAccounts( .openedBeforeClaudeAccounts(ptyId, target) .catch(() => false) .then((answer) => { - if (!cancelled) { - setOpenedBefore(answer) + if (!cancelled && isTerminalNoticeLifetimeCurrent(lifetime)) { + setAnswer({ lifetime, openedBefore: answer }) } }) return () => { cancelled = true - setOpenedBefore(false) + setAnswer(undefined) } - }, [selectionKey, ptyId, tabId]) - return openedBefore + }, [selectionKey, ptyId, tabId, lifetime]) + return answer?.lifetime === lifetime && answer?.openedBefore === true } /** For Claude in a terminal whose shell predates per-account folders: its `claude` ignores the selection. */ @@ -69,7 +75,11 @@ export function ClaudeOldTerminalBanner({ }): React.JSX.Element | null { const t = useTranslate() const paneKey = makePaneKey(tabId, leafId) - const [dismissed, setDismissed] = useState(() => dismissedPtyIds.has(ptyId)) + const lifetime = getTerminalNoticeLifetime(ptyId) + const [dismissedLifetime, setDismissedLifetime] = useState() + const dismissed = + lifetime !== undefined && + (dismissedLifetime === lifetime || lifetime.dismissed.has('claude-old-terminal')) const selectionKey = useAppStore(selectedClaudeAccountKey) // Why either signal: a typed claude is seen by the process read, or by its hooks. const claudeInPane = useAppStore( @@ -80,7 +90,8 @@ export function ClaudeOldTerminalBanner({ const openedBefore = useOpenedBeforeClaudeAccounts( ptyId, tabId, - claudeInPane && !dismissed ? selectionKey : null + claudeInPane && !dismissed ? selectionKey : null, + lifetime ) const [dialogOpen, setDialogOpen] = useState(false) // Why: the dialog has no Radix trigger, so closing it would leave focus on document.body. @@ -115,8 +126,10 @@ export function ClaudeOldTerminalBanner({ } onDismiss={() => { - dismissedPtyIds.add(ptyId) - setDismissed(true) + if (lifetime && isTerminalNoticeLifetimeCurrent(lifetime)) { + lifetime.dismissed.add('claude-old-terminal') + } + setDismissedLifetime(lifetime) }} /> ) : null} diff --git a/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/CodexSharedServerBanner.tsx b/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/CodexSharedServerBanner.tsx index b6a50937327..0fc1a753b70 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/CodexSharedServerBanner.tsx +++ b/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/CodexSharedServerBanner.tsx @@ -1,3 +1,8 @@ +import { + getTerminalNoticeLifetime, + isTerminalNoticeLifetimeCurrent, + type TerminalNoticeLifetime +} from '../terminal-notice-lifetime' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' @@ -17,8 +22,6 @@ import { retireCodexTerminalServerIsolationNotice } from './codex-terminal-serve // Why a ladder: Codex joins or starts the server a few seconds after its process appears. const CHECK_DELAYS_MS = [1_000, 4_000, 10_000] as const -// Why module scope: a pane remounts on tab switches, and × must hold for the app session. -const dismissedPtyIds = new Set() /** Asks on the ladder until an answer is yes; returns a cancel that drops any later answer. */ function askUntilOnSharedServer( @@ -54,20 +57,28 @@ function askUntilOnSharedServer( function usePaneCodexSharedServerStatus( ptyId: string, enabled: boolean, - recheck: number + recheck: number, + lifetime: TerminalNoticeLifetime | undefined ): CodexSharedServerStatus | null { - const [status, setStatus] = useState(null) + const [answer, setAnswer] = useState<{ + lifetime: TerminalNoticeLifetime + status: CodexSharedServerStatus + }>() useEffect(() => { - if (!enabled) { + if (!enabled || !lifetime) { return } - const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, setStatus) + const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, (status) => { + if (isTerminalNoticeLifetimeCurrent(lifetime)) { + setAnswer({ lifetime, status }) + } + }) return () => { cancel() - setStatus(null) + setAnswer(undefined) } - }, [enabled, ptyId, recheck]) - return status + }, [enabled, ptyId, recheck, lifetime]) + return answer?.lifetime === lifetime ? (answer?.status ?? null) : null } export function CodexSharedServerBanner({ @@ -81,7 +92,11 @@ export function CodexSharedServerBanner({ }): React.JSX.Element | null { const t = useTranslate() const paneKey = makePaneKey(tabId, leafId) - const [dismissed, setDismissed] = useState(() => dismissedPtyIds.has(ptyId)) + const lifetime = getTerminalNoticeLifetime(ptyId) + const [dismissedLifetime, setDismissedLifetime] = useState() + const dismissed = + lifetime !== undefined && + (dismissedLifetime === lifetime || lifetime.dismissed.has('codex-shared-server')) const warningEnabled = useAppStore( (state) => state.settings !== null && isCodexSharedServerWarningEnabled(state.settings) ) @@ -96,7 +111,8 @@ export function CodexSharedServerBanner({ const status = usePaneCodexSharedServerStatus( ptyId, warningEnabled && codexInPane && !dismissed, - recheck + recheck, + lifetime ) const [openDialog, setOpenDialog] = useState<'fix' | 'oldTerminal' | null>(null) // Why: neither dialog has a Radix trigger, so closing it would leave focus on document.body. @@ -164,8 +180,10 @@ export function CodexSharedServerBanner({ body={body} primaryAction={primaryAction} onDismiss={() => { - dismissedPtyIds.add(ptyId) - setDismissed(true) + if (lifetime && isTerminalNoticeLifetimeCurrent(lifetime)) { + lifetime.dismissed.add('codex-shared-server') + } + setDismissedLifetime(lifetime) }} onDontShowAgain={() => void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) diff --git a/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/codex-shared-server-banner.test.tsx b/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/codex-shared-server-banner.test.tsx index 33a621adbc1..611d94f1b68 100644 --- a/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/codex-shared-server-banner.test.tsx +++ b/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/codex-shared-server-banner.test.tsx @@ -8,8 +8,37 @@ import type { GlobalSettings } from '../../../../../../shared/global-settings-ty import { getDefaultSettings } from '../../../../../../shared/constants' import { CodexSharedServerBanner } from './CodexSharedServerBanner' import type { CodexSharedServerStatus } from '../../../../../../shared/codex-shared-server-command' +import { + admitTerminalNoticeLifetime, + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime +} from '../terminal-notice-lifetime' +import { + installIpcPtyWindow, + restorePtySpecWindow, + type PtyExitPayload +} from '../../pty-transport/ipc/pty-transport-test-harness' +import { closeTerminalTab } from '../../../terminal/terminal-tab-actions' +import { makeTab } from '@/store/terminals/terminal-tab.test-fixture' +import { mergeFetchedWorktrees } from '@/store/slices/worktrees/listing/fetched-worktree-merge' +import { TEST_REPO } from '@/store/repos/repo.test-fixture' +import { makeWorktree } from '@/store/slices/worktrees/worktree.test-fixture' +import { retireParkedTerminalTab } from '../../parking/output-watchers/terminal-parked-watcher-registry' +import { applyClosedTerminalLeafNotice } from '../../layout/close/closed-terminal-leaf-notice' import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' +import { + renderNoticePaneLifecycle, + deliverNoticePaneStartupExit +} from './terminal-notice-pane.test-fixture' +import type { PtyTransport } from '../../pty-transport/pty-transport-types' + +import { retireUnboundIpcTerminalPane } from '../../layout/close/retire-unbound-ipc-terminal-pane' + +import { startParkedPtyWatcher } from '../../parking/output-watchers/terminal-parked-pty-watcher' +import type { ParkedTabWatcherEntry } from '../../parking/output-watchers/terminal-parked-watcher-registry' +import { settleDeferredPtyShutdownExits } from '../../pty-transport/shutdown/pty-shutdown-exit-deferral' + const routing = vi.hoisted(() => ({ activateAndRevealWorkspace: vi.fn<(id: string) => unknown>(), revealFloatingWorkspacePanel: vi.fn(), @@ -45,6 +74,7 @@ let writeClipboardText: ReturnType Promise> let updateSettings: ReturnType) => Promise>> let nextPtyId = 0 let ptyId: string +let admitted: ReturnType class ResizeObserverStub { observe(): void {} @@ -59,6 +89,14 @@ function setState(settings: Partial, agent: 'codex' | null = 'co }) } +function installPtyEvents(sinks: Parameters[1]): void { + const domWindow = window + installIpcPtyWindow(domWindow, sinks) + const api = window.api + restorePtySpecWindow(domWindow) + Object.defineProperty(domWindow, 'api', { configurable: true, value: api }) +} + async function renderBanner(): Promise { await act(async () => { root.render() @@ -71,6 +109,10 @@ async function advance(ms: number): Promise { }) } +async function renderPaneLifecycle(transport: PtyTransport, worktreeId = 'wt-1') { + return renderNoticePaneLifecycle(root, ptyId, TAB_ID, LEAF_ID, transport, worktreeId) +} + // Why document: the dialog portals out of the pane. function button(label: string): HTMLButtonElement { const match = Array.from(document.querySelectorAll('button')).find( @@ -89,7 +131,8 @@ beforeEach(() => { routing.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) vi.stubGlobal('ResizeObserver', ResizeObserverStub) useAppStore.setState(useAppStore.getInitialState(), true) - ptyId = `pty-${(nextPtyId += 1)}` + ptyId = `wt-1@@pty-${(nextPtyId += 1)}` + admitted = admitTerminalNoticeLifetime(ptyId, { fresh: true }) paneElement = document.createElement('div') paneElement.className = 'pane' document.body.appendChild(paneElement) @@ -112,6 +155,10 @@ beforeEach(() => { }) afterEach(() => { + const current = getTerminalNoticeLifetime(ptyId) + if (current) { + retireTerminalNoticeLifetime(current) + } act(() => root.unmount()) paneElement.remove() vi.unstubAllGlobals() @@ -121,7 +168,7 @@ afterEach(() => { }) describe('CodexSharedServerBanner', () => { - it('keeps asking while Codex starts, then stops once it has an answer', async () => { + it('shows the warning after Codex joins the shared server', async () => { setState({}) isCodexOnSharedServer.mockResolvedValueOnce(NOT_JOINED) await renderBanner() @@ -129,8 +176,6 @@ describe('CodexSharedServerBanner', () => { expect(paneElement.textContent).toBe('') await advance(4_000) expect(paneElement.textContent).toContain(TITLE) - await advance(60_000) - expect(isCodexOnSharedServer).toHaveBeenCalledTimes(2) }) it('confirms before stopping the server, then hides once it is gone', async () => { @@ -160,20 +205,470 @@ describe('CodexSharedServerBanner', () => { expect(paneElement.textContent).toBe('') }) - it('dismisses for this pane only, and stays dismissed after a remount', async () => { + it('keeps a live dismissal through detach and loss, then shows for a later attached lifetime', async () => { + retireTerminalNoticeLifetime(admitted) + let exit: ((payload: PtyExitPayload) => void) | undefined + installPtyEvents({ + exit: (callback) => { + exit = callback + } + }) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const { ensurePtyDispatcher, reattachPtyDispatcherPushListeners } = + await import('../../pty-transport/delivery/pty-dispatcher') + ensurePtyDispatcher() + reattachPtyDispatcherPushListeners() setState({}) + let transport = createIpcPtyTransport({}) + const attach = () => { + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1', ptyId: null })] } + }) + useAppStore.getState().setPaneForegroundAgent(PANE_KEY, { + agent: 'codex', + shellForeground: false + }) + transport.attach({ + existingPtyId: ptyId, + callbacks: { + onExit: (code) => { + if (code >= 0) { + closeTerminalTab(TAB_ID, { hostCloseReason: 'pty-exit', lifecyclePtyId: ptyId }) + } + } + } + }) + } + attach() await renderBanner() await advance(1_000) await act(async () => button('Dismiss').click()) expect(paneElement.textContent).toBe('') - expect(paneElement.style.getPropertyValue('--orca-pane-top-banner-height')).toBe('') - expect(updateSettings).not.toHaveBeenCalled() - + transport.detach?.() + // The move path removes a tab with pty-exit while its process survives. + act(() => useAppStore.getState().closeTab(TAB_ID, { reason: 'pty-exit' })) act(() => root.unmount()) root = createRoot(paneElement) + transport = createIpcPtyTransport({}) + attach() await renderBanner() await advance(20_000) expect(paneElement.textContent).toBe('') + await act(async () => exit?.({ id: ptyId, code: -1 })) + transport = createIpcPtyTransport({}) + attach() + await renderBanner() + await advance(20_000) + expect(paneElement.textContent).toBe('') + await act(async () => exit?.({ id: ptyId, code: 0 })) + transport = createIpcPtyTransport({}) + attach() + await renderBanner() + await advance(1_000) + expect(paneElement.querySelector('button')).not.toBeNull() + transport.detach?.() + }) + + it('keeps dismissal after an unstamped buffered exit, then accepts a stamped death', async () => { + retireTerminalNoticeLifetime(admitted) + let exit: ((payload: PtyExitPayload) => void) | undefined + installPtyEvents({ + exit: (callback) => { + exit = callback + } + }) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const { ensurePtyDispatcher, reattachPtyDispatcherPushListeners } = + await import('../../pty-transport/delivery/pty-dispatcher') + ensurePtyDispatcher() + reattachPtyDispatcherPushListeners() + const spawn = vi.mocked(window.api.pty.spawn) + spawn.mockResolvedValueOnce({ id: ptyId, incarnationId: 'current-incarnation' }) + let transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Dismiss').click()) + transport.detach?.({ preserveExitObserver: false }) + spawn.mockImplementationOnce(async () => { + exit?.({ id: ptyId, code: 0 }) + return { id: ptyId, incarnationId: 'current-incarnation', isReattach: true } + }) + transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + transport = createIpcPtyTransport({}) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + await renderBanner() + await advance(20_000) + expect(paneElement.textContent).toBe('') + await act(async () => exit?.({ id: ptyId, code: 0, incarnationId: 'current-incarnation' })) + transport = createIpcPtyTransport({}) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + await renderBanner() + await advance(1_000) + expect(paneElement.querySelector('button')).not.toBeNull() + transport.detach?.({ preserveExitObserver: false }) + }) + + it('keeps dismissal on shutdown rollback and resets it on committed retirement', async () => { + retireTerminalNoticeLifetime(admitted) + let exit: ((payload: PtyExitPayload) => void) | undefined + installPtyEvents({ + exit: (callback) => { + exit = callback + } + }) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const { ensurePtyDispatcher, reattachPtyDispatcherPushListeners } = + await import('../../pty-transport/delivery/pty-dispatcher') + ensurePtyDispatcher() + reattachPtyDispatcherPushListeners() + const { settleDeferredPtyShutdownExits } = + await import('../../pty-transport/shutdown/pty-shutdown-exit-deferral') + vi.mocked(window.api.pty.spawn).mockResolvedValueOnce({ + id: ptyId, + incarnationId: 'current-incarnation' + }) + let transport = createIpcPtyTransport({}) + await transport.connect({ url: '', callbacks: {} }) + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Dismiss').click()) + useAppStore.setState({ pendingPtyShutdownIds: { [ptyId]: 1 } }) + await act(async () => exit?.({ id: ptyId, code: 0, incarnationId: 'current-incarnation' })) + settleDeferredPtyShutdownExits([ptyId], 'rolled-back') + useAppStore.setState({ pendingPtyShutdownIds: {} }) + transport = createIpcPtyTransport({}) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + await renderBanner() + await advance(20_000) + expect(paneElement.textContent).toBe('') + useAppStore.setState({ pendingPtyShutdownIds: { [ptyId]: 1 } }) + await act(async () => exit?.({ id: ptyId, code: 0, incarnationId: 'current-incarnation' })) + settleDeferredPtyShutdownExits([ptyId], 'committed') + useAppStore.setState({ pendingPtyShutdownIds: {} }) + transport = createIpcPtyTransport({}) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + await renderBanner() + await advance(1_000) + expect(paneElement.querySelector('button')).not.toBeNull() + transport.detach?.() + }) + + async function expectWarningAfterRetirement( + retire: (worktreeId: string, transport: PtyTransport) => void | Promise + ) { + installPtyEvents({}) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + let transport = createIpcPtyTransport({}) + const worktreeId = 'repo::/notice-retirement' + const publish = () => { + useAppStore.setState({ + repos: [{ ...TEST_REPO, id: 'repo', executionHostId: 'local' }], + worktreesByRepo: { + repo: [makeWorktree({ id: worktreeId, repoId: 'repo', hostId: 'local' })] + }, + tabsByWorktree: { [worktreeId]: [makeTab({ id: TAB_ID, worktreeId, ptyId })] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + } + } + }) + useAppStore + .getState() + .setPaneForegroundAgent(PANE_KEY, { agent: 'codex', shellForeground: false }) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + } + setState({}) + publish() + await renderBanner() + await advance(1_000) + await act(async () => button('Dismiss').click()) + transport.detach?.({ preserveExitObserver: false }) + await retire(worktreeId, transport) + transport = createIpcPtyTransport({}) + publish() + await renderBanner() + await advance(1_000) + expect(paneElement.querySelector('button')).not.toBeNull() + transport.detach?.({ preserveExitObserver: false }) + retireParkedTerminalTab(TAB_ID) + } + + it('shows the warning for a reused terminal after explicit purge', async () => { + await expectWarningAfterRetirement((worktreeId) => { + useAppStore.getState().purgeWorktreeTerminalState([worktreeId]) + }) + }) + + it('shows the warning for a reused terminal after authoritative listing', async () => { + await expectWarningAfterRetirement(() => { + mergeFetchedWorktrees(useAppStore.setState, useAppStore.getState, { + repoId: 'repo', + hostId: 'local', + ownerWasMissingAtStart: false, + requestStartedWorktrees: useAppStore.getState().worktreesByRepo.repo, + refresh: { + status: 'admitted', + executionHostId: 'local', + result: { repoId: 'repo', authoritative: true, source: 'git', worktrees: [] } + } + }) + }) + }) + + it('shows the warning for a reused terminal after parked main close', async () => { + await expectWarningAfterRetirement(async (worktreeId, transport) => { + const pane = await renderPaneLifecycle(transport, worktreeId) + await act(async () => { + pane.park() + useAppStore.setState({ terminalLayoutsByTabId: {} }) + applyClosedTerminalLeafNotice(TAB_ID, LEAF_ID, 'user') + }) + }) + }) + + function publishPaneOwner() { + useAppStore.setState({ + repos: [{ ...TEST_REPO, id: 'repo', executionHostId: 'local' }], + worktreesByRepo: { repo: [makeWorktree({ id: 'wt-1', repoId: 'repo', hostId: 'local' })] }, + tabsByWorktree: { 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1', ptyId })] }, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: ptyId } + } + } + }) + } + + async function dismissPaneWarning() { + installPtyEvents({}) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + publishPaneOwner() + setState({}) + const transport = createIpcPtyTransport({}) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + const pane = await renderPaneLifecycle(transport) + await advance(1_000) + await act(async () => button('Dismiss').click()) + return { ...pane, transport } + } + + async function reattachPaneWarning() { + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const next = createIpcPtyTransport({}) + next.attach({ existingPtyId: ptyId, callbacks: {} }) + setState({}) + await renderBanner() + await advance(1_000) + next.detach?.({ preserveExitObserver: false }) + } + + it('releases a closed bound pane dismissal when its own transport was its last owner', async () => { + const pane = await dismissPaneWarning() + await act(async () => pane.close()) + await reattachPaneWarning() + expect(paneElement.querySelector('button')).not.toBeNull() + }) + + it('keeps a dismissal when a replacement transport still owns the same PTY', async () => { + const pane = await dismissPaneWarning() + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const replacement = createIpcPtyTransport({}) + replacement.attach({ existingPtyId: ptyId, callbacks: {} }) + pane.transports.set(2, replacement) + await act(async () => pane.close()) + replacement.detach?.({ preserveExitObserver: false }) + await reattachPaneWarning() + expect(paneElement.textContent).toBe('') + }) + + it('releases an old-host exit dismissal while keeping the startup failure readable', async () => { + const pane = await dismissPaneWarning() + await deliverNoticePaneStartupExit(pane.transport, pane.transports, ptyId, TAB_ID, PANE_KEY) + expect(useAppStore.getState().tabsByWorktree['wt-1'].map((tab) => tab.id)).toEqual([TAB_ID]) + pane.transport.detach?.({ preserveExitObserver: false }) + await reattachPaneWarning() + expect(paneElement.querySelector('button')).not.toBeNull() + }) + + it('releases a pending unbound close without letting its late reply retire a successor', async () => { + const pane = await dismissPaneWarning() + pane.transport.detach?.({ preserveExitObserver: false }) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const spawn = Promise.withResolvers<{ id: string }>() + vi.mocked(window.api.pty.spawn).mockReturnValueOnce(spawn.promise) + const pending = createIpcPtyTransport({}) + const connecting = pending.connect({ url: '', sessionId: ptyId, callbacks: {} }) + await act(async () => + retireUnboundIpcTerminalPane({ + getState: useAppStore.getState, + tabId: TAB_ID, + leafId: LEAF_ID, + transport: pending, + getTransports: () => new Map([[1, pending]]) + }) + ) + await reattachPaneWarning() + expect(paneElement.querySelector('button')).not.toBeNull() + await act(async () => button('Dismiss').click()) + await act(async () => { + spawn.resolve({ id: ptyId }) + await connecting + }) + await reattachPaneWarning() + expect(paneElement.textContent).toBe('') + }) + + async function parkedExitFixture() { + const pane = await dismissPaneWarning() + pane.park() + let exit: ((payload: PtyExitPayload) => void) | undefined + installPtyEvents({ + exit: (callback) => { + exit = callback + } + }) + const { ensurePtyDispatcher, reattachPtyDispatcherPushListeners } = + await import('../../pty-transport/delivery/pty-dispatcher') + ensurePtyDispatcher() + reattachPtyDispatcherPushListeners() + const entry: ParkedTabWatcherEntry = { + worktreeId: 'wt-1', + tabPtyId: ptyId, + paneIdByPtyId: new Map(), + disposersByPtyId: new Map() + } + startParkedPtyWatcher({ + worktreeId: 'wt-1', + tab: { id: TAB_ID, ptyId }, + entry, + pane: { ptyId, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true, untouchedFreshSpawn: true }, + restoreTitleOnRegister: false, + restorePolicy: {} + }) + return { + exit: () => exit?.({ id: ptyId, code: 1 }), + dispose: () => entry.disposersByPtyId.get(ptyId)?.() + } + } + + it('releases a parked old-host startup failure dismissal without removing its diagnostic tab', async () => { + const parked = await parkedExitFixture() + await act(async () => parked.exit()) + expect(useAppStore.getState().tabsByWorktree['wt-1'].map((tab) => tab.id)).toEqual([TAB_ID]) + parked.dispose() + await reattachPaneWarning() + expect(paneElement.querySelector('button')).not.toBeNull() + retireParkedTerminalTab(TAB_ID) + }) + + it('keeps a parked dismissal when a pending shutdown rolls back', async () => { + const parked = await parkedExitFixture() + useAppStore.setState({ pendingPtyShutdownIds: { [ptyId]: 1 } }) + await act(async () => parked.exit()) + useAppStore.setState({ pendingPtyShutdownIds: {} }) + await act(async () => settleDeferredPtyShutdownExits([ptyId], 'rolled-back')) + parked.dispose() + await reattachPaneWarning() + expect(paneElement.textContent).toBe('') + retireParkedTerminalTab(TAB_ID) + }) + + it('releases a dismissal only after workspace removal succeeds', async () => { + await expectWarningAfterRetirement(async (worktreeId) => { + Object.assign(window.api, { worktrees: { forgetLocal: vi.fn().mockResolvedValue({}) } }) + const result = await useAppStore + .getState() + .removeWorktree({ id: worktreeId, executionHostId: 'local' }, false, { + mode: 'forget-local' + }) + expect(result.ok).toBe(true) + }) + }) + + it('keeps the dismissal when workspace removal fails at dispatch', async () => { + const pane = await dismissPaneWarning() + const forgetLocal = vi.fn().mockRejectedValue(new Error('disk unavailable')) + Object.assign(window.api, { worktrees: { forgetLocal } }) + const result = await useAppStore + .getState() + .removeWorktree({ id: 'wt-1', executionHostId: 'local' }, false, { mode: 'forget-local' }) + expect(result).toEqual({ ok: false, error: 'disk unavailable' }) + pane.transport.detach?.({ preserveExitObserver: false }) + await reattachPaneWarning() + expect(paneElement.textContent).toBe('') + }) + + it('preserves a shared dismissal when a workspace is purged', async () => { + installPtyEvents({}) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + let transport = createIpcPtyTransport({}) + setState({}) + useAppStore.setState({ + tabsByWorktree: { + gone: [makeTab({ id: TAB_ID, worktreeId: 'gone', ptyId })], + kept: [makeTab({ id: 'shared-owner', worktreeId: 'kept', ptyId })] + } + }) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + await renderBanner() + await advance(1_000) + await act(async () => button('Dismiss').click()) + transport.detach?.({ preserveExitObserver: false }) + await act(async () => useAppStore.getState().purgeWorktreeTerminalState(['gone'])) + transport = createIpcPtyTransport({}) + transport.attach({ existingPtyId: ptyId, callbacks: {} }) + setState({}) + await renderBanner() + await advance(20_000) + expect(paneElement.textContent).toBe('') + transport.detach?.({ preserveExitObserver: false }) + }) + + it('retires the freshly admitted same-ID parked owner after an older capture', async () => { + installPtyEvents({}) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1', ptyId })] } + }) + const old = createIpcPtyTransport({}) + old.attach({ existingPtyId: ptyId, callbacks: {} }) + const oldPane = await renderPaneLifecycle(old) + oldPane.park() + const transport = createIpcPtyTransport({}) + vi.mocked(window.api.pty.spawn).mockResolvedValueOnce({ id: ptyId }) + await transport.connect({ url: '', callbacks: {} }) + setState({}) + await renderBanner() + await advance(1_000) + await act(async () => button('Dismiss').click()) + const freshPane = await renderPaneLifecycle(transport) + freshPane.park() + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: TAB_ID, worktreeId: 'wt-1', ptyId: 'surviving-sibling' })] + }, + terminalLayoutsByTabId: {} + }) + await act(async () => applyClosedTerminalLeafNotice(TAB_ID, LEAF_ID, 'user')) + const next = createIpcPtyTransport({}) + next.attach({ existingPtyId: ptyId, callbacks: {} }) + setState({}) + await renderBanner() + await advance(1_000) + expect(paneElement.querySelector('button')).not.toBeNull() + next.detach?.({ preserveExitObserver: false }) + retireParkedTerminalTab(TAB_ID) }) describe('returning focus when a dialog closes', () => { diff --git a/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/terminal-notice-pane.test-fixture.tsx b/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/terminal-notice-pane.test-fixture.tsx new file mode 100644 index 00000000000..4fc16b2f75b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/provider-notices/codex-server-isolation/terminal-notice-pane.test-fixture.tsx @@ -0,0 +1,136 @@ +import { bindPanePtyExit } from '../../pty-connection/lifecycle/pane-pty-exit' +import type { ConnectPanePtySession } from '../../pty-connection/connect-pane-pty-session' +import { act, createRef, useImperativeHandle } from 'react' +import type { Root } from 'react-dom/client' +import { useAppStore } from '@/store/index' +import { CodexSharedServerBanner } from './CodexSharedServerBanner' +import { useTerminalPaneLifecycleRefs } from '../../mounting/use-terminal-pane-lifecycle-refs' +import { + useTerminalPaneLayoutBindings, + type TerminalPaneBindingController +} from '../../layout/ownership/use-terminal-pane-layout-bindings' +import { cleanupTerminalPaneMount } from '../../mounting/terminal-pane-mount-cleanup' +import { createTerminalPaneClosedHandler } from '../../mounting/terminal-pane-closed-handler' +import type { PtyTransport } from '../../pty-transport/pty-transport-types' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' + +export async function renderNoticePaneLifecycle( + root: Root, + ptyId: string, + tabId: string, + leafId: string, + transport: PtyTransport, + worktreeId: string +) { + const transports = new Map([[1, transport]]) + const managerFixture = { + getPanes: () => [{ id: 1, leafId }], + getLeafId: () => leafId, + getActivePane: () => null, + destroy: () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only these manager operations are reached by close and mount cleanup. + const manager = managerFixture as unknown as PaneManager + const actions = createRef<{ park: () => void; close: () => void }>() + function PaneLifecycle() { + const refs = useTerminalPaneLifecycleRefs() + const state = useAppStore.getState() + const partial: Partial = { + tabId, + worktreeId, + managerRef: { current: manager }, + paneTransportsRef: { current: transports }, + panePtyBindingsRef: { current: new Map() }, + paneMode2031Ref: { current: new Map() }, + paneKittyKeyboardModesRef: { current: new Map() }, + paneLastThemeModeRef: { current: new Map() }, + paneCwdRef: { current: new Map() }, + replayingPanesRef: refs.replayingPanesRef, + paneTitlesRef: { current: {} }, + containerRef: { current: null }, + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + setTabLayout: state.setTabLayout, + clearTabPtyId: state.clearTabPtyId, + clearRuntimePaneTitle: state.clearRuntimePaneTitle, + updateTabTitle: state.updateTabTitle, + setTabPaneExpanded: state.setTabPaneExpanded, + setTabCanExpandPane: state.setTabCanExpandPane, + setPaneTitles: () => {}, + setRenamingPaneId: () => {}, + setPaneCount: () => {}, + setExpandedPaneId: () => {}, + persistLayoutSnapshot: () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All controller fields used by layout bindings, close and cleanup are supplied. + const controller = partial as TerminalPaneBindingController + Object.assign(controller, useTerminalPaneLayoutBindings(controller)) + const context = { deps: controller, refs, deferredSplitHandoffs: new Map() } + const closed = createTerminalPaneClosedHandler( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Closed handler reads only the supplied deps, refs and handoffs. + context as unknown as Parameters[0] + ) + useImperativeHandle(actions, () => ({ + close: () => closed(1, { leafId, reason: 'close' }), + park: () => + cleanupTerminalPaneMount({ + manager, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Cleanup reads the supplied lifecycle subset; connection and keyboard callbacks are not reached. + deps: controller as unknown as Parameters[0]['deps'], + refs, + deferredSplitHandoffs: new Map(), + expandedStyleSnapshots: new Map(), + unregisterRuntimeTab: () => {}, + cancelResize: () => {} + }) + })) + return + } + await act(async () => root.render()) + if (!actions.current) { + throw new Error('Terminal lifecycle did not mount') + } + return { ...actions.current, transports } +} + +export async function deliverNoticePaneStartupExit( + transport: PtyTransport, + transports: Map, + ptyId: string, + tabId: string, + paneKey: string +) { + const state = useAppStore.getState() + const session = { + pane: { id: 1 }, + transport: transport, + runtimeEnvironmentId: null, + manager: { getPanes: () => [{ id: 1 }], setPaneGpuRendering: () => {} }, + deps: { + tabId: tabId, + worktreeId: 'wt-1', + paneTransportsRef: { current: transports }, + isPtyShutdownPending: state.isPtyShutdownPending, + consumeSuppressedPtyExit: state.consumeSuppressedPtyExit, + clearRuntimePaneTitle: state.clearRuntimePaneTitle, + clearTabPtyId: state.clearTabPtyId, + setCacheTimerStartedAt: state.setCacheTimerStartedAt + }, + processExitStateByPtyId: new Map(), + currentProcessExitState: {}, + cacheKey: paneKey, + spawnedFreshPtyId: ptyId, + lastTerminalInputAt: Number.NaN, + agentCompletionCoordinator: { dispose: () => {} }, + dropSideEffectFactConsumer: () => {}, + releaseHiddenRendererPtyDelivery: () => {}, + clearPanePtyFitBinding: () => {}, + clearExitedPanePtyLayoutBinding: () => {}, + resetRendererOrderedSeqForPtyExit: () => {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All session fields reached by a sole newborn, non-suppressed exit are supplied. + const mounted = session as unknown as ConnectPanePtySession + bindPanePtyExit(mounted) + await act(async () => mounted.onExit(ptyId, 1)) +} diff --git a/src/renderer/src/components/terminal-pane/provider-notices/terminal-notice-lifetime.ts b/src/renderer/src/components/terminal-pane/provider-notices/terminal-notice-lifetime.ts new file mode 100644 index 00000000000..84b447867e9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/provider-notices/terminal-notice-lifetime.ts @@ -0,0 +1,89 @@ +export type TerminalNotice = 'claude-old-terminal' | 'codex-shared-server' +export type TerminalNoticeStart = { + paneKey: string + generation?: number + shown: boolean +} +export type TerminalNoticeLifetime = { + readonly ptyId: string + incarnationId?: string + retired: boolean + readonly dismissed: Set + start?: TerminalNoticeStart +} + +// Notice contributions follow accepted bindings, rather than React panes or evictable buffers. +const lifetimes = new Map() + +export function getTerminalNoticeLifetime(ptyId: string): TerminalNoticeLifetime | undefined { + return lifetimes.get(ptyId) +} + +export function isTerminalNoticeLifetimeCurrent(lifetime: TerminalNoticeLifetime): boolean { + return !lifetime.retired && lifetimes.get(lifetime.ptyId) === lifetime +} + +/** Only the current successful fresh/reattach owner may admit a process. */ +export function admitTerminalNoticeLifetime( + ptyId: string, + options: { incarnationId?: string; fresh?: boolean } = {} +): TerminalNoticeLifetime { + const current = lifetimes.get(ptyId) + if ( + current && + !options.fresh && + (!options.incarnationId || + !current.incarnationId || + options.incarnationId === current.incarnationId) + ) { + current.incarnationId ??= options.incarnationId + return current + } + if (current) { + retireTerminalNoticeLifetime(current) + } + const lifetime: TerminalNoticeLifetime = { + ptyId, + ...(options.incarnationId ? { incarnationId: options.incarnationId } : {}), + retired: false, + dismissed: new Set() + } + lifetimes.set(ptyId, lifetime) + return lifetime +} + +/** Called after current attach/frame acceptance, never by a presentation read. */ +export function admitTerminalNoticeStart( + lifetime: TerminalNoticeLifetime, + paneKey: string, + generation?: number +): void { + if (!isTerminalNoticeLifetimeCurrent(lifetime)) { + return + } + const previous = lifetime.start + if (previous) { + // Generations belong to a leaf; moving a live process preserves its start. + if (previous.paneKey !== paneKey || generation === undefined) { + return + } + if (previous.generation === undefined) { + previous.generation = generation + return + } + if (generation <= previous.generation) { + return + } + } + lifetime.start = { paneKey, ...(generation !== undefined ? { generation } : {}), shown: false } +} + +/** The canonical owner emits final retirement with the old capture, before its identity is lost. */ +export function retireTerminalNoticeLifetime(lifetime: TerminalNoticeLifetime): void { + if (lifetimes.get(lifetime.ptyId) === lifetime) { + lifetimes.delete(lifetime.ptyId) + } + lifetime.retired = true + lifetime.dismissed.clear() + lifetime.start = undefined +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/lifecycle/pane-pty-exit.ts b/src/renderer/src/components/terminal-pane/pty-connection/lifecycle/pane-pty-exit.ts index 4fbc96d4eab..79d8dbff24a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/lifecycle/pane-pty-exit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/lifecycle/pane-pty-exit.ts @@ -1,3 +1,7 @@ +import { + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime +} from '../../provider-notices/terminal-notice-lifetime' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph/graph-sync-scheduler' import { useAppStore } from '@/store' import { @@ -110,6 +114,8 @@ export function bindPanePtyExit(session: ConnectPanePtySession): void { } const isSuppressedExit = session.deps.consumeSuppressedPtyExit(ptyId) || preserveRendererBinding || replacedByRestart + const endedNotice = + !isSuppressedExit && !isUnverifiedExit ? getTerminalNoticeLifetime(ptyId) : undefined if (!isSuppressedExit && !isUnverifiedExit) { session.clearExitedPanePtyLayoutBinding(ptyId) } @@ -117,6 +123,9 @@ export function bindPanePtyExit(session: ConnectPanePtySession): void { if (!preserveRendererBinding && !isUnverifiedExit) { session.deps.clearTabPtyId(session.deps.tabId, ptyId) } + if (endedNotice) { + retireTerminalNoticeLifetime(endedNotice) + } // Abrupt exits may omit title reset; always clear the cache countdown. session.deps.setCacheTimerStartedAt(session.cacheKey, null) useAppStore.getState().removeAgentStatus(session.cacheKey) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/reattach/reattach-result-handler.ts b/src/renderer/src/components/terminal-pane/pty-connection/reattach/reattach-result-handler.ts index 085f7ff4866..d31382d2da5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/reattach/reattach-result-handler.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/reattach/reattach-result-handler.ts @@ -15,7 +15,7 @@ import { shouldIgnoreStalePanePtyLayoutBinding } from './pane-pty-layout-binding import { hasEmptyReattachRetireEvidence } from './empty-reattach-retire-evidence' import { mainStartsPane } from '../spawn/main-starts-pane' -import { showMainPaneResumeOutcome } from '../spawn/main-pane-resume-banner' +import * as mainPaneResume from '../spawn/main-pane-resume-banner' import type { ColdRestoreAgentResumeStartup } from '../startup/fresh-spawn-types' import type { ConnectPanePtySession } from '../connect-pane-pty-session' @@ -323,7 +323,8 @@ export function bindHandleReattachResult(sessionBag: ConnectPanePtySession): voi session.warnParkRevealNoHostImage(ptyId, noHostImageReason) } session.scheduleReattachIdleAgentCursorReset() - showMainPaneResumeOutcome(sessionBag, ptyId) + mainPaneResume.acceptMainPaneResumeStart(sessionBag, ptyId) + mainPaneResume.showMainPaneResumeOutcome(sessionBag, ptyId) scheduleRuntimeGraphSync() return true diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn/main-pane-resume-banner.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn/main-pane-resume-banner.ts index 9b604b697c6..b18e28f0699 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn/main-pane-resume-banner.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn/main-pane-resume-banner.ts @@ -1,3 +1,9 @@ +import { + admitTerminalNoticeStart, + getTerminalNoticeLifetime, + isTerminalNoticeLifetimeCurrent +} from '../../provider-notices/terminal-notice-lifetime' +import type { TerminalNoticeStart } from '../../provider-notices/terminal-notice-lifetime' // Main owns agent resume; show its published restored/unavailable outcome once per terminal start. import { useAppStore } from '@/store/index' @@ -11,8 +17,6 @@ type Outcomes = Record // Why session storage: a reload reattaches the same start, which showed its banner already. const SHOWN_STARTS_KEY = 'orca.mainPaneResumeBannersShown' -/** This document's shown starts, for when session storage is unavailable. */ -const shownHere = new Set() const startOf = ({ ptyId, ptyGeneration }: TerminalPaneResumeOutcome): string => `${ptyId}#${ptyGeneration ?? ''}` @@ -27,12 +31,13 @@ function readShownStarts(): string[] { } /** Notes `start` as shown, keeping only starts main still publishes; false if it was already. */ -function noteShown(start: string, outcomes: Outcomes): boolean { +function noteShown(start: string, accepted: TerminalNoticeStart, outcomes: Outcomes): boolean { const shown = readShownStarts() - if (shownHere.has(start) || shown.includes(start)) { + if (accepted.shown || shown.includes(start)) { + accepted.shown = true return false } - shownHere.add(start) + accepted.shown = true const live = new Set(Object.values(outcomes).map(startOf)) try { globalThis.sessionStorage.setItem( @@ -69,6 +74,18 @@ export function attachResumeCheck( } } +/** The current attach owner accepts start facts before any presentation reads them. */ +export function acceptMainPaneResumeStart(session: ConnectPanePtySession, ptyId: string): void { + if (!mainStartsPane(session)) { + return + } + const lifetime = getTerminalNoticeLifetime(ptyId) + const found = useAppStore.getState().terminalPaneResumeOutcomesByPaneKey[session.cacheKey] + if (lifetime && found?.ptyId === ptyId) { + admitTerminalNoticeStart(lifetime, session.cacheKey, found.ptyGeneration) + } +} + /** Show main’s start outcome once after attach; its binding frame precedes the reply on ordered IPC and applies before the reply settles. */ export function showMainPaneResumeOutcome(session: ConnectPanePtySession, ptyId: string): void { if (!mainStartsPane(session)) { @@ -76,7 +93,18 @@ export function showMainPaneResumeOutcome(session: ConnectPanePtySession, ptyId: } const outcomes = useAppStore.getState().terminalPaneResumeOutcomesByPaneKey const found = outcomes[session.cacheKey] - if (found?.ptyId === ptyId && noteShown(startOf(found), outcomes)) { + const lifetime = getTerminalNoticeLifetime(ptyId) + const accepted = lifetime?.start + if ( + lifetime && + isTerminalNoticeLifetimeCurrent(lifetime) && + accepted && + found?.ptyId === ptyId && + (accepted.paneKey !== session.cacheKey || + found.ptyGeneration === undefined || + found.ptyGeneration === accepted.generation) && + noteShown(startOf(found), accepted, outcomes) + ) { session.showSessionRestoredBanner(found.outcome) } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/startup/main-pane-resume-banner.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/startup/main-pane-resume-banner.test.ts index 594f2142d78..febb72759b7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/startup/main-pane-resume-banner.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/startup/main-pane-resume-banner.test.ts @@ -28,8 +28,7 @@ const store = vi.hoisted(() => { reset: () => { state = { terminalPaneResumeOutcomesByPaneKey: {}, terminalLayoutsByTabId: {} } listeners.clear() - }, - listenerCount: () => listeners.size + } } }) @@ -54,7 +53,15 @@ function attachedView() { /** A fresh module, as a renderer reload loads it; session storage survives the reload. */ async function loadBannerModule(): Promise { vi.resetModules() - return (await import('../spawn/main-pane-resume-banner')).showMainPaneResumeOutcome + const { showMainPaneResumeOutcome, acceptMainPaneResumeStart } = + await import('../spawn/main-pane-resume-banner') + const { admitTerminalNoticeLifetime } = + await import('../../provider-notices/terminal-notice-lifetime') + return (session, ptyId) => { + admitTerminalNoticeLifetime(ptyId) + acceptMainPaneResumeStart(session, ptyId) + showMainPaneResumeOutcome(session, ptyId) + } } describe("the window's banner for main's start of a pane", () => { @@ -110,4 +117,83 @@ describe("the window's banner for main's start of a pane", () => { ;(await loadBannerModule())(reloaded.session, 'pty-1') expect(reloaded.view.showSessionRestoredBanner).not.toHaveBeenCalled() }) + + it('retains a shown start across a publication gap', async () => { + const attach = await loadBannerModule() + const publish = (ptyId: string, ptyGeneration: number) => + store.publish({ + terminalPaneResumeOutcomesByPaneKey: { + [PANE_KEY]: { ptyId, ptyGeneration, outcome: 'restored' } + } + }) + publish('pty-a', 3) + const first = attachedView() + attach(first.session, 'pty-a') + expect(first.view.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith('restored') + store.publish({ terminalPaneResumeOutcomesByPaneKey: {} }) + publish('pty-b', 4) + attach(attachedView().session, 'pty-b') + publish('pty-a', 3) + const remounted = attachedView() + attach(remounted.session, 'pty-a') + expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled() + // A late older fact cannot replace the accepted start or reset suppression. + publish('pty-a', 2) + attach(remounted.session, 'pty-a') + publish('pty-a', 3) + attach(remounted.session, 'pty-a') + expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled() + }) + + it('retains a shown start across a publication gap without session storage', async () => { + vi.stubGlobal('sessionStorage', { + getItem: () => { + throw new Error('storage unavailable') + }, + setItem: () => { + throw new Error('storage unavailable') + } + }) + const attach = await loadBannerModule() + const publish = (ptyId: string, ptyGeneration: number) => + store.publish({ + terminalPaneResumeOutcomesByPaneKey: { + [PANE_KEY]: { ptyId, ptyGeneration, outcome: 'restored' } + } + }) + publish('pty-a', 3) + const first = attachedView() + attach(first.session, 'pty-a') + expect(first.view.showSessionRestoredBanner).toHaveBeenCalledExactlyOnceWith('restored') + store.publish({ terminalPaneResumeOutcomesByPaneKey: {} }) + publish('pty-b', 4) + attach(attachedView().session, 'pty-b') + publish('pty-a', 3) + const remounted = attachedView() + attach(remounted.session, 'pty-a') + expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled() + // A late older fact cannot replace the accepted start or reset suppression. + publish('pty-a', 2) + attach(remounted.session, 'pty-a') + publish('pty-a', 3) + attach(remounted.session, 'pty-a') + expect(remounted.view.showSessionRestoredBanner).not.toHaveBeenCalled() + }) + + it('shows nothing for an outcome naming another terminal', async () => { + const showMainPaneResumeOutcome = await loadBannerModule() + store.publish({ + terminalPaneResumeOutcomesByPaneKey: { + [PANE_KEY]: { ptyId: 'pty-old', ptyGeneration: 1, outcome: 'restored' } + } + }) + const { view, session } = attachedView() + showMainPaneResumeOutcome(session, 'pty-1') + store.publish({ + terminalPaneResumeOutcomesByPaneKey: { + [PANE_KEY]: { ptyId: 'pty-old', ptyGeneration: 2, outcome: 'resume-unavailable' } + } + }) + expect(view.showSessionRestoredBanner).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-dispatcher.ts index 7cf943e59ce..9b8c762ed7b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-dispatcher.ts @@ -1,3 +1,5 @@ +import { getTerminalNoticeLifetime } from '../../provider-notices/terminal-notice-lifetime' +import { captureTerminalNoticeExitHandler } from './pty-notice-exit' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../../../shared/terminal/replay/terminal-scrollback-limits' import { clearProcessedPtyCharTotal, @@ -321,10 +323,14 @@ export function registerEagerPtyBuffer( eagerPtyHandles.set(ptyId, handle) drainPreHandlerPtyData(ptyId, dataHandler) + const deliverBufferedExit = captureTerminalNoticeExitHandler( + getTerminalNoticeLifetime(ptyId), + exitHandler + ) // Why: defer the pre-handler exit one microtask so the caller receives the returned handle before onExit fires. queueMicrotask(() => { if (ptyExitHandlers.get(ptyId) === exitHandler) { - drainPreHandlerPtyExit(ptyId, exitHandler, incarnationId) + drainPreHandlerPtyExit(ptyId, deliverBufferedExit, incarnationId) } else { clearPreHandlerPtyState(ptyId) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-exit-delivery.ts b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-exit-delivery.ts index ae74d4e0223..e46dbfec8b8 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-exit-delivery.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-exit-delivery.ts @@ -1,3 +1,5 @@ +import { getTerminalNoticeLifetime } from '../../provider-notices/terminal-notice-lifetime' +import { prepareTerminalNoticeExit } from './pty-notice-exit' import { bufferPreHandlerPtyExit, clearPreHandlerPtyState, @@ -26,15 +28,24 @@ export function isPtyExitReplacedByRestart(ptyId: string): boolean { /** Delivers one exit to its primary owner and every observational sidecar. */ export function deliverPtyExitToHandlers(delivery: PtyExitDelivery): void { - if (!delivery.replacedByRestart) { - deliverClassifiedPtyExit(delivery) - return - } - replacedExitsInDelivery.add(delivery.ptyId) + const finishNotice = prepareTerminalNoticeExit( + getTerminalNoticeLifetime(delivery.ptyId), + delivery.code, + delivery.incarnationId + ) try { - deliverClassifiedPtyExit(delivery) + if (!delivery.replacedByRestart) { + deliverClassifiedPtyExit(delivery) + return + } + replacedExitsInDelivery.add(delivery.ptyId) + try { + deliverClassifiedPtyExit(delivery) + } finally { + replacedExitsInDelivery.delete(delivery.ptyId) + } } finally { - replacedExitsInDelivery.delete(delivery.ptyId) + finishNotice() } } diff --git a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-notice-exit.ts b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-notice-exit.ts new file mode 100644 index 00000000000..1a51bdba337 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-notice-exit.ts @@ -0,0 +1,66 @@ +import { isProvenProcessExit } from '../../../../../../shared/terminal/pty/lifecycle/terminal-exit-cause' +import { isPtyDataHandlerShutdownPending } from '../shutdown/pty-shutdown-data-suspension' +import { deferPtyShutdownExit } from '../shutdown/pty-shutdown-exit-deferral' +import { + retireTerminalNoticeLifetime, + type TerminalNoticeLifetime +} from '../../provider-notices/terminal-notice-lifetime' + +let isStoreShutdownPending: (ptyId: string) => boolean = () => false + +export function registerTerminalNoticeShutdownReader(reader: (ptyId: string) => boolean): void { + isStoreShutdownPending = reader +} + +/** Snapshot before primary callbacks can settle shutdown or replace the binding. */ +export function prepareTerminalNoticeExit( + lifetime: TerminalNoticeLifetime | undefined, + code: number, + incarnationId?: string +): () => void { + // Unstamped transport loss needs canonical final-removal evidence, not a current-ID guess. + if ( + !lifetime || + !incarnationId || + lifetime.incarnationId !== incarnationId || + !isProvenProcessExit(code) + ) { + return () => {} + } + return prepareAcceptedTerminalNoticeExit(lifetime) +} + +/** The canonical owner already accepted process death; shutdown still owns settlement. */ +export function prepareAcceptedTerminalNoticeExit( + lifetime: TerminalNoticeLifetime | undefined +): () => void { + if (!lifetime || lifetime.retired) { + return () => {} + } + const pending = + isPtyDataHandlerShutdownPending(lifetime.ptyId) || isStoreShutdownPending(lifetime.ptyId) + if (pending) { + deferPtyShutdownExit(lifetime.ptyId, (settlement) => { + if (settlement === 'committed') { + retireTerminalNoticeLifetime(lifetime) + } + }) + return () => {} + } + return () => retireTerminalNoticeLifetime(lifetime) +} + +/** Bind buffered-exit delivery to its original admitted contribution. */ +export function captureTerminalNoticeExitHandler( + lifetime: TerminalNoticeLifetime | undefined, + handler: (code: number) => void +): (code: number, incarnationId?: string) => void { + return (code, incarnationId) => { + const finish = prepareTerminalNoticeExit(lifetime, code, incarnationId) + try { + handler(code) + } finally { + finish() + } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.test.ts b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.test.ts index 9f1f628ec48..de82dc1c008 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.test.ts @@ -125,7 +125,7 @@ describe('pre-handler PTY buffer', () => { bufferPreHandlerPtyExit(EXIT_PTY_ID, 3) drainPreHandlerPtyExit(EXIT_PTY_ID, onExit) expect(data).toEqual(['reattach output']) - expect(onExit).toHaveBeenCalledWith(3) + expect(onExit).toHaveBeenCalledWith(3, undefined) }) it('consumes a drained exit even when its handler throws', () => { @@ -226,7 +226,7 @@ describe('pre-handler PTY buffer', () => { // Data first: draining the exit transfers ownership and clears the buffered bytes with it. drainPreHandlerPtyData(RECYCLED_PTY_ID, data) drainPreHandlerPtyExit(RECYCLED_PTY_ID, exit) - expect(exit).toHaveBeenCalledWith(1) + expect(exit).toHaveBeenCalledWith(1, undefined) expect(data).toHaveBeenCalledWith('startup bytes', undefined) }) @@ -253,7 +253,7 @@ describe('pre-handler PTY buffer', () => { const exit = vi.fn() drainPreHandlerPtyExit(RECYCLED_PTY_ID, exit) - expect(exit).toHaveBeenCalledWith(1) + expect(exit).toHaveBeenCalledWith(1, FRESH_INCARNATION_ID) }) // Missing incarnation is unknown, preserving old-host and synthetic relay-exit fence behavior. @@ -294,7 +294,7 @@ describe('pre-handler PTY buffer', () => { const exit = vi.fn() expect(hasPreHandlerPtyExit(RECYCLED_PTY_ID)).toBe(true) drainPreHandlerPtyExit(RECYCLED_PTY_ID, exit) - expect(exit).toHaveBeenCalledWith(1) + expect(exit).toHaveBeenCalledWith(1, FRESH_INCARNATION_ID) }) it('replaces a duplicate exit for the same lifetime instead of crowding out another lifetime', () => { @@ -306,7 +306,7 @@ describe('pre-handler PTY buffer', () => { const exit = vi.fn() drainPreHandlerPtyExit(RECYCLED_PTY_ID, exit) - expect(exit).toHaveBeenCalledWith(9) + expect(exit).toHaveBeenCalledWith(9, FRESH_INCARNATION_ID) }) // Reads enforce lifetime filtering even for eager background launches that never invoke explicit discard. @@ -343,6 +343,6 @@ describe('pre-handler PTY buffer', () => { const exit = vi.fn() drainPreHandlerPtyExit(RECYCLED_PTY_ID, exit) - expect(exit).toHaveBeenCalledWith(3) + expect(exit).toHaveBeenCalledWith(3, undefined) }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.ts b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.ts index 895a612a1fc..02b9f9f0252 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/delivery/pty-pre-handler-buffer.ts @@ -292,7 +292,7 @@ export function hasPreHandlerPtyExit(ptyId: string, incarnationId?: unknown): bo export function drainPreHandlerPtyExit( ptyId: string, - handler: (code: number) => void, + handler: (code: number, incarnationId?: string) => void, incarnationId?: unknown ): void { // Select the newest admissible lifetime record to preserve last-write-wins behavior. @@ -307,7 +307,7 @@ export function drainPreHandlerPtyExit( } preHandlerPtyExit.delete(ptyId) try { - handler(exit.code) + handler(exit.code, exit.incarnationId) } finally { // Draining transfers exit ownership despite handler throws; duplicates cannot become new pre-handler events. consumePreHandlerPtyState(ptyId) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-attach.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-attach.ts index 5314a2a159a..d430f6b8447 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-attach.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-attach.ts @@ -1,3 +1,5 @@ +import { admitTerminalNoticeLifetime } from '../../provider-notices/terminal-notice-lifetime' +import { hasPreHandlerPtyExit } from '../delivery/pty-pre-handler-buffer' import { RELEASE_SYNCHRONIZED_OUTPUT } from '../../../../../../shared/terminal/protocol/input-modes/terminal-mode-reset-profiles' import { ensurePtyDispatcher, getEagerPtyBufferHandle } from '../delivery/pty-dispatcher' import { @@ -30,11 +32,15 @@ export function attachIpcPty(options: PtyAttachOptions, context: IpcPtyAttachCon const id = options.existingPtyId context.bind(id) context.handlers.registerData(id) + const hadBufferedExit = hasPreHandlerPtyExit(id) context.handlers.registerExit(id) if (!context.isCurrent(id)) { return } + if (!hadBufferedExit) { + admitTerminalNoticeLifetime(id) + } replayEagerPtyBuffer(options, context) if (options.cols && options.rows) { window.api.pty.resize(id, options.cols, options.rows) diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts index b415dca41be..3b92006c938 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-connect.ts @@ -1,3 +1,4 @@ +import { admitTerminalNoticeLifetime } from '../../provider-notices/terminal-notice-lifetime' import { resolveAgentWorkspaceTrustOwner } from '../../../../../../shared/agent-launch/permissions/agent-workspace-trust-decision' import { terminalLaunchFacts } from '../../../../../../shared/agent-session/lifecycle/agent-session-resume' import { readTerminalSpawnDeferral } from '../../../../../../shared/terminal/pty/admission/spawn-admission-contract' @@ -153,6 +154,12 @@ export async function connectIpcPty( if (context.isDestroyed()) { return } + if (!hasPreHandlerPtyExit(spawnResult.id, spawnResult.incarnationId)) { + admitTerminalNoticeLifetime(spawnResult.id, { + incarnationId: spawnResult.incarnationId, + fresh: !admittedSessionId && !spawnResult.isReattach && !spawnResult.coldRestore + }) + } const exitedBeforeAttach = handlers.registerExit(spawnResult.id, spawnResult.incarnationId) if (exitedBeforeAttach) { if (!context.isExpectedExitCurrent()) { diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-session-handlers.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-session-handlers.ts index c2181c95bfd..bf04aa03854 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-session-handlers.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/ipc-pty-session-handlers.ts @@ -1,3 +1,5 @@ +import { getTerminalNoticeLifetime } from '../../provider-notices/terminal-notice-lifetime' +import { prepareTerminalNoticeExit } from '../delivery/pty-notice-exit' import { ptyWriteUnavailableHandlers } from '../delivery/pty-dispatcher' import { drainRolledBackPtyShutdownData, @@ -135,6 +137,7 @@ export function createIpcPtySessionHandlers({ function registerExit(id: string, incarnationId?: string): boolean { const hadBufferedExit = hasPreHandlerPtyExit(id, incarnationId) + const lifetime = getTerminalNoticeLifetime(id) const exit = (code: number): void => { const currentId = getPtyId() if (currentId !== null && currentId !== id) { @@ -154,7 +157,18 @@ export function createIpcPtySessionHandlers({ ptyTeardownHandlers.set(id, clearAccumulatedState) ptyShutdownLifecycleHandlers.set(id, shutdownLifecycle) try { - drainPreHandlerPtyExit(id, exit, incarnationId) + drainPreHandlerPtyExit( + id, + (code, exitIncarnationId) => { + const finishNotice = prepareTerminalNoticeExit(lifetime, code, exitIncarnationId) + try { + exit(code) + } finally { + finishNotice() + } + }, + incarnationId + ) } catch (error) { if (!hadBufferedExit) { throw error diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index fb46571953a..9dde647cae2 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -1,3 +1,7 @@ +import { + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime +} from '../terminal-pane/provider-notices/terminal-notice-lifetime' import { useAppStore } from '@/store' import { closeWebRuntimeSessionTab } from '@/runtime/web-session-tabs-sync/actions/web-runtime-session-tab-lifecycle' import { isWebRuntimeSessionActive } from '@/runtime/web-session-tabs-sync/identity/web-runtime-session-environment' @@ -219,6 +223,11 @@ export function closeTerminalTab(tabId: string, options: CloseTerminalTabOptions } } + // Proven lifecycle callers retain the old address after pane exit cleared its layout binding. + const exitedNotice = + storeReason === 'pty-exit' && options.lifecyclePtyId + ? getTerminalNoticeLifetime(options.lifecyclePtyId) + : undefined closeLocalTerminalTabState(terminalTabId, { reason: storeReason, ...(options?.captureRecentlyClosed !== undefined @@ -230,6 +239,9 @@ export function closeTerminalTab(tabId: string, options: CloseTerminalTabOptions ? { precomputedRetirementPlan: options.precomputedRetirementPlan } : {}) }) + if (exitedNotice) { + retireTerminalNoticeLifetime(exitedNotice) + } if ( !options?.preserveWorktreeSelection && terminalCountBeforeClose <= 1 && diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 114d743cc4c..08f37ab4792 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -1,3 +1,4 @@ +import { registerTerminalNoticeShutdownReader } from '@/components/terminal-pane/pty-transport/delivery/pty-notice-exit' import { registerStructuredSelectionCatalogStore } from '@/lib/agent-session/options/structured-agent-session-selection-controller' import { canOfferHttpLinkGroupAction } from '@/lib/browser/navigation/http-link-in-group-availability' import { create, type StateCreator } from 'zustand' @@ -162,6 +163,10 @@ export const useAppStore = create()( ) ) +registerTerminalNoticeShutdownReader( + (ptyId) => (useAppStore.getState().pendingPtyShutdownIds[ptyId] ?? 0) > 0 +) + for (const install of STORE_OBSERVER_INSTALLERS) { install(useAppStore) } diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index bcaaa940ae5..3f471153840 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -1,3 +1,4 @@ +import { captureWorkspaceTerminalNoticeRemoval } from '../../../terminals/terminal-notice-removal' import type { StateCreator } from 'zustand' import { appliedWorktreeCatalogVersionPatch } from './worktree-catalog-version-state' import type { AppState } from '../../../types' @@ -141,18 +142,23 @@ export function mergeFetchedWorktrees( get: () => AppState, args: FencedWorktreeMergeArgs ): WorktreeListingMergeOutcome { + let retireNotices: ((after: AppState) => void) | undefined if (args.purgeRemovedWorktrees !== false) { // Why before the updater: delayed views must commit text the purge may offer back. const state = get() + const removedIds = getRemovedWorktreeIdsAfterAuthoritativeScan( + state, + args.repoId, + args.refresh.result, + args.hostId + ) flushPurgedWorkspaceEdits( state, - getRemovedWorktreeIdsAfterAuthoritativeScan( - state, - args.repoId, - args.refresh.result, - args.hostId - ).map((id) => hostWorktreeTarget({ id, hostId: args.hostId })) + removedIds.map((id) => hostWorktreeTarget({ id, hostId: args.hostId })) ) + if (removedIds.length > 0) { + retireNotices = captureWorkspaceTerminalNoticeRemoval(state, removedIds) + } } // Why a holder: the updater decides the outcome, and a narrowed `let` would hide that assignment. const decision: { outcome: WorktreeListingMergeOutcome } = { outcome: 'not-current' } @@ -295,6 +301,7 @@ export function mergeFetchedWorktrees( } }) if (decision.outcome === 'applied') { + retireNotices?.(get()) // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index c731b44527c..54c9eec7423 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -1,3 +1,4 @@ +import { captureWorkspaceTerminalNoticeRemoval } from '../../../terminals/terminal-notice-removal' import type { WorktreeSlice } from '../worktree-slice-contract' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' @@ -145,6 +146,10 @@ export function createPurgeWorktreeTerminalState( if (purgeableWorktreeTargets.length === 0) { return } + const retireNotices = captureWorkspaceTerminalNoticeRemoval( + get(), + purgeableWorktreeTargets.map((target) => (typeof target === 'string' ? target : target.id)) + ) // Why before the purge: delayed views must commit text the purge may offer back. flushPurgedWorkspaceEdits( get(), @@ -157,5 +162,6 @@ export function createPurgeWorktreeTerminalState( buildWorktreePurgeState(s, purgeableWorktreeTargets, changedPaneKeys) ) ) + retireNotices(get()) } } diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index bf09f51bd52..f051bd0aa11 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -1,3 +1,4 @@ +import { retireRemovedWorktreeTerminalNotices } from '../../../terminals/terminal-notice-removal' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' import type { WorktreeCatalogVersion } from '../../../../../../shared/worktree/catalog-version' @@ -99,6 +100,7 @@ export async function tearDownRemovedWorktreeRendererState(args: { requiredExecutionHostId ?? hostId, args.confirmedDiscards ) + retireRemovedWorktreeTerminalNotices(beforeRemoval.terminalNotices) // Purge destroyed-target projects after row removal so selection clears with the row, not earlier. await purgeOrphanedRuntimeSshProjects(get, runtimeCleanup.destroyedSshTargetIds) get().removeWorkspaceSpaceWorktrees?.( diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-state-before-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-state-before-removal.ts index 1f4110feeaa..3a1a5e0d3b6 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-state-before-removal.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-state-before-removal.ts @@ -1,4 +1,6 @@ -import type { AppState } from '../../../types' +import type { TerminalNoticeLifetime } from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' +import { captureWorktreeTerminalNoticeRemoval } from '../../../terminals/terminal-notice-removal' +import type { TerminalTabRetirementState } from '../../../terminals/terminal-tab-retirement' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { captureWorkspaceChatDraftKeys, @@ -9,16 +11,18 @@ import { /** What the teardown needs from the worktree's tab lists, which a listing refresh started during * the removal round trip can drop before the teardown runs. */ export type WorktreeStateBeforeRemoval = { + readonly terminalNotices: readonly TerminalNoticeLifetime[] readonly terminalPtyIds: readonly string[] readonly chatDraftKeys: WorkspaceChatDraftKeys } export function captureWorktreeStateBeforeRemoval( - state: Pick, + state: TerminalTabRetirementState, worktreeId: string, executionHostId: ExecutionHostId | undefined ): WorktreeStateBeforeRemoval { return { + terminalNotices: captureWorktreeTerminalNoticeRemoval(state, worktreeId), terminalPtyIds: (state.tabsByWorktree[worktreeId] ?? []).flatMap( (tab) => state.ptyIdsByTabId[tab.id] ?? [] ), diff --git a/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts b/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts index 26be26edaa0..1c8a0db95b8 100644 --- a/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees/worktrees-slice-test-harness.ts @@ -162,6 +162,10 @@ export function createTestStore() { shutdownWorktreeBrowsers: vi.fn().mockResolvedValue(undefined), deleteManagedBrowserSessionProfile: vi.fn().mockResolvedValue(undefined), ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + deferredSshSessionIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {}, tabsByWorktree: {}, tabBarOrderByWorktree: {}, pendingReconnectTabByWorktree: {}, diff --git a/src/renderer/src/store/terminals/terminal-notice-removal.ts b/src/renderer/src/store/terminals/terminal-notice-removal.ts new file mode 100644 index 00000000000..a3cf9e5db43 --- /dev/null +++ b/src/renderer/src/store/terminals/terminal-notice-removal.ts @@ -0,0 +1,117 @@ +import { + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime, + type TerminalNoticeLifetime +} from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' +import { + buildTerminalTabRetirementPlans, + collectPtyIdsForTab, + type TerminalTabRetirementPlan, + type TerminalTabRetirementState +} from './terminal-tab-retirement' + +function workspaceTerminalTabs(state: TerminalTabRetirementState, workspaceIds: readonly string[]) { + const tabs = new Map() + for (const id of workspaceIds) { + for (const tab of state.tabsByWorktree[id] ?? []) { + tabs.set(tab.id, tab.ptyId) + } + for (const tab of state.unifiedTabsByWorktree[id] ?? []) { + if (tab.contentType === 'terminal' && !tabs.has(tab.entityId)) { + tabs.set(tab.entityId, null) + } + } + } + return tabs +} + +function selectPlannedNotices( + plans: Iterable, + notices: ReadonlyMap +): TerminalNoticeLifetime[] { + const selected = new Set() + for (const plan of plans) { + for (const id of [ + ...plan.localOrSshPtyIds, + ...plan.cleanupOnlyPtyIds, + ...plan.unroutablePtyIds + ]) { + const notice = notices.get(id) + if (notice) { + selected.add(notice) + } + } + } + return [...selected] +} + +export function retireRemovedWorktreeTerminalNotices( + notices: readonly TerminalNoticeLifetime[] +): void { + for (const notice of notices) { + retireTerminalNoticeLifetime(notice) + } +} + +function captureWorkspaceNotices( + before: TerminalTabRetirementState, + workspaceIds: readonly string[] +) { + const tabs = workspaceTerminalTabs(before, workspaceIds) + const notices = new Map() + for (const [tabId, rowId] of tabs) { + for (const id of collectPtyIdsForTab(before, tabId, rowId)) { + const notice = getTerminalNoticeLifetime(id) + if (notice) { + notices.set(id, notice) + } + } + } + return { tabs, notices } +} + +/** Backend removal carries only selected objects across its asynchronous request. */ +export function captureWorktreeTerminalNoticeRemoval( + before: TerminalTabRetirementState, + workspaceId: string +): readonly TerminalNoticeLifetime[] { + const { tabs, notices } = captureWorkspaceNotices(before, [workspaceId]) + return notices.size === 0 + ? [] + : selectPlannedNotices( + buildTerminalTabRetirementPlans(before, [...tabs.keys()]).values(), + notices + ) +} + +/** Capture before a removal erases bindings; only accepted final removal settles these objects. */ +export function captureWorkspaceTerminalNoticeRemoval( + before: TerminalTabRetirementState, + workspaceIds: readonly string[] +): (after: TerminalTabRetirementState) => void { + const { tabs, notices } = captureWorkspaceNotices(before, workspaceIds) + return (after) => { + if (notices.size === 0) { + return + } + const remaining = new Set() + for (const bucket of Object.values(after.tabsByWorktree)) { + for (const tab of bucket) { + remaining.add(tab.id) + } + } + for (const bucket of Object.values(after.unifiedTabsByWorktree)) { + for (const tab of bucket) { + if (tab.contentType === 'terminal') { + remaining.add(tab.entityId) + } + } + } + const doomed = [...tabs.keys()].filter((id) => !remaining.has(id)) + if (doomed.length > 0) { + retireRemovedWorktreeTerminalNotices( + selectPlannedNotices(buildTerminalTabRetirementPlans(before, doomed).values(), notices) + ) + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index 849b1df102b..97ce5648c70 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -1,3 +1,7 @@ +import { + getTerminalNoticeLifetime, + retireTerminalNoticeLifetime +} from '@/components/terminal-pane/provider-notices/terminal-notice-lifetime' import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' import { sweepRetiredTerminalTabState } from './retired-terminal-tab-state-sweep' import { getRecentlyClosedTabPosition } from '../slices/tabs/recently-closed-tab-position' @@ -38,6 +42,16 @@ export function createTerminalTabCloseActions( opts?.precomputedRetirementPlan?.tabId === tabId ? opts.precomputedRetirementPlan : buildTerminalTabRetirementPlan(get(), tabId) + const closingPtyIds = new Set([ + ...retirementPlan.localOrSshPtyIds, + ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), + ...retirementPlan.cleanupOnlyPtyIds, + ...retirementPlan.unroutablePtyIds + ]) + const retiringNotices = [...closingPtyIds].flatMap((id) => { + const lifetime = getTerminalNoticeLifetime(id) + return lifetime ? [lifetime] : [] + }) let closingWorktreeId: string | null = null // Why: a parked tab has no mounted TerminalPane cleanup, so revoke its observer/candidate state before provider exit races. retireParkedTerminalTab(tabId) @@ -193,12 +207,6 @@ export function createTerminalTabCloseActions( // Clear unconsumed snapshots/cold restores even if closed before pane mount to bound memory. let nextSnapshots = s.pendingSnapshotByPtyId let nextColdRestores = s.pendingColdRestoreByPtyId - const closingPtyIds = new Set([ - ...retirementPlan.localOrSshPtyIds, - ...retirementPlan.runtimeTerminals.map((terminal) => terminal.ptyId), - ...retirementPlan.cleanupOnlyPtyIds, - ...retirementPlan.unroutablePtyIds - ]) for (const closingId of closingPtyIds) { if (closingId in nextSnapshots) { nextSnapshots = { ...nextSnapshots } @@ -260,6 +268,11 @@ export function createTerminalTabCloseActions( } }) ) + if (retiresSession) { + for (const lifetime of retiringNotices) { + retireTerminalNoticeLifetime(lifetime) + } + } if (intentReason && closingWorktreeId && opts?.remoteCloseOwnedByHost !== true) { // Switch point: main records the close through layout.apply. if ( From bd44dba7ae68473eb4b5e1eea5675a02b5e2488a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:45:17 -0800 Subject: [PATCH 004/106] Reuse orchestration facts and read pane context directly (#40564) * Cache orchestration source facts by dispatch and address * Declare keyed projection services and use lifecycle transaction in regression * Keep empty orchestration reads lightweight without hiding inserts * Preserve pane selection and bound unattended projection invalidations * Use persisted lifecycle inputs in projection regression scenarios * Own exact completion eligibility at the SQLite source * Keep public status source fixture deterministic * Coalesce pane candidates and preserve source refresh ownership * Align projection fixtures and annotations with static checks * Remove obsolete identity implementation-count comparison * Expand each addressed terminal family once per snapshot * Maintain addressed dispatch winners in source order indexes * Keep terminal identity rows separate from public orchestration status * Match optional public status reader to constructor dependency * Complete orchestration status and fixture database contracts --- .../runtime/agent-hook-runtime-deps.ts | 2 + .../server/combined-agent-status-entries.ts | 59 +++ .../agent-hooks/server/server-listeners.ts | 50 +-- ...server-structured-canonical-status.test.ts | 48 +++ .../runtime-agent-sender-history-triggers.ts | 30 +- .../runtime-construction-dependencies.ts | 1 + .../runtime-construction-services.ts | 1 + .../construction/runtime-construction.ts | 17 +- ...rca-runtime-test-scenario-builders.spec.ts | 1 + ...chestration-fleet-agent-status-snapshot.ts | 10 +- .../crew/crew-host-derivation.ts | 8 +- .../orchestration/crew/crew-party-snapshot.ts | 2 +- .../orchestration/crew/crew-read-model.ts | 10 +- .../crew/dispatch-presentation-deadlines.ts | 13 + .../crew/dispatch-presentation-priority.ts | 79 ++++ .../crew/orchestration-dispatch-context.ts | 140 +++++++ .../crew/orchestration-projection-debt.ts | 37 ++ .../crew/orchestration-projection-facts.ts | 294 +++++++++++++++ .../orchestration-projection-invalidations.ts | 85 +++++ .../crew/orchestration-terminal-candidates.ts | 114 ++++++ .../crew/presentation-dispatch-index.ts | 211 +++++++++++ ...untime-agent-orchestration-dependencies.ts | 48 +++ ...ime-agent-orchestration-projection.test.ts | 320 +++++++++++++++- .../runtime-agent-orchestration-projection.ts | 357 +++++++++--------- .../crew/runtime-agent-orchestration-scope.ts | 8 +- .../dispatch-completion-epoch.ts | 8 + .../db/dispatch-context/dispatch-lookup.ts | 125 +----- .../dispatch-context/dispatch-presentation.ts | 134 +++++++ .../presentation-completion-schema.ts | 32 ++ .../presentation-completion-source.test.ts | 92 +++++ .../presentation-completion-source.ts | 121 ++++++ .../db/projection-mutation-triggers.ts | 30 ++ .../worker-terminal-attention-query.ts | 132 ++++--- .../routing/terminal-pane-mail-routing.ts | 16 +- .../in-memory-orchestration-messages.spec.ts | 4 + .../structured-worker-authority.ts | 10 +- .../session-graph/session-window-graph.ts | 3 + .../identity/ordered-terminal-candidates.ts | 61 +++ ...minal-identity-placement-schedules.test.ts | 27 ++ .../terminal-identity-schedules.test.ts | 56 +++ .../identity/terminal-identity-source.ts | 65 ++-- .../identity/terminal-incarnation-bindings.ts | 60 +++ .../terminal-incarnation-candidates.ts | 45 --- .../identity/terminal-pane-candidates.ts | 28 ++ .../terminal-send-stale-leaf-liveness.test.ts | 1 + .../agent-generated-tab-titles-runtime.ts | 2 +- .../layout/agent-generated-tab-titles.test.ts | 6 +- .../layout/agent-generated-tab-titles.ts | 11 +- .../runtime/terminal/query/pty-pane-record.ts | 71 ++++ .../terminal/query/pty-terminal-summary.ts | 2 +- .../terminal-identity-read-context.test.ts | 183 --------- .../query/terminal-identity-read-context.ts | 55 --- .../workspace-process-listing.ts | 3 +- src/main/sqlite/sync-database.ts | 4 + 54 files changed, 2565 insertions(+), 767 deletions(-) create mode 100644 src/main/agent-hooks/server/combined-agent-status-entries.ts create mode 100644 src/main/runtime/orchestration/crew/dispatch-presentation-deadlines.ts create mode 100644 src/main/runtime/orchestration/crew/dispatch-presentation-priority.ts create mode 100644 src/main/runtime/orchestration/crew/orchestration-dispatch-context.ts create mode 100644 src/main/runtime/orchestration/crew/orchestration-projection-debt.ts create mode 100644 src/main/runtime/orchestration/crew/orchestration-projection-facts.ts create mode 100644 src/main/runtime/orchestration/crew/orchestration-projection-invalidations.ts create mode 100644 src/main/runtime/orchestration/crew/orchestration-terminal-candidates.ts create mode 100644 src/main/runtime/orchestration/crew/presentation-dispatch-index.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-context/dispatch-completion-epoch.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-context/dispatch-presentation.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-context/presentation-completion-schema.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.test.ts create mode 100644 src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.ts create mode 100644 src/main/runtime/orchestration/db/projection-mutation-triggers.ts create mode 100644 src/main/runtime/terminal/identity/ordered-terminal-candidates.ts create mode 100644 src/main/runtime/terminal/identity/terminal-incarnation-bindings.ts delete mode 100644 src/main/runtime/terminal/identity/terminal-incarnation-candidates.ts create mode 100644 src/main/runtime/terminal/identity/terminal-pane-candidates.ts create mode 100644 src/main/runtime/terminal/query/pty-pane-record.ts diff --git a/src/main/agent-hooks/runtime/agent-hook-runtime-deps.ts b/src/main/agent-hooks/runtime/agent-hook-runtime-deps.ts index 1d65427fe77..4d65da58001 100644 --- a/src/main/agent-hooks/runtime/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/runtime/agent-hook-runtime-deps.ts @@ -19,6 +19,8 @@ export function agentHookRuntimeDeps( getAgentStatusSnapshot: () => server.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), getAgentStatusSnapshotForPane: (paneKey) => server.getStatusSnapshotForPane(paneKey), + getPublicAgentStatusSnapshotForPane: (paneKey) => + server.getPublicStatusSnapshotForPane(paneKey), // Include resume identities so mobile can locate provider transcripts, including Pi sessions. getAgentProviderSessionSnapshot: (tabId) => tabId === undefined diff --git a/src/main/agent-hooks/server/combined-agent-status-entries.ts b/src/main/agent-hooks/server/combined-agent-status-entries.ts new file mode 100644 index 00000000000..d1468ed141f --- /dev/null +++ b/src/main/agent-hooks/server/combined-agent-status-entries.ts @@ -0,0 +1,59 @@ +import { isTmuxInnerSubject } from '../../../shared/tmux-selected-status' +import { + getLegacyStatusListingOrder, + type HookListenerState +} from '../../../shared/agent-hook-listener/listener-state' +import { + serializeAgentStatusSubject, + type AgentStatusSubject +} from '../../../shared/agent-status/contracts/agent-status-subject' +import type { AgentStatusStore } from '../../../shared/agent-status/store/agent-status-store' +import type { EnrichedAgentHookEventPayload } from './server-types' +import { structuredStatusLegacyEvent } from './server-structured-status-row' +import type { ProgramStatusFacetStore } from './program-status/facet-store' + +const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER + +export function combinedAgentStatusEntries( + source: { + state: HookListenerState + programStatusFacets: Pick + canonicalStatusStore: Pick + canonicalSubjectsByPane: ReadonlyMap> + canonicalListingOrder: ReadonlyMap + }, + paneKey?: string +): EnrichedAgentHookEventPayload[] { + const rows: { entry: EnrichedAgentHookEventPayload; order: number }[] = [] + const legacy = + paneKey === undefined + ? source.state.lastStatusByPaneKey + : source.state.lastStatusByPaneKey.has(paneKey) + ? [[paneKey, source.state.lastStatusByPaneKey.get(paneKey)!] as const] + : [] + for (const [paneKey, entry] of legacy) { + rows.push({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; shared listeners expose only the base event type. + entry: source.programStatusFacets.stitch(entry as EnrichedAgentHookEventPayload), + order: getLegacyStatusListingOrder(source.state, paneKey) ?? UNORDERED_STATUS_ROW + }) + } + const parents = + paneKey === undefined + ? source.canonicalStatusStore.getParents() + : [...(source.canonicalSubjectsByPane.get(paneKey)?.values() ?? [])].flatMap( + (subject) => source.canonicalStatusStore.getParent(subject) ?? [] + ) + for (const parent of parents) { + if (!parent.status || isTmuxInnerSubject(parent.subject)) { + continue + } + rows.push({ + entry: structuredStatusLegacyEvent(parent.status), + order: + source.canonicalListingOrder.get(serializeAgentStatusSubject(parent.subject)) ?? + UNORDERED_STATUS_ROW + }) + } + return rows.sort((a, b) => a.order - b.order).map(({ entry }) => entry) +} diff --git a/src/main/agent-hooks/server/server-listeners.ts b/src/main/agent-hooks/server/server-listeners.ts index 7db72a6c592..ec8a7911ef7 100644 --- a/src/main/agent-hooks/server/server-listeners.ts +++ b/src/main/agent-hooks/server/server-listeners.ts @@ -1,15 +1,11 @@ import { cliWriterExecutionMatcher, type CliWriterExecutionMatches } from './cli-writer-observation' -import { isTmuxInnerSubject } from '../../../shared/tmux-selected-status' import type { AgentStatusClearIpcPayload, AgentStatusIpcPayload } from '../../../shared/agent-status/contracts/agent-status-ipc-payload' import type { ClaudeStatusLineRateLimits } from '../../../shared/claude-statusline-rate-limits' import type { HookTransportInterferenceReport } from '../../../shared/agent-hook-listener/transport/agent-hook-transport-interference' -import { - getLegacyStatusListingOrder, - type HookListenerState -} from '../../../shared/agent-hook-listener/listener-state' +import type { HookListenerState } from '../../../shared/agent-hook-listener/listener-state' import type { AgentHookAuthorityEvidence, AgentHookProviderSessionIdentity, @@ -20,15 +16,11 @@ import type { } from './server-types' import { toAgentStatusIpcPayload } from './server-status-identity' import { AgentHookServerState } from './server-state' -import { serializeAgentStatusSubject } from '../../../shared/agent-status/contracts/agent-status-subject' -import { structuredStatusLegacyEvent } from './server-structured-status-row' +import { combinedAgentStatusEntries } from './combined-agent-status-entries' import { PaneTimers } from './program-status/pane-timers' import { ProgramStatusFacetStore } from './program-status/facet-store' import { parsePaneKey } from '../../../shared/workspace-layout/stable-pane-id' -// Unassigned rows sort after the listing counter, which starts at 1. -const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER - export abstract class AgentHookServerListeners extends AgentHookServerState { protected providerRowsByTab: Map | null = null protected statusChangeNotificationVersion = 0 @@ -76,27 +68,17 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { this.providerRowsByTab = null } - private combinedStatusEntries(): EnrichedAgentHookEventPayload[] { - const rows: { entry: EnrichedAgentHookEventPayload; order: number }[] = [] - for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { - rows.push({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Main admits enriched legacy rows; shared listeners expose only the base event type. - entry: this.programStatusFacets.stitch(entry as EnrichedAgentHookEventPayload), - order: getLegacyStatusListingOrder(this.state, paneKey) ?? UNORDERED_STATUS_ROW - }) - } - for (const parent of this.canonicalStatusStore.getParents()) { - if (!parent.status || isTmuxInnerSubject(parent.subject)) { - continue - } - rows.push({ - entry: structuredStatusLegacyEvent(parent.status), - order: - this.canonicalListingOrder.get(serializeAgentStatusSubject(parent.subject)) ?? - UNORDERED_STATUS_ROW - }) - } - return rows.sort((a, b) => a.order - b.order).map(({ entry }) => entry) + private combinedStatusEntries(paneKey?: string): EnrichedAgentHookEventPayload[] { + return combinedAgentStatusEntries( + { + state: this.state, + programStatusFacets: this.programStatusFacets, + canonicalStatusStore: this.canonicalStatusStore, + canonicalSubjectsByPane: this.canonicalSubjectsByPane, + canonicalListingOrder: this.canonicalListingOrder + }, + paneKey + ) } /** Report repeated hook-body truncation once per process; fail-open handling otherwise hides it (#11217). */ setTransportInterferenceListener( @@ -248,6 +230,12 @@ export abstract class AgentHookServerListeners extends AgentHookServerState { return this.buildStatusChangeNotification().providerSessions } + getPublicStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + return this.combinedStatusEntries(paneKey) + .filter((entry) => entry.providerSessionOnly !== true) + .map(toAgentStatusIpcPayload) + } + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { const legacy = this.state.lastStatusByPaneKey.get(paneKey) if (legacy) { diff --git a/src/main/agent-hooks/server/testing/structured-ingress/server-structured-canonical-status.test.ts b/src/main/agent-hooks/server/testing/structured-ingress/server-structured-canonical-status.test.ts index 4d79694cf68..22c10d7ee59 100644 --- a/src/main/agent-hooks/server/testing/structured-ingress/server-structured-canonical-status.test.ts +++ b/src/main/agent-hooks/server/testing/structured-ingress/server-structured-canonical-status.test.ts @@ -11,6 +11,7 @@ import { } from '../../../../../shared/agent-session/view/structured-agent-session-projection' import { AgentHookServer } from '../../agent-hook-server' import { PANE } from '../server.test-fixtures' +import { agentHookRuntimeDeps } from '../../../runtime/agent-hook-runtime-deps' vi.mock('../../../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) @@ -51,6 +52,53 @@ function terminal(server: AgentHookServer, paneKey: string): void { afterEach(() => vi.restoreAllMocks()) describe('structured canonical production slice', () => { + it('publishes the same public pane evidence while preserving resume-only provider identity', () => { + const server = new AgentHookServer() + const deps = agentHookRuntimeDeps(server, { read: () => ({ kind: 'unobserved' }) }, () => ({})) + server.recordLaunchResumeIdentity({ + paneKey: PANE, + agentType: 'codex', + providerSession: { key: 'session_id', id: 'resume-only' }, + launchedAt: 100 + }) + expect(deps.getPublicAgentStatusSnapshotForPane(PANE)).toEqual([]) + expect(deps.getAgentStatusSnapshotForPane(PANE)).toEqual([ + expect.objectContaining({ + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'resume-only' } + }) + ]) + expect(deps.getAgentProviderSessionRowsForPane(PANE)).toEqual([ + expect.objectContaining({ + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'resume-only' } + }) + ]) + terminal(server, PANE) + server.ingestStructuredStatus(summary(), SUBJECT) + const second = makeStructuredAgentStatusSubject( + { ...SCOPE, executionHostId: 'ssh:other' }, + SESSION + ) + server.ingestStructuredStatus({ ...summary(second), agent: 'claude', status: 'idle' }, second) + expect(deps.getAgentStatusSnapshotForPane(PANE).map(({ state }) => state)).toEqual(['working']) + expect( + deps + .getPublicAgentStatusSnapshotForPane(PANE_KEY) + .map(({ agentType, state }) => ({ agentType, state })) + ).toEqual([ + { agentType: 'codex', state: 'working' }, + { agentType: 'claude', state: 'done' } + ]) + expect(deps.getAgentStatusSnapshot().map(({ paneKey, state }) => ({ paneKey, state }))).toEqual( + [ + { paneKey: PANE, state: 'working' }, + { paneKey: PANE_KEY, state: 'working' }, + { paneKey: PANE_KEY, state: 'done' } + ] + ) + }) + it('isolates identical session identifiers across host, WSL and workspace kind scopes', () => { const server = new AgentHookServer() const scopes: AgentStatusExecutionScope[] = [ diff --git a/src/main/runtime/agent-senders/runtime-agent-sender-history-triggers.ts b/src/main/runtime/agent-senders/runtime-agent-sender-history-triggers.ts index f2247f0d6c0..bc4a2a4f6a2 100644 --- a/src/main/runtime/agent-senders/runtime-agent-sender-history-triggers.ts +++ b/src/main/runtime/agent-senders/runtime-agent-sender-history-triggers.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { projectionMutationTriggers } from '../orchestration/db/projection-mutation-triggers' import type { OrchestrationDb } from '../orchestration/db/orchestration-db' import { tolerant } from './agent-sender-row-decoding' @@ -65,26 +66,15 @@ CREATE TEMP TABLE IF NOT EXISTS agent_sender_tracked ( kind TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY (kind, key)) WITHOUT ROWID; CREATE TEMP TABLE IF NOT EXISTS agent_sender_dirty ( kind TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY (kind, key)) WITHOUT ROWID; -${HISTORY_TRIGGERS.flatMap((trigger) => { - const events: { name: string; event: string; marks: string[] }[] = [ - { name: 'insert', event: 'AFTER INSERT', marks: [markDirty('NEW', trigger)] }, - { name: 'delete', event: 'AFTER DELETE', marks: [markDirty('OLD', trigger)] }, - { - name: 'update', - event: `AFTER UPDATE OF ${trigger.columns}`, - marks: [markDirty('OLD', trigger), markDirty('NEW', trigger)] - } - ] - return events.map( - ({ - name, - event, - marks - }) => `CREATE TEMP TRIGGER IF NOT EXISTS agent_sender_${trigger.table}_${name} -${event} ON main.${trigger.table} -BEGIN UPDATE temp.agent_sender_generation SET history = history + 1; ${marks.join(' ')} END;` - ) -}).join('\n')} +${projectionMutationTriggers( + 'agent_sender', + HISTORY_TRIGGERS.map((trigger) => ({ + table: trigger.table, + columns: trigger.columns, + mark: (row) => markDirty(row, trigger) + })), + 'UPDATE temp.agent_sender_generation SET history = history + 1;' +)} ${['insert', 'delete', 'update'] .map( (name) => `CREATE TEMP TRIGGER IF NOT EXISTS agent_sender_federated_dispatches_${name} diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index c1dd0251255..e7d30136582 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -33,6 +33,7 @@ export type RuntimeConstructionDependencies = { onClaudeTerminalEvidence?: (paneKey: string, evidence: ClaudeTerminalEvidence) => void getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] getAgentStatusSnapshotForPane?: (paneKey: string) => AgentStatusIpcPayload[] + getPublicAgentStatusSnapshotForPane?: (paneKey: string) => AgentStatusIpcPayload[] structuredAgentStatusSink?: StructuredAgentSessionStatusSink readObservedAgentStatusPaneIdentity?: (paneKey: string) => ObservedAgentStatusPaneIdentity getAgentProviderSessionSnapshot?: (tabId?: string) => AgentStatusIpcPayload[] diff --git a/src/main/runtime/construction/runtime-construction-services.ts b/src/main/runtime/construction/runtime-construction-services.ts index d3ea3a29649..6c1c4baadcd 100644 --- a/src/main/runtime/construction/runtime-construction-services.ts +++ b/src/main/runtime/construction/runtime-construction-services.ts @@ -277,6 +277,7 @@ export abstract class RuntimeConstructionServices { protected abstract readonly getAgentStatusSnapshotForPaneFn: | ((paneKey: string) => AgentStatusIpcPayload[]) | null + protected abstract readonly publicPaneStatusFn?: (paneKey: string) => AgentStatusIpcPayload[] abstract readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null protected abstract getTrackedDisplayTitleForPty(ptyId: string): string | null protected abstract getLeavesForPty(ptyId: string): readonly RuntimeLeafRecord[] diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index dfffdd783c4..797bbb1e977 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -1,7 +1,7 @@ import { collectSavedStructuredAgentSessionIds } from '../agent-session/lifecycle/saved-structured-agent-session-restoration' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { - RuntimeConstructionBindings, + RuntimeConstructionBindings as Bindings, RuntimeConstructionDependencies } from './runtime-construction-dependencies' import type { OrcaRuntimeWithRuntimeId as IdentityState } from './runtime-identity-state' @@ -75,11 +75,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo declare protected readonly attestAgentHookCompatibilityAuthorityFn: HostServices['attestAgentHookCompatibilityAuthorityFn'] declare protected readonly retireAgentHookCompatibilityAuthorityFn: HostServices['retireAgentHookCompatibilityAuthorityFn'] declare protected readonly readHookCliWriterObservationFn: - | RuntimeConstructionBindings['readHookCliWriterObservation'] + | Bindings['readHookCliWriterObservation'] | null declare protected readonly checkHookAgentPresenceFn: HostServices['checkHookAgentPresenceFn'] declare readonly subscribeAgentProviderSessionChangesFn: - | RuntimeConstructionBindings['subscribeAgentProviderSessionChanges'] + | Bindings['subscribeAgentProviderSessionChanges'] | null declare protected readonly reconcileAgentStatusForEndedProcessFn: HostServices['reconcileAgentStatusForEndedProcessFn'] declare protected readonly forgetHookAgentExitFn: HostServices['forgetHookAgentExitFn'] @@ -115,14 +115,14 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo readonly workspaceSleepPlatform: WorkspaceSleepPlatform | null - protected readonly getAgentStatusSnapshotForPaneFn: - | RuntimeConstructionBindings['getAgentStatusSnapshotForPane'] - | null + declare protected readonly getAgentStatusSnapshotForPaneFn: HostServices['getAgentStatusSnapshotForPaneFn'] + + protected readonly publicPaneStatusFn?: Bindings['getPublicAgentStatusSnapshotForPane'] /** The agent's full final reply for that finished lead turn; status rows carry only a capped preview. */ - readonly getAcceptedAgentReply: RuntimeConstructionBindings['getAcceptedAgentReply'] + readonly getAcceptedAgentReply: Bindings['getAcceptedAgentReply'] /** Synchronous on every hook status row change, including clears. */ - readonly subscribeAgentStatusRowChanges: RuntimeConstructionBindings['subscribeAgentStatusRowChanges'] + readonly subscribeAgentStatusRowChanges: Bindings['subscribeAgentStatusRowChanges'] protected readonly machineName = new RuntimeMachineName( () => this.store?.getSettings?.().machineName @@ -253,6 +253,7 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null this.getAgentStatusSnapshotForPaneFn = deps?.getAgentStatusSnapshotForPane ?? null + this.publicPaneStatusFn = deps?.getPublicAgentStatusSnapshotForPane this.structuredAgentStatusSinkFn = deps?.structuredAgentStatusSink ?? null this.readObservedAgentStatusPaneIdentityFn = deps?.readObservedAgentStatusPaneIdentity ?? (() => ({ kind: 'unobserved' })) diff --git a/src/main/runtime/orca-runtime-tests/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-tests/orca-runtime-test-scenario-builders.spec.ts index 0054a769df0..2bab325844f 100644 --- a/src/main/runtime/orca-runtime-tests/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-tests/orca-runtime-test-scenario-builders.spec.ts @@ -218,6 +218,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({ crewChanges: new CrewChanges(), + hasAnyDispatchContexts: () => true, listSessionDispatchesForPresentation: () => [], reconcileMissingWorkerTerminal: vi.fn(), getActiveDispatchForTerminal: () => undefined, diff --git a/src/main/runtime/orchestration/addressing/orchestration-fleet-agent-status-snapshot.ts b/src/main/runtime/orchestration/addressing/orchestration-fleet-agent-status-snapshot.ts index 946aab137ce..8b04deb002d 100644 --- a/src/main/runtime/orchestration/addressing/orchestration-fleet-agent-status-snapshot.ts +++ b/src/main/runtime/orchestration/addressing/orchestration-fleet-agent-status-snapshot.ts @@ -1,3 +1,4 @@ +import { orchestrationProjectionFacts } from '../crew/orchestration-projection-facts' import type { TerminalIdentityReadContext } from '../../terminal/query/terminal-identity-read-context' import { conversationRecordReader } from './session-lineage/structured-session-lineage' import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/host/structured-agent-session-registry' @@ -39,17 +40,22 @@ export function readCapturedOrchestrationFleetAgentStatusSnapshot( dependencies: RuntimeAgentOrchestrationDependencies ): readonly FleetAgentStatusEvidence[] { const projection = new RuntimeAgentOrchestrationProjection(dependencies) + const raw = dependencies.getDb() + const sourceFacts = raw?.db ? orchestrationProjectionFacts(raw) : undefined + const db = sourceFacts?.view ?? raw let scope: ReturnType | undefined return readOrchestrationFleetAgentStatusSnapshot({ ...source, getAgentStatusOrchestrationContextForPaneKey: (paneKey) => { + if (raw?.db && !sourceFacts) { + return undefined + } const handle = source.getAgentStatusTerminalHandleForPaneKey?.(paneKey) if (!handle) { return undefined } - const db = dependencies.getDb() scope ??= createOrchestrationProjectionScope(db, dependencies) - return projection.getForHandle(handle, db, { paneKey }, scope) + return projection.getForHandle(handle, db, { paneKey, deferAttention: true }, scope) } }) } diff --git a/src/main/runtime/orchestration/crew/crew-host-derivation.ts b/src/main/runtime/orchestration/crew/crew-host-derivation.ts index a7e24cc5b20..e42c44f6d4c 100644 --- a/src/main/runtime/orchestration/crew/crew-host-derivation.ts +++ b/src/main/runtime/orchestration/crew/crew-host-derivation.ts @@ -9,12 +9,10 @@ import { readCrewAssignments, type CrewAssignmentRow } from './crew-query' import { createCrewSourceInputs } from './crew-source-inputs' import type { CrewReadDependencies } from './crew-read-dependencies' -export function createCrewHostDerivation( - deps: CrewReadDependencies, - now: number, - capacity?: () => number -) { +export function createCrewHostDerivation(deps: CrewReadDependencies, capacity?: () => number) { const { statuses, ...sources } = captureCrewIdentityInputs(deps) + // Reading statuses can refresh the same source; capture its cutoff afterwards. + const now = deps.now ?? Date.now() const registry = createCrewPartyRegistry({ ...sources, statuses }) const { alias } = registry const identity = deps.openIdentityRead?.() diff --git a/src/main/runtime/orchestration/crew/crew-party-snapshot.ts b/src/main/runtime/orchestration/crew/crew-party-snapshot.ts index 5138af1f54a..a29e6e4aaa2 100644 --- a/src/main/runtime/orchestration/crew/crew-party-snapshot.ts +++ b/src/main/runtime/orchestration/crew/crew-party-snapshot.ts @@ -39,7 +39,7 @@ function preview(body: string): string { export function buildCrewSnapshot( address: string, deps: CrewReadDependencies, - host = createCrewHostDerivation(deps, deps.now ?? Date.now()) + host = createCrewHostDerivation(deps) ): OrchestrationCrewSnapshot { deps = { ...host.deps, now: deps.now } const db = deps.db diff --git a/src/main/runtime/orchestration/crew/crew-read-model.ts b/src/main/runtime/orchestration/crew/crew-read-model.ts index 34ca2b7a173..f861a2becb9 100644 --- a/src/main/runtime/orchestration/crew/crew-read-model.ts +++ b/src/main/runtime/orchestration/crew/crew-read-model.ts @@ -86,9 +86,7 @@ export class CrewReadModel { deps = { ...deps, records: previous.deps.records } } // A one-shot read has no watch to keep captured truth current. - const host = createCrewHostDerivation(deps, deps.now ?? now, () => - Math.max(32, this.listeners.size * 2) - ) + const host = createCrewHostDerivation(deps, () => Math.max(32, this.listeners.size * 2)) if (this.listeners.size === 0) { return buildCrewSnapshot(address, { ...host.deps, now }, host) } @@ -104,10 +102,8 @@ export class CrewReadModel { previous.registry.refreshRecordNames(deps.records, renamed) this.host = isDeepStrictEqual(identities(previous.deps.statuses), identities(statuses)) ? refreshCrewStatuses(previous, statuses) - : createCrewHostDerivation( - { ...sources, records: previous.deps.records, statuses }, - now, - () => Math.max(32, this.listeners.size * 2) + : createCrewHostDerivation({ ...sources, records: previous.deps.records, statuses }, () => + Math.max(32, this.listeners.size * 2) ) } else { this.host = previous diff --git a/src/main/runtime/orchestration/crew/dispatch-presentation-deadlines.ts b/src/main/runtime/orchestration/crew/dispatch-presentation-deadlines.ts new file mode 100644 index 00000000000..e8a7961419e --- /dev/null +++ b/src/main/runtime/orchestration/crew/dispatch-presentation-deadlines.ts @@ -0,0 +1,13 @@ +import { DispatchPresentationPriority } from './dispatch-presentation-priority' + +/** One addressable deadline per eligible dispatch; removing its owner also removes its deadline. */ +export class DispatchPresentationDeadlines extends DispatchPresentationPriority { + *expired(now: number): Iterable { + let next = this.peek() + while (next && next.priority < now) { + this.delete(next.id) + yield next.id + next = this.peek() + } + } +} diff --git a/src/main/runtime/orchestration/crew/dispatch-presentation-priority.ts b/src/main/runtime/orchestration/crew/dispatch-presentation-priority.ts new file mode 100644 index 00000000000..009fc22302d --- /dev/null +++ b/src/main/runtime/orchestration/crew/dispatch-presentation-priority.ts @@ -0,0 +1,79 @@ +/** Addressable dispatch priorities; arbitrary removal leaves no stale entries. */ +export class DispatchPresentationPriority { + private readonly positions = new Map() + private readonly items: { id: string; priority: number }[] = [] + + set(id: string, priority: number): void { + this.delete(id) + this.items.push({ id, priority }) + this.positions.set(id, this.items.length - 1) + this.up(this.items.length - 1) + } + + delete(id: string): void { + const index = this.positions.get(id) + if (index === undefined) { + return + } + const last = this.items.pop()! + this.positions.delete(id) + if (index >= this.items.length) { + return + } + this.items[index] = last + this.positions.set(last.id, index) + const moved = this.up(index) + if (moved === index) { + this.down(index) + } + } + + get size(): number { + return this.items.length + } + + peek(): Readonly<{ id: string; priority: number }> | undefined { + return this.items[0] + } + + clear(): void { + this.items.length = 0 + this.positions.clear() + } + + private swap(a: number, b: number): void { + const entry = this.items[a] + this.items[a] = this.items[b] + this.items[b] = entry + this.positions.set(this.items[a].id, a) + this.positions.set(this.items[b].id, b) + } + + private up(index: number): number { + while (index > 0) { + const parent = (index - 1) >> 1 + if (this.items[parent].priority <= this.items[index].priority) { + break + } + this.swap(index, parent) + index = parent + } + return index + } + + private down(index: number): void { + for (;;) { + const left = index * 2 + 1 + const right = left + 1 + const child = + right < this.items.length && this.items[right].priority < this.items[left].priority + ? right + : left + if (child >= this.items.length || this.items[index].priority <= this.items[child].priority) { + return + } + this.swap(index, child) + index = child + } + } +} diff --git a/src/main/runtime/orchestration/crew/orchestration-dispatch-context.ts b/src/main/runtime/orchestration/crew/orchestration-dispatch-context.ts new file mode 100644 index 00000000000..b16edee66b2 --- /dev/null +++ b/src/main/runtime/orchestration/crew/orchestration-dispatch-context.ts @@ -0,0 +1,140 @@ +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status/contracts/agent-status-types' +import { creatorFirstParent, crewTaskDisplay } from './crew-rules' +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import type { OrchestrationDb } from '../db/orchestration-db' +import { buildWorkerAttentionContext } from '../workers/reports/worker-attention-context' +import { worktreeIdsEqual } from '../../../../shared/worktree/id' +import type { createOrchestrationProjectionScope } from './runtime-agent-orchestration-scope' +import { currentRunCoordinatorOrcaSessionId } from '../db/runs/run-coordinator-orca-session' +import type { DispatchContextRow } from '../db/orchestration-records' +import type { + DispatchProjectionOptions, + RuntimeAgentOrchestrationDependencies +} from './runtime-agent-orchestration-projection' + +export function composeOrchestrationDispatch( + deps: RuntimeAgentOrchestrationDependencies, + dispatch: DispatchContextRow, + handle: string, + db: OrchestrationDb | null, + options: DispatchProjectionOptions, + scope: ReturnType +): AgentStatusOrchestrationContext { + const { paneKey, evidence, deferAttention = false } = options + const session = options.observeSession?.(dispatch) + const task = scope.task(dispatch.task_id, dispatch.run_id) + const display = crewTaskDisplay(task) + const owningRun = + 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') + ? db?.getActiveCoordinatorRun?.() + : undefined + const handleWorktreeId = legacyActiveRun ? deps.getWorktreeId(handle) : null + const coordinatorWorktreeId = legacyActiveRun + ? deps.getWorktreeId(legacyActiveRun.coordinator_handle) + : null + const scopedLegacyRun = + legacyActiveRun && + handleWorktreeId && + coordinatorWorktreeId && + worktreeIdsEqual(coordinatorWorktreeId, handleWorktreeId) + ? legacyActiveRun + : undefined + const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyRun?.coordinator_handle + const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyRun?.id + const creatorPaneKey = task?.created_by_pane_key + const creatorPaneHandle = creatorPaneKey ? deps.getHandleForPaneKey(creatorPaneKey) : null + const creatorAuthority = creatorPaneHandle ? deps.getDispatchAuthority(creatorPaneHandle) : null + const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null + const currentCreatorPane = creatorAuthority?.paneKey + ? parsePaneKey(creatorAuthority.paneKey) + : null + const sameCreatorPane = Boolean( + creatorPaneKey && + creatorAuthority?.paneKey && + (creatorPaneKey === creatorAuthority.paneKey || + (storedCreatorPane && + currentCreatorPane && + storedCreatorPane.leafId === currentCreatorPane.leafId)) + ) + const paneRuns = creatorPaneKey ? scope.paneRuns(creatorPaneKey) : [] + const paneRun = paneRuns.find((run) => run.id === owningRun?.id) ?? paneRuns[0] + const sameRunCreatorDispatch = Boolean( + task?.creator_dispatch_id && + task.creator_dispatch_run_id === owningRun?.id && + task.creator_dispatch_pane_key && + task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && + parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId + ) + // Durable Run membership identifies the creator; live incarnation/handle checks only decide mutation authority. + const creatorLineageInRun = Boolean( + owningRun?.legacy === 0 && + task?.created_by_run_generation === owningRun.consumer_generation && + creatorPaneKey && + (paneRun + ? paneRun.id === owningRun.id && + paneRun.consumer_generation === task.created_by_run_generation + : sameRunCreatorDispatch) + ) + const currentCreatorHandle = + creatorLineageInRun && + task?.created_by_process_incarnation === creatorAuthority?.processIncarnation && + sameCreatorPane + ? (creatorPaneHandle ?? undefined) + : 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 = + creatorFirstParent( + (creator?.handle || (creatorSession && creator?.paneKey)) && + (!selfPane || creator?.paneKey !== selfPane) && + creator?.handle !== handle + ? creator + : undefined, + coordinator, + coordinatorIsSelf || coordinator.paneKey === selfPane + ) ?? {} + const attention = + !deferAttention && db && typeof db.getWorkerAttentionFacts === 'function' + ? buildWorkerAttentionContext({ db, dispatch, task, evidence, session, now: scope.now }) + : undefined + return { + taskId: dispatch.task_id, + dispatchId: dispatch.id, + dispatchStatus: dispatch.status, + ...(display.taskTitle ? { taskTitle: display.taskTitle } : {}), + ...(display.displayName ? { displayName: display.displayName } : {}), + ...(parent.handle ? { parentTerminalHandle: parent.handle } : {}), + ...(parent.paneKey ? { parentPaneKey: parent.paneKey } : {}), + ...(coordinator.handle ? { coordinatorHandle: coordinator.handle } : {}), + ...(orchestrationRunId ? { orchestrationRunId } : {}), + ...(attention ? { attention } : {}) + } +} diff --git a/src/main/runtime/orchestration/crew/orchestration-projection-debt.ts b/src/main/runtime/orchestration/crew/orchestration-projection-debt.ts new file mode 100644 index 00000000000..386a6e9aedd --- /dev/null +++ b/src/main/runtime/orchestration/crew/orchestration-projection-debt.ts @@ -0,0 +1,37 @@ +import type { OrchestrationDb } from '../db/orchestration-db' + +const state = 'orchestration_projection_state' +const dirty = 'orchestration_projection_dirty' +const MIN_CANDIDATE_CAPACITY = 64 + +export const projectionDebtSchema = `CREATE TEMP TABLE IF NOT EXISTS ${state}( + id INTEGER PRIMARY KEY CHECK(id = 1), queued INTEGER NOT NULL, + capacity INTEGER NOT NULL, rebuild INTEGER NOT NULL); + INSERT OR IGNORE INTO ${state} VALUES(1, 0, ${MIN_CANDIDATE_CAPACITY}, 0);` + +export function markProjectionCandidate(row: string): string { + return `INSERT OR IGNORE INTO ${dirty}(key) + SELECT 'candidate:' || ${row}.id FROM ${state} WHERE id = 1 AND rebuild = 0; + UPDATE ${state} SET queued = queued + changes(), + rebuild = (queued + changes() > capacity) WHERE id = 1 AND rebuild = 0;` +} + +export function projectionNeedsRebuild(db: OrchestrationDb): boolean { + return db.db.prepare(`SELECT rebuild FROM temp.${state} WHERE id = 1`).get()?.rebuild === 1 +} + +/** Retire debt only after current source facts have loaded successfully. */ +export function acknowledgeProjectionDebt(db: OrchestrationDb, candidates: number): void { + db.db.exec('SAVEPOINT orchestration_projection_ack') + try { + db.db.prepare(`DELETE FROM temp.${dirty}`).run() + db.db + .prepare(`UPDATE temp.${state} SET queued = 0, rebuild = 0, capacity = ? WHERE id = 1`) + .run(Math.max(MIN_CANDIDATE_CAPACITY, candidates)) + db.db.exec('RELEASE orchestration_projection_ack') + } catch (error) { + db.db.exec('ROLLBACK TO orchestration_projection_ack') + db.db.exec('RELEASE orchestration_projection_ack') + throw error + } +} diff --git a/src/main/runtime/orchestration/crew/orchestration-projection-facts.ts b/src/main/runtime/orchestration/crew/orchestration-projection-facts.ts new file mode 100644 index 00000000000..14b2d857666 --- /dev/null +++ b/src/main/runtime/orchestration/crew/orchestration-projection-facts.ts @@ -0,0 +1,294 @@ +import type { OrchestrationDb } from '../db/orchestration-db' +import { paneKeyMatchSuffix } from '../db/pane-key-match' +import { + readWorkerAttentionInputs, + projectWorkerAttentionInput +} from '../db/worker-terminal/worker-terminal-attention-query' +import { + installProjectionInvalidations, + projectionDirtyKeys, + trackProjectionKey +} from './orchestration-projection-invalidations' +import { projectionNeedsRebuild, acknowledgeProjectionDebt } from './orchestration-projection-debt' +import { PresentationDispatchIndex } from './presentation-dispatch-index' +import { withPresentationCompletionSource } from '../db/dispatch-context/presentation-completion-source' +type Fact = { value: unknown; dependencies: readonly string[]; owners: Set } +const sources = new WeakMap() +const views = new WeakMap() + +/** The connection owns persisted facts; every presentation still reads live identities and time. */ +export class OrchestrationProjectionFacts { + readonly view: OrchestrationDb + private readonly candidates: PresentationDispatchIndex + private readonly facts = new Map() + private readonly dependencies = new Map>() + private readonly owned = new Map>() + private owner: string | undefined + private version: unknown + private revision: number | undefined + private cold = true + private previousNow = -Infinity + + constructor( + private readonly db: OrchestrationDb, + private readonly transient = false + ) { + this.candidates = new PresentationDispatchIndex( + db, + (owner, key, dependencies, read) => + this.withDispatch(owner, () => this.memo(key, dependencies, read)), + (id) => this.release(id) + ) + if (!transient) { + installProjectionInvalidations(db) + } + // This facade is used only by projection readers; mutations and admission retain the real DB. + this.view = Object.create(db, { + getActiveDispatchForTerminal: { + value: (handle: string, pane?: string) => this.candidates.activeFor(handle, pane) + }, + getLatestDispatchForTerminal: { value: (handle: string) => this.candidates.latest(handle) }, + getTask: { + value: (id: string, run?: string) => + this.memo( + `task:${id}:${run ?? ''}`, + [`task:${id}`], + () => (run === undefined ? db.getTask(id) : db.getTask(id, run)), + (task) => + task?.created_by_terminal_handle ? [`handle:${task.created_by_terminal_handle}`] : [] + ) + }, + getRun: { value: (id: string) => this.memo(`run:${id}`, [`run:${id}`], () => db.getRun(id)) }, + runsBoundToPane: { + value: (pane: string) => + this.memo(`bound:${pane}`, [`bound:${paneKeyMatchSuffix(pane)}`], () => + db.runsBoundToPane(pane) + ) + }, + getActiveCoordinatorRun: { + value: () => this.memo('legacy', ['legacy'], () => db.getActiveCoordinatorRun()) + }, + getWorkerTerminalResourceByHandle: { + value: (handle: string) => + this.memo(`resource-handle:${handle}`, [`resource-handle:${handle}`], () => + db.getWorkerTerminalResourceByHandle(handle) + ) + }, + getWorkerTerminalResourceByProcessIncarnation: { + value: (process: string) => + this.memo(`resource-process:${process}`, [`resource-process:${process}`], () => + db.getWorkerTerminalResourceByProcessIncarnation(process) + ) + }, + hasRecordedStructuredWorkerSession: { + value: (session: string, process: string) => + this.memo(`recorded:${session}:${process}`, [`session:${session}`], () => + db.hasRecordedStructuredWorkerSession(session, process) + ) + }, + getWorkerAttentionFactsForDispatches: { + value: (ids: readonly string[], now: number) => this.attention(ids, now) + }, + getWorkerAttentionFacts: { + value: (id: string, now: number) => { + const result = this.attention([id], now).get(id) + if (!result) { + throw new Error(`Dispatch ${id} was not found.`) + } + return result + } + }, + listSessionDispatchesForPresentation: { + value: (now: number, sessions?: readonly string[]) => + this.candidates.sessions(now, sessions) + } + }) + views.set(this.view, this) + } + + refresh(now: number): void { + try { + withPresentationCompletionSource(this.db.db, now, ({ version, revision }) => { + const overflow = !this.transient && projectionNeedsRebuild(this.db) + const cold = + this.cold || + version !== this.version || + revision !== this.revision || + now < this.previousNow || + overflow + if (cold) { + this.reset() + } + const previousSize = this.candidates.size + const dirty = this.transient || cold ? [] : projectionDirtyKeys(this.db) + const affected = new Set() + for (const key of dirty) { + for (const factKey of this.dependencies.get(key) ?? []) { + for (const owner of this.facts.get(factKey)?.owners ?? []) { + affected.add(owner) + } + } + } + for (const owner of affected) { + this.release(owner) + } + this.candidates.refresh(now, dirty, cold) + if ( + !this.transient && + (cold || dirty.length > 0 || this.candidates.size !== previousSize) + ) { + acknowledgeProjectionDebt(this.db, this.candidates.size) + } + this.cold = false + this.version = version + this.revision = revision + this.previousNow = now + }) + } catch (error) { + // A retry discards partial indexes before consuming the still-unacknowledged obligation. + this.cold = true + throw error + } + } + + rows(): ReturnType { + return this.candidates.rows() + } + + withDispatch(id: string, read: () => T): T { + const previous = this.owner + this.owner = id + try { + return read() + } finally { + this.owner = previous + } + } + + private attention(ids: readonly string[], now: number) { + const missing = ids.filter((id) => !this.facts.has(`attention:${id}`)) + if (missing.length) { + for (const [id, input] of readWorkerAttentionInputs(this.db, missing)) { + this.withDispatch(id, () => + this.memo(`attention:${id}`, [`dispatch:${id}`, `task:${input.row.task_id}`], () => input) + ) + } + } + return new Map( + ids.flatMap((id) => { + const fact = this.facts.get(`attention:${id}`) + return fact + ? [ + [ + id, + projectWorkerAttentionInput( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attention entries are populated only from readWorkerAttentionInputs above. + fact.value as Parameters[0], + now + ) + ] as const + ] + : [] + }) + ) + } + + private memo( + key: string, + dependencies: readonly string[], + read: () => T, + valueDependencies?: (value: T) => readonly string[] + ): T { + let fact = this.facts.get(key) + if (!this.owner || !this.candidates.has(this.owner)) { + return read() + } + if (!fact) { + const value = read() + dependencies = [...dependencies, ...(valueDependencies?.(value) ?? [])] + fact = { value, dependencies, owners: new Set() } + this.facts.set(key, fact) + for (const dependency of dependencies) { + const keys = this.dependencies.get(dependency) ?? new Set() + if (keys.size === 0 && !this.transient) { + trackProjectionKey(this.db, dependency, true) + } + keys.add(key) + this.dependencies.set(dependency, keys) + } + } + fact.owners.add(this.owner) + const keys = this.owned.get(this.owner) ?? new Set() + keys.add(key) + this.owned.set(this.owner, keys) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each concrete domain key is written by one typed read function. + return fact.value as T + } + + private forget(key: string): void { + const fact = this.facts.get(key) + if (!fact) { + return + } + for (const owner of fact.owners) { + this.owned.get(owner)?.delete(key) + } + for (const dependency of fact.dependencies) { + const keys = this.dependencies.get(dependency) + keys?.delete(key) + if (keys?.size === 0) { + this.dependencies.delete(dependency) + if (!this.transient) { + trackProjectionKey(this.db, dependency, false) + } + } + } + this.facts.delete(key) + } + + private release(id: string): void { + for (const key of this.owned.get(id) ?? []) { + const fact = this.facts.get(key) + fact?.owners.delete(id) + if (fact?.owners.size === 0) { + this.forget(key) + } + } + this.owned.delete(id) + } + + private reset(): void { + if (!this.transient) { + // Rollback can restore SQL memberships after a failed refresh cleared the JS owners. + this.db.db.prepare('DELETE FROM temp.orchestration_projection_tracked').run() + } + this.facts.clear() + this.dependencies.clear() + this.candidates.clear() + this.owned.clear() + this.cold = true + } +} + +export function orchestrationProjectionFacts( + db: OrchestrationDb, + now = Date.now() +): OrchestrationProjectionFacts | undefined { + const view = views.get(db) + if (view) { + return view + } + let source = db.db.isTransaction ? undefined : sources.get(db) + if (!source) { + // An uncached probe also sees raw/local and external inserts after an empty read. + if (!db.db.prepare('SELECT 1 FROM dispatch_contexts LIMIT 1').get()) { + return undefined + } + source = new OrchestrationProjectionFacts(db, db.db.isTransaction) + if (!db.db.isTransaction) { + sources.set(db, source) + } + } + source.refresh(now) + return source +} diff --git a/src/main/runtime/orchestration/crew/orchestration-projection-invalidations.ts b/src/main/runtime/orchestration/crew/orchestration-projection-invalidations.ts new file mode 100644 index 00000000000..8a103db760e --- /dev/null +++ b/src/main/runtime/orchestration/crew/orchestration-projection-invalidations.ts @@ -0,0 +1,85 @@ +import type { OrchestrationDb } from '../db/orchestration-db' +import { projectionDebtSchema, markProjectionCandidate } from './orchestration-projection-debt' +import { projectionMutationTriggers } from '../db/projection-mutation-triggers' + +const table = 'orchestration_projection' +const pane = (value: string) => `substr(${value}, instr(${value}, ':') + 1)` +const tracked = (key: string) => `INSERT OR IGNORE INTO ${table}_dirty(key) + SELECT ${key} WHERE ${key} IS NOT NULL AND EXISTS (SELECT 1 FROM ${table}_tracked WHERE key = ${key});` + +export function installProjectionInvalidations(db: OrchestrationDb): void { + const specifications = [ + { + table: 'dispatch_contexts', + mark: (r: string) => + `${markProjectionCandidate(r)}${[ + `'dispatch:' || ${r}.id`, + `'task:' || ${r}.task_id`, + `'handle:' || ${r}.assignee_handle`, + `'session:' || ${r}.assignee_orca_session_id` + ] + .map(tracked) + .join(' ')}` + }, + { table: 'tasks', mark: (r: string) => tracked(`'task:' || ${r}.id`) }, + { + table: 'runs', + mark: (r: string) => + [ + tracked(`'run:' || ${r}.id`), + tracked(`'bound:' || ${pane(`${r}.coordinator_pane_key`)}`) + ].join(' ') + }, + { table: 'coordinator_runs', mark: () => tracked("'legacy'") }, + { + table: 'worker_dispatches', + mark: (r: string) => + tracked(`'dispatch:' || ${r}.dispatch_id`) + + tracked(`'task:' || (SELECT task_id FROM dispatch_contexts WHERE id = ${r}.dispatch_id)`) + }, + { + table: 'attempt_observation_facts', + mark: (r: string) => tracked(`'dispatch:' || ${r}.dispatch_id`) + }, + { table: 'question_threads', mark: (r: string) => tracked(`'dispatch:' || ${r}.dispatch_id`) }, + { table: 'decision_gates', mark: (r: string) => tracked(`'task:' || ${r}.task_id`) }, + { + table: 'worker_terminal_resources', + mark: (r: string) => + ['owner_dispatch_id', 'origin_dispatch_id'] + .map((key) => tracked(`'dispatch:' || ${r}.${key}`)) + .join(' ') + + tracked(`'resource-handle:' || ${r}.terminal_handle`) + + tracked(`'resource-process:' || ${r}.process_incarnation`) + }, + { + table: 'messages', + columns: 'run_id, to_handle, read, delivery_contract, type', + mark: (r: string) => `INSERT OR IGNORE INTO ${table}_dirty(key) + SELECT 'dispatch:' || substr(${r}.to_handle, 10) + WHERE ${r}.type <> 'heartbeat' AND substr(${r}.to_handle, 1, 9) = 'dispatch:' + AND EXISTS (SELECT 1 FROM ${table}_tracked WHERE key = 'dispatch:' || substr(${r}.to_handle, 10));` + } + ] + db.db.exec(`CREATE TEMP TABLE IF NOT EXISTS ${table}_tracked(key TEXT PRIMARY KEY) WITHOUT ROWID; + CREATE TEMP TABLE IF NOT EXISTS ${table}_dirty(key TEXT PRIMARY KEY) WITHOUT ROWID; + ${projectionDebtSchema} + ${projectionMutationTriggers(table, specifications)}`) +} + +export function projectionDirtyKeys(db: OrchestrationDb): string[] { + return db.db + .prepare(`SELECT key FROM temp.${table}_dirty`) + .all() + .flatMap((row) => (typeof row.key === 'string' ? [row.key] : [])) +} + +export function trackProjectionKey(db: OrchestrationDb, key: string, retained: boolean): void { + db.db + .prepare( + retained + ? `INSERT OR IGNORE INTO temp.${table}_tracked VALUES (?)` + : `DELETE FROM temp.${table}_tracked WHERE key = ?` + ) + .run(key) +} diff --git a/src/main/runtime/orchestration/crew/orchestration-terminal-candidates.ts b/src/main/runtime/orchestration/crew/orchestration-terminal-candidates.ts new file mode 100644 index 00000000000..ad90ea1da65 --- /dev/null +++ b/src/main/runtime/orchestration/crew/orchestration-terminal-candidates.ts @@ -0,0 +1,114 @@ +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import type { + RuntimeLeafRecord, + RuntimePtyWorktreeRecord +} from '../../terminal/records/runtime-terminal-state-records' +import type { PtyPaneOf } from '../../terminal/surface/pty-pane-query' + +/** One capture returns an unchanged, complete array per stable leaf or exact legacy pane. */ +export type OrchestrationTerminalCandidates = (pane: string) => readonly { + paneKey: string + handles: Iterable +}[] + +/** One read scope, retaining source order without choosing a terminal from dispatch insertion order. */ +export function captureOrchestrationTerminalCandidates(source: { + leaves: ReadonlyMap + ptys: ReadonlyMap + leafKey(tab: string, leaf: string): string + leavesForId(id: string): Iterable + ptysForPane?: (pane: string) => Iterable + capturePaneOf(): PtyPaneOf + paneOf?: PtyPaneOf + makePaneKey(leaf: RuntimeLeafRecord): string + issueLeaf(leaf: RuntimeLeafRecord): string + issuePty(pty: RuntimePtyWorktreeRecord): string +}): OrchestrationTerminalCandidates { + let captured: + | { panes: Map; leaves: Map; read: PtyPaneOf } + | undefined + const ptysFor = (pane: string): Iterable => { + if (source.ptysForPane) { + return source.ptysForPane(pane) + } + if (!captured) { + captured = { panes: new Map(), leaves: new Map(), read: source.capturePaneOf() } + for (const pty of source.ptys.values()) { + const key = captured.read(pty).paneKey + if (!key) { + continue + } + const leaf = parsePaneKey(key)?.leafId + for (const [map, address] of [ + [captured.panes, key], + [captured.leaves, leaf] + ] as const) { + if (!address) { + continue + } + const ids = map.get(address) ?? [] + ids.push(pty.ptyId) + map.set(address, ids) + } + } + } + const leaf = parsePaneKey(pane)?.leafId + return leaf ? (captured.leaves.get(leaf) ?? []) : (captured.panes.get(pane) ?? []) + } + const paneOf = (pty: RuntimePtyWorktreeRecord) => + (source.paneOf ?? captured?.read ?? source.capturePaneOf())(pty).paneKey + const requests = new Map>() + return (pane) => { + const parsed = parsePaneKey(pane) + const key = parsed ? `leaf:${parsed.leafId}` : `pane:${pane}` + const cached = requests.get(key) + if (cached) { + return cached + } + const panes = new Map() + if (parsed) { + for (const id of source.leavesForId(parsed.leafId)) { + const leaf = source.leaves.get(id) + if (leaf?.ptyId) { + panes.set(source.makePaneKey(leaf), []) + } + } + } + for (const id of ptysFor(pane)) { + const pty = source.ptys.get(id) + const address = pty && paneOf(pty) + if (pty && address) { + const records = panes.get(address) ?? [] + records.push(pty) + panes.set(address, records) + } + } + const groups = [...panes].map(([paneKey, ptys]) => ({ + paneKey, + handles: { + *[Symbol.iterator]() { + const seen = new Set() + const parsed = parsePaneKey(paneKey) + const leaf = parsed + ? source.leaves.get(source.leafKey(parsed.tabId, parsed.leafId)) + : undefined + if (leaf?.ptyId) { + const handle = source.issueLeaf(leaf) + seen.add(handle) + yield handle + } + // Mint fallback identities only if the preceding candidate had no context. + for (const pty of ptys) { + const handle = source.issuePty(pty) + if (!seen.has(handle)) { + seen.add(handle) + yield handle + } + } + } + } + })) + requests.set(key, groups) + return groups + } +} diff --git a/src/main/runtime/orchestration/crew/presentation-dispatch-index.ts b/src/main/runtime/orchestration/crew/presentation-dispatch-index.ts new file mode 100644 index 00000000000..d7b138008dd --- /dev/null +++ b/src/main/runtime/orchestration/crew/presentation-dispatch-index.ts @@ -0,0 +1,211 @@ +import { DispatchPresentationDeadlines } from './dispatch-presentation-deadlines' +import { DispatchPresentationPriority } from './dispatch-presentation-priority' +import { dispatchCompletionEpoch } from '../db/dispatch-context/dispatch-completion-epoch' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status/contracts/agent-status-freshness' +import type { OrchestrationDb } from '../db/orchestration-db' +import type { DispatchContextRow } from '../db/orchestration-records' +import { DISPATCH_CONTEXT_COLUMN_LIST } from '../db/row-column-lists' +import { + readPresentationDispatchCandidates, + isRecentPresentationDispatch +} from '../db/dispatch-context/dispatch-presentation' +import { paneKeyMatchSuffix } from '../db/pane-key-match' +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' + +type Candidate = DispatchContextRow & { presentation_order: number } +const active = (row: DispatchContextRow) => row.status === 'pending' || row.status === 'dispatched' +const paneAddress = (key: string) => `pane:${parsePaneKey(key) ? paneKeyMatchSuffix(key) : key}` +type Read = (owner: string, key: string, dependencies: readonly string[], read: () => T) => T + +export class PresentationDispatchIndex { + private readonly candidates = new Map() + private readonly addresses = new Map>() + private readonly allWinners = new Map() + private readonly activeWinners = new Map() + private readonly expiry = new DispatchPresentationDeadlines() + constructor( + private readonly db: OrchestrationDb, + private readonly read: Read, + private readonly release: (id: string) => void + ) {} + refresh(now: number, dirty: readonly string[], cold: boolean): void { + if (cold) { + for (const row of readPresentationDispatchCandidates(this.db, now)) { + if (active(row) || isRecentPresentationDispatch(row, now)) { + this.put(row) + } + } + } else { + for (const key of dirty) { + if (!key.startsWith('candidate:')) { + continue + } + const id = key.slice(10) + this.remove(id) + const raw = this.db.db + .prepare( + `SELECT rowid AS presentation_order, ${DISPATCH_CONTEXT_COLUMN_LIST} FROM dispatch_contexts WHERE id = ?` + ) + .get(id) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: complete schema-owned dispatch projection plus SQLite rowid. + const row = raw as Candidate | undefined + if (row && (active(row) || isRecentPresentationDispatch(row, now))) { + this.put(row) + } + } + } + for (const id of this.expiry.expired(now)) { + this.remove(id) + } + } + get size(): number { + return this.candidates.size + } + rows(): IterableIterator { + return this.candidates.values() + } + has(id: string): boolean { + return this.candidates.has(id) + } + clear(): void { + this.expiry.clear() + this.candidates.clear() + this.addresses.clear() + this.allWinners.clear() + this.activeWinners.clear() + } + private keys(row: DispatchContextRow): string[] { + return [ + ...(row.assignee_handle ? [`handle:${row.assignee_handle}`] : []), + ...(row.assignee_pane_key + ? [paneAddress(row.assignee_pane_key), `exact:${row.assignee_pane_key}`] + : []), + ...(row.assignee_orca_session_id ? [`session:${row.assignee_orca_session_id}`] : []) + ] + } + + private put(row: Candidate): void { + this.candidates.set(row.id, row) + if (!active(row) && row.completed_at) { + const completed = dispatchCompletionEpoch(row.completed_at) + if (completed !== null) { + this.expiry.set(row.id, completed + AGENT_STATUS_STALE_AFTER_MS) + } + } + for (const key of this.keys(row)) { + const bucket = this.addresses.get(key) ?? new Set() + bucket.add(row.id) + this.addresses.set(key, bucket) + this.order(this.allWinners, key, row) + if (active(row)) { + this.order(this.activeWinners, key, row) + } + } + } + + private remove(id: string): void { + const row = this.candidates.get(id) + if (row) { + for (const key of this.keys(row)) { + const bucket = this.addresses.get(key) + this.unorder(this.allWinners, key, id) + this.unorder(this.activeWinners, key, id) + bucket?.delete(id) + if (bucket?.size === 0) { + this.addresses.delete(key) + } + } + } + this.expiry.delete(id) + this.candidates.delete(id) + this.release(id) + } + + private order( + buckets: Map, + key: string, + row: Candidate + ): void { + const bucket = buckets.get(key) ?? new DispatchPresentationPriority() + bucket.set(row.id, -row.presentation_order) + buckets.set(key, bucket) + } + + private unorder( + buckets: Map, + key: string, + id: string + ): void { + const bucket = buckets.get(key) + bucket?.delete(id) + if (bucket?.size === 0) { + buckets.delete(key) + } + } + + private newest(key: string, onlyActive = true): Candidate | undefined { + const id = (onlyActive ? this.activeWinners : this.allWinners).get(key)?.peek()?.id + return id === undefined ? undefined : this.candidates.get(id) + } + + activeFor(handle: string, pane?: string): Candidate | undefined { + return ( + this.newest(`handle:${handle}`) ?? + (pane + ? (this.newest(`exact:${pane}`) ?? + (parsePaneKey(pane) ? this.newest(paneAddress(pane)) : undefined)) + : undefined) + ) + } + + latest(handle: string): DispatchContextRow | undefined { + const candidate = this.newest(`handle:${handle}`, false) + return candidate + ? this.read(candidate.id, `latest:${handle}`, [`handle:${handle}`], () => + this.db.getLatestDispatchForTerminal(handle) + ) + : undefined + } + + sessions(now: number, sessions?: readonly string[]) { + const candidates = sessions + ? sessions.flatMap((id) => + [...(this.addresses.get(`session:${id}`) ?? [])].flatMap( + (key) => this.candidates.get(key) ?? [] + ) + ) + : [...this.candidates.values()].filter((row) => row.assignee_orca_session_id) + const selected = new Map() + for (const row of candidates) { + const address = row.assignee_handle + ? `handle:${row.assignee_handle}` + : `session:${row.assignee_orca_session_id}` + const previous = selected.get(address) + if ( + !previous || + (active(row) && !active(previous)) || + (active(row) === active(previous) && row.presentation_order > previous.presentation_order) + ) { + selected.set(address, row) + } + } + return [...selected].flatMap(([key, row]) => { + const latest = + active(row) && !row.assignee_handle + ? this.newest(`session:${row.assignee_orca_session_id}`) + : this.read(row.id, `session-latest:${active(row)}:${key}`, [key], () => + this.db.db + .prepare( + `SELECT rowid AS n FROM dispatch_contexts WHERE ${row.assignee_handle ? 'assignee_handle' : 'assignee_orca_session_id'} = ? ${active(row) ? "AND status IN ('pending', 'dispatched')" : ''} ORDER BY rowid DESC LIMIT 1` + ) + .get(row.assignee_handle || row.assignee_orca_session_id) + ) + const order = + latest && ('presentation_order' in latest ? latest.presentation_order : latest.n) + return order === row.presentation_order && + (active(row) || isRecentPresentationDispatch(row, now)) + ? [{ dispatch: row, order: row.presentation_order }] + : [] + }) + } +} diff --git a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-dependencies.ts b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-dependencies.ts index ff691472a91..9cd3b898457 100644 --- a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-dependencies.ts +++ b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-dependencies.ts @@ -1,10 +1,43 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { captureOrchestrationTerminalCandidates } from './orchestration-terminal-candidates' +import { + mintAgentStatusFleetEvidence, + type ObservedAgentStatusPaneIdentity +} from '../../../ipc/agent-status-ipc-boundary' import { readAgentOrchestrationProjectionRecords } from '../addressing/orchestration-fleet-agent-status-snapshot' import type { RuntimeAgentOrchestrationDependencies } from './runtime-agent-orchestration-projection' import { OrcaRuntimeWithRuntimeId } from '../../construction/runtime-identity-state' +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status/contracts/agent-status-types' export abstract class OrcaRuntimeWithAgentOrchestrationDependencies extends OrcaRuntimeWithRuntimeId { + abstract getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined + abstract getAgentStatusOrchestrationContextForPaneKey( + paneKey: string + ): AgentStatusOrchestrationContext | undefined + abstract readonly readObservedAgentStatusPaneIdentityFn: ( + paneKey: string + ) => ObservedAgentStatusPaneIdentity + protected agentOrchestrationDependencies(bound?: Partial) { return { + captureTerminalCandidates: () => + captureOrchestrationTerminalCandidates({ + leaves: this.leaves, + ptys: this.ptysById, + leafKey: (tab, leaf) => this.getLeafKey(tab, leaf), + leavesForId: (id) => this.terminalIdentity.paneCandidates.leavesForId(id), + capturePaneOf: () => this.captureTerminalPaneReader(), + ...(!ORCA_LAYOUT_OWNER_IS_MAIN + ? { + ptysForPane: (pane: string) => + this.terminalIdentity.paneCandidates.ptysForPane(pane), + paneOf: (pty: Parameters[0]) => this.paneOfPty(pty) + } + : {}), + makePaneKey: (leaf) => this.makeRuntimePaneKey(leaf), + issueLeaf: (leaf) => this.issueHandle(leaf), + issuePty: (pty) => this.issuePtyHandle(pty) + }), getDb: () => this.getOrchestrationDbIfAvailable(), getLeaves: () => this.leaves.values(), getPtys: () => this.ptysById.values(), @@ -17,6 +50,21 @@ export abstract class OrcaRuntimeWithAgentOrchestrationDependencies extends Orca getPaneKey: (handle) => this.getPaneKeyForTerminalHandle(handle), getDispatchAuthority: (handle) => this.getOrchestrationDispatchAuthority(handle), getAgentStatusSnapshot: () => this.getOrchestrationFleetAgentStatusSnapshot(), + getAgentStatusForPaneKey: (paneKey, contextForPane) => + (this.publicPaneStatusFn?.(paneKey) ?? []).map((entry) => + mintAgentStatusFleetEvidence( + entry, + { + getAgentStatusTerminalHandleForPaneKey: (pane) => + this.getAgentStatusTerminalHandleForPaneKey(pane), + getTerminalProcessIncarnation: (handle) => this.getTerminalProcessIncarnation(handle), + getAgentStatusOrchestrationContextForPaneKey: + contextForPane ?? + ((pane) => this.getAgentStatusOrchestrationContextForPaneKey(pane)) + }, + this.readObservedAgentStatusPaneIdentityFn(paneKey) + ) + ), getSessionRecords: () => readAgentOrchestrationProjectionRecords(), ...bound } satisfies RuntimeAgentOrchestrationDependencies diff --git a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.test.ts b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.test.ts index f0e5e13b716..10d7334e522 100644 --- a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.test.ts +++ b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.test.ts @@ -1,3 +1,4 @@ +import { runLifecycleWriteTransaction } from '../db/lifecycle-write-transaction-runner' import 'node:fs' import 'node:os' import 'node:path' @@ -8,7 +9,11 @@ import '../../../native-chat/agent-session-wire/host/testing/structured-agent-se import '../../orca-runtime' import { afterEach, describe, expect, it, vi } from 'vitest' -import '../../../../shared/agent-session/lifecycle/agent-session-conversation-view' +import { agentSessionConversationView } from '../../../../shared/agent-session/lifecycle/agent-session-conversation-view' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../../shared/agent-session/lifecycle/agent-session-record.test-fixture' import '../../rpc/orchestration-session-caller-test-fixture' import '../../../../shared/orca-session-address' @@ -95,9 +100,10 @@ describe('RuntimeAgentOrchestrationProjection chat lineage', () => { issueLeafHandle: () => TERMINAL_WORKER_HANDLE, issuePtyHandle: () => '', makePaneKey: () => TERMINAL_WORKER_PANE, - getWorktreeId: () => null, - getHandleForPaneKey: () => null, - getPaneKey: () => null, + getWorktreeId: (handle) => (handle === TERMINAL_WORKER_HANDLE ? leaf.worktreeId : null), + getHandleForPaneKey: (pane) => + pane === TERMINAL_WORKER_PANE ? TERMINAL_WORKER_HANDLE : null, + getPaneKey: (handle) => (handle === TERMINAL_WORKER_HANDLE ? TERMINAL_WORKER_PANE : null), getDispatchAuthority: () => null, getAgentStatusSnapshot: () => [], getSessionRecords: () => null @@ -107,8 +113,314 @@ describe('RuntimeAgentOrchestrationProjection chat lineage', () => { expect(contexts?.[chatRow(WORKER_CHAT)]?.parentPaneKey).toBe(chatRow(COORDINATOR_CHAT)) expect(contexts?.[TERMINAL_WORKER_PANE]?.parentPaneKey).toBe(chatRow(COORDINATOR_CHAT)) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)).toEqual(contexts?.[TERMINAL_WORKER_PANE]) + expect(projection.getForPaneKey(chatRow(WORKER_CHAT))).toEqual(contexts?.[chatRow(WORKER_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) }) }) + +describe('keyed orchestration presentation', () => { + it('reads current task facts across commits, rollback and completion expiry', () => { + const db = new OrchestrationDb(':memory:') + const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-11-09T12:00:00Z')) + try { + const run = db.createRun({ + objective: 'Keyed presentation', + coordinatorHandle: 'coordinator', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'Original title' }) + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: TERMINAL_WORKER_HANDLE, + assigneePaneKey: TERMINAL_WORKER_PANE, + creator: { kind: 'system' }, + maxDepth: 99 + }) + const projection = new RuntimeAgentOrchestrationProjection({ + getDb: () => db, + getLeaves: () => [], + getPtys: () => [], + paneOf: () => ({ tabId: null, paneKey: null, recordedAtGraphSequence: 0 }), + issueLeafHandle: () => '', + issuePtyHandle: () => '', + makePaneKey: () => '', + getWorktreeId: (handle) => (handle === TERMINAL_WORKER_HANDLE ? 'folder:/workspace' : null), + getHandleForPaneKey: (pane) => + pane === TERMINAL_WORKER_PANE ? TERMINAL_WORKER_HANDLE : null, + getPaneKey: (handle) => (handle === TERMINAL_WORKER_HANDLE ? TERMINAL_WORKER_PANE : null), + getDispatchAuthority: () => null, + getAgentStatusSnapshot: () => [], + getAgentStatusForPaneKey: () => [], + getSessionRecords: () => null + }) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.taskTitle).toBe('Original title') + db.db.prepare('UPDATE tasks SET task_title = ? WHERE id = ?').run('Committed title', task.id) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.taskTitle).toBe('Committed title') + expect(() => + runLifecycleWriteTransaction(db.db, 'projection_rollback', () => { + db.db + .prepare('UPDATE tasks SET task_title = ? WHERE id = ?') + .run('Rolled back title', task.id) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.taskTitle).toBe( + 'Rolled back title' + ) + throw new Error('rollback') + }) + ).toThrow('rollback') + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.taskTitle).toBe('Committed title') + db.db + .prepare("UPDATE dispatch_contexts SET status = 'completed', completed_at = ? WHERE id = ?") + .run('2026-11-09 12:00:00', dispatch.id) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.dispatchStatus).toBe('completed') + clock.mockReturnValue(Date.parse('2026-11-10T12:00:00Z')) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)).toBeUndefined() + clock.mockReturnValue(Date.parse('2026-11-09T12:00:00Z')) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.dispatchId).toBe(dispatch.id) + } finally { + db.close() + clock.mockRestore() + } + }) +}) + +function terminalProjection(db: OrchestrationDb) { + const panes = new Map([ + [TERMINAL_WORKER_HANDLE, TERMINAL_WORKER_PANE], + ['creator', 'tab-creator:cccccccc-cccc-4ccc-8ccc-cccccccccccc'], + ['coordinator', 'tab-coordinator:dddddddd-dddd-4ddd-8ddd-dddddddddddd'] + ]) + return new RuntimeAgentOrchestrationProjection({ + getDb: () => db, + getLeaves: () => [], + getPtys: () => [], + paneOf: () => ({ tabId: null, paneKey: null, recordedAtGraphSequence: 0 }), + issueLeafHandle: () => '', + issuePtyHandle: () => '', + makePaneKey: () => '', + getWorktreeId: (handle) => (panes.has(handle) ? 'folder:/workspace' : null), + getHandleForPaneKey: (pane) => [...panes].find(([, key]) => key === pane)?.[0] ?? null, + getPaneKey: (handle) => panes.get(handle) ?? null, + getDispatchAuthority: (handle) => ({ + paneKey: panes.get(handle)!, + processIncarnation: 'creator-process', + runtimeId: 'runtime', + terminalHandle: handle, + ptyId: `pty-${handle}`, + worktreeId: 'folder:/workspace', + launchTokenHash: null, + hostScope: { kind: 'local', hostId: 'local' } + }), + getAgentStatusSnapshot: () => [], + getAgentStatusForPaneKey: () => [], + getSessionRecords: () => null + }) +} + +it('updates child nesting when its creator dispatch settles without a child task write', () => { + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'Creator proof', + coordinatorHandle: 'coordinator', + coordinatorPaneKey: 'tab-coordinator:dddddddd-dddd-4ddd-8ddd-dddddddddddd' + }) + const creator = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'Creator' }).id, + assigneeHandle: 'creator', + assigneePaneKey: 'tab-creator:cccccccc-cccc-4ccc-8ccc-cccccccccccc', + processIncarnation: 'creator-process', + creator: { kind: 'system' }, + maxDepth: 99 + }) + const task = db.createTask({ runId: run.id, spec: 'Child' }) + db.db + .prepare( + `UPDATE tasks SET created_by_terminal_handle = 'creator', created_by_pane_key = 'tab-creator:cccccccc-cccc-4ccc-8ccc-cccccccccccc', created_by_process_incarnation = 'creator-process', created_by_run_generation = ? WHERE id = ?` + ) + .run(run.consumer_generation, task.id) + db.createDispatchContext({ + taskId: task.id, + assigneeHandle: TERMINAL_WORKER_HANDLE, + assigneePaneKey: TERMINAL_WORKER_PANE, + creator: { kind: 'system' }, + maxDepth: 99 + }) + const projection = terminalProjection(db) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.parentTerminalHandle).toBe('creator') + db.db + .prepare( + "UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now') WHERE id = ?" + ) + .run(creator.id) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.parentTerminalHandle).toBe('coordinator') + } finally { + db.close() + } +}) + +it('updates legacy nesting when a coordinator starts and finishes without a dispatch write', () => { + const db = new OrchestrationDb(':memory:') + try { + const legacy = db.createRun({ + objective: 'Legacy work', + coordinatorHandle: null, + coordinatorPaneKey: null + }) + db.db.prepare('UPDATE runs SET legacy = 1 WHERE id = ?').run(legacy.id) + db.createDispatchContext({ + taskId: db.createTask({ runId: legacy.id, spec: 'Legacy worker' }).id, + assigneeHandle: TERMINAL_WORKER_HANDLE, + assigneePaneKey: TERMINAL_WORKER_PANE, + creator: { kind: 'system' }, + maxDepth: 99 + }) + const projection = terminalProjection(db) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.parentTerminalHandle).toBeUndefined() + const run = db.createCoordinatorRun({ + spec: 'Legacy coordinator', + coordinatorHandle: 'coordinator' + }) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.parentTerminalHandle).toBe('coordinator') + db.updateCoordinatorRun(run.id, 'completed') + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.parentTerminalHandle).toBeUndefined() + } finally { + db.close() + } +}) + +it('keeps a cleared chat worker under its coordinator in the keyed attention read', () => { + const db = new OrchestrationDb(':memory:') + try { + const successor = testOrcaSessionId('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') + const run = db.createRun({ + objective: 'Cleared worker', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: COORDINATOR_CHAT + }) + db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'Chat worker' }).id, + assigneeHandle: `orca_session_id:${WORKER_CHAT}`, + creator: { kind: 'system' }, + maxDepth: 99 + }) + const root = agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: WORKER_CHAT })) + root.conversationCommand = { + command: 'clear', + runtimeFence: 7, + operationId: 'clear-worker', + callerKey: 'renderer', + phase: 'committed', + state: 'completed', + replacementSessionId: successor + } + const records = new Map>([ + [WORKER_CHAT, agentSessionConversationView(root)], + [ + successor, + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: successor })) + ) + ], + [ + COORDINATOR_CHAT, + agentSessionConversationView( + agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: COORDINATOR_CHAT })) + ) + ] + ]) + const projection = 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: () => [], + getAgentStatusForPaneKey: () => [], + getSessionRecords: () => ({ + getRecord: (id) => records.get(id) ?? null, + listRecords: () => [...records.values()], + listClearedFrom: (id) => (id === successor ? [WORKER_CHAT] : []) + }) + }) + expect(projection.getForPaneKey(chatRow(successor))?.parentPaneKey).toBe( + chatRow(COORDINATOR_CHAT) + ) + expect(projection.getForPaneKey(chatRow(successor))).toEqual( + projection.buildByPaneKey()?.[chatRow(successor)] + ) + expect(projection.getForPaneKey(chatRow(WORKER_CHAT))).toBeUndefined() + } finally { + db.close() + } +}) + +it('reveals an accepted failure after only its sibling worker settles, and retires read guidance', () => { + const db = new OrchestrationDb(':memory:') + try { + const run = db.createRun({ + objective: 'Attention changes', + coordinatorHandle: 'coordinator', + coordinatorPaneKey: null + }) + const task = db.createTask({ runId: run.id, spec: 'Retrying work' }) + const first = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: TERMINAL_WORKER_HANDLE, + assigneePaneKey: TERMINAL_WORKER_PANE, + creator: { kind: 'system' }, + maxDepth: 99 + }) + // A retry shares the task; lifecycle writers can change only its worker row. + db.db + .prepare( + "INSERT INTO dispatch_contexts(id,run_id,task_id,assignee_handle,status) VALUES('retry',?,?,?,'dispatched')" + ) + .run(run.id, task.id, 'retry-worker') + db.db.prepare("INSERT INTO worker_dispatches(dispatch_id,state) VALUES('retry','ready')").run() + db.recordAttemptObservation({ + id: 'accepted-failure', + dispatchId: first.id, + sequence: 1, + authorityId: 'home', + authorityClock: 'home', + facet: 'worker_report', + payload: { status: 'accepted', outcome: 'failed' }, + homeReceivedAt: Date.now() + }) + const projection = terminalProjection(db) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.attention?.categories).not.toContain( + 'failure' + ) + db.db.prepare("UPDATE worker_dispatches SET state='stopped' WHERE dispatch_id='retry'").run() + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.attention?.categories).toContain( + 'failure' + ) + db.db + .prepare( + "INSERT INTO messages(id,run_id,from_handle,to_handle,subject) VALUES('guidance',?,'coordinator',?,'Please check the output')" + ) + .run(run.id, `dispatch:${first.id}`) + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.attention?.categories).toContain( + 'guidance' + ) + db.db.prepare("UPDATE messages SET read=1 WHERE id='guidance'").run() + expect(projection.getForPaneKey(TERMINAL_WORKER_PANE)?.attention?.categories).not.toContain( + 'guidance' + ) + expect(projection.buildByPaneKey()?.[TERMINAL_WORKER_PANE]?.attention?.categories).toContain( + 'failure' + ) + } finally { + db.close() + } +}) diff --git a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.ts b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.ts index 5653a51832f..8ffc08116f3 100644 --- a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.ts +++ b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-projection.ts @@ -1,6 +1,12 @@ +import type { OrchestrationTerminalCandidates } from './orchestration-terminal-candidates' +import { composeOrchestrationDispatch } from './orchestration-dispatch-context' +import { orchestrationProjectionFacts } from './orchestration-projection-facts' +import { + structuredAgentSessionPaneKey, + structuredAgentSessionTabId +} from '../../../../shared/agent-session/view/structured-agent-session-projection' import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status/contracts/agent-status-types' import type { FleetAgentStatusEvidence } from '../../../../shared/orchestration/fleet/orchestration-fleet-agent-status-evidence' -import { creatorFirstParent, crewTaskDisplay } from './crew-rules' import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import type { OrchestrationCompatibilityTerminalAuthority } from '../terminal-authority/orchestration-compatibility-authority' import type { @@ -8,15 +14,10 @@ import type { RuntimePtyWorktreeRecord } from '../../terminal/records/runtime-terminal-state-records' import type { OrchestrationDb } from '../db/orchestration-db' -import { - buildWorkerAttentionContext, - projectWorkerAttentionContext -} from '../workers/reports/worker-attention-context' -import { worktreeIdsEqual } from '../../../../shared/worktree/id' +import { projectWorkerAttentionContext } from '../workers/reports/worker-attention-context' import type { AgentSessionRecordReader } from '../addressing/session-lineage/structured-session-lineage' import { createOrchestrationProjectionScope } from './runtime-agent-orchestration-scope' -import { isRecentPresentationDispatch } from '../db/dispatch-context/dispatch-lookup' -import { currentRunCoordinatorOrcaSessionId } from '../db/runs/run-coordinator-orca-session' +import { isRecentPresentationDispatch } from '../db/dispatch-context/dispatch-presentation' import type { DispatchContextRow } from '../db/orchestration-records' import type { PtyPaneOf } from '../../terminal/surface/pty-pane-query' import { @@ -25,6 +26,7 @@ import { } from '../workers/lifecycle/session-worker-liveness' export type RuntimeAgentOrchestrationDependencies = { + captureTerminalCandidates?(): OrchestrationTerminalCandidates getDb(): OrchestrationDb | null getLeaves(): Iterable getPtys(): Iterable @@ -37,10 +39,14 @@ export type RuntimeAgentOrchestrationDependencies = { getPaneKey(handle: string): string | null getDispatchAuthority(handle: string): OrchestrationCompatibilityTerminalAuthority | null getAgentStatusSnapshot(): readonly FleetAgentStatusEvidence[] + getAgentStatusForPaneKey?( + paneKey: string, + contextForPane?: (paneKey: string) => AgentStatusOrchestrationContext | undefined + ): readonly FleetAgentStatusEvidence[] getSessionRecords(): AgentSessionRecordReader | null } -type DispatchProjectionOptions = { +export type DispatchProjectionOptions = { // Handles are process-local; pane identity still names the Dispatch after restart. paneKey?: string evidence?: FleetAgentStatusEvidence @@ -52,66 +58,82 @@ export class RuntimeAgentOrchestrationProjection { constructor(private readonly deps: RuntimeAgentOrchestrationDependencies) {} buildByPaneKey(): Record | undefined { - const db = this.deps.getDb() - if (!db || db.hasAnyDispatchContexts?.() === false) { + const raw = this.deps.getDb() + const source = raw?.db ? orchestrationProjectionFacts(raw) : undefined + if (raw?.db && !source) { + return undefined + } + const db = source?.view ?? raw + if (!db || (source ? source.rows().next().done : db.hasAnyDispatchContexts?.() === false)) { return undefined } const scope = createOrchestrationProjectionScope(db, this.deps) const contexts: Record = {} - const statuses = this.deps.getAgentStatusSnapshot() + const terminals = new Map>() + const terminalCandidates = this.deps.captureTerminalCandidates?.() + const expanded = new Set>() + for (const candidate of source?.rows() ?? []) { + const handle = + candidate.assignee_handle && this.deps.getWorktreeId(candidate.assignee_handle) !== null + ? candidate.assignee_handle + : candidate.assignee_pane_key + ? this.deps.getHandleForPaneKey(candidate.assignee_pane_key) + : null + const paneKey = handle ? this.deps.getPaneKey(handle) : null + for (const address of new Set([paneKey, candidate.assignee_pane_key])) { + if (!address) { + continue + } + const groups = + terminalCandidates?.(address) ?? + (handle && paneKey ? [{ paneKey, handles: [handle] }] : []) + if (expanded.has(groups)) { + continue + } + expanded.add(groups) + for (const group of groups) { + if (!terminals.has(group.paneKey)) { + terminals.set(group.paneKey, group.handles) + } + } + } + } + const statuses = + source && this.deps.getAgentStatusForPaneKey + ? [ + ...new Set([ + ...terminals.keys(), + ...[...source.rows()].flatMap((row) => + row.assignee_orca_session_id ? [scope.resolve(row.assignee_orca_session_id)] : [] + ) + ]) + ].flatMap((pane) => + this.deps.getAgentStatusForPaneKey!(pane, (key) => this.identityContext(key, db, scope)) + ) + : this.deps.getAgentStatusSnapshot() const evidenceByPaneKey = new Map( statuses.map((evidence) => [evidence.activity.paneKey, evidence]) ) const sessions = createDispatchSessionLivenessReader(db, statuses, scope.records) const observeSession = sessions.observe - // Batch attention queries below; per-pane reads would repeat every 16ms publication. const batchAttention = typeof db.getWorkerAttentionFactsForDispatches === 'function' - const queriedHandles = new Set() - for (const leaf of this.deps.getLeaves()) { - if (!leaf.ptyId) { - continue - } - 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), - observeSession, - deferAttention: batchAttention - }, - scope - ) - if (context) { - contexts[paneKey] = context - } - } - for (const pty of this.deps.getPtys()) { - const { paneKey } = this.deps.paneOf(pty) - if (!paneKey || contexts[paneKey]) { - continue - } - const handle = this.deps.issuePtyHandle(pty) - if (queriedHandles.has(handle)) { - continue - } - queriedHandles.add(handle) - const context = this.getForHandle( - handle, - db, - { - paneKey, - evidence: evidenceByPaneKey.get(paneKey), - observeSession, - deferAttention: batchAttention - }, - scope - ) - if (context) { - contexts[paneKey] = context + for (const [paneKey, handles] of terminals) { + for (const handle of handles) { + const context = this.getForHandle( + handle, + db, + { + paneKey, + evidence: evidenceByPaneKey.get(paneKey), + observeSession, + deferAttention: batchAttention + }, + scope + ) + if (context) { + contexts[paneKey] = context + break + } } } for (const [paneKey, { dispatch }] of scope.sessionDispatches()) { @@ -159,19 +181,106 @@ export class RuntimeAgentOrchestrationProjection { return contexts } + getForPaneKey(paneKey: string): AgentStatusOrchestrationContext | undefined { + const raw = this.deps.getDb() + if (!raw) { + return undefined + } + const source = raw.db ? orchestrationProjectionFacts(raw) : undefined + if (raw.db && !source) { + return undefined + } + const db = source?.view ?? raw + const scope = createOrchestrationProjectionScope(db, this.deps) + const statuses = + this.deps.getAgentStatusForPaneKey?.(paneKey, (key) => + this.identityContext(key, db, scope) + ) ?? this.deps.getAgentStatusSnapshot().filter((entry) => entry.activity.paneKey === paneKey) + const options = { + paneKey, + evidence: statuses.at(-1), + observeSession: createDispatchSessionLivenessReader(db, statuses, scope.records).observe + } + const groups = this.deps.captureTerminalCandidates?.()(paneKey) + const fallback = this.deps.getHandleForPaneKey(paneKey) + const handles = + groups?.find((group) => group.paneKey === paneKey)?.handles ?? (fallback ? [fallback] : []) + for (const handle of handles) { + const terminal = this.getForHandle(handle, db, options, scope) + if (terminal) { + return terminal + } + } + const parsed = parsePaneKey(paneKey) + const prefix = 'structured-agent-session-' + if (!parsed?.tabId.startsWith(prefix)) { + return undefined + } + const session = parsed.tabId.slice(prefix.length) + if (structuredAgentSessionPaneKey(structuredAgentSessionTabId(session), session) !== paneKey) { + return undefined + } + const ids = new Set([session]) + const visit = (id: string) => { + for (const predecessor of scope.records?.listClearedFrom?.(id) ?? []) { + if (!ids.has(predecessor)) { + ids.add(predecessor) + visit(predecessor) + } + } + } + visit(session) + // Old record readers have no predecessor index; only their compatibility path enumerates. + if (scope.records && !scope.records.listClearedFrom) { + for (const record of scope.records.listRecords()) { + ids.add(record.sessionId) + } + } + const candidate = scope.sessionDispatches([...ids]).get(paneKey) + return candidate + ? this.projectDispatch( + candidate.dispatch, + candidate.dispatch.assignee_handle ?? '', + db, + options, + scope + ) + : undefined + } + + private identityContext( + paneKey: string, + db: OrchestrationDb, + scope: ReturnType + ) { + const handle = this.deps.getHandleForPaneKey(paneKey) + return handle + ? this.getForHandle(handle, db, { paneKey, deferAttention: true }, scope) + : undefined + } + getForHandle( handle: string, db = this.deps.getDb(), options: DispatchProjectionOptions = {}, scope = createOrchestrationProjectionScope(db, this.deps) ): AgentStatusOrchestrationContext | undefined { + const facts = db?.db ? orchestrationProjectionFacts(db, scope.now) : undefined + if (db?.db && !facts) { + return undefined + } + if (facts && facts.view !== db) { + db = facts.view + scope = createOrchestrationProjectionScope(db, this.deps, scope.now) + } const { paneKey } = options const dispatch = db?.getActiveDispatchForTerminal?.(handle, paneKey) ?? this.getRecent(handle, db, scope.now) if (!dispatch) { return undefined } - return this.projectDispatch(dispatch, handle, db, options, scope) + const read = () => this.projectDispatch(dispatch, handle, db, options, scope) + return facts ? facts.withDispatch(dispatch.id, read) : read() } private projectDispatch( @@ -181,127 +290,15 @@ export class RuntimeAgentOrchestrationProjection { options: DispatchProjectionOptions, scope: ReturnType ): AgentStatusOrchestrationContext { - const { paneKey, evidence, deferAttention = false } = options - const session = options.observeSession?.(dispatch) - const task = scope.task(dispatch.task_id, dispatch.run_id) - const display = crewTaskDisplay(task) - const owningRun = - 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') - ? db?.getActiveCoordinatorRun?.() - : undefined - const handleWorktreeId = legacyActiveRun ? this.deps.getWorktreeId(handle) : null - const coordinatorWorktreeId = legacyActiveRun - ? this.deps.getWorktreeId(legacyActiveRun.coordinator_handle) - : null - const scopedLegacyRun = - legacyActiveRun && - handleWorktreeId && - coordinatorWorktreeId && - worktreeIdsEqual(coordinatorWorktreeId, handleWorktreeId) - ? legacyActiveRun - : undefined - const coordinatorHandle = runCoordinatorHandle ?? scopedLegacyRun?.coordinator_handle - const orchestrationRunId = owningRun?.legacy === 0 ? owningRun.id : scopedLegacyRun?.id - const creatorPaneKey = task?.created_by_pane_key - const creatorPaneHandle = creatorPaneKey ? this.deps.getHandleForPaneKey(creatorPaneKey) : null - const creatorAuthority = creatorPaneHandle - ? this.deps.getDispatchAuthority(creatorPaneHandle) - : null - const storedCreatorPane = creatorPaneKey ? parsePaneKey(creatorPaneKey) : null - const currentCreatorPane = creatorAuthority?.paneKey - ? parsePaneKey(creatorAuthority.paneKey) - : null - const sameCreatorPane = Boolean( - creatorPaneKey && - creatorAuthority?.paneKey && - (creatorPaneKey === creatorAuthority.paneKey || - (storedCreatorPane && - currentCreatorPane && - storedCreatorPane.leafId === currentCreatorPane.leafId)) - ) - const paneRuns = creatorPaneKey ? scope.paneRuns(creatorPaneKey) : [] - const paneRun = paneRuns.find((run) => run.id === owningRun?.id) ?? paneRuns[0] - const sameRunCreatorDispatch = Boolean( - task?.creator_dispatch_id && - task.creator_dispatch_run_id === owningRun?.id && - task.creator_dispatch_pane_key && - task.creator_dispatch_process_incarnation === task.created_by_process_incarnation && - parsePaneKey(task.creator_dispatch_pane_key)?.leafId === storedCreatorPane?.leafId - ) - // Durable Run membership identifies the creator; live incarnation/handle checks only decide mutation authority. - const creatorLineageInRun = Boolean( - owningRun?.legacy === 0 && - task?.created_by_run_generation === owningRun.consumer_generation && - creatorPaneKey && - (paneRun - ? paneRun.id === owningRun.id && - paneRun.consumer_generation === task.created_by_run_generation - : sameRunCreatorDispatch) - ) - const currentCreatorHandle = - creatorLineageInRun && - task?.created_by_process_incarnation === creatorAuthority?.processIncarnation && - sameCreatorPane - ? (creatorPaneHandle ?? undefined) - : 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 = - creatorFirstParent( - (creator?.handle || (creatorSession && creator?.paneKey)) && - (!selfPane || creator?.paneKey !== selfPane) && - creator?.handle !== handle - ? creator - : undefined, - coordinator, - coordinatorIsSelf || coordinator.paneKey === selfPane - ) ?? {} - const attention = - !deferAttention && db && typeof db.getWorkerAttentionFacts === 'function' - ? buildWorkerAttentionContext({ db, dispatch, task, evidence, session, now: scope.now }) - : undefined - return { - taskId: dispatch.task_id, - dispatchId: dispatch.id, - dispatchStatus: dispatch.status, - ...(display.taskTitle ? { taskTitle: display.taskTitle } : {}), - ...(display.displayName ? { displayName: display.displayName } : {}), - ...(parent.handle ? { parentTerminalHandle: parent.handle } : {}), - ...(parent.paneKey ? { parentPaneKey: parent.paneKey } : {}), - ...(coordinator.handle ? { coordinatorHandle: coordinator.handle } : {}), - ...(orchestrationRunId ? { orchestrationRunId } : {}), - ...(attention ? { attention } : {}) + const facts = db?.db ? orchestrationProjectionFacts(db, scope.now) : undefined + if (facts && db !== facts.view) { + db = facts.view } + return facts + ? facts.withDispatch(dispatch.id, () => + composeOrchestrationDispatch(this.deps, dispatch, handle, db, options, scope) + ) + : composeOrchestrationDispatch(this.deps, dispatch, handle, db, options, scope) } private getRecent(handle: string, db: OrchestrationDb | null, now: number) { diff --git a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-scope.ts b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-scope.ts index b00b5da501a..0d10868dac3 100644 --- a/src/main/runtime/orchestration/crew/runtime-agent-orchestration-scope.ts +++ b/src/main/runtime/orchestration/crew/runtime-agent-orchestration-scope.ts @@ -5,24 +5,24 @@ import { isStructuredWorkerHandle } from '../../../../shared/orchestration/worke export function createOrchestrationProjectionScope( db: OrchestrationDb | null, - deps: RuntimeAgentOrchestrationDependencies + deps: RuntimeAgentOrchestrationDependencies, + now = Date.now() ) { const tasks = new Map>() const runs = new Map>() const panes = new Map>() - const now = Date.now() const records = deps.getSessionRecords() const resolve = createCanonicalSessionPaneResolver(records) return { now, records, resolve, - sessionDispatches: () => { + sessionDispatches: (sessions?: readonly string[]) => { const destinations = new Map< string, ReturnType[number] >() - for (const candidate of db?.listSessionDispatchesForPresentation(now) ?? []) { + for (const candidate of db?.listSessionDispatchesForPresentation(now, sessions) ?? []) { if (!candidate.dispatch.assignee_orca_session_id) { continue } diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion-epoch.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion-epoch.ts new file mode 100644 index 00000000000..6d702735967 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-completion-epoch.ts @@ -0,0 +1,8 @@ +/** Preserve the existing ISO/local and SQLite/UTC completion timestamp contract. */ +export function dispatchCompletionEpoch(value: unknown): number | null { + if (typeof value !== 'string') { + return null + } + const epoch = Date.parse(value.includes('T') ? value : `${value.replace(' ', 'T')}Z`) + return Number.isFinite(epoch) ? epoch : 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 60a50128f61..87b1370e9d0 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-lookup.ts @@ -1,4 +1,4 @@ -import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' +import { listSessionDispatchesForPresentation } from './dispatch-presentation' import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' import type { DispatchContextRow } from '../orchestration-records' import { @@ -30,120 +30,17 @@ const ASSIGNEE_SQL = { latest: assigneeLookups('') } -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( +export function hasRecordedStructuredWorkerSession( 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) + session: string, + process: string +): boolean { + return Boolean( + this.db + .prepare(`SELECT 1 FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1`) + .get(session, process) ) - 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( @@ -303,6 +200,7 @@ export function hasOpenHelperDispatch(this: OrchestrationDb, dispatchId: string) } export type DispatchLookupMethods = { + hasRecordedStructuredWorkerSession: typeof hasRecordedStructuredWorkerSession getActiveDispatchForTerminal: typeof getActiveDispatchForTerminal hasAnyDispatchContexts: typeof hasAnyDispatchContexts getActiveDispatchForIdentity: typeof getActiveDispatchForIdentity @@ -316,6 +214,7 @@ export type DispatchLookupMethods = { export function attachDispatchLookup(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { + hasRecordedStructuredWorkerSession, getActiveDispatchForTerminal, hasAnyDispatchContexts, getActiveDispatchForIdentity, diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-presentation.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-presentation.ts new file mode 100644 index 00000000000..1781b43ce64 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-presentation.ts @@ -0,0 +1,134 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' +import type { DispatchContextRow } from '../orchestration-records' +import type { OrchestrationDb } from '../orchestration-db' +import { + DISPATCH_CONTEXT_COLUMN_LIST, + DISPATCH_CONTEXT_COLUMNS, + selectColumns +} from '../row-column-lists' +import { dispatchCompletionEpoch } from './dispatch-completion-epoch' +import { withPresentationCompletionSource } from './presentation-completion-source' +import { completionTable } from './presentation-completion-schema' +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 = dispatchCompletionEpoch(dispatch.completed_at) + return completedAt !== null && now - completedAt <= AGENT_STATUS_STALE_AFTER_MS +} + +export type PresentationDispatchCandidate = DispatchContextRow & { presentation_order: number } + +/** Active/recent source facts shared by the keyed index and session presentation. */ +export function readPresentationDispatchCandidates( + db: OrchestrationDb, + now: number +): PresentationDispatchCandidate[] { + return withPresentationCompletionSource(db.db, now, () => { + const rows = db.db + .prepare(`SELECT rowid AS presentation_order, ${DISPATCH_CONTEXT_COLUMN_LIST} + FROM dispatch_contexts WHERE status IN ('pending', 'dispatched') + UNION ALL SELECT d.rowid AS presentation_order, ${selectColumns(DISPATCH_CONTEXT_COLUMNS, 'd')} + FROM temp.${completionTable} c INDEXED BY orchestration_presentation_completion_epoch + CROSS JOIN dispatch_contexts d ON d.id = c.id WHERE c.epoch >= ?`) + .all(now - AGENT_STATUS_STALE_AFTER_MS) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: complete schema-owned dispatch projection plus SQLite rowid. + return (rows as PresentationDispatchCandidate[]).sort( + (a, b) => a.presentation_order - b.presentation_order + ) + }) +} + +/** Active first, otherwise latest per address; an older eligible completion never resurfaces. */ +export function listSessionDispatchesForPresentation( + this: OrchestrationDb, + now: number, + sessions?: readonly string[] +): { dispatch: DispatchContextRow; order: number }[] { + const candidates = readPresentationDispatchCandidates(this, now).filter( + (row) => + row.assignee_orca_session_id && (!sessions || sessions.includes(row.assignee_orca_session_id)) + ) + 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 +} diff --git a/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-schema.ts b/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-schema.ts new file mode 100644 index 00000000000..ca393021e15 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-schema.ts @@ -0,0 +1,32 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' + +export const completionTable = 'orchestration_presentation_completions' +export const completionState = 'orchestration_presentation_completion_state' + +const operationNow = `(SELECT operation_now FROM ${completionState} WHERE id = 1)` +const expire = `DELETE FROM ${completionTable} WHERE epoch < ${operationNow} - ${AGENT_STATUS_STALE_AFTER_MS};` +const clock = `UPDATE ${completionState} SET operation_now = orchestration_presentation_now() WHERE id = 1; + UPDATE ${completionState} SET recovery = recovery OR operation_now < stamp, + stamp = operation_now WHERE id = 1; ${expire}` +const replace = `INSERT INTO ${completionTable}(id, epoch) + SELECT NEW.id, epoch FROM (SELECT orchestration_completion_epoch(NEW.completed_at) AS epoch) + WHERE NEW.status NOT IN ('pending', 'dispatched') + AND epoch >= ${operationNow} - ${AGENT_STATUS_STALE_AFTER_MS};` + +export const presentationCompletionSchema = ` + CREATE TEMP TABLE IF NOT EXISTS ${completionTable}(id TEXT PRIMARY KEY, epoch REAL NOT NULL); + CREATE INDEX IF NOT EXISTS temp.orchestration_presentation_completion_epoch ON ${completionTable}(epoch, id); + CREATE TEMP TABLE IF NOT EXISTS ${completionState}( + id INTEGER PRIMARY KEY CHECK(id = 1), ready INTEGER NOT NULL DEFAULT 0, + version INTEGER, zone TEXT, stamp REAL NOT NULL DEFAULT 0, + recovery INTEGER NOT NULL DEFAULT 0, revision INTEGER NOT NULL DEFAULT 0, + operation_now REAL NOT NULL DEFAULT 0); + INSERT OR IGNORE INTO ${completionState}(id) VALUES(1); + CREATE TEMP TRIGGER IF NOT EXISTS orchestration_presentation_completion_insert + AFTER INSERT ON main.dispatch_contexts BEGIN ${clock} ${replace} END; + CREATE TEMP TRIGGER IF NOT EXISTS orchestration_presentation_completion_update + AFTER UPDATE OF id, status, completed_at ON main.dispatch_contexts + BEGIN ${clock} DELETE FROM ${completionTable} WHERE id = OLD.id; ${replace} END; + CREATE TEMP TRIGGER IF NOT EXISTS orchestration_presentation_completion_delete + AFTER DELETE ON main.dispatch_contexts + BEGIN ${clock} DELETE FROM ${completionTable} WHERE id = OLD.id; END;` diff --git a/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.test.ts b/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.test.ts new file mode 100644 index 00000000000..1568e5a8615 --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, expect, it, vi } from 'vitest' +import { OrchestrationDb } from '../orchestration-db' +import { readPresentationDispatchCandidates } from './dispatch-presentation' +import { withPresentationCompletionSource } from './presentation-completion-source' +import { orchestrationProjectionFacts } from '../../crew/orchestration-projection-facts' + +const now = Date.parse('2026-11-09T12:00:00Z') +afterEach(() => vi.restoreAllMocks()) + +function add(db: OrchestrationDb, id: string, completedAt: string) { + db.db + .prepare( + "INSERT INTO dispatch_contexts(id,task_id,assignee_handle,status,completed_at) VALUES(?,'task',?,'completed',?)" + ) + .run(id, id, completedAt) +} + +it('reinstalls a completion source whose first initialization rolled back with its caller', () => { + const db = new OrchestrationDb(':memory:') + try { + db.db.exec('BEGIN; SAVEPOINT caller') + add(db, 'rolled-back', '2026-11-09T12:00:00Z') + expect(readPresentationDispatchCandidates(db, now).map((row) => row.id)).toEqual([ + 'rolled-back' + ]) + db.db.exec('ROLLBACK TO caller; RELEASE caller; ROLLBACK') + add(db, 'committed', '2026-11-09 12:00:00') + expect(readPresentationDispatchCandidates(db, now).map((row) => row.id)).toEqual(['committed']) + db.db + .prepare( + "UPDATE dispatch_contexts SET id='renamed',completed_at='2020-01-01T00:00:00Z' WHERE id='committed'" + ) + .run() + expect(readPresentationDispatchCandidates(db, now)).toEqual([]) + } finally { + db.close() + } +}) + +it('rebuilds warm projection membership when the runtime timezone changes local completion meaning', () => { + const prior = process.env.TZ + const db = new OrchestrationDb(':memory:') + vi.spyOn(Date, 'now').mockReturnValue(now) + try { + process.env.TZ = 'UTC' + add(db, 'local-completion', '2026-11-09T04:00:00') + expect([...orchestrationProjectionFacts(db, now)!.rows()]).toEqual([]) + process.env.TZ = 'America/Los_Angeles' + expect([...orchestrationProjectionFacts(db, now)!.rows()].map((row) => row.id)).toEqual([ + 'local-completion' + ]) + process.env.TZ = 'UTC' + expect([...orchestrationProjectionFacts(db, now)!.rows()]).toEqual([]) + } finally { + if (prior === undefined) { + delete process.env.TZ + } else { + process.env.TZ = prior + } + db.close() + } +}) + +it('keeps normalization and candidate retrieval on one snapshot across an ordinary external commit', () => { + const folder = mkdtempSync(join(tmpdir(), 'orca-completion-source-')) + const path = join(folder, 'orchestration.sqlite') + const db = new OrchestrationDb(path) + db.db.pragma('journal_mode = WAL') + const other = new OrchestrationDb(path) + try { + add(db, 'first', '2026-11-09T12:00:00Z') + withPresentationCompletionSource(db.db, now, () => { + add(other, 'external', '2026-11-09T12:00:00Z') + expect(readPresentationDispatchCandidates(db, now).map((row) => row.id)).toEqual(['first']) + }) + expect(readPresentationDispatchCandidates(db, now).map((row) => row.id)).toEqual([ + 'first', + 'external' + ]) + other.db + .prepare("UPDATE dispatch_contexts SET completed_at='2020-01-01T00:00:00Z' WHERE id='first'") + .run() + expect(readPresentationDispatchCandidates(db, now).map((row) => row.id)).toEqual(['external']) + } finally { + other.close() + db.close() + rmSync(folder, { recursive: true, force: true }) + } +}) diff --git a/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.ts b/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.ts new file mode 100644 index 00000000000..7153d6dcfbb --- /dev/null +++ b/src/main/runtime/orchestration/db/dispatch-context/presentation-completion-source.ts @@ -0,0 +1,121 @@ +import type Database from '../../../../sqlite/sync-database' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../../shared/agent-status/contracts/agent-status-freshness' +import { dispatchCompletionEpoch } from './dispatch-completion-epoch' +import { + completionTable, + completionState, + presentationCompletionSchema +} from './presentation-completion-schema' + +type Snapshot = { revision: number; version: number } +const sources = new WeakMap() + +/** Normalization belongs to the connection, independently of disposable projection caches. */ +class PresentationCompletionSource { + private installed = false + private snapshot: Snapshot | undefined + + constructor(private readonly db: Database.Database) { + db.function('orchestration_completion_epoch', dispatchCompletionEpoch) + db.function('orchestration_presentation_now', () => Date.now()) + } + + read(now: number, read: (snapshot: Snapshot) => T): T { + if (this.snapshot) { + return read(this.snapshot) + } + const outer = this.db.isTransaction + this.db.exec('SAVEPOINT orchestration_presentation_source') + try { + this.install() + // Acquire the main snapshot before observing its data_version, including an empty source. + this.db.prepare('SELECT rowid FROM main.dispatch_contexts LIMIT 1').get() + const state = this.db + .prepare(`SELECT *, (SELECT data_version FROM pragma_data_version) AS current_version + FROM temp.${completionState} WHERE id = 1`) + .get() + if ( + !state || + typeof state.current_version !== 'number' || + typeof state.revision !== 'number' + ) { + throw new Error('Orchestration presentation source is unavailable') + } + const zone = `${process.env.TZ ?? ''}|${Intl.DateTimeFormat().resolvedOptions().timeZone}` + let revision = state.revision + if ( + state.ready !== 1 || + state.recovery === 1 || + state.version !== state.current_version || + state.zone !== zone || + typeof state.stamp !== 'number' || + now < state.stamp + ) { + this.rebuild(now) + revision++ + this.db + .prepare(`UPDATE temp.${completionState} SET ready = 1, version = ?, zone = ?, + recovery = 0, revision = ? WHERE id = 1`) + .run(state.current_version, zone, revision) + } + this.db + .prepare(`DELETE FROM temp.${completionTable} WHERE epoch < ?`) + .run(now - AGENT_STATUS_STALE_AFTER_MS) + this.db.prepare(`UPDATE temp.${completionState} SET stamp = ? WHERE id = 1`).run(now) + this.snapshot = { revision, version: state.current_version } + const result = read(this.snapshot) + this.db.exec('RELEASE orchestration_presentation_source') + if (!outer) { + this.installed = true + } + return result + } catch (error) { + this.db.exec('ROLLBACK TO orchestration_presentation_source') + this.db.exec('RELEASE orchestration_presentation_source') + throw error + } finally { + this.snapshot = undefined + } + } + + private install(): void { + if (this.installed) { + return + } + // First initialization inside a caller transaction may have rolled back its TEMP schema. + if ( + !this.db + .prepare('SELECT 1 FROM sqlite_temp_master WHERE type = ? AND name = ?') + .get('table', completionState) + ) { + this.db.exec(presentationCompletionSchema) + } + } + + private rebuild(now: number): void { + this.db.prepare(`DELETE FROM temp.${completionTable}`).run() + const insert = this.db.prepare(`INSERT INTO temp.${completionTable}(id, epoch) VALUES (?, ?)`) + for (const row of this.db + .prepare(`SELECT id, completed_at FROM dispatch_contexts + WHERE status NOT IN ('pending', 'dispatched')`) + .iterate()) { + const epoch = dispatchCompletionEpoch(row.completed_at) + if (epoch !== null && epoch >= now - AGENT_STATUS_STALE_AFTER_MS) { + insert.run(row.id, epoch) + } + } + } +} + +export function withPresentationCompletionSource( + db: Database.Database, + now: number, + read: (snapshot: Snapshot) => T +): T { + let source = sources.get(db) + if (!source) { + source = new PresentationCompletionSource(db) + sources.set(db, source) + } + return source.read(now, read) +} diff --git a/src/main/runtime/orchestration/db/projection-mutation-triggers.ts b/src/main/runtime/orchestration/db/projection-mutation-triggers.ts new file mode 100644 index 00000000000..bd0a3a415d8 --- /dev/null +++ b/src/main/runtime/orchestration/db/projection-mutation-triggers.ts @@ -0,0 +1,30 @@ +export type ProjectionMutationTrigger = { + table: string + columns?: string + mark: (row: 'NEW' | 'OLD') => string +} + +/** Connection-local invalidations share the writer's transaction, including rollback. */ +export function projectionMutationTriggers( + name: string, + triggers: readonly ProjectionMutationTrigger[], + changed = '' +): string { + return triggers + .flatMap((trigger) => + [ + { name: 'insert', event: 'AFTER INSERT', rows: ['NEW'] as const }, + { name: 'delete', event: 'AFTER DELETE', rows: ['OLD'] as const }, + { + name: 'update', + event: trigger.columns ? `AFTER UPDATE OF ${trigger.columns}` : 'AFTER UPDATE', + rows: ['OLD', 'NEW'] as const + } + ].map( + (event) => `CREATE TEMP TRIGGER IF NOT EXISTS ${name}_${trigger.table}_${event.name} +${event.event} ON main.${trigger.table} +BEGIN ${changed} ${event.rows.map(trigger.mark).join(' ')} END;` + ) + ) + .join('\n') +} diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-attention-query.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-attention-query.ts index bb908a26d07..a4c81f024cb 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-attention-query.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-attention-query.ts @@ -27,18 +27,39 @@ export type WorkerAttentionFacts = { releaseState?: string | null } -export function getWorkerAttentionFactsForDispatches( - this: OrchestrationDb, - dispatchIds: readonly string[], - authorityNow: number -): Map { +type WorkerAttentionRow = { + dispatch_id: string + task_id: string + dispatch_status: DispatchStatus + termination_reason: TerminalExitCause['kind'] | null + worker_state: WorkerDispatchState | null + worker_stage: string | null + parent_task_id: string | null + resource_id: string | null + host_scope: string | null + release_state: string | null + pending_input: number + pending_approval: number + pending_guidance: number + active_sibling: number +} + +export type WorkerAttentionInput = { + row: WorkerAttentionRow + observations: ReturnType[] +} + +export function readWorkerAttentionInputs( + db: OrchestrationDb, + dispatchIds: readonly string[] +): Map { const ids = [...new Set(dispatchIds)] if (ids.length === 0) { return new Map() } const serializedIds = JSON.stringify(ids) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this row cast is unchanged and matches every other row cast in db/; the gate flags it only because this diff adds a column inside its span. - const rows = this.db + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the query selects each schema-owned WorkerAttentionRow column. + const rows = db.db .prepare( `SELECT d.id AS dispatch_id, d.task_id, d.status AS dispatch_status, d.termination_reason, w.state AS worker_state, w.stage AS worker_stage, @@ -64,26 +85,17 @@ export function getWorkerAttentionFactsForDispatches( LEFT JOIN worker_terminal_resources r ON r.owner_dispatch_id = d.id WHERE d.id IN (SELECT value FROM json_each(?))` ) - .all(serializedIds) as { - dispatch_id: string - task_id: string - dispatch_status: DispatchStatus - termination_reason: TerminalExitCause['kind'] | null - worker_state: WorkerDispatchState | null - worker_stage: string | null - parent_task_id: string | null - resource_id: string | null - host_scope: string | null - release_state: string | null - pending_input: number - pending_approval: number - pending_guidance: number - active_sibling: number - }[] - const observationRows = this.db + .all(serializedIds) as WorkerAttentionRow[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the query selects the schema-owned observation column list. + const observationRows = db.db .prepare( `SELECT ${ATTEMPT_OBSERVATION_FACT_COLUMN_LIST} FROM attempt_observation_facts - WHERE dispatch_id IN (SELECT value FROM json_each(?)) + WHERE rowid IN ( + SELECT (SELECT rowid FROM attempt_observation_facts + WHERE dispatch_id = requested.value AND facet = facets.value + ORDER BY sequence DESC LIMIT 1) + FROM json_each(?) requested, json_each('["process_turn","artifact_git","worker_report","coordinator_ack","liveness","outcome"]') facets + ) ORDER BY dispatch_id, sequence, rowid` ) .all(serializedIds) as AttemptObservationStorageRow[] @@ -95,34 +107,58 @@ export function getWorkerAttentionFactsForDispatches( } return new Map( rows.map((row) => { - const projected = projectAttemptOutcome({ - dispatchId: row.dispatch_id, - taskId: row.task_id, - facts: factsByDispatch.get(row.dispatch_id) ?? [], - activeSibling: row.active_sibling === 1, - authorityNow: { home: authorityNow } - }).taskOutcome - return [ - row.dispatch_id, - { - outcome: projected, - pendingInput: row.pending_input === 1, - pendingGuidance: row.pending_guidance === 1, - pendingApproval: row.pending_approval === 1, - terminationReason: row.termination_reason, - isRoot: row.parent_task_id === null, - workerState: row.worker_state, - workerStage: row.worker_stage, - dispatchStatus: row.dispatch_status, - ...(row.resource_id === null - ? {} - : { hostScope: row.host_scope, releaseState: row.release_state }) + const latest = new Map>() + for (const fact of factsByDispatch.get(row.dispatch_id) ?? []) { + const previous = latest.get(fact.facet) + if (!previous || previous.sequence < fact.sequence) { + latest.set(fact.facet, fact) } - ] + } + return [row.dispatch_id, { row, observations: [...latest.values()] }] }) ) } +export function projectWorkerAttentionInput( + input: WorkerAttentionInput, + authorityNow: number +): WorkerAttentionFacts { + const { row, observations } = input + return { + outcome: projectAttemptOutcome({ + dispatchId: row.dispatch_id, + taskId: row.task_id, + facts: observations, + activeSibling: row.active_sibling === 1, + authorityNow: { home: authorityNow } + }).taskOutcome, + pendingInput: row.pending_input === 1, + pendingGuidance: row.pending_guidance === 1, + pendingApproval: row.pending_approval === 1, + terminationReason: row.termination_reason, + isRoot: row.parent_task_id === null, + workerState: row.worker_state, + workerStage: row.worker_stage, + dispatchStatus: row.dispatch_status, + ...(row.resource_id === null + ? {} + : { hostScope: row.host_scope, releaseState: row.release_state }) + } +} + +export function getWorkerAttentionFactsForDispatches( + this: OrchestrationDb, + dispatchIds: readonly string[], + authorityNow: number +): Map { + return new Map( + [...readWorkerAttentionInputs(this, dispatchIds)].map(([id, input]) => [ + id, + projectWorkerAttentionInput(input, authorityNow) + ]) + ) +} + export function getWorkerAttentionFacts( this: OrchestrationDb, dispatchId: string, diff --git a/src/main/runtime/orchestration/mailbox/routing/terminal-pane-mail-routing.ts b/src/main/runtime/orchestration/mailbox/routing/terminal-pane-mail-routing.ts index d253ae3a55d..795e9c18cc8 100644 --- a/src/main/runtime/orchestration/mailbox/routing/terminal-pane-mail-routing.ts +++ b/src/main/runtime/orchestration/mailbox/routing/terminal-pane-mail-routing.ts @@ -1,7 +1,6 @@ -import { - ptyRecordForPaneKey, - type TerminalIdentityReadContext -} from '../../../terminal/query/terminal-identity-read-context' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import type { TerminalIdentityReadContext } from '../../../terminal/query/terminal-identity-read-context' +import { ptyRecordForPaneKey } from '../../../terminal/query/pty-pane-record' import type { RuntimeTerminalAgentPresenceOptions } from '../../../terminal/query/runtime-terminal-agent-presence' import { OrcaRuntimeWithPruneMobileSessionTabGroupLayout } from '../../../session-lifecycle/projection/prune-mobile-session-tab-group-layout' import type { RuntimePtyWorktreeRecord } from '../../../terminal/records/runtime-terminal-state-records' @@ -52,7 +51,14 @@ export abstract class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeW leaves: this.leaves, ptys: this.ptysById, leafKey: (tab, leaf) => this.getLeafKey(tab, leaf), - capturePaneOf: () => this.captureTerminalPaneReader() + capturePaneOf: () => this.captureTerminalPaneReader(), + ...(!ORCA_LAYOUT_OWNER_IS_MAIN + ? { + paneOf: (pty: Parameters[0]) => this.paneOfPty(pty), + leavesForId: (id: string) => this.terminalIdentity.paneCandidates.leavesForId(id), + ptysForPane: (pane: string) => this.terminalIdentity.paneCandidates.ptysForPane(pane) + } + : {}) }, context ) diff --git a/src/main/runtime/orchestration/mailbox/terminal-pointer/in-memory-orchestration-messages.spec.ts b/src/main/runtime/orchestration/mailbox/terminal-pointer/in-memory-orchestration-messages.spec.ts index 264b99b1f56..b7d5ab26485 100644 --- a/src/main/runtime/orchestration/mailbox/terminal-pointer/in-memory-orchestration-messages.spec.ts +++ b/src/main/runtime/orchestration/mailbox/terminal-pointer/in-memory-orchestration-messages.spec.ts @@ -5,6 +5,10 @@ import type { MessagePriority, MessageRow, MessageType } from '../../db/orchestr export class InMemoryOrchestrationMessages { readonly crewChanges = new CrewChanges() + hasAnyDispatchContexts(): boolean { + return false + } + private sequence = 0 private activeCoordinatorRun: { coordinator_handle: string } | null = null diff --git a/src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-worker-authority.ts b/src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-worker-authority.ts index 7745cf67b03..1f3ce8ca6c5 100644 --- a/src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-worker-authority.ts +++ b/src/main/runtime/orchestration/terminal-authority/structured-sessions/structured-worker-authority.ts @@ -106,13 +106,9 @@ export function isRecordedStructuredWorkerSession( sessionId: OrcaSessionId, db: OrchestrationDb ): boolean { - return Boolean( - db.db - .prepare( - `SELECT 1 FROM dispatch_contexts - WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` - ) - .get(sessionId, structuredWorkerProcessIncarnation(sessionId)) + return db.hasRecordedStructuredWorkerSession( + sessionId, + structuredWorkerProcessIncarnation(sessionId) ) } diff --git a/src/main/runtime/session-graph/session-window-graph.ts b/src/main/runtime/session-graph/session-window-graph.ts index 1b9eb188010..0b69ae28946 100644 --- a/src/main/runtime/session-graph/session-window-graph.ts +++ b/src/main/runtime/session-graph/session-window-graph.ts @@ -275,6 +275,9 @@ export abstract class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAdmi } for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) + if (leaf.ptyId && this._orchestrationDb?.hasAnyDispatchContexts()) { + this.issueHandle(leaf) + } const previousLeaf = previousLeaves.get(this.getLeafKey(leaf.tabId, leaf.leafId)) // A pane newly showing a terminal can bind it to the pane's Run or Dispatch mailbox. if ( diff --git a/src/main/runtime/terminal/identity/ordered-terminal-candidates.ts b/src/main/runtime/terminal/identity/ordered-terminal-candidates.ts new file mode 100644 index 00000000000..fadd064dd80 --- /dev/null +++ b/src/main/runtime/terminal/identity/ordered-terminal-candidates.ts @@ -0,0 +1,61 @@ +/** Ordered membership only; the canonical source still owns records and validation. */ +export class OrderedTerminalCandidates { + private readonly entries = new Map() + private readonly byKey = new Map() + private nextPosition = 0n + + first(handle: string): string | undefined { + return this.byKey.get(handle)?.[0] + } + + matching(keys: readonly string[]): string[] { + return [...new Set(keys.flatMap((key) => this.byKey.get(key) ?? []))].sort((a, b) => { + const left = this.entries.get(a)!.position + const right = this.entries.get(b)!.position + return left < right ? -1 : left > right ? 1 : 0 + }) + } + + set(id: string, key: string | readonly string[]): void { + const keys = typeof key === 'string' ? [key] : key + const previous = this.entries.get(id) + if ( + previous?.keys.length === keys.length && + previous.keys.every((value, index) => value === keys[index]) + ) { + return + } + const position = previous?.position ?? this.nextPosition++ + this.delete(id) + this.entries.set(id, { keys, position }) + for (const address of keys) { + const bucket = this.byKey.get(address) ?? [] + let index = bucket.length + while (index > 0 && this.entries.get(bucket[index - 1])!.position > position) { + index-- + } + bucket.splice(index, 0, id) + this.byKey.set(address, bucket) + } + } + + delete(id: string): void { + const previous = this.entries.get(id) + if (!previous) { + return + } + for (const key of previous.keys) { + const bucket = this.byKey.get(key)! + bucket.splice(bucket.indexOf(id), 1) + if (bucket.length === 0) { + this.byKey.delete(key) + } + } + this.entries.delete(id) + } + + clear(): void { + this.entries.clear() + this.byKey.clear() + } +} diff --git a/src/main/runtime/terminal/identity/terminal-identity-placement-schedules.test.ts b/src/main/runtime/terminal/identity/terminal-identity-placement-schedules.test.ts index d5d09002d70..07849a219f0 100644 --- a/src/main/runtime/terminal/identity/terminal-identity-placement-schedules.test.ts +++ b/src/main/runtime/terminal/identity/terminal-identity-placement-schedules.test.ts @@ -99,3 +99,30 @@ it('discovers the actual restored placement only after layout apply and re-reads watched.close() } }) + +it('keeps current pane selection ordered across rebinds and detached same-ID writes', () => { + const f = identityFixture() + const suffix = PANE.slice(PANE.indexOf(':')) + const otherPane = `tab-other${suffix}` + f.add('pty-first', 'term_first', otherPane, false) + f.add('pty-second', 'term_second', PANE, true) + const first = f.owner.ptysById.get('pty-first')! + const second = f.owner.ptysById.get('pty-second')! + expect(f.owner.getPtyRecordForPaneKey(PANE)).toBe(second) + f.owner.setPtyConnected(first, true) + expect(f.owner.getPtyRecordForPaneKey(PANE)).toBe(first) + f.owner.setPtyConnected(first, false) + f.owner.setPtyConnected(second, false) + expect(f.owner.getPtyRecordForPaneKey(PANE)).toBe(second) + + const movedPane = 'tab-moved:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + f.owner.source.setRecordedSurface(second, 'tab-moved', movedPane, 1) + expect(f.owner.getPtyRecordForPaneKey(movedPane)).toBe(second) + expect(f.owner.getPtyRecordForPaneKey(PANE)).toBe(first) + const replacement = f.owner.source.createPty({ ...second, paneKey: PANE }) + f.owner.source.setRecordedSurface(second, 'tab-detached', movedPane, 2) + expect(f.owner.getPtyRecordForPaneKey(movedPane)).toBeNull() + expect(f.owner.getPtyRecordForPaneKey(PANE)).toBe(replacement) + f.owner.source.removePty('pty-second') + expect(f.owner.getPtyRecordForPaneKey(PANE)).toBe(first) +}) diff --git a/src/main/runtime/terminal/identity/terminal-identity-schedules.test.ts b/src/main/runtime/terminal/identity/terminal-identity-schedules.test.ts index e6727f0b341..814549959c6 100644 --- a/src/main/runtime/terminal/identity/terminal-identity-schedules.test.ts +++ b/src/main/runtime/terminal/identity/terminal-identity-schedules.test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest' import { identityFixture, identityGraph, LEAF, PANE } from '../query/terminal-identity.test-fixture' import { TEST_WORKTREE_ID } from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec' +import { OrchestrationDb } from '../../orchestration/db/orchestration-db' it('retains pane-issued process identity across reload with exact first-match and SSH precedence in either context', () => { for (const captured of [false, true]) { @@ -83,3 +84,58 @@ it('retains pane-issued process identity across reload with exact first-match an } } }) + +it('keeps the displayed leaf worker ahead of retained PTYs after a dispatch refresh', () => { + const f = identityFixture() + const db = new OrchestrationDb(':memory:') + try { + f.add('retained', 'old-worker', PANE, false) + f.stamp('old-worker', 'retained') + f.runtime.registerPty('current', TEST_WORKTREE_ID, null, { + tabId: 'tab-original', + leafId: LEAF, + incarnationId: 'current' + }) + f.runtime.attachWindow(1) + f.runtime.syncWindowGraph(1, identityGraph('current')) + const current = f.runtime.getTerminalHandleForPaneKey(PANE)! + const run = db.createRun({ + objective: 'Displayed worker', + coordinatorHandle: null, + coordinatorPaneKey: null + }) + const retained = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'Old worker' }).id, + assigneeHandle: 'old-worker', + assigneePaneKey: 'tab-retained:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + creator: { kind: 'system' }, + maxDepth: 99 + }) + const displayed = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'Current worker' }).id, + assigneeHandle: current, + assigneePaneKey: PANE, + creator: { kind: 'system' }, + maxDepth: 99 + }) + db.db + .prepare('UPDATE dispatch_contexts SET assignee_pane_key = ? WHERE id = ?') + .run(PANE, retained.id) + f.runtime.setOrchestrationDb(db) + expect( + f.runtime.syncWindowGraph(1, identityGraph('current')).agentOrchestrationByPaneKey?.[PANE] + ?.dispatchId + ).toBe(displayed.id) + f.owner.source.setPtyConnected(f.owner.ptysById.get('current')!, false) + db.db.prepare('UPDATE dispatch_contexts SET failure_count = 1 WHERE id = ?').run(retained.id) + expect( + f.runtime.syncWindowGraph(1, identityGraph('current')).agentOrchestrationByPaneKey?.[PANE] + ?.dispatchId + ).toBe(displayed.id) + expect(f.runtime.getAgentStatusOrchestrationContextForPaneKey(PANE)?.dispatchId).toBe( + displayed.id + ) + } finally { + db.close() + } +}) diff --git a/src/main/runtime/terminal/identity/terminal-identity-source.ts b/src/main/runtime/terminal/identity/terminal-identity-source.ts index dfcd5dcaf90..499245f4e37 100644 --- a/src/main/runtime/terminal/identity/terminal-identity-source.ts +++ b/src/main/runtime/terminal/identity/terminal-identity-source.ts @@ -5,7 +5,9 @@ import type { } from '../records/runtime-terminal-state-records' import type { TerminalHandleRecord, PtyIncarnationHandleRecord } from './terminal-handle-records' -import { TerminalIncarnationCandidates } from './terminal-incarnation-candidates' +import { OrderedTerminalCandidates } from './ordered-terminal-candidates' +import { TerminalIncarnationBindings } from './terminal-incarnation-bindings' +import { TerminalPaneCandidates } from './terminal-pane-candidates' import { retireReplacedLaunch } from '../launch/launch-evidence' type Mutable = { -readonly [K in keyof T]: T[K] } @@ -17,16 +19,13 @@ export class TerminalIdentitySource { private readonly handleRecords = new Map() private readonly ptyBindings = new Map() private readonly leafBindings = new Map() - private readonly incarnationBindings = new Map>() - private readonly incarnationCandidates = new TerminalIncarnationCandidates() + private readonly reverseCandidates = new OrderedTerminalCandidates() + private readonly incarnationSource = new TerminalIncarnationBindings(() => this.changed()) + readonly paneCandidates = new TerminalPaneCandidates() private readonly syntheticHandles = new Set() // Weak ownership links preserve detached canonical records without retaining their lifetime. private readonly ownedPtys = new WeakMap>() private readonly ownedLeaves = new WeakMap>() - private readonly ownedIncarnations = new WeakMap< - PtyIncarnationHandleRecord, - Mutable - >() private sequence = 0 private epoch = 0 @@ -42,7 +41,8 @@ export class TerminalIdentitySource { readonly handles: ReadonlyMap = this.handleRecords readonly reverse: ReadonlyMap = this.ptyBindings readonly leafHandles: ReadonlyMap = this.leafBindings - readonly incarnations: ReadonlyMap = this.incarnationBindings + readonly incarnations: ReadonlyMap = + this.incarnationSource.records readonly synthetic: ReadonlySet = this.syntheticHandles get graphSequence(): number { return this.sequence @@ -63,6 +63,7 @@ export class TerminalIdentitySource { const record = { ...input } this.ownedPtys.set(record, record) this.ptyRecords.set(record.ptyId, record) + this.paneCandidates.setPty(record) this.changed() return record } @@ -132,11 +133,15 @@ export class TerminalIdentitySource { owned.surfaceRecordedAtGraphSequence = standing } if (changed) { + if (this.ptyRecords.get(owned.ptyId) === owned) { + this.paneCandidates.setPty(owned) + } this.changed() } } removePty(id: string): boolean { + this.paneCandidates.removePty(id) return this.remove(this.ptyRecords, id) } @@ -145,10 +150,12 @@ export class TerminalIdentitySource { return } const next = new Map>() + this.paneCandidates.leaves.clear() for (const [key, input] of inputs) { const owned = this.ownedLeaves.get(input) ?? { ...input } this.ownedLeaves.set(owned, owned) next.set(key, owned) + this.paneCandidates.leaves.set(key, owned.leafId) } this.leafRecords = next this.changed() @@ -163,9 +170,11 @@ export class TerminalIdentitySource { } removeLeaf(key: string): boolean { + this.paneCandidates.leaves.delete(key) return this.remove(this.leafRecords, key) } clearLeaves(): void { + this.paneCandidates.leaves.clear() this.clear(this.leafRecords) } @@ -207,12 +216,18 @@ export class TerminalIdentitySource { } bindPty(id: string, handle: string): void { + this.reverseCandidates.set(id, handle) this.bind(this.ptyBindings, id, handle) } + firstReversePtyId(handle: string): string | undefined { + return this.reverseCandidates.first(handle) + } clearPtyBindings(): void { + this.reverseCandidates.clear() this.clear(this.ptyBindings) } unbindPty(id: string): boolean { + this.reverseCandidates.delete(id) return this.remove(this.ptyBindings, id) } bindLeaf(key: string, handle: string): void { @@ -226,44 +241,22 @@ export class TerminalIdentitySource { } setIncarnation(id: string, input: PtyIncarnationHandleRecord): void { - const previous = this.incarnationBindings.get(id) - const owned = { ...input } - this.ownedIncarnations.set(owned, owned) - this.incarnationBindings.set(id, owned) - this.incarnationCandidates.set(id, owned.handle) - if ( - !previous || - previous.handle !== owned.handle || - previous.incarnationId !== owned.incarnationId || - previous.leafKey !== owned.leafKey - ) { - this.changed() - } + this.incarnationSource.setIncarnation(id, input) } setIncarnationLeaf(record: PtyIncarnationHandleRecord, leafKey: string): void { - const owned = this.ownedIncarnations.get(record) - if (owned && owned.leafKey !== leafKey) { - owned.leafKey = leafKey - this.changed() - } + this.incarnationSource.setIncarnationLeaf(record, leafKey) } setIncarnationProcess(record: PtyIncarnationHandleRecord, incarnationId: string | null): void { - const owned = this.ownedIncarnations.get(record) - if (owned && owned.incarnationId !== incarnationId) { - owned.incarnationId = incarnationId - this.changed() - } + this.incarnationSource.setIncarnationProcess(record, incarnationId) } removeIncarnation(id: string): boolean { - this.incarnationCandidates.delete(id) - return this.remove(this.incarnationBindings, id) + return this.incarnationSource.removeIncarnation(id) } clearIncarnations(): void { - this.incarnationCandidates.clear() - this.clear(this.incarnationBindings) + this.incarnationSource.clearIncarnations() } firstIncarnationPtyId(handle: string): string | undefined { - return this.incarnationCandidates.first(handle) + return this.incarnationSource.firstIncarnationPtyId(handle) } addSynthetic(handle: string): void { diff --git a/src/main/runtime/terminal/identity/terminal-incarnation-bindings.ts b/src/main/runtime/terminal/identity/terminal-incarnation-bindings.ts new file mode 100644 index 00000000000..885169050a4 --- /dev/null +++ b/src/main/runtime/terminal/identity/terminal-incarnation-bindings.ts @@ -0,0 +1,60 @@ +import type { PtyIncarnationHandleRecord } from './terminal-handle-records' +import { OrderedTerminalCandidates } from './ordered-terminal-candidates' +type MutableIncarnation = { + -readonly [K in keyof PtyIncarnationHandleRecord]: PtyIncarnationHandleRecord[K] +} + +/** Ordered incarnation aliases and their canonical mutable records share one lifetime. */ +export class TerminalIncarnationBindings { + readonly records = new Map() + private readonly candidates = new OrderedTerminalCandidates() + private readonly ownedIncarnations = new WeakMap() + constructor(private readonly changed: () => void) {} + setIncarnation(id: string, input: PtyIncarnationHandleRecord): void { + const previous = this.records.get(id) + const owned = { ...input } + this.ownedIncarnations.set(owned, owned) + this.records.set(id, owned) + this.candidates.set(id, owned.handle) + if ( + !previous || + previous.handle !== owned.handle || + previous.incarnationId !== owned.incarnationId || + previous.leafKey !== owned.leafKey + ) { + this.changed() + } + } + setIncarnationLeaf(record: PtyIncarnationHandleRecord, leafKey: string): void { + const owned = this.ownedIncarnations.get(record) + if (owned && owned.leafKey !== leafKey) { + owned.leafKey = leafKey + this.changed() + } + } + setIncarnationProcess(record: PtyIncarnationHandleRecord, incarnationId: string | null): void { + const owned = this.ownedIncarnations.get(record) + if (owned && owned.incarnationId !== incarnationId) { + owned.incarnationId = incarnationId + this.changed() + } + } + removeIncarnation(id: string): boolean { + this.candidates.delete(id) + const removed = this.records.delete(id) + if (removed) { + this.changed() + } + return removed + } + clearIncarnations(): void { + this.candidates.clear() + if (this.records.size > 0) { + this.records.clear() + this.changed() + } + } + firstIncarnationPtyId(handle: string): string | undefined { + return this.candidates.first(handle) + } +} diff --git a/src/main/runtime/terminal/identity/terminal-incarnation-candidates.ts b/src/main/runtime/terminal/identity/terminal-incarnation-candidates.ts deleted file mode 100644 index b2168e0f92f..00000000000 --- a/src/main/runtime/terminal/identity/terminal-incarnation-candidates.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Ordered membership only; the canonical source still owns records and validation. */ -export class TerminalIncarnationCandidates { - private readonly entries = new Map() - private readonly byHandle = new Map() - private nextPosition = 0n - - first(handle: string): string | undefined { - return this.byHandle.get(handle)?.[0] - } - - set(id: string, handle: string): void { - const previous = this.entries.get(id) - if (previous?.handle === handle) { - return - } - const position = previous?.position ?? this.nextPosition++ - this.delete(id) - const bucket = this.byHandle.get(handle) ?? [] - let index = bucket.length - while (index > 0 && this.entries.get(bucket[index - 1])!.position > position) { - index-- - } - bucket.splice(index, 0, id) - this.entries.set(id, { handle, position }) - this.byHandle.set(handle, bucket) - } - - delete(id: string): void { - const previous = this.entries.get(id) - if (!previous) { - return - } - const bucket = this.byHandle.get(previous.handle)! - bucket.splice(bucket.indexOf(id), 1) - if (bucket.length === 0) { - this.byHandle.delete(previous.handle) - } - this.entries.delete(id) - } - - clear(): void { - this.entries.clear() - this.byHandle.clear() - } -} diff --git a/src/main/runtime/terminal/identity/terminal-pane-candidates.ts b/src/main/runtime/terminal/identity/terminal-pane-candidates.ts new file mode 100644 index 00000000000..f31952b7d86 --- /dev/null +++ b/src/main/runtime/terminal/identity/terminal-pane-candidates.ts @@ -0,0 +1,28 @@ +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import type { RuntimePtyWorktreeRecord } from '../records/runtime-terminal-state-records' +import { OrderedTerminalCandidates } from './ordered-terminal-candidates' + +/** Raw address membership; connectivity and pane authority remain read-time decisions. */ +export class TerminalPaneCandidates { + readonly leaves = new OrderedTerminalCandidates() + private readonly ptys = new OrderedTerminalCandidates() + + leavesForId(id: string): readonly string[] { + return this.leaves.matching([id]) + } + + ptysForPane(pane: string): readonly string[] { + const leaf = parsePaneKey(pane)?.leafId + return this.ptys.matching([`pane:${pane}`, ...(leaf ? [`leaf:${leaf}`] : [])]) + } + + setPty(record: RuntimePtyWorktreeRecord): void { + const pane = record.paneKey + const leaf = pane ? parsePaneKey(pane)?.leafId : null + this.ptys.set(record.ptyId, pane ? [`pane:${pane}`, ...(leaf ? [`leaf:${leaf}`] : [])] : []) + } + + removePty(id: string): void { + this.ptys.delete(id) + } +} diff --git a/src/main/runtime/terminal/input/terminal-send-stale-leaf-liveness.test.ts b/src/main/runtime/terminal/input/terminal-send-stale-leaf-liveness.test.ts index a567c6a4814..c9effa31366 100644 --- a/src/main/runtime/terminal/input/terminal-send-stale-leaf-liveness.test.ts +++ b/src/main/runtime/terminal/input/terminal-send-stale-leaf-liveness.test.ts @@ -336,6 +336,7 @@ function makeOrchestrationDbStub(toHandle: () => string) { }, db: { crewChanges: new CrewChanges(), + hasAnyDispatchContexts: () => false, listSessionDispatchesForPresentation: () => [], // Mirrors the real query: `read = 0 AND delivered_at IS NULL`. getUndeliveredUnreadMessages: (handle: string) => diff --git a/src/main/runtime/terminal/layout/agent-generated-tab-titles-runtime.ts b/src/main/runtime/terminal/layout/agent-generated-tab-titles-runtime.ts index afb982b09ac..e63c51a252e 100644 --- a/src/main/runtime/terminal/layout/agent-generated-tab-titles-runtime.ts +++ b/src/main/runtime/terminal/layout/agent-generated-tab-titles-runtime.ts @@ -15,7 +15,7 @@ export abstract class OrcaRuntimeWithAgentGeneratedTabTitles extends OrcaRuntime private readonly agentGeneratedTabTitles = new AgentGeneratedTabTitles({ enabled: () => this.store?.getSettings?.()?.tabAutoGenerateTitle === true, targetOf: (row) => this.generatedTitleTargetOf(row), - orchestrationByPaneKey: () => this.agentOrchestrationProjection.buildByPaneKey(), + orchestrationForPaneKey: (paneKey) => this.agentOrchestrationProjection.getForPaneKey(paneKey), apply: (command) => this.applyLayoutCommand(command) }) diff --git a/src/main/runtime/terminal/layout/agent-generated-tab-titles.test.ts b/src/main/runtime/terminal/layout/agent-generated-tab-titles.test.ts index 900c3c296dd..4727d394799 100644 --- a/src/main/runtime/terminal/layout/agent-generated-tab-titles.test.ts +++ b/src/main/runtime/terminal/layout/agent-generated-tab-titles.test.ts @@ -120,10 +120,10 @@ describe('main keeps the window’s generated-title rules', () => { ) { const host = await hostWith([GIT]) const reads = [labels].flat() - const read = vi.spyOn(host.runtime['agentOrchestrationProjection'], 'buildByPaneKey') - read.mockReturnValue(reads.at(-1)) + const read = vi.spyOn(host.runtime['agentOrchestrationProjection'], 'getForPaneKey') + read.mockImplementation((paneKey) => reads.at(-1)?.[paneKey]) for (const each of reads.slice(0, -1)) { - read.mockReturnValueOnce(each) + read.mockImplementationOnce((paneKey) => each[paneKey]) } for (const batch of statuses) { for (const row of batch) { diff --git a/src/main/runtime/terminal/layout/agent-generated-tab-titles.ts b/src/main/runtime/terminal/layout/agent-generated-tab-titles.ts index 5b9decf532c..1cb308727af 100644 --- a/src/main/runtime/terminal/layout/agent-generated-tab-titles.ts +++ b/src/main/runtime/terminal/layout/agent-generated-tab-titles.ts @@ -22,7 +22,7 @@ export type AgentGeneratedTabTitlesDeps = { enabled: () => boolean targetOf: (row: AgentStatusIpcPayload) => GeneratedTitleTarget | null /** Main's dispatch labels by pane key, which a worker's own status may lack. */ - orchestrationByPaneKey: () => Record | undefined + orchestrationForPaneKey: (paneKey: string) => AgentStatusOrchestrationContext | undefined apply: (command: RenameTabsCommand) => Promise } @@ -62,16 +62,15 @@ export class AgentGeneratedTabTitles { } // Later statuses in the batch must read earlier generated titles, matching window batching. const renames = new Map() - // Read once per batch, and only for a dispatch: it scans every dispatch record. - const orchestration = rows.some((row) => isOrcaDispatchPrompt(row.prompt)) - ? this.deps.orchestrationByPaneKey() - : undefined for (const row of rows) { const target = this.deps.targetOf(row) if (!target) { continue } - const labels = orchestration?.[row.paneKey] ?? row.orchestration + const labels = + (isOrcaDispatchPrompt(row.prompt) + ? this.deps.orchestrationForPaneKey(row.paneKey) + : undefined) ?? row.orchestration const key = `${target.workspace}\0${target.tabId}` const earlier = renames.get(key) const next = generatedTabTitleForStatus( diff --git a/src/main/runtime/terminal/query/pty-pane-record.ts b/src/main/runtime/terminal/query/pty-pane-record.ts new file mode 100644 index 00000000000..03c221349b7 --- /dev/null +++ b/src/main/runtime/terminal/query/pty-pane-record.ts @@ -0,0 +1,71 @@ +import type { + RuntimeLeafRecord, + RuntimePtyWorktreeRecord +} from '../records/runtime-terminal-state-records' +import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import type { PtyPaneOf } from '../surface/pty-pane-query' +import type { TerminalIdentityReadContext } from './terminal-identity-read-context' + +export function ptyRecordForPaneKey( + paneKey: string, + source: { + leaves: ReadonlyMap + ptys: ReadonlyMap + leafKey: (tab: string, leaf: string) => string + capturePaneOf: () => PtyPaneOf + paneOf?: PtyPaneOf + leavesForId?: (id: string) => Iterable + ptysForPane?: (pane: string) => Iterable + }, + context?: TerminalIdentityReadContext +): RuntimePtyWorktreeRecord | null { + const parsed = parsePaneKey(paneKey) + let leafPty: RuntimePtyWorktreeRecord | null = null + if (parsed) { + const leaf = source.leaves.get(source.leafKey(parsed.tabId, parsed.leafId)) + const pty = leaf?.ptyId ? source.ptys.get(leaf.ptyId) : undefined + if (pty?.connected) { + return pty + } + leafPty = pty ?? null + const candidates = context + ? context.leavesForId(parsed.leafId) + : (source.leavesForId?.(parsed.leafId) ?? source.leaves.keys()) + for (const key of candidates) { + const candidate = source.leaves.get(key) + if (!candidate) { + continue + } + if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { + continue + } + const remintedPty = source.ptys.get(candidate.ptyId) + if (remintedPty?.connected) { + return remintedPty + } + leafPty ??= remintedPty ?? null + } + } + let newestMatch: RuntimePtyWorktreeRecord | null = null + let paneOf: PtyPaneOf | undefined + const candidates = context + ? context.ptysForPane(paneKey) + : (source.ptysForPane?.(paneKey) ?? source.ptys.keys()) + for (const ptyId of candidates) { + const pty = source.ptys.get(ptyId) + if (!pty) { + continue + } + const recordedPaneKey = (paneOf ??= context?.paneOf ?? source.paneOf ?? source.capturePaneOf())( + pty + ).paneKey + const ptyPane = parsePaneKey(recordedPaneKey ?? '') + if (recordedPaneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { + if (pty.connected) { + return pty + } + newestMatch = pty + } + } + return leafPty ?? newestMatch +} diff --git a/src/main/runtime/terminal/query/pty-terminal-summary.ts b/src/main/runtime/terminal/query/pty-terminal-summary.ts index 6946cd32092..5d49d792e0f 100644 --- a/src/main/runtime/terminal/query/pty-terminal-summary.ts +++ b/src/main/runtime/terminal/query/pty-terminal-summary.ts @@ -211,7 +211,7 @@ export abstract class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntime if (!record) { const ptyId = context ? context.reversePtyIds(handle)[0] - : [...this.handleByPtyId.entries()].find(([, mappedHandle]) => mappedHandle === handle)?.[0] + : this.terminalIdentity.firstReversePtyId(handle) const pty = ptyId ? this.ptysById.get(ptyId) : null if (pty) { // Why: graph reload clears renderer handle records, but runtime-owned PTY handles remain the caller's control identity. diff --git a/src/main/runtime/terminal/query/terminal-identity-read-context.test.ts b/src/main/runtime/terminal/query/terminal-identity-read-context.test.ts index e8af70e3d38..174efc1112e 100644 --- a/src/main/runtime/terminal/query/terminal-identity-read-context.test.ts +++ b/src/main/runtime/terminal/query/terminal-identity-read-context.test.ts @@ -1,55 +1,15 @@ import { identityFixture as fixture, PANE } from './terminal-identity.test-fixture' -import { OrchestrationDb } from '../../orchestration/db/orchestration-db' import '../../orchestration/db/contract-constants' import '../../orchestration/db/messages/mailbox-routing-page' import '../../orchestration/db/dispatch-row-writer' import '../../orchestration/db/attempt-outcome-projection' -import { CrewReadModel } from '../../orchestration/crew/crew-read-model' -import { readCrewRuntimeInputs } from '../../orchestration/crew/crew-runtime-watch' import { expect, it, vi } from 'vitest' import '../../orca-runtime-tests/runtime-test-module-registration.spec' -import { TEST_WORKTREE_ID } from '../../orca-runtime-tests/orca-runtime-test-fixtures.spec' import '../../rpc/methods/orchestration/runs/runs' import '../../rpc/methods/orchestration/runs/run-scope' import '../../../../shared/orca-session-address' import '../../../../shared/orca-session-address-test-fixture' -function countIterations( - map: ReadonlyMap, - counts: { entries: number; get: number; set: number; delete: number } -) { - if (!(map instanceof Map)) { - throw new Error('Expected native map instrumentation') - } - const get = map.get.bind(map) - const set = map.set.bind(map) - const remove = map.delete.bind(map) - map.get = (key: K) => { - counts.get++ - return get(key) - } - map.set = (key: K, value: V) => { - counts.set++ - return set(key, value) - } - map.delete = (key: K) => { - counts.delete++ - return remove(key) - } - for (const method of ['entries', 'keys', 'values', Symbol.iterator] as const) { - const original = map[method].bind(map) - Object.defineProperty(map, method, { - configurable: true, - value: function* () { - for (const value of original()) { - counts.entries++ - yield value - } - } - }) - } -} - it('invalidates actual SSH-held facts on register and finally-release with equal and conflicting claims', async () => { const f = fixture() const wake = vi.fn() @@ -90,146 +50,3 @@ it('invalidates actual SSH-held facts on register and finally-release with equal expect(wake).toHaveBeenCalledTimes(6) stop() }) - -it('reuses raw inputs for new historical parties through the production Crew boundary and retires them on real owner writes', () => { - vi.useFakeTimers() - vi.setSystemTime('2026-10-28T20:00:00.000Z') - const measure = (population: number, captured: boolean) => { - const f = fixture() - const db = new OrchestrationDb(':memory:') - f.runtime.setOrchestrationDb(db) - f.add('coordinator', 'term_coord') - f.stamp('term_coord', 'coordinator') - for (let i = 0; i < population; i++) { - f.add(`unrelated-${i}`, `term_inventory_${i}`, `inventory-${i}`) - } - const counts = { entries: 0, get: 0, set: 0, delete: 0 } - countIterations(f.owner.handles, counts) - countIterations(f.owner.ptysById, counts) - countIterations(f.owner.handleByPtyId, counts) - const capture = vi.spyOn(f.runtime, 'createTerminalIdentityRawInputs') - const model = new CrewReadModel( - (previous) => { - const inputs = readCrewRuntimeInputs(f.owner.crewReaders, db, true, 'same-host', previous) - if (captured) { - return inputs - } - delete inputs.openIdentityRead - delete inputs.openRuntimeIdentityRead - return inputs - }, - (wake) => { - const stops = [f.owner.crewChanges.subscribe(wake), db.crewChanges.subscribe(wake)] - return () => stops.forEach((stop) => stop()) - } - ) - const wake = vi.fn() - const stop = model.subscribe(wake) - try { - expect(model.read('term_coord').sections.talkingWith).toEqual([]) - for (let i = 0; i < population; i++) { - db.insertMessage({ - from: `term_late_${i}`, - to: 'term_coord', - subject: 'Hello', - body: `message-${i}` - }) - } - db.db.prepare("UPDATE messages SET created_at = '2026-10-28 20:00:00'").run() - const output = model.read('term_coord') - expect(output.sections.talkingWith).toHaveLength(population) - const acquisition = { ...counts } - if (captured) { - const captures = capture.mock.calls.length - f.owner.crewChanges.notify(new Set(['status', 'names'])) - expect(model.read('term_coord')).toEqual(output) - expect(capture).toHaveBeenCalledTimes(captures) - expect(counts.entries).toBe(acquisition.entries) - // Register's actual controller handle, without another DB/status mutation, retires captured absence. - f.runtime.registerPty('late-pty', TEST_WORKTREE_ID, null, { - tabId: 'tab-late', - leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', - terminalHandle: 'term_late_0' - }) - const installed = model - .read('term_coord') - .sections.talkingWith.find((row) => row.sendAddress === 'term_late_0') - expect(installed?.openTarget).toEqual({ kind: 'terminal', handle: 'term_late_0' }) - expect(capture.mock.calls.length).toBeGreaterThan(captures) - // Owner repair may dirty the generation once; the next read must settle, never feed a rebuild loop. - model.read('term_coord') - expect(capture.mock.calls.length).toBeLessThanOrEqual(captures + 2) - const afterInstallation = capture.mock.calls.length - f.owner.recordPtyWorktree('late-pty', TEST_WORKTREE_ID, { - lastOutputAt: 500, - preview: 'unconsumed output' - }) - model.read('term_coord') - expect(capture).toHaveBeenCalledTimes(afterInstallation) - const mutation = (write: () => unknown) => { - const before = capture.mock.calls.length - write() - model.read('term_coord') - expect(capture.mock.calls.length).toBeGreaterThan(before) - model.read('term_coord') - const settled = capture.mock.calls.length - model.read('term_coord') - expect(capture).toHaveBeenCalledTimes(settled) - } - const pty = f.owner.ptysById.get('late-pty')! - mutation(() => f.owner.setPtyConnected(pty, false)) - const disconnected = capture.mock.calls.length - f.owner.setPtyConnected(pty, false) - model.read('term_coord') - expect(capture).toHaveBeenCalledTimes(disconnected) - mutation(() => f.runtime.onPtyData('late-pty', 'resume', 500)) - const resumed = capture.mock.calls.length - const normalEntries = counts.entries - const normalWakes = wake.mock.calls.length - for (let i = 0; i < population; i++) { - f.runtime.onPtyData('late-pty', 'ordinary output', 501 + i) - model.read('term_coord') - } - expect(capture).toHaveBeenCalledTimes(resumed) - expect(counts.entries).toBe(normalEntries) - expect(wake).toHaveBeenCalledTimes(normalWakes) - mutation(() => f.owner.setPtyConnected(pty, false)) - mutation(() => f.runtime.onPtySpawned('late-pty', 'new-process')) - mutation(() => - f.owner.recordPtyWorktree('late-pty', TEST_WORKTREE_ID, { - paneKey: 'tab-moved:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' - }) - ) - f.runtime.attachWindow(1) - mutation(() => f.owner.beginGraphReload(1)) - mutation(() => f.owner.dropDisconnectedPtyRecord('late-pty')) - expect( - model - .read('term_coord') - .sections.talkingWith.find((row) => row.sendAddress === 'term_late_0')?.openTarget - ).toBeUndefined() - stop() - const beforeReenroll = capture.mock.calls.length - const release = model.subscribe(wake) - model.read('term_coord') - expect(capture).toHaveBeenCalledTimes(beforeReenroll + 1) - release() - } - return { output, acquisition } - } finally { - stop() - db.close() - } - } - try { - for (const population of [3, 30]) { - const baseline = measure(population, false) - const candidate = measure(population, true) - expect(candidate.output).toEqual(baseline.output) - expect(candidate.acquisition.entries).toBeLessThan(baseline.acquisition.entries) - expect(candidate.acquisition.entries).toBeLessThan(10 * (population + 1)) - } - } finally { - vi.useRealTimers() - } -}) diff --git a/src/main/runtime/terminal/query/terminal-identity-read-context.ts b/src/main/runtime/terminal/query/terminal-identity-read-context.ts index 2bf15877064..13e3090f68a 100644 --- a/src/main/runtime/terminal/query/terminal-identity-read-context.ts +++ b/src/main/runtime/terminal/query/terminal-identity-read-context.ts @@ -262,58 +262,3 @@ export class TerminalIdentityReadContext { return values } } - -export function ptyRecordForPaneKey( - paneKey: string, - source: { - leaves: ReadonlyMap - ptys: ReadonlyMap - leafKey: (tab: string, leaf: string) => string - capturePaneOf: () => PtyPaneOf - }, - context?: TerminalIdentityReadContext -): RuntimePtyWorktreeRecord | null { - const parsed = parsePaneKey(paneKey) - let leafPty: RuntimePtyWorktreeRecord | null = null - if (parsed) { - const leaf = source.leaves.get(source.leafKey(parsed.tabId, parsed.leafId)) - const pty = leaf?.ptyId ? source.ptys.get(leaf.ptyId) : undefined - if (pty?.connected) { - return pty - } - leafPty = pty ?? null - const candidates = context ? context.leavesForId(parsed.leafId) : source.leaves.keys() - for (const key of candidates) { - const candidate = source.leaves.get(key) - if (!candidate) { - continue - } - if (candidate.leafId !== parsed.leafId || !candidate.ptyId) { - continue - } - const remintedPty = source.ptys.get(candidate.ptyId) - if (remintedPty?.connected) { - return remintedPty - } - leafPty ??= remintedPty ?? null - } - } - let newestMatch: RuntimePtyWorktreeRecord | null = null - let paneOf: PtyPaneOf | undefined - const candidates = context ? context.ptysForPane(paneKey) : source.ptys.keys() - for (const ptyId of candidates) { - const pty = source.ptys.get(ptyId) - if (!pty) { - continue - } - const recordedPaneKey = (paneOf ??= context?.paneOf ?? source.capturePaneOf())(pty).paneKey - const ptyPane = parsePaneKey(recordedPaneKey ?? '') - if (recordedPaneKey === paneKey || (parsed && ptyPane && parsed.leafId === ptyPane.leafId)) { - if (pty.connected) { - return pty - } - newestMatch = pty - } - } - return leafPty ?? newestMatch -} diff --git a/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts b/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts index 6f5424779f2..a133c8242db 100644 --- a/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts +++ b/src/main/runtime/workspaces/process-listing/workspace-process-listing.ts @@ -298,8 +298,7 @@ export abstract class OrcaRuntimeWithGetWorktreePs extends RuntimeChatWorkerEven readStructuredAttentionWorkspaceLabels(this.requireStore(), scope), isWorkspaceMuted: (scope) => this.isWorkspaceNotificationMuted(scope.workspaceId), // The same contexts the session's chat row carries. - readOrchestration: (paneKey) => - this.agentOrchestrationProjection.buildByPaneKey()?.[paneKey], + readOrchestration: (paneKey) => this.agentOrchestrationProjection.getForPaneKey(paneKey), dispatch: (event) => this.mobileNotifications.dispatch(event), reconcile: (state) => this.mobileNotifications.reconcileStructuredPromptAttention(state), now: () => Date.now() diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 19bb23352bd..152cf740043 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -91,6 +91,10 @@ class SyncDatabase { this.db.exec(sql) } + function(name: string, callback: Parameters[1]): void { + this.db.function(name, callback) + } + prepare(sql: string): SqliteStatement { const cached = this.statementCache.get(sql) if (cached) { From dcefac89388e46389da207e12026b747d3340220 Mon Sep 17 00:00:00 2001 From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:01:05 +0900 Subject: [PATCH 005/106] fix(agent-status): accept OpenCode 2 hooks after OSC reports opencode (#40759) * fix(agent-status): accept OpenCode 2 hooks after OSC reports opencode OpenCode 2's OSC app id is opencode, so the status plugin's opencode2 prompt was treated as a nested CLI and dropped. Branch rename never saw the first prompt. A different OpenCode process still does not own the pane. * fix(agent-status): keep OpenCode 2 on rows its OSC 7501 calls opencode OSC 7501 names every OpenCode `opencode`, so after an OpenCode 2 turn ended its OSC idle relabeled the row and dropped the session it resumes from. Read the OSC claim as the OpenCode 2 row's generation, and replace the identity unit tests with one server test of the real report order. --------- Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> --- .../server/server-ingest-terminal.ts | 31 ++++++------ .../server-opencode2-program-status.test.ts | 47 +++++++++++++++++++ .../identity/agent-status-identity.ts | 33 +++++++++++-- 3 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 src/main/agent-hooks/server/testing/providers/opencode/server-opencode2-program-status.test.ts diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 184f4761918..a15b3514900 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -180,6 +180,13 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges const previous = this.state.lastStatusByPaneKey.get(paneKey) as | EnrichedAgentHookEventPayload | undefined + // OSC 7501 names OpenCode 2 `opencode`; the row its hook or launch named keeps the generation. + const payload: ParsedAgentStatusPayload = + event.fromProgramStatus === true && + event.payload.agentType === 'opencode' && + previous?.payload.agentType === 'opencode2' + ? { ...event.payload, agentType: 'opencode2' } + : event.payload // OpenCode 1 run loads hooks in-process; a hook during the command owns its status. const hookOwnsCommand = event.yieldsToHookSince !== undefined && @@ -189,7 +196,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges // Record ordering before removal, which also marks a program turn boundary. const workingSince = event.fromProgramStatus === true - ? this.programStatusFacets.noteState(paneKey, event.payload, now) + ? this.programStatusFacets.noteState(paneKey, payload, now) : undefined if (event.removesProgramStatusRow === true) { this.hookSilenceRechecks.cancel(paneKey) @@ -205,7 +212,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges } const yieldEndsAt = event.fromProgramStatus === true - ? programStatusHookYieldEndsAt(previous, event.payload, workingSince) + ? programStatusHookYieldEndsAt(previous, payload, workingSince) : undefined const hookOwnsAgent = yieldEndsAt !== undefined && now < yieldEndsAt if (event.fromProgramStatus === true) { @@ -235,7 +242,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges // OSC 7501 uses the hook precedence above; other terminal reports cannot settle child-held work. (event.fromProgramStatus !== true && previous?.payload.agentType === 'claude' && - event.payload.agentType === 'claude' && + payload.agentType === 'claude' && isAgentStatusHeldOpenByChildWork(previous.payload) && previous.payload.subagents?.some((subagent) => subagent.state === 'working') === true) ) { @@ -258,10 +265,10 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges previous.worktreeId === worktreeId && // A terminal handle joins row identity; acquiring or replacing it requires publication. previous.terminalHandle === (terminalHandle ?? previous.terminalHandle) && - terminalStatusPayloadMatchesHook(previous.payload, event.payload, preserveActiveTurnStamp) && + terminalStatusPayloadMatchesHook(previous.payload, payload, preserveActiveTurnStamp) && // OSC 7501 child-only changes still require publication; absent children means none remain. (event.fromProgramStatus !== true || - agentSubagentsEqual(previous.payload.subagents, event.payload.subagents)) + agentSubagentsEqual(previous.payload.subagents, payload.subagents)) ) { // An authority transfer must refresh enriched subscribers even for an identical payload. this.refreshTerminalStatusEvidence( @@ -274,24 +281,22 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges } // Terminal status carries no provider session; retain eligible cached identity without treating unknown as a mismatch (#10630). const claimedAgentType = - event.payload.agentType && event.payload.agentType !== 'unknown' - ? event.payload.agentType - : undefined + payload.agentType && payload.agentType !== 'unknown' ? payload.agentType : undefined // A live agent's turn after `done` keeps its session (OSC 7501 reports hookless slash commands); a new run or an exited agent's resume-only row does not. Launch seeds are not completed turns. const preservedProviderSession = previous?.providerSession && (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) && (previous.payload.state !== 'done' || - event.payload.state === 'done' || + payload.state === 'done' || previous.observation?.origin === 'launch' || (!newRun && previous.providerSessionOnly !== true)) ? previous.providerSession : undefined // Without a new main-agent fact, preserve it only while the row's state and agent still match. const preservedMainAgent = - event.payload.mainAgent ?? + payload.mainAgent ?? (previous?.payload.mainAgent && - previous.payload.state === event.payload.state && + previous.payload.state === payload.state && (claimedAgentType === undefined || claimedAgentType === previous.payload.agentType) ? previous.payload.mainAgent : undefined) @@ -304,9 +309,7 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges connectionId, ...(preservedProviderSession ? { providerSession: preservedProviderSession } : {}), ...(terminalHandle ? { terminalHandle } : {}), - payload: preservedMainAgent - ? { ...event.payload, mainAgent: preservedMainAgent } - : event.payload + payload: preservedMainAgent ? { ...payload, mainAgent: preservedMainAgent } : payload }, undefined, event.origin ?? 'osc', diff --git a/src/main/agent-hooks/server/testing/providers/opencode/server-opencode2-program-status.test.ts b/src/main/agent-hooks/server/testing/providers/opencode/server-opencode2-program-status.test.ts new file mode 100644 index 00000000000..47bd88e71f0 --- /dev/null +++ b/src/main/agent-hooks/server/testing/providers/opencode/server-opencode2-program-status.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../../../agent-hook-server' +import { buildBody, PANE, postHookEvent } from '../../server.test-fixtures' + +vi.mock('../../../../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../../../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +let server: AgentHookServer | undefined +afterEach(() => { + server?.stop() +}) + +function oscStatus(state: 'working' | 'done'): void { + // OpenCode 2 reports OSC 7501 as `app=opencode`. + server!.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + connectionId: null, + fromProgramStatus: true, + payload: { state, prompt: '', agentType: 'opencode' } + }) +} + +async function hook(hook_event_name: string, prompt = ''): Promise { + const body = buildBody({ hook_event_name, sessionID: 'ses_opencode2', prompt }) + expect((await postHookEvent(server!, body, '/hook/opencode2')).status).toBe(204) +} + +it('keeps OpenCode 2 hooks and their session on a pane its OSC 7501 reports as opencode', async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + oscStatus('working') + await hook('SessionBusy', 'rename the branch') + expect(server.getStatusSnapshot()[0]).toMatchObject({ + agentType: 'opencode2', + prompt: 'rename the branch' + }) + + await hook('SessionIdle') + oscStatus('done') + expect(server.getStatusSnapshot()[0]).toMatchObject({ + agentType: 'opencode2', + state: 'done', + providerSession: { id: 'ses_opencode2' } + }) +}) diff --git a/src/shared/agent-status/identity/agent-status-identity.ts b/src/shared/agent-status/identity/agent-status-identity.ts index e6aee9920e1..b52a09c0751 100644 --- a/src/shared/agent-status/identity/agent-status-identity.ts +++ b/src/shared/agent-status/identity/agent-status-identity.ts @@ -38,6 +38,23 @@ function normalizedKnownAgentType(agentType: AgentType | null | undefined): Agen return agentType } +// OSC 7501 reports OpenCode 2 as `opencode`. The status plugin posts `opencode2`. +const OPEN_CODE_STATUS_AGENTS = new Set(['opencode', 'opencode2']) + +function sameOpenCodeStatusAgent(left: AgentType, right: AgentType): boolean { + return OPEN_CODE_STATUS_AGENTS.has(left) && OPEN_CODE_STATUS_AGENTS.has(right) +} + +function presenceComparedAs( + presence: AgentProcessPresence | undefined, + agent: AgentType +): AgentProcessPresence | undefined { + if (!presence || !sameOpenCodeStatusAgent(presence.agent, agent)) { + return presence + } + return { ...presence, agent } +} + function isActiveExistingIdentity( existing: ExistingAgentIdentity, now: number, @@ -70,11 +87,17 @@ export function resolveAgentStatusIdentity(args: { inheritedFromActivePane: false } } - const otherProcessOwner = hasOtherAgentProcessOwner(args.existing.agentPresence, { - agent: incomingAgentType, - process: args.incomingProcess - }) - if (existingAgentType === incomingAgentType) { + const sameOpenCode = sameOpenCodeStatusAgent(existingAgentType, incomingAgentType) + const otherProcessOwner = hasOtherAgentProcessOwner( + sameOpenCode + ? presenceComparedAs(args.existing.agentPresence, incomingAgentType) + : args.existing.agentPresence, + { + agent: incomingAgentType, + process: args.incomingProcess + } + ) + if (existingAgentType === incomingAgentType || sameOpenCode) { return { agentType: incomingAgentType, // Why: a nested `claude -p` shares the pane key; only the owner's own process speaks for it. From 5444524a2a803665f9dedbc304ad2a222470453b Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:06:06 +0000 Subject: [PATCH 006/106] fix(editor): show SVGs without a fixed width in the rich markdown editor (#40825) The image wrapper was inline-block, so it sized itself from the image's natural width. An SVG with a viewBox but no absolute width has none, so the wrapper and the image collapsed to 0x0. Keep the wrapper inline so the image sizes against the paragraph, as it does in the preview. --- .../rich-markdown/schema/rich-markdown-extensions.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/editor/rich-markdown/schema/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown/schema/rich-markdown-extensions.ts index 6deada4d943..653b47c2f71 100644 --- a/src/renderer/src/components/editor/rich-markdown/schema/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown/schema/rich-markdown-extensions.ts @@ -125,13 +125,15 @@ export function createRichMarkdownExtensions({ return ({ node, HTMLAttributes, editor }) => { // A wrapper prevents native image-byte dragging from conflicting with ProseMirror’s schema-node relocation. const dom = document.createElement('span') - // Keep the image wrapper inline so it cannot break surrounding text onto separate lines. - dom.style.display = 'inline-block' + // Inline, not inline-block: a shrink-to-fit wrapper collapses an SVG that has a viewBox but no width to 0×0. + dom.style.display = 'inline' dom.style.lineHeight = '0' - dom.style.maxWidth = '100%' const img = document.createElement('img') img.draggable = false + // Undo preflight's block, middle-aligned images so text keeps flowing around them as before. + img.style.display = 'inline-block' + img.style.verticalAlign = 'baseline' let renderedAttributeNames = new Set() const syncImageAttributes = (attributes: Record): void => { const nextNames = new Set() From a29fcfe7200df7485bc26ad7a2f17e3d5ed1d292 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:07:30 +0000 Subject: [PATCH 007/106] fix(linear): refuse relation writes when the duplicate lookup omits pageInfo (#40832) --- src/main/linear/issue-relation-write.test.ts | 54 ++++++++++++++++++++ src/main/linear/issue-relation-write.ts | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/main/linear/issue-relation-write.test.ts diff --git a/src/main/linear/issue-relation-write.test.ts b/src/main/linear/issue-relation-write.test.ts new file mode 100644 index 00000000000..9eb2ba48ae5 --- /dev/null +++ b/src/main/linear/issue-relation-write.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' + +const { rawRequest } = vi.hoisted(() => ({ rawRequest: vi.fn() })) + +vi.mock('./linear-request-concurrency', () => ({ + acquire: vi.fn().mockResolvedValue(undefined), + release: vi.fn() +})) + +vi.mock('./linear-token-store', () => ({ + clearToken: vi.fn() +})) + +vi.mock('./client', () => ({ + getClients: () => [{ workspace: { id: 'workspace-1' }, client: { client: { rawRequest } } }], + isAuthError: () => false +})) + +import { writeIssueRelation } from './issue-relation-write' + +describe('writeIssueRelation', () => { + it('does not create a relation when the duplicate lookup omits pageInfo', async () => { + rawRequest.mockImplementation(async (query: string) => + query.includes('issueRelationCreate') + ? { + data: { + issueRelationCreate: { + success: true, + issueRelation: { + id: 'rel-new', + type: 'blocks', + issue: { id: 'issue-1' }, + relatedIssue: { id: 'issue-2' } + } + } + } + } + : { data: { issue: { relations: { nodes: [] } } } } + ) + const issue = { id: 'issue-1', identifier: 'LIN-1', title: 'One', url: 'https://x/1' } + const relatedIssue = { id: 'issue-2', identifier: 'LIN-2', title: 'Two', url: 'https://x/2' } + + await expect( + writeIssueRelation({ + issue, + relatedIssue, + relationship: 'blocks', + operation: 'add', + workspaceId: 'workspace-1' + }) + ).rejects.toMatchObject({ code: 'linear_partial' }) + expect(rawRequest).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/linear/issue-relation-write.ts b/src/main/linear/issue-relation-write.ts index 9b340d28bd1..e63e26fdb70 100644 --- a/src/main/linear/issue-relation-write.ts +++ b/src/main/linear/issue-relation-write.ts @@ -9,6 +9,7 @@ import { acquire, release } from './linear-request-concurrency' import { clearToken } from './linear-token-store' import { getClients, isAuthError } from './client' import { linearError } from './issue-context/errors' +import { linearConnectionHasNextPage } from './issue-context/pagination' import { INVERSE_RELATIONS_QUERY, RELATIONS_QUERY, @@ -135,7 +136,7 @@ async function findRelationInDirection( } inspected += nodes.length - const hasMore = connection?.pageInfo?.hasNextPage === true + const hasMore = linearConnectionHasNextPage(connection) const nextCursor = connection?.pageInfo?.endCursor ?? undefined if (!hasMore) { return { relation: null, hasMore: false } From 15f7cb194f1255bb1fd6cfc577bcf95d3c4500c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 10 Nov 2026 11:09:08 -0800 Subject: [PATCH 008/106] Avoid repeated tab searches for terminal notifications (#40598) * perf: avoid repeated notification membership scans * fix: use the canonical tab transition reader * test: type optional notification membership fixtures * test: preserve optional membership type during replacement --- .../terminal-notification-liveness.ts | 27 ++++----- .../unread-agent-session-membership.test.ts | 42 ++++++++++++++ .../unread-agent-session-membership.ts | 56 +++++++++++++++++++ .../agent-hook-completion-notifications.ts | 52 +++-------------- .../workspace-attention-slice.ts | 21 ++++--- 5 files changed, 129 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.test.ts create mode 100644 src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.ts diff --git a/src/renderer/src/components/terminal-pane/notifications/terminal-notification-liveness.ts b/src/renderer/src/components/terminal-pane/notifications/terminal-notification-liveness.ts index de3b0331d12..8a51218acf7 100644 --- a/src/renderer/src/components/terminal-pane/notifications/terminal-notification-liveness.ts +++ b/src/renderer/src/components/terminal-pane/notifications/terminal-notification-liveness.ts @@ -4,8 +4,13 @@ import '@/store/index' import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' import type { TerminalPaneLayoutNode } from '../../../../../shared/workspace-layout/terminal-tab-types' +import { + getFirstTerminalTab, + terminalTabHasOwnerOutside +} from '@/store/terminals/terminal-tab-owner-index' type StoreSnapshot = ReturnType +const emptyTerminalTabs: StoreSnapshot['tabsByWorktree'] = {} export function getPaneKeyTabId(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) @@ -74,11 +79,9 @@ export function isCurrentLivePaneKey( return false } - const tabExistsInAnotherWorktree = Object.entries(state.tabsByWorktree).some( - ([candidateWorktreeId, tabs]) => - candidateWorktreeId !== worktreeId && tabs.some((tab) => tab.id === parsed.tabId) - ) - if (tabExistsInAnotherWorktree) { + if ( + terminalTabHasOwnerOutside(state.tabsByWorktree ?? emptyTerminalTabs, parsed.tabId, worktreeId) + ) { return false } @@ -113,17 +116,11 @@ export function isCurrentKnownPaneKey( return false } - let targetTabPtyId: string | null | undefined - for (const [candidateWorktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - const tab = tabs.find((candidate) => candidate.id === parsed.tabId) - if (!tab) { - continue - } - if (candidateWorktreeId !== worktreeId) { - return false - } - targetTabPtyId = tab.ptyId + const tabsByWorktree = state.tabsByWorktree ?? emptyTerminalTabs + if (terminalTabHasOwnerOutside(tabsByWorktree, parsed.tabId, worktreeId)) { + return false } + const targetTabPtyId = getFirstTerminalTab(tabsByWorktree, parsed.tabId)?.ptyId if (targetTabPtyId === undefined) { return false } diff --git a/src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.test.ts b/src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.test.ts new file mode 100644 index 00000000000..ef92954ffb4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { replaceTabBuckets } from '@/store/keyed-record-transitions' +import { hasUnreadAgentSessionTab } from './unread-agent-session-membership' + +describe('unread agent-session membership', () => { + it('keeps eligible duplicates until their last workspace membership leaves', () => { + const initial: Record = { + first: [ + { id: 'same-bucket' }, + { id: 'same-bucket', contentType: 'agent-session' }, + { id: 'cross-bucket', contentType: 'terminal' } + ], + second: [ + { id: 'cross-bucket', contentType: 'agent-session' }, + { id: 'same-bucket', contentType: 'agent-session' } + ] + } + expect(hasUnreadAgentSessionTab(initial, 'same-bucket')).toBe(true) + expect(hasUnreadAgentSessionTab(initial, 'cross-bucket')).toBe(true) + + const moved = replaceTabBuckets(initial, [['first', null]]) + expect(hasUnreadAgentSessionTab(moved, 'same-bucket')).toBe(true) + + const replaced = replaceTabBuckets<{ id: string; contentType?: string }>(moved, [ + ['second', [{ id: 'same-bucket', contentType: 'terminal' }]] + ]) + expect(hasUnreadAgentSessionTab(replaced, 'same-bucket')).toBe(false) + expect(hasUnreadAgentSessionTab(replaced, 'cross-bucket')).toBe(false) + }) + + it('reconciles unannotated replacements, removed buckets and older snapshots', () => { + const initial = { first: [{ id: 'chat', contentType: 'agent-session' }] } + expect(hasUnreadAgentSessionTab(initial, 'chat')).toBe(true) + + const next = { second: [{ id: 'next-chat', contentType: 'agent-session' }] } + expect(hasUnreadAgentSessionTab(next, 'chat')).toBe(false) + expect(hasUnreadAgentSessionTab(next, 'next-chat')).toBe(true) + expect(hasUnreadAgentSessionTab(initial, 'chat')).toBe(true) + expect(hasUnreadAgentSessionTab(initial, 'next-chat')).toBe(false) + expect(hasUnreadAgentSessionTab(undefined, 'chat')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.ts b/src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.ts new file mode 100644 index 00000000000..c5898f11592 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/notifications/unread-agent-session-membership.ts @@ -0,0 +1,56 @@ +import { readTabMapChanges } from '@/store/keyed-record-change-reads' + +type MembershipTab = { id: string; contentType?: string } +type TabBuckets = Readonly> +type IndexedBucket = { source: readonly MembershipTab[]; ids: ReadonlySet } + +const emptyBuckets: TabBuckets = {} +let source: TabBuckets = emptyBuckets +const buckets = new Map() +const membershipCounts = new Map() + +export function hasUnreadAgentSessionTab( + tabsByWorktree: TabBuckets | undefined, + tabId: string +): boolean { + const next = tabsByWorktree ?? emptyBuckets + if (source !== next) { + const changes = readTabMapChanges(next, source) + // Unknown provenance includes ordinary writes; unchanged bucket identities still bound the work. + const workspaceIds = + changes?.kind === 'addressed' + ? changes.workspaceIds + : new Set([...buckets.keys(), ...Object.keys(next)]) + for (const workspaceId of workspaceIds) { + const tabs = Object.hasOwn(next, workspaceId) ? next[workspaceId] : undefined + const previous = buckets.get(workspaceId) + if (previous?.source === tabs) { + continue + } + for (const id of previous?.ids ?? []) { + const count = membershipCounts.get(id) ?? 0 + if (count > 1) { + membershipCounts.set(id, count - 1) + } else { + membershipCounts.delete(id) + } + } + if (!tabs) { + buckets.delete(workspaceId) + continue + } + const ids = new Set() + for (const tab of tabs) { + if (tab.contentType === 'agent-session') { + ids.add(tab.id) + } + } + for (const id of ids) { + membershipCounts.set(id, (membershipCounts.get(id) ?? 0) + 1) + } + buckets.set(workspaceId, { source: tabs, ids }) + } + source = next + } + return membershipCounts.has(tabId) +} diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index b91f65180b0..cdedf36ea75 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -21,15 +21,16 @@ import { discardHookLaneCompletionRequests, submitHookLaneCompletion } from './agent-hook-completion-revision-hold' +import { getFirstTerminalTab } from '@/store/terminals/terminal-tab-owner-index' import * as finishDrops from '@/attention/agent-finish-drop-breadcrumb' import type { CoordinatorEntry, StoreSnapshot, - WorktreeTab, - TabIndex, PaneCoordinatorLivenessSnapshot } from './agent-hook-completion-notification-types' +const emptyTerminalTabs: StoreSnapshot['tabsByWorktree'] = {} + const coordinatorsByPaneKey = new Map() const paneKeysRequiringFreshWorking = new Set() let wasAgentTaskCompleteTrackingEnabled: boolean | undefined @@ -43,19 +44,6 @@ function disposeCoordinatorForPaneKey(paneKey: string): void { paneKeysRequiringFreshWorking.delete(paneKey) } -function buildTabIndex(tabsByWorktree: StoreSnapshot['tabsByWorktree']): TabIndex { - const index = new Map() - for (const tabs of Object.values(tabsByWorktree ?? {})) { - for (const tab of tabs) { - // Preserve first-wins lookup when tab IDs collide across worktrees. - if (!index.has(tab.id)) { - index.set(tab.id, tab) - } - } - } - return index -} - function pruneClosedPaneCoordinators(): void { // Module-scoped coordinators outlive panes unless close/sleep liveness changes prune them. if (coordinatorsByPaneKey.size === 0 && paneKeysRequiringFreshWorking.size === 0) { @@ -78,15 +66,14 @@ function pruneClosedPaneCoordinators(): void { return } lastPrunedLivenessSnapshot = livenessSnapshot - const tabIndex = buildTabIndex(livenessSnapshot.tabsByWorktree) for (const [paneKey, entry] of coordinatorsByPaneKey) { - if (!paneCanReceiveHookCompletion(paneKey, tabIndex)) { + if (!paneCanReceiveHookCompletion(paneKey)) { finishDrops.recordPrunedPendingFinish(entry.worktreeId, paneKey, entry.coordinator) disposeCoordinatorForPaneKey(paneKey) } } for (const paneKey of paneKeysRequiringFreshWorking) { - if (!paneCanReceiveHookCompletion(paneKey, tabIndex)) { + if (!paneCanReceiveHookCompletion(paneKey)) { paneKeysRequiringFreshWorking.delete(paneKey) } } @@ -140,33 +127,12 @@ function paneHasLivePty(paneKey: string): boolean { return getPtyIdForPaneKey(paneKey) !== null } -function resolveTabById( - state: StoreSnapshot, - tabId: string, - tabIndex?: TabIndex -): WorktreeTab | undefined { - if (tabIndex) { - return tabIndex.get(tabId) - } - for (const tabs of Object.values(state.tabsByWorktree ?? {})) { - const found = tabs.find((candidate) => candidate.id === tabId) - if (found) { - return found - } - } - return undefined -} - -function paneKeyHasUnsuppressedPtyHint( - state: StoreSnapshot, - paneKey: string, - tabIndex?: TabIndex -): boolean { +function paneKeyHasUnsuppressedPtyHint(state: StoreSnapshot, paneKey: string): boolean { const parsed = parsePaneKey(paneKey) if (!parsed) { return false } - const tab = resolveTabById(state, parsed.tabId, tabIndex) + const tab = getFirstTerminalTab(state.tabsByWorktree ?? emptyTerminalTabs, parsed.tabId) if (!tab) { return false } @@ -180,10 +146,10 @@ function paneKeyHasUnsuppressedPtyHint( return ptyHints.length === 0 || ptyHints.some((ptyId) => !state.suppressedPtyExitIds?.[ptyId]) } -function paneCanReceiveHookCompletion(paneKey: string, tabIndex?: TabIndex): boolean { +function paneCanReceiveHookCompletion(paneKey: string): boolean { const state = useAppStore.getState() // Native hooks prove live status even before inactive-worktree PTY maps hydrate. - return paneKeyHasUnsuppressedPtyHint(state, paneKey, tabIndex) || paneHasLivePty(paneKey) + return paneKeyHasUnsuppressedPtyHint(state, paneKey) || paneHasLivePty(paneKey) } function hookLaneDispatchEnabled(paneKey: string): boolean { diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts index 8adf60b8541..5ec3ba4ecb6 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-attention-slice.ts @@ -19,6 +19,10 @@ import { import { workspaceMarkerTabIds } from './workspace-live-markers' import { isTabOnScreen } from '@/hooks/agent-auto-ack-targets' import { isWorkspaceNotificationMutedBySidebarFilters } from '@/attention/hidden-workspace-notification-mute' +import { getTerminalTabOwnerWorktreeId } from '../../terminals/terminal-tab-owner-index' +import { hasUnreadAgentSessionTab } from '@/components/terminal-pane/notifications/unread-agent-session-membership' + +const emptyTerminalTabs: AppState['tabsByWorktree'] = {} export const createWorkspaceAttentionSlice: StateCreator< AppState, @@ -28,23 +32,18 @@ export const createWorkspaceAttentionSlice: StateCreator< > = (set, get) => { const markTabUnread = (tabId: string, reason: AgentAttentionUnreadReason): void => { const state = get() + if (state.unreadTerminalTabs[tabId]) { + return + } // Terminal tab IDs and structured-chat unified IDs both own markers; SortableTab/findRenamableUnifiedTab use the same pair. const ownsTab = - Object.values(state.tabsByWorktree ?? {}) - .flat() - .some((tab) => tab.id === tabId) || - Object.values(state.unifiedTabsByWorktree ?? {}) - .flat() - .some((tab) => tab.contentType === 'agent-session' && tab.id === tabId) + getTerminalTabOwnerWorktreeId(state.tabsByWorktree ?? emptyTerminalTabs, tabId) !== null || + hasUnreadAgentSessionTab(state.unifiedTabsByWorktree, tabId) if (!ownsTab) { return } // Why: a marker stays until its tab is read on screen, Marked as read, or closed. - set((s) => - s.unreadTerminalTabs[tabId] - ? s - : { unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: reason } } - ) + set((s) => ({ unreadTerminalTabs: { ...s.unreadTerminalTabs, [tabId]: reason } })) } const markPaneUnread = (paneKey: string, reason: AgentAttentionUnreadReason): void => { set((s) => From 34d98debdf1f0b4f8ce9fb777a20608468d4d314 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:11:30 +0000 Subject: [PATCH 009/106] fix(mobile): read and resume session history on the server a workspace runs on (#40829) --- .../runtime/rpc/server/runtime-rpc-mobile-method-routing.ts | 6 ++++++ .../rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/rpc/server/runtime-rpc-mobile-method-routing.ts b/src/main/runtime/rpc/server/runtime-rpc-mobile-method-routing.ts index 937d3cf7482..aad41f99239 100644 --- a/src/main/runtime/rpc/server/runtime-rpc-mobile-method-routing.ts +++ b/src/main/runtime/rpc/server/runtime-rpc-mobile-method-routing.ts @@ -16,6 +16,12 @@ export const EXECUTION_HOST_METHODS = [ 'agentActivity.setRead', 'agentTeams.prepareLaunch', 'agentTeams.tmuxCompat', + // Transcripts live on the host that ran the agent, which is also where resume launches it. + 'aiVault.listSessions', + 'aiVault.prepareSessionResume', + 'aiVault.resolveSessionTitles', + 'aiVault.searchSessions', + 'aiVault.searchStatus', 'automation.list', 'automation.show', 'automation.create', diff --git a/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts index 945761935cd..12b4c117365 100644 --- a/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/rpc/server/runtime-rpc-mobile-paired-desktop-methods.ts @@ -97,12 +97,6 @@ export const PAIRED_DESKTOP_METHODS = [ 'github.project.updatePullRequestBySlug', 'github.project.viewTable', 'github.project.workItemDetailsBySlug', - // Desktop main federates session history across hosts itself (executionHostId / scope params). - 'aiVault.listSessions', - 'aiVault.prepareSessionResume', - 'aiVault.resolveSessionTitles', - 'aiVault.searchSessions', - 'aiVault.searchStatus', // User preferences live on the Mac; the phone's settings screens mirror the desktop's. 'settings.get', 'settings.getTerminalQuickCommands', From 21de0ae3f98d034f63f9090cd82f8674217be554 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:14:44 +0000 Subject: [PATCH 010/106] fix(agent-launch): select a focused CLI chat launch in the window (#40828) `orca agent launch --focus` and `worktree create --agent --activate` left a chat launch unselected: main marked the chat active only in its tab listing, which the window follows solely on its own focus intent (#5435). Send the existing focusEditorTab for that chat; when its tab is not listed yet, record the focus intent so the listing selects it unless the user moved on. Co-authored-by: Manuel Meurer --- .../events/runtime-notifier-contract.ts | 3 +- src/main/runtime/orca-runtime.ts | 8 +++++ .../agent-launch-caller-selection.test.ts | 12 ++++++++ .../agent-launch-caller-selection.ts | 26 ++++++++++++++++- .../rpc/methods/agent-launch/agent-launch.ts | 6 ++-- src/main/window/runtime-window-lifecycle.ts | 3 +- .../terminal-ui-routing-ipc-bridge.ts | 29 +++++++++++++++++++ 7 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/client/events/runtime-notifier-contract.ts b/src/main/runtime/client/events/runtime-notifier-contract.ts index aeaacdf7d83..699cea69893 100644 --- a/src/main/runtime/client/events/runtime-notifier-contract.ts +++ b/src/main/runtime/client/events/runtime-notifier-contract.ts @@ -136,7 +136,8 @@ export type RuntimeNotifier = { focusTerminal(tabId: string, worktreeId: string, leafId?: string | null): void /** Raises the window onto a pane it shows; resolves false when the window does not know the worktree. */ revealTerminalPane?(target: TerminalPaneRevealTarget): Promise - focusEditorTab?(tabId: string, worktreeId: string): void + /** `userInitiated` lets a chat tab move the window to another worktree. */ + focusEditorTab?(tabId: string, worktreeId: string, options?: { userInitiated: true }): void closeSessionTab?(tabId: string, worktreeId: string): void | Promise moveSessionTab?(worktreeId: string, move: RuntimeMobileSessionTabMove): void /** The window moves a client's pane to a new tab, since it owns the layout; rejects when it cannot answer. */ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index aeebd8bad02..456f346a079 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -62,6 +62,7 @@ import { getHostDisplayLabelOverrides } from '../../shared/host-setting-override import type { OrchestrationDb } from './orchestration/db/orchestration-db' import { continueRestoredWorkerMailSoon } from './rpc/methods/orchestration/messaging/settled-worker-follow-through' import { PeerMailRelay } from './orchestration/federation/peer-mail/peer-mail-relay' +import { structuredAgentSessionHostTabId } from '../../shared/agent-session/view/structured-agent-session-host-tab-id' const OrcaRuntimeSidebarBase: typeof OrcaRuntimeWithAgentTransitions = ORCA_SIDEBAR_INPUTS_IS_MAIN ? OrcaRuntimeWithSidebarTemporal @@ -222,6 +223,13 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { return this.notifier.publishAgentLaunchTab(request) } + /** The window follows a listed active chat only on its own intent (#5435), so a focused launch names it. */ + focusLaunchedChatInHostWindow(worktreeId: string, sessionId: string): void { + this.notifier?.focusEditorTab?.(structuredAgentSessionHostTabId(sessionId), worktreeId, { + userInitiated: true + }) + } + /** Retain final verdicts, clear settled ones, and remove withdrawn panes without removing split siblings. */ reportAgentLaunchPaneVerdict( pane: AgentLaunchPaneAddress, diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.test.ts index 97f3bcf9a9d..9258ac71ab0 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.test.ts @@ -24,6 +24,7 @@ vi.mock('./agent-launch-terminal-prompt', () => ({ })) const { AGENT_LAUNCH_METHODS } = await import('./agent-launch') const AGENT_LAUNCH_REPLAY = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launchReplay') +const AGENT_LAUNCH = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') const TAB_ID = '9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d' const LEAF_ID = '3f2504e0-4f89-41d3-9a0c-0305e82c3301' @@ -76,3 +77,14 @@ describe('a replayed launch', () => { expect(replay.selectCreatedMobileSessionTabForClient).not.toHaveBeenCalled() }) }) + +describe('a focused chat launch from the local CLI', () => { + it('asks the host window to select the chat', async () => { + const runtime = Object.assign(runtimeStub({}), { focusLaunchedChatInHostWindow: vi.fn() }) + const params = AGENT_LAUNCH.params.parse({ ...EXISTING_LAUNCH, presentation: 'focused' }) + + await AGENT_LAUNCH.handler(params, rpcContext(runtime, {})) + + expect(runtime.focusLaunchedChatInHostWindow).toHaveBeenCalledWith('wt-7', 'sess-1') + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.ts index be3595a64eb..4876683f976 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch-caller-selection.ts @@ -11,6 +11,8 @@ import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane import type { OrcaRuntimeService } from '../../../orca-runtime' import type { RpcContext } from '../../core' import { clientRendersStructuredAgent } from '../structured-sessions/structured-agent-session-policy' +import type { AgentLaunchParams } from '../../../../../shared/rpc-contract/agent-launch-params' +import { agentLaunchTabViewerRule } from './agent-launch-tab-publication' /** The paired client whose view this launch should move, or null when it moves the host's. */ export function agentLaunchCallerNavigationId( @@ -23,7 +25,7 @@ export function agentLaunchCallerNavigationId( } /** Bookkeeping, never a gate: the agent already runs, so a failure here only leaves the view as it was. */ -export function selectAgentLaunchTabForCaller( +function selectAgentLaunchTabForCaller( runtime: Pick, surface: AgentLaunchPublishedSurface, clientNavigationId: string @@ -54,6 +56,28 @@ export function selectAgentLaunchTabForCaller( } } +/** Moves the view the launch asked for: the paired caller's own, or the host window's to a focused chat. */ +export function selectLaunchedTab( + context: Pick, + surface: AgentLaunchPublishedSurface, + presentation: AgentLaunchParams['presentation'], + callerNavigationId: string | null +): void { + if (callerNavigationId !== null) { + if (presentation !== 'background') { + selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) + } + return + } + // A terminal's reveal selects its own tab; a chat's listing alone never moves the window (#5435). + if ( + surface.outcome.kind === 'structured' && + agentLaunchTabViewerRule(context, presentation) === 'focus-window' + ) { + context.runtime.focusLaunchedChatInHostWindow(surface.worktreeId, surface.outcome.sessionId) + } +} + /** * `agent.launch.v2` was defined when Claude and Codex were the only chats, so it vouches for those * two. Any other agent's chat needs the client to say it reads it, by the rule tabs and restart diff --git a/src/main/runtime/rpc/methods/agent-launch/agent-launch.ts b/src/main/runtime/rpc/methods/agent-launch/agent-launch.ts index 9153c60688d..ce81b25b229 100644 --- a/src/main/runtime/rpc/methods/agent-launch/agent-launch.ts +++ b/src/main/runtime/rpc/methods/agent-launch/agent-launch.ts @@ -42,7 +42,7 @@ import { import { agentLaunchCallerNavigationId, callerRendersLaunchedChat, - selectAgentLaunchTabForCaller + selectLaunchedTab } from './agent-launch-caller-selection' import { agentLaunchWorkspaceFactory } from './agent-launch-worktree-creation' import { @@ -89,9 +89,7 @@ async function runAgentLaunch( } view.early?.surfacePublished(surface) replaySafe?.recordSurface(withPlacement(surface, view)) - if (callerNavigationId !== null && view.presentation !== 'background') { - selectAgentLaunchTabForCaller(context.runtime, surface, callerNavigationId) - } + selectLaunchedTab(context, surface, view.presentation, callerNavigationId) } }) return withPlacement(result, view) diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 9af8fcb8048..b5d40e737b9 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -182,7 +182,8 @@ export function registerRuntimeWindowLifecycle( focusTerminal: (tabId, worktreeId, leafId) => send('ui:focusTerminal', { tabId, worktreeId, leafId }), revealTerminalPane: (target) => revealTerminalPaneInWindow(mainWindow, target), - focusEditorTab: (tabId, worktreeId) => send('ui:focusEditorTab', { tabId, worktreeId }), + focusEditorTab: (tabId, worktreeId, options) => + send('ui:focusEditorTab', { tabId, worktreeId, ...options }), closeSessionTab: (tabId, worktreeId) => requestSessionTabCloseFromRenderer(mainWindow, tabId, worktreeId), moveSessionTab: (worktreeId: string, move: RuntimeMobileSessionTabMove) => diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts index 80029ac0ba8..9b723b2ae41 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -13,6 +13,31 @@ import { resolveWindowTabIdForHostTab } from './host-session-tab-target' import { revealWorkspaceSurface } from '@/lib/workspace/activation/workspace-surface-reveal' import { focusTerminalInitiatedTab } from './terminal-command-state' import { applyRuntimePaneTitle } from './terminal-pane-title-ipc-bridge' +import { isStructuredAgentSessionHostTabId } from '../../../../shared/agent-session/view/structured-agent-session-host-tab-id' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/native-chat/local-sessions/local-structured-session-owner' +import { + recordWebSessionFocusIntent, + resolveWebSessionVisibleTabId +} from '@/runtime/web-session-tabs-sync/navigation/web-session-focus-intent' + +/** A launched chat's listing can trail its focus request; follow it then, unless the user picked a tab meanwhile. */ +function focusChatWhenListed(worktreeId: string, hostTabId: string): void { + // A just-created worktree is not known yet; its own activation reveals it. + if (useAppStore.getState().getKnownWorktreeById(worktreeId)) { + revealWorkspaceSurface(useAppStore.getState(), worktreeId) + } + // Read after the reveal, which can change the workspace's visible tab. + const store = useAppStore.getState() + const populated = (store.unifiedTabsByWorktree[worktreeId] ?? []).length > 0 + recordWebSessionFocusIntent( + { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + worktreeId, + hostTabId, + undefined, + // An empty workspace is still provisioning; its Setup and first terminal tabs must not cancel this. + populated ? resolveWebSessionVisibleTabId(store, worktreeId) : undefined + ) +} type RuntimeTerminalSplitRequest = SplitTerminalPaneDetail & { worktreeId?: string } @@ -166,6 +191,10 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void return } if (!tab) { + if (userInitiated && isStructuredAgentSessionHostTabId(tabId)) { + focusChatWhenListed(worktreeId, tabId) + return + } if (browserTarget) { // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. revealWorkspaceSurface(store, worktreeId) From 83e36f848451faf9ccbc003ef7bd75f6a8c89c66 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:28:43 +0000 Subject: [PATCH 011/106] fix(agent-status): keep a finished Codex turn done while its background terminal runs (#40839) --- .../server/program-status/hook-precedence.ts | 16 ++++++-- ...er-claude-resume-record-continuity.test.ts | 11 +---- ...-codex-background-terminal-working.test.ts | 40 +++++++++++++++++++ .../server/testing/server.test-fixtures.ts | 17 ++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/main/agent-hooks/server/testing/providers/codex/server-codex-background-terminal-working.test.ts diff --git a/src/main/agent-hooks/server/program-status/hook-precedence.ts b/src/main/agent-hooks/server/program-status/hook-precedence.ts index c6e1e332197..b04c6f77100 100644 --- a/src/main/agent-hooks/server/program-status/hook-precedence.ts +++ b/src/main/agent-hooks/server/program-status/hook-precedence.ts @@ -41,11 +41,19 @@ export function programStatusHookYieldEndsAt( ) { return Infinity } - // Settled turns are naturally silent; a later wait must prove intervening program work. - if (previous.payload.state === 'done' && payload.state === 'waiting') { - return programWorkingSince !== undefined && programWorkingSince > previous.receivedAt + // A settled turn yields only to program work begun after it (a replayed done keeps its own time): + // Codex re-reports the settled turn's working while a background terminal it started runs. + if ( + previous.payload.state === 'done' && + (payload.state === 'waiting' || payload.state === 'working') + ) { + const doneAt = previous.evidenceObservedAt ?? previous.receivedAt + if (programWorkingSince === undefined || programWorkingSince <= doneAt) { + return Infinity + } + return payload.state === 'waiting' ? undefined - : Infinity + : previous.receivedAt + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS } // A question sends no hooks while open, so a `working` that began before it (e.g. re-sent by the // lease renewal) proves nothing; a working transition after it keeps the silence fallback. diff --git a/src/main/agent-hooks/server/testing/identity/server-claude-resume-record-continuity.test.ts b/src/main/agent-hooks/server/testing/identity/server-claude-resume-record-continuity.test.ts index 6c356e7b35b..35b2bdc1a6e 100644 --- a/src/main/agent-hooks/server/testing/identity/server-claude-resume-record-continuity.test.ts +++ b/src/main/agent-hooks/server/testing/identity/server-claude-resume-record-continuity.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { AgentHookServer } from '../../agent-hook-server' import { HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS } from '../../program-status/hook-precedence' -import { buildBody, PANE, postHookEvent } from '../server.test-fixtures' +import { buildBody, ingestProgramStatusOsc, PANE, postHookEvent } from '../server.test-fixtures' const SESSION = '70efcb3f-ae17-4863-9318-649f5469408b' @@ -54,14 +54,7 @@ it('keeps the start folder and effort when a terminal status row precedes the ne vi.useFakeTimers({ toFake: ['Date'] }) vi.setSystemTime(Date.now() + HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS) // Claude reports OSC 7501 `working` just before UserPromptSubmit. - server.ingestTerminalStatus({ - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - connectionId: null, - fromProgramStatus: true, - payload: { prompt: '', state: 'working', agentType: 'claude' } - }) + ingestProgramStatusOsc(server, '\x1b]7501;state=working:app=claude-code\x1b\\') await claudeHook({ hook_event_name: 'UserPromptSubmit', prompt: 'next', cwd: '/repo/sub' }) expect(recordedSession()).toMatchObject({ id: SESSION, cwd: '/repo', effort: 'max' }) diff --git a/src/main/agent-hooks/server/testing/providers/codex/server-codex-background-terminal-working.test.ts b/src/main/agent-hooks/server/testing/providers/codex/server-codex-background-terminal-working.test.ts new file mode 100644 index 00000000000..378a9a6739e --- /dev/null +++ b/src/main/agent-hooks/server/testing/providers/codex/server-codex-background-terminal-working.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AgentHookServer } from '../../../agent-hook-server' +import { HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS } from '../../../program-status/hook-precedence' +import { buildBody, ingestProgramStatusOsc, postHookEvent } from '../../server.test-fixtures' + +const TURN = { session_id: 'session-1', turn_id: 'turn-1' } + +let server: AgentHookServer + +beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) +}) + +afterEach(() => { + vi.useRealTimers() + server.stop() +}) + +async function codexHook(hookEventName: string, payload: Record = {}) { + const response = await postHookEvent( + server, + buildBody({ ...TURN, hook_event_name: hookEventName, ...payload }), + '/hook/codex' + ) + expect(response.status).toBe(204) +} + +it('keeps a finished Codex turn done while its background terminal keeps OSC 7501 working', async () => { + await codexHook('UserPromptSubmit', { prompt: 'start sleep 900 in the background' }) + // Codex 0.167 sends this at the turn start and nothing after it while `sleep 900` runs. + const working = ingestProgramStatusOsc(server, '\x1b]7501;state=working:app=codex\x1b\\') + await codexHook('Stop', { last_assistant_message: 'ok' }) + + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + server.ingestTerminalStatus({ ...working, renewsProgramStatusLease: true }) + vi.advanceTimersByTime(HOOK_SILENCE_BEFORE_PROGRAM_STATUS_FALLBACK_MS + 1) + + expect(server.getStatusSnapshot()[0]?.state).toBe('done') +}) diff --git a/src/main/agent-hooks/server/testing/server.test-fixtures.ts b/src/main/agent-hooks/server/testing/server.test-fixtures.ts index 9b380067b2f..4cd27a5c1d7 100644 --- a/src/main/agent-hooks/server/testing/server.test-fixtures.ts +++ b/src/main/agent-hooks/server/testing/server.test-fixtures.ts @@ -1,4 +1,5 @@ import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' +import { createProgramStatusProjector } from '../../../../shared/program-status/agent-status-projector' import type { AgentHookServer } from '../agent-hook-server' export const LEAF_1 = '11111111-1111-4111-8111-111111111111' @@ -44,6 +45,22 @@ export function buildBody(payload: Record, overrides: Partial Date: Tue, 10 Nov 2026 19:41:13 +0000 Subject: [PATCH 012/106] fix(editor): keep Mermaid diagram labels readable in dark themes with pastel chart colours (#40843) * fix(editor): keep Mermaid labels readable on pastel dark theme palettes In dark mode the Mermaid config fills nodes, actors and boxes with the theme's --chart-3/4/5 tokens while every label uses --foreground. Themes with pastel chart tokens (e.g. Catppuccin Mocha) put light text on light fills, so sequence actors, class boxes and similar shapes became unreadable (contrast ~1.06:1). The flowchart contrast pass only covers flowcharts. Tint each palette fill toward the background just until the foreground reaches WCAG 4.5:1. Palettes that already read (Orca's default dark blues) are unchanged. Co-Authored-By: Claude Opus 5.5 * fix(editor): tint only Mermaid fills under foreground labels Darkening the palette seeds also darkened the git graph branch boxes and venn colours, which Mermaid pairs with its own label colours, and the active Gantt border. Keep the seeds and tint mainBkg, clusterBkg, requirementBackground and the Gantt task fills instead. * fix(editor): pin Mermaid fills only when the theme colour needs tinting An always-set mainBkg overrode a diagram's own primaryColor in every dark theme. Pin the tinted fills only when the chart colour fails, and cover git tag pills too. * refactor(editor): reuse readableOn for Mermaid fill contrast Drop the hand-rolled hex mix loop and the extra luminance exports; move hexToRgb next to rgbaToHex so terminal appearance and Mermaid share it. * fix(editor): draw clickable Mermaid Gantt labels in the foreground colour Mermaid's default dark-blue clickable label reads only on light bars; on tinted or dark bars, and beside a bar on a dark page, it disappears. * fix(editor): tint Mermaid railroad terminal and comment fills too --------- Co-authored-by: Pro100v <2681328+Pro100v@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../components/editor/mermaid-config.test.ts | 51 ++++++++++--- .../src/components/editor/mermaid-config.ts | 75 ++++++++++++------- .../appearance/terminal-appearance.ts | 16 +--- src/shared/color/rgba-contrast.ts | 14 ++++ src/shared/mermaid-flowchart-contrast.ts | 2 +- 5 files changed, 104 insertions(+), 54 deletions(-) diff --git a/src/renderer/src/components/editor/mermaid-config.test.ts b/src/renderer/src/components/editor/mermaid-config.test.ts index 3da9ab84853..390435c48bb 100644 --- a/src/renderer/src/components/editor/mermaid-config.test.ts +++ b/src/renderer/src/components/editor/mermaid-config.test.ts @@ -13,6 +13,16 @@ const darkColors = { border: '#8ec5ff' } +// Catppuccin Mocha: pastel chart tokens on a dark background. +const pastelDarkColors = { + background: '#1e1e2e', + foreground: '#cdd6f4', + primary: '#f5c2e7', + secondary: '#fab387', + tertiary: '#a6e3a1', + border: '#cba6f7' +} + function luminance(color: string | boolean | undefined): number { if (typeof color !== 'string') { throw new Error('Expected a resolved Mermaid color') @@ -28,10 +38,10 @@ function luminance(color: string | boolean | undefined): number { return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722 } -function resolvedDarkTheme(): NonNullable< - ReturnType['themeVariables'] -> { - mermaid.initialize(getMermaidConfig(true, darkColors)) +function resolvedDarkTheme( + colors = darkColors +): NonNullable['themeVariables']> { + mermaid.initialize(getMermaidConfig(true, colors)) const variables = mermaid.mermaidAPI.getConfig().themeVariables if (!variables) { throw new Error('Mermaid did not resolve the theme') @@ -39,18 +49,35 @@ function resolvedDarkTheme(): NonNullable< return variables } +function expectReadable( + variables: ReturnType, + pairs: [text: string, background: string][] +): void { + for (const [text, background] of pairs) { + const first = luminance(variables[text]) + const second = luminance(variables[background]) + const contrast = (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05) + expect(contrast, `${text} on ${background}`).toBeGreaterThanOrEqual(4.5) + } +} + describe('getMermaidConfig', () => { it('keeps Gantt labels readable on completed and active tasks and alternate sections', () => { - const variables = resolvedDarkTheme() - for (const [text, background] of [ + expectReadable(resolvedDarkTheme(), [ ['taskTextDarkColor', 'doneTaskBkgColor'], ['taskTextDarkColor', 'activeTaskBkgColor'], ['taskTextOutsideColor', 'altSectionBkgColor'] - ]) { - const first = luminance(variables[text]) - const second = luminance(variables[background]) - const contrast = (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05) - expect(contrast, `${text} on ${background}`).toBeGreaterThanOrEqual(4.5) - } + ]) + }) + + it('keeps labels readable on fills from a pastel dark palette', () => { + expectReadable(resolvedDarkTheme(pastelDarkColors), [ + ['actorTextColor', 'actorBkg'], + ['nodeTextColor', 'mainBkg'], + ['textColor', 'clusterBkg'], + ['taskTextColor', 'taskBkgColor'], + ['taskTextClickableColor', 'taskBkgColor'], + ['taskTextDarkColor', 'activeTaskBkgColor'] + ]) }) }) diff --git a/src/renderer/src/components/editor/mermaid-config.ts b/src/renderer/src/components/editor/mermaid-config.ts index c727ad087f2..bae98aa3d25 100644 --- a/src/renderer/src/components/editor/mermaid-config.ts +++ b/src/renderer/src/components/editor/mermaid-config.ts @@ -1,4 +1,6 @@ import type mermaid from 'mermaid' +import { hexToRgb, readableOn, rgbaToHex } from '../../../../shared/color/rgba-contrast' +import { READABLE_CONTRAST } from '../../../../shared/mermaid-flowchart-contrast' export type MermaidThemeColors = { background: string @@ -20,33 +22,54 @@ export function getMermaidConfig( // The legacy dark theme removes color from flowcharts. theme: isDark ? (colors ? 'base' : 'dark') : 'default', darkMode: isDark, - ...(isDark && colors - ? { - themeVariables: { - darkMode: true, - background: colors.background, - primaryColor: colors.primary, - secondaryColor: colors.secondary, - tertiaryColor: colors.tertiary, - primaryTextColor: colors.foreground, - secondaryTextColor: colors.foreground, - tertiaryTextColor: colors.foreground, - primaryBorderColor: colors.border, - secondaryBorderColor: colors.border, - tertiaryBorderColor: colors.border, - lineColor: colors.foreground, - textColor: colors.foreground, - edgeLabelBackground: colors.background, - // Base defaults to light Gantt surfaces even when darkMode is enabled. - altSectionBkgColor: colors.background, - excludeBkgColor: colors.background, - doneTaskBkgColor: colors.background, - doneTaskBorderColor: colors.border, - activeTaskBkgColor: colors.secondary - } - } - : {}), + ...(isDark && colors ? { themeVariables: darkThemeVariables(colors) } : {}), // Strict mode sanitizes label HTML; sanitizeMermaidSvg keeps the foreignObject labels it emits. htmlLabels: true } } + +function darkThemeVariables(colors: MermaidThemeColors): Record { + // Mermaid draws dark-mode labels in the foreground colour, so darken a pastel chart token until it reads. + const surface = (fill: string): string => + rgbaToHex(readableOn(hexToRgb(fill), [hexToRgb(colors.foreground)], true, READABLE_CONTRAST)) + // Seeds stay the theme's because git and venn pair them with their own label colours. Fills are + // pinned only when tinted, so otherwise a diagram's own primaryColor/tertiaryColor still drives them. + const tinted = (fill: string, keys: string[]): Record => { + const readable = surface(fill) + return readable === fill.toLowerCase() + ? {} + : Object.fromEntries(keys.map((key) => [key, readable])) + } + return { + darkMode: true, + background: colors.background, + primaryColor: colors.primary, + secondaryColor: colors.secondary, + tertiaryColor: colors.tertiary, + ...tinted(colors.primary, [ + 'mainBkg', + 'requirementBackground', + 'taskBkgColor', + 'tagLabelBackground' + ]), + ...tinted(colors.secondary, ['secondBkg']), + ...tinted(colors.tertiary, ['clusterBkg', 'labelBackground']), + primaryTextColor: colors.foreground, + secondaryTextColor: colors.foreground, + tertiaryTextColor: colors.foreground, + primaryBorderColor: colors.border, + secondaryBorderColor: colors.border, + tertiaryBorderColor: colors.border, + lineColor: colors.foreground, + textColor: colors.foreground, + edgeLabelBackground: colors.background, + // Base defaults to light Gantt surfaces even when darkMode is enabled. + altSectionBkgColor: colors.background, + excludeBkgColor: colors.background, + doneTaskBkgColor: colors.background, + doneTaskBorderColor: colors.border, + // Base's dark-blue clickable label is drawn for light bars, inside and beside the bar. + taskTextClickableColor: colors.foreground, + activeTaskBkgColor: surface(colors.secondary) + } +} diff --git a/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts index 99cac7af0b4..5adda4f2f9d 100644 --- a/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts @@ -32,23 +32,9 @@ import { maybePushMode2031Flip } from '../emulation/terminal-mode-2031-replies' import { resolveTerminalMinimumContrastRatio } from '../../../../../shared/terminal-themes/terminal-contrast-correction' import { resolveTerminalInlineImagesEnabled } from '../../../../../shared/terminal/images/terminal-inline-images-settings' import { windowBackdropActive } from '@/app-shell/appearance/window-backdrop' -import { contrastRatio, over, type Rgba } from '../../../../../shared/color/rgba-contrast' +import { contrastRatio, hexToRgb, over, type Rgba } from '../../../../../shared/color/rgba-contrast' import { getTerminalFontSizeOverride } from './terminal-font-size-overrides' -function hexToRgb(hex: string): Rgba { - let clean = hex.replace('#', '') - if (clean.length === 3) { - clean = clean - .split('') - .map((c) => c + c) - .join('') - } - const r = Number.parseInt(clean.slice(0, 2), 16) - const g = Number.parseInt(clean.slice(2, 4), 16) - const b = Number.parseInt(clean.slice(4, 6), 16) - return [r, g, b, 1] -} - export function hexToRgba(hex: string, alpha: number): string { const [r, g, b] = hexToRgb(hex) return `rgba(${r}, ${g}, ${b}, ${alpha})` diff --git a/src/shared/color/rgba-contrast.ts b/src/shared/color/rgba-contrast.ts index c132f0d1a50..3febbc8c67f 100644 --- a/src/shared/color/rgba-contrast.ts +++ b/src/shared/color/rgba-contrast.ts @@ -48,6 +48,20 @@ export function readableOn( return extreme } +export function hexToRgb(hex: string): Rgba { + let clean = hex.replace('#', '') + if (clean.length === 3) { + clean = clean + .split('') + .map((c) => c + c) + .join('') + } + const r = Number.parseInt(clean.slice(0, 2), 16) + const g = Number.parseInt(clean.slice(2, 4), 16) + const b = Number.parseInt(clean.slice(4, 6), 16) + return [r, g, b, 1] +} + export function rgbaToHex([r, g, b]: Rgba): string { return `#${[r, g, b] .map((channel) => diff --git a/src/shared/mermaid-flowchart-contrast.ts b/src/shared/mermaid-flowchart-contrast.ts index 3b2662d2c5a..7c5aaae66c9 100644 --- a/src/shared/mermaid-flowchart-contrast.ts +++ b/src/shared/mermaid-flowchart-contrast.ts @@ -1,4 +1,4 @@ -const READABLE_CONTRAST = 4.5 +export const READABLE_CONTRAST = 4.5 /** WCAG relative luminance of a `#rrggbb` or opaque `rgb()` colour; null for anything else. */ function luminance(color: string): number | null { From 3a8daa2418d0ab929e509a1d154364fd6b9c432f Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:50:17 +0000 Subject: [PATCH 013/106] fix(terminal): let a paste made while the terminal starts wait for it like typed keys (#40840) Co-authored-by: Wooseong Kim <2222333+innocarpe@users.noreply.github.com> --- .../terminal-pane-paste-dispatch.test.ts | 73 +++++++++++++++++++ .../paste/terminal-pane-paste-dispatch.ts | 12 ++- .../paste/terminal-paste-target-state.test.ts | 51 +++++++++++++ .../paste/terminal-paste-target-state.ts | 19 +++-- .../pty-transport/ipc/pty-transport.ts | 2 + .../legacy-relay-bridge-transport.ts | 2 + .../pty-transport/pty-transport-types.ts | 2 + .../remote-runtime-pty-transport.ts | 2 + 8 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.test.ts create mode 100644 src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.test.ts diff --git a/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.test.ts b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.test.ts new file mode 100644 index 00000000000..192a23bc4c0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createDeferred, + flushAsyncTicks +} from '../../pty-connection/test-fixtures/pty-connection-test-async' +import { + installIpcPtyWindow, + restorePtySpecWindow +} from '../../pty-transport/ipc/pty-transport-test-harness' +import { pasteTextIntoTerminalPane } from './terminal-pane-paste-dispatch' + +describe('pasteTextIntoTerminalPane into a starting terminal', () => { + const originalWindow = globalThis.window + + beforeEach(() => { + vi.resetModules() + installIpcPtyWindow(originalWindow, {}) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + async function pasteBeforeConnect(text: string): Promise<{ status: string; written: string }> { + const spawn = createDeferred<{ id: string }>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise) + const written: string[] = [] + vi.mocked(window.api.pty.write).mockImplementation((_id, data) => { + written.push(data) + }) + vi.mocked(window.api.pty.writeAccepted).mockImplementation(async (_id, data) => { + written.push(data) + return true + }) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) + const connecting = transport.connect({ url: '', callbacks: {} }) + const terminal = { + modes: { bracketedPasteMode: false }, + options: {}, + paste: (data: string) => { + transport.sendInput(data, 'driving') + }, + input: (data: string) => { + transport.sendInput(data, 'driving') + } + } + + const result = await pasteTextIntoTerminalPane({ + pane: { id: 1, leafId: 'leaf-1', terminal }, + text, + source: 'keyboard', + ptyId: null, + runtime: { kind: 'local', platform: 'darwin', runtimeKey: 'local:darwin' }, + transport, + inputKind: 'driving', + isTargetCurrent: () => true, + canContinue: () => true + }) + transport.sendInput('\r', 'driving') + spawn.resolve({ id: 'pty-1' }) + await connecting + await flushAsyncTicks() + return { status: result.status, written: written.join('') } + } + + it('refuses a paste too large for one held entry before writing any of it', async () => { + await expect(pasteBeforeConnect('x'.repeat(70_000))).resolves.toEqual({ + status: 'rejected', + written: '\r' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.ts b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.ts index 3b2115f2ff5..bca71e75a57 100644 --- a/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/input/paste/terminal-pane-paste-dispatch.ts @@ -1,3 +1,4 @@ +import type { PtyTransport } from '../../pty-transport/pty-transport-types' import { pasteTerminalText } from './terminal-bracketed-paste' import { executeTerminalPastePlan } from './terminal-paste-executor' import { planTerminalPasteWithYield } from './terminal-paste-coordinator' @@ -15,7 +16,7 @@ type PasteTextIntoTerminalPaneArgs = { source: TerminalPasteSource ptyId: string | null runtime: TerminalPasteRuntime - transport: Parameters[0] + transport: Pick | undefined inputKind: 'driving' | 'launch' isTargetCurrent: () => boolean canContinue: () => boolean @@ -46,11 +47,16 @@ export async function pasteTextIntoTerminalPane({ windowsInputRecordNewline: planOptions?.windowsInputRecordNewline, terminalBracketedPasteMode: pane.terminal.modes?.bracketedPasteMode === true }) + // Why: a held terminal takes a paste only as one entry, like a typed key; chunks need a bound, live PTY. + const isHolding = (): boolean => transport?.isHoldingInput?.() === true return executeTerminalPastePlan(plan, { pasteText: (pasteText, pasteOptions) => pasteTerminalText(pane.terminal, pasteText, pasteOptions), - writePty: (data, signal) => writeTerminalPastePtyInput(transport, data, inputKind, signal), + writePty: + ptyId !== null && !isHolding() + ? (data, signal) => writeTerminalPastePtyInput(transport, data, inputKind, signal) + : undefined, isTargetCurrent, - canContinue + canContinue: () => canContinue() && !isHolding() }) } diff --git a/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.test.ts b/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.test.ts new file mode 100644 index 00000000000..fa36f764e0a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDeferred } from '../../pty-connection/test-fixtures/pty-connection-test-async' +import { + installIpcPtyWindow, + restorePtySpecWindow +} from '../../pty-transport/ipc/pty-transport-test-harness' +import type { PtyTransport } from '../../pty-transport/pty-transport-types' +import { isTerminalPanePasteTargetCurrent } from './terminal-paste-target-state' + +describe('isTerminalPanePasteTargetCurrent', () => { + const originalWindow = globalThis.window + const manager = { getPanes: () => [{ id: 1, leafId: 'leaf-1' }] } + + beforeEach(() => { + vi.resetModules() + installIpcPtyWindow(originalWindow, {}) + }) + + afterEach(() => { + restorePtySpecWindow(originalWindow) + }) + + const isCurrent = (transport: PtyTransport, ptyId: string | null): boolean => + isTerminalPanePasteTargetCurrent({ + manager, + paneTransports: new Map([[1, transport]]), + paneId: 1, + leafId: 'leaf-1', + transport, + ptyId + }) + + it('accepts a paste into a starting terminal that holds typed input until it connects', async () => { + const spawn = createDeferred<{ id: string }>() + vi.mocked(window.api.pty.spawn).mockReturnValue(spawn.promise) + const { createIpcPtyTransport } = await import('../../pty-transport/ipc/pty-transport') + const transport = createIpcPtyTransport({ bufferInputUntilConnect: true }) + + const connecting = transport.connect({ url: '', callbacks: {} }) + expect(isCurrent(transport, null)).toBe(true) + + spawn.resolve({ id: 'pty-1' }) + await connecting + expect(isCurrent(transport, null)).toBe(true) + expect(isCurrent(transport, 'pty-1')).toBe(true) + expect(isCurrent(transport, 'pty-0')).toBe(false) + + transport.disconnect() + expect(isCurrent(transport, null)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.ts b/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.ts index e1f7ff426f2..de8fffee77a 100644 --- a/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.ts +++ b/src/renderer/src/components/terminal-pane/input/paste/terminal-paste-target-state.ts @@ -12,6 +12,7 @@ type TerminalPastePaneManager = { type TerminalPasteTransport = { getPtyId: () => string | null isConnected: () => boolean + isHoldingInput?: () => boolean } export type TerminalPasteTargetState = { @@ -31,12 +32,18 @@ export function isTerminalPanePasteTargetCurrent({ transport, ptyId }: TerminalPasteTargetState): boolean { - return Boolean( - manager?.getPanes().some((pane) => pane.id === paneId && pane.leafId === leafId) && - transport && - paneTransports.get(paneId) === transport && - transport.isConnected() && - transport.getPtyId() === ptyId + if ( + !manager?.getPanes().some((pane) => pane.id === paneId && pane.leafId === leafId) || + !transport || + paneTransports.get(paneId) !== transport + ) { + return false + } + // A paste made before the first bind (ptyId null) may follow this transport onto the PTY it binds. + const livePtyId = transport.getPtyId() + return ( + (livePtyId === ptyId || ptyId === null) && + (transport.isConnected() || transport.isHoldingInput?.() === true) ) } diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts index e9912c7aad3..0da077fb4a4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport.ts @@ -181,6 +181,8 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, + isHoldingInput: () => !destroyed && preconnectInputBuffer?.isBuffering() === true, + abandonPreconnectInput() { dropPreconnectInput('its pending connection was abandoned') }, diff --git a/src/renderer/src/components/terminal-pane/pty-transport/legacy-relay/legacy-relay-bridge-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport/legacy-relay/legacy-relay-bridge-transport.ts index 0848e801f56..3e5ee0d728b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/legacy-relay/legacy-relay-bridge-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/legacy-relay/legacy-relay-bridge-transport.ts @@ -292,6 +292,8 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp isConnected: () => (bridged() ? viewer !== null : inner.isConnected()), getPtyId: () => (bridged() ? ptyId : inner.getPtyId()), isConnectPending: () => (bridged() ? false : (inner.isConnectPending?.() ?? false)), + // Why not pending: selecting the bridge never drains the inner hold. + isHoldingInput: () => mode === 'inner' && (inner.isHoldingInput?.() ?? false), getRuntimeEnvironmentId: () => (bridged() ? null : (inner.getRuntimeEnvironmentId?.() ?? null)), getExecutionHostId: () => (bridged() ? null : (inner.getExecutionHostId?.() ?? null)), onExecutionHostChange: (listener) => inner.onExecutionHostChange?.(listener) ?? (() => {}), diff --git a/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts index 324787a93ad..ad8594d0ee7 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/pty-transport-types.ts @@ -252,6 +252,8 @@ export type PtyTransport = { ownsRecovery?: () => boolean /** Typed input the host has not applied yet, which a remount would discard. */ hasUndeliveredInput?: () => boolean + /** Input sent now is held and delivered to this pane's terminal once it connects. */ + isHoldingInput?: () => boolean /** The user dismissed the error surface; the next occurrence of the same message must surface again. */ notifyErrorSurfaceDismissed?: () => void getPtyId: () => string | null diff --git a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts index faefc6be092..43f4e9f1fc4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport/remote-runtime/remote-runtime-pty-transport.ts @@ -4151,6 +4151,8 @@ export function createRemoteRuntimePtyTransport( return !destroyed && !terminalEnded && recoveryBlocksIo() }, + isHoldingInput: shouldHoldInput, + hasUndeliveredInput() { return ( inputJournal.hasUnacknowledgedInput() || From 6843d8fd08a5b3dd5798e2a8d2be4a5cf60abaa9 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:54:49 +0000 Subject: [PATCH 014/106] fix(browser): stop a stalled tab capture keeping the Mac's display awake for hours (#40847) * fix(browser): hold a page drawable until its capture settles A stalled capture stays pending until the page draws and keeps macOS display sleep off ("Capturing"); dropping the paint hold at our timeout left it there. Fixes #24946 * fix(browser): keep the debugger attached while a CDP capture is pending * refactor(browser): share one settle-scoped hold helper for paint and debugger leases --- .../browser/cdp/commands/cdp-screenshot.ts | 39 +++++++++------- .../browser/cdp/debugger/guest-paint-hold.ts | 39 ++++++++++++++++ ...ser-screencast-first-frame-timeout.test.ts | 44 +++++++++++++++++++ ...rowser-screencast-guest-view-limit.test.ts | 1 + .../browser-screencast-lifecycle.test.ts | 2 + ...r-screencast-live-frame-allocation.test.ts | 1 + .../browser-screencast-snapshot-capture.ts | 20 +++++++-- .../browser-screencast-stream-types.ts | 2 + .../browser-screencast-stream.test.ts | 3 ++ .../browser/screencast/browser-screencasts.ts | 2 + ...browser-screencast-frame-admission.test.ts | 1 + ...r-publication-throttle-electron-fixture.ts | 1 + 12 files changed, 136 insertions(+), 19 deletions(-) diff --git a/src/main/browser/cdp/commands/cdp-screenshot.ts b/src/main/browser/cdp/commands/cdp-screenshot.ts index f43f5c1e194..76cc4726365 100644 --- a/src/main/browser/cdp/commands/cdp-screenshot.ts +++ b/src/main/browser/cdp/commands/cdp-screenshot.ts @@ -1,4 +1,8 @@ -import type { CapturePaintHold } from '../debugger/guest-paint-hold' +import { + holdPaintUntilCdpCaptureSettles, + holdPaintUntilSettled, + type CapturePaintHold +} from '../debugger/guest-paint-hold' import type { WebContents } from 'electron' import { sendGuestCdpCommand } from '../debugger/guest-cdp-command' import { @@ -137,7 +141,8 @@ export function withTimeout( // Undrawn pages can leave captures pending; cheap probes trigger a frame for the original request. function captureUntilDrawn( webContents: WebContents, - params: Record + params: Record, + holdPaint: CapturePaintHold ): Promise<{ data: string } | null> { return new Promise((resolve, reject) => { let settled = false @@ -173,12 +178,12 @@ function captureUntilDrawn( } catch { // Guest teardown can reject repaint requests; still try CDP. } - return sendGuestCdpCommand(webContents, 'Page.captureScreenshot', requestParams).then( - (result) => { - const data = isRecord(result) ? result.data : undefined - return typeof data === 'string' ? { data } : undefined - } - ) + return holdPaintUntilCdpCaptureSettles(webContents, holdPaint, () => + sendGuestCdpCommand(webContents, 'Page.captureScreenshot', requestParams) + ).then((result) => { + const data = isRecord(result) ? result.data : undefined + return typeof data === 'string' ? { data } : undefined + }) } const deadline = setTimeout(() => finish(() => resolve(null)), SCREENSHOT_TIMEOUT_MS) let probes: NodeJS.Timeout[] = [] @@ -223,11 +228,15 @@ export async function captureFullPageScreenshot( if (!clip) { throw new Error('Unable to determine full-page screenshot bounds') } - const frame = await captureUntilDrawn(webContents, { - format, - captureBeyondViewport: true, - clip - }) + const frame = await captureUntilDrawn( + webContents, + { + format, + captureBeyondViewport: true, + clip + }, + holdPaint + ) if (!frame) { throw new Error(SCREENSHOT_TIMEOUT_MESSAGE) } @@ -269,13 +278,13 @@ export async function captureScreenshot( const release = holdPaint() try { - const frame = await captureUntilDrawn(webContents, screenshotParams) + const frame = await captureUntilDrawn(webContents, screenshotParams, holdPaint) if (frame) { return frame } const fallback = await readRemoteCliAdmittedPage(webContents.id, () => withTimeout( - Promise.resolve().then(() => webContents.capturePage()), + holdPaintUntilSettled(holdPaint, () => webContents.capturePage()), FALLBACK_CAPTURE_TIMEOUT_MS, SCREENSHOT_TIMEOUT_MESSAGE ) diff --git a/src/main/browser/cdp/debugger/guest-paint-hold.ts b/src/main/browser/cdp/debugger/guest-paint-hold.ts index 885a3e98a84..c235cf72541 100644 --- a/src/main/browser/cdp/debugger/guest-paint-hold.ts +++ b/src/main/browser/cdp/debugger/guest-paint-hold.ts @@ -1,2 +1,41 @@ +import type { WebContents } from 'electron' +import { acquireElectronDebugger } from './electron-debugger-lease' + /** Draws a hidden guest while a capture or agent input needs its frames; returns its release. */ export type CapturePaintHold = () => () => void + +function retainUntilSettled(acquire: () => () => void, run: () => Promise): Promise { + let release: () => void + try { + release = acquire() + } catch (error) { + return Promise.reject(error) + } + const pending = new Promise((resolve) => resolve(run())) + void pending.then(release, release) + return pending +} + +/** + * Holds paint until Chromium answers the capture, not just while the caller waits. A capture stays + * pending until the guest draws, and while pending it can hold macOS display sleep off ("Capturing"); + * timeouts do not cancel it (#24946). + */ +export function holdPaintUntilSettled( + holdPaint: CapturePaintHold, + capture: () => Promise +): Promise { + return retainUntilSettled(holdPaint, capture) +} + +/** Also keeps the debugger attached: a detach rejects the command while Chromium keeps the capture. */ +export function holdPaintUntilCdpCaptureSettles( + webContents: WebContents, + holdPaint: CapturePaintHold, + capture: () => Promise +): Promise { + return retainUntilSettled( + () => acquireElectronDebugger(webContents).release, + () => retainUntilSettled(holdPaint, capture) + ) +} diff --git a/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts b/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts index 797f2ff07a5..8129fb06766 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-first-frame-timeout.test.ts @@ -24,6 +24,7 @@ function startOptions(viewport: boolean) { maxHeight: 1200, everyNthFrame: 2, minFrameIntervalMs: 0, + holdPaint: () => () => {}, ...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}), onFrame: vi.fn(() => true), onError: vi.fn() @@ -59,6 +60,49 @@ describe('browser screencast first-frame deadline', () => { await session.done }) + it('keeps the page drawable until Chromium answers the captures the stream gave up on', async () => { + let settleCapture!: (image: unknown) => void + const webContents = Object.assign(createMockScreencastWebContents(), { + capturePage: vi.fn( + () => + new Promise((resolve) => { + settleCapture = resolve + }) + ) + }) + let settleScreenshot!: (result: unknown) => void + let rejectScreenshot!: (error: Error) => void + webContents.debugger.sendCommand.mockImplementation(async (method: string) => + method === 'Page.captureScreenshot' + ? new Promise((resolve, reject) => { + settleScreenshot = resolve + rejectScreenshot = reject + }) + : {} + ) + // Electron rejects pending commands on detach, but Chromium keeps the capture. + webContents.debugger.detach.mockImplementation(() => + rejectScreenshot(new Error('target closed while handling command')) + ) + const releasePaint = vi.fn() + const options = { ...startOptions(true), holdPaint: vi.fn(() => releasePaint) } + const session = await start(webContents, options) + + // capturePage's 10 s bound, then the CDP fallback's 8 s. + await vi.advanceTimersByTimeAsync(18_000) + session.stop() + await session.done + expect(options.holdPaint).toHaveBeenCalledTimes(2) + expect(webContents.debugger.detach).not.toHaveBeenCalled() + expect(releasePaint).not.toHaveBeenCalled() + + settleCapture({ getSize: () => ({ width: 0, height: 0 }) }) + settleScreenshot({}) + await vi.advanceTimersByTimeAsync(0) + expect(releasePaint).toHaveBeenCalledTimes(2) + expect(webContents.debugger.detach).toHaveBeenCalledOnce() + }) + it('drops a capture that settles after the limit', async () => { let settleCapture!: (image: unknown) => void const webContents = Object.assign(createMockScreencastWebContents(), { diff --git a/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts b/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts index 5968bf42069..a07487bfd5b 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-guest-view-limit.test.ts @@ -70,6 +70,7 @@ async function startOversizedStream(guest: ReturnType, onFra deviceScaleFactor: 2, everyNthFrame: 1, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame }) } diff --git a/src/main/browser/cdp/screencast/browser-screencast-lifecycle.test.ts b/src/main/browser/cdp/screencast/browser-screencast-lifecycle.test.ts index 09889e723c7..4443f28f7e3 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-lifecycle.test.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-lifecycle.test.ts @@ -35,6 +35,7 @@ describe('browser screencast lifecycle', () => { maxHeight: 2160, everyNthFrame: 2, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame }) @@ -80,6 +81,7 @@ describe('browser screencast lifecycle', () => { maxHeight: 1200, everyNthFrame: 2, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame: vi.fn() }, isClosed: () => false, diff --git a/src/main/browser/cdp/screencast/browser-screencast-live-frame-allocation.test.ts b/src/main/browser/cdp/screencast/browser-screencast-live-frame-allocation.test.ts index 0d61e942a80..86185d11b0b 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-live-frame-allocation.test.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-live-frame-allocation.test.ts @@ -40,6 +40,7 @@ function createLiveFrameRoute( maxHeight: 1200, everyNthFrame: 2, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame } const isClosed = () => false diff --git a/src/main/browser/cdp/screencast/browser-screencast-snapshot-capture.ts b/src/main/browser/cdp/screencast/browser-screencast-snapshot-capture.ts index 733a4cb542a..b9b3c1f037d 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-snapshot-capture.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-snapshot-capture.ts @@ -1,7 +1,11 @@ import { Buffer } from 'node:buffer' import type { Debugger, WebContents } from 'electron' import { readBrowserScreencastImageSize } from './browser-screencast-image-size' -import { sendDebuggerCommand } from './browser-screencast-debugger-command' +import { runDebuggerCommandWithTimeout } from './browser-screencast-debugger-command' +import { + holdPaintUntilCdpCaptureSettles, + holdPaintUntilSettled +} from '../debugger/guest-paint-hold' import type { BrowserScreencastOptions, PendingScreencastFrame @@ -35,6 +39,7 @@ export function createBrowserScreencastSnapshotCapture( ): BrowserScreencastSnapshotCapture { const { webContents, dbg, options, isClosed, isStopping, getSeq, queueFrame } = deps const { applyDeviceMetricsOverride } = deps + const { holdPaint } = options let snapshotGeneration = 0 let navigationCaptureTimer: ReturnType | null = null @@ -81,7 +86,9 @@ export function createBrowserScreencastSnapshotCapture( try { // Prefer capturePage to avoid CDP tiling; stopped compositing can leave it pending. const nativeImage = await withTimeout( - webContents.capturePage({ x: 0, y: 0, width: viewportWidth, height: viewportHeight }), + holdPaintUntilSettled(holdPaint, () => + webContents.capturePage({ x: 0, y: 0, width: viewportWidth, height: viewportHeight }) + ), SNAPSHOT_CAPTURE_TIMEOUT_MS, null ) @@ -101,7 +108,7 @@ export function createBrowserScreencastSnapshotCapture( } } if (!image) { - const result = await sendDebuggerCommand(dbg, 'Page.captureScreenshot', { + const screenshotParams = { format: options.format, ...(options.format === 'jpeg' ? { quality: options.quality } : {}), ...(viewportWidth && viewportHeight @@ -117,7 +124,12 @@ export function createBrowserScreencastSnapshotCapture( } : {}), captureBeyondViewport: false - }) + } + const result = await runDebuggerCommandWithTimeout('Page.captureScreenshot', () => + holdPaintUntilCdpCaptureSettles(webContents, holdPaint, () => + dbg.sendCommand('Page.captureScreenshot', screenshotParams) + ) + ) if (isSnapshotStale(initialOnly, generation)) { return } diff --git a/src/main/browser/cdp/screencast/browser-screencast-stream-types.ts b/src/main/browser/cdp/screencast/browser-screencast-stream-types.ts index 0676e970411..666bba266b2 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-stream-types.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-stream-types.ts @@ -2,6 +2,7 @@ import type { BrowserScreencastFormat, BrowserScreencastFrameMetadata } from '../../../../shared/browser/screencast/browser-screencast-protocol' +import type { CapturePaintHold } from '../debugger/guest-paint-hold' export type BrowserScreencastOptions = { format: BrowserScreencastFormat @@ -17,6 +18,7 @@ export type BrowserScreencastOptions = { onFrame: (bytes: Uint8Array) => boolean | void onEvent?: (event: BrowserScreencastEvent) => void onError?: (message: string) => void + holdPaint: CapturePaintHold } export type BrowserScreencastViewport = Pick< diff --git a/src/main/browser/cdp/screencast/browser-screencast-stream.test.ts b/src/main/browser/cdp/screencast/browser-screencast-stream.test.ts index 6870a27f172..456ff1f213c 100644 --- a/src/main/browser/cdp/screencast/browser-screencast-stream.test.ts +++ b/src/main/browser/cdp/screencast/browser-screencast-stream.test.ts @@ -27,6 +27,7 @@ describe('startBrowserScreencast', () => { maxHeight: 1200, everyNthFrame: 1, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame }) @@ -84,6 +85,7 @@ describe('startBrowserScreencast', () => { maxHeight: 1200, everyNthFrame: 1, minFrameIntervalMs: 100, + holdPaint: () => () => {}, onFrame }) @@ -136,6 +138,7 @@ describe('startBrowserScreencast', () => { maxHeight: 1200, everyNthFrame: 2, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame: vi.fn(), onEvent }) diff --git a/src/main/runtime/browser/screencast/browser-screencasts.ts b/src/main/runtime/browser/screencast/browser-screencasts.ts index 192aa7412ed..d8a97faa304 100644 --- a/src/main/runtime/browser/screencast/browser-screencasts.ts +++ b/src/main/runtime/browser/screencast/browser-screencasts.ts @@ -2,6 +2,7 @@ import type { BrowserScreencastResult } from '../../../../shared/browser/runtime import { BrowserError } from '../../../browser/browser-error' import { randomUUID } from 'node:crypto' import { startBrowserScreencast } from '../../../browser/cdp/screencast/browser-screencast-stream' +import { browserManager } from '../../../browser/guest-lifecycle/manager/browser-manager' import { rendererPublicationThrottle } from '../../../window/renderer-publication-throttle' import { mintBrowserScreencastSubscriptionId } from './browser-screencast-subscription-id' import { @@ -102,6 +103,7 @@ export class RuntimeBrowserScreencasts { format: params.format, ...budget, ...viewport, + holdPaint: () => browserManager.holdPaintForCapture(guest.id), onFrame: (bytes) => { const ghosts: string[] = [] for (const [subscriptionId, subscriber] of pendingRecord.subscribers) { diff --git a/src/main/runtime/browser/screencast/remote-browser-screencast-frame-admission.test.ts b/src/main/runtime/browser/screencast/remote-browser-screencast-frame-admission.test.ts index 6e5d0b3db0c..c87652a7309 100644 --- a/src/main/runtime/browser/screencast/remote-browser-screencast-frame-admission.test.ts +++ b/src/main/runtime/browser/screencast/remote-browser-screencast-frame-admission.test.ts @@ -92,6 +92,7 @@ describe('sendRemoteBrowserScreencastFrame', () => { maxHeight: 2160, everyNthFrame: 1, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame: (bytes) => sendRemoteBrowserScreencastFrame(transport.sendBinary, bytes) !== 'refused' }) diff --git a/src/main/window/renderer-publication-throttle-electron-fixture.ts b/src/main/window/renderer-publication-throttle-electron-fixture.ts index 9adaac8227b..3a417f1dc9f 100644 --- a/src/main/window/renderer-publication-throttle-electron-fixture.ts +++ b/src/main/window/renderer-publication-throttle-electron-fixture.ts @@ -47,6 +47,7 @@ async function run(): Promise { maxHeight: 480, everyNthFrame: 1, minFrameIntervalMs: 0, + holdPaint: () => () => {}, onFrame: () => { frames += 1 firstFrame() From 88880fca7a49a39600008bfbea9bde5e45b84992 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:56:02 -0500 Subject: [PATCH 015/106] feat(orchestration): a paired Orca as a Run's coordinator on the server it placed work on (inert) (#40717) * feat(orchestration): a paired Orca as a Run's coordinator on the server it placed work on (inert) The server half of remote-owned workers. A paired Orca's call names one of its Runs; the server admits it as a peer caller kind, writes that Run at frame receipt, and holds it to the one access check. Its workers' mail and state queue home under a sequence no reset reuses. Nothing sends the envelope until the coordinator side ships, so this changes nothing today. * fix(orchestration): reserve the peer address, keep conversation kinds apart, queue a correction once with its result * fix(orchestration): hold back a corrected Task's change whatever its latest attempt; rotate pulls in a stable order per database * chore(orchestration): typed field read, cast rationales, a wake test on fake timers * fix(orchestration): a peer's receipts per conversation, its sends only into its Run, a page that fits every conversation, pending adopted Tasks keep their gate * fix(orchestration): a peer's follow-up places where its worker ran * fix(orchestration): a peer's current/new-child start gets the --on placement refusal --- .../orchestration/worker-agent-views.ts | 3 + .../orchestration/worker-terminal-handlers.ts | 5 +- .../orchestration-caller-identity.ts | 46 ++ .../db/attach-orchestration-db-methods.ts | 2 + .../worker-report-settlement.ts | 5 + .../db/federation/peer-mail-store.ts | 10 +- .../orchestration/db/lifecycle-transition.ts | 4 +- .../db/messages/message-inbox.ts | 32 +- .../db/messages/message-insert.ts | 15 + .../db/orchestration-db-methods.ts | 2 + .../orchestration/db/orchestration-db.ts | 2 + .../db/peer-runs/peer-run-queue-sql.ts | 175 +++++++ .../db/peer-runs/peer-run-queue.test.ts | 265 +++++++++++ .../db/peer-runs/peer-run-store.ts | 146 ++++++ .../db/questions/question-threads.ts | 7 +- .../db/reset/orchestration-reset.ts | 32 +- .../orchestration/db/runs/run-binding.ts | 9 + .../orchestration/db/runs/run-create.ts | 78 ++-- .../orchestration/db/schema/create-tables.ts | 5 +- .../orchestration/db/tasks/task-store.ts | 4 +- .../worker-dispatch/worker-dispatch-start.ts | 71 ++- .../worker-terminal-listing.ts | 5 + .../federation/peer-mail/peer-mail-waiters.ts | 13 +- .../federated-worker-child-parent.ts | 4 +- .../federated-worker-start-request.ts | 4 +- .../launch/worker-dispatch-preamble.ts | 10 +- .../workers/launch/worker-start-card.ts | 9 +- src/main/runtime/rpc/core.ts | 8 +- .../runtime/rpc/dispatcher-stream-options.ts | 4 +- src/main/runtime/rpc/dispatcher.ts | 12 +- .../orchestration-access-outcomes.test.ts | 2 + .../access/orchestration-method-access.ts | 59 ++- .../orchestration/access/peer-run-access.ts | 208 +++++++++ .../rpc/methods/orchestration/caller-show.ts | 10 +- .../federation/federation-methods.ts | 4 +- .../federation/peer-coordinator.test.ts | 437 ++++++++++++++++++ .../federation/peer-mail-methods.ts | 34 +- .../federation/peer-mail.test.ts | 2 +- .../federation/peer-run-methods.ts | 54 +++ .../orchestration/messaging/check-methods.ts | 1 + .../messaging/check-run-pending-mail.ts | 4 +- .../orchestration/messaging/check-run.ts | 4 +- .../messaging/helper-question-answerer.ts | 4 +- .../messaging/message-methods.ts | 8 +- .../messaging/peer-mail-reply.ts | 6 +- .../orchestration/messaging/peer-mail-send.ts | 6 +- .../orchestration/messaging/reply-method.ts | 1 + .../orchestration/messaging/send-methods.ts | 13 + .../messaging/send-point-to-point.ts | 3 + .../settled-worker-follow-through.ts | 19 +- .../orchestration-caller-workspace.ts | 10 +- .../orchestration/runs/dispatch-creator.ts | 4 +- .../methods/orchestration/runs/run-scope.ts | 8 +- .../deliver-worker-dispatch-preamble.ts | 1 + .../explicit-worker-terminal-validation.ts | 4 +- .../worker/local-worker-start-dispatch.ts | 1 + .../worker/local-worker-start.ts | 4 +- .../worker/worker-list-method.ts | 1 + .../worker/worker-start-authority.ts | 6 +- .../methods/orchestration/worker/workers.ts | 24 +- .../orchestration-mutation-executor.test.ts | 25 + .../rpc/orchestration-mutation-executor.ts | 25 +- .../rpc/orchestration-mutation-receipt.ts | 15 +- .../runtime/rpc/orchestration-peer-caller.ts | 72 +++ .../rpc/orchestration-session-caller.ts | 7 +- .../rpc/rpc-request-admission-prologue.ts | 11 + .../orchestration-rpc-contract.ts | 3 +- src/shared/orchestration/remote-owned-wire.ts | 79 ++++ .../remote-runtime-memory-limits.ts | 1 + .../rpc-contract/orchestration-params.ts | 4 +- .../orchestration-worker-start-params.ts | 4 +- .../rpc-params-catalog.generated.ts | 2 + src/shared/runtime-rpc-envelope.ts | 3 + 73 files changed, 1997 insertions(+), 183 deletions(-) create mode 100644 src/main/runtime/orchestration/db/peer-runs/peer-run-queue-sql.ts create mode 100644 src/main/runtime/orchestration/db/peer-runs/peer-run-queue.test.ts create mode 100644 src/main/runtime/orchestration/db/peer-runs/peer-run-store.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/access/peer-run-access.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/peer-coordinator.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/peer-run-methods.ts create mode 100644 src/main/runtime/rpc/orchestration-peer-caller.ts create mode 100644 src/shared/orchestration/remote-owned-wire.ts diff --git a/src/cli/handlers/orchestration/worker-agent-views.ts b/src/cli/handlers/orchestration/worker-agent-views.ts index ac76e9c214a..ca993bc66c1 100644 --- a/src/cli/handlers/orchestration/worker-agent-views.ts +++ b/src/cli/handlers/orchestration/worker-agent-views.ts @@ -108,6 +108,7 @@ export type WorkerListResult = { dispatchStatus: string agentTerminalHandle: string | null terminalState: string | null + coordinatedFrom?: string | null resource?: { worktreeId?: string | null; autoReleaseAt?: string | null } | null projection?: WorkerProjection }[] @@ -133,6 +134,7 @@ type WorkerListRow = { terminalState?: string workspace?: string host?: string + coordinatedFrom?: string attention?: string[] autoReleaseAt?: string next?: string @@ -168,6 +170,7 @@ export const WORKER_LIST_VIEW: OrchestrationAgentView +/** A paired Orca acting as the coordinator of its Run here, as its authenticated frame names it. + * Its handle and binding key are its address, which no terminal or session here can have. */ +export type OrchestrationPeerCaller = OrchestrationCallerIdentity & + Readonly<{ + kind: 'peer' + terminalHandle: string + paneKey: string + orcaSessionId: null + peerRun: PeerRunRow + }> + +/** The caller the dispatch entry resolved, which wins over any declared handle: a session from + * its environment, or a paired Orca from its pairing. */ +export type OrchestrationEntryCaller = OrchestrationSessionCaller | OrchestrationPeerCaller + +/** The address, and Run binding key, of the paired Orca coordinating a conversation's Run here. */ +export function peerCoordinatorAddress(conversationId: string): string { + return `${PEER_ADDRESS_PREFIX}${conversationId}` +} + +const PEER_ADDRESS_PREFIX = 'peer:' + +export function isPeerCoordinatorAddress(value: string | null | undefined): boolean { + return !!value?.startsWith(PEER_ADDRESS_PREFIX) +} + +export function peerCoordinatorCaller(peerRun: PeerRunRow): OrchestrationPeerCaller { + const address = peerCoordinatorAddress(peerRun.conversation_id) + return Object.freeze({ + kind: 'peer', + address, + terminalHandle: address, + paneKey: address, + orcaSessionId: null, + peerRun + }) +} + +export function isPeerCaller( + caller: OrchestrationCallerIdentity | null | undefined +): caller is OrchestrationPeerCaller { + return !!caller && 'kind' in caller && caller.kind === 'peer' +} + /** A caller with neither a pane nor an Orca session id can never be bound to a Run. */ export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { return caller.paneKey !== null || caller.orcaSessionId !== null diff --git a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts index 7d5bc369062..68624a6ade3 100644 --- a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts @@ -11,6 +11,7 @@ import { attachFederationRelayAck } from './federation/federation-relay-ack' import { attachLegacyRemoteDbMethods } from '../legacy-remote/legacy-remote-db-methods' import { attachLegacyRemoteHomeDbMethods } from '../legacy-remote-home/legacy-remote-home-db-methods' import { attachPeerMailStore } from './federation/peer-mail-store' +import { attachPeerRunStore } from './peer-runs/peer-run-store' import { attachLegacyAskOperation } from './legacy/legacy-ask-operation' import { attachLegacyCompatibilityCandidates } from './legacy/legacy-compatibility-candidates' import { attachLegacyCompatibilityPrincipals } from './legacy/legacy-compatibility-principals' @@ -107,6 +108,7 @@ export function attachOrchestrationDbMethods(ctor: { prototype: object }): void attachLegacyRemoteDbMethods(ctor) attachFederationRelayAck(ctor) attachPeerMailStore(ctor) + attachPeerRunStore(ctor) attachWorkerTerminalResourceStore(ctor) attachWorkerTerminalTransfer(ctor) attachWorkerTerminalRelease(ctor) diff --git a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts index 9152202ada9..4f812539964 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/worker-report-settlement.ts @@ -168,6 +168,10 @@ export function settleWorkerReportInTransaction( let dispatchUpdate: { changes: number } let taskUpdate: { changes: number } if (settledByUnobservedPrompt) { + // A paired coordinator gets the correction once, after all its writes; see peer_run_corrections. + this.db + .prepare('INSERT INTO peer_run_corrections (dispatch_id) VALUES (?)') + .run(params.dispatchId) const now = new Date().toISOString() const dispatchTransition = transitionLifecycleWithDb(this.db, { entity: 'dispatch', @@ -239,6 +243,7 @@ export function settleWorkerReportInTransaction( projection: { stage: 'settled', updated_at: new Date().toISOString() }, correction: 'unobserved_prompt_report' }) + this.db.prepare('DELETE FROM peer_run_corrections WHERE dispatch_id = ?').run(params.dispatchId) } else if ((unprovenWorker || reportingStart) && params.outcome === 'succeeded') { transitionLifecycleWithDb(this.db, { entity: 'worker', diff --git a/src/main/runtime/orchestration/db/federation/peer-mail-store.ts b/src/main/runtime/orchestration/db/federation/peer-mail-store.ts index da829b40e5e..ffe1e3b2cb9 100644 --- a/src/main/runtime/orchestration/db/federation/peer-mail-store.ts +++ b/src/main/runtime/orchestration/db/federation/peer-mail-store.ts @@ -78,7 +78,7 @@ export function listHomePeerMailEnvironments(this: OrchestrationDb): string[] { export function listPeerConversationIds(this: OrchestrationDb, peerFingerprint: string): string[] { const statement = this.db.prepare( `SELECT conversation_id FROM peer_mail_conversations - WHERE role = 'peer' AND peer_fingerprint = ?` + WHERE role = 'peer' AND peer_fingerprint = ? ORDER BY conversation_id` ) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT over this table returns exactly these columns. const rows = statement.all(peerFingerprint) as { conversation_id: string }[] @@ -118,6 +118,14 @@ export function insertPeerMailMessage( | { message: MessageRow | undefined; duplicate: true } { this.db.exec('SAVEPOINT peer_mail_insert') try { + // A peer Run's conversation keeps its own queue sequence, so it never carries peer mail. + if (this.getPeerRunForConversation(params.conversationId)) { + throw new OrchestrationError( + 'request_mismatch', + `Conversation ${params.conversationId} belongs to a coordinated Run. Nothing was delivered.`, + { effectsApplied: false } + ) + } if (params.peerFingerprint !== undefined) { const conversation = this.getPeerMailConversation(params.conversationId) if ( diff --git a/src/main/runtime/orchestration/db/lifecycle-transition.ts b/src/main/runtime/orchestration/db/lifecycle-transition.ts index 5eefd879ecb..f0b44ee85ae 100644 --- a/src/main/runtime/orchestration/db/lifecycle-transition.ts +++ b/src/main/runtime/orchestration/db/lifecycle-transition.ts @@ -1,6 +1,7 @@ import type Database from '../../../sqlite/sync-database' import { OrchestrationError } from '../orchestration-error' import type { OrchestrationDb } from './orchestration-db' +import { notifyPeerMailQueued } from '../federation/peer-mail/peer-mail-waiters' /** Callers own transactions so lifecycle projections and mailbox effects commit atomically together. */ export type LifecycleEntity = 'task' | 'dispatch' | 'worker' @@ -189,7 +190,8 @@ export function transitionLifecycleWithDb( `${params.entity} ${params.id} changed while transitioning.` ) } - + // A peer Run's change is queued home by trigger in this statement. + notifyPeerMailQueued(db) return { changed: true } } diff --git a/src/main/runtime/orchestration/db/messages/message-inbox.ts b/src/main/runtime/orchestration/db/messages/message-inbox.ts index e0ec891fd63..f201d19e808 100644 --- a/src/main/runtime/orchestration/db/messages/message-inbox.ts +++ b/src/main/runtime/orchestration/db/messages/message-inbox.ts @@ -226,11 +226,14 @@ export function markAsReadAndDelivered(this: OrchestrationDb, ids: string[]): vo ) } -export function getInbox(this: OrchestrationDb, limit = 20): MessageRow[] { +export function getInbox(this: OrchestrationDb, limit = 20, runId?: string): MessageRow[] { return exposeMessageListTimestamps( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over messages returns the row shape its schema and MessageRow define, like every row cast in db/. this.db - .prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?') - .all(limit) as MessageRow[] + .prepare( + 'SELECT * FROM messages WHERE (?1 IS NULL OR run_id = ?1) ORDER BY sequence DESC LIMIT ?2' + ) + .all(runId ?? null, limit) as MessageRow[] ) } @@ -239,22 +242,19 @@ export function getAllMessagesForHandle( this: OrchestrationDb, toHandle: string, limit = 100, - types?: MessageType[] + types?: MessageType[], + runId?: string ): MessageRow[] { - if (types && types.length > 0) { - const placeholders = types.map(() => '?').join(',') - return exposeMessageListTimestamps( - this.db - .prepare( - `SELECT * FROM messages WHERE to_handle = ? AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` - ) - .all(toHandle, ...types, limit) as MessageRow[] - ) - } + const typed = types && types.length > 0 + const typeFilter = typed ? `AND type IN (${types.map(() => '?').join(',')})` : '' return exposeMessageListTimestamps( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over messages returns the row shape its schema and MessageRow define, like every row cast in db/. this.db - .prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?') - .all(toHandle, limit) as MessageRow[] + .prepare( + `SELECT * FROM messages WHERE to_handle = ? AND (? IS NULL OR run_id = ?) ${typeFilter} + ORDER BY sequence DESC LIMIT ?` + ) + .all(toHandle, runId ?? null, runId ?? null, ...(typed ? types : []), limit) as MessageRow[] ) } diff --git a/src/main/runtime/orchestration/db/messages/message-insert.ts b/src/main/runtime/orchestration/db/messages/message-insert.ts index cc590b76b1a..24c45a19e7d 100644 --- a/src/main/runtime/orchestration/db/messages/message-insert.ts +++ b/src/main/runtime/orchestration/db/messages/message-insert.ts @@ -8,6 +8,7 @@ import { generateId } from '../generated-id' import { exposeMessageTimestamps } from '../utc-timestamp' import type { OrchestrationDb } from '../orchestration-db' import { runLifecycleWriteTransaction } from '../lifecycle-write-transaction-runner' +import { notifyPeerMailQueued } from '../../federation/peer-mail/peer-mail-waiters' import { ORCHESTRATION_UNBOUND_RUN_ID as UNBOUND_RUN_ID } from '../../../../../shared/orchestration/orchestration-rpc-contract' const MESSAGE_INSERT_SAVEPOINT = 'message_insert_batch' @@ -27,6 +28,8 @@ export type MessageInsert = { runId?: string answerSenderSnapshot?: string deliveryContract?: MessageDeliveryContract + /** The Dispatch whose worker sent this, as the sender's own check found it; kept for a peer Run's queue. */ + senderDispatchId?: string } export function insertMessage(this: OrchestrationDb, msg: MessageInsert): MessageRow { @@ -44,6 +47,16 @@ export function insertMessage(this: OrchestrationDb, msg: MessageInsert): Messag this.requireRun(runId) const id = msg.id ?? generateId('msg') const type = msg.type ?? 'status' + if (msg.senderDispatchId) { + // Before the insert, whose trigger queues a peer Run's mail home with it; immutable after. + this.db + .prepare( + `INSERT OR IGNORE INTO message_dispatch_provenance (message_id, dispatch_id, task_id) + SELECT ?, d.id, d.task_id FROM dispatch_contexts d + WHERE d.id = ? AND d.run_id IN (SELECT run_id FROM peer_runs)` + ) + .run(id, msg.senderDispatchId) + } // Heartbeats only record liveness: store them handled so they never reach a mailbox or wake it. const handled = type === 'heartbeat' ? 1 : 0 const stmt = this.db.prepare(` @@ -70,6 +83,8 @@ export function insertMessage(this: OrchestrationDb, msg: MessageInsert): Messag handled, handled ) + // Mail filed in a peer Run is queued home by trigger in this statement. + notifyPeerMailQueued(this.db) return exposeMessageTimestamps( this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow ) diff --git a/src/main/runtime/orchestration/db/orchestration-db-methods.ts b/src/main/runtime/orchestration/db/orchestration-db-methods.ts index 0e06a05191d..9072fe5c5fe 100644 --- a/src/main/runtime/orchestration/db/orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/orchestration-db-methods.ts @@ -10,6 +10,7 @@ import type { FederationRelayAckMethods } from './federation/federation-relay-ac import type { LegacyRemoteDbMethods } from '../legacy-remote/legacy-remote-db-methods' import type { LegacyRemoteHomeDbMethods } from '../legacy-remote-home/legacy-remote-home-db-methods' import type { PeerMailStoreMethods } from './federation/peer-mail-store' +import type { PeerRunStoreMethods } from './peer-runs/peer-run-store' import type { LegacyAskOperationMethods } from './legacy/legacy-ask-operation' import type { LegacyCompatibilityCandidatesMethods } from './legacy/legacy-compatibility-candidates' import type { LegacyCompatibilityPrincipalsMethods } from './legacy/legacy-compatibility-principals' @@ -104,6 +105,7 @@ export type OrchestrationDbMethods = AttemptObservationStoreMethods & LegacyRemoteDbMethods & FederationRelayAckMethods & PeerMailStoreMethods & + PeerRunStoreMethods & StructuredMailSendStoreMethods & WorkerTerminalResourceStoreMethods & WorkerTerminalTransferMethods & diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 25bec3ae31c..3fcd88f18b1 100644 --- a/src/main/runtime/orchestration/db/orchestration-db.ts +++ b/src/main/runtime/orchestration/db/orchestration-db.ts @@ -15,6 +15,7 @@ import { rememberCurrentRunCoordinatorHandles } from './runs/run-coordinator-mail-routing' import { createTables } from './schema/create-tables' +import { createPeerRunQueueTriggers } from './peer-runs/peer-run-queue-sql' import { migrate } from './schema/migrate' import { backfillStructuredWorkerOrcaSessionIds } from './schema/structured-worker-orca-session-backfill' import { reconcileSettledWorkerDispatches } from './worker-dispatch/worker-dispatch-settlement' @@ -63,6 +64,7 @@ class OrchestrationDbCore { } } createRunCoordinatorAddressTriggers(this.db) + createPeerRunQueueTriggers(this.db) backfillFederatedStubHomeRuns(this.db) // After every open step that creates Runs, before any step that deletes work. installRemovedWorkCapture(this.db, removedWorkCaptureWasIntact) diff --git a/src/main/runtime/orchestration/db/peer-runs/peer-run-queue-sql.ts b/src/main/runtime/orchestration/db/peer-runs/peer-run-queue-sql.ts new file mode 100644 index 00000000000..c2862a64508 --- /dev/null +++ b/src/main/runtime/orchestration/db/peer-runs/peer-run-queue-sql.ts @@ -0,0 +1,175 @@ +import type Database from '../../../../sqlite/sync-database' +import { REMOTE_OWNED_WIRE_VERSION } from '../../../../../shared/orchestration/remote-owned-wire' + +/** + * A Run here whose coordinator is a paired Orca, keyed by the conversation its queue goes home + * under. It outlives this Orca's resets, so the conversation keeps its Run ID and its sequence. + * Each conversation's next `to_home` sequence is persistent too, so a reset never reuses one. + * A worker message's Dispatch is recorded when it is filed, before any reroute into the Run. + */ +export const PEER_RUN_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS peer_runs ( + run_id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL UNIQUE, + peer_fingerprint TEXT NOT NULL, + home_run_id TEXT NOT NULL, + home_label TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'open' CHECK(state IN ('open', 'closed')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS federation_relay_sequences ( + dispatch_id TEXT NOT NULL, + direction TEXT NOT NULL, + last_sequence INTEGER NOT NULL, + PRIMARY KEY (dispatch_id, direction) +); + +-- A Dispatch whose report owner's correction is being written: its edges queue nothing until the +-- row is removed, which queues the corrected state once, flagged. +CREATE TABLE IF NOT EXISTS peer_run_corrections ( + dispatch_id TEXT PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS message_dispatch_provenance ( + message_id TEXT PRIMARY KEY, + dispatch_id TEXT NOT NULL, + task_id TEXT NOT NULL +); +` + +/** A peer-Run Dispatch as a `worker_state` item carries it, with its Task and Run. */ +const PEER_RUN_DISPATCH_STATE_VIEW_SQL = ` +DROP VIEW IF EXISTS peer_run_dispatch_state; +CREATE VIEW peer_run_dispatch_state AS +SELECT d.id AS dispatch_id, pr.conversation_id, pr.home_run_id, pr.run_id AS peer_run_id, + json_object( + 'id', d.id, 'taskId', d.task_id, 'status', d.status, 'workerState', w.state, + 'lastFailure', d.last_failure, 'agent', json_extract(w.start_options, '$.agent') + ) AS dispatch_json, + json_object('status', t.status, 'result', t.result) AS task_json +FROM dispatch_contexts d +JOIN peer_runs pr ON pr.run_id = d.run_id +JOIN tasks t ON t.id = d.task_id +LEFT JOIN worker_dispatches w ON w.dispatch_id = d.id; +` + +const SEQUENCE = `(SELECT last_sequence FROM federation_relay_sequences + WHERE dispatch_id = conversation_id AND direction = 'to_home')` + +/** + * Takes the conversation's next sequence, then queues the item under it. `rows` selects at most one + * row with `conversation_id` and the item's `message_id` and `payload`; both statements read it. + */ +function queueItemStatements(rows: string, kind: string): [string, string] { + return [ + `INSERT INTO federation_relay_sequences (dispatch_id, direction, last_sequence) + SELECT conversation_id, 'to_home', 1 FROM (${rows}) WHERE true + ON CONFLICT(dispatch_id, direction) DO UPDATE SET last_sequence = last_sequence + 1`, + `INSERT INTO federation_relay_items + (dispatch_id, direction, sequence, message_id, kind, payload, byte_count) + SELECT conversation_id, 'to_home', ${SEQUENCE}, message_id, '${kind}', payload, length(payload) + FROM (${rows})` + ] +} + +/** One `worker_state` item for a peer-Run Dispatch; its ID is the sequence it is queued under. */ +export function peerRunDispatchStateStatements( + dispatchId: string, + corrected: boolean, + removed: string +): [string, string] { + return queueItemStatements( + `SELECT conversation_id, 'state_' || ${SEQUENCE} AS message_id, + json_object( + 'v', ${REMOTE_OWNED_WIRE_VERSION}, 'id', 'state_' || ${SEQUENCE}, + 'homeRunId', home_run_id, 'peerRunId', peer_run_id, + 'dispatch', json(dispatch_json), 'task', json(task_json), + 'correctedUnobservedPrompt', json('${corrected}'), + 'removed', ${removed} + ) AS payload + FROM peer_run_dispatch_state WHERE dispatch_id = ${dispatchId}`, + 'worker_state' + ) +} + +/** A message filed in a peer Run's mailbox, once, with the Dispatch its sender worked for. */ +function peerRunMailStatements(row: string): [string, string] { + return queueItemStatements( + `SELECT pr.conversation_id, ${row}.id AS message_id, + json_object( + 'v', ${REMOTE_OWNED_WIRE_VERSION}, 'id', ${row}.id, + 'homeRunId', pr.home_run_id, 'peerRunId', pr.run_id, + 'message', json_object( + 'from', ${row}.from_handle, 'type', ${row}.type, 'subject', ${row}.subject, + 'body', ${row}.body, 'payload', ${row}.payload, 'priority', ${row}.priority, + 'threadId', ${row}.thread_id + ), + 'dispatchId', p.dispatch_id, 'taskId', p.task_id + ) AS payload + FROM peer_runs pr + LEFT JOIN message_dispatch_provenance p ON p.message_id = ${row}.id + WHERE ${row}.to_handle = 'run:' || pr.run_id + AND NOT EXISTS ( + SELECT 1 FROM federation_relay_items + WHERE dispatch_id = pr.conversation_id AND direction = 'to_home' + AND message_id = ${row}.id + )`, + 'worker_mail' + ) +} + +const LATEST_TASK_DISPATCH = (taskId: string) => + `(SELECT id FROM dispatch_contexts WHERE task_id = ${taskId} ORDER BY rowid DESC LIMIT 1)` + +const NOT_CORRECTING = (dispatchId: string) => + `NOT EXISTS (SELECT 1 FROM peer_run_corrections WHERE dispatch_id = ${dispatchId})` + +const trigger = (statements: [string, string]) => `BEGIN ${statements.join(';\n')}; END;` + +/** + * Every final message of a peer Run's mailbox and every lifecycle change of its Dispatches, their + * workers and their Tasks queue home in the transaction that made them. Rebuilt on every open. + * The report owner's unobserved-prompt correction queues once, flagged, after all its writes. + */ +export function createPeerRunQueueTriggers(db: Database.Database): void { + db.exec('BEGIN IMMEDIATE') + try { + db.exec(` + ${PEER_RUN_DISPATCH_STATE_VIEW_SQL} + DROP TRIGGER IF EXISTS trg_peer_run_mail_insert; + DROP TRIGGER IF EXISTS trg_peer_run_mail_reroute; + DROP TRIGGER IF EXISTS trg_peer_run_dispatch_insert; + DROP TRIGGER IF EXISTS trg_peer_run_dispatch_status; + DROP TRIGGER IF EXISTS trg_peer_run_worker_state; + DROP TRIGGER IF EXISTS trg_peer_run_task_status; + DROP TRIGGER IF EXISTS trg_peer_run_correction; + CREATE TRIGGER trg_peer_run_mail_insert AFTER INSERT ON messages + WHEN NEW.to_handle LIKE 'run:%' AND NEW.type <> 'heartbeat' + ${trigger(peerRunMailStatements('NEW'))} + CREATE TRIGGER trg_peer_run_mail_reroute AFTER UPDATE OF to_handle ON messages + WHEN NEW.to_handle LIKE 'run:%' AND NEW.to_handle <> OLD.to_handle AND NEW.type <> 'heartbeat' + ${trigger(peerRunMailStatements('NEW'))} + CREATE TRIGGER trg_peer_run_dispatch_insert AFTER INSERT ON dispatch_contexts + ${trigger(peerRunDispatchStateStatements('NEW.id', false, 'NULL'))} + CREATE TRIGGER trg_peer_run_dispatch_status AFTER UPDATE OF status ON dispatch_contexts + WHEN NEW.status <> OLD.status AND ${NOT_CORRECTING('NEW.id')} + ${trigger(peerRunDispatchStateStatements('NEW.id', false, 'NULL'))} + CREATE TRIGGER trg_peer_run_worker_state AFTER UPDATE OF state ON worker_dispatches + WHEN NEW.state <> OLD.state AND ${NOT_CORRECTING('NEW.dispatch_id')} + ${trigger(peerRunDispatchStateStatements('NEW.dispatch_id', false, 'NULL'))} + CREATE TRIGGER trg_peer_run_task_status AFTER UPDATE OF status ON tasks + WHEN NEW.status <> OLD.status AND NOT EXISTS ( + SELECT 1 FROM peer_run_corrections c JOIN dispatch_contexts d ON d.id = c.dispatch_id + WHERE d.task_id = NEW.id + ) + ${trigger(peerRunDispatchStateStatements(LATEST_TASK_DISPATCH('NEW.id'), false, 'NULL'))} + CREATE TRIGGER trg_peer_run_correction AFTER DELETE ON peer_run_corrections + ${trigger(peerRunDispatchStateStatements('OLD.dispatch_id', true, 'NULL'))} + `) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} diff --git a/src/main/runtime/orchestration/db/peer-runs/peer-run-queue.test.ts b/src/main/runtime/orchestration/db/peer-runs/peer-run-queue.test.ts new file mode 100644 index 00000000000..a8b9bd4ce3b --- /dev/null +++ b/src/main/runtime/orchestration/db/peer-runs/peer-run-queue.test.ts @@ -0,0 +1,265 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from '../orchestration-db' +import { generateId } from '../generated-id' +import { + RemoteOwnedWorkerMail, + RemoteOwnedWorkerState +} from '../../../../../shared/orchestration/remote-owned-wire' +import { AGENT_PROMPT_STALLED_ERROR } from '../../../terminal/input/agent-prompt-submission-verification' +import type { PeerRunRow } from './peer-run-store' + +describe("a paired Orca's Run: its queue home", () => { + let db: OrchestrationDb + let peerRun: PeerRunRow + + beforeEach(() => { + db = new OrchestrationDb(':memory:') + peerRun = db.admitPeerRun({ + peerFingerprint: 'fingerprint_a', + peerRun: { runId: 'run_home', conversationId: generateId('peerconv'), label: 'devA' } + }) + }) + + afterEach(() => db.close()) + + function start(params: { adoptTaskId?: string; retryOf?: string } = {}) { + return db.createStartingWorkerDispatch({ + taskSpec: 'Build it', + taskRunId: peerRun.run_id, + startOptions: { agent: 'claude' }, + creator: { kind: 'system' }, + maxDepth: 5, + ...params + }) + } + + function pending() { + return db.listPendingFederationRelay(peerRun.conversation_id, 'to_home', 500) + } + + function states() { + return pending() + .filter((item) => item.kind === 'worker_state') + .map((item) => RemoteOwnedWorkerState.parse(JSON.parse(item.payload))) + } + + function fail(dispatchId: string, taskId: string) { + db.transitionLifecycle({ entity: 'worker', id: dispatchId, from: 'starting', to: 'failed' }) + db.transitionLifecycle({ entity: 'dispatch', id: dispatchId, from: 'pending', to: 'failed' }) + db.transitionLifecycle({ entity: 'task', id: taskId, from: 'dispatched', to: 'failed' }) + } + + it('queues every lifecycle change of a Dispatch and its Task, in order, as the wire schema', () => { + const { dispatch, task } = start({ adoptTaskId: 'task_home1' }) + fail(dispatch.id, task.id) + + expect(task.id).toBe('task_home1') + const items = pending() + expect(items.map((item) => item.sequence)).toEqual(items.map((_, index) => index + 1)) + expect( + states().map((state) => [ + state.dispatch.status, + state.dispatch.workerState, + state.task.status + ]) + ).toEqual([ + ['pending', null, 'ready'], + ['pending', 'starting', 'dispatched'], + ['pending', 'failed', 'dispatched'], + ['failed', 'failed', 'dispatched'], + ['failed', 'failed', 'failed'] + ]) + expect(states().every((state) => state.homeRunId === 'run_home')).toBe(true) + for (const item of items) { + expect(JSON.parse(item.payload).id).toBe(item.message_id) + } + }) + + it("queues the report owner's unobserved-prompt correction once, flagged, with its result", () => { + const { dispatch, task } = start() + fail(dispatch.id, task.id) + db.transitionLifecycle({ + entity: 'dispatch', + id: dispatch.id, + from: 'failed', + to: 'failed', + projection: { last_failure: AGENT_PROMPT_STALLED_ERROR } + }) + const before = states().length + + db.settleWorkerReport({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded', + result: 'actually done' + }) + + expect(states().slice(before)).toEqual([ + expect.objectContaining({ + correctedUnobservedPrompt: true, + dispatch: expect.objectContaining({ status: 'completed', workerState: 'succeeded' }), + task: { status: 'completed', result: 'actually done' } + }) + ]) + }) + + it('holds back the Task change of a correction even when a newer attempt is the latest', () => { + const first = start() + fail(first.dispatch.id, first.task.id) + db.transitionLifecycle({ + entity: 'dispatch', + id: first.dispatch.id, + from: 'failed', + to: 'failed', + projection: { last_failure: AGENT_PROMPT_STALLED_ERROR } + }) + const retry = start({ adoptTaskId: first.task.id }) + fail(retry.dispatch.id, first.task.id) + const before = states().length + + db.settleWorkerReport({ + taskId: first.task.id, + dispatchId: first.dispatch.id, + outcome: 'succeeded', + result: 'actually done' + }) + + expect(states().slice(before)).toEqual([ + expect.objectContaining({ + correctedUnobservedPrompt: true, + dispatch: expect.objectContaining({ id: first.dispatch.id }) + }) + ]) + }) + + it("queues a worker's question with its thread, so the coordinator can answer in it", () => { + const { dispatch } = start() + const { message } = db.createQuestion({ + runId: peerRun.run_id, + dispatchId: dispatch.id, + askerHandle: 'term_w', + question: 'Which branch?' + }) + const queued = pending().find((item) => item.message_id === message.id)! + expect(RemoteOwnedWorkerMail.parse(JSON.parse(queued.payload)).message.threadId).toBe( + message.id + ) + }) + + it('never reuses a sequence after a reset, and a reset tells the coordinator what it removed', () => { + const first = start() + const throughOld = Math.max(...pending().map((item) => item.sequence)) + + db.resetTasks() + const removed = states().filter((state) => state.removed) + expect(removed.map((state) => [state.dispatch.id, state.removed?.reason])).toEqual([ + [first.dispatch.id, 'reset on the server running it'] + ]) + start() + db.acknowledgeFederationRelay({ + dispatchId: peerRun.conversation_id, + direction: 'to_home', + throughSequence: throughOld + }) + + const after = pending() + expect(after.length).toBeGreaterThan(0) + expect(after.every((item) => item.sequence > throughOld)).toBe(true) + }) + + it('keeps the queue and the Run ID through a full reset, with a removed item per Dispatch', () => { + const { dispatch } = start() + db.resetAll() + + expect(states().find((state) => state.removed)?.dispatch.id).toBe(dispatch.id) + const again = db.admitPeerRun({ + peerFingerprint: 'fingerprint_a', + peerRun: { runId: 'run_home', conversationId: peerRun.conversation_id, label: 'devA' } + }) + expect(again.run_id).toBe(peerRun.run_id) + expect(db.getRun(peerRun.run_id)).toBeDefined() + }) + + it('attributes mail rerouted into the Run to the Dispatch its sender worked for when it sent it', () => { + const coordinator = `peer:${peerRun.conversation_id}` + const first = start() + const note = db.insertMessage({ + from: 'term_w', + to: 'term_unbound', + subject: 'late note', + runId: peerRun.run_id, + senderDispatchId: first.dispatch.id + }) + fail(first.dispatch.id, first.task.id) + start() + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(coordinator, note.id) + db.db + .prepare('UPDATE messages SET to_handle = ? WHERE id = ?') + .run(`run:${peerRun.run_id}`, note.id) + + const mail = pending() + .filter((item) => item.kind === 'worker_mail') + .map((item) => RemoteOwnedWorkerMail.parse(JSON.parse(item.payload))) + expect(mail.map((item) => [item.id, item.dispatchId, item.taskId])).toEqual([ + [note.id, first.dispatch.id, first.task.id] + ]) + }) + + it('reopens an adopted Task whose last attempt settled, so the coordinator can run it again', () => { + const first = start({ adoptTaskId: 'task_home1' }) + fail(first.dispatch.id, first.task.id) + + const again = start({ adoptTaskId: 'task_home1' }) + expect(again.task.id).toBe('task_home1') + expect(again.dispatch.id).not.toBe(first.dispatch.id) + expect(() => start({ adoptTaskId: 'task_home1' })).toThrow(/only a ready Task can start/) + }) + + it('keeps a peer Run conversation and a peer-mail conversation apart, so their sequences never collide', () => { + const mailConversation = generateId('peerconv') + db.insertPeerMailMessage({ + conversationId: mailConversation, + peerFingerprint: 'fingerprint_a', + remoteParty: null, + message: { id: generateId('msg'), from: 'devA:term_x', to: 'term_w', subject: 'hi' } + }) + expect(() => + db.admitPeerRun({ + peerFingerprint: 'fingerprint_a', + peerRun: { runId: 'run_home2', conversationId: mailConversation, label: 'devA' } + }) + ).toThrow(expect.objectContaining({ code: 'request_mismatch' })) + expect(() => + db.insertPeerMailMessage({ + conversationId: peerRun.conversation_id, + peerFingerprint: 'fingerprint_a', + remoteParty: null, + message: { id: generateId('msg'), from: 'devA:term_x', to: 'term_w', subject: 'hi' } + }) + ).toThrow(expect.objectContaining({ code: 'request_mismatch' })) + }) + + it("follows a rename of the coordinating Orca's machine", () => { + const renamed = db.admitPeerRun({ + peerFingerprint: 'fingerprint_a', + peerRun: { runId: 'run_home', conversationId: peerRun.conversation_id, label: 'devA2' } + }) + expect(renamed.home_label).toBe('devA2') + }) + + it('leaves an adopted Task that still waits on its dependencies waiting', () => { + const dependency = db.createTask({ spec: 'first', runId: peerRun.run_id }) + const task = db.createTask({ + spec: 'second', + runId: peerRun.run_id, + deps: [dependency.id] + }) + expect(() => start({ adoptTaskId: task.id })).toThrow(/only a ready Task can start/) + expect(db.getTask(task.id)?.status).toBe('pending') + }) + + it('admits no start into a Run its coordinator cancelled', () => { + db.closePeerRun(peerRun.run_id) + expect(() => start()).toThrow(/cancelled by devA/) + }) +}) diff --git a/src/main/runtime/orchestration/db/peer-runs/peer-run-store.ts b/src/main/runtime/orchestration/db/peer-runs/peer-run-store.ts new file mode 100644 index 00000000000..20f70500176 --- /dev/null +++ b/src/main/runtime/orchestration/db/peer-runs/peer-run-store.ts @@ -0,0 +1,146 @@ +import type { RemoteOwnedPeerRun } from '../../../../../shared/orchestration/remote-owned-wire' +import { OrchestrationError } from '../../orchestration-error' +import { generateId } from '../generated-id' +import type { OrchestrationDb } from '../orchestration-db' +import { peerRunDispatchStateStatements } from './peer-run-queue-sql' +import { peerCoordinatorAddress } from '../../addressing/orchestration-caller-identity' + +export type PeerRunRow = { + run_id: string + conversation_id: string + peer_fingerprint: string + home_run_id: string + home_label: string + state: 'open' | 'closed' + created_at: string +} + +const NO_EFFECTS = { effectsApplied: false } as const + +/** + * The Run a paired Orca coordinates here for one conversation, written when its frame arrives and + * before any await, so a later frame of the same conversation (a cancel) always finds it. The Run + * keeps its ID across this Orca's resets: a reset that removed its row gets it back on the next call. + */ +export function admitPeerRun( + this: OrchestrationDb, + params: { peerFingerprint: string; peerRun: RemoteOwnedPeerRun } +): PeerRunRow { + const { peerFingerprint, peerRun } = params + this.db.exec('BEGIN IMMEDIATE') + try { + const conversation = this.getPeerMailConversation(peerRun.conversationId) + const existing = this.getPeerRunForConversation(peerRun.conversationId) + // A peer-mail conversation keeps its own queue sequence, so it never becomes a peer Run's. + if ( + existing + ? existing.peer_fingerprint !== peerFingerprint || existing.home_run_id !== peerRun.runId + : conversation + ) { + throw new OrchestrationError( + 'request_mismatch', + `Conversation ${peerRun.conversationId} belongs to another Run or pairing. No effects were applied.`, + NO_EFFECTS + ) + } + const runId = existing?.run_id ?? generateId('run') + if (existing && existing.home_label !== peerRun.label) { + this.db + .prepare('UPDATE peer_runs SET home_label = ? WHERE run_id = ?') + .run(peerRun.label, runId) + } + if (!existing) { + this.db + .prepare( + `INSERT OR IGNORE INTO peer_mail_conversations (conversation_id, role, peer_fingerprint) + VALUES (?, 'peer', ?)` + ) + .run(peerRun.conversationId, peerFingerprint) + this.db + .prepare( + `INSERT INTO peer_runs (run_id, conversation_id, peer_fingerprint, home_run_id, home_label) + VALUES (?, ?, ?, ?, ?)` + ) + .run(runId, peerRun.conversationId, peerFingerprint, peerRun.runId, peerRun.label) + } + if (!this.getRunRaw(runId)) { + const coordinator = peerCoordinatorAddress(peerRun.conversationId) + this.insertRunRow(runId, { + objective: `Coordinated from ${peerRun.label}`, + coordinatorHandle: coordinator, + coordinatorPaneKey: coordinator + }) + } + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the committed transaction above wrote or found this conversation's row. + return this.getPeerRunForConversation(peerRun.conversationId) as PeerRunRow +} + +export function getPeerRun(this: OrchestrationDb, runId: string): PeerRunRow | undefined { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns exactly its row shape. + return this.db.prepare('SELECT * FROM peer_runs WHERE run_id = ?').get(runId) as + | PeerRunRow + | undefined +} + +export function hasPeerRuns(this: OrchestrationDb): boolean { + return this.db.prepare('SELECT 1 FROM peer_runs LIMIT 1').get() !== undefined +} + +export function getPeerRunForConversation( + this: OrchestrationDb, + conversationId: string +): PeerRunRow | undefined { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns exactly its row shape. + return this.db + .prepare('SELECT * FROM peer_runs WHERE conversation_id = ?') + .get(conversationId) as PeerRunRow | undefined +} + +/** Closed for good: the claim refuses every start, follow-up and helper into it from now on. */ +export function closePeerRun(this: OrchestrationDb, runId: string): void { + this.db.prepare(`UPDATE peer_runs SET state = 'closed' WHERE run_id = ?`).run(runId) +} + +/** Inside a reset's transaction, before it deletes them: one final item per peer-Run Dispatch. */ +export function queueRemovedPeerRunDispatches(this: OrchestrationDb, reason: string): void { + const [allocate, queue] = peerRunDispatchStateStatements( + '?1', + false, + `json_object('reason', ?2)` + ).map((sql) => this.db.prepare(sql)) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT of one column returns exactly that column. + const dispatches = this.db + .prepare( + `SELECT d.id FROM dispatch_contexts d JOIN peer_runs pr ON pr.run_id = d.run_id ORDER BY d.rowid` + ) + .all() as { id: string }[] + for (const { id } of dispatches) { + allocate.run(id, reason) + queue.run(id, reason) + } +} + +export type PeerRunStoreMethods = { + admitPeerRun: typeof admitPeerRun + getPeerRun: typeof getPeerRun + getPeerRunForConversation: typeof getPeerRunForConversation + hasPeerRuns: typeof hasPeerRuns + closePeerRun: typeof closePeerRun + queueRemovedPeerRunDispatches: typeof queueRemovedPeerRunDispatches +} + +export function attachPeerRunStore(ctor: { prototype: object }): void { + Object.assign(ctor.prototype, { + admitPeerRun, + getPeerRun, + getPeerRunForConversation, + hasPeerRuns, + closePeerRun, + queueRemovedPeerRunDispatches + }) +} diff --git a/src/main/runtime/orchestration/db/questions/question-threads.ts b/src/main/runtime/orchestration/db/questions/question-threads.ts index 4f0be287206..5741dc5750a 100644 --- a/src/main/runtime/orchestration/db/questions/question-threads.ts +++ b/src/main/runtime/orchestration/db/questions/question-threads.ts @@ -2,6 +2,7 @@ import type { MessageRow, QuestionRow } from '../orchestration-records' import { OrchestrationError } from '../../orchestration-error' import { exposeQuestionTimestamps } from '../utc-timestamp' import type { OrchestrationDb } from '../orchestration-db' +import { generateId } from '../generated-id' import { handHelperMailToRun } from '../messages/helper-mailbox-hand-back' export function createQuestion( @@ -27,7 +28,11 @@ export function createQuestion( `Dispatch ${params.dispatchId} is not in Run ${params.runId}.` ) } + // A question is its own thread, set at insert so mail queued for a paired coordinator carries it. + const id = generateId('msg') const message = this.insertMessage({ + id, + threadId: id, from: `dispatch:${params.dispatchId}`, to: params.to ?? `run:${params.runId}`, subject: 'Question', @@ -39,9 +44,9 @@ export function createQuestion( question: params.question, options: params.options ?? [] }), + senderDispatchId: dispatch.id, runId: params.runId }) - this.db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(message.id, message.id) this.db .prepare( `INSERT INTO question_threads ( diff --git a/src/main/runtime/orchestration/db/reset/orchestration-reset.ts b/src/main/runtime/orchestration/db/reset/orchestration-reset.ts index ac68ba65767..b8af86962be 100644 --- a/src/main/runtime/orchestration/db/reset/orchestration-reset.ts +++ b/src/main/runtime/orchestration/db/reset/orchestration-reset.ts @@ -1,5 +1,6 @@ import { ORCHESTRATION_LEGACY_RUN_ID as LEGACY_RUN_ID } from '../../../../../shared/orchestration/orchestration-rpc-contract' import type { OrchestrationDb } from '../orchestration-db' +import { notifyPeerMailQueued } from '../../federation/peer-mail/peer-mail-waiters' import type { ResetRemovedWork } from '../../../../../shared/orchestration/orchestration-removed-work' import { readRemovedWorkWatermark, @@ -7,13 +8,22 @@ import { } from './removed-orchestration-work-read' /** Returns the removed-work watermark, read inside the transaction so the reply counts only this reset. */ -export function runResetTransaction(this: OrchestrationDb, statements: string): number { +export function runResetTransaction( + this: OrchestrationDb, + statements: string, + { removesDispatches = false }: { removesDispatches?: boolean } = {} +): number { this.db.exec('BEGIN IMMEDIATE') try { // Why unguarded: an error that ends the transaction must roll the whole reset back, not let the deletes autocommit. const watermark = readRemovedWorkWatermark(this.db) + if (removesDispatches) { + // Their paired coordinator learns of it from its queue, which the reset keeps. + this.queueRemovedPeerRunDispatches('reset on the server running it') + } this.db.exec(statements) this.db.exec('COMMIT') + notifyPeerMailQueued(this.db) return watermark } catch (error) { try { @@ -27,7 +37,8 @@ export function runResetTransaction(this: OrchestrationDb, statements: string): export function resetAll(this: OrchestrationDb): void { // Keep receipts so a lost reset reply cannot replay the mutation. - this.runResetTransaction(` + this.runResetTransaction( + ` DELETE FROM coordinator_runs; DELETE FROM decision_gates; DELETE FROM remote_questions; @@ -37,7 +48,7 @@ export function resetAll(this: OrchestrationDb): void { DELETE FROM legacy_operation_receipts; DELETE FROM legacy_compatibility_principals; DELETE FROM legacy_adoptions; - DELETE FROM federation_relay_items; + DELETE FROM federation_relay_items WHERE NOT (direction = 'to_home' AND dispatch_id IN (SELECT conversation_id FROM peer_runs)); DELETE FROM remote_dispatch_attachments; DELETE FROM federated_dispatches; DELETE FROM worker_terminal_archives; @@ -48,18 +59,22 @@ export function resetAll(this: OrchestrationDb): void { DELETE FROM dispatch_contexts; DELETE FROM tasks; DELETE FROM messages; + DELETE FROM message_dispatch_provenance; DELETE FROM run_coordinator_handles; DELETE FROM runs; DELETE FROM removed_orchestration_work; INSERT INTO runs (id, objective, home_database, consumer_generation, legacy) VALUES ('${LEGACY_RUN_ID}', 'Legacy orchestration state (inspect only)', 'this_database', 0, 1); - `) + `, + { removesDispatches: true } + ) this.hasAnyDispatchContextsCache = undefined } export function resetTasks(this: OrchestrationDb): ResetRemovedWork | undefined { // Messages survive, and so do replies waiting for a paired Orca to pull them; close threads so orphaned questions cannot masquerade as generic replies. - const watermark = this.runResetTransaction(` + const watermark = this.runResetTransaction( + ` DELETE FROM coordinator_runs; DELETE FROM decision_gates; DELETE FROM remote_questions; @@ -70,7 +85,7 @@ export function resetTasks(this: OrchestrationDb): ResetRemovedWork | undefined DELETE FROM legacy_operation_receipts; DELETE FROM legacy_compatibility_principals; DELETE FROM legacy_adoptions; - DELETE FROM federation_relay_items WHERE kind <> 'peer_mail'; + DELETE FROM federation_relay_items WHERE kind <> 'peer_mail' AND NOT (direction = 'to_home' AND dispatch_id IN (SELECT conversation_id FROM peer_runs)); DELETE FROM remote_dispatch_attachments; DELETE FROM federated_dispatches; DELETE FROM worker_terminal_archives; @@ -80,7 +95,9 @@ export function resetTasks(this: OrchestrationDb): ResetRemovedWork | undefined DELETE FROM worker_dispatches; DELETE FROM dispatch_contexts; DELETE FROM tasks; - `) + `, + { removesDispatches: true } + ) this.hasAnyDispatchContextsCache = undefined return summarizeRemovedWorkSince(this.db, watermark) } @@ -92,6 +109,7 @@ export function resetMessages(this: OrchestrationDb): void { DELETE FROM question_threads; DELETE FROM deliveries; DELETE FROM messages; + DELETE FROM message_dispatch_provenance; DELETE FROM structured_pointer_operations; `) } diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index 0d47fd2e34c..6950c8cba8b 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -42,6 +42,15 @@ export function bindRun( return undefined } const sameBinding = runBoundToCoordinator(run, coordinator) + const peerRun = this.getPeerRun(params.runId) + if (peerRun && !sameBinding) { + // A paired Orca's Run has that Orca as its only consumer; it never binds anything here. + throw new OrchestrationError( + 'consumer_fenced', + `Run ${params.runId} is coordinated from ${peerRun.home_label}, which stays its only coordinator. No effects were applied.`, + { effectsApplied: false } + ) + } const explicitTakeover = params.takeoverLegacy === true // Why: before the legacy gate, so a worker never sees its --takeover-legacy recovery command. const workerRefusal = sameBinding diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index 53b17414ea6..6ce7877082c 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -4,42 +4,19 @@ import type { OrchestrationDb } from '../orchestration-db' import type { OrcaSessionId } from '../../../../../shared/orca-session-address' import { mailboxAddressOf } from '../../addressing/orchestration-caller-identity' -export function createRun( - this: OrchestrationDb, - params: { - objective: string - coordinatorHandle: string | null - coordinatorPaneKey: string | null - /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ - coordinatorOrcaSessionId?: OrcaSessionId | null - } -): RunRow { - const coordinator = { - terminalHandle: params.coordinatorHandle, - paneKey: params.coordinatorPaneKey, - orcaSessionId: params.coordinatorOrcaSessionId ?? null - } +type RunCoordinatorParams = { + objective: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null +} + +export function createRun(this: OrchestrationDb, params: RunCoordinatorParams): RunRow { const id = generateId('run') this.db.exec('BEGIN IMMEDIATE') try { - this.db - .prepare( - `INSERT INTO runs ( - id, objective, coordinator_handle, coordinator_pane_key, coordinator_orca_session_id, - coordinator_orca_session_id_generation, consumer_generation, legacy - ) VALUES (?, ?, ?, ?, ?, 1, 1, 0)` - ) - .run( - id, - params.objective, - coordinator.terminalHandle, - coordinator.paneKey, - coordinator.orcaSessionId - ) - const address = mailboxAddressOf(coordinator) - if (address !== null) { - this.rememberRunCoordinatorHandle(id, address) - } + this.insertRunRow(id, params) this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') @@ -48,12 +25,45 @@ export function createRun( return this.getRun(id) as RunRow } +/** Writes a Run bound to its coordinator inside the caller's transaction. */ +export function insertRunRow( + this: OrchestrationDb, + id: string, + params: RunCoordinatorParams +): void { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + orcaSessionId: params.coordinatorOrcaSessionId ?? null + } + this.db + .prepare( + `INSERT INTO runs ( + id, objective, coordinator_handle, coordinator_pane_key, coordinator_orca_session_id, + coordinator_orca_session_id_generation, consumer_generation, legacy + ) VALUES (?, ?, ?, ?, ?, 1, 1, 0)` + ) + .run( + id, + params.objective, + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId + ) + const address = mailboxAddressOf(coordinator) + if (address !== null) { + this.rememberRunCoordinatorHandle(id, address) + } +} + export type RunCreateMethods = { createRun: typeof createRun + insertRunRow: typeof insertRunRow } export function attachRunCreate(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { - createRun + createRun, + insertRunRow }) } diff --git a/src/main/runtime/orchestration/db/schema/create-tables.ts b/src/main/runtime/orchestration/db/schema/create-tables.ts index 1895230e847..5378ee71629 100644 --- a/src/main/runtime/orchestration/db/schema/create-tables.ts +++ b/src/main/runtime/orchestration/db/schema/create-tables.ts @@ -3,9 +3,12 @@ import { createCoreTablesSql } from './create-core-tables-sql' import { createGraphTablesSql } from './create-graph-tables-sql' import { DERIVED_DELIVERY_SCHEMA_SQL } from './migrate-v41' import { REMOVED_WORK_SCHEMA_SQL } from '../reset/removed-orchestration-work-store' +import { PEER_RUN_SCHEMA_SQL } from '../peer-runs/peer-run-queue-sql' export function createTables(this: OrchestrationDb): void { - this.db.exec(`${createCoreTablesSql()}\n${createGraphTablesSql()}\n${REMOVED_WORK_SCHEMA_SQL}`) + this.db.exec( + `${createCoreTablesSql()}\n${createGraphTablesSql()}\n${REMOVED_WORK_SCHEMA_SQL}\n${PEER_RUN_SCHEMA_SQL}` + ) // Unreleased builds saved each send's fence as NOT NULL; nothing reads it, and new rows carry none. if (this.hasColumn('structured_mail_sends', 'fence')) { this.db.exec('ALTER TABLE structured_mail_sends DROP COLUMN fence') diff --git a/src/main/runtime/orchestration/db/tasks/task-store.ts b/src/main/runtime/orchestration/db/tasks/task-store.ts index 401d3d68544..aebef0d890f 100644 --- a/src/main/runtime/orchestration/db/tasks/task-store.ts +++ b/src/main/runtime/orchestration/db/tasks/task-store.ts @@ -10,6 +10,8 @@ import { selectColumns, TASK_COLUMNS } from '../row-column-lists' export function createTask( this: OrchestrationDb, task: { + /** Set only for a Task placed on a paired server: one ID for it here and there, from the start's retry key. */ + id?: string spec: string taskTitle?: string displayName?: string @@ -39,7 +41,7 @@ export function createTask( throw new Error(`Dependency task ${depId} must belong to run ${runId}`) } } - const id = generateId('task') + const id = task.id ?? generateId('task') const depsJson = JSON.stringify(task.deps ?? []) const display = buildOrchestrationTaskDisplayMetadata({ spec: task.spec, diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts index b65da6e2f61..f45cf6677e0 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-start.ts @@ -18,6 +18,8 @@ export function createStartingWorkerDispatch( params: { taskId?: string taskSpec?: string + /** The paired coordinator's ID for this Task: an existing one is reopened, else the new one takes it. */ + adoptTaskId?: string taskRunId?: string taskCreatedByTerminalHandle?: string taskCreatedByPaneKey?: string @@ -89,26 +91,42 @@ export function createStartingWorkerDispatch( { followUpDispatchId: claimedFollowUp.id } ) } + const adopted = params.adoptTaskId ? this.getTask(params.adoptTaskId) : undefined + if (adopted && !params.retryOf) { + reopenSettledAdoptedTask(this, adopted, params.taskRunId) + } const task = params.taskId ? this.getTask(params.taskId) - : params.taskSpec - ? this.createTask({ - spec: params.taskSpec, - taskTitle: params.taskTitle, - deps: params.taskDeps, - parentId: params.taskParentId, - createdByTerminalHandle: params.taskCreatedByTerminalHandle, - createdByPaneKey: params.taskCreatedByPaneKey, - createdByProcessIncarnation: params.taskCreatedByProcessIncarnation, - createdByRunGeneration: params.taskCreatedByRunGeneration, - runId: params.taskRunId - }) - : undefined + : adopted + ? this.getTask(adopted.id) + : params.taskSpec + ? this.createTask({ + id: params.adoptTaskId, + spec: params.taskSpec, + taskTitle: params.taskTitle, + deps: params.taskDeps, + parentId: params.taskParentId, + createdByTerminalHandle: params.taskCreatedByTerminalHandle, + createdByPaneKey: params.taskCreatedByPaneKey, + createdByProcessIncarnation: params.taskCreatedByProcessIncarnation, + createdByRunGeneration: params.taskCreatedByRunGeneration, + runId: params.taskRunId + }) + : undefined if (!task) { // An inline spec already creates its Task; missing rows here imply an explicit id. const taskId = params.taskId ?? '' throw taskNotFoundError(`Task ${taskId} was not found.`, { taskId }) } + const peerRun = this.getPeerRun(task.run_id) + if (peerRun?.state === 'closed') { + // Starts, follow-ups and helpers alike: its coordinator cancelled it. + throw new OrchestrationError( + 'run_not_found', + `Run ${task.run_id} was cancelled by ${peerRun.home_label}. No effects were applied.`, + { effectsApplied: false } + ) + } let priorFailures = 0 if (params.retryOf) { const prior = this.getDispatchContextById(params.retryOf) @@ -251,3 +269,30 @@ export function attachWorkerDispatchStart(ctor: { prototype: object }): void { createStartingWorkerDispatch }) } + +/** A Task its paired coordinator reopened gets a new attempt here once its latest attempt settled. */ +function reopenSettledAdoptedTask( + db: OrchestrationDb, + task: TaskRow, + runId: string | undefined +): void { + if (task.run_id !== runId) { + throw taskNotFoundError(`Task ${task.id} was not found in Run ${runId ?? ''}.`, { + taskId: task.id + }) + } + const latest = db.getDispatchContext(task.id) + const worker = latest ? db.getWorkerDispatch(latest.id) : undefined + const settled = worker + ? ['succeeded', 'failed', 'stopped', 'abandoned'].includes(worker.state) + : !latest || ['completed', 'failed', 'circuit_broken'].includes(latest.status) + // A pending Task still waits on its dependencies; the ordinary promotion readies it. + if (settled && task.status !== 'ready' && task.status !== 'pending') { + transitionLifecycleWithDb(db.db, { + entity: 'task', + id: task.id, + from: task.status, + to: 'ready' + }) + } +} diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-listing.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-listing.ts index 3e625c2126c..c2ce51ded5e 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-listing.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-listing.ts @@ -103,6 +103,8 @@ export function listWorkerTerminalResources( pendingApproval: boolean terminationReason: TerminalExitCause['kind'] | null resource: WorkerTerminalResourceRow | null + /** The paired Orca's name when its Run here coordinates this worker. */ + coordinatedFrom: string | null createdAt: string databaseId: number }[] { @@ -170,6 +172,7 @@ export function listWorkerTerminalResources( t.parent_id AS parent_task_id, d.task_id, d.run_id, d.status AS dispatch_status, d.termination_reason, + (SELECT home_label FROM peer_runs WHERE run_id = d.run_id) AS coordinated_from, EXISTS ( SELECT 1 FROM question_threads q WHERE q.dispatch_id = d.id AND q.status = 'pending' @@ -199,6 +202,7 @@ export function listWorkerTerminalResources( termination_reason: TerminalExitCause['kind'] | null pending_input: number pending_approval: number + coordinated_from: string | null created_at: string database_id: number }[] @@ -236,6 +240,7 @@ export function listWorkerTerminalResources( pendingApproval: row.pending_approval === 1, terminationReason: row.termination_reason, resource, + coordinatedFrom: row.coordinated_from, createdAt: row.created_at, databaseId: row.database_id } diff --git a/src/main/runtime/orchestration/federation/peer-mail/peer-mail-waiters.ts b/src/main/runtime/orchestration/federation/peer-mail/peer-mail-waiters.ts index 0e2b13c329d..5b88ac6206d 100644 --- a/src/main/runtime/orchestration/federation/peer-mail/peer-mail-waiters.ts +++ b/src/main/runtime/orchestration/federation/peer-mail/peer-mail-waiters.ts @@ -1,17 +1,18 @@ -import type { OrchestrationDb } from '../../db/orchestration-db' +import type Database from '../../../../sqlite/sync-database' -const waiters = new WeakMap void>>() +const waiters = new WeakMap void>>() -/** Wakes every pull waiting on this store; each re-reads its own conversations. */ -export function notifyPeerMailQueued(db: OrchestrationDb): void { +/** Wakes every pull waiting on this database; each re-reads its own conversations. Called by every + * write that can queue an item home: a reply, a message filed in a Run, a lifecycle change, a reset. */ +export function notifyPeerMailQueued(db: Database.Database): void { for (const wake of waiters.get(db) ?? []) { wake() } } -/** Resolves when a reply is queued, the wait ends, or the caller goes away. */ +/** Resolves when an item may have been queued, the wait ends, or the caller goes away. */ export function waitForPeerMail( - db: OrchestrationDb, + db: Database.Database, timeoutMs: number, signal?: AbortSignal ): Promise { diff --git a/src/main/runtime/orchestration/legacy-remote-home/federated-worker-child-parent.ts b/src/main/runtime/orchestration/legacy-remote-home/federated-worker-child-parent.ts index e0249544e10..be0580a0127 100644 --- a/src/main/runtime/orchestration/legacy-remote-home/federated-worker-child-parent.ts +++ b/src/main/runtime/orchestration/legacy-remote-home/federated-worker-child-parent.ts @@ -1,5 +1,5 @@ import type { OrcaRuntimeService } from '../../orca-runtime' -import type { OrchestrationSessionCaller } from '../addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../addressing/orchestration-caller-identity' import { OrchestrationError } from '../orchestration-error' import { readCallerStoreInstallationId } from '../../hosts/platform-probe/host-descriptor' import { getCanonicalUserDataPath } from '../../../persistence/loading-store/user-data-path' @@ -22,7 +22,7 @@ export async function resolveFederatedChildParent(args: { | 'getRuntimeId' > from: string - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined serverCapabilities: readonly string[] | undefined serverName: string }): Promise< diff --git a/src/main/runtime/orchestration/legacy-remote-home/federated-worker-start-request.ts b/src/main/runtime/orchestration/legacy-remote-home/federated-worker-start-request.ts index 21dd2d80613..d4a6950dfc6 100644 --- a/src/main/runtime/orchestration/legacy-remote-home/federated-worker-start-request.ts +++ b/src/main/runtime/orchestration/legacy-remote-home/federated-worker-start-request.ts @@ -1,4 +1,4 @@ -import type { OrchestrationSessionCaller } from '../addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../addressing/orchestration-caller-identity' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../db/orchestration-db' import type { WorkerStartInput } from '../../rpc/methods/orchestration/worker/worker-start-schema' @@ -14,5 +14,5 @@ export type FederatedWorkerStartRequest = { taskDeps: string[] | undefined orchestrationMutation?: WorkerStartMutation /** The coordinator's resolved session, when it is one; recorded as the Dispatch creator. */ - callerSession?: OrchestrationSessionCaller + callerSession?: OrchestrationEntryCaller } diff --git a/src/main/runtime/orchestration/workers/launch/worker-dispatch-preamble.ts b/src/main/runtime/orchestration/workers/launch/worker-dispatch-preamble.ts index bc1fa776441..a68c56d5f2e 100644 --- a/src/main/runtime/orchestration/workers/launch/worker-dispatch-preamble.ts +++ b/src/main/runtime/orchestration/workers/launch/worker-dispatch-preamble.ts @@ -10,6 +10,8 @@ export type PreambleParams = { taskSpec: string /** A terminal handle, or a session's `orca_session_id:`; each is labelled by its kind. */ coordinatorHandle: string + /** The paired Orca's name when it coordinates this worker's Run from another computer. */ + coordinatedFrom?: string workerHandle: string devMode?: boolean // Packaged WSL uses orca-ide; other hosts retain their existing launcher. @@ -38,9 +40,11 @@ function isSessionHandle(handle: string): boolean { /** Terminal agents keep their handle's wording; only a session is named by its Orca session ID. */ function dispatchIdentityLines(params: PreambleParams): string { return [ - isSessionHandle(params.coordinatorHandle) - ? `Your coordinator's Orca session ID is: ${params.coordinatorHandle}` - : `Your coordinator's terminal handle is: ${params.coordinatorHandle}`, + params.coordinatedFrom + ? `You are coordinated from ${params.coordinatedFrom}. Your coordinator's address here is: ${params.coordinatorHandle}` + : isSessionHandle(params.coordinatorHandle) + ? `Your coordinator's Orca session ID is: ${params.coordinatorHandle}` + : `Your coordinator's terminal handle is: ${params.coordinatorHandle}`, `Your task ID is: ${params.taskId}`, ...(isSessionHandle(params.workerHandle) ? [`Your Orca session ID is: ${params.workerHandle}`] diff --git a/src/main/runtime/orchestration/workers/launch/worker-start-card.ts b/src/main/runtime/orchestration/workers/launch/worker-start-card.ts index 7f114fd9544..c68dca11f1a 100644 --- a/src/main/runtime/orchestration/workers/launch/worker-start-card.ts +++ b/src/main/runtime/orchestration/workers/launch/worker-start-card.ts @@ -6,7 +6,7 @@ import type { import type { AgentMessageSender } from '../../../../../shared/agent-session/agent-session-message-source' import type { AgentType } from '../../../../../shared/agent-status/contracts/agent-status-types' import type { OrcaRuntimeService } from '../../../orca-runtime' -import type { OrchestrationSessionCaller } from '../../addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../addressing/orchestration-caller-identity' export type AgentCreationCard = AgentJournalAgentStartItem | AgentJournalAgentHandoffItem @@ -48,7 +48,7 @@ export function startedAgentSender( export function prepareWorkerStartCard( runtime: OrcaRuntimeService, - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined ): (args: { dispatchId: string workerAddress: string @@ -56,7 +56,10 @@ export function prepareWorkerStartCard( /** What this start launched; the only kind known for a worker on another host. */ agent: AgentType | null }) => void { - const publish = prepareAgentCreationCard(callerSession?.sessionId) + // A paired Orca has no chat here to show the card in. + const publish = prepareAgentCreationCard( + callerSession?.kind === 'peer' ? undefined : callerSession?.sessionId + ) if (!publish) { return () => {} } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 729f5cf8e54..f135d2ae3f8 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -20,7 +20,7 @@ import type { DelegatedMobileDeviceSyncResult } from '../../../shared/delegated-mobile-device-contract' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration/orchestration-compatibility-evidence' -import type { OrchestrationSessionCaller } from '../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../orchestration/addressing/orchestration-caller-identity' import type { RpcCallerIdentity } from './rpc-caller-identity' import type { OrcadPeerDesktopsResult } from '../../../shared/orcad-peer-desktops-contract' import type { RpcBinarySender } from './rpc-binary-sender' @@ -91,6 +91,8 @@ export type RpcRequest = { params?: unknown orchestrationContractVersion?: number orchestrationRequestId?: string + // Untrusted wire input; only a paired Orca's runtime pairing admits it (orchestration-peer-caller). + orchestrationPeerRun?: unknown compatibilityInvocationId?: string orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence // Untrusted wire evidence; runtime-source-admission validates it. @@ -151,8 +153,8 @@ export type RpcContext = { replayedMutationReceipt?: unknown // Compare declared Run handles against request attestation. orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence - // Resolve the injected Orca session id once at dispatch; session identity wins. - orchestrationCaller?: OrchestrationSessionCaller + // Resolved once at dispatch from a session's environment or a paired Orca's pairing; it wins. + orchestrationCaller?: OrchestrationEntryCaller // Only the trusted compatibility router sets this; params cannot bypass Run consumer binding. legacyCoordinatorRunId?: string legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 551b7db074d..21b77e43870 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -1,6 +1,6 @@ import type { OrcaRuntimeService } from '../orca-runtime' import { captureStructuredAgentCliSource } from '../../native-chat/structured-agent-cli-source' -import type { OrchestrationSessionCaller } from '../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../orchestration/addressing/orchestration-caller-identity' import { isRegistrationFencedUnsubscribe, type RpcContext, type RpcRequest } from './core' import { resolveRpcCallerIdentity } from './rpc-caller-identity' import type { RpcCallerScope } from './rpc-caller-scope' @@ -41,7 +41,7 @@ export function rpcContextFromTransport( runtime: OrcaRuntimeService, request: RpcRequest, options: RpcDispatchStreamingOptions | undefined, - orchestrationCaller: OrchestrationSessionCaller | undefined, + orchestrationCaller: OrchestrationEntryCaller | undefined, params?: unknown ): RpcContext { return { diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index a4daf612975..8ad691b1fda 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -15,7 +15,7 @@ import { withoutMethodsThisHostDoesNotServe } from './methods/terminal/session-t import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' import { isDurableMutation } from '../../../shared/orchestration/orchestration-rpc-contract' -import type { OrchestrationSessionCaller } from '../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../orchestration/addressing/orchestration-caller-identity' import { getOrchestrationMutationExecutor, type DurableMutationInvocation, @@ -45,7 +45,7 @@ type AdmittedUnaryCall = { request: RpcRequest method: RpcMethod params: unknown - caller: OrchestrationSessionCaller | undefined + caller: OrchestrationEntryCaller | undefined } export class RpcDispatcher { @@ -231,7 +231,11 @@ export class RpcDispatcher { return desktopBrowser.result } - const compatibility = await legacyOrchestration.tryHandle(request, params, context.signal) + // A paired Orca is never a legacy caller, so its calls reach only the declared access check. + const compatibility = + caller?.kind === 'peer' + ? { handled: false as const } + : await legacyOrchestration.tryHandle(request, params, context.signal) if (compatibility.handled) { return compatibility.result } @@ -275,7 +279,7 @@ export class RpcDispatcher { effectiveParams, invoke, callerFingerprint, - context.orchestrationCaller?.orcaSessionId + context.orchestrationCaller ) if (request.method.startsWith('orchestration.')) { queueMicrotask(() => { diff --git a/src/main/runtime/rpc/methods/orchestration/access/orchestration-access-outcomes.test.ts b/src/main/runtime/rpc/methods/orchestration/access/orchestration-access-outcomes.test.ts index 0165250f962..694ff102ae1 100644 --- a/src/main/runtime/rpc/methods/orchestration/access/orchestration-access-outcomes.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/access/orchestration-access-outcomes.test.ts @@ -223,6 +223,7 @@ const CASES = { }) }, peerMailPull: { method: 'orchestration.peerMailPull', params: () => ({ waitMs: 0 }) }, + peerRunCancel: { method: 'orchestration.peerRunCancel', params: () => ({}) }, replyHelperQuestion: { method: 'orchestration.reply', params: (from, ids) => ({ id: ids.helperQuestion, body: 'a', from }) @@ -376,6 +377,7 @@ const EXPECTED: Record & Record = { 'mail_requires_server_pairing mail_requires_server_pairing mail_requires_server_pairing mail_requires_server_pairing', peerMailPull: 'mail_requires_server_pairing mail_requires_server_pairing mail_requires_server_pairing mail_requires_server_pairing', + peerRunCancel: 'forbidden forbidden forbidden forbidden', replyHelperQuestion: 'ok consumer_fenced consumer_fenced ok', replyMail: 'ok ok ok ok', replyQuestion: 'ok consumer_fenced consumer_fenced worker_cannot_coordinate_own_run', diff --git a/src/main/runtime/rpc/methods/orchestration/access/orchestration-method-access.ts b/src/main/runtime/rpc/methods/orchestration/access/orchestration-method-access.ts index 01c58ffda86..c36f9dd7336 100644 --- a/src/main/runtime/rpc/methods/orchestration/access/orchestration-method-access.ts +++ b/src/main/runtime/rpc/methods/orchestration/access/orchestration-method-access.ts @@ -3,6 +3,11 @@ import { types } from 'node:util' import type { ZodType } from 'zod' import type { RpcContext, RpcTypedMethod, RpcTypedStreamingMethod } from '../../../core' +import { + checkPeerAccess, + refusePeerAddressClaim, + refuseServerCallFromPeerRun +} from './peer-run-access' type ParsedParams = TSchema extends ZodType ? TSchema['_output'] @@ -28,6 +33,9 @@ export type OrchestrationSubjectKind = /** A field naming who is calling, as opposed to what the call acts on. */ export type OrchestrationCallerField = 'from' | 'terminal' | 'callerTerminalHandle' +/** The pane key a caller sends with its caller field, which only its own host can fill. */ +export type OrchestrationCallerPaneField = 'terminalPaneKey' | 'senderPaneKey' + type FieldOf

= P extends object ? Extract : never export type OrchestrationAccessRuleInput

= { @@ -43,6 +51,9 @@ export type OrchestrationAccessRule = (input: OrchestrationAccessRuleInput export type OrchestrationMethodAccess = { readonly kind: OrchestrationAccessKind readonly caller?: Extract, OrchestrationCallerField> + readonly callerPane?: Extract, OrchestrationCallerPaneField> + /** The field that sends the call on to a paired server (`--on`). */ + readonly server?: Extract, 'on'> /** Every field naming something the call acts on, kept apart from the caller field. */ readonly targets: Readonly, OrchestrationSubjectKind>>> /** A read returning a collection; a narrower caller restricts its query, never its page. */ @@ -54,32 +65,55 @@ export type OrchestrationMethodAccess = { export type OrchestrationMethodAccessDeclaration = { readonly kind: OrchestrationAccessKind readonly caller?: OrchestrationCallerField + readonly callerPane?: OrchestrationCallerPaneField + readonly server?: 'on' readonly targets: Readonly>> readonly collection?: true readonly rule?: OrchestrationAccessRule } -/** The one access check: every caller on this Orca is held to the method's own rule. */ +/** The one access check: every caller on this Orca is held to the method's own rule, and a paired + * Orca also to the Run it coordinates here. Returns the params the method runs with and its grant. */ export function checkOrchestrationAccess( access: OrchestrationMethodAccess, params: P, ctx: RpcContext -): G { +): { params: P; grant: G } { + const caller = ctx.orchestrationCaller + if (caller?.kind !== 'peer') { + refusePeerAddressClaim(access.caller && fieldValue(access.caller, params)) + refusePeerAddressClaim(access.callerPane && fieldValue(access.callerPane, params)) + } + const scoped = + caller?.kind === 'peer' + ? checkPeerAccess(access, params, ctx.runtime.getOrchestrationDb(), caller) + : params + if (access.server && fieldValue(access.server, scoped) !== undefined) { + refuseServerCallFromPeerRun(ctx, declaredCaller(access.caller, scoped)) + } if (!access.rule) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: G defaults to undefined exactly when a method declares no rule. - return undefined as G + return { params: scoped, grant: undefined as G } + } + return { + params: scoped, + grant: access.rule({ params: scoped, ctx, caller: declaredCaller(access.caller, scoped) }) } - return access.rule({ params, ctx, caller: declaredCaller(access.caller, params) }) } function declaredCaller

( field: Extract, OrchestrationCallerField> | undefined, params: P ): string | undefined { - if (field === undefined) { - return undefined - } - const value: unknown = params[field] + return field === undefined ? undefined : fieldValue(field, params) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function fieldValue(field: string, params: unknown): string | undefined { + const value: unknown = isRecord(params) ? params[field] : undefined return typeof value === 'string' ? value : undefined } @@ -154,7 +188,8 @@ export function defineOrchestrationMethod< // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a replay returns the receipt this handler produced when the call was accepted; a promise-returning handler's wrapper is async. return replayed.receipt as TResult } - return handler(params, ctx, checkOrchestrationAccess(access, params, ctx)) + const checked = checkOrchestrationAccess(access, params, ctx) + return handler(checked.params, ctx, checked.grant) }) } } @@ -191,8 +226,10 @@ export function defineOrchestrationStreamingMethod< handler: checked( handler, declaration, - (params: ParsedParams, ctx: RpcContext, emit: (result: unknown) => void) => - handler(params, ctx, emit, checkOrchestrationAccess(access, params, ctx)) + (params: ParsedParams, ctx: RpcContext, emit: (result: unknown) => void) => { + const checked = checkOrchestrationAccess(access, params, ctx) + return handler(checked.params, ctx, emit, checked.grant) + } ) } } diff --git a/src/main/runtime/rpc/methods/orchestration/access/peer-run-access.ts b/src/main/runtime/rpc/methods/orchestration/access/peer-run-access.ts new file mode 100644 index 00000000000..6755ba30dbb --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/access/peer-run-access.ts @@ -0,0 +1,208 @@ +import type { OrchestrationDb } from '../../../../orchestration/db/orchestration-db' +import { + isPeerCoordinatorAddress, + type OrchestrationPeerCaller +} from '../../../../orchestration/addressing/orchestration-caller-identity' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' +import type { RpcContext } from '../../../core' +import { resolveDispatchCreator } from '../runs/dispatch-creator' +import type { + OrchestrationMethodAccessDeclaration, + OrchestrationSubjectKind +} from './orchestration-method-access' + +const NO_EFFECTS = { effectsApplied: false } as const + +/** Kinds named by an ID: one this Orca does not have is in no Run, and the method answers it as + * not found. Addresses also name terminals and sessions that work in no Run, so they must resolve. */ +const ID_KINDS: ReadonlySet = new Set([ + 'dispatch', + 'task', + 'message', + 'gate' +]) + +/** + * A paired Orca acts only inside the Run it coordinates here: every subject a call names must be in + * that Run, and a collection read is narrowed to it before its query runs, so counts and cursors + * stay right. Calls that act on this Orca as a whole are this Orca's own. Returns the params the + * method runs with. + */ +export function checkPeerAccess

( + access: OrchestrationMethodAccessDeclaration, + params: P, + db: OrchestrationDb, + peer: OrchestrationPeerCaller +): P { + const { run_id: runId, home_label: label } = peer.peerRun + if (access.kind === 'localOnly') { + throw new OrchestrationError( + 'forbidden', + `This command acts on this server as a whole, so only this server can run it, not ${label}. No effects were applied.`, + NO_EFFECTS + ) + } + const values: Record = + params && typeof params === 'object' ? { ...(params as Record) } : {} + for (const [field, kind] of Object.entries(access.targets)) { + for (const subject of subjectsOf(values[field])) { + const subjectRunId = kind && resolveSubjectRunId(db, kind, subject, peer) + if (kind && subjectRunId !== runId && !(subjectRunId === undefined && ID_KINDS.has(kind))) { + throw new OrchestrationError( + 'forbidden', + `${subject} is not in the Run ${label} coordinates here. No effects were applied.`, + NO_EFFECTS + ) + } + } + } + if (access.collection) { + const runField = Object.entries(access.targets).find(([, kind]) => kind === 'run')?.[0] + if (!runField) { + throw new OrchestrationError( + 'forbidden', + `This list spans every Run on this server, so ${label} cannot read it. No effects were applied.`, + NO_EFFECTS + ) + } + values[runField] = runId + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the same params with at most the declared Run field set to a string, which every Run field accepts. + return (params && typeof params === 'object' ? values : params) as P +} + +function subjectsOf(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === 'string') + } + if (typeof value !== 'string' || value === '') { + return [] + } + if (!value.startsWith('[') && !value.startsWith('{')) { + return [value] + } + // A JSON list of IDs (`--deps`), or send's JSON payload, which names its Dispatch by `dispatchId`. + let parsed: unknown + try { + parsed = JSON.parse(value) + } catch { + return [value] + } + if (Array.isArray(parsed)) { + return subjectsOf(parsed) + } + const dispatchId = + parsed && typeof parsed === 'object' && 'dispatchId' in parsed ? parsed.dispatchId : undefined + return typeof dispatchId === 'string' ? [dispatchId] : [] +} + +/** The Run a subject belongs to on this Orca, or undefined when it names nothing here. */ +export function resolveSubjectRunId( + db: OrchestrationDb, + kind: OrchestrationSubjectKind, + subject: string, + peer: OrchestrationPeerCaller +): string | undefined { + switch (kind) { + case 'run': + return subject + case 'dispatch': + return db.getDispatchContextById(subject)?.run_id + case 'task': + return db.getTask(subject)?.run_id + case 'message': + return db.getMessageById(subject)?.run_id + case 'gate': + return db.getGate(subject)?.run_id + case 'request': + // Receipts are keyed by the pairing, so they are its own; the same paired Orca may read another of its Runs' receipts. + return peer.peerRun.run_id + case 'worker': + case 'session': + return assigneeRunId(db, subject) + case 'party': + return partyRunId(db, subject, peer) + case 'conversation': + return undefined + } +} + +function partyRunId( + db: OrchestrationDb, + address: string, + peer: OrchestrationPeerCaller +): string | undefined { + if (address === peer.address) { + return peer.peerRun.run_id + } + if (address.startsWith('run:')) { + return address.slice('run:'.length) + } + if (address.startsWith('dispatch:')) { + return db.getDispatchContextById(address.slice('dispatch:'.length))?.run_id + } + // A group spans Runs, so it never resolves into one. + return address.startsWith('@') ? undefined : assigneeRunId(db, address) +} + +/** The Run of the newest Dispatch a terminal, pane or session was given. */ +function assigneeRunId(db: OrchestrationDb, subject: string): string | undefined { + const session = parseOrcaSessionAddress(subject) ?? subject + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT of one column returns exactly that column. + const row = db.db + .prepare( + `SELECT run_id FROM dispatch_contexts + WHERE assignee_handle = ?1 OR assignee_pane_key = ?1 OR assignee_orca_session_id = ?2 + ORDER BY rowid DESC LIMIT 1` + ) + .get(subject, session) as { run_id: string } | undefined + return row?.run_id +} + +/** Only a paired Orca's own frames speak as it: its address is predictable, so no other caller may + * declare it as its handle or pane and read or bind its Run's mail. */ +export function refusePeerAddressClaim(declared: string | undefined): void { + if (isPeerCoordinatorAddress(declared)) { + throw new OrchestrationError( + 'forbidden', + `${declared} is a paired Orca's coordinator address, and only that Orca can act as it. No effects were applied.`, + NO_EFFECTS + ) + } +} + +/** Pairing is one-way: work running in a paired Orca's Run, its helpers' included, never calls on + * to another server, and neither does that Orca through this one. */ +export function refuseServerCallFromPeerRun( + ctx: RpcContext, + callerHandle: string | undefined +): void { + const db = ctx.runtime.getOrchestrationDb() + const caller = ctx.orchestrationCaller + // Most servers coordinate no paired Orca's Run, and then the creator lookup (which can refuse) is skipped. + const peerRun = + caller?.kind === 'peer' + ? caller.peerRun + : db.hasPeerRuns() + ? peerRunOfCallerWork(ctx, db, callerHandle) + : undefined + if (peerRun) { + throw new OrchestrationError( + 'forbidden', + `This work is coordinated from ${peerRun.home_label}, so it cannot use --on. Ask that coordinator instead. No effects were applied.`, + NO_EFFECTS + ) + } +} + +function peerRunOfCallerWork( + ctx: RpcContext, + db: OrchestrationDb, + callerHandle: string | undefined +): ReturnType { + const creator = resolveDispatchCreator(ctx.runtime, callerHandle, ctx.orchestrationCaller) + const dispatchId = db.resolveCreatorDispatchId(creator) + const runId = dispatchId ? db.getDispatchContextById(dispatchId)?.run_id : undefined + return runId ? db.getPeerRun(runId) : undefined +} diff --git a/src/main/runtime/rpc/methods/orchestration/caller-show.ts b/src/main/runtime/rpc/methods/orchestration/caller-show.ts index 9ee58a20e4d..5d0acbef31b 100644 --- a/src/main/runtime/rpc/methods/orchestration/caller-show.ts +++ b/src/main/runtime/rpc/methods/orchestration/caller-show.ts @@ -75,9 +75,13 @@ export const ORCHESTRATION_CALLER_METHODS = [ _params, { orchestrationCaller, orchestrationCompatibilityEvidence, runtime } ): OrchestrationCallerShowResult => ({ - caller: orchestrationCaller - ? { orcaSessionId: formatOrcaSessionAddress(orchestrationCaller.orcaSessionId), live: true } - : null, + caller: + orchestrationCaller && orchestrationCaller.kind !== 'peer' + ? { + orcaSessionId: formatOrcaSessionAddress(orchestrationCaller.orcaSessionId), + live: true + } + : null, // An Orca-launched agent's own pane and launch secret prove it is one; no hook needs to have fired. terminal: !orchestrationCaller && diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts index c0e0e2f50a8..5d4c9640030 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-methods.ts @@ -4,10 +4,12 @@ import { ORCHESTRATION_FEDERATION_RELAY_METHODS } from '../../../../orchestration/legacy-remote/legacy-remote' import { ORCHESTRATION_PEER_MAIL_METHODS } from './peer-mail-methods' +import { ORCHESTRATION_PEER_RUN_METHODS } from './peer-run-methods' export const ORCHESTRATION_FEDERATION_METHODS = [ ...ORCHESTRATION_FEDERATION_ATTACH_METHODS, ...ORCHESTRATION_FEDERATION_RELAY_METHODS, ...ORCHESTRATION_FEDERATION_CONTROL_METHODS, - ...ORCHESTRATION_PEER_MAIL_METHODS + ...ORCHESTRATION_PEER_MAIL_METHODS, + ...ORCHESTRATION_PEER_RUN_METHODS ] diff --git a/src/main/runtime/rpc/methods/orchestration/federation/peer-coordinator.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/peer-coordinator.test.ts new file mode 100644 index 00000000000..8d06f4d4123 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/peer-coordinator.test.ts @@ -0,0 +1,437 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../../../shared/orchestration/orchestration-runtime-capabilities' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db/orchestration-db' +import { generateId } from '../../../../orchestration/db/generated-id' +import { createRootDispatch } from '../../../../orchestration/db/root-dispatch-test-fixture' +import { RpcDispatcher } from '../../../dispatcher' +import type { RpcResponse } from '../../../core' +import { ORCHESTRATION_METHODS } from '../orchestration' +import { startWorker } from '../worker/workers' +import { peerCoordinatorCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' + +const WORKER_PANE = 'tab_w:11111111-1111-4111-8111-111111111111' +const OTHER_PANE = 'tab_o:22222222-2222-4222-8222-222222222222' +const LOCAL_PANE = 'tab_l:33333333-3333-4333-8333-333333333333' +const PAIRED = { + authenticatedCallerFingerprint: 'fingerprint_a', + pairedDeviceId: 'device_a', + clientKind: 'runtime' as const +} + +describe('a paired Orca coordinating its Run on this server', () => { + let dir: string + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let dispatcher: RpcDispatcher + let conversationId: string + let sequence = 0 + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-peer-coordinator-')) + db = new OrchestrationDb(join(dir, 'b.db')) + runtime = new OrcaRuntimeService(null) + const panes: Record = { + term_w: WORKER_PANE, + term_o: OTHER_PANE, + term_l: LOCAL_PANE + } + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => panes[handle] ?? null) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation( + (handle) => panes[handle] ?? null + ) + runtime.setOrchestrationDb(db) + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + conversationId = generateId('peerconv') + }) + + afterEach(() => { + db.close() + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + vi.useRealTimers() + }) + + function peerCall( + method: string, + params: Record, + options: { requestId?: string; conversation?: string } = {} + ): Promise { + sequence += 1 + return dispatcher.dispatch( + { + id: `peer_${sequence}`, + authToken: 'token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationPeerRun: { + runId: 'run_home', + conversationId: options.conversation ?? conversationId, + label: 'devA' + }, + ...(options.requestId ? { orchestrationRequestId: options.requestId } : {}) + }, + PAIRED + ) + } + + function localCall(method: string, params: Record): Promise { + sequence += 1 + return dispatcher.dispatch({ + id: `local_${sequence}`, + authToken: 'token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }) + } + + function peerRunId(): string { + return db.getPeerRunForConversation(conversationId)!.run_id + } + + /** The peer's Run with one worker in it, and a local Run with another. */ + async function twoRuns() { + await peerCall('orchestration.runCurrent', {}) + const peerTask = db.createTask({ spec: 'peer work', runId: peerRunId() }) + const peerWork = createRootDispatch(db, peerTask.id, 'term_w', WORKER_PANE) + const localRun = db.createRun({ + objective: 'local', + coordinatorHandle: 'term_l', + coordinatorPaneKey: LOCAL_PANE + }) + const localTask = db.createTask({ spec: 'local work', runId: localRun.id }) + const localWork = createRootDispatch(db, localTask.id, 'term_o', OTHER_PANE) + return { peerWork, localRun, localWork } + } + + async function pull(id: string, params: Record = {}) { + const response = await dispatcher.dispatch( + { + id, + authToken: 'token', + method: 'orchestration.peerMailPull', + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }, + PAIRED + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pull's result schema; a refusal reads as an empty page. + return (response.ok ? response.result : { items: [] }) as { + items: { conversationId: string; kind: string }[] + } + } + + function refused(response: RpcResponse): string | undefined { + return response.ok ? undefined : response.error.code + } + + it("writes the peer's Run when its frame arrives, before the call awaits anything", () => { + const pending = peerCall('orchestration.runCurrent', {}) + const run = db.getPeerRunForConversation(conversationId) + expect(run).toMatchObject({ home_run_id: 'run_home', home_label: 'devA', state: 'open' }) + expect(db.getRun(run!.run_id)).toBeDefined() + return pending + }) + + it('admits a peer Run only from a runtime pairing', async () => { + sequence += 1 + const response = await dispatcher.dispatch( + { + id: 'mobile', + authToken: 'token', + method: 'orchestration.runCurrent', + params: {}, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationPeerRun: { runId: 'run_home', conversationId, label: 'devA' } + }, + { ...PAIRED, clientKind: 'mobile' } + ) + expect(refused(response)).toBe('mail_requires_server_pairing') + expect(db.getPeerRunForConversation(conversationId)).toBeUndefined() + }) + + it('refuses commands that act on this server as a whole', async () => { + const { localRun } = await twoRuns() + expect(refused(await peerCall('orchestration.reset', { all: true }))).toBe('forbidden') + expect(refused(await peerCall('orchestration.runUse', { id: localRun.id }))).toBe('forbidden') + expect(db.getRun(localRun.id)).toBeDefined() + }) + + it("keeps another Run's worker out of reach by terminal, pane or session, but not its own", async () => { + const { peerWork, localWork } = await twoRuns() + db.db + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run('33333333-3333-4333-8333-333333333334', localWork.id) + + for (const target of [ + { terminal: 'term_o' }, + { paneKey: OTHER_PANE }, + { sessionId: '33333333-3333-4333-8333-333333333334' } + ]) { + expect( + refused(await peerCall('orchestration.workerKeepOpen', { ...target, keep: true })) + ).toBe('forbidden') + } + expect(peerWork.assignee_handle).toBe('term_w') + expect( + (await peerCall('orchestration.workerKeepOpen', { terminal: 'term_w', keep: true })).ok + ).toBe(true) + }) + + it('reads only its own Run from an unqualified worker list and inbox', async () => { + const { peerWork, localRun } = await twoRuns() + db.insertMessage({ + from: 'term_w', + to: `run:${peerRunId()}`, + subject: 'mine', + runId: peerRunId() + }) + db.insertMessage({ + from: 'term_o', + to: `run:${localRun.id}`, + subject: 'theirs', + runId: localRun.id + }) + + const list = await peerCall('orchestration.workerList', { paginate: true }) + const inbox = await peerCall('orchestration.inbox', {}) + + expect(list.ok && list.result).toMatchObject({ + workers: [{ dispatchId: peerWork.id, coordinatedFrom: 'devA' }], + page: { total: 1 } + }) + expect(inbox.ok && inbox.result).toMatchObject({ messages: [{ subject: 'mine' }], count: 1 }) + }) + + it('sends and replies only inside its own Run, by any address form', async () => { + const { localRun, localWork } = await twoRuns() + const question = db.insertMessage({ + from: 'term_o', + to: `run:${localRun.id}`, + subject: 'Question', + type: 'question', + runId: localRun.id + }) + for (const to of ['term_o', `dispatch:${localWork.id}`, `run:${localRun.id}`, '@all']) { + expect(refused(await peerCall('orchestration.send', { to, subject: 'hi' }))).toBe('forbidden') + } + expect(refused(await peerCall('orchestration.reply', { id: question.id, body: 'yes' }))).toBe( + 'forbidden' + ) + }) + + it('cannot reach its former worker once that terminal coordinates local work', async () => { + await twoRuns() + db.createRun({ + objective: 'later local', + coordinatorHandle: 'term_w', + coordinatorPaneKey: WORKER_PANE + }) + expect(refused(await peerCall('orchestration.send', { to: 'term_w', subject: 'hi' }))).toBe( + 'forbidden' + ) + }) + + it('refuses a start that would place in its workspace, which it has none of here', async () => { + for (const worktree of ['current', 'new-child']) { + const response = await peerCall('orchestration.workerStart', { spec: 'work', worktree }) + expect(response.ok ? undefined : response.error).toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('--on requires an exact remote worktree selector') + }) + } + }) + + it('follows up with a finished worker where that worker ran, though it has no workspace here', async () => { + await peerCall('orchestration.runCurrent', {}) + const peer = peerCoordinatorCaller(db.getPeerRunForConversation(conversationId)!) + const placed = startWorker( + { from: peer.address, run: peerRunId(), spec: 'One more thing' }, + { + runtime, + orchestrationMutation: undefined, + orchestrationCompatibilityEvidence: undefined, + orchestrationCaller: peer + }, + { + of: 'ctx_finished', + addressed: 'ctx_finished', + place: async () => { + throw new Error('placed where the worker ran') + } + } + ) + await expect(placed).rejects.toThrow('placed where the worker ran') + }) + + it("never answers one Run's cancel with another Run's receipt", async () => { + const other = generateId('peerconv') + await peerCall('orchestration.peerRunCancel', {}, { requestId: 'cancel:1.1' }) + const second = await peerCall( + 'orchestration.peerRunCancel', + {}, + { requestId: 'cancel:1.1', conversation: other } + ) + expect(second.ok && second.result).not.toMatchObject({ mutation: { replayed: true } }) + }) + + it('stays the only coordinator of its Run: a local terminal cannot bind it', async () => { + await twoRuns() + const response = await localCall('orchestration.runUse', { id: peerRunId(), from: 'term_l' }) + expect(refused(response)).toBe('consumer_fenced') + expect(db.getRun(peerRunId())?.coordinator_handle).toBe(`peer:${conversationId}`) + }) + + it('cannot be impersonated by a local caller declaring its address', async () => { + await twoRuns() + const address = `peer:${conversationId}` + const mail = db.insertMessage({ + from: 'term_w', + to: address, + subject: 'done', + runId: peerRunId() + }) + const check = await localCall('orchestration.check', { + terminal: address, + terminalPaneKey: address + }) + expect(refused(check)).toBe('forbidden') + expect(db.getMessageById(mail.id)?.read).toBe(0) + const send = await localCall('orchestration.send', { + from: 'term_l', + senderPaneKey: address, + to: 'term_w', + subject: 'hi' + }) + expect(refused(send)).toBe('forbidden') + }) + + it('keeps work in its Run, helpers included, from calling on to another server', async () => { + const { localRun } = await twoRuns() + const helperTask = db.createTask({ spec: 'helper', runId: peerRunId() }) + createRootDispatch(db, helperTask.id, 'term_l', LOCAL_PANE) + for (const [method, params] of [ + ['orchestration.send', { from: 'term_w', to: 'term_x', subject: 'hi', on: 'devC' }], + ['orchestration.workerStart', { from: 'term_l', spec: 'more', on: 'devC' }] + ] as const) { + const response = await localCall(method, params) + expect(response.ok ? undefined : response.error.message).toMatch(/coordinated from devA/) + } + expect(localRun.id).toBeDefined() + }) + + it('cancels over the ordinary stop and answers what it could not yet prove stopped', async () => { + await peerCall('orchestration.runCurrent', {}) + const task = db.createTask({ spec: 'peer work', runId: peerRunId() }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 5 + }) + const remaining = async (requestId: string) => { + const response = await peerCall('orchestration.peerRunCancel', {}, { requestId }) + return response.ok ? response.result : response.error + } + + // A worker still starting cannot be stopped yet, so the cancel is owed again. + expect(await remaining('cancel:1.1')).toMatchObject({ remaining: 1 }) + expect(db.getPeerRunForConversation(conversationId)?.state).toBe('closed') + db.transitionLifecycle({ entity: 'worker', id: dispatch.id, from: 'starting', to: 'failed' }) + // A lost answer's retry replays it; a new attempt runs the stops again. + expect(await remaining('cancel:1.1')).toMatchObject({ + remaining: 1, + mutation: { replayed: true } + }) + expect(await remaining('cancel:1.2')).toMatchObject({ remaining: 0 }) + }) + + it('cancels a Run it never had, and closes it', async () => { + const response = await peerCall('orchestration.peerRunCancel', {}, { requestId: 'cancel:1.1' }) + expect(response.ok && response.result).toMatchObject({ remaining: 0 }) + expect(db.getPeerRunForConversation(conversationId)?.state).toBe('closed') + // A closed Run still admits its coordinator's cancel. + const again = await peerCall('orchestration.peerRunCancel', {}, { requestId: 'cancel:1.2' }) + expect(again.ok && again.result).toMatchObject({ remaining: 0 }) + }) + + it('gives every conversation a share of each pull, so an unimportable head starves no other', async () => { + const stuck = conversationId + const other = generateId('peerconv') + await peerCall('orchestration.runCurrent', {}) + await peerCall('orchestration.runCurrent', {}, { conversation: other }) + const stuckRun = db.getPeerRunForConversation(stuck)!.run_id + const otherRun = db.getPeerRunForConversation(other)!.run_id + for (let index = 0; index < 60; index += 1) { + db.insertMessage({ + from: 'term_w', + to: `run:${stuckRun}`, + subject: `s${index}`, + runId: stuckRun + }) + } + db.insertMessage({ from: 'term_w', to: `run:${otherRun}`, subject: 'done', runId: otherRun }) + + const items = await pull('pull') + expect(items.items.filter((item) => item.conversationId === other)).toEqual([ + expect.objectContaining({ kind: 'worker_mail' }) + ]) + }) + + it('fits every pending conversation in one page when they number fewer than its size', async () => { + const conversations = Array.from({ length: 30 }, () => generateId('peerconv')) + for (const conversation of conversations) { + await peerCall('orchestration.runCurrent', {}, { conversation }) + const runId = db.getPeerRunForConversation(conversation)!.run_id + for (const subject of ['one', 'two']) { + db.insertMessage({ from: 'term_w', to: `run:${runId}`, subject, runId }) + } + } + const page = await pull('pull') + expect(new Set(page.items.map((item) => item.conversationId)).size).toBe(30) + }) + + it('rotates the page when more conversations are pending than it holds, so none starves', async () => { + const conversations = Array.from({ length: 51 }, () => generateId('peerconv')) + for (const conversation of conversations) { + await peerCall('orchestration.runCurrent', {}, { conversation }) + const runId = db.getPeerRunForConversation(conversation)!.run_id + db.insertMessage({ from: 'term_w', to: `run:${runId}`, subject: 'stuck', runId }) + } + const seen = new Set() + for (const id of ['pull_1', 'pull_2']) { + for (const item of (await pull(id)).items) { + seen.add(item.conversationId) + } + } + expect(seen.size).toBe(51) + }) + + it('wakes a waiting pull when a worker in its Run changes state', async () => { + await peerCall('orchestration.runCurrent', {}) + const task = db.createTask({ spec: 'peer work', runId: peerRunId() }) + const { dispatch } = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + creator: { kind: 'system' }, + maxDepth: 5 + }) + db.acknowledgeFederationRelay({ + dispatchId: conversationId, + direction: 'to_home', + throughSequence: 1_000 + }) + // The pull's own deadline never passes, so only the state change can answer it. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const waiting = pull('pull', { waitMs: 20_000 }) + await new Promise((resolve) => setImmediate(resolve)) + db.transitionLifecycle({ entity: 'worker', id: dispatch.id, from: 'starting', to: 'ready' }) + + expect((await waiting).items).toEqual([expect.objectContaining({ kind: 'worker_state' })]) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/peer-mail-methods.ts b/src/main/runtime/rpc/methods/orchestration/federation/peer-mail-methods.ts index 6d79a0577dd..7c7b5df0a94 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/peer-mail-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/peer-mail-methods.ts @@ -35,17 +35,24 @@ function requireRuntimePairing( return ctx.authenticatedCallerFingerprint } +// Where each pairing's next page starts, per database, when more conversations are pending than a page holds. +const pullRotations = new WeakMap>() + +/** Every conversation with pending items gets an equal share of the page, so an item the paired Orca + * cannot import yet, which stays at its conversation's head, never starves another conversation. */ function pendingReplies(db: OrchestrationDb, peerFingerprint: string): FederationRelayItemRow[] { - const items: FederationRelayItemRow[] = [] - for (const conversationId of db.listPeerConversationIds(peerFingerprint)) { - if (items.length >= PULL_PAGE_SIZE) { - break - } - items.push( - ...db.listPendingFederationRelay(conversationId, 'to_home', PULL_PAGE_SIZE - items.length) - ) - } - return items + const pending = db + .listPeerConversationIds(peerFingerprint) + .filter((id) => db.listPendingFederationRelay(id, 'to_home', 1).length > 0) + const share = Math.max(1, Math.floor(PULL_PAGE_SIZE / pending.length)) + const pullRotation = pullRotations.get(db.db) ?? new Map() + pullRotations.set(db.db, pullRotation) + const start = pending.length > PULL_PAGE_SIZE ? (pullRotation.get(peerFingerprint) ?? 0) : 0 + pullRotation.set(peerFingerprint, (start + PULL_PAGE_SIZE) % Math.max(1, pending.length)) + return [...pending.slice(start), ...pending.slice(0, start)] + .slice(0, PULL_PAGE_SIZE) + .flatMap((id) => db.listPendingFederationRelay(id, 'to_home', share)) + .slice(0, PULL_PAGE_SIZE) } export const ORCHESTRATION_PEER_MAIL_METHODS = [ @@ -154,8 +161,10 @@ export const ORCHESTRATION_PEER_MAIL_METHODS = [ const db = ctx.runtime.getOrchestrationDb() let items = pendingReplies(db, peerFingerprint) const waitMs = Math.min(Math.max(params.waitMs ?? 0, 0), MAX_PULL_WAIT_MS) - if (items.length === 0 && waitMs > 0) { - await waitForPeerMail(db, waitMs, ctx.signal) + const deadline = Date.now() + waitMs + // A wake is any queued change on this Orca, so one that queued nothing for this peer waits on. + while (items.length === 0 && Date.now() < deadline && !ctx.signal?.aborted) { + await waitForPeerMail(db.db, deadline - Date.now(), ctx.signal) items = pendingReplies(ctx.runtime.getOrchestrationDb(), peerFingerprint) } return { @@ -163,6 +172,7 @@ export const ORCHESTRATION_PEER_MAIL_METHODS = [ conversationId: item.dispatch_id, sequence: item.sequence, messageId: item.message_id, + kind: item.kind, payload: item.payload })) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/peer-mail.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/peer-mail.test.ts index c624b2989fa..3ee7865d728 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/peer-mail.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/peer-mail.test.ts @@ -385,7 +385,7 @@ describe('agent mail between paired Orca servers', () => { a.db.resetMessages() // The ack was lost: the server serves the stored reply again. b.db['db'].prepare('UPDATE federation_relay_items SET acked_at = NULL').run() - notifyPeerMailQueued(b.db) + notifyPeerMailQueued(b.db.db) await vi.waitFor(() => expect(b.db.listPendingFederationRelay(conversationId!, 'to_home')).toEqual([]) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/peer-run-methods.ts b/src/main/runtime/rpc/methods/orchestration/federation/peer-run-methods.ts new file mode 100644 index 00000000000..a64ce31c976 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/peer-run-methods.ts @@ -0,0 +1,54 @@ +import { defineOrchestrationMethod } from '../access/orchestration-method-access' +import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import type { OrchestrationDb } from '../../../../orchestration/db/orchestration-db' +import { WORKER_SETTLED_STATES } from '../../../../orchestration/terminal-authority/release/worker-terminal-ownership' +import { + PeerRunCancelParams, + type PeerRunCancelResult +} from '../../../../../../shared/orchestration/remote-owned-wire' +import type { z } from 'zod' +import { stopWorkerAndHelpers } from '../worker/worker-stop' + +export const ORCHESTRATION_PEER_RUN_METHODS = [ + defineOrchestrationMethod({ + name: 'orchestration.peerRunCancel', + access: { kind: 'writes', targets: {} }, + permission: 'workspace', + params: PeerRunCancelParams, + /** The paired Orca reset its Run: close it here, then run the ordinary stop on each live worker. */ + handler: async (_params, { runtime, orchestrationCaller }) => { + if (orchestrationCaller?.kind !== 'peer') { + throw new OrchestrationError( + 'forbidden', + 'Only the paired Orca that coordinates a Run cancels it. No effects were applied.', + { effectsApplied: false } + ) + } + const db = runtime.getOrchestrationDb() + const runId = orchestrationCaller.peerRun.run_id + // Committed before any stop, so nothing more starts in it while the stops run. + db.closePeerRun(runId) + for (const dispatchId of liveWorkerDispatchIds(db, runId)) { + // Each stop's own outcome stays on its Dispatch; the count below is the answer. + await stopWorkerAndHelpers(runtime, dispatchId, undefined).catch(() => undefined) + } + const result: z.infer = { + remaining: liveWorkerDispatchIds(db, runId).length + } + return result + } + }) +] + +/** Workers in the Run not yet proven settled; a Run this Orca never had work in has none. */ +function liveWorkerDispatchIds(db: OrchestrationDb, runId: string): string[] { + const settled = WORKER_SETTLED_STATES.map(() => '?').join(', ') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT of one column returns exactly that column. + const rows = db.db + .prepare( + `SELECT d.id FROM dispatch_contexts d JOIN worker_dispatches w ON w.dispatch_id = d.id + WHERE d.run_id = ? AND w.state NOT IN (${settled}) ORDER BY d.rowid` + ) + .all(runId, ...WORKER_SETTLED_STATES) as { id: string }[] + return rows.map((row) => row.id) +} diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index cc9251aeff5..11af8dd3c25 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -22,6 +22,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ access: { kind: 'writes', caller: 'terminal', + callerPane: 'terminalPaneKey', targets: { run: 'run', ack: 'message' }, collection: true }, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts index 2b90558c392..31ec54fb285 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-run-pending-mail.ts @@ -7,7 +7,7 @@ import { acknowledgedOnlyReceipt, handsOverBatch } from './mailbox-message-recei import { currentDispatchAssigneeRun } from './recipient-routing' import { callerHoldsDispatchPane, dispatchFenced } from './dispatch-mailbox-fence' import { orchestrationCallerIdentity } from '../runs/run-scope' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { CheckParams } from '../../../../../../shared/rpc-contract/orchestration-params' import type { z } from 'zod' @@ -18,7 +18,7 @@ export async function checkRunPendingMail(args: { run: RunRow handle: string paneKey: string | undefined - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined typeFilter: MessageType[] | undefined signal: AbortSignal | undefined revalidateConsumer: () => void diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts index 49ef6a58dbe..b52ac709c7e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts @@ -11,7 +11,7 @@ import { routeAllMailboxPages } from './mailbox-page-routing' import { resolveRunScope } from '../runs/run-scope' import type { CheckParams } from '../../../../../../shared/rpc-contract/orchestration-params' import type { z } from 'zod' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import { checkRunPendingMail } from './check-run-pending-mail' import { FEDERATION_RELAY_WAIT_REARM_MS } from '../../../../orchestration/federation/relay/federation-relay-scheduler' @@ -23,7 +23,7 @@ export async function checkRunMailbox(args: { db: OrchestrationDb handle: string paneKey: string | undefined - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined typeFilter: MessageType[] | undefined signal: AbortSignal | undefined legacyCoordinatorRunId: string | undefined diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/helper-question-answerer.ts b/src/main/runtime/rpc/methods/orchestration/messaging/helper-question-answerer.ts index d38486eb25d..944a3dfcb14 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/helper-question-answerer.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/helper-question-answerer.ts @@ -1,5 +1,5 @@ import type { OrchestrationCompatibilityEvidence } from '../../../../../../shared/orchestration/orchestration-compatibility-evidence' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { MessageRow } from '../../../../orchestration/db/orchestration-records' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { assertCallerHandleMatchesEvidence, orchestrationCallerIdentity } from '../runs/run-scope' @@ -10,7 +10,7 @@ export function helperQuestionAnswerer( args: { question: MessageRow from: string | undefined - session: OrchestrationSessionCaller | undefined + session: OrchestrationEntryCaller | undefined evidence: OrchestrationCompatibilityEvidence | undefined } ): { dispatchId: string; consumerGeneration: number } | undefined { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index 027c7f424e3..fff196ea676 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -18,16 +18,18 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ name: 'orchestration.inbox', permission: 'workspace', params: InboxParams, - access: { kind: 'reads', targets: { terminal: 'party' }, collection: true }, + access: { kind: 'reads', targets: { terminal: 'party', run: 'run' }, collection: true }, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() // Return empty for stale/unknown handles because historical mail outlives handle deletion (design doc §3.3). const messages = params.terminal ? db.getAllMessagesForHandle( resolveOrchestrationParty(params.terminal, db).address, - params.limit + params.limit, + undefined, + params.run ) - : db.getInbox(params.limit) + : db.getInbox(params.limit, params.run) return { messages: messages.map(({ answer_sender_snapshot: _answer, ...message }) => message), count: messages.length diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-reply.ts b/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-reply.ts index e78a9abda6a..f23c85fdc82 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-reply.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-reply.ts @@ -1,5 +1,5 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { PeerMailMessageRow } from '../../../../orchestration/db/federation/peer-mail-store' import type { MessageRow } from '../../../../orchestration/db/orchestration-records' import { OrchestrationError } from '../../../../orchestration/orchestration-error' @@ -21,7 +21,7 @@ export async function replyOverPeerMail(args: { original: MessageRow route: PeerMailMessageRow params: { body: string; from?: string; on?: string } - orchestrationCaller: OrchestrationSessionCaller | undefined + orchestrationCaller: OrchestrationEntryCaller | undefined orchestrationMutation: { callerFingerprint: string; requestId: string } | undefined recordMutationReceipt: ((receipt: unknown) => void) | undefined }): Promise { @@ -67,7 +67,7 @@ export async function replyOverPeerMail(args: { return recordReceiptBeforeNudge( args.recordMutationReceipt, { message: { id, subject }, to: original.from_handle }, - () => notifyPeerMailQueued(db), + () => notifyPeerMailQueued(db.db), { kind: 'messages', targets: [] } ) } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-send.ts b/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-send.ts index a9fa69344b3..a18969d81c4 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-send.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/peer-mail-send.ts @@ -1,6 +1,6 @@ import type { z } from 'zod' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { OrchestrationCompatibilityCallerAuthority } from '../../../../orchestration/terminal-authority/orchestration-compatibility-authority' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { @@ -20,7 +20,7 @@ const NOTHING_SENT = { effectsApplied: false } as const export function peerMailLocalCaller(args: { runtime: OrcaRuntimeService from: string | undefined - orchestrationCaller: OrchestrationSessionCaller | undefined + orchestrationCaller: OrchestrationEntryCaller | undefined attestedCaller: OrchestrationCompatibilityCallerAuthority | undefined }): { address: string; paneKey: string | null } { if (args.orchestrationCaller) { @@ -51,7 +51,7 @@ export function peerMailLocalCaller(args: { export async function sendPeerMail(args: { params: z.infer & { on: string } runtime: OrcaRuntimeService - orchestrationCaller: OrchestrationSessionCaller | undefined + orchestrationCaller: OrchestrationEntryCaller | undefined attestedCaller: OrchestrationCompatibilityCallerAuthority | undefined orchestrationMutation: { callerFingerprint: string; requestId: string } | undefined recordMutationReceipt: ((receipt: unknown) => void) | undefined diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/reply-method.ts b/src/main/runtime/rpc/methods/orchestration/messaging/reply-method.ts index 94e0996ac2d..19d52a02e68 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/reply-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/reply-method.ts @@ -29,6 +29,7 @@ export const ORCHESTRATION_REPLY_METHOD = defineOrchestrationMethod({ access: { kind: 'writes', caller: 'from', + server: 'on', targets: { id: 'message', run: 'run' }, rule: replyAccess }, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 1d9c485e919..a0ad726c0cd 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -39,6 +39,8 @@ export const ORCHESTRATION_SEND_METHODS = [ access: { kind: 'writes', caller: 'from', + callerPane: 'senderPaneKey', + server: 'on', targets: { to: 'party', threadId: 'message', run: 'run', payload: 'dispatch' } }, permission: 'workspace', @@ -172,6 +174,17 @@ export const ORCHESTRATION_SEND_METHODS = [ if (!recipient.ok) { throw new OrchestrationError(recipient.code, recipient.message) } + // A paired Orca's former worker may since have taken up local work; it is no longer its to reach. + if ( + orchestrationCaller?.kind === 'peer' && + recipient.runId !== orchestrationCaller.peerRun.run_id + ) { + throw new OrchestrationError( + 'forbidden', + `${params.to} now works outside the Run ${orchestrationCaller.peerRun.home_label} coordinates here. No effects were applied.`, + { effectsApplied: false } + ) + } to = recipient.to messageRunId = recipient.runId if (recipient.warning) { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts index 56273cfec5a..85752b5aa3d 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-point-to-point.ts @@ -8,6 +8,7 @@ import { reconcileLifecycleMessage } from '../../../../orchestration/workers/lif import { bindCoordinatorMutationPayload } from '../../../../orchestration/workers/reports/dispatch-message-binding' import { isDispatchMutationMessageType } from '../../../../../../shared/rpc-contract/orchestration-params' import { parseMessageTaskId } from './message-payload' +import { isDispatchAssignee } from '../../../../orchestration/db/dispatch-context/dispatch-lookup' import type { SendParams } from './send-params' import { legacyWorkerDeliveryContract } from '../routing' import { exposeMessage } from './mailbox-message-receipt' @@ -74,6 +75,8 @@ export function sendPointToPointMessage(args: { ? bindCoordinatorMutationPayload(messageType, params.payload, dispatch.id) : params.payload, senderPaneKey, + senderDispatchId: + dispatch && isDispatchAssignee(dispatch, from, senderPaneKey) ? dispatch.id : undefined, runId: messageRunId, deliveryContract: legacyWorkerDeliveryContract( runtime, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/settled-worker-follow-through.ts b/src/main/runtime/rpc/methods/orchestration/messaging/settled-worker-follow-through.ts index fc223637a6a..3aec8ace463 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/settled-worker-follow-through.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/settled-worker-follow-through.ts @@ -10,7 +10,11 @@ import { receivesMailWhole } from '../../../../orchestration/db/dispatch-context/worker-follow-up-lineage' import { FOLLOW_UP_MESSAGE_TYPES } from '../../../../orchestration/db/dispatch-context/worker-follow-up-plan' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import { + peerCoordinatorAddress, + peerCoordinatorCaller, + type OrchestrationEntryCaller +} from '../../../../orchestration/addressing/orchestration-caller-identity' import { resolveOrcaSessionParty } from '../../../../orchestration/addressing/orchestration-party' import { readStructuredAgentSessionRecord } from '../../../../orchestration/terminal-authority/structured-sessions/structured-worker-authority' import { createHash } from 'node:crypto' @@ -111,7 +115,7 @@ async function continueUnreadMail( outcome = plan?.kind === 'start' ? await startWorkerFollowUp({ - ctx: { runtime, orchestrationCaller: senderSession(db, first.from_handle) }, + ctx: { runtime, orchestrationCaller: senderCaller(db, first) }, db, plan, from: first.from_handle, @@ -304,7 +308,16 @@ export function continueAllUnreadWorkerMail( } } -function senderSession(db: OrchestrationDb, from: string): OrchestrationSessionCaller | undefined { +/** The sender continues as itself: a session as its session, a paired Orca as the coordinator of its Run. */ +function senderCaller( + db: OrchestrationDb, + message: { from_handle: string; run_id: string } +): OrchestrationEntryCaller | undefined { + const peerRun = db.getPeerRun(message.run_id) + if (peerRun && message.from_handle === peerCoordinatorAddress(peerRun.conversation_id)) { + return peerCoordinatorCaller(peerRun) + } + const from = message.from_handle const sessionId = parseOrcaSessionAddress(from) const record = sessionId ? readStructuredAgentSessionRecord(sessionId) : null return sessionId && record diff --git a/src/main/runtime/rpc/methods/orchestration/orchestration-caller-workspace.ts b/src/main/runtime/rpc/methods/orchestration/orchestration-caller-workspace.ts index d22964215f7..ce78dca6226 100644 --- a/src/main/runtime/rpc/methods/orchestration/orchestration-caller-workspace.ts +++ b/src/main/runtime/rpc/methods/orchestration/orchestration-caller-workspace.ts @@ -2,14 +2,18 @@ * showTerminal requires a real PTY; fabricating its writable fields would break terminal verbs for chat workers. */ import type { OrcaRuntimeService } from '../../../orca-runtime' -import type { OrchestrationSessionCaller } from '../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../orchestration/addressing/orchestration-caller-identity' import { isStructuredWorkerHandle } from '../../../../../shared/orchestration/workers/structured-worker-handle' export async function resolveDispatchCallerWorktreeId( runtime: Pick, callerHandle: string, - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined ): Promise { + // A paired Orca has no workspace here, so `current` and `new-child` refuse as for an unmanaged one. + if (callerSession?.kind === 'peer') { + return PEER_COORDINATOR_HAS_NO_WORKSPACE + } // A session caller's workspace is on its record, whichever owner (chat or terminal) holds it. if (callerSession) { return callerSession.workspaceId @@ -22,3 +26,5 @@ export async function resolveDispatchCallerWorktreeId( } return (await runtime.showTerminal(callerHandle)).worktreeId } + +const PEER_COORDINATOR_HAS_NO_WORKSPACE = 'peer-coordinator-has-no-workspace' diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts index c43da58338e..fb415f5b858 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts @@ -1,5 +1,5 @@ import type { DispatchCreator } from '../../../../orchestration/db/dispatch-depth' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { orchestrationCallerIdentity } from './run-scope' @@ -7,7 +7,7 @@ import { orchestrationCallerIdentity } from './run-scope' export function resolveDispatchCreator( runtime: OrcaRuntimeService, callerHandle: string | undefined, - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined ): DispatchCreator { if (!callerHandle) { // No declared caller resolves no parent; retain legacy Run-binding depth zero. diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts index a5993600973..5d59eb85261 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts @@ -8,7 +8,7 @@ import { hasRunBindingKey, mailboxAddressOf, runCoordinatorKey, - type OrchestrationSessionCaller + type OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { OrchestrationPartyIdentity as OrchestrationCallerIdentity } from '../../../../../../shared/orchestration/mailbox/orchestration-party-identity' import { resolveDeclaredCallerParty } from '../../../../orchestration/addressing/orchestration-party' @@ -24,7 +24,7 @@ export type RunScopeParams = { callerTerminalHandle?: string callerPaneKey?: string /** Resolved at the dispatch entry; when set it is the caller, whatever the declared handle. */ - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined requireCurrentConsumer: boolean legacyCoordinatorRunId?: string // Attest user-supplied handles against this trusted handle. @@ -56,7 +56,7 @@ export function orchestrationCallerIdentity( caller: { handle: string paneKey: string | null | undefined - session: OrchestrationSessionCaller | undefined + session: OrchestrationEntryCaller | undefined } ): OrchestrationCallerIdentity { if (caller.session) { @@ -70,7 +70,7 @@ export type OrchestrationCallerParams = { callerTerminalHandle: string callerEvidence?: OrchestrationCompatibilityEvidence callerAuthority?: OrchestrationCompatibilityCallerAuthority - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined /** Preserve legacy callers that treated a missing pane as an ordinary fence. */ requireStablePane?: boolean /** Only skip attestation when the caller does it after the legacy-takeover check (run-use); omitting that reopens impersonation. */ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index da0075d287c..dc79fd4eef1 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -60,6 +60,7 @@ export async function deliverWorkerDispatchPreamble(args: { dispatchId: args.dispatchId, taskSpec: args.taskSpec, coordinatorHandle: orcaSessionIdOrHandle(args.coordinatorHandle, args.db), + coordinatedFrom: args.db.getPeerRun(args.runId)?.home_label, // Its mailbox stays keyed by the handle; its commands name its Orca session ID, which binds to it. workerHandle: structuredSession && isOrcaSessionId(structuredSession.identity.sessionId) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts index a2d53e01de2..6adb724d6f9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts @@ -1,5 +1,6 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' +import { isPeerCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import { isStructuredWorkerHandle } from '../../../../../../shared/orchestration/workers/structured-worker-handle' import type { OrchestrationPartyIdentity as OrchestrationCallerIdentity, @@ -27,8 +28,9 @@ export async function assertExplicitWorkerTerminalUsable(args: { const targetPane = runtime.getTerminalPaneKey(terminal) const callerPane = coordinator?.paneKey ?? runtime.getTerminalPaneKey(from) // Structured coordinators may have no showable terminal; compare their handle/pane identity, or session address for handle-less callers. + // A paired Orca's coordinator has no terminal here either; its address is its handle. const coordinatorHandle = - coordinator?.terminalHandle === null + coordinator?.terminalHandle === null || isPeerCaller(coordinator) ? coordinator.address : isStructuredWorkerHandle(from) ? from diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start-dispatch.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start-dispatch.ts index 5d26db76cb2..50ee585d311 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start-dispatch.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start-dispatch.ts @@ -64,6 +64,7 @@ export function createLocalWorkerStartDispatch( requiredCreatorDispatchId: helperCreator?.id, taskId: existingTask?.id, taskSpec: params.spec, + adoptTaskId: params.adoptTaskId, taskTitle: params.taskTitle, taskDeps, // A helper's Task sits under its creator's Task, so the Run's Task tree shows who started it. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 1af01cf6710..6d7446b10f6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -11,7 +11,7 @@ import type { RunRow, TaskRow } from '../../../../orchestration/db/orchestration-records' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { OrchestrationPartyIdentity as OrchestrationCallerIdentity } from '../../../../../../shared/orchestration/mailbox/orchestration-party-identity' import { resolveDispatchCallerWorktreeId } from '../orchestration-caller-workspace' import { resolveWorkerStartModeOnHost } from '../structured-workers/orchestration-worker-start-mode' @@ -48,7 +48,7 @@ export async function startLocalWorker(args: { bindRun: () => RunRow taskDeps: string[] | undefined coordinator: OrchestrationCallerIdentity | null - callerSession?: OrchestrationSessionCaller + callerSession?: OrchestrationEntryCaller existingTask?: TaskRow orchestrationMutation?: WorkerStartMutation /** Settings-driven; the executing host still gets to refuse below. */ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts index b506a17124f..c8083a27648 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-list-method.ts @@ -251,6 +251,7 @@ async function projectWorkerListPageWithFilteredSnapshot( dispatchStatus: row.dispatchStatus, agentTerminalHandle: row.agentTerminalHandle, terminalState: row.terminalState, + coordinatedFrom: row.coordinatedFrom, resource: row.resource ? exposeWorkerTerminalResource(row.resource) : null, // Emit only derived ownership; resource identity/release fields already live on the row. projection: { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-authority.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-authority.ts index 3df8bd1e22f..d54f92f4c16 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-authority.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-authority.ts @@ -4,7 +4,7 @@ import { liveDispatchCreatorEvaluation } from '../../../../orchestration/db/disp import { selectExistingWorkerStartRun } from './worker-start-run-authority' import type { OrchestrationDb } from '../../../../orchestration/db/orchestration-db' import { OrchestrationError } from '../../../../orchestration/orchestration-error' -import type { OrchestrationSessionCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' +import type { OrchestrationEntryCaller } from '../../../../orchestration/addressing/orchestration-caller-identity' import type { OrchestrationPartyIdentity as OrchestrationCallerIdentity } from '../../../../../../shared/orchestration/mailbox/orchestration-party-identity' import type { DispatchContextRow, @@ -27,7 +27,7 @@ export function resolveWorkerStartRun( args: { coordinator: OrchestrationCallerIdentity | null from: string - callerSession: OrchestrationSessionCaller | undefined + callerSession: OrchestrationEntryCaller | undefined runId: string | undefined /** Objective for a Run this start may create; absent when it names an existing Task. */ objective: string | undefined @@ -120,7 +120,7 @@ export function qualifyLiveWorkerFollowUp( runtime: OrcaRuntimeService, coordinator: OrchestrationCallerIdentity | null, from: string, - session: OrchestrationSessionCaller | undefined, + session: OrchestrationEntryCaller | undefined, runId: string, followUp: Parameters>[1] ) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index dcf2e63ef94..2538b27521b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -40,13 +40,15 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ access: { kind: 'writes', caller: 'from', + server: 'on', targets: { task: 'task', parent: 'task', deps: 'task', run: 'run', retryOf: 'dispatch', - terminal: 'worker' + terminal: 'worker', + adoptTaskId: 'task' } }, requires: ['accounts'], @@ -81,6 +83,26 @@ export async function startWorker( { effectsApplied: false } ) } + if (params.adoptTaskId !== undefined && orchestrationCaller?.kind !== 'peer') { + throw new OrchestrationError( + 'invalid_argument', + 'Only the paired Orca coordinating a Run names its Task IDs. No effects were applied.', + { effectsApplied: false } + ) + } + const placement = params.worktree ?? 'current' + if ( + orchestrationCaller?.kind === 'peer' && + !followUp && + !params.terminal && + /^(current|new-child)$/.test(placement) + ) { + throw new OrchestrationError( + 'invalid_argument', + `--on requires an exact remote worktree selector or new-top-level: ${orchestrationCaller.peerRun.home_label} has no workspace on this server for --worktree ${placement}. No effects were applied.`, + { effectsApplied: false } + ) + } const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) const db = runtime.getOrchestrationDb() const coordinator = resolveOrchestrationCaller(runtime, { diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.test.ts b/src/main/runtime/rpc/orchestration-mutation-executor.test.ts index 2bda0a49813..c3e7a5466b8 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.test.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.test.ts @@ -50,6 +50,7 @@ function createHarness() { vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('window-1:leaf-prompt') return { db, + runtime, executor: new OrchestrationMutationExecutor(runtime), bindTerminal: (next: { generation: number; processIncarnation: string }) => { binding.mockReturnValue({ ptyId: 'pty-prompt', ...next }) @@ -236,3 +237,27 @@ describe('worker start mutation coalescing', () => { expect(invoke).toHaveBeenCalledOnce() }) }) + +describe('a stop or Run cancel interrupted by a restart', () => { + it.each(['orchestration.workerStop', 'orchestration.peerRunCancel'])( + '%s runs again on a retry with the same request ID instead of answering operation_unknown', + async (method) => { + const harness = createHarness() + const params = method === 'orchestration.workerStop' ? { dispatch: 'ctx_1' } : {} + // The first attempt's receipt is pending when this runtime goes away. + void harness.executor.run( + workerStartRequest(method, 'stop-1.1:ctx_1', params), + params, + () => new Promise(() => {}) + ) + const restarted = new OrchestrationMutationExecutor(harness.runtime) + + const invoke = vi.fn(async () => ({ remaining: 0 })) + await expect( + restarted.run(workerStartRequest(method, 'stop-1.1:ctx_1', params), params, invoke) + ).resolves.toMatchObject({ remaining: 0 }) + expect(invoke).toHaveBeenCalledOnce() + harness.db.close() + } + ) +}) diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index 2569a8d0e6e..a20faeb6649 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -3,7 +3,7 @@ import { isTerminalPromptMutation } from '../../../shared/orchestration/orchestration-rpc-contract' import type { OrcaRuntimeService } from '../orca-runtime' -import type { OrcaSessionId } from '../../../shared/orca-session-address' +import type { OrchestrationEntryCaller } from '../orchestration/addressing/orchestration-caller-identity' import { OrchestrationError } from '../orchestration/orchestration-error' import type { RpcRequest } from './core' import { @@ -32,6 +32,14 @@ export type DurableMutationInvocation = { replayedReceipt?: unknown } +/** Mutations whose pending receipt a restart re-enters, so a stop or cancel still finishes rather + * than answering operation_unknown: each re-runs its ordinary owner, which is safe to repeat. */ +const RESUMABLE_PENDING_METHODS: ReadonlySet = new Set([ + 'orchestration.workerRelease', + 'orchestration.workerStop', + 'orchestration.peerRunCancel' +]) + type InFlightMutation = { method: string payloadHash: string @@ -48,8 +56,8 @@ export class OrchestrationMutationExecutor { params: unknown, invoke: (mutation?: DurableMutationInvocation) => unknown, callerFingerprintOverride?: string, - /** The resolved session's Orca session id; it joins the payload so another caller cannot replay it. */ - callerOrcaSessionId?: OrcaSessionId + /** The resolved caller; its identity joins the payload so another caller cannot replay it. */ + caller?: OrchestrationEntryCaller ): Promise { const requestId = request.orchestrationRequestId if (!requestId || !isDurableMutation(request.method, params)) { @@ -57,12 +65,7 @@ export class OrchestrationMutationExecutor { } const callerFingerprint = callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint() - const stableParams = replayStableCallerParams( - this.runtime, - params, - callerOrcaSessionId, - request.method - ) + const stableParams = replayStableCallerParams(this.runtime, params, caller, request.method) const basePayloadHash = hashCanonical({ method: request.method, params: stableParams }) const key = `${callerFingerprint}:${requestId}` const db = this.runtime.getOrchestrationDb() @@ -134,7 +137,7 @@ export class OrchestrationMutationExecutor { isResumablePendingWorkerDone(request.method, params, begun.row.receipt) const resumedPendingMutation = begun.disposition === 'pending' && - (request.method === 'orchestration.workerRelease' || resumedPendingWorkerDone) + (RESUMABLE_PENDING_METHODS.has(request.method) || resumedPendingWorkerDone) if (begun.disposition === 'completed') { const active = this.inFlight.get(key) @@ -191,7 +194,7 @@ export class OrchestrationMutationExecutor { { requestId } ) } - if (request.method !== 'orchestration.workerRelease' && !resumedPendingWorkerDone) { + if (!resumedPendingMutation) { const recovery = getPendingWorkerStartRecovery(request.method, begun.row.receipt) throw new OrchestrationError( 'operation_unknown', diff --git a/src/main/runtime/rpc/orchestration-mutation-receipt.ts b/src/main/runtime/rpc/orchestration-mutation-receipt.ts index 03cf9ebd9d7..ed20925ed9a 100644 --- a/src/main/runtime/rpc/orchestration-mutation-receipt.ts +++ b/src/main/runtime/rpc/orchestration-mutation-receipt.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto' import { isTerminalPromptMutation } from '../../../shared/orchestration/orchestration-rpc-contract' import { parsePaneKey } from '../../../shared/workspace-layout/stable-pane-id' import type { OrcaRuntimeService } from '../orca-runtime' -import type { OrcaSessionId } from '../../../shared/orca-session-address' +import type { OrchestrationEntryCaller } from '../orchestration/addressing/orchestration-caller-identity' export const EFFECT_FREE_WORKER_DONE_CHECKPOINT = JSON.stringify({ pending: { effectFree: 'worker_done' } @@ -15,11 +15,13 @@ export type MutationReplayNudge = | { kind: 'federation'; runId?: string } const CALLER_ORCA_SESSION_ID_KEY = '__orcaCallerOrcaSessionId' +// A paired Orca's address names its conversation, so one Run's receipt never answers another's. +const PEER_CALLER_KEY = '__orcaPeerCaller' export function replayStableCallerParams( runtime: OrcaRuntimeService, params: unknown, - callerOrcaSessionId?: OrcaSessionId, + caller?: OrchestrationEntryCaller, method?: string ): unknown { if (!params || typeof params !== 'object' || Array.isArray(params)) { @@ -27,9 +29,12 @@ export function replayStableCallerParams( } const source = params as Record // Absent for terminal callers, so their payload hashes are unchanged. - const result: Record = callerOrcaSessionId - ? { ...source, [CALLER_ORCA_SESSION_ID_KEY]: callerOrcaSessionId } - : { ...source } + const result: Record = + caller?.kind === 'peer' + ? { ...source, [PEER_CALLER_KEY]: caller.address } + : caller?.orcaSessionId + ? { ...source, [CALLER_ORCA_SESSION_ID_KEY]: caller.orcaSessionId } + : { ...source } if (method === 'orchestration.federationImport' && Array.isArray(result.items)) { result.items = result.items.map((item: Record) => { const { answerSenderReport: _answer, ...canonical } = item diff --git a/src/main/runtime/rpc/orchestration-peer-caller.ts b/src/main/runtime/rpc/orchestration-peer-caller.ts new file mode 100644 index 00000000000..f57053b9d88 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-peer-caller.ts @@ -0,0 +1,72 @@ +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcMethod, RpcRequest, RpcStreamingMethod } from './core' +import type { RpcDispatchStreamingOptions } from './dispatcher-stream-options' +import { OrchestrationError } from '../orchestration/orchestration-error' +import { isGeneratedId } from '../orchestration/db/generated-id' +import { RemoteOwnedPeerRun } from '../../../shared/orchestration/remote-owned-wire' +import { + peerCoordinatorCaller, + type OrchestrationPeerCaller +} from '../orchestration/addressing/orchestration-caller-identity' + +const NO_EFFECTS = { effectsApplied: false } as const + +/** + * A call a paired Orca makes as the coordinator of one of its Runs here. Admitted from the + * authenticated runtime pairing, never from a param, and synchronously: its Run is written in the + * frame's first turn, so a later frame of the same conversation (a cancel) always finds it. + * The declared caller field then names the peer, as a session's names the session. + */ +export function admitOrchestrationPeerCall( + runtime: OrcaRuntimeService, + request: RpcRequest, + method: RpcMethod | RpcStreamingMethod, + options: RpcDispatchStreamingOptions | undefined +): { request: RpcRequest; caller: OrchestrationPeerCaller } | undefined { + if (request.orchestrationPeerRun === undefined) { + return undefined + } + const access = method.orchestrationAccess + if (!access) { + throw new OrchestrationError( + 'invalid_argument', + `${request.method} is not an orchestration method, so it takes no peer Run. No effects were applied.`, + NO_EFFECTS + ) + } + const fingerprint = options?.authenticatedCallerFingerprint + if (options?.clientKind !== 'runtime' || options.pairedDeviceId === undefined || !fingerprint) { + throw new OrchestrationError( + 'mail_requires_server_pairing', + 'Only a paired computer can coordinate a Run on this server. No effects were applied.', + NO_EFFECTS + ) + } + const peerRun = RemoteOwnedPeerRun.safeParse(request.orchestrationPeerRun) + if (!peerRun.success || !isGeneratedId(peerRun.data.conversationId, 'peerconv')) { + throw new OrchestrationError( + 'invalid_argument', + 'A peer Run needs its Run, conversation ID and label. No effects were applied.', + NO_EFFECTS + ) + } + const caller = peerCoordinatorCaller( + runtime + .getOrchestrationDb() + .admitPeerRun({ peerFingerprint: fingerprint, peerRun: peerRun.data }) + ) + // Terminal attestation and pane keys name callers on the host that has them, never this one. + const peerRequest = { ...request, orchestrationCompatibilityEvidence: undefined } + const params: unknown = request.params + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return { request: peerRequest, caller } + } + const bound: Record = { ...params } + if (access.callerPane) { + delete bound[access.callerPane] + } + if (access.caller) { + bound[access.caller] = caller.address + } + return { request: { ...peerRequest, params: bound }, caller } +} diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 10a82436696..160cf758f1e 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -11,7 +11,10 @@ import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../shared/orca-session-addres import { isOrcaSessionId, parseOrcaSessionAddress } from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration/orchestration-session-caller-codes' import type { OrcaRuntimeService } from '../orca-runtime' -import type { OrchestrationSessionCaller } from '../orchestration/addressing/orchestration-caller-identity' +import type { + OrchestrationEntryCaller, + OrchestrationSessionCaller +} from '../orchestration/addressing/orchestration-caller-identity' import { OrchestrationError } from '../orchestration/orchestration-error' import { canonicalOrcaSessionId } from '../orchestration/addressing/session-lineage/canonical-orca-session-id' import type { OrchestrationDb } from '../orchestration/db/orchestration-db' @@ -36,7 +39,7 @@ export type OrchestrationRequestRoute = { export type ResolvedOrchestrationRequest = { /** The request with its declared caller bound to the session and its evidence reduced to it. */ request: RpcRequest - caller?: OrchestrationSessionCaller + caller?: OrchestrationEntryCaller } const NO_EFFECTS = { effectsApplied: false } as const diff --git a/src/main/runtime/rpc/rpc-request-admission-prologue.ts b/src/main/runtime/rpc/rpc-request-admission-prologue.ts index ee6560cea66..fa0deb52116 100644 --- a/src/main/runtime/rpc/rpc-request-admission-prologue.ts +++ b/src/main/runtime/rpc/rpc-request-admission-prologue.ts @@ -23,6 +23,7 @@ import { resolveOrchestrationSessionCaller, type ResolvedOrchestrationRequest } from './orchestration-session-caller' +import { admitOrchestrationPeerCall } from './orchestration-peer-caller' type RpcRequestAdmissionDeps = { runtime: OrcaRuntimeService @@ -59,6 +60,13 @@ export function admitRpcRequest( return reject(migrationFence) } + let peer: ReturnType + try { + peer = admitOrchestrationPeerCall(runtime, rawRequest, method, options) + } catch (error) { + return reject(mapDispatcherError(rawRequest, meta, error)) + } + const admitResolved = ({ request, caller }: ResolvedOrchestrationRequest) => { const parsedParams = parseRpcRequestParams(request, method, meta) if (parsedParams.error) { @@ -76,6 +84,9 @@ export function admitRpcRequest( } return { method, request, caller, params: parsedParams.value } } + if (peer) { + return admitResolved(peer) + } // Resolve once before parse and unary/streaming branching so both see the same caller. const callerField = method.orchestrationAccess?.caller return needsOrchestrationCallerResolution(rawRequest, callerField) diff --git a/src/shared/orchestration/orchestration-rpc-contract.ts b/src/shared/orchestration/orchestration-rpc-contract.ts index 40872534b1d..43828627849 100644 --- a/src/shared/orchestration/orchestration-rpc-contract.ts +++ b/src/shared/orchestration/orchestration-rpc-contract.ts @@ -40,7 +40,8 @@ const ORCHESTRATION_MUTATION_METHODS = new Set([ 'orchestration.federationImport', 'orchestration.federationStop', 'orchestration.federationRelease', - 'orchestration.federationRetain' + 'orchestration.federationRetain', + 'orchestration.peerRunCancel' ]) const RETIRED_ORCHESTRATION_METHODS = new Set(['orchestration.run', 'orchestration.runStop']) diff --git a/src/shared/orchestration/remote-owned-wire.ts b/src/shared/orchestration/remote-owned-wire.ts new file mode 100644 index 00000000000..d52d253b10c --- /dev/null +++ b/src/shared/orchestration/remote-owned-wire.ts @@ -0,0 +1,79 @@ +import { z } from 'zod' + +// What a paired server and the Orca that coordinates a Run on it exchange about that Run's workers +// (contract-b1-b3). The server owns the workers; these carry its facts home, in its order. + +/** The coordinator's Run a call is about, and the conversation its queue items come home under. */ +export const RemoteOwnedPeerRun = z.object({ + runId: z.string().min(1).max(256), + conversationId: z.string().min(1).max(256), + label: z.string().min(1).max(256) +}) +export type RemoteOwnedPeerRun = z.infer + +export const REMOTE_OWNED_WIRE_VERSION = 1 + +const Header = { + v: z.literal(REMOTE_OWNED_WIRE_VERSION), + id: z.string().min(1), + homeRunId: z.string().min(1), + peerRunId: z.string().min(1) +} + +/** A final message filed in the peer Run's mailbox, with the Dispatch and Task its sender worked for. */ +export const RemoteOwnedWorkerMail = z.object({ + ...Header, + message: z.object({ + from: z.string().min(1), + type: z.enum([ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate', + 'question' + ]), + subject: z.string(), + body: z.string(), + payload: z.string().nullable(), + priority: z.enum(['normal', 'high', 'urgent']), + threadId: z.string().nullable() + }), + dispatchId: z.string().min(1).nullable(), + taskId: z.string().min(1).nullable() +}) +export type RemoteOwnedWorkerMail = z.infer + +/** A peer-Run Dispatch, its worker or its Task changed state on the server, or the server's reset removed it. */ +export const RemoteOwnedWorkerState = z.object({ + ...Header, + dispatch: z.object({ + id: z.string().min(1), + taskId: z.string().min(1), + status: z.enum(['pending', 'dispatched', 'completed', 'failed', 'circuit_broken']), + workerState: z.string().min(1).nullable(), + lastFailure: z.string().nullable(), + agent: z.string().nullable() + }), + task: z.object({ + status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']), + result: z.string().nullable() + }), + /** The server's report owner settled this change through its unobserved-prompt correction. */ + correctedUnobservedPrompt: z.boolean(), + removed: z.object({ reason: z.string().min(1) }).nullable() +}) +export type RemoteOwnedWorkerState = z.infer + +export const REMOTE_OWNED_ITEM_KINDS = { + worker_mail: RemoteOwnedWorkerMail, + worker_state: RemoteOwnedWorkerState +} as const + +/** Params of orchestration.peerRunCancel; the Run comes from the call's peer-Run envelope. */ +export const PeerRunCancelParams = z.object({}).strict() + +/** Dispatches in the peer Run the server has not proven terminal after running the ordinary stop on each. */ +export const PeerRunCancelResult = z.object({ remaining: z.number().int().nonnegative() }) diff --git a/src/shared/remote-runtime/remote-runtime-memory-limits.ts b/src/shared/remote-runtime/remote-runtime-memory-limits.ts index c35e01b1f0a..d0ffb5a95c9 100644 --- a/src/shared/remote-runtime/remote-runtime-memory-limits.ts +++ b/src/shared/remote-runtime/remote-runtime-memory-limits.ts @@ -84,6 +84,7 @@ export function serializeRemoteRuntimeRpcRequest(args: { orchestrationCapability: args.envelope?.orchestrationCapability, orchestrationContractVersion: args.envelope?.orchestrationContractVersion, orchestrationRequestId: args.envelope?.orchestrationRequestId, + orchestrationPeerRun: args.envelope?.orchestrationPeerRun, compatibilityInvocationId: args.envelope?.compatibilityInvocationId, orchestrationCompatibilityEvidence: args.envelope?.orchestrationCompatibilityEvidence }) diff --git a/src/shared/rpc-contract/orchestration-params.ts b/src/shared/rpc-contract/orchestration-params.ts index cedac9622cb..a86e95b8198 100644 --- a/src/shared/rpc-contract/orchestration-params.ts +++ b/src/shared/rpc-contract/orchestration-params.ts @@ -74,7 +74,9 @@ export const ReplyParams = z.object({ export const InboxParams = z.object({ limit: OptionalFiniteNumber, // Filter inbox by handle to agree with check --all. - terminal: OptionalString + terminal: OptionalString, + // Only this Run's mail; a paired Orca's inbox is always narrowed to its Run. + run: OptionalString }) export const TaskCreateParams = z.object({ diff --git a/src/shared/rpc-contract/orchestration-worker-start-params.ts b/src/shared/rpc-contract/orchestration-worker-start-params.ts index 128c8a46a8e..1cbdeb8853c 100644 --- a/src/shared/rpc-contract/orchestration-worker-start-params.ts +++ b/src/shared/rpc-contract/orchestration-worker-start-params.ts @@ -31,7 +31,9 @@ export const WorkerStartParams = z effort: OptionalWorkerLaunchPreference, retryOf: OptionalString, timeoutMs: OptionalFiniteNumber, - devMode: z.boolean().optional() + devMode: z.boolean().optional(), + /** Only from the paired Orca coordinating this Run: its own ID for the Task this start creates or reopens. */ + adoptTaskId: OptionalString }) .superRefine((params, ctx) => { if (!params.task && !params.spec) { diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index d68a27482b2..60356b6a064 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -41,6 +41,7 @@ import { } from '../orcad-peer-desktops-contract' import { OrcadTerminalCensusParamsSchema } from '../orcad-terminal-census' import { CrewSubscribeParams } from '../orchestration/orchestration-crew' +import { PeerRunCancelParams } from '../orchestration/remote-owned-wire' import { RegisterDeviceKeyParamsSchema } from '../remote-runtime/pairing/device-key-registration-contract' import { PairingCreateOfferParamsSchema } from '../remote-runtime/pairing/pairing-create-offer-contract' import { ExchangePairingTicketParamsSchema } from '../remote-runtime/pairing/ticket-exchange-contract' @@ -1371,6 +1372,7 @@ export const RPC_PARAMS_BY_METHOD = { 'orchestration.peerMailAck': PeerMailAckParams, 'orchestration.peerMailDeliver': PeerMailDeliverParams, 'orchestration.peerMailPull': PeerMailPullParams, + 'orchestration.peerRunCancel': PeerRunCancelParams, 'orchestration.reply': ReplyParams, 'orchestration.requestShow': RequestShowParams, 'orchestration.reset': ResetParams, diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts index 5eb43892d1a..46c09295183 100644 --- a/src/shared/runtime-rpc-envelope.ts +++ b/src/shared/runtime-rpc-envelope.ts @@ -1,6 +1,7 @@ // Share envelopes across CLI/desktop/non-Electron clients without client-to-client imports. import { z } from 'zod' import type { OrchestrationCompatibilityEvidence } from './orchestration/orchestration-compatibility-evidence' +import type { RemoteOwnedPeerRun } from './orchestration/remote-owned-wire' // Independently updated peers may add envelope fields; strip unknown fields while validating known ones. const MetaSuccess = z @@ -76,6 +77,8 @@ export type RuntimeOrchestrationEnvelope = { orchestrationCapability?: string orchestrationContractVersion?: number orchestrationRequestId?: string + /** A paired Orca acting as the coordinator of one of its Runs on this server. */ + orchestrationPeerRun?: RemoteOwnedPeerRun compatibilityInvocationId?: string orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence } From d5759e67a2f2183b911d16887107f9463aa44b93 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 10 Nov 2026 20:48:45 +0000 Subject: [PATCH 016/106] fix(browser): keep unsent page notes across restarts and ask before closing a tab that has them (#40851) * fix(browser): keep unsent page notes across restarts and ask before closing a tab that has them Unsent browser notes lived only in the renderer store and hydration reset them, so a reload, restart or tab close lost them. Each saved browser page now carries its unsent notes, hydration restores them, and every close path (tab strip, keyboard, CLI, phone) asks before discarding them. A page script's window.close() can't discard them. * fix(browser): keep tabs with unsent notes open on bulk close, like pinned tabs --- .../browser-page-window-close.test.ts | 15 +++- .../host-guest/browser-page-window-close.ts | 3 +- .../layout/close/PinnedTabCloseDialog.tsx | 55 ++++++++----- .../use-terminal-bulk-close-actions.ts | 8 +- .../ipc-events-close-routing-test-harness.ts | 1 + .../browser-request-ipc-bridge.owner.test.ts | 1 + .../ipc-events/browser-request-ipc-bridge.ts | 44 +++-------- .../browser-tab-close-with-reply.ts | 52 +++++++++++++ .../ipc-events/session-tab-ipc-bridge.ts | 7 +- src/renderer/src/i18n/locales/en.json | 5 +- .../workspace-session-browser-tabs.ts | 24 ++++-- .../persistence/workspace-session-patch.ts | 9 ++- .../persistence/workspace-session.ts | 5 +- .../workspace/tabs/workspace-tab-commands.ts | 9 ++- .../annotations/browser-page-annotation.ts | 13 ++++ ...browser-annotation-session-restore.test.ts | 76 ++++++++++++++++++ .../hydration/browser-hydration-state.ts | 23 ++++-- .../browser-workspace-doc-location.test.ts | 1 + .../tabs/closing/pinned-tab-close-guard.ts | 16 ++-- .../slices/tabs/pinned-tab-close-confirm.ts | 6 +- .../workspace-layout-frame-store-view.ts | 3 +- .../browser-page-annotation-schema.ts | 77 +++++++++++++++++++ .../languages/protected-copy-keys.json | 2 + .../workspace-session-browser-schema.ts | 5 +- .../workspace-session-state-types.ts | 6 +- 25 files changed, 374 insertions(+), 92 deletions(-) create mode 100644 src/renderer/src/hooks/ipc-events/browser-tab-close-with-reply.ts create mode 100644 src/renderer/src/store/slices/browser/hydration/browser-annotation-session-restore.test.ts create mode 100644 src/shared/browser/annotations/browser-page-annotation-schema.ts diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts index 257250b024a..7f10e7912b4 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.test.ts @@ -12,9 +12,15 @@ vi.mock('@/lib/workspace/tabs/workspace-tab-commands', () => ({ import { handleGuestWindowCloseMessage } from './browser-page-window-close' import { BROWSER_GUEST_WINDOW_CLOSE_CHANNEL } from '../../../../../shared/browser-guest-window-close' -function seedTab(pageId: string, isPinned = false, siblingPageIds: string[] = []): void { +function seedTab( + pageId: string, + isPinned = false, + siblingPageIds: string[] = [], + browserAnnotationsByPageId: Record = {} +): void { storeState.value = { closeBrowserPage: closeBrowserPageMock, + browserAnnotationsByPageId, browserTabsByWorktree: { 'wt-1': [{ id: 'ws-1' }] }, browserPagesByWorkspace: { 'ws-1': [{ id: pageId }, ...siblingPageIds.map((id) => ({ id }))] }, unifiedTabsByWorktree: { @@ -40,6 +46,13 @@ describe('window.close() from a browser page', () => { expect(dispatchMock).not.toHaveBeenCalled() }) + it('never discards unsent notes', () => { + seedTab('page-n', false, [], { 'page-n': [{ id: 'note-1' }] }) + closeFrom('page-n', { back: false, forward: false }) + expect(dispatchMock).not.toHaveBeenCalled() + expect(closeBrowserPageMock).not.toHaveBeenCalled() + }) + it('closes only the calling page when its Orca tab holds other pages', () => { seedTab('page-e', false, ['page-sibling']) closeFrom('page-e', { back: false, forward: false }) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts index 452f602c2b1..5b37f60164e 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-window-close.ts @@ -32,7 +32,8 @@ export function handleGuestWindowCloseMessage( const tab = (state.unifiedTabsByWorktree[owner.worktreeId] ?? []).find( (candidate) => candidate.contentType === 'browser' && candidate.entityId === owner.workspaceId ) - if (tab?.isPinned) { + // A script may not discard what the user pinned or left unsent; the page's notes are all it closes. + if (tab?.isPinned || (state.browserAnnotationsByPageId[pageId]?.length ?? 0) > 0) { return } pagesOpenedByPage.delete(pageId) diff --git a/src/renderer/src/components/terminal-pane/layout/close/PinnedTabCloseDialog.tsx b/src/renderer/src/components/terminal-pane/layout/close/PinnedTabCloseDialog.tsx index 576c72f73ea..36b9280f7da 100644 --- a/src/renderer/src/components/terminal-pane/layout/close/PinnedTabCloseDialog.tsx +++ b/src/renderer/src/components/terminal-pane/layout/close/PinnedTabCloseDialog.tsx @@ -25,6 +25,7 @@ export default function PinnedTabCloseDialog(): React.JSX.Element { const [previousRequest, setPreviousRequest] = useState(request) const tabLabel = request?.tabLabel.trim() + const unsentNotes = request?.unsentNotes === true // Reset new requests before paint without reviving a cancelled request’s checkbox state. if (request !== previousRequest) { @@ -53,16 +54,26 @@ export default function PinnedTabCloseDialog(): React.JSX.Element { - {t( - 'auto.components.terminal.pane.PinnedTabCloseDialog.6c190f295a', - 'Close pinned tab?' - )} + {unsentNotes + ? t( + 'auto.components.terminal.pane.PinnedTabCloseDialog.unsent_notes_title', + 'Discard unsent notes?' + ) + : t( + 'auto.components.terminal.pane.PinnedTabCloseDialog.6c190f295a', + 'Close pinned tab?' + )} - {t( - 'auto.components.terminal.pane.PinnedTabCloseDialog.0d1963f4a6', - 'This tab is pinned. Are you sure you want to close it?' - )} + {unsentNotes + ? t( + 'auto.components.terminal.pane.PinnedTabCloseDialog.unsent_notes_description', + "This tab has notes you haven't sent. Closing it discards them." + ) + : t( + 'auto.components.terminal.pane.PinnedTabCloseDialog.0d1963f4a6', + 'This tab is pinned. Are you sure you want to close it?' + )} {tabLabel ? ( @@ -70,19 +81,21 @@ export default function PinnedTabCloseDialog(): React.JSX.Element { {tabLabel}

) : null} -
- setDontAskAgain(checked === true)} - /> - -
+ {unsentNotes ? null : ( +
+ setDontAskAgain(checked === true)} + /> + +
+ )} + + + + + {openLabel} + + + {expanded && ( + + + + )} +
+ {actions} +
+
+ {showSummaryRow && ( + + {branch && ( + + + + {branch} + )} - - {(branch || !!ahead || !!behind) && ( - - {branch && ( - - - {branch} - - )} - {!!ahead && ( - - - {ahead} - - )} - {!!behind && ( - - - {behind} + + {!expanded && } + {!expanded && changeCount > 0 && ( + + {t( + 'auto.components.rightSidebar.FolderWorkspaceSourceControl.changeCount', + '{{count}} changes', + { count: changeCount, defaultValue_one: '{{count}} change' } + )} )} - )} - - - - - - {openLabel} - + + )} ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx index e6b95041d57..00a38832642 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/section-header.tsx @@ -3,6 +3,7 @@ import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/class-names' import { Button } from '@/components/ui/button' import { useTranslate } from '@/i18n/use-translate' +import { useSourceControlScope } from '../panel/worktree-scope' export function SectionHeader({ label, @@ -23,22 +24,27 @@ export function SectionHeader({ actions?: React.ReactNode }): React.JSX.Element { const t = useTranslate() + // Folder cards keep the gap above the first file equal to the one below the last. + const compact = useSourceControlScope() !== null // Share the rounded container so hover styling spans the whole row. return ( -
+
-
{actions}
+
+ {actions} +
) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx index c80dea85b36..6c77ceaf81a 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-sections.tsx @@ -14,6 +14,7 @@ import { import { ActionButton } from './action-button' import { SectionHeader } from './section-header' import { SourceControlSectionFileList } from './section-file-list' +import { useSourceControlScope } from '../panel/worktree-scope' import { getSourceControlSectionViewAction, type SourceControlDisplaySection, @@ -89,6 +90,7 @@ export function SourceControlUncommittedSections(props: { requestDiscardEntry: (entry: GitStatusEntry) => void diffCommentCountByPath: Map }): React.JSX.Element { + const embedded = useSourceControlScope() !== null const t = useTranslate() return ( <> @@ -176,35 +178,37 @@ export function SourceControlUncommittedSections(props: { } /> {!isCollapsed && ( - +
+ +
)}
) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts index 9888f31b182..9cc0b17c0d0 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-selection.ts @@ -190,7 +190,8 @@ export function useSourceControlSelection({ return } - const container = containerRef.current + const panel = containerRef.current + const container = panel?.closest('[data-source-control-worktree]') ?? panel const target = e.target if (!container || !(target instanceof Node) || container.contains(target)) { return diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx index af4712d35a9..940a009f98c 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/branch-context-row.tsx @@ -103,6 +103,7 @@ export function SourceControlBranchContextRow({ headControl, manualReviewUrl, branchLineTotal, + reviewControl, onChangeBaseRef, onRetry }: { @@ -112,6 +113,7 @@ export function SourceControlBranchContextRow({ headControl?: React.ReactNode manualReviewUrl?: string | null branchLineTotal?: GitBranchLineTotal | null + reviewControl?: React.ReactNode onChangeBaseRef: () => void onRetry: () => void }): React.JSX.Element | null { @@ -124,8 +126,11 @@ export function SourceControlBranchContextRow({ return null } return ( -
- {headControl ?? } +
+ + {headControl ?? } + + {reviewControl}
) } @@ -159,7 +164,12 @@ export function SourceControlBranchContextRow({ onChangeBaseRef={onChangeBaseRef} changeBaseTitle={changeBaseTitle} leading={