diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 338bcda7dbd..f8589081a24 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -539,8 +539,10 @@ jobs: - name: Old/new client and server compatibility journeys run: >- pnpm exec vitest run --config config/vitest.config.ts + tests/e2e/cross-version-wire/release-checkout.unit.test.ts tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts + tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index bf7d5b1382c..a6000d02297 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -140,6 +140,9 @@ module.exports = { // it is gitignored, but exclude it defensively so a stray local capture at // package time never bloats app.asar. '!pr-evidence{,/**/*}', + // Why: local agent/tooling directories may contain worktree symlink loops; + // they are never runtime inputs and must not be traversed by electron-builder. + '!{.claude,.grok,.agents,.codex}{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}', '!out/**/*.test.js', diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index 88dccc5746d..ef6b02767b8 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -22,6 +22,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'jsonc-parser', 'node-pty', 'posthog-node', + 'proper-lockfile', // serve-sim (for CLI JS entry + closure + state/middleware + to make packaged require('serve-sim') + its internal relatives work; mirrors other runtime JS like ws/yaml/zod. Natives/dylibs still via extraResources + the node_modules/serve-sim copy in resources from builder. Client if added too. 'serve-sim', 'qrcode', diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index ce712d1c01b..a4b6bd10f07 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8519,6 +8519,7 @@ "remote terminal reveal and input", "paired host relaunch with preserved daemon PTYs", "paired host session inventory publication authority", + "paired host session inventory subscription linearizability", "manual server disconnect" ], "platforms": ["macos", "linux", "windows"], @@ -8537,7 +8538,7 @@ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex-initial-snapshot-buffering.test.ts src/main/runtime/rpc/terminal-multiplex-snapshot-serialization.test.ts src/main/runtime/rpc/terminal-multiplex-pty-wait-capacity.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-parked-ssh-snapshot.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-activation-inventory-fallback.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api-runtime-environment.test.ts src/main/ipc/runtime-environments-pairing.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/session-tabs-inventory-publication.test.ts src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts src/main/daemon/terminal-host-agent-session.test.ts tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-ipc-hidden-delivery-gate.test.ts", @@ -8571,6 +8572,7 @@ "src/main/runtime/rpc/terminal-subscribe-buffer.test.ts", "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "src/main/runtime/session-tabs-inventory-publication.test.ts", + "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts", "src/main/runtime/rpc/methods/session-tabs.test.ts", "src/renderer/src/runtime/host-session-mirror-empty-inventory-settle.test.ts", "src/main/daemon/terminal-host-agent-session.test.ts", @@ -8658,6 +8660,18 @@ "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] }, + { + "file": "src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts", + "assertions": [ + "pre-boundary creation and removal are subsumed by the census exactly once", + "post-boundary creation and removal replay after the initial snapshot in sequence order", + "create/remove/recreate transitions are preserved without collapse", + "projected PTY state already visible in the census is deduplicated while later projected state is delivered", + "coalesced delivery uses its scheduling watermark across every subscriber", + "initialization churn is memory-bounded and repeated structural overflow fails after bounded recollection", + "initialization clears buffered transitions on cancellation" + ] + }, { "file": "tests/e2e/session-tabs-empty-inventory-daemon-oracle.unit.test.ts", "assertions": [ diff --git a/config/scripts/build-windows-process-tree-relay-addon.mjs b/config/scripts/build-windows-process-tree-relay-addon.mjs index 3e43beb359e..364335e537c 100644 --- a/config/scripts/build-windows-process-tree-relay-addon.mjs +++ b/config/scripts/build-windows-process-tree-relay-addon.mjs @@ -32,9 +32,12 @@ import { import { createRequire } from 'node:module' import { dirname, join, resolve } from 'node:path' import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts' +import { + nodeGypRebuildInvocation, + WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR +} from './windows-process-tree-gyp-rebuild.mjs' const ROOT = resolve(import.meta.dirname, '..', '..') -const PACKAGE_DIR = join(ROOT, 'node_modules', '@vscode', 'windows-process-tree') const SUPPORTED_ARCHES = ['x64', 'arm64'] /** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */ @@ -169,12 +172,9 @@ function main() { applyWindowsProcessTreeBuildFixes() assertPatchApplied() - console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`) - execFileSync( - process.execPath, - [join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), 'rebuild', `--arch=${arch}`], - { cwd: PACKAGE_DIR, stdio: 'inherit' } - ) + const gyp = nodeGypRebuildInvocation(arch) + console.log(`[windows-process-tree] building ${arch} from ${gyp.cwd}`) + execFileSync(process.execPath, gyp.args, { cwd: gyp.cwd, stdio: 'inherit' }) const built = join(PACKAGE_DIR, 'build', 'Release', 'windows_process_tree.node') if (!existsSync(built)) { diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index bf28401fd35..0a42eea5067 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -44,6 +44,7 @@ describe('electron-builder config', () => { '!tests{,/**/*}', '!examples{,/**/*}', '!pr-evidence{,/**/*}', + '!{.claude,.grok,.agents,.codex}{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}', '!out/**/*.test.js', @@ -52,6 +53,23 @@ describe('electron-builder config', () => { ) }) + it('keeps local agent tooling out of app.asar', () => { + const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files) + matcher.prependPattern('**/*') + const isPacked = matcher.createFilter() + const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false }) + + for (const toolingPath of [ + '.grok/skills/review-and-submit/review-and-submit/SKILL.md', + '.claude/skills/review-and-submit/review-and-submit/SKILL.md', + '.agents/skills/electron/SKILL.md', + '.codex/sessions/session.json' + ]) { + expect(packs(toolingPath)).toBe(false) + } + expect(packs('out/main/index.js')).toBe(true) + }) + // Why: `files` is an all-negation list, so electron-builder's default `**/*` packs // anything without an explicit `!` entry — examples/ landed without one and shipped // hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar. @@ -436,7 +454,7 @@ describe('electron-builder config', () => { } }) - it('includes @parcel/watcher in the packaged runtime closure', () => { + it('includes external main dependencies in the packaged runtime closure', () => { // Why: the main process imports '@parcel/watcher' for filesystem change // events; if it is absent from the packaged closure the serve host silently // stops propagating file changes to clients (regression guard for #4851). @@ -448,6 +466,7 @@ describe('electron-builder config', () => { target.startsWith(join('node_modules', '@parcel', 'watcher-')) ) ).toBe(true) + expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile')) }) it('prunes non-target @parcel/watcher architecture subpackages', async () => { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index c51b1c18a5c..3ea588aaf8f 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -93,8 +93,18 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', 'src/shared/browser-client-host-placement', + 'src/shared/agent-session-wire', + 'src/shared/agent-session-mutation-envelope', + 'src/shared/agent-session-journal-', + 'src/main/ai-vault/structured-session-ownership.ts', + 'src/main/native-chat/agent-session-journal/', + 'src/main/native-chat/agent-session-wire/', + 'src/main/runtime/agent-session-record-store', 'src/main/runtime/rpc/dispatcher', + 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema', + 'src/main/runtime/rpc/methods/session-tabs.ts', + 'src/main/runtime/rpc/methods/structured-agent-session', 'src/main/runtime/rpc/methods/terminal', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' ] diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index ea83796e6cb..3f35dae1dd9 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -181,8 +181,24 @@ describe('per-job path classification', () => { for (const file of [ 'src/shared/protocol-version.ts', 'src/shared/terminal-stream-protocol.ts', + 'src/shared/agent-session-wire.ts', + 'src/shared/agent-session-mutation-envelope.ts', + 'src/shared/agent-session-journal-item-key.ts', + 'src/shared/agent-session-journal-types.ts', + 'src/main/ai-vault/structured-session-ownership.ts', + 'src/main/native-chat/agent-session-journal/journal-cursor.ts', + 'src/main/native-chat/agent-session-journal/journal-reducer.ts', + 'src/main/native-chat/agent-session-journal/journal-row-schema.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-host.ts', + 'src/main/runtime/agent-session-record-store.ts', 'src/main/runtime/rpc/dispatcher.ts', + 'src/main/runtime/rpc/methods/ai-vault.ts', 'src/main/runtime/rpc/methods/browser-tab-create-schema.ts', + 'src/main/runtime/rpc/methods/session-tabs.ts', + 'src/main/runtime/rpc/methods/structured-agent-session.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-gate.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-hold.ts', + 'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts', 'src/main/runtime/rpc/methods/terminal.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' ]) { diff --git a/config/scripts/windows-process-tree-gyp-rebuild.mjs b/config/scripts/windows-process-tree-gyp-rebuild.mjs new file mode 100644 index 00000000000..06ffcb3dfb1 --- /dev/null +++ b/config/scripts/windows-process-tree-gyp-rebuild.mjs @@ -0,0 +1,33 @@ +/** + * Where and how `@vscode/windows-process-tree` is rebuilt from source. + * + * node-gyp must run from the package's physical directory, never the + * `node_modules` symlink/junction pnpm installs there: gyp expands the + * node-addon-api dependency by probing node (whose cwd resolves to the + * physical path), gets back a store-relative `../../../../node-addon-api@…` + * hop, then resolves that hop against the rebuild cwd. From the link path the + * hop escapes the store and configure fails with "node_addon_api.gyp not + * found" (run 32999886072). + */ +import { realpathSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const ROOT = resolve(import.meta.dirname, '..', '..') + +export const WINDOWS_PROCESS_TREE_PACKAGE_DIR = join( + ROOT, + 'node_modules', + '@vscode', + 'windows-process-tree' +) + +export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) { + return { + args: [ + join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), + 'rebuild', + `--arch=${arch}` + ], + cwd: realpathSync(packageDir) + } +} diff --git a/config/scripts/windows-process-tree-gyp-rebuild.test.mjs b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs new file mode 100644 index 00000000000..a90a92f42bf --- /dev/null +++ b/config/scripts/windows-process-tree-gyp-rebuild.test.mjs @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, realpathSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + nodeGypRebuildInvocation, + WINDOWS_PROCESS_TREE_PACKAGE_DIR +} from './windows-process-tree-gyp-rebuild.mjs' + +describe('windows-process-tree node-gyp rebuild', () => { + it("resolves node-addon-api's gyp target from the rebuild cwd", () => { + // gyp probes node-addon-api with the package's physical directory as cwd, + // so the emitted target is store-relative; gyp then resolves that hop + // against the rebuild cwd. Rebuilding from pnpm's node_modules link sends + // the hop outside the store and configure fails (run 32999886072). + const { cwd } = nodeGypRebuildInvocation('x64') + const targets = execFileSync(process.execPath, ['-p', "require('node-addon-api').targets"], { + cwd: realpathSync(WINDOWS_PROCESS_TREE_PACKAGE_DIR), + encoding: 'utf8' + }).trim() + expect(existsSync(resolve(cwd, targets))).toBe(true) + }) + + it('forwards the requested arch to node-gyp', () => { + const { args } = nodeGypRebuildInvocation('arm64') + expect(args).toContain('rebuild') + expect(args).toContain('--arch=arm64') + }) +}) diff --git a/config/scripts/windows-process-tree-patch-contract.test.mjs b/config/scripts/windows-process-tree-patch-contract.test.mjs new file mode 100644 index 00000000000..f9c1c90c91a --- /dev/null +++ b/config/scripts/windows-process-tree-patch-contract.test.mjs @@ -0,0 +1,22 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectDir = resolve(import.meta.dirname, '../..') + +describe('Windows process-tree patch contract', () => { + it('keeps the patch LF-only and hash-synced with the lockfile', () => { + const patchBytes = readFileSync( + join(projectDir, 'config/patches/@vscode__windows-process-tree@0.8.0.patch') + ) + // pnpm hashes patches CRLF-normalized, so CR bytes cannot affect install + // behavior; keep the file LF-only so the bytes match main and diff clean. + expect(patchBytes.includes(0x0d)).toBe(false) + const patchHash = createHash('sha256') + .update(patchBytes.toString('utf8').replaceAll('\r\n', '\n')) + .digest('hex') + const lockfile = readFileSync(join(projectDir, 'pnpm-lock.yaml'), 'utf8') + expect(lockfile).toContain(`hash: ${patchHash}`) + }) +}) diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 9f83cddee95..c2de6e5dcbb 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -17,6 +17,11 @@ "../src/main/wsl-distro-list-output.ts", "../src/main/wsl-distro-retry.ts", "../src/main/wsl.ts", + "../src/main/persistence/applying-settings/ui-state-read.ts", + "../src/main/persistence/applying-settings/ui-state-update.ts", + "../src/main/persistence/applying-settings/ui-selection-normalization.ts", + "../src/main/persistence/applying-settings/ui-interaction-merge.ts", + "../src/main/protected-secret-persistence.ts", "../src/main/startup/serve-desktop-activation.ts", "../src/main/startup/serve-mode-argv.ts", "../src/main/startup/single-instance-lock.ts", diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 9f3d8d66472..423149950ce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -95,10 +95,29 @@ negotiated capabilities differ from the contract. Adding an optional field keeps green (Rule 1); making a client depend on that field turns the new-client/old-host pairing red. -The harness covers the terminal stream only. It does **not** cover the session-tab -sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or -the relay transport. A change on those paths still needs its own reasoning against -the three rules above. +`tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` pairs the +same two builds over the structured `agentSession.*` surface. Because a released build +cannot name a capability string its own source never contains, the old side's advertised +list and registered method names are read from the extracted checkout rather than +hand-written. It covers the three skews that surface can fail on: + +- an old client — advertising only what the baseline build defines — is told the whole + surface does not exist and reaches no host method; +- a new client against the old dispatcher gets `method_not_found` on every method, and + can see the absence during negotiation instead of by calling; +- a cursor survives a host restart: the client's fence is refused as stale with the live + one attached, and resuming from the held cursor replays only what it missed. + +Run it with: + +```bash +pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +``` + +The harness covers the terminal stream and the structured agent-session surface. It does +**not** cover the session-tab sync channel, legacy agent-session publications, file or Git +RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its +own reasoning against the three rules above. ## Worked example: `agentWait` on terminal and worker reads diff --git a/mobile/app.json b/mobile/app.json index 6b6d43f8f0e..d5a420cc74b 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.44", + "version": "0.0.47", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 13 + "versionCode": 15 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 18ebe3efe3d..ed26d3dd5cd 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -83,7 +83,7 @@ import { } from '../../../src/host-route-action-state' import { applyDesktopViewSettings, - groupModeToDesktop, + buildWorkspaceViewSettingsUpdate, type MobileGroupMode, type MobileSortMode, type MobileViewState, @@ -249,7 +249,7 @@ export function HostScreen({ }) }, []) - // Apply the change locally, then push full settings to the desktop's shared store (ui.set) so both apps stay in sync. + // Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync. const persistViewSettings = useCallback( (patch: Partial) => { const next: MobileViewState = { ...viewStateRef.current, ...patch } @@ -257,16 +257,12 @@ export function HostScreen({ if (!client) { return } - // alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it - // but has no toggle, so echoing its local default would silently revert a - // desktop opt-out on the first filter tap before ui.get lands (#8873). - const payload: WorkspaceViewSettings = { - groupBy: groupModeToDesktop(next.groupMode), - sortBy: next.sortMode, - hideSleepingWorkspaces: next.hideSleeping, - hideDefaultBranchWorkspace: next.hideDefaultBranch, - filterRepoIds: next.filterRepoIds, - collapsedGroups: next.collapsedGroups + // Send only the touched fields: the host merges partial updates, so a stale + // mirror can no longer revert sibling settings another client just changed + // (STA-5781; supersedes the #8873 whole-payload special case). + const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next) + if (Object.keys(payload).length === 0) { + return } void client.sendRequest('ui.set', payload).catch(() => { // Best-effort: view settings are a convenience preference. diff --git a/mobile/src/components/AgentSpinner.tsx b/mobile/src/components/AgentSpinner.tsx index a5e81ec07a9..c05e5d2a489 100644 --- a/mobile/src/components/AgentSpinner.tsx +++ b/mobile/src/components/AgentSpinner.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { Radio } from 'lucide-react-native' +import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentWorkingMode } from '../../../src/shared/agent-status-types' @@ -50,7 +50,7 @@ export function AgentSpinner({ if (monitoring) { return ( - + ) } diff --git a/mobile/src/components/AgentStateDot.tsx b/mobile/src/components/AgentStateDot.tsx index 4d596b7e094..1637296d900 100644 --- a/mobile/src/components/AgentStateDot.tsx +++ b/mobile/src/components/AgentStateDot.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef } from 'react' -import { Radio } from 'lucide-react-native' +import { Activity } from 'lucide-react-native' import { Animated, Easing, StyleSheet, View } from 'react-native' import type { AgentDotState } from '../worktree/agent-row-display' @@ -49,7 +49,7 @@ export function AgentStateDot({ state }: { state: AgentDotState }) { if (state === 'monitoring') { return ( - + ) } diff --git a/mobile/src/components/NewWorktreeModal.test.tsx b/mobile/src/components/NewWorktreeModal.test.tsx index 0bbdb462d4c..23aabfbff21 100644 --- a/mobile/src/components/NewWorktreeModal.test.tsx +++ b/mobile/src/components/NewWorktreeModal.test.tsx @@ -1,4 +1,4 @@ -import { createElement } from 'react' +import { createElement, Suspense, type ReactElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' @@ -348,7 +348,11 @@ describe('NewWorktreeModal project targets', () => { renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: freshClient })) await Promise.resolve() }) - expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', '']) + // The swap must not restart the form session — see the reconnect test below. + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([ + 'stale-client-name', + 'stale-client-name' + ]) resolveOldList?.({ ok: true, result: { repos } }) await flushUpdates() @@ -377,6 +381,142 @@ describe('NewWorktreeModal project targets', () => { expect(repoListCalls).toHaveLength(2) }) + // A reconnect / forceReconnect / foreground revival hands the same host a NEW + // RpcClient object (see useHostClient). Keying the form session on that object + // remounted the modal mid-edit and silently threw away the picked source. + it('keeps the picked source when a reconnect swaps the client for the same host', async () => { + const makeClient = () => + ({ + sendRequest: vi.fn().mockImplementation((method: string) => { + if (method === 'repo.list') { + return Promise.resolve({ ok: true, result: { repos } }) + } + if (method === 'status.get') { + return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } }) + } + return new Promise(() => {}) + }) + }) as unknown as RpcClient + const modalProps = { + visible: true, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() })) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('feat/keep-me')) + + await act(async () => { + renderer.update(createElement(NewWorktreeModal, { ...modalProps, client: makeClient() })) + await Promise.resolve() + }) + + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([ + 'feat/keep-me', + 'feat/keep-me' + ]) + }) + + // react-native-screens freezes a blurred screen by suspending its subtree + // (react-freeze), so React runs this component and then throws that render away. + // Anything the render mutated in place outlives the work React discarded. + function suspendableTree(frozen: () => boolean, child: ReactElement) { + const never = new Promise(() => {}) + const Freezer = () => { + if (frozen()) { + throw never + } + return null + } + // The modal renders first, so its render completes before the freeze throws. + return createElement(Suspense, { fallback: null }, child, createElement(Freezer, null)) + } + + it('keeps the form when a host switch renders but never commits', async () => { + setCachedRepos('host-2', repos) + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} } + let frozen = false + const tree = (hostId: string) => + suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, hostId })) + + await act(async () => { + renderer = create(tree('host-1')) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me')) + + frozen = true + await act(async () => { + renderer.update(tree('host-2')) + }) + frozen = false + await act(async () => { + renderer.update(tree('host-1')) + }) + + // host-2 never committed, so host-1's session was never superseded. + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['keep-me', 'keep-me']) + }) + + it('keeps the form when a close renders but never commits', async () => { + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { + client, + hostId: 'host-1', + onCreated: () => {}, + onClose: () => {} + } + let frozen = false + const tree = (visible: boolean) => + suspendableTree(() => frozen, createElement(NewWorktreeModal, { ...modalProps, visible })) + + await act(async () => { + renderer = create(tree(true)) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('keep-me-too')) + + frozen = true + await act(async () => { + renderer.update(tree(false)) + }) + frozen = false + await act(async () => { + renderer.update(tree(true)) + }) + + // The drawer never committed a closed state, so this is not a reopening. + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual([ + 'keep-me-too', + 'keep-me-too' + ]) + }) + + it('starts a fresh form session when the modal switches hosts', async () => { + setCachedRepos('host-2', repos) + const client = { + sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) + } as unknown as RpcClient + const modalProps = { visible: true, client, onCreated: () => {}, onClose: () => {} } + + await act(async () => { + renderer = create(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-1' })) + }) + act(() => sourceInputs(renderer)[0]!.props.onChangeText('host-one-name')) + + await act(async () => { + renderer.update(createElement(NewWorktreeModal, { ...modalProps, hostId: 'host-2' })) + }) + + expect(sourceInputs(renderer).map((input) => input.props.value)).toEqual(['', '']) + }) + it('starts with fresh form state after closing and reopening', async () => { const client = { sendRequest: vi.fn().mockImplementation(() => new Promise(() => {})) diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 92bac7d59bb..a986d347782 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react' +import { useMemo, useState } from 'react' import { Keyboard } from 'react-native' import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' @@ -33,26 +33,25 @@ import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script' import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation' export function NewWorktreeModal(props: NewWorktreeModalProps) { - const openEpochRef = useRef(0) - const wasVisibleRef = useRef(false) - const clientEpochRef = useRef({ client: props.client, epoch: 0 }) - // Why: each drawer opening is a fresh form session; remounting resets local // form state before paint instead of clearing it in a visible-prop Effect. - if (props.visible && !wasVisibleRef.current) { - openEpochRef.current += 1 - } - wasVisibleRef.current = props.visible - if (clientEpochRef.current.client !== props.client) { - clientEpochRef.current = { client: props.client, epoch: clientEpochRef.current.epoch + 1 } + // State, not a ref: react-native-screens freezes a blurred screen by suspending + // this subtree, and a counter bumped during a render React then throws away + // would restart the session for an opening that never committed. + const [session, setSession] = useState({ openEpoch: 0, visible: props.visible }) + if (session.visible !== props.visible) { + setSession({ + openEpoch: props.visible ? session.openEpoch + 1 : session.openEpoch, + visible: props.visible + }) } - return ( - - ) + // Why: key the session on the HOST, never on the RpcClient object. A reconnect, + // forceReconnect, or foreground revival swaps that object for the same host + // (see useHostClient), and keying on it silently remounted this form mid-edit + // and threw away the picked source. Every client-scoped hook below already + // drops responses from a superseded client, so no remount is needed for that. + return } function NewWorktreeModalContent(props: NewWorktreeModalProps) { diff --git a/mobile/src/components/agent-monitoring-indicators.test.ts b/mobile/src/components/agent-monitoring-indicators.test.ts index 749d2636b98..eefa2137f14 100644 --- a/mobile/src/components/agent-monitoring-indicators.test.ts +++ b/mobile/src/components/agent-monitoring-indicators.test.ts @@ -19,7 +19,7 @@ const { animationLoop, animationTiming, setValue } = vi.hoisted(() => ({ setValue: vi.fn() })) -vi.mock('lucide-react-native', () => ({ Radio: 'Radio' })) +vi.mock('lucide-react-native', () => ({ Activity: 'Activity' })) vi.mock('react-native', () => ({ Animated: { Value: function Value() { @@ -48,12 +48,12 @@ describe('mobile monitoring indicators', () => { renderer = null }) - it('renders a static Radio for a monitoring agent', async () => { + it('renders a static Activity heartbeat for a monitoring agent', async () => { await act(async () => { renderer = create(createElement(AgentStateDot, { state: 'monitoring' })) }) - expect(renderer?.root.findByType('Radio').props).toMatchObject({ + expect(renderer?.root.findByType('Activity').props).toMatchObject({ color: DESKTOP_WORKING_COLOR, size: 10 }) @@ -61,14 +61,14 @@ describe('mobile monitoring indicators', () => { expect(animationLoop).not.toHaveBeenCalled() }) - it('renders a static Radio for an all-monitoring workspace', async () => { + it('renders a static Activity heartbeat for an all-monitoring workspace', async () => { await act(async () => { renderer = create( createElement(AgentSpinner, { status: 'working', workingMode: 'monitoring' }) ) }) - expect(renderer?.root.findByType('Radio').props).toMatchObject({ + expect(renderer?.root.findByType('Activity').props).toMatchObject({ color: DESKTOP_WORKING_COLOR, size: 12 }) diff --git a/mobile/src/components/bottom-drawer-window-handback.test.ts b/mobile/src/components/bottom-drawer-window-handback.test.ts new file mode 100644 index 00000000000..a1d9c790a69 --- /dev/null +++ b/mobile/src/components/bottom-drawer-window-handback.test.ts @@ -0,0 +1,160 @@ +import { createElement, useEffect } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const withTimingCalls = vi.hoisted(() => [] as { to: number; duration: number | undefined }[]) +const sharedWrites = vi.hoisted(() => [] as { key: string; value: unknown }[]) + +vi.mock('react-native', () => ({ + BackHandler: { addEventListener: () => ({ remove: () => {} }) }, + Keyboard: { + addListener: () => ({ remove: () => {} }), + dismiss: () => {}, + metrics: () => null + }, + Modal: 'Modal', + Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { + create: (styles: T) => styles, + absoluteFillObject: {} + }, + View: 'View', + useWindowDimensions: () => ({ width: 440, height: 956 }) +})) +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 62, bottom: 34, left: 0, right: 0 }) +})) +vi.mock('react-native-gesture-handler', () => { + const chain: Record = {} + for (const method of [ + 'activeOffsetY', + 'simultaneousWithExternalGesture', + 'onBegin', + 'onUpdate', + 'onEnd' + ]) { + chain[method] = () => chain + } + return { + Gesture: { Pan: () => chain, Native: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) +vi.mock('react-native-reanimated', () => { + function makeShared(key: string, initial: number) { + let value = initial + return { + get value() { + return value + }, + set value(next: number) { + value = next + sharedWrites.push({ key, value: next }) + } + } + } + let sharedIndex = 0 + return { + default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' }, + useSharedValue: (initial: number) => makeShared(`shared-${sharedIndex++}`, initial), + useAnimatedStyle: () => ({}), + useAnimatedScrollHandler: () => () => {}, + withSpring: (to: number) => to, + withTiming: (to: number, config?: { duration?: number }) => { + withTimingCalls.push({ to, duration: config?.duration }) + return to + }, + runOnJS: (fn: () => void) => fn, + interpolate: () => 0, + Extrapolation: { CLAMP: 'clamp' } + } +}) + +import { MountedBottomDrawer } from './mounted-bottom-drawer' + +const noop = () => {} + +// Counts mounts of the sheet's CONTENT, so a test can tell an ordinary re-render +// apart from the subtree rebuild the fix relies on to repaint a stale native view. +const sheetBodyMounts = { count: 0 } +function SheetBody() { + useEffect(() => { + sheetBodyMounts.count += 1 + }, []) + return null +} + +function drawer(interactive: boolean) { + return createElement( + MountedBottomDrawer, + { visible: true, interactive, onClose: noop, onHidden: noop }, + createElement(SheetBody) + ) +} + +function render(interactive: boolean): ReactTestRenderer { + let renderer!: ReactTestRenderer + act(() => { + renderer = create(drawer(interactive)) + }) + return renderer +} + +function update(renderer: ReactTestRenderer, interactive: boolean): void { + act(() => { + renderer.update(drawer(interactive)) + }) +} + +// A sheet pinned under a fill picker keeps progress at 1 the whole time, so +// nothing re-applies its enter transform when the picker gives the window back. +// On device that left the create form laid out but unpainted — a dimmed screen +// with no sheet and no way back except dismissing the whole modal. +describe('bottom drawer window hand-back', () => { + afterEach(() => { + withTimingCalls.length = 0 + sharedWrites.length = 0 + sheetBodyMounts.count = 0 + }) + + it('re-asserts the enter transform when a pinned sheet takes the window back', () => { + const renderer = render(true) + update(renderer, false) + const beforeHandback = withTimingCalls.filter((call) => call.to === 1).length + + update(renderer, true) + + expect(withTimingCalls.filter((call) => call.to === 1).length).toBe(beforeHandback + 1) + act(() => renderer.unmount()) + }) + + // Shared-value writes cannot heal a sheet whose native view was rebuilt under + // Reanimated (verified on device); only a fresh view repaints. Counting content + // mounts asserts the subtree actually rebuilt, not merely that a prop changed. + it('rebuilds the sheet subtree when it takes the window back', () => { + const renderer = render(true) + update(renderer, false) + const mountsWhilePinned = sheetBodyMounts.count + expect(mountsWhilePinned).toBe(1) + + update(renderer, true) + + expect(sheetBodyMounts.count).toBe(2) + act(() => renderer.unmount()) + }) + + it('does not re-assert or rebuild while the sheet stays pinned', () => { + const renderer = render(true) + update(renderer, false) + const pinned = withTimingCalls.filter((call) => call.to === 1).length + + update(renderer, false) + + expect(withTimingCalls.filter((call) => call.to === 1).length).toBe(pinned) + expect(sheetBodyMounts.count).toBe(1) + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx index 3331d7298d8..cea320553f1 100644 --- a/mobile/src/components/mounted-bottom-drawer.tsx +++ b/mobile/src/components/mounted-bottom-drawer.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useEffect, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { View, Pressable, @@ -89,6 +89,28 @@ export function MountedBottomDrawer({ }) : undefined + // Why: a sheet pinned under a fill picker holds progress at its target while the + // picker owns the window, so nothing re-applies its transform when the picker + // leaves. If the native view was rebuilt underneath, it keeps a stale transform + // and never paints — a dimmed, dead screen the user can only escape by dismissing + // the whole modal. A shared-value write alone cannot heal that (verified on + // device: an unchanged or nudged style lands on the stale native binding), so the + // remount is what repaints; the writes below keep the shared values authoritative + // for the fresh view, which matters because the drawer swap (166ms) hands back + // before the 180ms enter animation has finished. + const [windowEpoch, setWindowEpoch] = useState(0) + const wasInteractiveRef = useRef(interactive) + useEffect(() => { + const tookWindowBack = visible && interactive && !wasInteractiveRef.current + wasInteractiveRef.current = interactive + if (!tookWindowBack) { + return + } + translateY.value = 0 + progress.value = withTiming(1, { duration: SHOW_DURATION }) + setWindowEpoch((epoch) => epoch + 1) + }, [interactive, visible]) + useEffect(() => { if (visible) { translateY.value = 0 @@ -358,6 +380,8 @@ export function MountedBottomDrawer({ { - it('keeps the form under the source picker and its close transition', () => { + it('keeps the form under the source picker', () => { + expect(resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView: 'source' })).toBe( + true + ) + }) + + // The host Modal stays mounted across every drawer swap, so a transition that + // renders no sheet is a transparent tap-swallowing screen with no way out if + // the queued transition never lands. + it('never leaves the mounted modal without a sheet during a drawer swap', () => { expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'source', - formPinnedUnderSource: true - }) - ).toBe(true) - expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'transition', - formPinnedUnderSource: true - }) + resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView: 'transition' }) ).toBe(true) }) - it('hides the form for sequential repo/agent transitions', () => { - expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'transition', - formPinnedUnderSource: false - }) - ).toBe(false) - expect( - resolveNewWorktreeFormSheetVisible({ - modalVisible: true, - drawerView: 'project', - formPinnedUnderSource: false - }) - ).toBe(false) + it('yields the window to the content-sized pickers and the trust prompt', () => { + for (const drawerView of ['project', 'runTarget', 'agent', 'trust']) { + expect(resolveNewWorktreeFormSheetVisible({ modalVisible: true, drawerView })).toBe(false) + } + }) + + it('hides everything once the modal closes', () => { + expect(resolveNewWorktreeFormSheetVisible({ modalVisible: false, drawerView: 'form' })).toBe( + false + ) }) }) diff --git a/mobile/src/components/new-worktree-form-sheet-visibility.ts b/mobile/src/components/new-worktree-form-sheet-visibility.ts index 57ad63c0ef6..5125f112b7c 100644 --- a/mobile/src/components/new-worktree-form-sheet-visibility.ts +++ b/mobile/src/components/new-worktree-form-sheet-visibility.ts @@ -1,16 +1,18 @@ -// Why: pin the create form under the fill-height name picker (and during that -// picker's close transition) so dismiss reveals the original content height. +// Why: the create form is the modal's floor. Every other drawer layers above it, +// so the shared modal host is never mounted with no sheet in it — a beat with a +// transparent full-screen host swallows taps, and a dropped transition timer +// would strand the user there with no way back (#16165 follow-up). export function resolveNewWorktreeFormSheetVisible(input: { modalVisible: boolean drawerView: string - formPinnedUnderSource: boolean }): boolean { if (!input.modalVisible) { return false } - if (input.drawerView === 'form' || input.drawerView === 'source') { - return true - } - return input.drawerView === 'transition' && input.formPinnedUnderSource + return ( + input.drawerView === 'form' || + input.drawerView === 'source' || + input.drawerView === 'transition' + ) } diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.test.ts b/mobile/src/components/use-new-worktree-drawer-navigation.test.ts new file mode 100644 index 00000000000..347e309c5e0 --- /dev/null +++ b/mobile/src/components/use-new-worktree-drawer-navigation.test.ts @@ -0,0 +1,63 @@ +import { createElement } from 'react' +import { act, create } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + useNewWorktreeDrawerNavigation, + type NewWorktreeDrawerView +} from './use-new-worktree-drawer-navigation' + +type Nav = ReturnType + +function renderNavigation(modalVisible: boolean): { current: Nav } { + const handle = { current: null as unknown as Nav } + function Probe(props: { modalVisible: boolean }) { + handle.current = useNewWorktreeDrawerNavigation(props.modalVisible) + return null + } + act(() => { + create(createElement(Probe, { modalVisible })) + }) + return handle +} + +describe('useNewWorktreeDrawerNavigation', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + // BottomDrawerModalHost keeps one native Modal mounted for the whole flow. A + // transition beat that renders no sheet is therefore a transparent full-screen + // window that eats every tap, and the queued timer is the only way out of it. + it('shows the form sheet for the whole transition, even if the queued timer never lands', () => { + const nav = renderNavigation(true) + act(() => nav.current.openSourceDrawer()) + expect(nav.current.drawerView).toBe('source') + + act(() => nav.current.transitionDrawer('form')) + expect(nav.current.drawerView).toBe('transition') + expect(nav.current.formSheetVisible).toBe(true) + expect(nav.current.formSheetInteractive).toBe(false) + }) + + it('keeps a sheet on screen while swapping to a content-sized picker', () => { + const nav = renderNavigation(true) + act(() => nav.current.transitionDrawer('agent')) + + expect(nav.current.drawerView).toBe('transition') + expect(nav.current.formSheetVisible).toBe(true) + + act(() => vi.advanceTimersByTime(500)) + expect(nav.current.drawerView).toBe('agent') + expect(nav.current.formSheetVisible).toBe(false) + }) + + it('hands the form back interactive once the transition lands', () => { + const nav = renderNavigation(true) + act(() => nav.current.openSourceDrawer()) + act(() => nav.current.transitionDrawer('form')) + act(() => vi.advanceTimersByTime(500)) + + expect(nav.current.drawerView).toBe('form') + expect(nav.current.formSheetVisible).toBe(true) + expect(nav.current.formSheetInteractive).toBe(true) + }) +}) diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.ts b/mobile/src/components/use-new-worktree-drawer-navigation.ts index 4f3261cb685..34dfe956e53 100644 --- a/mobile/src/components/use-new-worktree-drawer-navigation.ts +++ b/mobile/src/components/use-new-worktree-drawer-navigation.ts @@ -23,11 +23,10 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): { openSourceDrawer: () => void } { const [drawerView, setDrawerView] = useState('form') - const formPinnedUnderSourceRef = useRef(false) const drawerTransitionTimerRef = useRef | null>(null) // Why: cancel any queued transition and reset when the modal closes, so a - // timer can't land after close and leave a stale drawer/pin for the next open. + // timer can't land after close and leave a stale drawer for the next open. useEffect(() => { if (modalVisible) { return @@ -36,7 +35,6 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): { clearTimeout(drawerTransitionTimerRef.current) drawerTransitionTimerRef.current = null } - formPinnedUnderSourceRef.current = false setDrawerView('form') }, [modalVisible]) @@ -55,31 +53,23 @@ export function useNewWorktreeDrawerNavigation(modalVisible: boolean): { setDrawerView('transition') drawerTransitionTimerRef.current = setTimeout(() => { drawerTransitionTimerRef.current = null - if (nextView === 'form') { - formPinnedUnderSourceRef.current = false - } setDrawerView(nextView) }, NEW_WORKTREE_DRAWER_TRANSITION_MS) } function openSourceDrawer(): void { - // Why: same-beat open; pin form under fill picker so outer content height - // is preserved when the name dialog dismisses. + // Why: same-beat open; the form stays mounted underneath so the outer + // content height is preserved when the fill picker dismisses. if (drawerTransitionTimerRef.current) { clearTimeout(drawerTransitionTimerRef.current) } drawerTransitionTimerRef.current = null - formPinnedUnderSourceRef.current = true setDrawerView('source') } return { drawerView, - formSheetVisible: resolveNewWorktreeFormSheetVisible({ - modalVisible, - drawerView, - formPinnedUnderSource: formPinnedUnderSourceRef.current - }), + formSheetVisible: resolveNewWorktreeFormSheetVisible({ modalVisible, drawerView }), formSheetInteractive: drawerView === 'form', transitionDrawer, openSourceDrawer diff --git a/mobile/src/tasks/smart-source-paste-concurrency.test.ts b/mobile/src/tasks/smart-source-paste-concurrency.test.ts new file mode 100644 index 00000000000..fe97fe462d3 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-concurrency.test.ts @@ -0,0 +1,59 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { useSmartWorkspaceSource } from './use-smart-workspace-source' + +function Probe(props: { client: RpcClient; query: string }) { + useSmartWorkspaceSource({ + client: props.client, + enabled: true, + mode: 'smart', + query: props.query, + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: false, + linearAvailable: false, + mrStateFilter: 'opened', + repos: [{ id: 'repo-1', displayName: 'orca', slug: { owner: 'stablyai', repo: 'orca' } }] + }) + return null +} + +// The picker makes two independent host round trips for a pasted PR number: the +// provider fan-out and the exact-item lookup. Awaiting the fan-out first stacked +// them, so the rows appeared a whole extra round trip late. +describe('smart source paste lookup concurrency', () => { + const mounted: ReactTestRenderer[] = [] + + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + act(() => { + for (const renderer of mounted) { + renderer.unmount() + } + }) + mounted.length = 0 + vi.useRealTimers() + }) + + it('issues the pasted-number lookup while the fan-out is still in flight', async () => { + const sent: string[] = [] + const sendRequest = vi.fn((method: string) => { + sent.push(method) + // Nothing ever settles: only requests issued concurrently can be observed. + return new Promise(() => {}) + }) + const client = { sendRequest } as unknown as RpcClient + + await act(async () => { + mounted.push(create(createElement(Probe, { client, query: '16831' }))) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(sent).toContain('github.listWorkItems') + expect(sent).toContain('github.workItem') + }) +}) diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts index 86c6ca2204a..a57c9f17d7d 100644 --- a/mobile/src/tasks/use-smart-workspace-source.ts +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -180,6 +180,64 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { } } +type PasteLookup = { paste: PasteResolved; crossRepoPrompt: SmartCrossRepoPrompt | null } + +const EMPTY_PASTE_LOOKUP: PasteLookup = { + paste: { github: null, gitlab: null }, + crossRepoPrompt: null +} + +// Resolves a pasted issue/PR/MR reference to the exact item it names. +async function resolvePastedItem(args: { + client: RpcClient + intent: NonNullable> + repoId: string + repos: readonly PasteRepoCandidate[] + repoSlugCache: Map +}): Promise { + const { client, intent, repoId, repos, repoSlugCache } = args + if (intent.kind === 'github-number') { + return { + paste: { + github: await lookupGitHubItemByNumber(client, repoId, intent.number), + gitlab: null + }, + crossRepoPrompt: null + } + } + if (intent.kind === 'github-link') { + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + if (matchingRepo && matchingRepo.id !== repoId) { + return { + paste: { github: null, gitlab: null }, + crossRepoPrompt: { link: intent.link, matchingRepo } + } + } + return { + paste: { + github: await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ), + gitlab: null + }, + crossRepoPrompt: null + } + } + return { + paste: { github: null, gitlab: await lookupGitLabItemByPath(client, repoId, intent.link) }, + crossRepoPrompt: null + } +} + async function runSmartSearch(args: { client: RpcClient mode: SmartNameMode @@ -199,42 +257,21 @@ async function runSmartSearch(args: { crossRepoPrompt: SmartCrossRepoPrompt | null }> { const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args - const fan = await fanOutSmartSearch(args) - const paste: PasteResolved = { github: null, gitlab: null } - let crossRepoPrompt: SmartCrossRepoPrompt | null = null - const intent = mode === 'branches' || dismissedPasteRef.current === query.trim() ? null : resolvePasteIntent(query) - if (intent && repoId) { - try { - if (intent.kind === 'github-number') { - paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) - } else if (intent.kind === 'github-link') { - const matchingRepo = await findRepoMatchingSlugForPaste( - client, - repos, - intent.link.slug, - repoSlugCache + // Why: the paste lookup and the provider fan-out hit different host endpoints, + // so awaiting the fan-out first stacked two full round trips on the one path a + // user is most likely to take — typing a PR/issue number. Run them together. + const [fan, pasteLookup] = await Promise.all([ + fanOutSmartSearch(args), + intent && repoId + ? resolvePastedItem({ client, intent, repoId, repos, repoSlugCache }).catch( + // Best-effort paste resolution; fall back to the fan-out results. + () => EMPTY_PASTE_LOOKUP ) - if (matchingRepo && matchingRepo.id !== repoId) { - crossRepoPrompt = { link: intent.link, matchingRepo } - } else { - paste.github = await lookupGitHubItemByOwnerRepo( - client, - repoId, - intent.link.slug, - intent.link.number, - intent.link.type - ) - } - } else if (intent.kind === 'gitlab-link') { - paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) - } - } catch { - // Best-effort paste resolution; fall back to the fan-out results. - } - } - return { fan, paste, crossRepoPrompt } + : Promise.resolve(EMPTY_PASTE_LOOKUP) + ]) + return { fan, paste: pasteLookup.paste, crossRepoPrompt: pasteLookup.crossRepoPrompt } } diff --git a/mobile/src/worktree/workspace-view-settings.test.ts b/mobile/src/worktree/workspace-view-settings.test.ts index 23f898da60e..81ea12621c4 100644 --- a/mobile/src/worktree/workspace-view-settings.test.ts +++ b/mobile/src/worktree/workspace-view-settings.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses' import { applyDesktopViewSettings, + buildWorkspaceViewSettingsUpdate, groupModeFromDesktop, groupModeToDesktop, sortModeFromDesktop, @@ -83,3 +84,51 @@ describe('applyDesktopViewSettings', () => { expect(next.groupMode).toBe('repo') }) }) + +describe('buildWorkspaceViewSettingsUpdate', () => { + const next: MobileViewState = { + ...base, + alwaysShowDefaultBranch: true, + groupMode: 'workspaceStatus', + sortMode: 'name', + hideSleeping: true, + hideDefaultBranch: true, + filterRepoIds: ['repo-1'], + collapsedGroups: ['g1'] + } + + it('carries only the fields the patch touched (STA-5781)', () => { + expect(buildWorkspaceViewSettingsUpdate({ hideSleeping: true }, next)).toEqual({ + hideSleepingWorkspaces: true + }) + expect(buildWorkspaceViewSettingsUpdate({ groupMode: 'workspaceStatus' }, next)).toEqual({ + groupBy: 'workspace-status' + }) + expect(buildWorkspaceViewSettingsUpdate({ collapsedGroups: ['g1'] }, next)).toEqual({ + collapsedGroups: ['g1'] + }) + }) + + it('maps a multi-field reset patch without dragging untouched siblings along', () => { + const update = buildWorkspaceViewSettingsUpdate( + { hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] }, + { ...next, hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] } + ) + expect(update).toEqual({ + hideSleepingWorkspaces: false, + hideDefaultBranchWorkspace: false, + filterRepoIds: [] + }) + }) + + it('never invents alwaysShowDefaultBranchWorkspace for patches that omit it (#8873)', () => { + expect( + 'alwaysShowDefaultBranchWorkspace' in + buildWorkspaceViewSettingsUpdate({ hideSleeping: true }, next) + ).toBe(false) + }) + + it('returns an empty update for an empty patch', () => { + expect(buildWorkspaceViewSettingsUpdate({}, next)).toEqual({}) + }) +}) diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index a90b54590d9..f1d17189adc 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -56,6 +56,49 @@ export function sortModeFromDesktop( return sortBy && SORT_VALUES.includes(sortBy) ? sortBy : null } +/** + * Map a user edit to the ui.set payload, carrying only the fields the edit touched. + * + * Why patch-only (STA-5781): the shared store is edited concurrently by desktop and + * web clients, and this screen's mirror refreshes only on connect/focus. Echoing the + * whole snapshot let a stale mirror revert sibling fields another client had just + * changed; the host merges partial updates field-by-field, so sending only the + * touched fields is lossless. This also supersedes the old #8873 special case: + * alwaysShowDefaultBranchWorkspace has no mobile toggle, so it is simply never in a + * patch and can no longer revert a desktop opt-out. + */ +export function buildWorkspaceViewSettingsUpdate( + patch: Partial, + next: MobileViewState +): WorkspaceViewSettings { + const update: WorkspaceViewSettings = {} + if ('groupMode' in patch) { + update.groupBy = groupModeToDesktop(next.groupMode) + } + if ('sortMode' in patch) { + update.sortBy = next.sortMode + } + if ('hideSleeping' in patch) { + update.hideSleepingWorkspaces = next.hideSleeping + } + if ('hideDefaultBranch' in patch) { + update.hideDefaultBranchWorkspace = next.hideDefaultBranch + } + if ('alwaysShowDefaultBranch' in patch) { + update.alwaysShowDefaultBranchWorkspace = next.alwaysShowDefaultBranch + } + if ('filterRepoIds' in patch) { + update.filterRepoIds = next.filterRepoIds + } + if ('collapsedGroups' in patch) { + update.collapsedGroups = next.collapsedGroups + } + if ('workspaceStatuses' in patch) { + update.workspaceStatuses = [...next.workspaceStatuses] + } + return update +} + export type MobileViewState = { groupMode: MobileGroupMode sortMode: MobileSortMode diff --git a/package.json b/package.json index c8e59299c70..7d0cc956642 100644 --- a/package.json +++ b/package.json @@ -159,6 +159,7 @@ "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "posthog-node": "^5.33.3", + "proper-lockfile": "4.1.2", "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "^17.0.8", @@ -201,6 +202,7 @@ "@tiptap/react": "^3.22.5", "@tiptap/starter-kit": "^3.22.5", "@types/node": "^25.6.0", + "@types/proper-lockfile": "^4.1.4", "@types/qrcode": "^1.5.6", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c36d26ae93..75b6ec89192 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,9 @@ importers: posthog-node: specifier: ^5.33.3 version: 5.33.3 + proper-lockfile: + specifier: 4.1.2 + version: 4.1.2 psl: specifier: 1.15.0 version: 1.15.0 @@ -194,6 +197,9 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.9.5 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -3172,6 +3178,9 @@ packages: '@types/node@25.9.5': resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -3390,7 +3399,6 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - '@xterm/addon-fit@0.12.0-beta.287': resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==} peerDependencies: @@ -9435,6 +9443,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.0 + '@types/qrcode@1.5.6': dependencies: '@types/node': 25.9.5 diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts index d19666556c6..cd48e0f4c32 100644 --- a/src/cli/handlers/orchestration-worker-cli.test.ts +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -164,6 +164,52 @@ describe('orchestration worker-start CLI contract', () => { expect(process.exitCode).toBe(1) }) + it('prints the Structured Chat recovery action for a refused worker start', async () => { + callMock.mockResolvedValue({ + result: { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'failed', + failedStage: 'dispatch_input', + lastError: + 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.', + effects: [], + residualResources: [] + } + }) + + await ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags: new Map([ + ['task', 'task_1'], + ['terminal', 'term_worker'], + ['from', 'term_coord'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: false + } as never) + + const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as + | ((result: { + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + }) => string) + | undefined + expect( + formatter?.({ + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'failed', + failedStage: 'dispatch_input', + lastError: + 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + }) + ).toMatch(/Structured Chat.*Switch it to Terminal.*orca orchestration worker-start/s) + }) + it('prints a reveal warning for a live background worker', async () => { callMock.mockResolvedValue({ result: { diff --git a/src/cli/handlers/terminal.test.ts b/src/cli/handlers/terminal.test.ts index 7d08658fc66..18832ba28a6 100644 --- a/src/cli/handlers/terminal.test.ts +++ b/src/cli/handlers/terminal.test.ts @@ -5,6 +5,8 @@ import { printHelp } from '../help' import { COMMAND_SPECS } from '../specs' import { TERMINAL_HANDLERS } from './terminal' +const ORIGINAL_EXIT_CODE = process.exitCode + describe('terminal close CLI', () => { afterEach(() => { vi.restoreAllMocks() @@ -65,6 +67,7 @@ describe('terminal close CLI', () => { describe('terminal send CLI', () => { afterEach(() => { vi.restoreAllMocks() + process.exitCode = ORIGINAL_EXIT_CODE }) it('marks combined text and Enter as an agent prompt candidate', async () => { @@ -94,6 +97,44 @@ describe('terminal send CLI', () => { }) }) + it('explains that Structured Chat blocked a refused send and how to recover', async () => { + const call = vi.fn().mockResolvedValue({ + result: { + send: { + handle: 'term-1', + accepted: false, + bytesWritten: 0, + agentSessionRefusal: { + code: 'agent_session_conflict', + sessionId: 'session-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7 + } + } + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + process.exitCode = undefined + + await TERMINAL_HANDLERS['terminal send']({ + flags: new Map([ + ['terminal', 'term-1'], + ['text', 'review'], + ['enter', true] + ]), + client: { call } as unknown as RuntimeClient, + cwd: '/tmp/worktree', + json: false + }) + + expect(console.log).toHaveBeenCalledWith( + expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s) + ) + expect(process.exitCode).toBe(1) + }) + it('keeps text-only and bare Enter sends as direct terminal input', async () => { const call = vi.fn().mockResolvedValue({ result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } } diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index 00c0a5cd807..d0ced262a43 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -115,6 +115,9 @@ export const TERMINAL_HANDLERS: Record = { client: { id: 'orca-cli', type: 'desktop' } }) printResult(result, json, formatTerminalSend) + if (!result.result.send.accepted) { + process.exitCode = 1 + } }, 'terminal wait': async ({ flags, client, cwd, json }) => { const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms') diff --git a/src/cli/terminal-format.ts b/src/cli/terminal-format.ts index 4d57e7693a1..edf4cbaa22c 100644 --- a/src/cli/terminal-format.ts +++ b/src/cli/terminal-format.ts @@ -1,4 +1,5 @@ import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict' +import { structuredChatPtyWriteRefusalCopy } from '../shared/agent-session-pty-write-refusal-copy' import type { RuntimeTerminalClose, RuntimeTerminalCreate, @@ -181,6 +182,12 @@ function formatTerminalReadLimitedWarning(terminal: RuntimeTerminalRead): string } export function formatTerminalSend(result: { send: RuntimeTerminalSend }): string { + if (result.send.agentSessionRefusal) { + const copy = structuredChatPtyWriteRefusalCopy(result.send.agentSessionRefusal, 'terminal-send') + if (copy) { + return copy + } + } return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.` } diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index 06502cd4a0b..4ba72fdd43b 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -67,6 +67,12 @@ const aiVaultSessionSchema = z.object({ queuedMessageCount: z.number().default(0), subagentTranscriptCount: z.number().default(0), resumeCommand: z.string(), + structuredSession: z + .object({ + sessionId: z.string().min(1).max(512), + workspaceId: z.string().min(1).max(512) + }) + .optional(), subagent: z .object({ parentSessionId: z.string(), diff --git a/src/main/ai-vault/structured-session-ownership.test.ts b/src/main/ai-vault/structured-session-ownership.test.ts new file mode 100644 index 00000000000..b7104e50a0c --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { StructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + assertLegacyAiVaultResumeAllowed, + assertLegacyAiVaultResumeCommandAllowed, + projectStructuredAiVaultSessions +} from './structured-session-ownership' + +const PROVIDER_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +describe('structured AI Vault ownership', () => { + afterEach(() => setStructuredAgentSessionHost(null)) + + it('hides owned rows from legacy clients and annotates them for capable clients', () => { + installOwnership() + const result = listResult() + + expect(projectStructuredAiVaultSessions(result, false).sessions).toEqual([]) + expect(projectStructuredAiVaultSessions(result, true).sessions[0]).toMatchObject({ + structuredSession: { sessionId: 'session-alpha', workspaceId: 'workspace-1' } + }) + }) + + it('derives typed refusals from the single writer predicate for live and proving leases', async () => { + installOwnership() + expect(() => + assertLegacyAiVaultResumeAllowed({ + agent: 'codex', + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + executionHostId: 'local' + }) + ).toThrow('agent_session_conflict') + + installOwnership({ + lease: agentSessionLeaseFixture({ + handoffStage: 'new-owner-proving', + claimStatus: 'reserved', + ownerProcess: null + }) + }) + await expect( + assertLegacyAiVaultResumeCommandAllowed( + `codex resume '${PROVIDER_SESSION}'`, + async () => undefined + ) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it.each([ + `codex resume --last`, + `claude --resume`, + `claude -r`, + `claude --continue`, + `claude -c`, + // `--continue` takes no session id, so the trailing token is a prompt — + // reading it as a target would admit a writer onto the owned session. + `claude --continue "keep going"`, + `claude -c 019fd532-7c11-7a90-b6de-4e1a2c3d5f61` + ])('refuses resume commands without a provably different target: %s', async (command) => { + installOwnership(command.startsWith('claude') ? { provider: 'claude' } : {}) + + await expect( + assertLegacyAiVaultResumeCommandAllowed(command, async () => undefined) + ).rejects.toThrow('agent_session_conflict') + }) + + it('allows a resume command that names a different provider session', async () => { + installOwnership() + + await expect( + assertLegacyAiVaultResumeCommandAllowed( + 'codex resume 019fd532-7c11-7a90-b6de-4e1a2c3d5f61', + async () => undefined + ) + ).resolves.toBeUndefined() + }) +}) + +function installOwnership(overrides: Partial = {}): void { + const ownership: StructuredProviderSessionOwnership = { + sessionId: 'session-alpha', + workspaceId: 'workspace-1', + provider: 'codex', + providerSessionId: PROVIDER_SESSION, + lease: agentSessionLeaseFixture(), + ...overrides + } + const record = agentSessionRecordFixture(ownership.lease) + setStructuredAgentSessionHost({ + deps: { + store: { + listRecords: () => [ + { + ...record, + sessionId: ownership.sessionId, + location: { ...record.location, workspaceId: ownership.workspaceId }, + provider: ownership.provider, + providerHandleChain: [ + { + ...record.providerHandleChain[0]!, + handle: { provider: ownership.provider, threadId: ownership.providerSessionId } + } + ], + lease: { ...ownership.lease, sessionId: ownership.sessionId } + } + ] + } + } + } as never) +} + +function listResult(): AiVaultListResult { + const session: AiVaultSession = { + id: `local:codex:${PROVIDER_SESSION}`, + executionHostId: 'local', + agent: 'codex', + sessionId: PROVIDER_SESSION, + title: 'Owned', + cwd: '/repo', + branch: null, + model: null, + filePath: `/sessions/rollout-${PROVIDER_SESSION}.jsonl`, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-08-11T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `codex resume '${PROVIDER_SESSION}'`, + subagent: null + } + return { sessions: [session], issues: [], scannedAt: '2026-08-11T00:00:00.000Z' } +} diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts new file mode 100644 index 00000000000..03c84f023c6 --- /dev/null +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -0,0 +1,186 @@ +import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + listStructuredProviderSessionOwnership, + type StructuredProviderSessionOwnership +} from '../native-chat/agent-session-wire/structured-provider-session-ownership' + +export function projectStructuredAiVaultSessions( + result: AiVaultListResult, + structuredSupported: boolean +): AiVaultListResult { + const host = getStructuredAgentSessionHost() + if (!host) { + return result + } + const sessions = result.sessions.flatMap((session) => { + const ownership = findSessionOwnership(session) + if (!ownership) { + return [session] + } + if (!structuredSupported) { + return [] + } + return [ + { + ...session, + structuredSession: { + sessionId: ownership.sessionId, + workspaceId: ownership.workspaceId + } + } + ] + }) + return sessions.length === result.sessions.length && + sessions.every((row, index) => row === result.sessions[index]) + ? result + : { ...result, sessions } +} + +export function assertLegacyAiVaultResumeAllowed(args: AiVaultPrepareSessionResumeArgs): void { + const ownership = findResumeOwnership(args) + if (ownership) { + refuseLegacyWriter(ownership) + } +} + +export async function assertLegacyAiVaultResumeCommandAllowed( + command: string, + ensureHost: () => Promise +): Promise { + if (!isPotentialStructuredResumeCommand(command)) { + return + } + await ensureHost() + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + for (const ownership of listOwnership()) { + if (isResumeCommandFor(command, ownership)) { + refuseLegacyWriter(ownership) + } + } +} + +function isPotentialStructuredResumeCommand(command: string): boolean { + return parseResumeInvocation(command) !== null +} + +function findSessionOwnership(session: AiVaultSession): StructuredProviderSessionOwnership | null { + if (session.agent !== 'codex' && session.agent !== 'claude') { + return null + } + return findOwnership(session.agent, session.sessionId) +} + +function findResumeOwnership( + args: AiVaultPrepareSessionResumeArgs +): StructuredProviderSessionOwnership | null { + if (args.agent !== 'codex' && args.agent !== 'claude') { + return null + } + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + const exact = args.sessionId ? findOwnership(args.agent, args.sessionId) : null + if (exact) { + return exact + } + const fileName = args.filePath.split(/[\\/]/).at(-1) ?? '' + return ( + listOwnership().find( + (ownership) => + ownership.provider === args.agent && fileName.includes(ownership.providerSessionId) + ) ?? null + ) +} + +function findOwnership( + provider: 'claude' | 'codex', + providerSessionId: string +): StructuredProviderSessionOwnership | null { + return ( + listOwnership().find( + (ownership) => + ownership.provider === provider && ownership.providerSessionId === providerSessionId + ) ?? null + ) +} + +function listOwnership(): StructuredProviderSessionOwnership[] { + const host = getStructuredAgentSessionHost() + return host ? listStructuredProviderSessionOwnership(host.deps.store.listRecords()) : [] +} + +function isResumeCommandFor( + command: string, + ownership: StructuredProviderSessionOwnership +): boolean { + const invocation = parseResumeInvocation(command) + if (!invocation || invocation.provider !== ownership.provider) { + return false + } + // A target-less resume (--last, --continue, or a bare --resume/-r) may pick + // any provider session, so it cannot be admitted while one is structured. + // Only an explicit target that differs from this owned session is safe. + return invocation.target === null || invocation.target === ownership.providerSessionId +} + +type ResumeInvocation = { + provider: 'codex' | 'claude' + target: string | null +} + +function parseResumeInvocation(command: string): ResumeInvocation | null { + // Keep this deliberately conservative: shell quoting is normalized only + // enough to identify executable/flag tokens; an unrecognized shape is not + // treated as proof that a different session is being resumed. + const tokens = command.match(/"[^"\\]*(?:\\.[^"\\]*)*"|'[^']*'|[^\s]+/g) ?? [] + const normalized = tokens.map((token) => token.replace(/^['"]|['"]$/g, '')) + const executableIndex = normalized.findIndex((token) => + /(?:^|[\\/])(?:codex|claude)(?:\.exe)?$/i.test(token) + ) + if (executableIndex === -1) { + return null + } + const provider = /codex(?:\.exe)?$/i.test(normalized[executableIndex]!) ? 'codex' : 'claude' + const args = normalized.slice(executableIndex + 1) + // `--continue`/`-c` resume the most recent session and never take an id, so a + // following token is a prompt, not a target — they are always target-less. + const targetlessFlags = provider === 'codex' ? [] : ['--continue', '-c'] + const targetlessIndex = args.findIndex((token) => targetlessFlags.includes(token.toLowerCase())) + if (targetlessIndex !== -1) { + return { provider, target: null } + } + const resumeFlags = provider === 'codex' ? ['resume'] : ['--resume', '-r'] + const inlineIndex = args.findIndex( + (token) => + provider === 'claude' && + (token.toLowerCase().startsWith('--resume=') || token.toLowerCase().startsWith('-r=')) + ) + if (inlineIndex !== -1) { + const target = args[inlineIndex]!.slice(args[inlineIndex]!.indexOf('=') + 1) + return { provider, target: target.length > 0 ? target : null } + } + const markerIndex = args.findIndex((token) => resumeFlags.includes(token.toLowerCase())) + if (markerIndex === -1) { + return null + } + const candidate = args[markerIndex + 1] + return { + provider, + target: candidate && !candidate.startsWith('-') ? candidate : null + } +} + +function refuseLegacyWriter(ownership: StructuredProviderSessionOwnership): never { + throw new Error( + agentSessionLeaseAdmitsWriter(ownership.lease) + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + ) +} diff --git a/src/main/claude/claude-structured-owner-identity.ts b/src/main/claude/claude-structured-owner-identity.ts new file mode 100644 index 00000000000..1d13e6ec7c2 --- /dev/null +++ b/src/main/claude/claude-structured-owner-identity.ts @@ -0,0 +1,21 @@ +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' + +export function claudeProviderHandleLink(input: { + sessionId: string + leafUuid: string | null + resumed: boolean + origin?: 'adopted' + fence: number + linkId?: string + observedAt: number +}): AgentSessionProviderHandleLink { + return { + linkId: + input.linkId ?? + `claude-${input.fence}-${input.sessionId}-${input.leafUuid ?? 'empty'}`.slice(0, 128), + handle: { provider: 'claude', sessionId: input.sessionId, leafUuid: input.leafUuid }, + origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), + mintedAtFence: input.fence, + observedAt: input.observedAt + } +} diff --git a/src/main/claude/claude-transcript-branch-proof.ts b/src/main/claude/claude-transcript-branch-proof.ts new file mode 100644 index 00000000000..d7065caa275 --- /dev/null +++ b/src/main/claude/claude-transcript-branch-proof.ts @@ -0,0 +1,128 @@ +import { readFile } from 'node:fs/promises' + +const MAX_CLAUDE_TRANSCRIPT_ANCESTRY = 10_000 + +type TranscriptNode = { + parentUuid: string | null + sessionId: string | null +} + +export type ClaudeTranscriptBranchProof = { + leafUuid: string + relation: 'initial' | 'same' | 'descendant' +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + +function transcriptError(reason: string): Error { + return new Error(`Claude transcript branch proof failed: ${reason}`) +} + +export class ClaudeTranscriptTailIncompleteError extends Error { + constructor() { + super('Claude transcript branch proof failed: malformed JSONL') + this.name = 'ClaudeTranscriptTailIncompleteError' + } +} + +export function proveClaudeTranscriptBranchFromJsonl(input: { + contents: string + providerSessionId: string + previousLeafUuid: string | null +}): ClaudeTranscriptBranchProof { + const nodes = new Map() + let leafUuid: string | null = null + const lines = input.contents.split('\n') + for (const [index, line] of lines.entries()) { + if (!line.trim()) { + continue + } + let record: unknown + try { + record = JSON.parse(line) + } catch { + if (index === lines.length - 1 && !input.contents.endsWith('\n')) { + throw new ClaudeTranscriptTailIncompleteError() + } + throw transcriptError('malformed JSONL') + } + if (typeof record !== 'object' || record === null || Array.isArray(record)) { + throw transcriptError('non-object record') + } + const row = record as Record + if (row.type === 'last-prompt') { + const markerSessionId = nonEmptyString(row.sessionId) + const markerLeaf = nonEmptyString(row.leafUuid) + if (markerSessionId !== input.providerSessionId || !markerLeaf) { + throw transcriptError('invalid last-prompt marker') + } + leafUuid = markerLeaf + } + const uuid = nonEmptyString(row.uuid) + if (!uuid) { + continue + } + const parentUuid = row.parentUuid === null ? null : nonEmptyString(row.parentUuid) + if (row.parentUuid !== null && !parentUuid) { + throw transcriptError(`record ${uuid} has no parent identity`) + } + const sessionId = nonEmptyString(row.sessionId) + const existing = nodes.get(uuid) + if (existing && (existing.parentUuid !== parentUuid || existing.sessionId !== sessionId)) { + throw transcriptError(`record ${uuid} has conflicting ancestry`) + } + nodes.set(uuid, { parentUuid, sessionId }) + } + if (!leafUuid) { + throw transcriptError('missing last-prompt marker') + } + const leaf = nodes.get(leafUuid) + if (!leaf || leaf.sessionId !== input.providerSessionId) { + throw transcriptError('marker leaf is missing from the session graph') + } + const previousLeafUuid = input.previousLeafUuid + if (!previousLeafUuid) { + return { leafUuid, relation: 'initial' } + } + const previous = nodes.get(previousLeafUuid) + if (!previous || previous.sessionId !== input.providerSessionId) { + throw transcriptError('previous cursor is missing from the session graph') + } + if (leafUuid === previousLeafUuid) { + return { leafUuid, relation: 'same' } + } + const visited = new Set() + let cursor: string | null = leafUuid + for (let depth = 0; cursor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) { + if (visited.has(cursor)) { + throw transcriptError('cycle in parentUuid ancestry') + } + visited.add(cursor) + const node = nodes.get(cursor) + if (!node || node.sessionId !== input.providerSessionId) { + throw transcriptError(`missing ancestor ${cursor}`) + } + cursor = node.parentUuid + if (cursor === previousLeafUuid) { + return { leafUuid, relation: 'descendant' } + } + } + if (cursor !== null) { + throw transcriptError('ancestry exceeds the bounded proof limit') + } + throw transcriptError('latest marker is on a sibling branch') +} + +export async function proveClaudeTranscriptBranch(input: { + transcriptPath: string + providerSessionId: string + previousLeafUuid: string | null +}): Promise { + return proveClaudeTranscriptBranchFromJsonl({ + contents: await readFile(input.transcriptPath, 'utf8'), + providerSessionId: input.providerSessionId, + previousLeafUuid: input.previousLeafUuid + }) +} diff --git a/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts b/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts index aa09855eeb5..86067a584d3 100644 --- a/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts +++ b/src/main/codex-accounts/runtime-home-system-resource-materialization.test.ts @@ -61,7 +61,7 @@ describe('CodexRuntimeHomeService', () => { '', '[model_providers.codex-lb]', 'base_url = "https://codex-lb.example.test/v1"', - 'env_key = "CODEX_LB_API_KEY"', + 'env_key = "EXAMPLE_GATEWAY_TOKEN"', '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') diff --git a/src/main/codex-accounts/service-account-add-login.test.ts b/src/main/codex-accounts/service-account-add-login.test.ts index 7f645ad0bea..68aa3e2f3af 100644 --- a/src/main/codex-accounts/service-account-add-login.test.ts +++ b/src/main/codex-accounts/service-account-add-login.test.ts @@ -176,7 +176,7 @@ describe('CodexAccountService config sync', () => { '[model_providers.codex-lb]', 'name = "Codex load balancer"', 'base_url = "https://codex-lb.example.test/v1"', - 'env_key = "CODEX_LB_API_KEY"', + 'env_key = "EXAMPLE_GATEWAY_TOKEN"', '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') diff --git a/src/main/codex/codex-app-server-client.test.ts b/src/main/codex/codex-app-server-client.test.ts index 30d4941ad57..1a1c402352f 100644 --- a/src/main/codex/codex-app-server-client.test.ts +++ b/src/main/codex/codex-app-server-client.test.ts @@ -192,16 +192,21 @@ describe('killCodexAppServerProcessTree', () => { expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) - it('kills the direct app-server process on non-Windows hosts', () => { + it('kills the launcher descendants before the direct process on non-Windows hosts', () => { const child = { pid: 1234, kill: vi.fn(() => true) as ChildProcess['kill'] } - const spawnImpl = vi.fn() as unknown as typeof spawn + const descendants = { unref: vi.fn(), on: vi.fn() } + const spawnImpl = vi.fn(() => descendants) as unknown as typeof spawn killCodexAppServerProcessTree(child, { platform: 'linux', spawnImpl }) - expect(spawnImpl).not.toHaveBeenCalled() + expect(spawnImpl).toHaveBeenCalledWith('pkill', ['-KILL', '-P', '1234'], { stdio: 'ignore' }) + // A missing pkill arrives as an async 'error' event; unhandled, it would + // take down the main process. + expect(descendants.on).toHaveBeenCalledWith('error', expect.any(Function)) + expect(descendants.unref).toHaveBeenCalledOnce() expect(child.kill).toHaveBeenCalledWith('SIGKILL') }) }) diff --git a/src/main/codex/codex-app-server-connection-types.ts b/src/main/codex/codex-app-server-connection-types.ts new file mode 100644 index 00000000000..495846bc1cf --- /dev/null +++ b/src/main/codex/codex-app-server-connection-types.ts @@ -0,0 +1,27 @@ +export type CodexAppServerServerRequest = { + id: number | string + method: string + params: unknown +} + +export type CodexAppServerConnectionHandlers = { + onNotification?: (method: string, params: unknown) => void + onServerRequest?: (request: CodexAppServerServerRequest) => void + onUnhandledFrame?: (kind: string, payload: unknown) => void + onExit?: (error: Error) => void +} + +export type CodexAppServerConnection = { + readonly pid: number | undefined + readonly closed: boolean + request: ( + method: string, + params?: Record, + options?: { timeoutMs?: number } + ) => Promise + notify: (method: string, params?: Record) => void + respond: (id: number | string, result: unknown) => void + respondWithError: (id: number | string, code: number, message: string) => void + /** Resolves true only after the child emitted `exit` or `close`; false is unproven. */ + close: () => Promise +} diff --git a/src/main/codex/codex-app-server-connection.test.ts b/src/main/codex/codex-app-server-connection.test.ts new file mode 100644 index 00000000000..55a9b52deaa --- /dev/null +++ b/src/main/codex/codex-app-server-connection.test.ts @@ -0,0 +1,549 @@ +import { EventEmitter } from 'node:events' +import { realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { spawnProcess } from '../../shared/child-process/run-process' +import { + isCodexAppServerRequestError, + openCodexAppServerConnection, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers +} from './codex-app-server-connection' +import { isCodexAppServerUnsupportedError } from './codex-app-server-session' + +const originalCodexHome = process.env.CODEX_HOME + +afterEach(() => { + vi.useRealTimers() + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME + } else { + process.env.CODEX_HOME = originalCodexHome + } +}) + +/** + * A real `node -e` child speaking the same JSONL framing Codex does. Slower than + * a stub, but it is the only thing that proves the spawn, the environment, and + * both traffic directions actually work end to end. + */ +const FAKE_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ id: message.id, result: { codexHome: process.env.CODEX_HOME ?? null } }) + } + if (message.method === 'test/cwd') { + return send({ id: message.id, result: { cwd: process.cwd() } }) + } + if (message.method === 'test/notify') { + send({ method: 'turn/started', params: { threadId: 'thread-1', turn: { id: 'turn-7' } } }) + return send({ id: message.id, result: {} }) + } + if (message.method === 'test/ask') { + return send({ id: 99, method: 'item/fileChange/requestApproval', params: { itemId: 'i1' } }) + } + if (message.method === 'test/refuse') { + return send({ id: message.id, error: { code: -32602, message: 'bad params' } }) + } + if (message.method === 'test/missing') { + return send({ id: message.id, error: { code: -32601, message: 'method not found' } }) + } + if (message.id === 99) { + return send({ method: 'test/answered', params: message }) + } + }) +` + +async function openFakeServer( + handlers: CodexAppServerConnectionHandlers = {}, + env?: Record, + envToDelete?: string[], + cwd?: string +): Promise { + return openCodexAppServerConnection( + { command: process.execPath, args: ['-e', FAKE_APP_SERVER], env, envToDelete, cwd }, + handlers + ) +} + +type StubChild = EventEmitter & { + stdout: PassThrough + stderr: PassThrough + stdin: PassThrough + pid: number + kill: ReturnType +} + +/** Full control over framing and death, which a real child cannot give. */ +function stubChild(options: { exitOnStdinEnd?: boolean } = {}): { + child: StubChild + spawnImpl: typeof spawnProcess + written: Record[] +} { + const child = new EventEmitter() as StubChild + child.stdout = new PassThrough() + child.stderr = new PassThrough() + child.stdin = new PassThrough() + // Keep the synthetic pid outside any real process table so teardown never + // mistakes an unrelated process for this stub. + child.pid = 9_999_999 + child.kill = vi.fn() + const written: Record[] = [] + child.stdin.on('data', (chunk: Buffer) => { + for (const line of chunk.toString('utf8').split('\n')) { + if (line.trim()) { + written.push(JSON.parse(line) as Record) + } + } + }) + if (options.exitOnStdinEnd !== false) { + child.stdin.on('finish', () => child.emit('exit', 0, null)) + } + return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written } +} + +/** Answers the handshake so `openCodexAppServerConnection` can resolve. */ +function answerInitialize(child: StubChild): void { + child.stdin.once('data', () => { + child.stdout.write(`${JSON.stringify({ id: 1, result: {} })}\n`) + }) +} + +/** Stream writes land a tick later, so the stderr tail is only complete here. */ +async function flushStreams(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +function rejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected the call to reject') + }, + (error: Error) => error + ) +} + +describe('openCodexAppServerConnection', () => { + it('advertises the experimental API required for rollout-path resume', async () => { + const { child, spawnImpl, written } = stubChild() + answerInitialize(child) + + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + expect(written[0]).toMatchObject({ + method: 'initialize', + params: { capabilities: { experimentalApi: true } } + }) + await connection.close() + }) + + it('completes the handshake and keeps the child alive across calls', async () => { + const notifications: { method: string; params: unknown }[] = [] + const connection = await openFakeServer({ + onNotification: (method, params) => notifications.push({ method, params }) + }) + + await connection.request('test/notify') + await connection.request('test/notify') + + expect(connection.pid).toBeGreaterThan(0) + expect(connection.closed).toBe(false) + expect(notifications).toHaveLength(2) + expect(notifications[0]).toEqual({ + method: 'turn/started', + params: { threadId: 'thread-1', turn: { id: 'turn-7' } } + }) + await connection.close() + expect(connection.closed).toBe(true) + }) + + it('applies the environment overlay after stripping inherited keys', async () => { + process.env.CODEX_HOME = '/tmp/inherited-home' + const pinned = await openFakeServer({}, { CODEX_HOME: '/tmp/pinned-home' }) + expect(await pinned.request('test/env')).toEqual({ codexHome: '/tmp/pinned-home' }) + await pinned.close() + + const stripped = await openFakeServer({}, undefined, ['CODEX_HOME']) + expect(await stripped.request('test/env')).toEqual({ codexHome: null }) + await stripped.close() + }) + + it('starts the provider in the resolved workspace directory', async () => { + const workspace = realpathSync(tmpdir()) + const connection = await openFakeServer({}, undefined, undefined, workspace) + + await expect(connection.request('test/cwd')).resolves.toEqual({ cwd: workspace }) + await connection.close() + }) + + it('routes a server request to the handler and writes the reply back', async () => { + const requests: { id: number | string; method: string }[] = [] + let resolveAnswered: (params: unknown) => void = () => {} + const answered = new Promise((resolve) => { + resolveAnswered = resolve + }) + const connection = await openFakeServer({ + onServerRequest: (request) => { + requests.push({ id: request.id, method: request.method }) + connection.respond(request.id, { decision: 'accept' }) + }, + onNotification: (method, params) => { + if (method === 'test/answered') { + resolveAnswered(params) + } + } + }) + + connection.notify('test/ask') + + expect(await answered).toEqual({ id: 99, result: { decision: 'accept' } }) + expect(requests).toEqual([{ id: 99, method: 'item/fileChange/requestApproval' }]) + await connection.close() + }) + + it('classifies a refusal apart from a missing method', async () => { + const connection = await openFakeServer() + + const refusal = await connection.request('test/refuse').catch((error: unknown) => error) + const missing = await connection.request('test/missing').catch((error: unknown) => error) + + expect(isCodexAppServerRequestError(refusal)).toBe(true) + expect((refusal as Error).message).toContain('bad params') + expect(isCodexAppServerUnsupportedError(missing)).toBe(true) + expect(isCodexAppServerRequestError(missing)).toBe(false) + await connection.close() + }) + + it('reassembles a message split mid-character across chunks', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const notifications: unknown[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onNotification: (_method, params) => notifications.push(params) }, + spawnImpl + ) + + const payload = Buffer.from( + `${JSON.stringify({ method: 'item/agentMessage/delta', params: { delta: '日本語' } })}\n`, + 'utf8' + ) + const split = payload.indexOf(Buffer.from('日', 'utf8')) + 1 + child.stdout.write(payload.subarray(0, split)) + child.stdout.write(payload.subarray(split)) + await vi.waitFor(() => expect(notifications).toHaveLength(1)) + + expect(notifications[0]).toEqual({ delta: '日本語' }) + await connection.close() + }) + + it('surfaces valid but unclassified frames instead of dropping them', async () => { + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const frames: { kind: string; payload: unknown }[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onUnhandledFrame: (kind, payload) => frames.push({ kind, payload }) }, + spawnImpl + ) + + child.stdout.write(`${JSON.stringify({ id: 'late-string-id', result: { value: 1 } })}\n`) + child.stdout.write(`${JSON.stringify({ id: 999, result: { value: 2 } })}\n`) + await vi.waitFor(() => expect(frames).toHaveLength(2)) + + expect(frames.map((frame) => frame.kind)).toEqual(['frame:unclassified', 'response:unmatched']) + await connection.close() + }) + + it('fails in-flight requests and reports an unexpected exit once', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + const inFlight = rejection(connection.request('turn/start')) + child.stderr.write('codex crashed\n') + await flushStreams() + child.emit('exit', 1, null) + child.emit('close', 1, null) + + expect((await inFlight).message).toContain('codex crashed') + expect(exits).toHaveLength(1) + await connection.close() + }) + + it('classifies a CLI without the app-server subcommand as unsupported', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + const opening = openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ).catch((error: unknown) => error) + + child.stderr.write("error: unrecognized subcommand 'app-server'\n") + await flushStreams() + child.emit('exit', 2, null) + child.emit('close', 2, null) + + expect(isCodexAppServerUnsupportedError(await opening)).toBe(true) + }) + + it('exposes an unproven handshake child for later cleanup', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + child.stdin.once('data', () => { + child.stdout.write( + `${JSON.stringify({ id: 1, error: { code: -32602, message: 'initialize failed' } })}\n` + ) + }) + const opening = rejection( + openCodexAppServerConnection({ command: 'codex', args: ['app-server'] }, {}, spawnImpl) + ) + + await vi.advanceTimersByTimeAsync(5_000) + const error = (await opening) as Error & { connection?: CodexAppServerConnection } + + expect(error.name).toBe('CodexAppServerHandshakeExitUnprovenError') + expect(error.connection).toBeDefined() + child.emit('close', 1, null) + await expect(error.connection?.close()).resolves.toBe(true) + }) + + it('times out one request without ending the connection', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild() + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + const slow = rejection(connection.request('turn/start', undefined, { timeoutMs: 50 })) + await vi.advanceTimersByTimeAsync(60) + + expect((await slow).name).toBe('CodexAppServerTimeoutError') + expect(connection.closed).toBe(false) + await vi.advanceTimersByTimeAsync(0) + }) + + it('kills a child that ignores stdin EOF', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const closing = connection.close() + await vi.advanceTimersByTimeAsync(2_000) + await closing + + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + }) + + it('reports unproven close when forced termination did not produce an exit event', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + await expect(connection.close()).resolves.toBe(false) + }, 10_000) + + it('shares one eventual exit proof across concurrent close callers', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + child.kill.mockImplementation(() => { + setTimeout(() => child.emit('exit', null, 'SIGKILL'), 10) + return true + }) + + const first = connection.close() + const second = connection.close() + await vi.advanceTimersByTimeAsync(4_100) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(child.kill.mock.calls.map(([signal]) => signal)).toEqual(['SIGSTOP', 'SIGKILL']) + }) + + it('allows a later close to observe exit after an unproven attempt', async () => { + vi.useFakeTimers() + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + {}, + spawnImpl + ) + + const first = connection.close() + await vi.advanceTimersByTimeAsync(5_000) + await expect(first).resolves.toBe(false) + child.emit('exit', 0, null) + + await expect(connection.close()).resolves.toBe(true) + }) + + it('ends the connection rather than buffering an oversized line', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + child.stdout.write('x'.repeat(1024 * 1024 + 1)) + + expect((await inFlight).message).toContain('oversized') + expect(exits[0]).toContain('oversized') + await connection.close() + }) + + it.each([ + { + kind: 'notification', + frame: { method: 'turn/started', params: { turn: { id: 'turn-1' } } } + }, + { + kind: 'server request', + frame: { id: 41, method: 'item/fileChange/requestApproval', params: { itemId: 'item-1' } } + } + ])('surfaces a synchronous $kind handler failure as a terminal exit', async ({ frame }) => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const fail = (): never => { + throw new Error('structured sink failed') + } + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { + onNotification: fail, + onServerRequest: fail, + onExit: (error) => exits.push(error.message) + }, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + child.stdout.write(`${JSON.stringify(frame)}\n`) + + expect((await inFlight).message).toContain('structured sink failed') + expect(exits).toEqual([expect.stringContaining('structured sink failed')]) + expect(connection.closed).toBe(true) + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + await connection.close() + }) + + it('reports one exit for a death that arrives through two listeners', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + + // The oversized line kills the child, so its own `close` lands afterwards. + child.stdout.write('x'.repeat(1024 * 1024 + 1)) + child.stderr.write('killed\n') + await flushStreams() + child.emit('exit', null, 'SIGKILL') + child.emit('close', null, 'SIGKILL') + + expect(exits).toHaveLength(1) + // The first cause survives; the generic exit that follows does not overwrite it. + expect(exits[0]).toContain('oversized') + await connection.close() + }) + + it('treats a broken stdin pipe as the end of the transport', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + child.stdin.emit('error', new Error('write EPIPE')) + + expect((await inFlight).message).toContain('EPIPE') + expect(exits).toHaveLength(1) + // A child nobody can write to is not a live session: the owner must see the + // connection as gone rather than keep issuing calls that can only time out. + expect(connection.closed).toBe(true) + await vi.waitFor(() => expect(child.kill).toHaveBeenCalledWith('SIGKILL')) + expect((await rejection(connection.request('turn/start'))).message).toContain('EPIPE') + await connection.close() + }) + + it('keeps a graceful close quiet when stdin breaks during the reap', async () => { + const { child, spawnImpl } = stubChild({ exitOnStdinEnd: false }) + answerInitialize(child) + const exits: string[] = [] + const connection = await openCodexAppServerConnection( + { command: 'codex', args: ['app-server'] }, + { onExit: (error) => exits.push(error.message) }, + spawnImpl + ) + child.stdin.on('finish', () => child.stdin.emit('error', new Error('write EPIPE'))) + child.kill.mockImplementation(() => { + child.emit('exit', null, 'SIGKILL') + return true + }) + + const inFlight = rejection(connection.request('turn/start')) + await connection.close() + + expect((await inFlight).message).toContain('EPIPE') + expect(exits).toHaveLength(0) + }) +}) diff --git a/src/main/codex/codex-app-server-connection.ts b/src/main/codex/codex-app-server-connection.ts new file mode 100644 index 00000000000..23b7068b76a --- /dev/null +++ b/src/main/codex/codex-app-server-connection.ts @@ -0,0 +1,349 @@ +import { spawnProcess } from '../../shared/child-process/run-process' +import { RetryableProcessExitProof } from '../../shared/child-process/retryable-process-exit-proof' +import { createProviderSpawnSpec } from './codex-app-server-posix-supervisor' +import { buildCodexAppServerExitError } from './codex-app-server-exit-error' +import { initializeCodexAppServerConnection } from './codex-app-server-handshake' +import { CodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' +import { isAppServerRecord, parseCodexAppServerJsonLine } from './codex-app-server-jsonl' +import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' +import { CodexAppServerRequestError } from './codex-app-server-request-error' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { waitForProcessExitUntil } from './codex-process-exit-deadline' +import { + CodexAppServerTimeoutError, + CodexAppServerUnsupportedError, + isCodexMethodNotFoundError +} from './codex-app-server-session' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers +} from './codex-app-server-connection-types' + +export type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerServerRequest +} from './codex-app-server-connection-types' +export { + CodexAppServerRequestError, + isCodexAppServerRequestError +} from './codex-app-server-request-error' + +// Structured chat needs a persistent bidirectional child and per-request deadlines; +// the request-scoped app-server runner cannot carry approvals or streamed turns. + +export type CodexAppServerLaunch = { + command: string + args: string[] + /** Workspace directory used by the provider process itself. */ + cwd?: string + /** Overlay on the inherited environment — the pinned CODEX_HOME lives here. */ + env?: Record + /** Keys stripped after the overlay, matching `CodexAppServerInvocation`. */ + envToDelete?: readonly string[] +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000 +const GRACEFUL_EXIT_MS = 1_500 +const FORCED_EXIT_MS = 1_000 +const STDERR_TAIL_MAX_BYTES = 8192 +const STDOUT_LINE_MAX_BYTES = 1024 * 1024 + +type PendingRequest = { + method: string + resolve: (result: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +/** + * Spawns `codex app-server`, completes the initialize handshake, and returns a + * connection that stays open until `close()`. Rejects — after reaping the child + * — when the handshake cannot complete. + */ +export async function openCodexAppServerConnection( + launch: CodexAppServerLaunch, + handlers: CodexAppServerConnectionHandlers = {}, + spawnImpl: typeof spawnProcess = spawnProcess +): Promise { + const childEnv: NodeJS.ProcessEnv = { ...process.env, ...launch.env } + for (const key of launch.envToDelete ?? []) { + delete childEnv[key] + } + const spawnSpec = createProviderSpawnSpec(launch, childEnv, process.platform) + const child = spawnImpl(spawnSpec) + const spawnToken = launch.env?.[CODEX_SPAWN_TOKEN_ENV] + + function terminateProcessTree(): Promise { + // The supervisor and provider own separate POSIX groups so the supervisor can prove the + // provider group empty before relaying its exit. Forced wrapper teardown uses descendant proof. + return terminateCodexAppServerProcessTree(child, spawnToken) + } + + const pending = new Map() + let stderrTail = '' + let nextRequestId = 1 + let exited = false + let exitObserved = false + let closing = false + const exitProof = new RetryableProcessExitProof() + /** First terminal cause, or null while the transport is still usable. Set once: + * a child that dies reaches us through several listeners, and the specific + * first cause is the one worth reporting. */ + let terminalError: Error | null = null + + let resolveExit = (): void => undefined + const exitPromise = new Promise((resolve) => { + resolveExit = resolve + }) + + function observeExit(): void { + exited = true + exitObserved = true + resolveExit() + } + + child.on('exit', observeExit) + + function buildExitError(cause?: Error): Error { + return buildCodexAppServerExitError(stderrTail, cause) + } + + function failPending(error: Error): void { + for (const waiter of pending.values()) { + clearTimeout(waiter.timer) + waiter.reject(error) + } + pending.clear() + } + + /** A death nobody asked for kills every in-flight call AND tells the owner, + * which is the only signal the session has that its lease is now worthless. + * Once only: an oversized line kills the child and its `close` arrives after, + * and a spawn failure arrives as both `error` and `close`. */ + function handleUnexpectedEnd(cause?: Error): void { + if (terminalError) { + return + } + terminalError = buildExitError(cause) + failPending(terminalError) + if (!closing) { + handlers.onExit?.(terminalError) + } + } + + child.on('error', (error) => { + handleUnexpectedEnd(error) + }) + child.on('close', () => { + observeExit() + handleUnexpectedEnd() + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_MAX_BYTES) + }) + child.stdin.on('error', (error) => { + // A broken pipe is terminal, not one failed write: every later request can + // only error or time out, so the session must learn its lease is worthless + // instead of staying live in front of a child nobody can reach. During a + // close the reap is already under way and `exited` must stay honest, or + // `close` would skip the kill it still owes. + if (closing) { + failPending(error) + return + } + void terminateProcessTree() + handleUnexpectedEnd(error) + }) + + function dispatchMessage(message: Record): void { + const hasMethod = typeof message.method === 'string' + const hasId = typeof message.id === 'number' || typeof message.id === 'string' + if (hasMethod && hasId) { + handlers.onServerRequest?.({ + id: message.id as number | string, + method: message.method as string, + params: message.params + }) + return + } + if (hasMethod) { + handlers.onNotification?.(message.method as string, message.params) + return + } + if (typeof message.id !== 'number') { + handlers.onUnhandledFrame?.('frame:unclassified', message) + return + } + const waiter = pending.get(message.id) + if (!waiter) { + handlers.onUnhandledFrame?.('response:unmatched', message) + return + } + pending.delete(message.id) + clearTimeout(waiter.timer) + const error = message.error + if (isAppServerRecord(error)) { + const detail = typeof error.message === 'string' ? error.message : 'unknown error' + waiter.reject( + isCodexMethodNotFoundError(error) + ? new CodexAppServerUnsupportedError( + `codex app-server does not support ${waiter.method}: ${detail}` + ) + : new CodexAppServerRequestError( + waiter.method, + typeof error.code === 'number' ? error.code : null, + `codex app-server ${waiter.method} failed: ${detail}` + ) + ) + return + } + waiter.resolve(message.result) + } + + let stdoutBuffer = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdoutBuffer += chunk + if (Buffer.byteLength(stdoutBuffer) > STDOUT_LINE_MAX_BYTES) { + child.stdout.destroy() + void terminateProcessTree() + handleUnexpectedEnd(new Error('codex app-server emitted an oversized JSONL line')) + return + } + let newlineIndex: number + while ((newlineIndex = stdoutBuffer.indexOf('\n')) !== -1) { + const line = stdoutBuffer.slice(0, newlineIndex).trim() + stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1) + if (!line) { + continue + } + const parsed = parseCodexAppServerJsonLine(line) + if (!parsed) { + handlers.onUnhandledFrame?.('frame:invalid-json', line) + continue + } + try { + dispatchMessage(parsed) + } catch (error) { + child.stdout.destroy() + void terminateProcessTree() + handleUnexpectedEnd(error instanceof Error ? error : new Error(String(error))) + return + } + } + }) + + function sendLine(payload: Record): void { + child.stdin.write(`${JSON.stringify(payload)}\n`) + } + + function notify(method: string, params?: Record): void { + if (exited || terminalError) { + return + } + try { + sendLine(params === undefined ? { method } : { method, params }) + } catch { + // Fire-and-forget; the next request surfaces a dead child. + } + } + + function request( + method: string, + params?: Record, + options: { timeoutMs?: number } = {} + ): Promise { + if (closing) { + return Promise.reject(new Error(`codex app-server connection is closed (${method})`)) + } + if (terminalError) { + return Promise.reject(terminalError) + } + if (exited) { + return Promise.reject(buildExitError()) + } + const id = nextRequestId++ + const timeoutMs = options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS + return new Promise((resolve, reject) => { + // Why: per request, not per session — a chat session outlives every call, + // so only the individual call can carry a deadline. + const timer = setTimeout(() => { + pending.delete(id) + reject(new CodexAppServerTimeoutError(`codex app-server ${method} exceeded ${timeoutMs}ms`)) + }, timeoutMs) + pending.set(id, { method, resolve, reject, timer }) + try { + sendLine(params === undefined ? { method, id } : { method, id, params }) + } catch (error) { + pending.delete(id) + clearTimeout(timer) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + function writeResponse(payload: Record): void { + if (exited || terminalError || child.stdin.destroyed || !child.stdin.writable) { + return + } + try { + sendLine(payload) + } catch { + // The turn that asked is already gone with the child. + } + } + + function close(): Promise { + if (exitObserved) { + return Promise.resolve(true) + } + closing = true + return exitProof.run(async () => { + try { + child.stdin.end() + } catch { + // Already destroyed; the reap below still runs. + } + if (!exited) { + await waitForProcessExitUntil(exitPromise, GRACEFUL_EXIT_MS) + if (!exited) { + const treeExited = await terminateProcessTree() + if (!treeExited) { + failPending(new Error('codex app-server process-tree exit was not proven')) + return false + } + await waitForProcessExitUntil(exitPromise, FORCED_EXIT_MS) + } + } + failPending(new Error('codex app-server connection closed')) + return exitObserved + }) + } + + const connection: CodexAppServerConnection = { + get pid() { + return child.pid + }, + get closed() { + return closing || exited || terminalError !== null + }, + request, + notify, + respond: (id, result) => writeResponse({ id, result }), + respondWithError: (id, code, message) => writeResponse({ id, error: { code, message } }), + close + } + + try { + await initializeCodexAppServerConnection(connection) + } catch (error) { + if ((await close()) !== true) { + throw new CodexAppServerHandshakeExitUnprovenError(connection, error) + } + throw error instanceof CodexAppServerUnsupportedError || + error instanceof CodexAppServerTimeoutError + ? error + : buildExitError(error instanceof Error ? error : new Error(String(error))) + } + return connection +} diff --git a/src/main/codex/codex-app-server-exit-error.ts b/src/main/codex/codex-app-server-exit-error.ts new file mode 100644 index 00000000000..85d8bc7dd6e --- /dev/null +++ b/src/main/codex/codex-app-server-exit-error.ts @@ -0,0 +1,19 @@ +// What a dead `codex app-server` child means to whoever was talking to it. The +// stderr tail is the only evidence: a CLI without the subcommand is a durable +// capability fact, and anything else is this run's crash. + +import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal' +import { CodexAppServerUnsupportedError } from './codex-app-server-session' + +const EXIT_DETAIL_MAX_CHARS = 400 + +export function buildCodexAppServerExitError(stderrTail: string, cause?: Error): Error { + const tail = stderrTail.trim().slice(0, EXIT_DETAIL_MAX_CHARS) + if (stderrIndicatesMissingAppServer(stderrTail)) { + return new CodexAppServerUnsupportedError( + `codex CLI does not support the app-server subcommand: ${tail}` + ) + } + const detail = cause ? `: ${cause.message}` : tail ? `: ${tail}` : '' + return new Error(`codex app-server connection ended${detail}`) +} diff --git a/src/main/codex/codex-app-server-handshake-exit-proof.ts b/src/main/codex/codex-app-server-handshake-exit-proof.ts new file mode 100644 index 00000000000..d3090b84946 --- /dev/null +++ b/src/main/codex/codex-app-server-handshake-exit-proof.ts @@ -0,0 +1,26 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection-types' + +export class CodexAppServerHandshakeExitUnprovenError extends Error { + constructor( + readonly connection: CodexAppServerConnection, + cause: unknown + ) { + super('codex app-server handshake failed without process-exit proof', { cause }) + this.name = 'CodexAppServerHandshakeExitUnprovenError' + } +} + +export function isCodexAppServerHandshakeExitUnprovenError( + error: unknown +): error is CodexAppServerHandshakeExitUnprovenError { + const connection = + error instanceof Error && 'connection' in error + ? (error.connection as Partial | null) + : null + return ( + error instanceof Error && + error.name === 'CodexAppServerHandshakeExitUnprovenError' && + connection !== null && + typeof connection.close === 'function' + ) +} diff --git a/src/main/codex/codex-app-server-handshake.ts b/src/main/codex/codex-app-server-handshake.ts new file mode 100644 index 00000000000..9c89d653baa --- /dev/null +++ b/src/main/codex/codex-app-server-handshake.ts @@ -0,0 +1,22 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection-types' + +const HANDSHAKE_TIMEOUT_MS = 15_000 + +export async function initializeCodexAppServerConnection( + connection: CodexAppServerConnection +): Promise { + await connection.request( + 'initialize', + { + clientInfo: { name: 'orca_desktop', title: 'Orca', version: '0.0.0' }, + capabilities: { + experimentalApi: true, + requestAttestation: false, + mcpServerOpenaiFormElicitation: false, + extensions: {} + } + }, + { timeoutMs: HANDSHAKE_TIMEOUT_MS } + ) + connection.notify('initialized') +} diff --git a/src/main/codex/codex-app-server-jsonl.ts b/src/main/codex/codex-app-server-jsonl.ts new file mode 100644 index 00000000000..3a65e1fb138 --- /dev/null +++ b/src/main/codex/codex-app-server-jsonl.ts @@ -0,0 +1,12 @@ +export function isAppServerRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseCodexAppServerJsonLine(line: string): Record | null { + try { + const parsed: unknown = JSON.parse(line) + return isAppServerRecord(parsed) ? parsed : null + } catch { + return null + } +} diff --git a/src/main/codex/codex-app-server-notification-schema.ts b/src/main/codex/codex-app-server-notification-schema.ts new file mode 100644 index 00000000000..a7319273cdb --- /dev/null +++ b/src/main/codex/codex-app-server-notification-schema.ts @@ -0,0 +1,78 @@ +// Stable ServerNotification method discriminators generated by codex-cli 0.147.0. +export const CODEX_APP_SERVER_NOTIFICATION_METHODS = [ + 'error', + 'thread/started', + 'thread/status/changed', + 'thread/archived', + 'thread/deleted', + 'thread/unarchived', + 'thread/closed', + 'skills/changed', + 'thread/name/updated', + 'thread/goal/updated', + 'thread/goal/cleared', + 'thread/environment/connected', + 'thread/environment/disconnected', + 'thread/settings/updated', + 'thread/tokenUsage/updated', + 'turn/started', + 'hook/started', + 'turn/completed', + 'hook/completed', + 'turn/diff/updated', + 'turn/plan/updated', + 'item/started', + 'item/autoApprovalReview/started', + 'item/autoApprovalReview/completed', + 'item/completed', + 'rawResponseItem/completed', + 'rawResponse/completed', + 'item/agentMessage/delta', + 'item/plan/delta', + 'command/exec/outputDelta', + 'process/outputDelta', + 'process/exited', + 'item/commandExecution/outputDelta', + 'item/commandExecution/terminalInteraction', + 'item/fileChange/outputDelta', + 'item/fileChange/patchUpdated', + 'serverRequest/resolved', + 'item/mcpToolCall/progress', + 'mcpServer/oauthLogin/completed', + 'mcpServer/startupStatus/updated', + 'account/updated', + 'account/rateLimits/updated', + 'app/list/updated', + 'remoteControl/status/changed', + 'externalAgentConfig/import/progress', + 'externalAgentConfig/import/completed', + 'fs/changed', + 'item/reasoning/summaryTextDelta', + 'item/reasoning/summaryPartAdded', + 'item/reasoning/textDelta', + 'thread/compacted', + 'model/rerouted', + 'model/verification', + 'turn/moderationMetadata', + 'model/safetyBuffering/updated', + 'warning', + 'guardianWarning', + 'deprecationNotice', + 'configWarning', + 'fuzzyFileSearch/sessionUpdated', + 'fuzzyFileSearch/sessionCompleted', + 'thread/realtime/started', + 'thread/realtime/itemAdded', + 'thread/realtime/transcript/delta', + 'thread/realtime/transcript/done', + 'thread/realtime/outputAudio/delta', + 'thread/realtime/sdp', + 'thread/realtime/error', + 'thread/realtime/closed', + 'windows/worldWritableWarning', + 'windowsSandbox/setupCompleted', + 'account/login/completed' +] as const + +export type CodexAppServerNotificationMethod = + (typeof CODEX_APP_SERVER_NOTIFICATION_METHODS)[number] diff --git a/src/main/codex/codex-app-server-posix-supervisor.test.ts b/src/main/codex/codex-app-server-posix-supervisor.test.ts new file mode 100644 index 00000000000..f51157a443c --- /dev/null +++ b/src/main/codex/codex-app-server-posix-supervisor.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import type { CodexAppServerLaunch } from './codex-app-server-connection' +import { + createProviderSpawnSpec, + POSIX_PROVIDER_SUPERVISOR_SCRIPT, + supervisedPosixLaunch +} from './codex-app-server-posix-supervisor' + +const launch: CodexAppServerLaunch = { + command: '/opt/codex', + args: ['app-server', '--flag'], + cwd: '/work/repo', + env: { CODEX_HOME: '/tmp/codex' } +} + +describe('structured provider supervision', () => { + it('wraps POSIX launches in a detached supervisor and preserves the launch spec', () => { + const childEnv = { PATH: '/bin', CODEX_HOME: '/tmp/codex' } + const spec = supervisedPosixLaunch(launch, childEnv) + + expect(spec.command).toBe(process.execPath) + expect(spec.args).toEqual(['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT]) + expect(spec.env.PATH).toBe('/bin') + expect( + JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString()) + ).toEqual( + expect.objectContaining({ + command: '/opt/codex', + args: ['app-server', '--flag'], + cwd: '/work/repo' + }) + ) + expect( + JSON.parse(Buffer.from(spec.env.ORCA_PROVIDER_SUPERVISOR_SPEC!, 'base64').toString()) + ).not.toHaveProperty('env') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain( + 'delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC' + ) + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('delete childEnv.ELECTRON_RUN_AS_NODE') + expect(spec.env.ELECTRON_RUN_AS_NODE).toBe('1') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('process.ppid !== originalParent') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain( + "process.stdin.once('close', scheduleOwnerShutdown)" + ) + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('detached: true') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain("process.kill(-child.pid, 'SIGKILL')") + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('providerGroupExists()') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).toContain('finishWithProviderOutcome(code, signal)') + expect(POSIX_PROVIDER_SUPERVISOR_SCRIPT).not.toContain('process.ppid === 1') + }) + + it('uses direct provider spawning on Windows because the job owns the tree', () => { + expect(createProviderSpawnSpec(launch, { PATH: '/bin' }, 'win32')).toEqual({ + program: '/opt/codex', + args: ['app-server', '--flag'], + env: { PATH: '/bin' }, + cwd: '/work/repo', + detached: false + }) + }) +}) diff --git a/src/main/codex/codex-app-server-posix-supervisor.ts b/src/main/codex/codex-app-server-posix-supervisor.ts new file mode 100644 index 00000000000..f83ba6f374b --- /dev/null +++ b/src/main/codex/codex-app-server-posix-supervisor.ts @@ -0,0 +1,128 @@ +import type { CodexAppServerLaunch } from './codex-app-server-connection' + +/** Inline supervisor source kept dependency-free for the spawned Node child. */ +export const POSIX_PROVIDER_SUPERVISOR_SCRIPT = ` +const { spawn } = require('node:child_process') +const spec = JSON.parse(Buffer.from(process.env.ORCA_PROVIDER_SUPERVISOR_SPEC, 'base64').toString()) +const childEnv = { ...process.env } +delete childEnv.ORCA_PROVIDER_SUPERVISOR_SPEC +delete childEnv.ELECTRON_RUN_AS_NODE +const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: childEnv, + stdio: ['pipe', 'pipe', 'pipe'], + detached: true +}) +const originalParent = process.ppid +let timer +let ownerShutdownTimer +let settling = false +const providerGroupExists = () => { + if (!child.pid) return false + try { + process.kill(-child.pid, 0) + return true + } catch (error) { + return Boolean(error && error.code !== 'ESRCH') + } +} +const reapOwnedProviderGroup = async () => { + if (!child.pid) return false + try { process.kill(-child.pid, 'SIGKILL') } catch (error) { + if (error && error.code !== 'ESRCH') return false + } + const deadline = Date.now() + 1500 + while (providerGroupExists()) { + if (Date.now() >= deadline) return false + await new Promise((resolve) => setTimeout(resolve, 25)) + } + return true +} +const terminateOwnedGroup = () => { + if (settling) return + settling = true + clearInterval(timer) + void reapOwnedProviderGroup().then((reaped) => process.exit(reaped ? 137 : 1)) +} +const scheduleOwnerShutdown = () => { + if (settling || ownerShutdownTimer) return + // A normal close ends the provider's stdin first; allow it to flush and + // exit before forcing the group, while still bounding an orphaned child. + ownerShutdownTimer = setTimeout(terminateOwnedGroup, 1250) + ownerShutdownTimer.unref() +} +process.stdin.once('end', scheduleOwnerShutdown) +process.stdin.once('close', scheduleOwnerShutdown) +process.stdin.pipe(child.stdin) +child.stdout.pipe(process.stdout) +child.stderr.pipe(process.stderr) +for (const stream of [process.stdin, child.stdin, child.stdout, child.stderr]) stream.on('error', () => {}) +const finishWithProviderOutcome = (code, signal) => { + if (!signal) return process.exit(code ?? 1) + process.kill(process.pid, signal) +} +const reapProviderExit = async (code, signal) => { + if (settling) return + settling = true + clearInterval(timer) + if (ownerShutdownTimer) clearTimeout(ownerShutdownTimer) + if (!(await reapOwnedProviderGroup())) return process.exit(1) + finishWithProviderOutcome(code, signal) +} +timer = setInterval(() => { + // A detached supervisor is reparented when its owner exits. The new parent + // may be PID 1 or a platform subreaper, so any parent change is proof that + // this process group no longer has a live Orca owner. + if (process.ppid !== originalParent) { + terminateOwnedGroup() + } +}, 100) +timer.unref() +child.once('error', () => { + clearInterval(timer) + process.exit(127) +}) +child.once('exit', (code, signal) => { + void reapProviderExit(code, signal) +}) +` + +export function supervisedPosixLaunch( + launch: CodexAppServerLaunch, + childEnv: NodeJS.ProcessEnv, + cwd = launch.cwd ?? process.cwd() +): { command: string; args: string[]; env: NodeJS.ProcessEnv } { + const supervisorSpec = Buffer.from( + JSON.stringify({ + command: launch.command, + args: launch.args, + cwd + }) + ).toString('base64') + return { + command: process.execPath, + args: ['-e', POSIX_PROVIDER_SUPERVISOR_SCRIPT], + // Electron's executable needs Node mode for the inline supervisor. The + // marker is removed above so providers never inherit Electron semantics. + env: { + ...childEnv, + ELECTRON_RUN_AS_NODE: '1', + ORCA_PROVIDER_SUPERVISOR_SPEC: supervisorSpec + } + } +} + +export function createProviderSpawnSpec( + launch: CodexAppServerLaunch, + childEnv: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): { program: string; args: string[]; env: NodeJS.ProcessEnv; cwd: string; detached: boolean } { + const supervised = platform === 'win32' ? null : supervisedPosixLaunch(launch, childEnv) + return { + program: supervised?.command ?? launch.command, + args: supervised?.args ?? launch.args, + env: supervised?.env ?? childEnv, + cwd: launch.cwd ?? process.cwd(), + detached: platform !== 'win32' + } +} diff --git a/src/main/codex/codex-app-server-process-teardown.test.ts b/src/main/codex/codex-app-server-process-teardown.test.ts new file mode 100644 index 00000000000..013ee183d12 --- /dev/null +++ b/src/main/codex/codex-app-server-process-teardown.test.ts @@ -0,0 +1,148 @@ +import type { ChildProcess } from 'node:child_process' +import { describe, expect, it, vi } from 'vitest' +import { terminateCodexAppServerProcessTree } from './codex-app-server-process-teardown' + +function child() { + return { + pid: 1234, + kill: vi.fn(() => true) as ChildProcess['kill'] + } +} + +describe('terminateCodexAppServerProcessTree', () => { + it('waits for the Windows tree kill before releasing the wrapper', async () => { + const target = child() + const release = Promise.withResolvers() + const terminateWindowsTree = vi.fn(() => release.promise) + + const teardown = terminateCodexAppServerProcessTree(target, undefined, { + platform: 'win32', + terminateWindowsTree + }) + expect(target.kill).not.toHaveBeenCalled() + release.resolve() + await teardown + + expect(terminateWindowsTree).toHaveBeenCalledWith(1234) + expect(target.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills exact Linux spawn-token PIDs before the recorded wrapper', async () => { + const target = child() + const findSpawnTokenProcesses = vi + .fn<() => Promise>() + .mockResolvedValueOnce([1234, 2345, 3456]) + .mockResolvedValueOnce([1234]) + .mockResolvedValueOnce([1234]) + const signalPid = vi.fn() + + await expect( + terminateCodexAppServerProcessTree(target, 'spawn-1', { + platform: 'linux', + findSpawnTokenProcesses, + signalPid, + isPidPresent: () => false, + wait: async () => undefined + }) + ).resolves.toBe(true) + + expect(signalPid.mock.calls).toEqual([ + [2345, 'SIGKILL'], + [3456, 'SIGKILL'] + ]) + expect(target.kill).toHaveBeenCalledTimes(1) + expect(target.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('keeps the wrapper reachable when Linux cannot prove descendant exit', async () => { + const target = child() + + await expect( + terminateCodexAppServerProcessTree(target, 'spawn-1', { + platform: 'linux', + findSpawnTokenProcesses: async () => null + }) + ).resolves.toBe(false) + + expect(target.kill).not.toHaveBeenCalled() + }) + + it('waits for an owned POSIX snapshot before killing the wrapper', async () => { + const target = child() + const snapshot = { rootPgid: 1234, descendants: [], capturedAtMs: 1 } + const release = Promise.withResolvers() + + const teardown = terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + captureDescendants: async () => snapshot, + terminateDescendants: () => release.promise + }) + await vi.waitFor(() => expect(target.kill).toHaveBeenCalledWith('SIGSTOP')) + expect(target.kill).not.toHaveBeenCalledWith('SIGKILL') + release.resolve(true) + await teardown + + expect(target.kill).toHaveBeenLastCalledWith('SIGKILL') + }) + + it('signals a proven dedicated POSIX process group without scanning descendants', async () => { + const target = child() + const captureDescendants = vi.fn() + const signalProcessGroup = vi.fn() + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'darwin', + dedicatedProcessGroup: true, + captureDescendants, + signalProcessGroup + }) + ).resolves.toBe(true) + + expect(signalProcessGroup).toHaveBeenCalledWith(1234, 'SIGKILL') + expect(captureDescendants).not.toHaveBeenCalled() + expect(target.kill).not.toHaveBeenCalled() + }) + + it('keeps the dedicated-group wrapper reachable when signalling is unproven', async () => { + const target = child() + + await expect( + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'linux', + dedicatedProcessGroup: true, + signalProcessGroup: () => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }) + } + }) + ).resolves.toBe(false) + + expect(target.kill).not.toHaveBeenCalled() + }) + + it('tears down 40 dedicated groups without process-table scans or cross-group fanout', async () => { + const killMocks = Array.from({ length: 40 }, () => vi.fn(() => true)) + const targets = killMocks.map((kill, index) => ({ + pid: 10_000 + index, + kill: kill as ChildProcess['kill'] + })) + const captureDescendants = vi.fn() + const signalProcessGroup = vi.fn() + + const results = await Promise.all( + targets.map((target) => + terminateCodexAppServerProcessTree(target, undefined, { + platform: 'linux', + dedicatedProcessGroup: true, + captureDescendants, + signalProcessGroup + }) + ) + ) + + expect(results).toEqual(Array.from({ length: targets.length }, () => true)) + expect(signalProcessGroup.mock.calls).toEqual(targets.map((target) => [target.pid, 'SIGKILL'])) + expect(captureDescendants).not.toHaveBeenCalled() + expect(killMocks.every((kill) => kill.mock.calls.length === 0)).toBe(true) + }) +}) diff --git a/src/main/codex/codex-app-server-process-teardown.ts b/src/main/codex/codex-app-server-process-teardown.ts new file mode 100644 index 00000000000..cc7ea8c8d17 --- /dev/null +++ b/src/main/codex/codex-app-server-process-teardown.ts @@ -0,0 +1,183 @@ +import type { ChildProcessHandle } from '../../shared/child-process/run-process' +import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' +import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' +import { terminateWindowsProcessTree } from '../windows-process-tree-kill' +import { findAgentSessionSpawnTokenProcesses } from '../runtime/agent-session-spawn-token-process-scan' + +const TOKEN_PROCESS_EXIT_TIMEOUT_MS = 3_500 +const TOKEN_PROCESS_POLL_MS = 25 +const activeTeardowns = new WeakMap>() + +type TeardownChild = Pick + +export type CodexAppServerProcessTeardownDeps = { + platform?: NodeJS.Platform + dedicatedProcessGroup?: boolean + /** Diagnostic/recovery injection only; never used by the primary teardown. */ + findSpawnTokenProcesses?: (spawnToken: string) => Promise + captureDescendants?: (rootPid: number) => Promise + terminateDescendants?: (snapshot: DescendantSnapshot) => Promise + terminateWindowsTree?: (rootPid: number) => Promise + signalPid?: (pid: number, signal: NodeJS.Signals) => void + signalProcessGroup?: (pgid: number, signal: NodeJS.Signals) => void + isPidPresent?: (pid: number) => boolean + wait?: (ms: number) => Promise + now?: () => number +} + +function terminateDedicatedPosixGroup( + rootPid: number, + deps: CodexAppServerProcessTeardownDeps +): boolean { + const signalGroup = + deps.signalProcessGroup ?? + ((pgid: number, signal: NodeJS.Signals) => process.kill(-pgid, signal)) + try { + signalGroup(rootPid, 'SIGKILL') + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'ESRCH' + } +} + +function sendSignal(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal) + } catch { + // An already-gone exact PID is the desired outcome. + } +} + +function isPidPresent(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +async function diagnosticTokenFallback( + rootPid: number, + spawnToken: string, + deps: CodexAppServerProcessTeardownDeps +): Promise { + const find = deps.findSpawnTokenProcesses ?? findAgentSessionSpawnTokenProcesses + const signal = deps.signalPid ?? sendSignal + const pidPresent = deps.isPidPresent ?? isPidPresent + const delay = + deps.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const now = deps.now ?? Date.now + const deadline = now() + TOKEN_PROCESS_EXIT_TIMEOUT_MS + const signalled = new Set() + while (now() < deadline) { + const pids = await find(spawnToken).catch(() => null) + if (pids === null) { + return false + } + for (const pid of pids.filter((candidate) => candidate !== rootPid)) { + signalled.add(pid) + signal(pid, 'SIGKILL') + } + if ([...signalled].every((pid) => !pidPresent(pid))) { + return true + } + await delay(TOKEN_PROCESS_POLL_MS) + } + return false +} + +async function terminatePosixTree( + child: TeardownChild, + rootPid: number, + _spawnToken: string | undefined, + deps: CodexAppServerProcessTeardownDeps +): Promise { + // Kept only for explicit recovery callers/tests. Production always follows + // the dedicated process-group path below; token enumeration is evidence, + // never the owner of orphan-reaping decisions. + if (_spawnToken && deps.findSpawnTokenProcesses) { + const reaped = await diagnosticTokenFallback(rootPid, _spawnToken, deps) + if (reaped) { + child.kill('SIGKILL') + return true + } + return false + } + child.kill('SIGSTOP') + const capture = deps.captureDescendants ?? captureDescendantSnapshot + const snapshot = await capture(rootPid).catch(() => null) + if (!snapshot) { + child.kill('SIGKILL') + return true + } + const terminate = deps.terminateDescendants ?? terminateDescendantSnapshotAndWait + const descendantsExited = await terminate(snapshot) + // A detached POSIX launch is the leader of its own process group. Group + // signalling reaches grandchildren even after they daemonise/reparent, + // while the stopped root and captured pgid make the ownership proof exact. + // The identity-gated descendant sweep remains the fallback for older hosts + // or launches that could not establish a dedicated group. + if (descendantsExited && snapshot.rootPgid === rootPid) { + const signalGroup = + deps.signalProcessGroup ?? + ((pgid: number, signal: NodeJS.Signals) => { + try { + process.kill(-pgid, signal) + } catch { + // Group already exited. + } + }) + signalGroup(snapshot.rootPgid, 'SIGKILL') + } + if (!descendantsExited) { + child.kill('SIGCONT') + return false + } + child.kill('SIGKILL') + return true +} + +/** Stops every process owned by one app-server launch before releasing its wrapper. */ +async function terminateOnce( + child: TeardownChild, + spawnToken: string | undefined, + deps: CodexAppServerProcessTeardownDeps +): Promise { + const rootPid = child.pid + if (!rootPid) { + child.kill('SIGKILL') + return false + } + if ((deps.platform ?? process.platform) === 'win32') { + const terminate = deps.terminateWindowsTree ?? terminateWindowsProcessTree + await terminate(rootPid) + // taskkill owns the tree; this preserves the prior direct-child fallback when it fails. + child.kill('SIGKILL') + return true + } + if (deps.dedicatedProcessGroup) { + return terminateDedicatedPosixGroup(rootPid, deps) + } + return terminatePosixTree(child, rootPid, spawnToken, deps) +} + +export function terminateCodexAppServerProcessTree( + child: TeardownChild, + spawnToken?: string, + deps: CodexAppServerProcessTeardownDeps = {} +): Promise { + const key = child as object + const active = activeTeardowns.get(key) + if (active) { + return active + } + const attempt = terminateOnce(child, spawnToken, deps).catch(() => false) + activeTeardowns.set(key, attempt) + void attempt.then(() => { + if (activeTeardowns.get(key) === attempt) { + activeTeardowns.delete(key) + } + }) + return attempt +} diff --git a/src/main/codex/codex-app-server-request-error.ts b/src/main/codex/codex-app-server-request-error.ts new file mode 100644 index 00000000000..0dbefaca965 --- /dev/null +++ b/src/main/codex/codex-app-server-request-error.ts @@ -0,0 +1,15 @@ +/** Codex answered the call and refused it, rather than timing out or exiting. */ +export class CodexAppServerRequestError extends Error { + constructor( + readonly method: string, + readonly code: number | null, + message: string + ) { + super(message) + this.name = 'CodexAppServerRequestError' + } +} + +export function isCodexAppServerRequestError(error: unknown): error is CodexAppServerRequestError { + return error instanceof Error && error.name === 'CodexAppServerRequestError' +} diff --git a/src/main/codex/codex-app-server-session.ts b/src/main/codex/codex-app-server-session.ts index 76e6acf9337..2e176e13ae2 100644 --- a/src/main/codex/codex-app-server-session.ts +++ b/src/main/codex/codex-app-server-session.ts @@ -100,11 +100,35 @@ export function killCodexAppServerProcessTree( // Fall through to the direct-child best effort when taskkill cannot start. } } + if (child.pid) { + try { + // npm/package-manager launchers insert a shim child on POSIX. Reap its + // direct descendants before signalling the wrapper itself. + const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], { + stdio: 'ignore' + }) + // A missing pkill surfaces as an async 'error' event, and an unhandled one + // takes down the main process. + descendants.on('error', () => undefined) + descendants.unref() + } catch { + // The direct kill below remains the fallback when pkill is unavailable. + } + } child.kill('SIGKILL') } -function isMethodNotFoundError(error: { code?: number; message?: string }): boolean { - return error.code === JSON_RPC_METHOD_NOT_FOUND || /method not found/i.test(error.message ?? '') +/** Codex answering "no such method" is the only response that proves the RPC + * surface is absent rather than temporarily failing. */ +export function isCodexMethodNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false + } + const { code, message } = error as { code?: unknown; message?: unknown } + return ( + code === JSON_RPC_METHOD_NOT_FOUND || + /method not found/i.test(typeof message === 'string' ? message : '') + ) } /** @@ -265,7 +289,7 @@ export async function runCodexAppServerSession( } }) if (response.error) { - if (isMethodNotFoundError(response.error)) { + if (isCodexMethodNotFoundError(response.error)) { throw new CodexAppServerUnsupportedError( `codex app-server does not support ${method}: ${response.error.message ?? 'method not found'}` ) diff --git a/src/main/codex/codex-app-server-teardown.integration.test.ts b/src/main/codex/codex-app-server-teardown.integration.test.ts new file mode 100644 index 00000000000..c2b09c66444 --- /dev/null +++ b/src/main/codex/codex-app-server-teardown.integration.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { + openCodexAppServerConnection, + type CodexAppServerConnection +} from './codex-app-server-connection' + +const ITERATIONS = 40 + +const FORCE_KILL_APP_SERVER = String.raw` + const { spawn } = require('node:child_process') + const readline = require('node:readline') + const descendant = spawn(process.execPath, ['-e', "process.on('SIGTERM', () => {}); setInterval(() => {}, 60000)"], { + stdio: 'ignore' + }) + const exitMode = process.env.ORCA_TEST_PROVIDER_EXIT_MODE + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'initialized') { + send({ method: 'test/descendant', params: { pid: descendant.pid } }) + if (exitMode === 'normal' || exitMode === 'stdin-race') { + setTimeout(() => process.exit(0), 25) + } else if (exitMode === 'signal') { + setTimeout(() => process.kill(process.pid, 'SIGTERM'), 25) + } + } + }) + setInterval(() => {}, 60000) +` + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +type RunningServer = { + connection: CodexAppServerConnection + descendantPid: number + exit: Promise + supervisorPid: number +} + +async function openServer( + iteration: number, + exitMode?: 'normal' | 'signal' | 'stdin-race' +): Promise { + const descendant = Promise.withResolvers() + const exit = Promise.withResolvers() + const connection = await openCodexAppServerConnection( + { + command: process.execPath, + args: ['-e', FORCE_KILL_APP_SERVER], + env: { + [CODEX_SPAWN_TOKEN_ENV]: `teardown-test-${process.pid}-${iteration}`, + ...(exitMode ? { ORCA_TEST_PROVIDER_EXIT_MODE: exitMode } : {}) + } + }, + { + onExit: (error) => exit.resolve(error), + onNotification: (method, params) => { + if (method === 'test/descendant') { + descendant.resolve((params as { pid: number }).pid) + } + } + } + ) + return { + connection, + descendantPid: await descendant.promise, + exit: exit.promise, + supervisorPid: connection.pid ?? 0 + } +} + +async function cleanupServer(server: RunningServer): Promise { + await server.connection.close().catch(() => false) + for (const pid of [server.descendantPid, server.supervisorPid]) { + if (pid > 0 && processExists(pid)) { + process.kill(pid, 'SIGKILL') + } + } +} + +describe.runIf(process.platform !== 'win32')('Codex app-server process teardown', () => { + it('reaps the forced-close descendant in 40 consecutive launches', async () => { + const running: RunningServer[] = [] + try { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + running.push(await openServer(iteration)) + } + expect(running.every(({ descendantPid }) => processExists(descendantPid))).toBe(true) + + const closed = await Promise.all(running.map(({ connection }) => connection.close())) + + expect(closed).toEqual(Array.from({ length: ITERATIONS }, () => true)) + expect(running.filter(({ descendantPid }) => processExists(descendantPid))).toEqual([]) + } finally { + for (const server of running) { + await cleanupServer(server) + } + } + }, 30_000) + + it.each(['normal', 'signal'] as const)( + 'reaps provider descendants before relaying a %s root exit in 40 consecutive launches', + async (exitMode) => { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const server = await openServer(iteration, exitMode) + try { + await server.exit + expect(processExists(server.supervisorPid)).toBe(false) + expect(processExists(server.descendantPid)).toBe(false) + await expect(server.connection.close()).resolves.toBe(true) + } finally { + await cleanupServer(server) + } + } + }, + 60_000 + ) + + it('does not settle a stdin-close/root-exit race before the descendant is reaped', async () => { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const server = await openServer(iteration, 'stdin-race') + try { + await expect(server.connection.close()).resolves.toBe(true) + expect(processExists(server.supervisorPid)).toBe(false) + expect(processExists(server.descendantPid)).toBe(false) + } finally { + await cleanupServer(server) + } + } + }, 60_000) +}) diff --git a/src/main/codex/codex-resume-process-proof.test.ts b/src/main/codex/codex-resume-process-proof.test.ts new file mode 100644 index 00000000000..a46e01551ee --- /dev/null +++ b/src/main/codex/codex-resume-process-proof.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { + isCodexResumeProcessCommandLine, + readCodexResumeProcessIdentity +} from './codex-resume-process-proof' + +const THREAD_ID = '01a03a0d-acbd-74e0-86f2-2615984d3b37' + +describe('Codex resume process proof', () => { + it('binds the exact resumed thread while ignoring a generic Codex sibling', async () => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { + pid: 101, + ppid: 100, + stat: 'S+', + command: `node /opt/codex/bin/codex resume ${THREAD_ID}` + }, + { + pid: 103, + ppid: 101, + stat: 'S+', + command: `/opt/codex/vendor/codex resume ${THREAD_ID}` + }, + { + pid: 102, + ppid: 100, + stat: 'S+', + command: `node /opt/codex/bin/codex --profile work resume ${THREAD_ID}` + } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], + readStartTime: async () => 1_700_000_000_000, + timeoutMs: 0 + }) + ).resolves.toMatchObject({ pid: 102 }) + }) + + it('rejects the previous owner process tree when no new resume child appears', async () => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex' }, + { + pid: 102, + ppid: 101, + stat: 'S+', + command: `/opt/codex/vendor/codex resume ${THREAD_ID}` + } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: null }], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it.each([ + ['another thread', `node /opt/codex/bin/codex resume thread-other`], + ['a generic Codex child', 'node /opt/codex/bin/codex --profile work'] + ])('rejects %s', async (_case, command) => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command } + ], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it('accepts an exact resume process after the previous PID was recycled', async () => { + await expect( + readCodexResumeProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + threadId: THREAD_ID, + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { + pid: 101, + ppid: 100, + stat: 'S+', + command: `node /opt/codex/bin/codex resume ${THREAD_ID}` + } + ], + excludedProcessTreeRootIdentities: [{ pid: 101, processStartTimeMs: 10 }], + readStartTime: async () => 5_000, + timeoutMs: 0 + }) + ).resolves.toMatchObject({ pid: 101 }) + }) + + it('parses a quoted Windows executable path with the exact resume argv', () => { + expect( + isCodexResumeProcessCommandLine( + `"C:\\Program Files\\Codex\\codex.exe" --profile work resume ${THREAD_ID}`, + THREAD_ID, + 'win32' + ) + ).toBe(true) + }) +}) diff --git a/src/main/codex/codex-resume-process-proof.ts b/src/main/codex/codex-resume-process-proof.ts new file mode 100644 index 00000000000..1c6839bf867 --- /dev/null +++ b/src/main/codex/codex-resume-process-proof.ts @@ -0,0 +1,101 @@ +import { tokenizeCustomCommandTemplate } from '../../shared/commit-message-prompt' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readStructuredTuiProcessIdentity } from '../runtime/structured-tui-process-identity' + +const PROCESS_COMMAND_LINE_MAX_CHARS = 16 * 1024 + +function tokenizeWindowsProcessCommandLine(commandLine: string): string[] { + const tokens: string[] = [] + let current = '' + let quoted = false + let started = false + let index = 0 + while (index < commandLine.length) { + const char = commandLine[index]! + if (!started && /\s/.test(char)) { + index += 1 + continue + } + started = true + if (char === '\\') { + let backslashes = 0 + while (commandLine[index] === '\\') { + backslashes += 1 + index += 1 + } + if (commandLine[index] === '"') { + current += '\\'.repeat(Math.floor(backslashes / 2)) + if (backslashes % 2 === 1) { + current += '"' + index += 1 + } + } else { + current += '\\'.repeat(backslashes) + } + continue + } + if (char === '"') { + if (quoted && commandLine[index + 1] === '"') { + current += '"' + index += 2 + continue + } + quoted = !quoted + index += 1 + continue + } + if (!quoted && /\s/.test(char)) { + tokens.push(current) + current = '' + started = false + index += 1 + continue + } + current += char + index += 1 + } + if (started) { + tokens.push(current) + } + return tokens +} + +function tokenizeProcessCommandLine( + commandLine: string, + platform: NodeJS.Platform +): string[] | null { + if (!commandLine || commandLine.length > PROCESS_COMMAND_LINE_MAX_CHARS) { + return null + } + if (platform === 'win32') { + return tokenizeWindowsProcessCommandLine(commandLine) + } + const parsed = tokenizeCustomCommandTemplate(commandLine) + return parsed.ok ? parsed.tokens : null +} + +export function isCodexResumeProcessCommandLine( + commandLine: string, + threadId: string, + platform: NodeJS.Platform = process.platform +): boolean { + const tokens = tokenizeProcessCommandLine(commandLine, platform) + if (!tokens || !threadId) { + return false + } + return tokens.some((token, index) => token === 'resume' && tokens[index + 1] === threadId) +} + +type StructuredTuiIdentityInput = Parameters[0] + +export function readCodexResumeProcessIdentity( + input: Omit & { threadId: string } +): Promise { + const { threadId, ...identityInput } = input + const platform = input.platform ?? process.platform + return readStructuredTuiProcessIdentity({ + ...identityInput, + agent: 'codex', + processCommandMatches: (command) => isCodexResumeProcessCommandLine(command, threadId, platform) + }) +} diff --git a/src/main/codex/codex-server-request-disposition.test.ts b/src/main/codex/codex-server-request-disposition.test.ts new file mode 100644 index 00000000000..c5cf4fafcf2 --- /dev/null +++ b/src/main/codex/codex-server-request-disposition.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CODEX_ATTESTATION_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_BLOCKING_SERVER_REQUEST_METHODS, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + disposeCodexServerRequest +} from './codex-server-request-disposition' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CodexPromptRegistry +} from './codex-structured-prompt-replies' + +function harness() { + return { + registry: new CodexPromptRegistry(), + connection: { respond: vi.fn(), respondWithError: vi.fn() } + } +} + +const promptParams = { threadId: 'thread-1', turnId: 'turn-1', itemId: 'item-1' } + +describe('Codex blocking server request dispositions', () => { + it.each([ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD + ])('routes %s to the durable prompt registry', (method) => { + const { registry, connection } = harness() + const result = disposeCodexServerRequest(registry, connection, { + id: 1, + method, + params: + method === CODEX_USER_INPUT_METHOD + ? { ...promptParams, questions: [{ id: 'q1' }] } + : promptParams + }) + + expect(result.kind).toBe('prompt') + expect(connection.respond).not.toHaveBeenCalled() + expect(connection.respondWithError).not.toHaveBeenCalled() + }) + + it.each([ + [CODEX_MCP_ELICITATION_METHOD, { action: 'decline', content: null, _meta: null }], + [CODEX_PERMISSIONS_APPROVAL_METHOD, { permissions: {}, scope: 'turn', strictAutoReview: true }], + [CODEX_DYNAMIC_TOOL_CALL_METHOD, { contentItems: [], success: false }], + [CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, { decision: 'abort' }], + [CODEX_LEGACY_EXEC_APPROVAL_METHOD, { decision: 'abort' }] + ])('safely responds to %s', (method, response) => { + const { registry, connection } = harness() + + expect(disposeCodexServerRequest(registry, connection, { id: 2, method, params: {} })).toEqual({ + kind: 'responded', + method + }) + expect(connection.respond).toHaveBeenCalledWith(2, response) + }) + + it.each([ + [CODEX_AUTH_TOKEN_REFRESH_METHOD, 'cannot refresh app-server auth tokens'], + [CODEX_ATTESTATION_METHOD, 'did not negotiate attestation'] + ])('explicitly refuses %s', (method, message) => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { id: 3, method, params: {} }) + + expect(connection.respondWithError).toHaveBeenCalledWith( + 3, + -32001, + expect.stringContaining(message) + ) + }) + + it('cancels a malformed interactive request instead of using method-not-found', () => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { + id: 4, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: {} + }) + + expect(connection.respond).toHaveBeenCalledWith(4, { decision: 'cancel' }) + }) + + it('enumerates every server request in the negotiated stable schema', () => { + expect(new Set(CODEX_BLOCKING_SERVER_REQUEST_METHODS)).toEqual( + new Set([ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_ATTESTATION_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD + ]) + ) + }) + + it('bounds the future-method fallback to one explicit rejection', () => { + const { registry, connection } = harness() + + disposeCodexServerRequest(registry, connection, { + id: 5, + method: 'future/blockingRequest', + params: { opaque: true } + }) + + expect(connection.respond).not.toHaveBeenCalled() + expect(connection.respondWithError).toHaveBeenCalledOnce() + expect(connection.respondWithError).toHaveBeenCalledWith( + 5, + -32000, + 'Orca rejected unrecognized blocking request future/blockingRequest' + ) + }) +}) diff --git a/src/main/codex/codex-server-request-disposition.ts b/src/main/codex/codex-server-request-disposition.ts new file mode 100644 index 00000000000..362a4292de0 --- /dev/null +++ b/src/main/codex/codex-server-request-disposition.ts @@ -0,0 +1,86 @@ +import type { + CodexAppServerConnection, + CodexAppServerServerRequest +} from './codex-app-server-connection' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + type CodexPromptRegistry, + type CodexPendingPrompt +} from './codex-structured-prompt-replies' + +export const CODEX_MCP_ELICITATION_METHOD = 'mcpServer/elicitation/request' +export const CODEX_PERMISSIONS_APPROVAL_METHOD = 'item/permissions/requestApproval' +export const CODEX_DYNAMIC_TOOL_CALL_METHOD = 'item/tool/call' +export const CODEX_AUTH_TOKEN_REFRESH_METHOD = 'account/chatgptAuthTokens/refresh' +export const CODEX_ATTESTATION_METHOD = 'attestation/generate' +export const CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD = 'applyPatchApproval' +export const CODEX_LEGACY_EXEC_APPROVAL_METHOD = 'execCommandApproval' + +export const CODEX_BLOCKING_SERVER_REQUEST_METHODS = [ + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD, + CODEX_MCP_ELICITATION_METHOD, + CODEX_PERMISSIONS_APPROVAL_METHOD, + CODEX_DYNAMIC_TOOL_CALL_METHOD, + CODEX_AUTH_TOKEN_REFRESH_METHOD, + CODEX_ATTESTATION_METHOD, + CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD, + CODEX_LEGACY_EXEC_APPROVAL_METHOD +] as const + +export type CodexServerRequestDisposition = + | { kind: 'prompt'; prompt: CodexPendingPrompt } + | { kind: 'responded'; method: string } + +type ResponseConnection = Pick + +/** Every app-server request either becomes a durable prompt or receives a safe reply. */ +export function disposeCodexServerRequest( + registry: CodexPromptRegistry, + connection: ResponseConnection, + request: CodexAppServerServerRequest +): CodexServerRequestDisposition { + const prompt = registry.register(request) + if (prompt) { + return { kind: 'prompt', prompt } + } + + switch (request.method) { + case CODEX_COMMAND_APPROVAL_METHOD: + case CODEX_FILE_CHANGE_APPROVAL_METHOD: + connection.respond(request.id, { decision: 'cancel' }) + break + case CODEX_USER_INPUT_METHOD: + connection.respond(request.id, { answers: {} }) + break + case CODEX_MCP_ELICITATION_METHOD: + connection.respond(request.id, { action: 'decline', content: null, _meta: null }) + break + case CODEX_PERMISSIONS_APPROVAL_METHOD: + connection.respond(request.id, { permissions: {}, scope: 'turn', strictAutoReview: true }) + break + case CODEX_DYNAMIC_TOOL_CALL_METHOD: + connection.respond(request.id, { contentItems: [], success: false }) + break + case CODEX_LEGACY_APPLY_PATCH_APPROVAL_METHOD: + case CODEX_LEGACY_EXEC_APPROVAL_METHOD: + connection.respond(request.id, { decision: 'abort' }) + break + case CODEX_AUTH_TOKEN_REFRESH_METHOD: + connection.respondWithError(request.id, -32001, 'Orca cannot refresh app-server auth tokens') + break + case CODEX_ATTESTATION_METHOD: + connection.respondWithError(request.id, -32001, 'Orca did not negotiate attestation') + break + default: + connection.respondWithError( + request.id, + -32000, + `Orca rejected unrecognized blocking request ${request.method}` + ) + } + return { kind: 'responded', method: request.method } +} diff --git a/src/main/codex/codex-structured-acquisition-exit-proof.test.ts b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts new file mode 100644 index 00000000000..f0737fc36a3 --- /dev/null +++ b/src/main/codex/codex-structured-acquisition-exit-proof.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest' + +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { CodexAppServerConnection } from './codex-app-server-connection-types' +import { CodexStructuredSessionAdapter } from './codex-structured-session-adapter' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +describe('Codex failed-acquisition exit proof', () => { + it('retains a connection whose handshake cleanup could not prove exit', async () => { + const close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: true, + request: async () => ({}), + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const handshakeError = Object.assign(new Error('initialize failed'), { + name: 'CodexAppServerHandshakeExitUnprovenError', + connection + }) + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => { + throw handshakeError + }, + readProcessStartTime: async () => 1_700_000_000_000 + }) + + await expect( + adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(close).toHaveBeenCalledTimes(2) + }) + + it('retains an uncommitted child until a later close proves exit', async () => { + const close = vi + .fn<() => Promise>() + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => + method === 'thread/resume' + ? { thread: { id: 'thread-1', path: '/rollouts/thread-1.jsonl' } } + : {}, + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => connection, + readProcessStartTime: async () => null + }) + + await expect( + adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('agent_session_acquisition_exit_unproven') + await expect(adapter.releaseAcquisition({ sessionId: 'session-1' })).resolves.toBe(true) + expect(close).toHaveBeenCalledTimes(2) + }) + + it('keeps closeAll blocked by an unproven canceled acquisition', async () => { + const processStart = Promise.withResolvers() + const readStarted = Promise.withResolvers() + const close = vi.fn<() => Promise>().mockResolvedValue(false) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async () => ({ thread: { id: 'thread-1' } }), + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => connection, + readProcessStartTime: () => { + readStarted.resolve() + return processStart.promise + } + }) + const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + await readStarted.promise + + await expect(adapter.closeAll()).rejects.toThrow( + 'codex structured session shutdown could not prove every child stopped' + ) + processStart.resolve(null) + await expect(acquiring).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(close).toHaveBeenCalledTimes(4) + }) + + it('retains a child that opens after closeAll starts when exit remains unproven', async () => { + const openStarted = Promise.withResolvers() + const releaseOpen = Promise.withResolvers() + const close = vi.fn<() => Promise>().mockResolvedValue(false) + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async () => ({ thread: { id: 'thread-1' } }), + notify: () => undefined, + respond: () => undefined, + respondWithError: () => undefined, + close + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: 'thread-1' + }), + openConnection: async () => { + openStarted.resolve() + await releaseOpen.promise + return connection + }, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const acquiring = adapter.acquire({ identity: IDENTITY, fence: 7, spawnToken: 'spawn-9' }) + await openStarted.promise + + const closing = adapter.closeAll() + releaseOpen.resolve() + + await expect(closing).rejects.toThrow( + 'codex structured session shutdown could not prove every child stopped' + ) + await expect(acquiring).rejects.toThrow('agent_session_acquisition_exit_unproven') + expect(close).toHaveBeenCalledTimes(4) + }) +}) diff --git a/src/main/codex/codex-structured-acquisition-lifecycle.ts b/src/main/codex/codex-structured-acquisition-lifecycle.ts new file mode 100644 index 00000000000..740f95bce2d --- /dev/null +++ b/src/main/codex/codex-structured-acquisition-lifecycle.ts @@ -0,0 +1,53 @@ +import { AgentSessionAcquisitionExitUnprovenError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { isCodexAppServerHandshakeExitUnprovenError } from './codex-app-server-handshake-exit-proof' +import { + cancelCodexAcquisitionAttempt, + type CodexAcquisitionAttempt, + type CodexAcquisitionRegistry +} from './codex-structured-session-state' + +export async function stopSupersededCodexAcquisition(input: { + sessionId: string + registry: CodexAcquisitionRegistry + replacement: CodexAcquisitionAttempt + previous: CodexAcquisitionAttempt | undefined +}): Promise { + try { + if (!(await cancelCodexAcquisitionAttempt(input.previous))) { + throw new AgentSessionAcquisitionExitUnprovenError( + new Error(`codex acquisition for session ${input.sessionId} could not be stopped`) + ) + } + } catch (error) { + if (input.previous) { + input.registry.restoreIfCurrent(input.sessionId, input.replacement, input.previous) + } + throw error + } +} + +export async function closeFailedCodexAcquisition(input: { + sessionId: string + registry: CodexAcquisitionRegistry + attempt: CodexAcquisitionAttempt + cause: unknown + dispose: () => void +}): Promise { + if (isCodexAppServerHandshakeExitUnprovenError(input.cause)) { + input.attempt.window.connection = input.cause.connection + } + input.dispose() + try { + if (!(await input.registry.closeFailedAttempt(input.sessionId, input.attempt))) { + throw new AgentSessionAcquisitionExitUnprovenError(input.cause) + } + } catch (cleanupError) { + if (cleanupError instanceof AgentSessionAcquisitionExitUnprovenError) { + throw cleanupError + } + throw new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([input.cause, cleanupError], 'codex acquisition cleanup failed') + ) + } + throw input.cause +} diff --git a/src/main/codex/codex-structured-acquisition-window.ts b/src/main/codex/codex-structured-acquisition-window.ts new file mode 100644 index 00000000000..8db5534c5d6 --- /dev/null +++ b/src/main/codex/codex-structured-acquisition-window.ts @@ -0,0 +1,33 @@ +// The gap between spawning `codex app-server` and publishing the session it +// belongs to. Codex talks during that gap — the handshake, an early +// notification, even an approval request — and those events belong to the +// session that is still being acquired, so they wait here instead of arriving +// before anything can route them. The gap is bounded by the thread-open request +// timeout; a failed acquisition discards the buffer along with the child. + +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexPromptRegistry } from './codex-structured-prompt-replies' + +export class CodexAcquisitionWindow { + readonly prompts = new CodexPromptRegistry() + /** Null until the spawn resolves; the handshake can already emit events. */ + connection: CodexAppServerConnection | null = null + private readonly buffered: (() => void)[] = [] + private open = true + + /** Returns false once the session is published, which is the caller's cue to + * deliver live rather than buffer. */ + buffer(event: () => void): boolean { + if (!this.open) { + return false + } + this.buffered.push(event) + return true + } + + /** Closes the window and hands back what arrived while it was open, in order. */ + drain(): (() => void)[] { + this.open = false + return this.buffered.splice(0) + } +} diff --git a/src/main/codex/codex-structured-app-server-args.test.ts b/src/main/codex/codex-structured-app-server-args.test.ts new file mode 100644 index 00000000000..f76305cf7c1 --- /dev/null +++ b/src/main/codex/codex-structured-app-server-args.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexStructuredAppServerArgs } from './codex-structured-app-server-args' + +describe('structured Codex app-server arguments', () => { + it('keeps configuration flags and converts effort to the app-server config contract', () => { + expect( + resolveCodexStructuredAppServerArgs( + '--profile review -c approval_policy=never --model gpt-5.6 --effort high --search', + 'posix' + ) + ).toEqual([ + '--profile', + 'review', + '-c', + 'approval_policy=never', + '--model', + 'gpt-5.6', + '-c', + 'model_reasoning_effort=high', + '--search' + ]) + }) + + it.each(['--no-alt-screen', '--remote ws://host', '-C /tmp/elsewhere', 'resume thread-1'])( + 'reports an incompatible configured argument instead of dropping %s', + (configured) => { + expect(() => resolveCodexStructuredAppServerArgs(configured, 'posix')).toThrow( + /cannot apply the configured CLI arguments.*Settings or use terminal view/ + ) + } + ) +}) diff --git a/src/main/codex/codex-structured-app-server-args.ts b/src/main/codex/codex-structured-app-server-args.ts new file mode 100644 index 00000000000..af83c46c8a2 --- /dev/null +++ b/src/main/codex/codex-structured-app-server-args.ts @@ -0,0 +1,84 @@ +import { + tokenizeStartupCommand, + type AgentStartupShell +} from '../../shared/tui-agent-startup-shell' + +const VALUE_FLAGS = new Set([ + '-a', + '--add-dir', + '--ask-for-approval', + '-c', + '--config', + '--disable', + '--effort', + '--enable', + '--local-provider', + '-m', + '--model', + '-p', + '--profile', + '--reasoning-effort', + '-s', + '--sandbox' +]) + +const BOOLEAN_FLAGS = new Set([ + '--approve-for-me', + '--dangerously-bypass-approvals-and-sandbox', + '--dangerously-bypass-hook-trust', + '--oss', + '--search', + '--strict-config' +]) + +const EFFORT_FLAGS = new Set(['--effort', '--reasoning-effort']) + +function configuredArgsError(detail: string): Error { + return new Error( + `Structured Codex chat cannot apply the configured CLI arguments to app-server: ${detail}. Update Codex CLI arguments in Settings or use terminal view.` + ) +} + +function splitOption(token: string): { flag: string; inlineValue?: string } { + const separator = token.indexOf('=') + return separator > 0 + ? { flag: token.slice(0, separator), inlineValue: token.slice(separator + 1) } + : { flag: token } +} + +/** Keeps config-affecting Codex flags and refuses every TUI-only or unknown token visibly. */ +export function resolveCodexStructuredAppServerArgs( + configuredArgs: string, + shell: AgentStartupShell +): string[] { + const parsed = tokenizeStartupCommand(configuredArgs.trim(), shell) + if (!parsed.ok) { + throw configuredArgsError(parsed.error) + } + const divergent = parsed.spans.find((span) => span.divergesFromShell) + if (divergent) { + throw configuredArgsError(configuredArgs.slice(divergent.start, divergent.end)) + } + const result: string[] = [] + for (let index = 0; index < parsed.tokens.length; index += 1) { + const token = parsed.tokens[index] + const { flag, inlineValue } = splitOption(token) + if (BOOLEAN_FLAGS.has(flag) && inlineValue === undefined) { + result.push(flag) + continue + } + if (!VALUE_FLAGS.has(flag)) { + throw configuredArgsError(token || 'an empty positional argument') + } + const value = inlineValue ?? parsed.tokens[++index] + if (value === undefined || value.length === 0) { + throw configuredArgsError(`${flag} requires a value`) + } + if (EFFORT_FLAGS.has(flag)) { + result.push('-c', `model_reasoning_effort=${value}`) + } else { + result.push(flag, value) + } + } + return result +} diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts new file mode 100644 index 00000000000..98e988ec7d5 --- /dev/null +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' + +describe('buildCodexStructuredChildEnvironment', () => { + it('keeps shell exports while pinned launch values win', () => { + expect( + buildCodexStructuredChildEnvironment( + { + command: 'codex', + args: ['app-server'], + cwd: '/worktree', + codexHome: '/pinned/home', + resumeThreadId: null, + env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } + }, + 'spawn-token' + ) + ).toEqual({ + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/pinned/home', + [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token' + }) + }) +}) diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts new file mode 100644 index 00000000000..88326eb3fc0 --- /dev/null +++ b/src/main/codex/codex-structured-child-environment.ts @@ -0,0 +1,13 @@ +import type { CodexStructuredLaunch } from './codex-structured-session-state' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' + +export function buildCodexStructuredChildEnvironment( + launch: CodexStructuredLaunch, + spawnToken: string +): Record { + return { + ...launch.env, + ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}), + [CODEX_SPAWN_TOKEN_ENV]: spawnToken + } +} diff --git a/src/main/codex/codex-structured-item-streams.ts b/src/main/codex/codex-structured-item-streams.ts new file mode 100644 index 00000000000..9eb2204b72b --- /dev/null +++ b/src/main/codex/codex-structured-item-streams.ts @@ -0,0 +1,176 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { + createAgentSessionDeltaCoalescer, + type AgentSessionDeltaCoalescerDeps +} from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { + codexJournalItem, + codexStreamingJournalItem, + type CodexThreadItem +} from './codex-structured-item-translation' + +const CODEX_ITEM_STREAM_TYPES = { + 'item/agentMessage/delta': 'agentMessage', + 'item/plan/delta': 'plan', + 'item/commandExecution/outputDelta': 'commandExecution', + 'item/fileChange/outputDelta': 'fileChange', + 'item/reasoning/summaryTextDelta': 'reasoning', + 'item/reasoning/textDelta': 'reasoning' +} as const + +const PATCH_UPDATED_METHOD = 'item/fileChange/patchUpdated' +const REASONING_PART_METHOD = 'item/reasoning/summaryPartAdded' +const TERMINAL_INTERACTION_METHOD = 'item/commandExecution/terminalInteraction' + +type CodexItemStreamDeps = { + sink: StructuredAgentSessionEventSink + identityFor: ( + threadId: string, + params: unknown, + item: CodexThreadItem + ) => AgentJournalItemIdentity + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +type StreamState = { identity: AgentJournalItemIdentity; item: CodexThreadItem } + +export type CodexStructuredItemStreams = { + track: (threadId: string, item: CodexThreadItem, identity: AgentJournalItemIdentity) => void + handle: (threadId: string, method: string, params: unknown) => boolean + forget: (threadId: string, itemId: string) => void + flush: () => void + dispose: () => void +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function codexStructuredItemKey(threadId: string, itemId: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(itemId)}` +} + +export function createCodexStructuredItemStreams( + deps: CodexItemStreamDeps +): CodexStructuredItemStreams { + const states = new Map() + const latestText = new Map() + const checkpointLengths = new Map() + + const append = (state: StreamState, text: string): void => { + const translated = codexStreamingJournalItem(state.item, text) + if (!translated.body) { + return + } + deps.sink.appendItem(state.identity, translated.body, translated.blobs) + deps.sink.publish() + } + + const persist = (key: string, text: string, force: boolean): void => { + latestText.set(key, text) + const checkpointLength = checkpointLengths.get(key) ?? 0 + const nextLength = Math.max(checkpointLength + 32, Math.ceil(checkpointLength * 1.125)) + if (!force && checkpointLength > 0 && text.length < nextLength) { + return + } + checkpointLengths.set(key, text.length) + const state = states.get(key) + if (state) { + append(state, text) + } + } + + const coalescer = createAgentSessionDeltaCoalescer({ + windowMs: deps.coalesceMs, + schedule: deps.schedule, + emit: (key, text) => persist(key, text, false) + }) + + const ensureState = ( + threadId: string, + itemId: string, + type: string, + params: unknown + ): StreamState => { + const key = codexStructuredItemKey(threadId, itemId) + const existing = states.get(key) + if (existing) { + return existing + } + const item = { type, id: itemId } + const state = { item, identity: deps.identityFor(threadId, params, item) } + states.set(key, state) + return state + } + + const flush = (): void => { + coalescer.flushAll() + for (const [key, text] of latestText) { + if (checkpointLengths.get(key) !== text.length) { + persist(key, text, true) + } + } + } + + return { + track: (threadId, item, identity) => { + states.set(codexStructuredItemKey(threadId, item.id), { item, identity }) + }, + handle: (threadId, method, params) => { + const paramsRecord = readRecord(params) + const itemId = readString(paramsRecord, 'itemId') + if (method === PATCH_UPDATED_METHOD) { + if (!itemId || !Array.isArray(paramsRecord.changes)) { + return true + } + const key = codexStructuredItemKey(threadId, itemId) + coalescer.flush(key) + const state = ensureState(threadId, itemId, 'fileChange', params) + state.item = { ...state.item, changes: paramsRecord.changes } + const translated = codexJournalItem(state.item) + if (translated.body) { + deps.sink.appendItem(state.identity, translated.body, translated.blobs) + deps.sink.publish() + } + return true + } + if (method === TERMINAL_INTERACTION_METHOD) { + return true + } + const type = CODEX_ITEM_STREAM_TYPES[method as keyof typeof CODEX_ITEM_STREAM_TYPES] + if (!type && method !== REASONING_PART_METHOD) { + return false + } + if (!itemId) { + return true + } + const state = ensureState(threadId, itemId, type ?? 'reasoning', params) + const delta = method === REASONING_PART_METHOD ? '\n' : paramsRecord.delta + if (typeof delta === 'string') { + coalescer.append(codexStructuredItemKey(threadId, state.item.id), delta) + } + return true + }, + forget: (threadId, itemId) => { + const key = codexStructuredItemKey(threadId, itemId) + coalescer.forget(key) + states.delete(key) + latestText.delete(key) + checkpointLengths.delete(key) + }, + flush, + dispose: () => { + coalescer.dispose() + states.clear() + latestText.clear() + checkpointLengths.clear() + } + } +} diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts new file mode 100644 index 00000000000..8895facc11a --- /dev/null +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + codexItemBody, + codexItemIdentity, + codexMessageBlocks, + CodexTurnOrdinals, + isCodexMessageItemType, + readCodexThreadItem, + type CodexThreadItem +} from './codex-structured-item-translation' + +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' + +/** + * Captured from a live `codex app-server` turn: Codex numbers items in arrival + * order and includes the command it ran. + */ +const LIVE_TURN: CodexThreadItem[] = [ + { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list the files' }] }, + { type: 'agentMessage', id: 'item-1', text: 'Let me look.' }, + { + type: 'commandExecution', + id: 'item-2', + command: 'ls', + cwd: '/tmp', + status: 'completed', + exitCode: 0, + aggregatedOutput: 'a\nb\n' + }, + { type: 'agentMessage', id: 'item-3', text: 'Two files.' } +] + +/** + * The SAME turn read back after `thread/resume`: ids are renumbered from 1 and + * the command execution is gone entirely, because Codex does not persist it. + */ +const RESUMED_TURN: CodexThreadItem[] = [ + { type: 'userMessage', id: 'item-1', content: [{ type: 'text', text: 'list the files' }] }, + { type: 'agentMessage', id: 'item-2', text: 'Let me look.' }, + { type: 'agentMessage', id: 'item-3', text: 'Two files.' } +] + +function keysFor(items: CodexThreadItem[]): string[] { + const ordinals = new CodexTurnOrdinals() + return items + .filter((item) => isCodexMessageItemType(item.type)) + .map((item) => + agentJournalItemKey( + codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) + ) + ) +} + +describe('codex turn ordinals', () => { + it('releases a forgotten turn without ever reusing an ordinal it assigned', () => { + const ordinals = new CodexTurnOrdinals() + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(0) + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-2')).toBe(1) + + ordinals.forgetTurn('thread-1', 'turn-1') + + // A straggler for the released turn — even a previously seen item id — gets + // a FRESH ordinal: reusing a released slot would upsert another item's row. + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-1')).toBe(2) + expect(ordinals.ordinalFor('thread-1', 'turn-1', 'item-3')).toBe(3) + // Other turns are untouched. + expect(ordinals.ordinalFor('thread-1', 'turn-2', 'item-1')).toBe(0) + }) +}) + +describe('codex item identity', () => { + it('gives a resumed turn the same message keys as the live turn it renumbered', () => { + expect(keysFor(LIVE_TURN)).toEqual(keysFor(RESUMED_TURN)) + }) + + it('numbers messages 0,1,2 on both sides — the projection skips the dropped command', () => { + const ordinals = new CodexTurnOrdinals() + const live = LIVE_TURN.map((item) => + codexItemIdentity({ threadId: THREAD_ID, turnId: TURN_ID, item, ordinals }) + ) + + expect(live.map((id) => (id.provider === 'codex' ? id.ordinal : null))).toEqual([0, 1, null, 2]) + }) + + it('survives an item type this build does not model without consuming a message ordinal', () => { + const withUnknown = [ + LIVE_TURN[0] as CodexThreadItem, + { type: 'somethingCodexAddedLater', id: 'item-9' }, + LIVE_TURN[1] as CodexThreadItem + ] + + expect(keysFor(withUnknown)).toEqual(keysFor([LIVE_TURN[0], LIVE_TURN[1]] as CodexThreadItem[])) + }) + + it('assigns an ordinal once and reuses it, so a delta and its completion upsert one row', () => { + const ordinals = new CodexTurnOrdinals() + ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') + + expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-1')).toBe(1) + expect(ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0')).toBe(0) + }) + + it('restarts numbering per turn', () => { + const ordinals = new CodexTurnOrdinals() + ordinals.ordinalFor(THREAD_ID, TURN_ID, 'item-0') + + expect(ordinals.ordinalFor(THREAD_ID, 'turn-2', 'item-1')).toBe(0) + }) + + it('keys a non-message item and a turnless message in the orca namespace', () => { + const ordinals = new CodexTurnOrdinals() + const command = codexItemIdentity({ + threadId: THREAD_ID, + turnId: TURN_ID, + item: LIVE_TURN[2] as CodexThreadItem, + ordinals + }) + const orphan = codexItemIdentity({ + threadId: THREAD_ID, + turnId: null, + item: LIVE_TURN[1] as CodexThreadItem, + ordinals + }) + + expect(command).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-2' }) + expect(orphan).toEqual({ provider: 'orca', clientMessageId: 'codex-item:thread-abc:item-1' }) + }) +}) + +describe('codex item bodies', () => { + it('reads structured user content and flat agent text alike', () => { + expect(codexMessageBlocks(LIVE_TURN[0] as CodexThreadItem)).toEqual([ + { type: 'text', text: 'list the files' } + ]) + expect(codexMessageBlocks(LIVE_TURN[1] as CodexThreadItem)).toEqual([ + { type: 'text', text: 'Let me look.' } + ]) + }) + + it('keeps provider image echoes in mixed user content', () => { + expect( + codexMessageBlocks({ + type: 'userMessage', + id: 'm', + content: [ + { type: 'text', text: 'look' }, + { type: 'image', url: 'https://example.test/a.png' }, + { type: 'localImage', path: '/tmp/a.png' } + ] + }) + ).toEqual([ + { type: 'text', text: 'look' }, + { type: 'image-ref', url: 'https://example.test/a.png' }, + { type: 'image-ref', path: '/tmp/a.png' } + ]) + }) + + it('maps a finished zero-exit command to a completed shell tool call', () => { + expect(codexItemBody(LIVE_TURN[2] as CodexThreadItem)).toEqual({ + kind: 'tool-call', + name: 'shell', + input: { command: 'ls', cwd: '/tmp' }, + state: 'completed', + output: { head: 'a\nb\n', byteLength: 4, truncated: false, digest: expect.any(String) } + }) + }) + + it('calls a nonzero exit a failure even though codex calls the status completed', () => { + const body = codexItemBody({ + type: 'commandExecution', + id: 'item-2', + command: 'false', + status: 'completed', + exitCode: 1 + }) + + expect(body).toMatchObject({ state: 'failed' }) + }) + + it('treats an unfinished command as running and an aborted one as failed', () => { + expect( + codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'inProgress' }) + ).toMatchObject({ state: 'running' }) + expect( + codexItemBody({ type: 'commandExecution', id: 'a', command: 'sleep', status: 'aborted' }) + ).toMatchObject({ state: 'failed' }) + }) + + it('maps file changes to one bounded diff item', () => { + expect( + codexItemBody({ + type: 'fileChange', + id: 'patch-1', + status: 'completed', + changes: [ + { path: 'src/a.ts', diff: '@@ a @@' }, + { path: 'src/b.ts', diff: '@@ b @@' } + ] + }) + ).toMatchObject({ + kind: 'diff', + path: '2 files', + patch: { head: '@@ a @@\n@@ b @@', truncated: false } + }) + }) + + it('renders reasoning as status and exposes an unknown item as a provider frame', () => { + expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ + kind: 'status', + text: 'thinking' + }) + expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull() + expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull() + expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({ + kind: 'status', + text: 'codex · item:webSearch', + providerFrame: { provider: 'codex', kind: 'item:webSearch' } + }) + }) + + it('renders array-shaped reasoning content', () => { + expect( + codexItemBody({ + type: 'reasoning', + id: 'r', + summary: ['first', 'second'], + content: [{ text: 'fallback' }] + }) + ).toEqual({ kind: 'status', text: 'first\nsecond' }) + }) + + it('refuses a value that is not a thread item at all', () => { + expect(readCodexThreadItem({ type: 'agentMessage' })).toBeNull() + expect(readCodexThreadItem(null)).toBeNull() + expect(readCodexThreadItem({ type: 'agentMessage', id: 'm' })).not.toBeNull() + }) +}) diff --git a/src/main/codex/codex-structured-item-translation.ts b/src/main/codex/codex-structured-item-translation.ts new file mode 100644 index 00000000000..9269c952eb4 --- /dev/null +++ b/src/main/codex/codex-structured-item-translation.ts @@ -0,0 +1,321 @@ +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import { + boundInlineText, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from '../native-chat/agent-session-journal/journal-payload-bounds' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' + +// Codex thread items → journal item bodies and durable identities. +// +// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers +// item ids positionally on resume (`item-1`…`item-N` across the whole thread), +// and a resumed turn does NOT contain every item the live turn emitted — +// reasoning and command execution are dropped from persisted history. Numbering +// by live position would therefore shift every message after the first tool +// call and hand the user a duplicate of the assistant's answer after a resume. +// +// So the ordinal counts MESSAGE items only, and the same projection is applied +// to the live stream and to a resumed turn's item list. Any other item type — +// including ones this build does not model — is skipped identically on both +// sides, which is what makes the key survive a Codex release that adds one. + +/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */ +const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage']) + +export type CodexThreadItem = { + type: string + id: string + [key: string]: unknown +} + +export function isCodexMessageItemType(type: string): boolean { + return CODEX_MESSAGE_ITEM_TYPES.has(type) +} + +export function readCodexThreadItem(value: unknown): CodexThreadItem | null { + if (typeof value !== 'object' || value === null) { + return null + } + const record = value as Record + return typeof record.type === 'string' && typeof record.id === 'string' + ? (record as CodexThreadItem) + : null +} + +/** + * Ordinals for one thread, assigned on first sight and never reassigned. + * + * Non-message items are given no ordinal at all rather than a number from a + * second counter: a counter that a resumed history cannot reproduce is worse + * than no key, because it would look reconcilable and reconcile wrongly. + */ +export class CodexTurnOrdinals { + private readonly turns = new Map; next: number }>() + + ordinalFor(threadId: string, turnId: string, codexItemId: string): number { + const turnKey = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` + let turn = this.turns.get(turnKey) + if (!turn) { + turn = { assigned: new Map(), next: 0 } + this.turns.set(turnKey, turn) + } + const existing = turn.assigned.get(codexItemId) + if (existing !== undefined) { + return existing + } + const ordinal = turn.next + turn.assigned.set(codexItemId, ordinal) + turn.next += 1 + return ordinal + } + + /** Releases a finished turn's per-item map while keeping its counter, so a + * straggler frame can never be assigned an ordinal the turn already used — + * a reused slot would upsert another item's journal row. */ + forgetTurn(threadId: string, turnId: string): void { + const turn = this.turns.get(`${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}`) + if (turn) { + turn.assigned = new Map() + } + } +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +/** + * Durable identity for a Codex item, or null for one that has none. + * + * Non-message items fall back to the `orca` namespace keyed by the Codex item + * id. That id is unstable across resume, so those rows are live-session detail + * that a recovered journal simply will not contain — which is correct: Codex + * itself does not persist them either. + */ +export function codexItemIdentity(input: { + threadId: string + turnId: string | null + item: CodexThreadItem + ordinals: CodexTurnOrdinals +}): AgentJournalItemIdentity { + const { item, turnId } = input + if (turnId && isCodexMessageItemType(item.type)) { + return { + provider: 'codex', + threadId: input.threadId, + turnId, + ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id) + } + } + return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` } +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readTextContent(source: Record, key: string): string | null { + const direct = readString(source, key) + if (direct) { + return direct + } + const value = source[key] + if (!Array.isArray(value)) { + return null + } + const parts = value.flatMap((part) => { + if (typeof part === 'string') { + return part.length > 0 ? [part] : [] + } + if (typeof part !== 'object' || part === null) { + return [] + } + const text = readString(part as Record, 'text') + return text ? [text] : [] + }) + return parts.length > 0 ? parts.join('\n') : null +} + +/** `userMessage` carries structured content parts; `agentMessage` a flat text. */ +export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] { + const text = readString(item, 'text') + if (text !== null) { + return [{ type: 'text', text }] + } + const content = item.content + if (!Array.isArray(content)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const part of content) { + if (typeof part !== 'object' || part === null) { + continue + } + const partText = readString(part as Record, 'text') + if (partText !== null) { + blocks.push({ type: 'text', text: partText }) + continue + } + const record = part as Record + if (record.type === 'image' && typeof record.url === 'string') { + blocks.push({ type: 'image-ref', url: record.url }) + } else if (record.type === 'localImage' && typeof record.path === 'string') { + blocks.push({ type: 'image-ref', path: record.path }) + } + } + return blocks +} + +/** Codex reports `inProgress` then a terminal status; a zero exit code is the + * only thing that makes a finished command a success. */ +function commandState(item: CodexThreadItem): 'running' | 'completed' | 'failed' { + const status = readString(item, 'status') + if (status === null || status === 'inProgress') { + return 'running' + } + if (status !== 'completed') { + return 'failed' + } + const exitCode = item.exitCode + return typeof exitCode === 'number' && exitCode !== 0 ? 'failed' : 'completed' +} + +export type CodexJournalItem = { + body: AgentJournalItemBody | null + blobs: { digest: string; payload: string }[] + handled: boolean +} + +function commandItem(item: CodexThreadItem): CodexJournalItem { + const output = readString(item, 'aggregatedOutput') + const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { + body: { + kind: 'tool-call', + name: 'shell', + input: { command: item.command ?? null, cwd: item.cwd ?? null }, + state: commandState(item), + ...(bounded === null ? {} : { output: bounded.bounded }) + }, + blobs: + output !== null && bounded?.bounded.truncated + ? [{ digest: bounded.bounded.digest, payload: output }] + : [], + handled: true + } +} + +function fileChangeItem(item: CodexThreadItem): CodexJournalItem { + const changes = Array.isArray(item.changes) + ? item.changes.flatMap((change) => { + const record = typeof change === 'object' && change !== null ? readRecord(change) : {} + const path = readString(record, 'path') + const diff = readString(record, 'diff') + return path && diff ? [{ path, diff }] : [] + }) + : [] + if (changes.length === 0) { + return { + body: { + kind: 'tool-call', + name: 'apply_patch', + input: { changes: item.changes ?? null }, + state: commandState(item) + }, + blobs: [], + handled: true + } + } + const patch = changes.map((change) => change.diff).join('\n') + const bounded = boundInlineText(patch, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded + return { + body: { + kind: 'diff', + path: changes.length === 1 ? changes[0]!.path : `${changes.length} files`, + patch: bounded + }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: patch }] : [], + handled: true + } +} + +/** + * Journal body for a Codex item, or null for one with nothing to render. + * + * Known empty items wait for later deltas. Unknown types become bounded status + * rows so a provider release cannot make new activity invisible. + */ +export function codexJournalItem(item: CodexThreadItem): CodexJournalItem { + if (item.type === 'userMessage' || item.type === 'agentMessage') { + const blocks = codexMessageBlocks(item) + return { + body: + blocks.length === 0 + ? null + : { kind: 'message', role: item.type === 'userMessage' ? 'user' : 'assistant', blocks }, + blobs: [], + handled: true + } + } + if (item.type === 'commandExecution') { + return commandItem(item) + } + if (item.type === 'fileChange') { + return fileChangeItem(item) + } + if (item.type === 'reasoning' || item.type === 'plan') { + const text = + readTextContent(item, 'text') ?? + readTextContent(item, 'summary') ?? + readTextContent(item, 'content') + return { + body: + text === null + ? null + : { kind: 'status', text: boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text }, + blobs: [], + handled: true + } + } + const unhandled = unhandledProviderFrameJournalItem('codex', `item:${item.type}`, item) + return unhandled + ? { body: unhandled.body, blobs: unhandled.blobs, handled: false } + : { body: null, blobs: [], handled: true } +} + +export function codexItemBody(item: CodexThreadItem): AgentJournalItemBody | null { + return codexJournalItem(item).body +} + +/** Snapshot body for text still streaming, before its item completes. */ +export function codexStreamingMessageBody(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +/** Snapshot body for any item-level stream, keyed onto its parent item. */ +export function codexStreamingJournalItem(item: CodexThreadItem, text: string): CodexJournalItem { + if (item.type === 'agentMessage') { + return { body: codexStreamingMessageBody(text), blobs: [], handled: true } + } + if (item.type === 'commandExecution') { + return commandItem({ ...item, aggregatedOutput: text }) + } + if (item.type === 'fileChange') { + const path = Array.isArray(item.changes) + ? readString(readRecord(item.changes[0]), 'path') + : null + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).bounded + return { + body: { kind: 'diff', path: path ?? 'pending patch', patch: bounded }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: text }] : [], + handled: true + } + } + const bounded = boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + return { body: { kind: 'status', text: bounded.text }, blobs: [], handled: true } +} diff --git a/src/main/codex/codex-structured-journal-translation.test.ts b/src/main/codex/codex-structured-journal-translation.test.ts new file mode 100644 index 00000000000..84497c50c79 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation.test.ts @@ -0,0 +1,785 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import { + projectStructuredAgentSessionStatus, + projectStructuredItemsToNativeChat +} from '../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CodexTurnOrdinals } from './codex-structured-item-translation' +import { + createCodexJournalTranslator, + MAX_CODEX_GENERIC_ROWS_PER_TURN +} from './codex-structured-journal-translation' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_USER_INPUT_METHOD +} from './codex-structured-prompt-replies' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-abc' +const TURN_ID = 'turn-1' + +type Row = { key: string; body: AgentJournalItemBody } + +function recorder() { + const rows: Row[] = [] + const tombstones: string[] = [] + const bound: [string, string, string][] = [] + let publishes = 0 + const sink: StructuredAgentSessionEventSink = { + appendItem: (identity: AgentJournalItemIdentity, body) => + rows.push({ key: agentJournalItemKey(identity), body }), + appendTombstone: (identity) => tombstones.push(agentJournalItemKey(identity)), + publish: () => { + publishes += 1 + } + } + return { + sink, + rows, + tombstones, + bound, + publishes: () => publishes, + bindPromptItemId: (journalItemId: string, threadId: string, promptKey: string) => + bound.push([journalItemId, threadId, promptKey]) + } +} + +/** Fires the coalescing window on demand instead of on wall time. */ +function manualWindow() { + let pending: (() => void) | null = null + return { + schedule: (run: () => void) => { + pending = run + return () => { + pending = null + } + }, + fire: () => { + const run = pending + pending = null + run?.() + }, + idle: () => pending === null + } +} + +function notification(method: string, params: unknown): CodexStructuredSessionEvent { + return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params } +} + +const TURN_STARTED = notification('turn/started', { turn: { id: TURN_ID } }) + +function translatorWith(tap = recorder(), window = manualWindow()) { + const translator = createCodexJournalTranslator({ + sink: tap.sink, + bindPromptItemId: tap.bindPromptItemId, + schedule: window.schedule + }) + return { translator, tap, window } +} + +describe('codex journal translation', () => { + it('projects turns restored by thread/resume into durable conversation rows', () => { + const { translator, tap } = translatorWith() + + translator.restoreThread(THREAD_ID, { + turns: [ + { + id: 'turn-restored', + items: [ + { + type: 'userMessage', + id: 'user-restored', + content: [{ type: 'text', text: 'existing question' }] + }, + { type: 'agentMessage', id: 'agent-restored', text: 'existing answer' } + ] + } + ] + }) + + expect(tap.rows.map((row) => row.body)).toEqual([ + { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'existing question' }] + }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'existing answer' }] + } + ]) + }) + + it('durably opens and closes the primary turn cancellation lifecycle', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(TURN_STARTED) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + + expect(tap.rows).toEqual([ + { + key: 'legacy:codex:session-1:turn-lifecycle%3Aturn-1', + body: { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId: TURN_ID, state: 'running' } + } + } + ]) + expect(tap.tombstones).toEqual(['legacy:codex:session-1:turn-lifecycle%3Aturn-1']) + }) + + it('closes every active turn when the provider session ends after a later turn starts', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(notification('turn/started', { turn: { id: 'turn-stale' } })) + translator.handle(notification('turn/started', { turn: { id: 'turn-later' } })) + translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' }) + + expect(tap.rows.filter((row) => row.body.kind === 'status')).toHaveLength(2) + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ turnLifecycle: { turnId: 'turn-stale', state: 'running' } }), + expect.objectContaining({ turnLifecycle: { turnId: 'turn-later', state: 'running' } }) + ]) + expect(tap.tombstones).toEqual([ + 'legacy:codex:session-1:turn-lifecycle%3Aturn-stale', + 'legacy:codex:session-1:turn-lifecycle%3Aturn-later' + ]) + // The tombstones remove both running rows from the reduced journal; no + // lifecycle identity remains live after a session end. + expect( + projectStructuredAgentSessionStatus( + tap.rows + .filter((row) => !tap.tombstones.includes(row.key)) + .map((row, sequence) => ({ + itemId: row.key, + revision: 1, + sequence: sequence + 1, + observedAt: sequence + 1, + body: row.body + })) + ) + ).toBe('idle') + }) + + it('matches out-of-order completions to each turn identity', () => { + const tap = recorder() + const translator = createCodexJournalTranslator({ + sink: tap.sink, + primaryThreadId: () => THREAD_ID + }) + + translator.handle(notification('turn/started', { turn: { id: 'turn-stale' } })) + translator.handle(notification('turn/started', { turn: { id: 'turn-later' } })) + translator.handle(notification('turn/completed', { turn: { id: 'turn-stale' } })) + translator.handle(notification('turn/completed', { turn: { id: 'turn-later' } })) + + expect(tap.tombstones).toEqual([ + 'legacy:codex:session-1:turn-lifecycle%3Aturn-stale', + 'legacy:codex:session-1:turn-lifecycle%3Aturn-later' + ]) + expect( + projectStructuredAgentSessionStatus( + tap.rows + .filter((row) => !tap.tombstones.includes(row.key)) + .map((row, sequence) => ({ + itemId: row.key, + revision: 1, + sequence: sequence + 1, + observedAt: sequence + 1, + body: row.body + })) + ) + ).toBe('idle') + }) + + it('journals a user turn and the assistant answer under durable codex keys', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + }) + ) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'hello' } + }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-abc:turn-1:1' + ]) + expect(tap.rows[1]?.body).toEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'hello' }] + }) + }) + + it('folds streamed deltas into one snapshot row on the same key the item started under', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'he' })) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'llo' })) + window.fire() + + // `item/started` had no text to journal; only the coalesced snapshot lands. + expect(tap.rows).toEqual([ + { + key: 'codex:thread-abc:turn-1:0', + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } + } + ]) + }) + + it('upserts the streamed text and the completed body onto one row, body last', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'part' })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'partial' } + }) + ) + window.fire() + + // One key, so the reducer keeps the last write; the stale snapshot cannot + // come back after the window it was pending on fires. + expect(new Set(tap.rows.map((row) => row.key))).toEqual(new Set(['codex:thread-abc:turn-1:0'])) + expect(tap.rows.map((row) => row.body)).toEqual([ + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'part' }] }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'partial' }] } + ]) + }) + + it('flushes pending text before a lifecycle event, so nothing is journaled ahead of it', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'text' })) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'item-2', command: 'ls', status: 'inProgress' } + }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-2' + ]) + }) + + it('flushes what streamed when the child dies unannounced', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'half' })) + translator.handle({ type: 'ended', sessionId: SESSION_ID, reason: 'app-server exited' }) + + expect(tap.rows.at(-1)?.body).toMatchObject({ blocks: [{ type: 'text', text: 'half' }] }) + expect(window.idle()).toBe(true) + }) + + it('journals an approval naming the command the item already announced, and binds it', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { + type: 'commandExecution', + id: 'item-2', + command: 'rm -rf build', + status: 'inProgress' + } + }) + ) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept', 'decline'] }, + codexItemId: 'item-2', + promptKey: 'item-2' + }) + + const approval = tap.rows.at(-1) + expect(approval?.key).toBe('orca:codex-prompt%3Athread-abc%3Aitem-2') + expect(approval?.body).toMatchObject({ kind: 'approval', detail: 'rm -rf build' }) + expect(tap.bound).toEqual([['orca:codex-prompt%3Athread-abc%3Aitem-2', THREAD_ID, 'item-2']]) + }) + + it('journals one row per approval when a tool item asks twice', () => { + const { translator, tap } = translatorWith() + const ask = (promptKey: string): void => { + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept', 'decline'] }, + codexItemId: 'item-2', + promptKey + }) + } + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'item-2', command: 'ls', status: 'inProgress' } + }) + ) + ask('approval-a') + ask('approval-b') + + // Two asks, two answerable rows — keying by the tool item would have made the + // second ask overwrite the first, leaving the turn blocked. + const approvals = tap.rows.slice(-2) + expect(approvals.map((row) => row.key)).toEqual([ + 'orca:codex-prompt%3Athread-abc%3Aapproval-a', + 'orca:codex-prompt%3Athread-abc%3Aapproval-b' + ]) + // Both still name the command the shared item announced. + expect(approvals.every((row) => (row.body as { detail?: string }).detail === 'ls')).toBe(true) + expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['approval-a', 'approval-b']) + }) + + it('journals and binds one row per question in a user-input request', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle({ + type: 'prompt', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: CODEX_USER_INPUT_METHOD, + params: { + questions: [ + { id: 'q1', question: 'Which branch?', options: [{ label: 'main' }] }, + { id: 'q2', question: 'Proceed?', options: [{ label: 'yes' }] } + ] + }, + codexItemId: 'item-3', + promptKey: 'item-3' + }) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq1', + 'orca:codex-prompt%3Athread-abc%3Aitem-3%3Aq2' + ]) + expect(tap.bound.map(([, , promptKey]) => promptKey)).toEqual(['item-3', 'item-3']) + }) + + it('starts a new turn at ordinal zero and refuses to adopt an ended turn', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'one' } }) + ) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'orphan' } + }) + ) + translator.handle(notification('turn/started', { turn: { id: 'turn-2' } })) + translator.handle( + notification('item/completed', { item: { type: 'userMessage', id: 'item-2', text: 'two' } }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'orca:codex-item%3Athread-abc%3Aitem-1', + 'codex:thread-abc:turn-2:0' + ]) + }) + + it('prefers a turn id the event carries over the turn currently open', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { + turnId: 'turn-9', + item: { type: 'userMessage', id: 'item-0', text: 'late' } + }) + ) + + expect(tap.rows[0]?.key).toBe('codex:thread-abc:turn-9:0') + }) + + it('keeps interleaved thread turns, items, and deltas separate', () => { + const { translator, tap } = translatorWith() + const child = (method: string, params: unknown): CodexStructuredSessionEvent => ({ + type: 'notification', + sessionId: SESSION_ID, + threadId: 'thread-child', + method, + params + }) + + translator.handle(TURN_STARTED) + translator.handle(child('turn/started', { threadId: 'thread-child', turnId: 'turn-child' })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-0', text: 'root' } + }) + ) + translator.handle( + child('item/completed', { item: { type: 'agentMessage', id: 'item-0', text: 'child' } }) + ) + translator.handle(child('turn/completed', { turnId: 'turn-child' })) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'still root' } + }) + ) + + expect(tap.rows.map((row) => row.key)).toEqual([ + 'codex:thread-abc:turn-1:0', + 'codex:thread-child:turn-child:0', + 'codex:thread-abc:turn-1:1' + ]) + }) + + it('checkpoints long streams geometrically and flushes the final snapshot', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + + for (let index = 0; index < 512; index += 1) { + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'x' })) + window.fire() + } + translator.flush() + + expect(tap.rows.length).toBeLessThan(40) + expect(tap.rows.at(-1)?.body).toMatchObject({ + blocks: [{ type: 'text', text: 'x'.repeat(512) }] + }) + }) + + it('folds long-running command output into one exec item and zero generic rows', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { + item: { type: 'commandExecution', id: 'exec-1', command: 'long-task', status: 'inProgress' } + }) + ) + + for (let index = 0; index < 512; index += 1) { + translator.handle( + notification('item/commandExecution/outputDelta', { itemId: 'exec-1', delta: 'x' }) + ) + window.fire() + } + translator.flush() + + expect(new Set(tap.rows.map((row) => row.key))).toEqual( + new Set(['orca:codex-item%3Athread-abc%3Aexec-1']) + ) + expect(tap.rows.every((row) => row.body.kind === 'tool-call')).toBe(true) + expect(tap.rows.length).toBeLessThan(40) + expect(tap.rows.at(-1)?.body).toMatchObject({ + kind: 'tool-call', + output: { head: 'x'.repeat(512) } + }) + }) + + it('folds reasoning and patch streams into their parent rows', () => { + const { translator, tap, window } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle(notification('item/started', { item: { type: 'reasoning', id: 'r-1' } })) + translator.handle( + notification('item/reasoning/summaryTextDelta', { itemId: 'r-1', delta: 'thinking' }) + ) + translator.handle( + notification('item/started', { + item: { type: 'fileChange', id: 'patch-1', changes: [], status: 'inProgress' } + }) + ) + translator.handle( + notification('item/fileChange/patchUpdated', { + itemId: 'patch-1', + changes: [{ path: 'src/app.ts', kind: { type: 'update' }, diff: '@@ -1 +1 @@' }] + }) + ) + window.fire() + + const reduced = new Map(tap.rows.map((row) => [row.key, row.body])) + expect(reduced.get('orca:codex-item%3Athread-abc%3Ar-1')).toEqual({ + kind: 'status', + text: 'thinking' + }) + expect(reduced.get('orca:codex-item%3Athread-abc%3Apatch-1')).toMatchObject({ + kind: 'diff', + path: 'src/app.ts', + patch: { head: '@@ -1 +1 @@' } + }) + }) + + it('publishes after every write so a subscriber never trails the journal', () => { + const { translator, tap } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { item: { type: 'userMessage', id: 'item-0', text: 'hi' } }) + ) + + expect(tap.publishes()).toBe(1) + }) + + it('releases a turn ordinal map when the turn completes', () => { + const spy = vi.spyOn(CodexTurnOrdinals.prototype, 'forgetTurn') + try { + const { translator } = translatorWith() + translator.handle(TURN_STARTED) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + expect(spy).toHaveBeenCalledWith(THREAD_ID, TURN_ID) + } finally { + spy.mockRestore() + } + }) + + it('journals malformed item events but never malformed deltas', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle(notification('item/completed', {})) + translator.handle(notification('item/agentMessage/delta', { delta: 'orphan' })) + window.fire() + + expect(tap.rows.map((row) => row.body)).toEqual([ + expect.objectContaining({ + kind: 'status', + providerFrame: expect.objectContaining({ kind: 'notification:item/completed' }) + }) + ]) + }) + + it('journals unknown notifications, server requests, and decoded provider frames', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('future/notification', { value: 1 })) + translator.handle({ + type: 'server-request', + sessionId: SESSION_ID, + threadId: THREAD_ID, + method: 'future/request', + params: { value: 2 } + }) + translator.handle({ + type: 'provider-frame', + sessionId: SESSION_ID, + threadId: THREAD_ID, + kind: 'frame:unclassified', + payload: { value: 3 } + }) + + expect( + tap.rows.map((row) => (row.body.kind === 'status' ? row.body.providerFrame?.kind : undefined)) + ).toEqual(['notification:future/notification', 'request:future/request', 'frame:unclassified']) + }) + + it('bounds generic rows per turn while keeping the suppression visible and countable', () => { + const { translator, tap } = translatorWith() + translator.handle(TURN_STARTED) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 20; index += 1) { + translator.handle(notification('future/notification', { value: index })) + } + translator.handle(notification('item/future/outputDelta', { itemId: 'future', delta: 'x' })) + + const generic = tap.rows.filter( + (row) => row.body.kind === 'status' && row.body.providerFrame !== undefined + ) + expect(generic).toHaveLength(MAX_CODEX_GENERIC_ROWS_PER_TURN) + expect(generic[0]?.body).toMatchObject({ + kind: 'status', + providerFrame: { kind: 'notification:future/notification' } + }) + // The 20 capped frames reduce to ONE summary row whose count is exact, so + // suppressed provider activity is never invisible. + const summaries = new Map( + tap.rows + .filter((row) => row.key.includes('provider-frame-suppressed')) + .map((row) => [row.key, row.body]) + ) + expect(summaries.size).toBe(1) + expect([...summaries.values()][0]).toEqual({ + kind: 'status', + text: '20 more provider notifications not shown for this turn' + }) + expect( + tap.rows.some( + (row) => + row.body.kind === 'status' && + row.body.providerFrame?.kind === 'notification:item/future/outputDelta' + ) + ).toBe(false) + }) + + it('never lets the generic-row cap hide an error frame', () => { + const { translator, tap } = translatorWith() + translator.handle(TURN_STARTED) + for (let index = 0; index < MAX_CODEX_GENERIC_ROWS_PER_TURN + 3; index += 1) { + translator.handle(notification('future/notification', { value: index })) + } + translator.handle(notification('future/failure', { error: 'provider exploded' })) + + expect( + tap.rows.some( + (row) => + row.body.kind === 'status' && + row.body.providerFrame?.kind === 'notification:future/failure' + ) + ).toBe(true) + }) + + it('keeps a fresh session timeline empty through startup and status notifications', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('thread/started', { thread: { id: THREAD_ID } })) + for (let index = 0; index < 8; index += 1) { + translator.handle( + notification('mcpServer/startupStatus/updated', { + server: `server-${index}`, + status: 'starting' + }) + ) + } + translator.handle(notification('remoteControl/status/changed', { status: 'disabled' })) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline).toEqual([]) + }) + + it('projects only user and assistant content for a complete turn with hooks', () => { + const { translator, tap } = translatorWith() + + translator.handle(notification('thread/started', { thread: { id: THREAD_ID } })) + translator.handle(notification('hook/started', { run: { id: 'hook-1', status: 'running' } })) + translator.handle(notification('account/rateLimits/updated', { rateLimits: { primary: null } })) + translator.handle(TURN_STARTED) + translator.handle( + notification('item/completed', { + item: { type: 'userMessage', id: 'item-0', text: 'hi' } + }) + ) + translator.handle( + notification('hook/completed', { run: { id: 'hook-1', status: 'completed' } }) + ) + translator.handle( + notification('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'hello' } + }) + ) + translator.handle(notification('turn/completed', { turn: { id: TURN_ID } })) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline.map(({ role, blocks }) => ({ role, blocks }))).toEqual([ + { role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + { role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } + ]) + }) + + it('renders a system error carried by a suppressed status kind', () => { + const { translator, tap } = translatorWith() + + translator.handle( + notification('thread/status/changed', { + threadId: THREAD_ID, + status: { type: 'systemError' } + }) + ) + + const timeline = projectStructuredItemsToNativeChat( + tap.rows.map((row, index) => ({ + itemId: row.key, + revision: 1, + sequence: index + 1, + observedAt: index + 1, + body: row.body + })) + ) + expect(timeline).toEqual([ + expect.objectContaining({ + role: 'system', + blocks: [ + expect.objectContaining({ + providerFrame: expect.objectContaining({ + kind: 'notification:thread/status/changed' + }) + }) + ] + }) + ]) + }) + + it('writes nothing more after dispose', () => { + const { translator, tap, window } = translatorWith() + + translator.handle(TURN_STARTED) + translator.handle( + notification('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + ) + translator.handle(notification('item/agentMessage/delta', { itemId: 'item-1', delta: 'gone' })) + translator.dispose() + window.fire() + + expect(tap.rows).toEqual([]) + }) +}) diff --git a/src/main/codex/codex-structured-journal-translation.ts b/src/main/codex/codex-structured-journal-translation.ts new file mode 100644 index 00000000000..10bfcb9ef98 --- /dev/null +++ b/src/main/codex/codex-structured-journal-translation.ts @@ -0,0 +1,335 @@ +import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key' +import type { AgentSessionDeltaCoalescerDeps } from '../native-chat/agent-session-wire/agent-session-delta-coalescer' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' +import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter' +import { + codexItemIdentity, + codexJournalItem, + CodexTurnOrdinals, + readCodexThreadItem +} from './codex-structured-item-translation' +import { + codexStructuredItemKey, + createCodexStructuredItemStreams +} from './codex-structured-item-streams' +import { + codexApprovalItem, + codexPromptIdentity, + codexQuestionItems +} from './codex-structured-prompt-items' +import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' +import { readCodexTurnId } from './codex-structured-thread-facts' + +// The one place Codex events become journal rows. +// +// Every durable decision lives here rather than in the adapter: the adapter +// knows the protocol, this knows what a user is owed after a reconnect. It is +// per-session and per-acquisition — a new lease gets a new translator and a new +// sink, so a superseded child cannot keep writing. + +export const MAX_CODEX_GENERIC_ROWS_PER_TURN = 8 + +export type CodexJournalTranslatorDeps = { + sink: StructuredAgentSessionEventSink + /** Points an answered journal item back at the live Codex request. */ + bindPromptItemId?: (journalItemId: string, threadId: string, promptKey: string) => void + primaryThreadId?: () => string | null + coalesceMs?: number + schedule?: AgentSessionDeltaCoalescerDeps['schedule'] +} + +export type CodexJournalTranslator = { + handle: (event: CodexStructuredSessionEvent) => void + restoreThread: (threadId: string, thread: Record) => void + flush: () => void + dispose: () => void +} + +function readRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +export function createCodexJournalTranslator( + deps: CodexJournalTranslatorDeps +): CodexJournalTranslator { + const ordinals = new CodexTurnOrdinals() + /** Identity assigned when an item was announced, reused by its deltas and by + * its completion so all three upsert one row. */ + const identities = new Map() + /** What each announced item is, so an approval can name what it approves. */ + const details = new Map() + /** Turns announced by the provider and not yet closed. */ + const currentTurnIds = new Map>() + const genericRowsByTurn = new Map() + const suppressedRowsByTurn = new Map() + let fallbackSequence = 0 + + const currentTurnIdFor = (threadId: string): string | null => + [...(currentTurnIds.get(threadId) ?? [])].at(-1) ?? null + + const rememberTurn = (threadId: string, turnId: string): void => { + currentTurnIds.set(threadId, new Set([...(currentTurnIds.get(threadId) ?? []), turnId])) + } + + const forgetTurn = (threadId: string, turnId: string): void => { + const active = currentTurnIds.get(threadId) + active?.delete(turnId) + if (!active?.size) { + currentTurnIds.delete(threadId) + } + } + + const appendUnhandled = (kind: string, payload: unknown, threadId = 'session'): void => { + const translated = unhandledProviderFrameJournalItem('codex', kind, payload) + if (!translated) { + return + } + const turnId = readCodexTurnId(payload) ?? currentTurnIdFor(threadId) ?? 'outside-turn' + const bucket = `${encodeURIComponent(threadId)}:${encodeURIComponent(turnId)}` + const rowCount = genericRowsByTurn.get(bucket) ?? 0 + // The cap bounds noise, never evidence: an error frame is always journaled, + // and capped frames stay countable through one summary row per turn. + const capped = + rowCount >= MAX_CODEX_GENERIC_ROWS_PER_TURN && translated.classification !== 'error-surface' + if (capped) { + const suppressed = (suppressedRowsByTurn.get(bucket) ?? 0) + 1 + suppressedRowsByTurn.set(bucket, suppressed) + deps.sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame-suppressed:codex:${bucket}` }, + { + kind: 'status', + text: `${suppressed} more provider notification${suppressed === 1 ? '' : 's'} not shown for this turn` + } + ) + deps.sink.publish() + return + } + genericRowsByTurn.set(bucket, rowCount + 1) + fallbackSequence += 1 + deps.sink.appendItem( + { provider: 'orca', clientMessageId: `provider-frame:codex:${fallbackSequence}` }, + translated.body, + translated.blobs + ) + deps.sink.publish() + } + + const publishTurnLifecycle = ( + sessionId: string, + threadId: string, + turnId: string, + state: 'running' | 'completed' + ): void => { + if (deps.primaryThreadId?.() !== threadId) { + return + } + const identity = { + provider: 'legacy' as const, + agent: 'codex' as const, + sessionId, + recordId: `turn-lifecycle:${turnId}` + } + if (state === 'completed') { + deps.sink.appendTombstone(identity) + } else { + deps.sink.appendItem(identity, { + kind: 'status', + text: 'Codex is working…', + turnLifecycle: { turnId, state } + }) + } + deps.sink.publish() + } + + const identityFor = ( + threadId: string, + turnId: string | null, + item: { type: string; id: string } + ): AgentJournalItemIdentity => { + const key = codexStructuredItemKey(threadId, item.id) + const existing = identities.get(key) + if (existing) { + return existing + } + const identity = codexItemIdentity({ threadId, turnId, item, ordinals }) + identities.set(key, identity) + return identity + } + + const streams = createCodexStructuredItemStreams({ + sink: deps.sink, + coalesceMs: deps.coalesceMs, + schedule: deps.schedule, + identityFor: (threadId, params, item) => { + const turnId = readCodexTurnId(params) ?? currentTurnIdFor(threadId) + return identityFor(threadId, turnId, item) + } + }) + + const handleItemEvent = (event: { + threadId: string + method: string + params: unknown + }): boolean => { + const params = readRecord(event.params) + const item = readCodexThreadItem(params.item) + if (!item) { + return false + } + const turnId = readCodexTurnId(event.params) ?? currentTurnIdFor(event.threadId) + const identity = identityFor(event.threadId, turnId, item) + const translated = codexJournalItem(item) + const command = readString(item, 'command') + if (command) { + details.set(codexStructuredItemKey(event.threadId, item.id), command) + } + if (event.method === 'item/completed') { + // The completed body is authoritative; the coalesced text is now stale. + streams.forget(event.threadId, item.id) + } else { + streams.track(event.threadId, item, identity) + } + if (!translated.body) { + return true + } + deps.sink.appendItem(identity, translated.body, translated.blobs) + deps.sink.publish() + return true + } + + // The row is keyed by the prompt and the announced command is looked up by the + // tool item, because one item can ask more than once. + const handlePrompt = (event: { + threadId: string + method: string + params: unknown + codexItemId: string + promptKey: string + }): void => { + if (event.method === CODEX_USER_INPUT_METHOD) { + for (const question of codexQuestionItems({ + threadId: event.threadId, + promptKey: event.promptKey, + params: event.params + })) { + deps.sink.appendItem(question.identity, question.body) + deps.bindPromptItemId?.( + agentJournalItemKey(question.identity), + event.threadId, + event.promptKey + ) + } + deps.sink.publish() + return + } + const identity = codexPromptIdentity({ + threadId: event.threadId, + promptKey: event.promptKey + }) + deps.sink.appendItem( + identity, + codexApprovalItem({ + method: event.method, + params: event.params, + detail: details.get(codexStructuredItemKey(event.threadId, event.codexItemId)) ?? null + }) + ) + deps.bindPromptItemId?.(agentJournalItemKey(identity), event.threadId, event.promptKey) + deps.sink.publish() + } + + return { + restoreThread: (threadId, thread) => { + const turns = Array.isArray(thread.turns) ? thread.turns : [] + for (const rawTurn of turns) { + const turn = readRecord(rawTurn) + const turnId = readString(turn, 'id') + if (!turnId) { + continue + } + currentTurnIds.set(threadId, new Set([turnId])) + for (const item of Array.isArray(turn.items) ? turn.items : []) { + handleItemEvent({ threadId, method: 'item/completed', params: { turnId, item } }) + } + currentTurnIds.delete(threadId) + ordinals.forgetTurn(threadId, turnId) + } + streams.flush() + }, + handle: (event) => { + if (event.type === 'ended') { + streams.flush() + for (const [threadId, turnIds] of currentTurnIds) { + for (const turnId of turnIds) { + publishTurnLifecycle(event.sessionId, threadId, turnId, 'completed') + ordinals.forgetTurn(threadId, turnId) + } + } + currentTurnIds.clear() + return + } + if ( + event.type === 'notification' && + streams.handle(event.threadId, event.method, event.params) + ) { + return + } + // Lifecycle bypass: nothing may be journaled ahead of the text it follows. + streams.flush() + if (event.type === 'prompt') { + handlePrompt(event) + return + } + if (event.type === 'server-request') { + appendUnhandled(`request:${event.method}`, event.params, event.threadId) + return + } + if (event.type === 'provider-frame') { + appendUnhandled(event.kind, event.payload, event.threadId) + return + } + if (event.method === 'turn/started') { + const turnId = readCodexTurnId(event.params) + if (turnId) { + rememberTurn(event.threadId, turnId) + publishTurnLifecycle(event.sessionId, event.threadId, turnId, 'running') + } + return + } + if (event.method === 'turn/completed') { + const turnId = readCodexTurnId(event.params) ?? currentTurnIdFor(event.threadId) + if (turnId) { + publishTurnLifecycle(event.sessionId, event.threadId, turnId, 'completed') + ordinals.forgetTurn(event.threadId, turnId) + forgetTurn(event.threadId, turnId) + } + // A later item without its own turn id falls back to another active + // turn, if one exists; completed turns are never adopted again. + return + } + if (event.method === 'item/started' || event.method === 'item/completed') { + if (!handleItemEvent(event)) { + appendUnhandled(`notification:${event.method}`, event.params, event.threadId) + } + return + } + appendUnhandled(`notification:${event.method}`, event.params, event.threadId) + }, + flush: streams.flush, + dispose: () => { + streams.dispose() + identities.clear() + details.clear() + currentTurnIds.clear() + genericRowsByTurn.clear() + suppressedRowsByTurn.clear() + } + } +} diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts new file mode 100644 index 00000000000..484f7c1ee80 --- /dev/null +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' + +const SESSION_ID = 'session-1' +const IDENTITY = { sessionId: SESSION_ID } as Parameters< + ReturnType +>[0]['identity'] + +async function withPlatform(platform: NodeJS.Platform, run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +function record(overrides: Partial = {}): AgentSessionRecord { + return { + sessionId: SESSION_ID, + provider: 'codex', + location: { + executionHostId: LOCAL_EXECUTION_HOST_ID, + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + accountHome: { variable: 'CODEX_HOME', path: '/home/work/.codex' }, + providerHandleChain: [], + ...overrides + } as AgentSessionRecord +} + +function resolverFor( + value: AgentSessionRecord | null, + resolveWorkspacePath: (workspaceId: string) => Promise = async (id) => `/repos/${id}`, + resolveRollout: () => Promise = async () => null +) { + return createCodexStructuredLaunchResolver({ + store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + resolveWorkspacePath, + resolveCommand: () => '/usr/local/bin/codex', + resolveRollout + }) +} + +describe('codex structured launch resolution', () => { + it('launches the app server in the workspace and account home the record pinned', async () => { + const launch = await resolverFor(record())({ identity: IDENTITY }) + + expect(launch).toEqual({ + command: '/usr/local/bin/codex', + args: ['app-server'], + cwd: '/repos/workspace-1', + codexHome: '/home/work/.codex', + resumeThreadId: null + }) + }) + + it('passes a Windows .cmd path containing cmd syntax directly to the safe spawn layer', async () => { + const command = String.raw`C:\Users\r&d\npm-prefix\codex.cmd` + + await withPlatform('win32', async () => { + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, + resolveCommand: () => command + }) + + await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ + command, + args: ['app-server'] + }) + }) + }) + + it('resumes the last thread this session actually proved, not one a caller names', async () => { + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-old' } }, + { handle: { provider: 'codex', threadId: 'thread-current' } } + ] as AgentSessionRecord['providerHandleChain'] + }) + )({ identity: IDENTITY }) + + expect(launch.resumeThreadId).toBe('thread-current') + }) + + it('places the durable user configuration before the app-server subcommand', async () => { + const launch = await resolverFor( + record({ launchArgs: ['--profile', 'review', '-c', 'model_reasoning_effort=high'] }) + )({ identity: IDENTITY }) + + expect(launch.args).toEqual([ + '--profile', + 'review', + '-c', + 'model_reasoning_effort=high', + 'app-server' + ]) + }) + + it('pins resume to the rollout file that proved the durable thread', async () => { + const resolveRollout = vi.fn(async () => '/home/work/.codex/sessions/rollout.jsonl') + const launch = await resolverFor( + record({ + providerHandleChain: [ + { handle: { provider: 'codex', threadId: 'thread-current' } } + ] as AgentSessionRecord['providerHandleChain'] + }), + async (id) => `/repos/${id}`, + resolveRollout + )({ identity: IDENTITY }) + + expect(resolveRollout).toHaveBeenCalledWith('/home/work/.codex', 'thread-current') + expect(launch.resumePath).toBe('/home/work/.codex/sessions/rollout.jsonl') + }) + + it('refuses a session pinned to another host rather than starting a second writer here', async () => { + await expect( + resolverFor( + record({ + location: { ...record().location, executionHostId: 'ssh:build-box' } + } as Partial) + )({ identity: IDENTITY }) + ).rejects.toThrow(/local host/) + }) + + it('refuses a WSL session, which is a separate filesystem and process namespace', async () => { + await expect( + resolverFor(record({ location: { ...record().location, wslDistro: 'Ubuntu' } }))({ + identity: IDENTITY + }) + ).rejects.toThrow(/local host/) + }) + + it('refuses a record this adapter does not speak for', async () => { + await expect( + resolverFor(record({ provider: 'claude' } as Partial))({ + identity: IDENTITY + }) + ).rejects.toThrow(/is a claude session/) + await expect( + resolverFor( + record({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/.claude' } }) + )({ + identity: IDENTITY + }) + ).rejects.toThrow(/CODEX_HOME/) + }) + + it('refuses to launch for a session the store has no record of', async () => { + await expect(resolverFor(null)({ identity: IDENTITY })).rejects.toThrow(/no durable/) + }) + + it('surfaces a workspace that no longer resolves instead of falling back to a default cwd', async () => { + await expect( + resolverFor(record(), async () => { + throw new Error('workspace-1 is gone') + })({ identity: IDENTITY }) + ).rejects.toThrow('workspace-1 is gone') + }) +}) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts new file mode 100644 index 00000000000..b1cc7854808 --- /dev/null +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -0,0 +1,81 @@ +// How a durable session record becomes a Codex process launch. +// +// Every input is read back from the record the store already made durable, not +// from the call that triggered the acquire. A client that attaches twice must +// land in the same working directory under the same account home, and a resume +// must name the thread this session actually proved — never one a caller asks +// for, which is how a resume becomes a fork wearing a resume's name. + +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { resolveCodexCommand } from '../codex-cli/command' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { CodexStructuredLaunch } from './codex-structured-session-adapter' +import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof' + +export type CodexStructuredLaunchResolverDeps = { + store: AgentSessionRecordStore + /** Absolute path of a workspace on this host. Rejects when the workspace no + * longer resolves, which is the case a stale mobile client hits. */ + resolveWorkspacePath: (workspaceId: string) => Promise + /** Overridden in tests; production scans the boot-cached PATH and version-manager dirs. */ + resolveCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + /** Fresh shell/configured environment for this spawn; never written to the session record. */ + resolveEnvironment?: () => Promise + resolveRollout?: typeof resolvePinnedCodexRolloutProof +} + +export function createCodexStructuredLaunchResolver( + deps: CodexStructuredLaunchResolverDeps +): (input: { identity: AgentSessionJournalIdentity }) => Promise { + return async ({ identity }) => { + const record = deps.store.getRecord(identity.sessionId) + if (!record) { + throw new Error(`no durable agent-session record for ${identity.sessionId}`) + } + const { location, accountHome } = record + if (record.provider !== 'codex') { + throw new Error(`session ${identity.sessionId} is a ${record.provider} session`) + } + // This adapter spawns a child on the machine the runtime itself runs on. + // A session pinned elsewhere belongs to that host's runtime, and quietly + // starting it here would put a second writer on the same thread. + if (location.executionHostId !== LOCAL_EXECUTION_HOST_ID || location.wslDistro !== null) { + throw new Error( + `codex structured sessions run on the local host, not ${location.executionHostId}` + ) + } + if (accountHome.variable !== 'CODEX_HOME') { + throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`) + } + const environment = await deps.resolveEnvironment?.() + const pathEnv = environment?.PATH ?? environment?.Path ?? null + const homePath = environment?.HOME ?? environment?.USERPROFILE + const command = (deps.resolveCommand ?? resolveCodexCommand)({ + pathEnv, + ...(homePath ? { homePath } : {}) + }) + const args = [...(record.launchArgs ?? []), 'app-server'] + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const resumeThreadId = head?.handle.provider === 'codex' ? head.handle.threadId : null + return { + command, + args, + cwd: await deps.resolveWorkspacePath(location.workspaceId), + codexHome: accountHome.path, + ...(environment ? { env: { ...environment } as Record } : {}), + // An empty chain is a session that has never proved a thread, so it + // starts one; anything else resumes the last link this session proved. + resumeThreadId, + ...(resumeThreadId + ? { + resumePath: await (deps.resolveRollout ?? resolvePinnedCodexRolloutProof)( + accountHome.path, + resumeThreadId + ) + } + : {}) + } + } +} diff --git a/src/main/codex/codex-structured-location-support.ts b/src/main/codex/codex-structured-location-support.ts new file mode 100644 index 00000000000..915d9edaa83 --- /dev/null +++ b/src/main/codex/codex-structured-location-support.ts @@ -0,0 +1,11 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table' + +export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean { + return ( + location.executionHostId === LOCAL_EXECUTION_HOST_ID && + location.wslDistro === null && + (process.platform !== 'win32' || isWindowsProcessStartTimeAvailable()) + ) +} diff --git a/src/main/codex/codex-structured-owner-identity.test.ts b/src/main/codex/codex-structured-owner-identity.test.ts new file mode 100644 index 00000000000..c9a7b682218 --- /dev/null +++ b/src/main/codex/codex-structured-owner-identity.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { codexProcessIdentity } from './codex-structured-owner-identity' + +const IDENTITY = { + sessionId: 'session-identity', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: 'thread-1' } +} + +describe('codex process identity', () => { + it('records the observed start time alongside the spawn token', async () => { + await expect( + codexProcessIdentity( + { identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, + async () => 123 + ) + ).resolves.toEqual({ + hostId: 'local', + pid: 4242, + processStartTimeMs: 123, + spawnToken: 'spawn-a' + }) + }) + + it('retries a failed start-time read before giving up', async () => { + const readStartTime = vi + .fn<(pid: number) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(456) + await expect( + codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).resolves.toMatchObject({ processStartTimeMs: 456 }) + expect(readStartTime).toHaveBeenCalledTimes(3) + }) + + it('refuses an owner whose start time is unreadable rather than record one no probe can verify', async () => { + // A null start time guarantees every later owner probe answers indeterminate, which is + // a durable latch; refusing here is a retryable failure instead. + const readStartTime = vi.fn(async () => null) + await expect( + codexProcessIdentity({ identity: IDENTITY, spawnToken: 'spawn-a', pid: 4242 }, readStartTime) + ).rejects.toThrow('start time') + expect(readStartTime).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/codex/codex-structured-owner-identity.ts b/src/main/codex/codex-structured-owner-identity.ts new file mode 100644 index 00000000000..2259303a4b8 --- /dev/null +++ b/src/main/codex/codex-structured-owner-identity.ts @@ -0,0 +1,64 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { readProcessStartTimeMs } from '../runtime/agent-session-process-identity-probe' + +// What the lease records about the child Codex just handed back: the process it +// will later re-prove, and the provider handle link the journal binds to. Both +// must describe the thread Codex actually opened, never the one a client asked +// for. + +/** The child echoes its spawn token here so the owner probe can tell a live + * child of THIS reservation from a same-pid stranger. */ +export const CODEX_SPAWN_TOKEN_ENV = 'ORCA_AGENT_SESSION_SPAWN_TOKEN' + +const START_TIME_READ_ATTEMPTS = 3 + +export async function codexProcessIdentity( + input: { + identity: AgentSessionJournalIdentity + spawnToken: string + pid: number | undefined + }, + readStartTime: (pid: number) => Promise = readProcessStartTimeMs +): Promise { + if (input.pid === undefined) { + throw new Error('codex app-server started without a pid') + } + let processStartTimeMs: number | null = null + for ( + let attempt = 0; + attempt < START_TIME_READ_ATTEMPTS && processStartTimeMs === null; + attempt += 1 + ) { + processStartTimeMs = await readStartTime(input.pid) + } + if (processStartTimeMs === null) { + // Why: recording null makes every later owner probe indeterminate — a durable latch. + // Failing here reaps the child and leaves a retryable refusal instead. + throw new Error(`codex app-server start time for pid ${input.pid} could not be read`) + } + return { + hostId: input.identity.hostId, + pid: input.pid, + processStartTimeMs, + spawnToken: input.spawnToken + } +} + +export function codexProviderHandleLink(input: { + threadId: string + resumed: boolean + origin?: 'adopted' + fence: number + linkId?: string + observedAt: number +}): AgentSessionProviderHandleLink { + return { + linkId: input.linkId ?? `codex-${input.fence}-${input.threadId}`.slice(0, 128), + handle: { provider: 'codex', threadId: input.threadId }, + origin: input.origin ?? (input.resumed ? 'resumed' : 'created'), + mintedAtFence: input.fence, + observedAt: input.observedAt + } +} diff --git a/src/main/codex/codex-structured-prompt-items.test.ts b/src/main/codex/codex-structured-prompt-items.test.ts new file mode 100644 index 00000000000..21e0d1a76b0 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-items.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { + codexApprovalItem, + codexApprovalOptions, + codexPromptIdentity, + codexQuestionItems +} from './codex-structured-prompt-items' +import { + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + encodeCodexQuestionOptionId +} from './codex-structured-prompt-replies' + +const THREAD_ID = 'thread-abc' +const CODEX_ITEM_ID = 'item-4' + +describe('codex approval items', () => { + it('offers only the decisions this request named', () => { + expect(codexApprovalOptions({ availableDecisions: ['accept', 'decline'] })).toEqual([ + { id: 'accept', label: 'Allow' }, + { id: 'decline', label: 'Deny' } + ]) + }) + + it('offers the full set when the request names none, so the turn stays answerable', () => { + expect(codexApprovalOptions({}).map((option) => option.id)).toEqual([ + 'accept', + 'acceptForSession', + 'decline', + 'cancel' + ]) + }) + + it('drops a decision this build cannot send rather than offering a dead button', () => { + expect( + codexApprovalOptions({ availableDecisions: ['accept', 'teleport'] }).map((o) => o.id) + ).toEqual(['accept']) + }) + + it('titles the prompt by what codex asked for and starts it pending', () => { + const command = codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { availableDecisions: ['accept'] }, + detail: 'rm -rf build' + }) + + expect(command).toMatchObject({ + kind: 'approval', + title: 'Run a command?', + detail: 'rm -rf build', + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) + expect( + codexApprovalItem({ method: CODEX_FILE_CHANGE_APPROVAL_METHOD, params: {}, detail: null }) + ).toMatchObject({ title: 'Apply file changes?', detail: null }) + expect( + codexApprovalItem({ method: 'item/other/requestApproval', params: {}, detail: null }) + ).toMatchObject({ title: 'Approve this action?' }) + }) + + it("prefers codex's own reason over the command the item announced", () => { + const item = codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { reason: 'writes outside the workspace' }, + detail: 'rm -rf build' + }) + + expect(item.detail).toBe('writes outside the workspace') + }) + + it('prefers the approval request command and describes file-change grants', () => { + expect( + codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { command: ['git', 'status'] }, + detail: 'parent command' + }).detail + ).toBe('git status') + expect( + codexApprovalItem({ + method: CODEX_COMMAND_APPROVAL_METHOD, + params: { command: ['pnpm', 'test'], reason: 'same parent reason' }, + detail: 'parent command' + }).detail + ).toBe('pnpm test') + expect( + codexApprovalItem({ + method: CODEX_FILE_CHANGE_APPROVAL_METHOD, + params: { grantRoot: '/outside' }, + detail: null + }).detail + ).toBe('"/outside"') + }) +}) + +describe('codex question items', () => { + const params = { + questions: [ + { + id: 'q1', + question: 'Which branch?', + options: [{ label: 'main' }, { label: 'release/1.0' }] + }, + { id: 'q2', header: 'Proceed?', options: [{ label: 'yes' }] } + ] + } + + it('makes one journal item per question, each with its own resolution', () => { + const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params }) + + expect(items.map((item) => item.questionId)).toEqual(['q1', 'q2']) + expect(items.map((item) => item.body.question)).toEqual(['Which branch?', 'Proceed?']) + expect(items[0]?.body.resolution.state).toBe('pending') + }) + + it('keys each question separately so two answers cannot collide on one row', () => { + const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params }) + + expect(items.map((item) => item.identity)).toEqual([ + { provider: 'orca', clientMessageId: 'codex-prompt:thread-abc:item-4:q1' }, + { provider: 'orca', clientMessageId: 'codex-prompt:thread-abc:item-4:q2' } + ]) + }) + + it('names the question inside every option id, because codex replies by question', () => { + const items = codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params }) + + expect(items[0]?.body.options).toEqual([ + { id: encodeCodexQuestionOptionId('q1', 'main'), label: 'main' }, + { id: encodeCodexQuestionOptionId('q1', 'release/1.0'), label: 'release/1.0' } + ]) + expect(items[0]?.body.options[1]?.id).toBe('q1:release%2F1.0') + }) + + it('skips a question with no id or no prompt rather than minting an unanswerable row', () => { + const items = codexQuestionItems({ + threadId: THREAD_ID, + promptKey: CODEX_ITEM_ID, + params: { questions: [{ question: 'no id' }, { id: 'q3' }, { id: 'q4', question: 'ok' }] } + }) + + expect(items.map((item) => item.questionId)).toEqual(['q4']) + }) + + it('returns nothing when the request carries no questions at all', () => { + expect( + codexQuestionItems({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID, params: {} }) + ).toEqual([]) + }) + + it('preserves a free-text path for null options and Other', () => { + const [withoutOptions, withOther] = codexQuestionItems({ + threadId: THREAD_ID, + promptKey: CODEX_ITEM_ID, + params: { + questions: [ + { id: 'q1', question: 'Describe it', options: null }, + { + id: 'q2', + question: 'Pick or type', + options: [{ label: 'Known' }, { label: 'Other', isOther: true }] + } + ] + } + }) + + expect(withoutOptions?.body).toMatchObject({ options: [], freeTextQuestionId: 'q1' }) + expect(withOther?.body).toMatchObject({ + options: [{ id: 'q2:Known', label: 'Known' }], + freeTextQuestionId: 'q2' + }) + }) + + it('keys an approval without a question id', () => { + expect(codexPromptIdentity({ threadId: THREAD_ID, promptKey: CODEX_ITEM_ID })).toEqual({ + provider: 'orca', + clientMessageId: 'codex-prompt:thread-abc:item-4' + }) + }) +}) diff --git a/src/main/codex/codex-structured-prompt-items.ts b/src/main/codex/codex-structured-prompt-items.ts new file mode 100644 index 00000000000..4fd05763315 --- /dev/null +++ b/src/main/codex/codex-structured-prompt-items.ts @@ -0,0 +1,188 @@ +import type { + AgentJournalApprovalItem, + AgentJournalItemIdentity, + AgentJournalPromptOption, + AgentJournalQuestionItem +} from '../../shared/agent-session-journal-types' +import { + CODEX_APPROVAL_DECISIONS, + CODEX_COMMAND_APPROVAL_METHOD, + CODEX_FILE_CHANGE_APPROVAL_METHOD, + encodeCodexQuestionOptionId, + type CodexApprovalDecision +} from './codex-structured-prompt-replies' + +// Codex prompt requests → durable journal items. +// +// Codex blocks the turn on these, but the answer may arrive minutes later from +// a different device, so the prompt has to exist as a journal item with its own +// resolution state rather than as live callback state. The reply path already +// lives in `codex-structured-prompt-replies.ts`; this is only the render model. + +const APPROVAL_DECISION_LABELS: Record = { + accept: 'Allow', + acceptForSession: 'Allow for this session', + decline: 'Deny', + cancel: 'Stop' +} + +const PENDING = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +function readParams(params: unknown): Record { + return typeof params === 'object' && params !== null ? (params as Record) : {} +} + +function readString(source: Record, key: string): string | null { + const value = source[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** + * Codex offers a per-request decision set, so the options come off the request + * when it names them. Falling back to the full set is deliberate: a build that + * omits the field still accepts all four, and offering nothing would leave the + * turn blocked with no way to answer it. + */ +export function codexApprovalOptions(params: unknown): AgentJournalPromptOption[] { + const available = readParams(params).availableDecisions + const offered = Array.isArray(available) + ? available.filter((decision): decision is CodexApprovalDecision => + (CODEX_APPROVAL_DECISIONS as readonly unknown[]).includes(decision) + ) + : [] + const decisions = offered.length > 0 ? offered : CODEX_APPROVAL_DECISIONS + return decisions.map((decision) => ({ id: decision, label: APPROVAL_DECISION_LABELS[decision] })) +} + +export function codexApprovalItem(input: { + method: string + params: unknown + /** What is being approved, taken from the item Codex already announced — + * the approval request itself does not repeat the command or the patch. */ + detail: string | null +}): AgentJournalApprovalItem { + const params = readParams(input.params) + return { + kind: 'approval', + title: + input.method === CODEX_FILE_CHANGE_APPROVAL_METHOD + ? 'Apply file changes?' + : input.method === CODEX_COMMAND_APPROVAL_METHOD + ? 'Run a command?' + : 'Approve this action?', + detail: approvalDetail(params) ?? input.detail, + options: codexApprovalOptions(input.params), + resolution: { ...PENDING } + } +} + +function approvalDetail(params: Record): string | null { + const command = params.command + if (typeof command === 'string' && command.length > 0) { + return command + } + if (Array.isArray(command) && command.every((part) => typeof part === 'string')) { + return command.join(' ') + } + const reason = readString(params, 'reason') + if (reason) { + return reason + } + const detail = params.grantRoot ?? params.changes + return detail === undefined ? null : JSON.stringify(detail) +} + +export type CodexQuestionItem = { + questionId: string + identity: AgentJournalItemIdentity + body: AgentJournalQuestionItem +} + +/** + * One journal item per question, not one per request. Codex takes a single + * reply covering every question, but a client answers them one at a time, and + * each answer has to win its own compare-and-set — so each question needs its + * own resolution state. The reply fires when the last one lands. + */ +export function codexQuestionItems(input: { + threadId: string + promptKey: string + params: unknown +}): CodexQuestionItem[] { + const questions = readParams(input.params).questions + if (!Array.isArray(questions)) { + return [] + } + const items: CodexQuestionItem[] = [] + for (const entry of questions) { + const question = readParams(entry) + const questionId = readString(question, 'id') + const prompt = readString(question, 'question') ?? readString(question, 'header') + if (!questionId || !prompt) { + continue + } + items.push({ + questionId, + identity: codexPromptIdentity({ ...input, questionId }), + body: { + kind: 'question', + question: prompt, + options: questionOptions(question, questionId), + ...(questionAllowsFreeText(question) ? { freeTextQuestionId: questionId } : {}), + resolution: { ...PENDING } + } + }) + } + return items +} + +function questionAllowsFreeText(question: Record): boolean { + const options = question.options + return ( + !Array.isArray(options) || + options.length === 0 || + options.some((option) => readParams(option).isOther === true) + ) +} + +function questionOptions( + question: Record, + questionId: string +): AgentJournalPromptOption[] { + const options = question.options + if (!Array.isArray(options)) { + return [] + } + const mapped: AgentJournalPromptOption[] = [] + for (const entry of options) { + const option = readParams(entry) + const label = readString(option, 'label') + if (label !== null && option.isOther !== true) { + // The option id has to name its question: Codex's reply is a map keyed by + // question id, and the client only ever hands back an option id. + mapped.push({ id: encodeCodexQuestionOptionId(questionId, label), label }) + } + } + return mapped +} + +/** Prompts are live-session state Codex does not persist, so they are keyed in + * the Orca namespace rather than by `(threadId, turnId, ordinal)`. */ +/** Keyed by the prompt, not by the tool item it is about: one shell item can + * ask several times, and each ask is its own journal row to answer. */ +export function codexPromptIdentity(input: { + threadId: string + promptKey: string + questionId?: string +}): AgentJournalItemIdentity { + const suffix = input.questionId ? `:${input.questionId}` : '' + return { + provider: 'orca', + clientMessageId: `codex-prompt:${input.threadId}:${input.promptKey}${suffix}` + } +} diff --git a/src/main/codex/codex-structured-prompt-replies.test.ts b/src/main/codex/codex-structured-prompt-replies.test.ts new file mode 100644 index 00000000000..75dfb954fca --- /dev/null +++ b/src/main/codex/codex-structured-prompt-replies.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest' +import { + applyCodexPromptAnswer, + CodexPromptRegistry, + decodeCodexQuestionOptionId, + encodeCodexQuestionOptionId +} from './codex-structured-prompt-replies' + +function userInputRequest(questionIds: string[]): { + id: number + method: string + params: unknown +} { + return { + id: 5, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-item-1', + threadId: 'thread-1', + turnId: 'turn-1', + questions: questionIds.map((id) => ({ id })) + } + } +} + +describe('codex question option ids', () => { + it('round-trips a question id that itself contains the separator', () => { + const optionId = encodeCodexQuestionOptionId('scope:write', 'yes / no') + + expect(decodeCodexQuestionOptionId(optionId)).toEqual({ + questionId: 'scope:write', + answer: 'yes / no' + }) + }) + + it('reads nothing from an id with no separator', () => { + expect(decodeCodexQuestionOptionId('accept')).toBeNull() + }) +}) + +describe('CodexPromptRegistry', () => { + it('ignores a request that names no item or thread', () => { + const registry = new CodexPromptRegistry() + + expect( + registry.register({ id: 1, method: 'item/tool/requestUserInput', params: { itemId: 'i1' } }) + ).toBeNull() + expect(registry.register({ id: 2, method: 'account/refresh', params: {} })).toBeNull() + }) + + it('keeps two prompts that share one tool item apart', () => { + const registry = new CodexPromptRegistry() + const ask = (id: number, approvalId: string): void => { + registry.register({ + id, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', approvalId, threadId: 'thread-1' } + }) + } + + ask(1, 'approval-a') + ask(2, 'approval-b') + + // The second ask must not have replaced the first, or the turn blocks on a + // request nobody can address any more. + expect(registry.find('approval-a')?.requestId).toBe(1) + expect(registry.find('approval-b')?.requestId).toBe(2) + // Nothing addresses the shared item id, because it names two live prompts. + expect(registry.find('codex-item-1')).toBeNull() + }) + + it('addresses a prompt by its journal item id once bound, and forgets both', () => { + const registry = new CodexPromptRegistry() + const prompt = registry.register(userInputRequest(['q1'])) + registry.bindJournalItemId('codex:thread-1:turn-1:2', 'thread-1', 'codex-item-1') + + expect(registry.find('codex:thread-1:turn-1:2')).toBe(prompt) + expect(registry.find('codex-item-1')).toBe(prompt) + + registry.forget(prompt as NonNullable) + expect(registry.find('codex:thread-1:turn-1:2')).toBeNull() + expect(registry.find('codex-item-1')).toBeNull() + }) + + it('keeps identical item ids on different threads independently answerable', () => { + const registry = new CodexPromptRegistry() + const register = (id: number, threadId: string) => + registry.register({ + id, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'item-2', threadId } + }) + + register(1, 'thread-root') + register(2, 'thread-child') + registry.bindJournalItemId('journal-root', 'thread-root', 'item-2') + registry.bindJournalItemId('journal-child', 'thread-child', 'item-2') + + expect(registry.find('journal-root')?.requestId).toBe(1) + expect(registry.find('journal-child')?.requestId).toBe(2) + expect(registry.find('item-2')).toBeNull() + }) +}) + +describe('applyCodexPromptAnswer', () => { + it('accepts a bare answer only when the request has one question', () => { + const registry = new CodexPromptRegistry() + const single = registry.register(userInputRequest(['q1'])) + + expect(applyCodexPromptAnswer(single as NonNullable, 'sure')).toEqual({ + answers: { q1: { answers: ['sure'] } } + }) + }) + + it('refuses an answer that names no question of a multi-question request', () => { + const registry = new CodexPromptRegistry() + const many = registry.register(userInputRequest(['q1', 'q2'])) + + expect(() => applyCodexPromptAnswer(many as NonNullable, 'sure')).toThrow( + 'does not name a question' + ) + expect(() => + applyCodexPromptAnswer( + many as NonNullable, + encodeCodexQuestionOptionId('q3', 'sure') + ) + ).toThrow('does not name a question') + }) + + it('keeps the last answer when a question is answered twice', () => { + const registry = new CodexPromptRegistry() + const single = registry.register(userInputRequest(['q1'])) + const prompt = single as NonNullable + + applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'first')) + + expect(applyCodexPromptAnswer(prompt, encodeCodexQuestionOptionId('q1', 'second'))).toEqual({ + answers: { q1: { answers: ['second'] } } + }) + }) +}) diff --git a/src/main/codex/codex-structured-prompt-replies.ts b/src/main/codex/codex-structured-prompt-replies.ts new file mode 100644 index 00000000000..ad31d86043a --- /dev/null +++ b/src/main/codex/codex-structured-prompt-replies.ts @@ -0,0 +1,209 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection' + +// Codex asks for approvals and tool input by sending JSON-RPC REQUESTS back to +// Orca, and the turn blocks until each one is answered. The journal answers them +// much later, through a durable item id, so this module holds the live request +// ids and turns a chosen option back into the reply payload Codex expects. + +export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval' +export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval' +export const CODEX_USER_INPUT_METHOD = 'item/tool/requestUserInput' + +/** The decisions Codex accepts for both approval requests. Anything else is a + * client-supplied option id that never came from a Codex prompt. */ +export const CODEX_APPROVAL_DECISIONS = ['accept', 'acceptForSession', 'decline', 'cancel'] as const +export type CodexApprovalDecision = (typeof CODEX_APPROVAL_DECISIONS)[number] + +export type CodexPendingPrompt = { + requestId: number | string + method: string + threadId: string + turnId: string | null + codexItemId: string + /** What addresses this prompt. One tool item can ask more than once — a shell + * bridge re-asks per command under the same `itemId` — so the request's own + * `approvalId` is the identity whenever Codex sends one. */ + promptKey: string + /** One entry per question for a user-input request; empty for an approval. */ + questionIds: readonly string[] + answers: Map +} + +/** A user-input request can carry several questions but takes ONE reply, so an + * option id has to name the question it answers. */ +export function encodeCodexQuestionOptionId(questionId: string, answer: string): string { + return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}` +} + +export function decodeCodexQuestionOptionId( + optionId: string +): { questionId: string; answer: string } | null { + const separator = optionId.indexOf(':') + if (separator <= 0) { + return null + } + try { + return { + questionId: decodeURIComponent(optionId.slice(0, separator)), + answer: decodeURIComponent(optionId.slice(separator + 1)) + } + } catch { + return null + } +} + +function readString(params: unknown, key: string): string | null { + if (typeof params !== 'object' || params === null) { + return null + } + const value = (params as Record)[key] + return typeof value === 'string' && value.length > 0 ? value : null +} + +function readQuestionIds(params: unknown): string[] { + const questions = (params as { questions?: unknown } | null)?.questions + if (!Array.isArray(questions)) { + return [] + } + return questions + .map((question) => (question as { id?: unknown })?.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0) +} + +export function isCodexPromptMethod(method: string): boolean { + return ( + method === CODEX_COMMAND_APPROVAL_METHOD || + method === CODEX_FILE_CHANGE_APPROVAL_METHOD || + method === CODEX_USER_INPUT_METHOD + ) +} + +/** + * Live Codex prompt requests for one session, addressable by the journal item + * id the client will eventually answer with. The binding is registered by the + * translation module, because only it knows which journal item a Codex item + * became. + */ +export class CodexPromptRegistry { + private readonly byAddress = new Map() + /** Journal item id to thread-scoped prompt address. */ + private readonly journalItemIds = new Map() + + private address(threadId: string, promptKey: string): string { + return `${encodeURIComponent(threadId)}:${encodeURIComponent(promptKey)}` + } + + /** Returns null for a request this build does not model, so the caller can + * refuse it instead of leaving Codex blocked on an answer forever. */ + register(request: { + id: number | string + method: string + params: unknown + }): CodexPendingPrompt | null { + const codexItemId = readString(request.params, 'itemId') + const threadId = readString(request.params, 'threadId') + if (!isCodexPromptMethod(request.method) || !codexItemId || !threadId) { + return null + } + const prompt: CodexPendingPrompt = { + requestId: request.id, + method: request.method, + threadId, + turnId: readString(request.params, 'turnId'), + codexItemId, + promptKey: readString(request.params, 'approvalId') ?? codexItemId, + questionIds: + request.method === CODEX_USER_INPUT_METHOD ? readQuestionIds(request.params) : [], + answers: new Map() + } + this.byAddress.set(this.address(prompt.threadId, prompt.promptKey), prompt) + return prompt + } + + /** Called by the translation module once the prompt has a journal id. */ + bindJournalItemId(journalItemId: string, threadId: string, promptKey: string): void { + this.journalItemIds.set(journalItemId, this.address(threadId, promptKey)) + } + + /** Falls back to treating the id as a prompt key, which is what it is before + * any binding exists. */ + find(journalItemId: string): CodexPendingPrompt | null { + const address = this.journalItemIds.get(journalItemId) + if (address) { + return this.byAddress.get(address) ?? null + } + const matches = [...this.byAddress.values()].filter( + (prompt) => prompt.promptKey === journalItemId + ) + return matches.length === 1 ? matches[0]! : null + } + + forget(prompt: CodexPendingPrompt): void { + const address = this.address(prompt.threadId, prompt.promptKey) + this.byAddress.delete(address) + for (const [journalItemId, boundAddress] of this.journalItemIds) { + if (boundAddress === address) { + this.journalItemIds.delete(journalItemId) + } + } + } + + clear(): void { + this.byAddress.clear() + this.journalItemIds.clear() + } +} + +/** + * Records one answer and returns the reply payload once the request is fully + * answered. A multi-question user-input request stays pending until every + * question has an answer, because Codex takes one reply for all of them. + */ +export function applyCodexPromptAnswer( + prompt: CodexPendingPrompt, + optionId: string +): Record | null { + if (prompt.method !== CODEX_USER_INPUT_METHOD) { + if (!(CODEX_APPROVAL_DECISIONS as readonly string[]).includes(optionId)) { + throw new Error(`${optionId} is not a Codex approval decision`) + } + return { decision: optionId } + } + const decoded = decodeCodexQuestionOptionId(optionId) + const questionId = + decoded?.questionId ?? (prompt.questionIds.length === 1 ? prompt.questionIds[0] : null) + const answer = decoded?.answer ?? optionId + if (!questionId || !prompt.questionIds.includes(questionId)) { + throw new Error(`${optionId} does not name a question on Codex item ${prompt.codexItemId}`) + } + prompt.answers.set(questionId, answer) + if (prompt.questionIds.some((id) => !prompt.answers.has(id))) { + return null + } + const answers: Record = {} + for (const id of prompt.questionIds) { + answers[id] = { answers: [prompt.answers.get(id) as string] } + } + return { answers } +} + +/** Throws for a prompt Codex is no longer waiting on, which the wire reports as + * "recorded but not confirmed" rather than as a delivered answer. */ +export function answerCodexPrompt( + registry: CodexPromptRegistry, + connection: Pick, + itemId: string, + optionId: string +): void { + const prompt = registry.find(itemId) + if (!prompt) { + throw new Error(`codex app-server is no longer waiting on ${itemId}`) + } + const reply = applyCodexPromptAnswer(prompt, optionId) + if (reply === null) { + return + } + // Forget first: a second answer must find nothing rather than reply twice. + registry.forget(prompt) + connection.respond(prompt.requestId, reply) +} diff --git a/src/main/codex/codex-structured-provider-events.ts b/src/main/codex/codex-structured-provider-events.ts new file mode 100644 index 00000000000..4dbdd0a28f1 --- /dev/null +++ b/src/main/codex/codex-structured-provider-events.ts @@ -0,0 +1,77 @@ +import type { CodexAppServerServerRequest } from './codex-app-server-connection' +import { disposeCodexServerRequest } from './codex-server-request-disposition' +import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state' +import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' + +type EmitCodexEvent = (session: CodexSession, event: CodexStructuredSessionEvent) => void + +export function deliverCodexNotification( + sessionId: string, + session: CodexSession | undefined, + method: string, + params: unknown, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + const threadId = readCodexThreadId(params) ?? session.threadId + if (method === 'turn/started' && threadId === session.threadId) { + const turnId = readCodexTurnId(params) + const waiter = turnId ? session.turnIdWaiters.shift() : undefined + waiter?.(turnId as string) + } + emit(session, { type: 'notification', sessionId, threadId, method, params }) +} + +export function deliverCodexServerRequest( + sessionId: string, + session: CodexSession | undefined, + request: CodexAppServerServerRequest, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + const disposition = disposeCodexServerRequest(session.prompts, session.connection, request) + const threadId = readCodexThreadId(request.params) ?? session.threadId + if (disposition.kind === 'responded') { + emit(session, { + type: 'server-request', + sessionId, + threadId, + method: request.method, + params: request.params + }) + return + } + const prompt = disposition.prompt + emit(session, { + type: 'prompt', + sessionId, + threadId: prompt.threadId, + method: request.method, + params: request.params, + codexItemId: prompt.codexItemId, + promptKey: prompt.promptKey + }) +} + +export function deliverCodexUnhandledFrame( + sessionId: string, + session: CodexSession | undefined, + kind: string, + payload: unknown, + emit: EmitCodexEvent +): void { + if (!session) { + return + } + emit(session, { + type: 'provider-frame', + sessionId, + threadId: readCodexThreadId(payload) ?? session.threadId, + kind, + payload + }) +} diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts new file mode 100644 index 00000000000..e686231e043 --- /dev/null +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -0,0 +1,883 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { CodexAppServerRequestError } from './codex-app-server-connection' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + CodexAppServerLaunch, + openCodexAppServerConnection +} from './codex-app-server-connection' +import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' +import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' +import { encodeCodexQuestionOptionId } from './codex-structured-prompt-replies' +import { + CodexStructuredSessionAdapter, + type CodexStructuredLaunch, + type CodexStructuredSessionAdapterDeps, + type CodexStructuredSessionEvent +} from './codex-structured-session-adapter' + +const THREAD_ID = 'thread-abc' + +function identityFor(sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +type Route = (params: Record | undefined) => unknown + +// `closed` is readonly on the real connection; the fake flips it so a test can +// kill the child at a chosen moment. +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number; message?: string }[] + closeCount: number +} + +/** Stands in for a live `codex app-server`: every RPC is answered from `routes`, + * and the test drives Codex's own traffic through `handlers`. */ +function fakeCodex(routes: Record = {}): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + replies: [], + closeCount: 0, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + const route = routes[method] + return route ? route(params) : {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code, message) => connection.replies.push({ id, code, message }), + close: async () => { + connection.closeCount += 1 + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + routes['thread/start'] ??= () => ({ + thread: { id: THREAD_ID, path: '/rollouts/abc.jsonl' }, + model: 'gpt-live', + reasoningEffort: 'medium' + }) + routes['thread/resume'] ??= (params) => ({ + thread: { id: (params as { threadId: string }).threadId }, + model: 'gpt-live', + reasoningEffort: 'medium' + }) + return { connections, openConnection, routes } +} + +function adapterFor( + codex: ReturnType, + launch: Partial = {}, + events: CodexStructuredSessionEvent[] = [], + processControl: Partial< + Pick + > = {} +): CodexStructuredSessionAdapter { + return new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null, + ...launch + }), + onEvent: (event) => events.push(event), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), + terminateTurnProcesses: async () => true, + now: () => 1_700_000_000_500, + ...processControl + }) +} + +async function acquired( + codex: ReturnType, + launch: Partial = {}, + events: CodexStructuredSessionEvent[] = [] +): Promise { + const adapter = adapterFor(codex, launch, events) + await adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +describe('CodexStructuredSessionAdapter.acquire', () => { + it('starts a new thread and reports the process and link the lease will prove', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex, { codexHome: '/codex/home' }) + + const acquisition = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + + expect(codex.connections[0].launch.env).toEqual({ + [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', + CODEX_HOME: '/codex/home' + }) + expect(codex.connections[0].launch.cwd).toBe('/work/repo') + expect(codex.connections[0].calls[0]).toEqual({ + method: 'thread/start', + params: { cwd: '/work/repo' } + }) + expect(acquisition.process).toEqual({ + hostId: 'host-1', + pid: 4321, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-9' + }) + expect(acquisition.link).toEqual({ + linkId: `codex-7-${THREAD_ID}`, + handle: { provider: 'codex', threadId: THREAD_ID }, + origin: 'created', + mintedAtFence: 7, + observedAt: 1_700_000_000_500 + }) + }) + + it('resumes the thread the durable handle chain names, not the client one', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex, { + resumeThreadId: 'thread-proven', + resumePath: '/rollouts/thread-proven.jsonl' + }) + + const acquisition = await adapter.acquire({ + identity: identityFor('session-1'), + fence: 9, + spawnToken: 'spawn-9' + }) + + expect(codex.connections[0].calls[0]).toEqual({ + method: 'thread/resume', + params: { + threadId: 'thread-proven', + cwd: '/work/repo', + path: '/rollouts/thread-proven.jsonl' + } + }) + expect(acquisition.link.origin).toBe('resumed') + expect(acquisition.link.handle).toEqual({ provider: 'codex', threadId: 'thread-proven' }) + }) + + it('refuses a resume that lands on a different thread and reaps the child', async () => { + const codex = fakeCodex({ 'thread/resume': () => ({ thread: { id: 'thread-other' } }) }) + const adapter = adapterFor(codex, { resumeThreadId: 'thread-proven' }) + + await expect( + adapter.acquire({ identity: identityFor('session-1'), fence: 9, spawnToken: 'spawn-9' }) + ).rejects.toThrow('resumed thread-other instead of thread-proven') + expect(codex.connections[0].closeCount).toBe(1) + }) + + it('refuses a thread Codex never named', async () => { + const codex = fakeCodex({ 'thread/start': () => ({}) }) + const adapter = adapterFor(codex) + + await expect( + adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-9' }) + ).rejects.toThrow('did not name the thread') + expect(codex.connections[0].closeCount).toBe(1) + }) + + it('closes the previous child before re-acquiring at a new fence', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + + expect(codex.connections).toHaveLength(2) + expect(codex.connections[0].closeCount).toBe(1) + expect(codex.connections[1].closeCount).toBe(0) + }) + + it('keeps the traffic Codex sends before the session is published', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + codex.routes['thread/start'] = () => { + // Codex talks as soon as the child is up, which is before the adapter has + // a thread id to publish the session under. + codex.connections[0].handlers.onNotification?.('item/started', { threadId: THREAD_ID }) + codex.connections[0].handlers.onServerRequest?.({ + id: 5, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-early', threadId: THREAD_ID, turnId: 'turn-1' } + }) + return { thread: { id: THREAD_ID } } + } + + const adapter = await acquired(codex, {}, events) + + expect(events.map((event) => event.type)).toEqual(['notification', 'prompt']) + // The early approval is answerable, so Codex is not left blocked on a + // request that arrived a moment too soon. + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-early', + kind: 'approval', + optionId: 'accept', + fence: 7 + }) + expect(codex.connections[0].replies).toEqual([{ id: 5, result: { decision: 'accept' } }]) + }) + + it('refuses to publish a session whose child died while it was being acquired', async () => { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection: codex.openConnection, + // The child dies while the acquisition is still reading its identity. + readProcessStartTime: async () => { + codex.connections[0].closed = true + return 1_700_000_000_000 + } + }) + + await expect( + adapter.acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + ).rejects.toThrow('exited while being acquired') + expect(codex.connections[0].closeCount).toBe(1) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).rejects.toThrow('no live codex app-server') + }) + + it('classifies launch validation failure as pre-spawn without opening a child', async () => { + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => { + throw new Error('workspace no longer exists') + }, + openConnection: codex.openConnection + }) + + const error = await adapter + .acquire({ identity: identityFor('session-1'), fence: 7, spawnToken: 'spawn-9' }) + .catch((cause: unknown) => cause) + + expect(error).toMatchObject({ + name: 'AgentSessionPreSpawnError', + message: 'workspace no longer exists' + }) + expect(codex.connections).toHaveLength(0) + }) + + it('reports the rollout path Codex named, and null when it named none', async () => { + const withPath = fakeCodex() + const adapter = await acquired(withPath) + expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( + '/rollouts/abc.jsonl' + ) + + const withoutPath = fakeCodex({ 'thread/start': () => ({ thread: { id: THREAD_ID } }) }) + const bare = await acquired(withoutPath) + expect(await bare.historyFilePath({ identity: identityFor('session-1') })).toBeNull() + }) + + it('lets closeAll cancel and reap an acquisition still opening', async () => { + const codex = fakeCodex() + let releaseOpen = (): void => {} + let markOpenEntered = (): void => {} + const gate = new Promise((resolve) => { + releaseOpen = resolve + }) + const openEntered = new Promise((resolve) => { + markOpenEntered = resolve + }) + const openConnection: typeof openCodexAppServerConnection = async (...args) => { + markOpenEntered() + await gate + return codex.openConnection(...args) + } + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }), + openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const acquiring = adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + await openEntered + + const closing = adapter.closeAll() + releaseOpen() + + await expect(acquiring).rejects.toThrow('superseded while being acquired') + await closing + expect(codex.connections[0]?.closeCount).toBe(1) + }) + + it('fences an acquisition while launch resolution is still pending', async () => { + const launch = Promise.withResolvers() + const codex = fakeCodex() + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: () => launch.promise, + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const acquiring = adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9' + }) + + const closing = adapter.closeAll() + launch.resolve({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null + }) + + await expect(acquiring).rejects.toThrow('superseded while being acquired') + await closing + expect(codex.connections).toHaveLength(0) + }) +}) + +describe('CodexStructuredSessionAdapter.dispatch', () => { + it('accepts a turn Codex names in its response', async () => { + const codex = fakeCodex({ 'turn/start': () => ({ turn: { id: 'turn-1' } }) }) + const adapter = await acquired(codex) + + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'ship it' }, + { type: 'image-ref', path: '/tmp/shot.png' }, + { type: 'image-ref', url: 'https://example.test/a.png' } + ] + }, + fence: 7 + }) + + expect(outcome).toEqual({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-1', ordinal: 0 } + }) + expect(codex.connections[0].calls[1].params).toEqual({ + threadId: THREAD_ID, + clientUserMessageId: 'client-1', + input: [ + { type: 'text', text: 'ship it' }, + { type: 'localImage', path: '/tmp/shot.png' }, + { type: 'image', url: 'https://example.test/a.png' } + ] + }) + }) + + it('accepts a turn named only by the notification that raced the ack', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + codex.routes['turn/start'] = () => { + codex.connections[0].handlers.onNotification?.('turn/started', { + threadId: THREAD_ID, + turn: { id: 'turn-late' } + }) + return {} + } + + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(outcome).toMatchObject({ state: 'accepted' }) + expect(outcome).toMatchObject({ providerIdentity: { turnId: 'turn-late' } }) + expect(events.at(-1)).toMatchObject({ type: 'notification', method: 'turn/started' }) + }) + + it('does not let a child thread answer for the root thread', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + codex.routes['turn/start'] = () => { + // A subagent runs its own thread over the same connection, and its turn + // starts first. + const notify = codex.connections[0].handlers.onNotification + notify?.('turn/started', { threadId: 'thread-child', turn: { id: 'turn-child' } }) + notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-root' } }) + return {} + } + const adapter = await acquired(codex, {}, events) + + const outcome = await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + expect(outcome).toEqual({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD_ID, turnId: 'turn-root', ordinal: 0 } + }) + // Each event carries the thread it actually came from, so the journal can + // keep a subagent's turn out of the root conversation. + expect(events.map((event) => (event.type === 'notification' ? event.threadId : null))).toEqual([ + 'thread-child', + THREAD_ID + ]) + }) + + it('settles unknown rather than failed when Codex never names the turn', async () => { + vi.useFakeTimers() + try { + const codex = fakeCodex() + const adapter = await acquired(codex) + + const dispatching = adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await vi.advanceTimersByTimeAsync(10_000) + + expect(await dispatching).toEqual({ + state: 'unknown', + reason: 'codex app-server started a turn it did not name in time' + }) + } finally { + vi.useRealTimers() + } + }) + + it('rejects only when Codex answered and declined', async () => { + const codex = fakeCodex({ + 'turn/start': () => { + throw new CodexAppServerRequestError('turn/start', -32602, 'turn already running') + } + }) + const adapter = await acquired(codex) + + expect( + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).toEqual({ state: 'rejected', reason: 'turn already running' }) + }) + + it('rethrows a dead child so the wire settles the submission unknown', async () => { + const codex = fakeCodex({ + 'turn/start': () => { + throw new Error('codex app-server connection ended') + } + }) + const adapter = await acquired(codex) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).rejects.toThrow('connection ended') + }) + + it('applies an option change to the next turn only', async () => { + const codex = fakeCodex({ + 'model/list': () => ({ + data: [ + { + model: 'gpt-live', + supportedReasoningEfforts: [{ reasoningEffort: 'medium' }], + defaultReasoningEffort: 'medium' + }, + { + model: 'gpt-5', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + defaultReasoningEffort: 'high' + } + ], + nextCursor: null + }), + 'turn/start': () => ({ turn: { id: 'turn-1' } }) + }) + const adapter = await acquired(codex) + + await adapter.setOption({ sessionId: 'session-1', key: 'model', value: 'gpt-5', fence: 7 }) + await adapter.setOption({ sessionId: 'session-1', key: 'effort', value: 'high', fence: 7 }) + await expect( + adapter.setOption({ sessionId: 'session-1', key: 'sandboxEscape', value: 'yes', fence: 7 }) + ).rejects.toThrow('no thread option named sandboxEscape') + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + + const turnStart = codex.connections[0].calls.findLast((call) => call.method === 'turn/start') + expect(turnStart?.params).toMatchObject({ model: 'gpt-5', effort: 'high' }) + expect(turnStart?.params).not.toHaveProperty('sandboxEscape') + }) +}) + +describe('CodexStructuredSessionAdapter prompts', () => { + function askApproval(codex: ReturnType): void { + codex.connections[0].handlers.onServerRequest?.({ + id: 11, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + } + + it('surfaces an approval request and answers it exactly once', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + + askApproval(codex) + adapter.bindPromptItemId('session-1', 'codex:thread-abc:turn-1:3', 'codex-item-1') + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex:thread-abc:turn-1:3', + kind: 'approval', + optionId: 'accept', + fence: 7 + }) + + expect(events.at(-1)).toMatchObject({ type: 'prompt', codexItemId: 'codex-item-1' }) + expect(codex.connections[0].replies).toEqual([{ id: 11, result: { decision: 'accept' } }]) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex:thread-abc:turn-1:3', + kind: 'approval', + optionId: 'decline', + fence: 7 + }) + ).rejects.toThrow('no longer waiting on') + expect(codex.connections[0].replies).toHaveLength(1) + }) + + it('answers each approval a tool item asks for separately', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + // A shell bridge re-asks per command under one parent tool item, so only the + // approval id tells the two requests apart. + const ask = (id: number, approvalId: string): void => { + codex.connections[0].handlers.onServerRequest?.({ + id, + method: 'item/commandExecution/requestApproval', + params: { itemId: 'codex-item-1', approvalId, threadId: THREAD_ID, turnId: 'turn-1' } + }) + } + + ask(11, 'approval-a') + ask(12, 'approval-b') + adapter.bindPromptItemId('session-1', 'journal-a', 'approval-a') + adapter.bindPromptItemId('session-1', 'journal-b', 'approval-b') + for (const [itemId, optionId] of [ + ['journal-b', 'decline'], + ['journal-a', 'accept'] + ]) { + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId, + kind: 'approval', + optionId, + fence: 7 + }) + } + + expect(codex.connections[0].replies).toEqual([ + { id: 12, result: { decision: 'decline' } }, + { id: 11, result: { decision: 'accept' } } + ]) + expect(events.map((event) => (event.type === 'prompt' ? event.promptKey : null))).toEqual([ + 'approval-a', + 'approval-b' + ]) + }) + + it('rejects an option id that is not a Codex decision', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + askApproval(codex) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-1', + kind: 'approval', + optionId: 'yolo', + fence: 7 + }) + ).rejects.toThrow('is not a Codex approval decision') + expect(codex.connections[0].replies).toEqual([]) + }) + + it('holds a multi-question request until every question is answered', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + codex.connections[0].handlers.onServerRequest?.({ + id: 12, + method: 'item/tool/requestUserInput', + params: { + itemId: 'codex-item-2', + threadId: THREAD_ID, + turnId: 'turn-1', + questions: [{ id: 'q1' }, { id: 'q2' }] + } + }) + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-2', + kind: 'question', + optionId: encodeCodexQuestionOptionId('q1', 'yes'), + fence: 7 + }) + expect(codex.connections[0].replies).toEqual([]) + + await adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-2', + kind: 'question', + optionId: encodeCodexQuestionOptionId('q2', 'no'), + fence: 7 + }) + + expect(codex.connections[0].replies).toEqual([ + { id: 12, result: { answers: { q1: { answers: ['yes'] }, q2: { answers: ['no'] } } } } + ]) + }) + + it('declines MCP elicitation and journals the explicit disposition', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + await acquired(codex, {}, events) + + codex.connections[0].handlers.onServerRequest?.({ + id: 13, + method: 'mcpServer/elicitation/request', + params: { itemId: 'codex-item-3', threadId: THREAD_ID } + }) + + expect(codex.connections[0].replies).toEqual([ + { id: 13, result: { action: 'decline', content: null, _meta: null } } + ]) + expect(events.some((event) => event.type === 'prompt')).toBe(false) + }) + + it('surfaces an answer to a prompt Codex already forgot', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + await expect( + adapter.answerPrompt({ + sessionId: 'session-1', + itemId: 'codex-item-gone', + kind: 'approval', + optionId: 'accept', + fence: 7 + }) + ).rejects.toThrow('no longer waiting on codex-item-gone') + }) +}) + +describe('CodexStructuredSessionAdapter lifecycle', () => { + it('keeps sessions isolated and closes each child once', async () => { + const codex = fakeCodex() + const adapter = adapterFor(codex) + await adapter.acquire({ identity: identityFor('session-1'), fence: 1, spawnToken: 'spawn-a' }) + await adapter.acquire({ identity: identityFor('session-2'), fence: 1, spawnToken: 'spawn-b' }) + + codex.connections[0].handlers.onServerRequest?.({ + id: 21, + method: 'item/fileChange/requestApproval', + params: { itemId: 'codex-item-1', threadId: THREAD_ID, turnId: 'turn-1' } + }) + await expect( + adapter.answerPrompt({ + sessionId: 'session-2', + itemId: 'codex-item-1', + kind: 'approval', + optionId: 'accept', + fence: 1 + }) + ).rejects.toThrow('no longer waiting on') + + await adapter.closeAll() + expect(codex.connections.map((connection) => connection.closeCount)).toEqual([1, 1]) + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 1 }) + ).rejects.toThrow('no live codex app-server for session session-1') + }) + + it('retains ownership until a child exit is proven and reports it once', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + + const connection = codex.connections[0] + connection.close = async () => { + connection.closeCount += 1 + return false + } + connection.handlers.onExit?.(new Error('codex app-server connection ended')) + + expect(events.at(-1)).toEqual({ + type: 'ended', + sessionId: 'session-1', + reason: 'codex app-server connection ended' + }) + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + ).rejects.toThrow('no live codex app-server') + expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( + '/rollouts/abc.jsonl' + ) + await expect(adapter.closeSession('session-1')).resolves.toBe(false) + expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) + }) + + it('keeps the live session when a child it already replaced dies', async () => { + const codex = fakeCodex() + const events: CodexStructuredSessionEvent[] = [] + const adapter = await acquired(codex, {}, events) + await adapter.acquire({ identity: identityFor('session-1'), fence: 8, spawnToken: 'spawn-10' }) + const endedBeforeStaleExit = events.filter((event) => event.type === 'ended').length + + codex.connections[0].handlers.onExit?.(new Error('the superseded child died')) + + expect(events.filter((event) => event.type === 'ended')).toHaveLength(endedBeforeStaleExit) + expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( + '/rollouts/abc.jsonl' + ) + }) + + it('ignores Codex traffic that arrives after the session is gone', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + const connection = codex.connections[0] + + await adapter.closeSession('session-1') + connection.handlers.onNotification?.('item/agentMessage/delta', { delta: 'x' }) + connection.handlers.onServerRequest?.({ + id: 31, + method: 'item/fileChange/requestApproval', + params: { itemId: 'codex-item-9', threadId: THREAD_ID } + }) + + expect(connection.replies).toEqual([]) + }) + + it('flushes the final coalesced text before a graceful close', async () => { + const codex = fakeCodex() + const bodies: AgentJournalMessageItem[] = [] + const tombstones: unknown[] = [] + const sink: StructuredAgentSessionEventSink = { + appendItem: (_identity, body) => { + if (body.kind === 'message') { + bodies.push(body) + } + }, + appendTombstone: (identity) => tombstones.push(identity), + publish: () => {} + } + const adapter = adapterFor(codex) + await adapter.acquire({ + identity: identityFor('session-1'), + fence: 7, + spawnToken: 'spawn-9', + events: sink + }) + const notify = codex.connections[0]!.handlers.onNotification + notify?.('turn/started', { threadId: THREAD_ID, turn: { id: 'turn-1' } }) + notify?.('item/started', { + threadId: THREAD_ID, + item: { type: 'agentMessage', id: 'item-1', text: '' } + }) + notify?.('item/agentMessage/delta', { + threadId: THREAD_ID, + itemId: 'item-1', + delta: 'last words' + }) + + await adapter.closeSession('session-1') + + expect(bodies.at(-1)?.blocks).toEqual([{ type: 'text', text: 'last words' }]) + expect(tombstones).toContainEqual({ + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + }) + }) +}) diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts new file mode 100644 index 00000000000..ed209e4c5c9 --- /dev/null +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -0,0 +1,325 @@ +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { + AgentSessionPreSpawnError, + type AgentSessionAcquisition, + type AgentSessionDispatchOutcome, + type StructuredAgentSessionAcquireInput, + type StructuredAgentSessionAdapter, + type StructuredAgentSessionSetOptionInput +} from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + closeFailedCodexAcquisition, + stopSupersededCodexAcquisition +} from './codex-structured-acquisition-lifecycle' +import { createCodexJournalTranslator } from './codex-structured-journal-translation' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { codexProcessIdentity, codexProviderHandleLink } from './codex-structured-owner-identity' +import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' +import { answerCodexPrompt } from './codex-structured-prompt-replies' +import { openCodexThread } from './codex-structured-thread-open' +import { dispatchCodexTurn, isCodexTurnOptionKey } from './codex-structured-turn-start' +import { supportsCodexStructuredLocation } from './codex-structured-location-support' +import { + closeAllCodexSessions, + closeCodexPublishedSession, + closeCodexSession, + handleCodexSessionExit +} from './codex-structured-session-close' +import { + applyCodexStructuredSessionOption, + readLiveCodexSessionOptions, + reportedCodexThreadOptions, + restoredCodexSessionOptions +} from './codex-structured-session-options' +import { + CodexAcquisitionRegistry, + type CodexAcquisitionAttempt, + type CodexSession, + type CodexStructuredSessionAdapterDeps, + type CodexStructuredSessionEvent +} from './codex-structured-session-state' +import { + deliverCodexNotification, + deliverCodexServerRequest, + deliverCodexUnhandledFrame +} from './codex-structured-provider-events' +import { CodexStructuredTurnCancellation } from './codex-structured-turn-cancellation' + +export type { + CodexStructuredLaunch, + CodexStructuredSessionAdapterDeps, + CodexStructuredSessionEvent +} from './codex-structured-session-state' + +export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdapter { + private readonly sessions = new Map() + private readonly acquisitions = new CodexAcquisitionRegistry() + private readonly turnCancellation: CodexStructuredTurnCancellation + + constructor(private readonly deps: CodexStructuredSessionAdapterDeps) { + this.turnCancellation = new CodexStructuredTurnCancellation({ + captureTurnProcesses: deps.captureTurnProcesses, + terminateTurnProcesses: deps.terminateTurnProcesses, + requestTimeoutMs: deps.requestTimeoutMs, + emit: (session, event) => this.emit(session, event) + }) + } + + supportsLocation = supportsCodexStructuredLocation + + async acquire(input: StructuredAgentSessionAcquireInput): Promise { + const sessionId = input.identity.sessionId + const { previousAttempt, attempt } = this.acquisitions.start(sessionId) + const acquisition = attempt.window + let primaryThreadId = + input.identity.providerHandle.kind === 'codex' ? input.identity.providerHandle.threadId : null + const translator = input.events + ? createCodexJournalTranslator({ + sink: input.events, + primaryThreadId: () => primaryThreadId, + bindPromptItemId: (journalItemId, threadId, promptKey) => + acquisition.prompts.bindJournalItemId(journalItemId, threadId, promptKey) + }) + : null + const open = this.deps.openConnection ?? openCodexAppServerConnection + + try { + await stopSupersededCodexAcquisition({ + sessionId, + registry: this.acquisitions, + replacement: attempt, + previous: previousAttempt + }) + this.acquisitions.assertCurrent(sessionId, attempt) + if (!(await closeCodexPublishedSession(this.sessions, sessionId, this.deps.onEvent))) { + throw new Error(`codex app-server for session ${sessionId} could not be stopped`) + } + this.acquisitions.assertCurrent(sessionId, attempt) + const launch = await this.deps + .resolveLaunch({ identity: input.identity }) + .catch((error: unknown) => { + throw new AgentSessionPreSpawnError(error) + }) + this.acquisitions.assertCurrent(sessionId, attempt) + const connection = await open( + { + command: launch.command, + args: launch.args, + cwd: launch.cwd, + env: buildCodexStructuredChildEnvironment(launch, input.spawnToken) + }, + { + onNotification: (method, params) => + this.deliver(acquisition, sessionId, () => + this.handleNotification(sessionId, method, params) + ), + onServerRequest: (request) => + this.deliver(acquisition, sessionId, () => + this.handleServerRequest(sessionId, request) + ), + onUnhandledFrame: (kind, payload) => + this.deliver(acquisition, sessionId, () => + this.handleUnhandledFrame(sessionId, kind, payload) + ), + onExit: (error) => { + acquisition.prompts.clear() + handleCodexSessionExit({ + sessions: this.sessions, + sessionId, + connection: acquisition.connection, + error, + ...(this.deps.onEvent ? { onEvent: this.deps.onEvent } : {}) + }) + } + } + ) + acquisition.connection = connection + this.acquisitions.assertCurrent(sessionId, attempt) + const opened = await openCodexThread(connection, launch, this.deps.requestTimeoutMs) + this.acquisitions.assertCurrent(sessionId, attempt) + primaryThreadId = opened.threadId + translator?.restoreThread(opened.threadId, opened.thread ?? {}) + const process = await codexProcessIdentity( + { ...input, pid: connection.pid }, + this.deps.readProcessStartTime + ) + this.acquisitions.assertCurrent(sessionId, attempt) + const acquired: AgentSessionAcquisition = { + process, + link: codexProviderHandleLink({ + threadId: opened.threadId, + resumed: launch.resumeThreadId !== null, + fence: input.fence, + linkId: this.deps.mintLinkId?.(), + observedAt: this.deps.now?.() ?? Date.now() + }) + } + // Publish only after every promised identity is proven and this attempt still owns the child. + if (connection.closed) { + throw new Error(`codex app-server for session ${sessionId} exited while being acquired`) + } + this.acquisitions.assertCurrent(sessionId, attempt) + this.acquisitions.deleteIfCurrent(sessionId, attempt) + const session: CodexSession = { + connection, + ended: false, + threadId: opened.threadId, + historyPath: opened.historyPath, + prompts: acquisition.prompts, + options: restoredCodexSessionOptions(input.options), + reportedOptions: reportedCodexThreadOptions(opened), + turnIdWaiters: [], + translator + } + this.turnCancellation.register(session) + this.sessions.set(sessionId, session) + for (const event of acquisition.drain()) { + event() + } + return acquired + } catch (error) { + // Reap this attempt's child only. A replacement already published for the + // same session keeps running. + if (this.sessions.get(sessionId)?.connection !== acquisition.connection) { + return closeFailedCodexAcquisition({ + sessionId, + registry: this.acquisitions, + attempt, + cause: error, + dispose: () => translator?.dispose() + }) + } + this.acquisitions.deleteIfCurrent(sessionId, attempt) + throw error + } finally { + attempt.finish() + } + } + + /** Buffers pre-publication events and drops events from superseded children. */ + private deliver( + acquisition: CodexAcquisitionAttempt['window'], + sessionId: string, + event: () => void + ): void { + if (acquisition.buffer(event)) { + return + } + if (this.sessions.get(sessionId)?.connection === acquisition.connection) { + event() + } + } + + private handleNotification(sessionId: string, method: string, params: unknown): void { + const session = this.sessions.get(sessionId) + if (session && this.turnCancellation.handleNotification(sessionId, session, method, params)) { + return + } + deliverCodexNotification(sessionId, session, method, params, (session, event) => + this.emit(session, event) + ) + } + + /** Journal first so observers never see an event ahead of its durable row. */ + private emit(session: CodexSession, event: CodexStructuredSessionEvent): void { + session.translator?.handle(event) + this.deps.onEvent?.(event) + } + + private handleServerRequest( + sessionId: string, + request: Parameters[2] + ): void { + deliverCodexServerRequest(sessionId, this.sessions.get(sessionId), request, (session, event) => + this.emit(session, event) + ) + } + + private handleUnhandledFrame(sessionId: string, kind: string, params: unknown): void { + deliverCodexUnhandledFrame( + sessionId, + this.sessions.get(sessionId), + kind, + params, + (session, event) => this.emit(session, event) + ) + } + + bindPromptItemId = (sessionId: string, journalItemId: string, promptKey: string): void => + this.sessions + .get(sessionId) + ?.prompts.bindJournalItemId(journalItemId, this.session(sessionId).threadId, promptKey) + + async dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + }): Promise { + const session = this.session(input.sessionId) + await this.turnCancellation.captureBaseline(session) + return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs) + } + + async cancelTurn(input: { + sessionId: string + turnId: string + fence: number + }): Promise<{ cancelled: boolean }> { + const session = this.session(input.sessionId) + return this.turnCancellation.cancel(session, input.turnId) + } + + async answerPrompt(input: { + sessionId: string + itemId: string + kind: 'approval' | 'question' + optionId: string + fence: number + }): Promise { + const session = this.session(input.sessionId) + answerCodexPrompt(session.prompts, session.connection, input.itemId, input.optionId) + } + + async setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> { + if (!isCodexTurnOptionKey(input.key)) { + throw new Error(`codex app-server has no thread option named ${input.key}`) + } + return applyCodexStructuredSessionOption( + this.session(input.sessionId), + input.key, + input.value, + this.deps.requestTimeoutMs + ) + } + + readOptions = (input: { sessionId: string; fence: number }) => + readLiveCodexSessionOptions(this.session(input.sessionId), this.deps.requestTimeoutMs) + + historyFilePath = async (input: { + identity: AgentSessionJournalIdentity + }): Promise => this.sessions.get(input.identity.sessionId)?.historyPath ?? null + + closeSession = (sessionId: string): Promise => + closeCodexSession(sessionId, this.sessions, this.acquisitions, this.deps.onEvent) + disposeSession = (sessionId: string): Promise => this.closeSession(sessionId) + closeAll = (): Promise => + closeAllCodexSessions(this.sessions, this.acquisitions, (sessionId) => + this.disposeSession(sessionId) + ) + releaseAcquisition = (input: { sessionId: string }): Promise => + this.closeSession(input.sessionId) + + private session(sessionId: string): CodexSession { + const session = this.sessions.get(sessionId) + if (!session || session.ended) { + throw new Error(`no live codex app-server for session ${sessionId}`) + } + return session + } +} diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts new file mode 100644 index 00000000000..aed0722b79b --- /dev/null +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -0,0 +1,279 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../shared/agent-session-journal-types' +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type CodexAppServerLaunch, + type openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAppServerUnsupportedError } from './codex-app-server-session' +import { + CodexStructuredSessionAdapter, + type CodexStructuredSessionAdapterDeps, + type CodexStructuredSessionEvent +} from './codex-structured-session-adapter' + +const THREAD_ID = 'thread-abc' +const USER_MESSAGE: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'ship it' }] +} + +type Route = (params: Record | undefined) => unknown +type FakeConnection = Omit & { + closed: boolean + launch: CodexAppServerLaunch + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] +} + +function identity(): AgentSessionJournalIdentity { + return { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +function fakeCodex(): { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + routes: Record +} { + const connections: FakeConnection[] = [] + const routes: Record = { + 'thread/resume': () => ({ thread: { id: THREAD_ID } }) + } + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + return routes[method]?.(params) ?? {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + return { connections, openConnection, routes } +} + +async function acquired( + codex: ReturnType, + events: CodexStructuredSessionEvent[] = [], + processControl: Partial< + Pick + > = {} +): Promise { + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => ({ + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: THREAD_ID + }), + onEvent: (event) => events.push(event), + openConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + captureTurnProcesses: async () => ({ platform: 'win32', identities: new Map() }), + terminateTurnProcesses: async () => true, + ...processControl + }) + await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-9' }) + return adapter +} + +function completeTurn(codex: ReturnType, turnId = 'turn-1'): void { + codex.connections[0].handlers.onNotification?.('turn/completed', { + threadId: THREAD_ID, + turn: { id: turnId, status: 'interrupted' } + }) +} + +describe('CodexStructuredSessionAdapter.cancelTurn', () => { + it('confirms an interrupt Codex acknowledged', async () => { + const codex = fakeCodex() + const adapter = await acquired(codex) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: true }) + expect(codex.connections[0].calls.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: THREAD_ID, turnId: 'turn-1' } + }) + }) + + it('reports not-cancelled when Codex declines or lacks the method', async () => { + const declined = fakeCodex() + declined.routes['turn/interrupt'] = () => { + throw new CodexAppServerRequestError('turn/interrupt', -32602, 'no such turn') + } + const absent = fakeCodex() + absent.routes['turn/interrupt'] = () => { + throw new CodexAppServerUnsupportedError('no turn/interrupt') + } + + await expect( + (await acquired(declined)).cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7 + }) + ).resolves.toEqual({ cancelled: false }) + await expect( + (await acquired(absent)).cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7 + }) + ).resolves.toEqual({ cancelled: false }) + }) + + it('rethrows an unsettled interrupt so the turn is not shown as cancelled', async () => { + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + throw new Error('codex app-server turn/interrupt exceeded 30000ms') + } + + await expect( + (await acquired(codex)).cancelTurn({ + sessionId: 'session-1', + turnId: 'turn-1', + fence: 7 + }) + ).rejects.toThrow('exceeded 30000ms') + }) + + it('publishes terminal state only after streaming interruption is physically settled', async () => { + const events: CodexStructuredSessionEvent[] = [] + let finishTermination!: (terminated: boolean) => void + const termination = new Promise((resolve) => { + finishTermination = resolve + }) + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => termination + }) + codex.connections[0].handlers.onNotification?.('item/agentMessage/delta', { + threadId: THREAD_ID, + turnId: 'turn-1', + itemId: 'item-1', + delta: 'still streaming' + }) + + const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + await vi.waitFor(() => expect(codex.connections[0].calls.at(-1)?.method).toBe('turn/interrupt')) + expect(events).toContainEqual(expect.objectContaining({ method: 'item/agentMessage/delta' })) + expect(events).not.toContainEqual(expect.objectContaining({ method: 'turn/completed' })) + + finishTermination(true) + await expect(pending).resolves.toEqual({ cancelled: true }) + expect(events.at(-1)).toMatchObject({ method: 'turn/completed' }) + }) + + it('starts physical termination without waiting for the interrupt receipt', async () => { + let finishInterrupt!: () => void + const interruptReceipt = new Promise((resolve) => { + finishInterrupt = resolve + }) + const terminateTurnProcesses = vi.fn(async () => true) + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => interruptReceipt + const adapter = await acquired(codex, [], { terminateTurnProcesses }) + + const pending = adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + await vi.waitFor(() => expect(terminateTurnProcesses).toHaveBeenCalledOnce()) + finishInterrupt() + + await expect(pending).resolves.toEqual({ cancelled: true }) + }) + + it('keeps the turn live when process termination cannot be verified', async () => { + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => false + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).resolves.toEqual({ cancelled: false }) + expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) + }) + + it('accepts an immediate resend after verified interruption', async () => { + let nextTurn = 0 + const codex = fakeCodex() + codex.routes['turn/start'] = () => ({ turn: { id: `turn-${++nextTurn}` } }) + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + return {} + } + const adapter = await acquired(codex) + + await adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-1', + body: USER_MESSAGE, + fence: 7 + }) + await adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-2', + body: USER_MESSAGE, + fence: 7 + }) + ).resolves.toMatchObject({ + state: 'accepted', + providerIdentity: { turnId: 'turn-2' } + }) + }) + + it('does not strand a deferred completion when the interrupt receipt fails', async () => { + const events: CodexStructuredSessionEvent[] = [] + const codex = fakeCodex() + codex.routes['turn/interrupt'] = () => { + completeTurn(codex) + throw new Error('interrupt receipt lost') + } + const adapter = await acquired(codex, events, { + terminateTurnProcesses: async () => true + }) + + await expect( + adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) + ).rejects.toThrow('interrupt receipt lost') + expect(events).toContainEqual(expect.objectContaining({ method: 'turn/completed' })) + }) +}) diff --git a/src/main/codex/codex-structured-session-close.ts b/src/main/codex/codex-structured-session-close.ts new file mode 100644 index 00000000000..c4a69b19f2d --- /dev/null +++ b/src/main/codex/codex-structured-session-close.ts @@ -0,0 +1,89 @@ +import type { CodexAppServerConnection } from './codex-app-server-connection-types' +import { closeProcessRegistry } from '../../shared/child-process/close-process-registry' +import { + cancelCodexAcquisitionAttempt, + type CodexAcquisitionRegistry, + type CodexSession, + type CodexStructuredSessionEvent +} from './codex-structured-session-state' + +export function handleCodexSessionExit(input: { + sessions: Map + sessionId: string + connection: CodexAppServerConnection | null + error: Error + onEvent?: (event: CodexStructuredSessionEvent) => void +}): void { + const session = input.sessions.get(input.sessionId) + if (!session || session.connection !== input.connection || session.ended) { + return + } + session.ended = true + const event = { type: 'ended', sessionId: input.sessionId, reason: input.error.message } as const + session.translator?.handle(event) + input.onEvent?.(event) + session.translator?.dispose() +} + +export async function closeCodexPublishedSession( + sessions: Map, + sessionId: string, + onEvent?: (event: CodexStructuredSessionEvent) => void +): Promise { + const session = sessions.get(sessionId) + if (!session) { + return true + } + session.prompts.clear() + // Keep the session indexed until the child exit is observed. A timeout or + // failed kill must leave the live connection available for a safe retry. + const exited = await session.connection.close() + if (exited !== true) { + return false + } + sessions.delete(sessionId) + if (!session.ended) { + session.ended = true + const event: CodexStructuredSessionEvent = { + type: 'ended', + sessionId, + reason: 'codex session closed' + } + session.translator?.handle(event) + onEvent?.(event) + session.translator?.flush() + session.translator?.dispose() + } + return true +} + +export async function closeCodexSession( + sessionId: string, + sessions: Map, + acquisitions: CodexAcquisitionRegistry, + onEvent?: (event: CodexStructuredSessionEvent) => void +): Promise { + const attempt = acquisitions.get(sessionId) + if (!(await cancelCodexAcquisitionAttempt(attempt))) { + return false + } + if (attempt) { + acquisitions.deleteIfCurrent(sessionId, attempt) + } + return closeCodexPublishedSession(sessions, sessionId, onEvent) +} + +export async function closeAllCodexSessions( + sessions: Map, + acquisitions: CodexAcquisitionRegistry, + close: (sessionId: string) => Promise +): Promise { + acquisitions.close() + await closeProcessRegistry({ + attempts: 3, + hasEntries: () => sessions.size > 0 || acquisitions.size > 0, + entryIds: () => new Set([...sessions.keys(), ...acquisitions.sessionIds()]), + closeEntry: close, + failureMessage: 'codex structured session shutdown could not prove every child stopped' + }) +} diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts new file mode 100644 index 00000000000..96dd56b11e6 --- /dev/null +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi } from 'vitest' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import { + applyCodexStructuredSessionOption, + readCodexStructuredSessionOptions, + reportedCodexThreadOptions, + restoredCodexSessionOptions +} from './codex-structured-session-options' +import type { CodexSession } from './codex-structured-session-state' + +function optionSession(request: CodexAppServerConnection['request']): CodexSession { + return { + connection: { + pid: 1, + closed: false, + request, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + }, + ended: false, + threadId: 'thread-1', + historyPath: null, + prompts: new CodexAcquisitionWindow().prompts, + options: new Map(), + reportedOptions: { model: 'gpt-live', effort: 'high' }, + turnIdWaiters: [], + translator: null + } +} + +describe('structured Codex session options', () => { + it('filters restored records to recognized turn options', () => { + expect( + Object.fromEntries( + restoredCodexSessionOptions({ + model: 'gpt-live', + effort: 'high', + threadId: 'thread-injected', + input: 'input-injected' + }) + ) + ).toEqual({ model: 'gpt-live', effort: 'high' }) + }) + + it('hydrates paged provider models and their supported efforts', async () => { + const request = vi.fn(async (_method: string, params?: Record) => + params?.cursor + ? { + data: [ + { + model: 'gpt-second', + displayName: 'GPT Second', + description: 'Fast', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Quick reasoning' } + ], + defaultReasoningEffort: 'low', + isDefault: false + } + ], + nextCursor: null + } + : { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: 'page-2' + } + ) + + await expect( + readCodexStructuredSessionOptions({ + connection: { request } as never, + current: { model: 'gpt-live', effort: 'medium' } + }) + ).resolves.toEqual({ + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium', description: 'Balanced' }, + { value: 'high', label: 'High', description: 'Deep reasoning' } + ] + }, + { + id: 'gpt-second', + label: 'GPT Second', + description: 'Fast', + isDefault: false, + defaultEffort: 'low', + efforts: [{ value: 'low', label: 'Low', description: 'Quick reasoning' }] + } + ], + current: { model: 'gpt-live', effort: 'medium' } + }) + expect(request).toHaveBeenNthCalledWith( + 2, + 'model/list', + { limit: 100, includeHidden: false, cursor: 'page-2' }, + { timeoutMs: undefined } + ) + }) + + it('hydrates current values from thread start or resume', () => { + expect( + reportedCodexThreadOptions({ + threadId: 'thread-1', + historyPath: null, + model: 'gpt-live', + effort: 'high' + }) + ).toEqual({ model: 'gpt-live', effort: 'high' }) + }) + + it('reconciles an incompatible effort when only the model changes', async () => { + const session = optionSession( + vi.fn(async () => ({ + data: [ + { + model: 'gpt-live', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }], + defaultReasoningEffort: 'high' + }, + { + model: 'gpt-fast', + supportedReasoningEfforts: [{ reasoningEffort: 'low' }], + defaultReasoningEffort: 'low' + } + ], + nextCursor: null + })) + ) + + await expect( + applyCodexStructuredSessionOption(session, 'model', 'gpt-fast', undefined) + ).resolves.toEqual({ model: 'gpt-fast', effort: 'low' }) + }) + + it('rejects values absent from the provider catalog', async () => { + const session = optionSession( + vi.fn(async () => ({ + data: [{ model: 'gpt-live', supportedReasoningEfforts: [] }], + nextCursor: null + })) + ) + + await expect( + applyCodexStructuredSessionOption(session, 'model', 'not-entitled', undefined) + ).rejects.toThrow('does not offer model not-entitled') + await expect( + applyCodexStructuredSessionOption(session, 'effort', 'high', undefined) + ).rejects.toThrow('does not support high') + }) +}) diff --git a/src/main/codex/codex-structured-session-options.ts b/src/main/codex/codex-structured-session-options.ts new file mode 100644 index 00000000000..e7ff155625c --- /dev/null +++ b/src/main/codex/codex-structured-session-options.ts @@ -0,0 +1,203 @@ +import type { + AgentSessionModelOption, + AgentSessionOptionChoice, + AgentSessionOptionsResult +} from '../../shared/agent-session-wire' +import type { CodexAppServerConnection } from './codex-app-server-connection' +import type { CodexOpenedThread } from './codex-structured-thread-open' +import type { CodexSession } from './codex-structured-session-state' +import { isCodexTurnOptionKey } from './codex-structured-turn-start' +import { AgentSessionOptionRejectedError } from '../native-chat/agent-session-wire/structured-agent-session-option-error' + +const MODEL_PAGE_LIMIT = 100 +const MAX_MODEL_PAGES = 20 + +export function restoredCodexSessionOptions( + options: Readonly> | undefined +): Map { + return new Map(Object.entries(options ?? {}).filter(([key]) => isCodexTurnOptionKey(key))) +} + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +function effortLabel(value: string): string { + return value === 'xhigh' + ? 'Extra high' + : value === 'minimal' + ? 'Minimal' + : `${value.charAt(0).toUpperCase()}${value.slice(1)}` +} + +function effortChoice(value: unknown): AgentSessionOptionChoice | null { + const row = record(value) + const effort = text(row?.reasoningEffort) + if (!effort) { + return null + } + const description = text(row?.description) + return { + value: effort, + label: effortLabel(effort), + ...(description ? { description } : {}) + } +} + +function modelOption(value: unknown): AgentSessionModelOption | null { + const row = record(value) + if (!row) { + return null + } + const id = text(row.model) ?? text(row.id) + const label = text(row.displayName) ?? id + if (!id || !label || row.hidden === true) { + return null + } + const description = text(row.description) + const defaultEffort = text(row.defaultReasoningEffort) + const efforts = Array.isArray(row.supportedReasoningEfforts) + ? row.supportedReasoningEfforts + .map(effortChoice) + .filter((choice): choice is AgentSessionOptionChoice => choice !== null) + : [] + return { + id, + label, + ...(description ? { description } : {}), + isDefault: row.isDefault === true, + ...(defaultEffort ? { defaultEffort } : {}), + efforts + } +} + +export async function readCodexStructuredSessionOptions(input: { + connection: Pick + current: { model?: string; effort?: string } + timeoutMs?: number +}): Promise { + const models: AgentSessionModelOption[] = [] + let cursor: string | null = null + for (let page = 0; page < MAX_MODEL_PAGES; page += 1) { + const response = record( + await input.connection.request( + 'model/list', + { limit: MODEL_PAGE_LIMIT, includeHidden: false, ...(cursor ? { cursor } : {}) }, + { timeoutMs: input.timeoutMs } + ) + ) + const rows = Array.isArray(response?.data) ? response.data : [] + for (const row of rows) { + const parsed = modelOption(row) + if (parsed && !models.some((model) => model.id === parsed.id)) { + models.push(parsed) + } + } + cursor = text(response?.nextCursor) + if (!cursor) { + break + } + } + if (input.current.model && !models.some((model) => model.id === input.current.model)) { + models.push({ + id: input.current.model, + label: input.current.model, + isDefault: false, + efforts: [] + }) + } + const model = input.current.model ?? models.find((entry) => entry.isDefault)?.id ?? models[0]?.id + if (!model) { + throw new Error('codex app-server returned no available models') + } + return { + models, + current: { model, ...(input.current.effort ? { effort: input.current.effort } : {}) } + } +} + +export function reportedCodexThreadOptions( + opened: CodexOpenedThread +): CodexSession['reportedOptions'] { + return { + ...(opened.model ? { model: opened.model } : {}), + ...(opened.effort ? { effort: opened.effort } : {}) + } +} + +export function readLiveCodexSessionOptions( + session: CodexSession, + timeoutMs: number | undefined +): Promise { + const model = session.options.get('model') ?? session.reportedOptions.model + const effort = session.options.get('effort') ?? session.reportedOptions.effort + return readCodexStructuredSessionOptions({ + connection: session.connection, + current: { ...(model ? { model } : {}), ...(effort ? { effort } : {}) }, + timeoutMs + }) +} + +export async function applyCodexStructuredSessionOption( + session: CodexSession, + key: string, + value: string, + timeoutMs: number | undefined +): Promise>> { + try { + return await applyValidatedCodexStructuredSessionOption(session, key, value, timeoutMs) + } catch (error) { + throw new AgentSessionOptionRejectedError(error) + } +} + +async function applyValidatedCodexStructuredSessionOption( + session: CodexSession, + key: string, + value: string, + timeoutMs: number | undefined +): Promise>> { + if (key !== 'model' && key !== 'effort') { + session.options.set(key, value) + return Object.fromEntries(session.options) + } + const priorModel = session.options.get('model') ?? session.reportedOptions.model + const priorEffort = session.options.get('effort') ?? session.reportedOptions.effort + const catalog = await readCodexStructuredSessionOptions({ + connection: session.connection, + current: { + ...(priorModel ? { model: priorModel } : {}), + ...(priorEffort ? { effort: priorEffort } : {}) + }, + timeoutMs + }) + if (key === 'model' && !catalog.models.some((entry) => entry.id === value)) { + throw new Error(`codex app-server does not offer model ${value}`) + } + const modelId = key === 'model' ? value : catalog.current.model + const model = catalog.models.find((entry) => entry.id === modelId) + const requestedEffort = key === 'effort' ? value : priorEffort + if ( + key === 'effort' && + (!model?.efforts.length || !model.efforts.some((effort) => effort.value === requestedEffort)) + ) { + throw new Error(`codex app-server model ${modelId} does not support ${value}`) + } + const effort = + model?.efforts.length === 0 + ? undefined + : (model?.efforts.find((entry) => entry.value === requestedEffort)?.value ?? + model?.defaultEffort ?? + model?.efforts[0]?.value) + session.options.set('model', modelId) + if (effort) { + session.options.set('effort', effort) + } else { + session.options.delete('effort') + } + return Object.fromEntries(session.options) +} diff --git a/src/main/codex/codex-structured-session-shutdown.test.ts b/src/main/codex/codex-structured-session-shutdown.test.ts new file mode 100644 index 00000000000..3883dad4600 --- /dev/null +++ b/src/main/codex/codex-structured-session-shutdown.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { + CodexStructuredSessionAdapter, + type CodexStructuredLaunch +} from './codex-structured-session-adapter' + +const SESSION_ID = 'session-1' +const THREAD_ID = 'thread-1' +const LAUNCH: CodexStructuredLaunch = { + command: 'codex', + args: ['app-server'], + cwd: '/work/repo', + codexHome: null, + resumeThreadId: null +} + +function identity(): AgentSessionJournalIdentity { + return { + sessionId: SESSION_ID, + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD_ID } + } +} + +describe('CodexStructuredSessionAdapter shutdown', () => { + it('refuses acquisitions that enter after closeAll starts', async () => { + const connections: CodexAppServerConnection[] = [] + const openConnection = (async () => { + const connection = { + pid: 4321, + closed: false, + request: async (method: string) => + method === 'thread/start' ? { thread: { id: THREAD_ID } } : {}, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } satisfies CodexAppServerConnection + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + const firstLaunch = Promise.withResolvers() + let launchCount = 0 + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: () => { + launchCount += 1 + return launchCount === 1 ? firstLaunch.promise : Promise.resolve(LAUNCH) + }, + openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const first = adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + await vi.waitFor(() => expect(launchCount).toBe(1)) + + const closing = adapter.closeAll() + const second = adapter.acquire({ identity: identity(), fence: 8, spawnToken: 'spawn-2' }) + const acquisitions = Promise.allSettled([first, second]) + firstLaunch.resolve(LAUNCH) + + const [firstResult, secondResult] = await acquisitions + await closing + expect(firstResult).toMatchObject({ status: 'rejected' }) + expect(secondResult).toMatchObject({ + status: 'rejected', + reason: expect.objectContaining({ message: 'codex structured session adapter is closing' }) + }) + expect(connections).toHaveLength(0) + }) + + it('bounds shutdown when a provider child never proves exit', async () => { + const close = vi.fn(async () => false) + const openConnection = (async () => + ({ + pid: 4321, + closed: false, + request: async (method: string) => + method === 'thread/start' ? { thread: { id: THREAD_ID } } : {}, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close + }) satisfies CodexAppServerConnection) as typeof openCodexAppServerConnection + const adapter = new CodexStructuredSessionAdapter({ + resolveLaunch: async () => LAUNCH, + openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + + await adapter.acquire({ identity: identity(), fence: 7, spawnToken: 'spawn-1' }) + await expect(adapter.closeAll()).rejects.toThrow( + 'codex structured session shutdown could not prove every child stopped' + ) + expect(close).toHaveBeenCalledTimes(3) + }) +}) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts new file mode 100644 index 00000000000..1eb6d8d8d1c --- /dev/null +++ b/src/main/codex/codex-structured-session-state.ts @@ -0,0 +1,166 @@ +import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' +import { cancelProcessAcquisition } from '../../shared/child-process/cancel-process-acquisition' +import type { + CodexAppServerConnection, + openCodexAppServerConnection +} from './codex-app-server-connection' +import { CodexAcquisitionWindow } from './codex-structured-acquisition-window' +import type { CodexJournalTranslator } from './codex-structured-journal-translation' +import type { CodexTurnProcessSnapshot } from './codex-structured-turn-processes' + +export type CodexStructuredLaunch = { + command: string + args: string[] + cwd: string + codexHome: string | null + resumeThreadId: string | null + resumePath?: string | null + env?: Record +} + +export type CodexStructuredSessionEvent = + | { type: 'notification'; sessionId: string; threadId: string; method: string; params: unknown } + | { type: 'server-request'; sessionId: string; threadId: string; method: string; params: unknown } + | { type: 'provider-frame'; sessionId: string; threadId: string; kind: string; payload: unknown } + | { + type: 'prompt' + sessionId: string + threadId: string + method: string + params: unknown + codexItemId: string + promptKey: string + } + | { type: 'ended'; sessionId: string; reason: string } + +export type CodexStructuredSessionAdapterDeps = { + resolveLaunch: (input: { + identity: AgentSessionJournalIdentity + }) => Promise + onEvent?: (event: CodexStructuredSessionEvent) => void + openConnection?: typeof openCodexAppServerConnection + readProcessStartTime?: (pid: number) => Promise + mintLinkId?: () => string + now?: () => number + requestTimeoutMs?: number + captureTurnProcesses?: (rootPid: number) => Promise + terminateTurnProcesses?: ( + rootPid: number, + baseline: CodexTurnProcessSnapshot | null + ) => Promise +} + +export type CodexSession = { + connection: CodexAppServerConnection + ended: boolean + threadId: string + historyPath: string | null + prompts: CodexAcquisitionWindow['prompts'] + options: Map + reportedOptions: { model?: string; effort?: string } + turnIdWaiters: ((turnId: string) => void)[] + translator: CodexJournalTranslator | null +} + +export type CodexAcquisitionAttempt = { + window: CodexAcquisitionWindow + cancelled: boolean + exitProven: boolean + finished: Promise + finish: () => void +} + +export function createCodexAcquisitionAttempt(): CodexAcquisitionAttempt { + let finish = (): void => {} + const finished = new Promise((resolve) => { + finish = resolve + }) + return { + window: new CodexAcquisitionWindow(), + cancelled: false, + exitProven: false, + finished, + finish + } +} + +export class CodexAcquisitionRegistry { + private readonly attempts = new Map() + private closing = false + + get size(): number { + return this.attempts.size + } + + start(sessionId: string): { + previousAttempt: CodexAcquisitionAttempt | undefined + attempt: CodexAcquisitionAttempt + } { + if (this.closing) { + throw new Error('codex structured session adapter is closing') + } + const previousAttempt = this.attempts.get(sessionId) + const attempt = createCodexAcquisitionAttempt() + this.attempts.set(sessionId, attempt) + return { previousAttempt, attempt } + } + + assertCurrent(sessionId: string, attempt: CodexAcquisitionAttempt): void { + if (this.closing || attempt.cancelled || this.attempts.get(sessionId) !== attempt) { + throw new Error(`codex session ${sessionId} was superseded while being acquired`) + } + } + + get(sessionId: string): CodexAcquisitionAttempt | undefined { + return this.attempts.get(sessionId) + } + + deleteIfCurrent(sessionId: string, attempt: CodexAcquisitionAttempt): void { + if (this.attempts.get(sessionId) === attempt) { + this.attempts.delete(sessionId) + } + } + + restoreIfCurrent( + sessionId: string, + replacement: CodexAcquisitionAttempt, + previous: CodexAcquisitionAttempt + ): void { + if (this.attempts.get(sessionId) === replacement) { + this.attempts.set(sessionId, previous) + } + } + + async closeFailedAttempt(sessionId: string, attempt: CodexAcquisitionAttempt): Promise { + const stopped = (await attempt.window.connection?.close()) ?? true + if (stopped) { + attempt.exitProven = true + this.deleteIfCurrent(sessionId, attempt) + } + return stopped + } + + sessionIds(): IterableIterator { + return this.attempts.keys() + } + + close(): void { + this.closing = true + } +} + +export async function cancelCodexAcquisitionAttempt( + attempt: CodexAcquisitionAttempt | undefined +): Promise { + if (!attempt) { + return true + } + return cancelProcessAcquisition({ + cancel: () => { + attempt.cancelled = true + }, + connection: () => attempt.window.connection, + exitProven: () => attempt.exitProven, + finished: attempt.finished + }) +} diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts new file mode 100644 index 00000000000..349246ecf29 --- /dev/null +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -0,0 +1,39 @@ +// The handful of facts Orca reads out of Codex app-server payloads. Codex has +// moved these fields between the envelope and a nested `thread` / `turn` object +// across releases, so each reader accepts both shapes rather than pinning one. + +function record(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +/** `thread/start`, `thread/resume`, and `thread/started` all name the thread. */ +export function readCodexThreadId(payload: unknown): string | null { + const root = record(payload) + if (!root) { + return null + } + return nonEmptyString(record(root.thread)?.id) ?? nonEmptyString(root.threadId) +} + +/** Rollout file for the thread, when Codex reports one. Journal recovery reads + * it; a null just falls back to the existing session-file resolver. */ +export function readCodexThreadPath(payload: unknown): string | null { + const root = record(payload) + return root ? nonEmptyString(record(root.thread)?.path) : null +} + +/** `turn/start` responses carry `turn.id`; `turn/started` notifications carry + * the same under `turn`, and older builds put `turnId` on the envelope. */ +export function readCodexTurnId(payload: unknown): string | null { + const root = record(payload) + if (!root) { + return null + } + return nonEmptyString(record(root.turn)?.id) ?? nonEmptyString(root.turnId) +} diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts new file mode 100644 index 00000000000..fe977d6a37d --- /dev/null +++ b/src/main/codex/codex-structured-thread-open.ts @@ -0,0 +1,61 @@ +// Starting or resuming the single Codex thread a structured session owns. +// +// The reply is verified before the caller registers the session, because a +// resume that lands on a different thread is a fork wearing a resume's name — +// recording it would make the durable handle chain lie about what this session +// actually proved. + +import type { CodexAppServerConnection } from './codex-app-server-connection' +import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts' + +export type CodexOpenedThread = { + threadId: string + thread?: Record + /** Rollout file Codex named, when it named one. */ + historyPath: string | null + model?: string + effort?: string +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null +} + +export async function openCodexThread( + connection: CodexAppServerConnection, + launch: { cwd: string; resumeThreadId: string | null; resumePath?: string | null }, + timeoutMs: number | undefined +): Promise { + const opened = await connection.request( + launch.resumeThreadId ? 'thread/resume' : 'thread/start', + launch.resumeThreadId + ? { + threadId: launch.resumeThreadId, + cwd: launch.cwd, + ...(launch.resumePath ? { path: launch.resumePath } : {}) + } + : { cwd: launch.cwd }, + { timeoutMs } + ) + const threadId = readCodexThreadId(opened) + if (!threadId) { + throw new Error('codex app-server did not name the thread it opened') + } + if (launch.resumeThreadId && threadId !== launch.resumeThreadId) { + throw new Error(`codex app-server resumed ${threadId} instead of ${launch.resumeThreadId}`) + } + const result = opened as Record + const thread = + typeof result.thread === 'object' && result.thread !== null + ? (result.thread as Record) + : {} + const model = nonEmptyString(result.model) + const effort = nonEmptyString(result.reasoningEffort) + return { + threadId, + thread, + historyPath: readCodexThreadPath(opened), + ...(model ? { model } : {}), + ...(effort ? { effort } : {}) + } +} diff --git a/src/main/codex/codex-structured-turn-cancellation.ts b/src/main/codex/codex-structured-turn-cancellation.ts new file mode 100644 index 00000000000..1f31197e8d5 --- /dev/null +++ b/src/main/codex/codex-structured-turn-cancellation.ts @@ -0,0 +1,154 @@ +import { + isCodexAppServerRequestError, + type CodexAppServerConnection +} from './codex-app-server-connection' +import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import type { + CodexSession, + CodexStructuredSessionAdapterDeps, + CodexStructuredSessionEvent +} from './codex-structured-session-state' +import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts' +import { + captureCodexTurnProcesses, + terminateCodexTurnProcesses, + type CodexTurnProcessSnapshot +} from './codex-structured-turn-processes' + +type TurnProcessState = { + baseline: Promise + blockedCompletions: Set + deferredCompletions: Map +} + +type TurnCancellationDeps = Pick< + CodexStructuredSessionAdapterDeps, + 'captureTurnProcesses' | 'requestTimeoutMs' | 'terminateTurnProcesses' +> & { + emit: (session: CodexSession, event: CodexStructuredSessionEvent) => void +} + +export class CodexStructuredTurnCancellation { + private readonly states = new WeakMap() + + constructor(private readonly deps: TurnCancellationDeps) {} + + register(session: CodexSession): void { + this.states.set(session, { + baseline: Promise.resolve(null), + blockedCompletions: new Set(), + deferredCompletions: new Map() + }) + } + + captureBaseline(session: CodexSession): Promise { + this.refreshBaseline(session) + return this.state(session).baseline + } + + handleNotification( + sessionId: string, + session: CodexSession, + method: string, + params: unknown + ): boolean { + const threadId = readCodexThreadId(params) ?? session.threadId + if (method !== 'turn/completed' || threadId !== session.threadId) { + return false + } + const turnId = readCodexTurnId(params) + const state = this.state(session) + if (!turnId || !state.blockedCompletions.has(turnId)) { + return false + } + const event = { + type: 'notification' as const, + sessionId, + threadId, + method, + params + } + state.deferredCompletions.set(turnId, event) + return true + } + + async cancel(session: CodexSession, turnId: string): Promise<{ cancelled: boolean }> { + const state = this.state(session) + state.blockedCompletions.add(turnId) + const baseline = await state.baseline + let requestError: unknown + const interruptReceipt = session.connection + .request( + 'turn/interrupt', + { threadId: session.threadId, turnId }, + { timeoutMs: this.deps.requestTimeoutMs } + ) + .then( + () => true, + (error: unknown) => { + requestError = error + return false + } + ) + const [acknowledged, terminated] = await Promise.all([ + interruptReceipt, + this.terminate(session.connection, baseline) + ]) + if (terminated && acknowledged) { + this.releaseCompletion(session, turnId) + return { cancelled: true } + } + if ( + requestError && + !isCodexAppServerRequestError(requestError) && + !isCodexAppServerUnsupportedError(requestError) + ) { + this.releaseCompletion(session, turnId) + throw requestError + } + // A failed cancellation must not permanently divert the provider's later + // completion for this turn. Let the normal completion path settle it. + this.releaseCompletion(session, turnId) + return { cancelled: false } + } + + private capture(pid: number | undefined): Promise { + return pid + ? (this.deps.captureTurnProcesses ?? captureCodexTurnProcesses)(pid) + : Promise.resolve(null) + } + + private terminate( + connection: Pick, + baseline: CodexTurnProcessSnapshot | null + ): Promise { + return connection.pid + ? (this.deps.terminateTurnProcesses ?? terminateCodexTurnProcesses)(connection.pid, baseline) + : Promise.resolve(false) + } + + private refreshBaseline(session: CodexSession): void { + this.state(session).baseline = this.capture(session.connection.pid) + } + + private releaseCompletion( + session: CodexSession, + turnId: string, + completion = this.state(session).deferredCompletions.get(turnId) + ): void { + const state = this.state(session) + state.blockedCompletions.delete(turnId) + state.deferredCompletions.delete(turnId) + if (completion) { + this.deps.emit(session, completion) + } + } + + private state(session: CodexSession): TurnProcessState { + const state = this.states.get(session) + if (!state) { + throw new Error('codex turn process state is unavailable') + } + return state + } +} diff --git a/src/main/codex/codex-structured-turn-processes.integration.test.ts b/src/main/codex/codex-structured-turn-processes.integration.test.ts new file mode 100644 index 00000000000..2a63c8099ea --- /dev/null +++ b/src/main/codex/codex-structured-turn-processes.integration.test.ts @@ -0,0 +1,103 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { + captureCodexTurnProcesses, + terminateCodexTurnProcesses +} from './codex-structured-turn-processes' + +function nextLine(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let buffer = '' + const onData = (chunk: Buffer): void => { + buffer += chunk.toString('utf8') + const newline = buffer.indexOf('\n') + if (newline === -1) { + return + } + child.stdout.off('data', onData) + resolve(buffer.slice(0, newline)) + } + child.once('error', reject) + child.stdout.on('data', onData) + }) +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +describe.runIf(process.platform !== 'win32')('Codex structured turn process termination', () => { + it('removes the exact PID of a stopped 60-second command', async () => { + const root = spawn( + process.execPath, + [ + '-e', + `const { spawn } = require('node:child_process'); + process.stdin.once('data', () => { + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); + process.stdout.write(String(child.pid) + '\\n'); + }); + setTimeout(() => {}, 60000);` + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + let commandPid = 0 + try { + const baseline = await captureCodexTurnProcesses(root.pid!) + root.stdin.write('start\n') + commandPid = Number(await nextLine(root)) + expect(processExists(commandPid)).toBe(true) + + await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true) + expect(processExists(commandPid)).toBe(false) + } finally { + if (commandPid > 0 && processExists(commandPid)) { + process.kill(commandPid, 'SIGKILL') + } + root.kill('SIGKILL') + } + }, 15_000) + + it('preserves descendants that predate the turn', async () => { + const root = spawn( + process.execPath, + [ + '-e', + `const { spawn } = require('node:child_process'); + const persistent = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); + process.stdout.write(String(persistent.pid) + '\\n'); + process.stdin.once('data', () => { + const child = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 60000)'], { stdio: 'ignore' }); + process.stdout.write(String(child.pid) + '\\n'); + }); + setTimeout(() => {}, 60000);` + ], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + let persistentPid = 0 + let commandPid = 0 + try { + persistentPid = Number(await nextLine(root)) + const baseline = await captureCodexTurnProcesses(root.pid!) + root.stdin.write('start\n') + commandPid = Number(await nextLine(root)) + + await expect(terminateCodexTurnProcesses(root.pid!, baseline)).resolves.toBe(true) + expect(processExists(persistentPid)).toBe(true) + expect(processExists(commandPid)).toBe(false) + } finally { + if (persistentPid > 0 && processExists(persistentPid)) { + process.kill(persistentPid, 'SIGKILL') + } + if (commandPid > 0 && processExists(commandPid)) { + process.kill(commandPid, 'SIGKILL') + } + root.kill('SIGKILL') + } + }, 15_000) +}) diff --git a/src/main/codex/codex-structured-turn-processes.ts b/src/main/codex/codex-structured-turn-processes.ts new file mode 100644 index 00000000000..f69c0455a2e --- /dev/null +++ b/src/main/codex/codex-structured-turn-processes.ts @@ -0,0 +1,85 @@ +import { captureDescendantSnapshot, type DescendantSnapshot } from '../pty-descendant-termination' +import { terminateDescendantSnapshotAndWait } from '../pty-descendant-exit-verification' +import { queryWindowsProcessDescendants } from '../providers/windows-foreground-process-rows' +import { terminateWindowsProcessTree } from '../windows-process-tree-kill' + +export type CodexTurnProcessSnapshot = + | { platform: 'posix'; snapshot: DescendantSnapshot } + | { platform: 'win32'; identities: ReadonlyMap } + +function windowsIdentity(row: { + ppid: number + name: string + command: string + executablePath?: string +}): string { + return [row.ppid, row.name, row.command, row.executablePath ?? ''].join('\0') +} + +export async function captureCodexTurnProcesses( + rootPid: number +): Promise { + if (process.platform === 'win32') { + const descendants = await queryWindowsProcessDescendants(rootPid, { fresh: true }) + return descendants + ? { + platform: 'win32', + identities: new Map(descendants.map((row) => [row.pid, windowsIdentity(row)])) + } + : null + } + const snapshot = await captureDescendantSnapshot(rootPid) + return snapshot ? { platform: 'posix', snapshot } : null +} + +function addedPosixDescendants( + baseline: DescendantSnapshot, + current: DescendantSnapshot +): DescendantSnapshot { + const baselineRows = new Map(baseline.descendants.map((row) => [row.pid, row])) + return { + ...current, + descendants: current.descendants.filter((row) => { + const prior = baselineRows.get(row.pid) + return prior?.startedAt !== row.startedAt || prior.pgid !== row.pgid + }) + } +} + +async function terminateWindowsAddedProcesses( + rootPid: number, + baseline: ReadonlyMap +): Promise { + const current = await queryWindowsProcessDescendants(rootPid, { fresh: true }) + if (!current) { + return false + } + const added = current.filter((row) => baseline.get(row.pid) !== windowsIdentity(row)) + const addedPids = new Set(added.map((row) => row.pid)) + const roots = added.filter((row) => !addedPids.has(row.ppid)) + await Promise.all(roots.map((row) => terminateWindowsProcessTree(row.pid))) + const targetIdentities = new Map(added.map((row) => [row.pid, windowsIdentity(row)])) + const remaining = await queryWindowsProcessDescendants(rootPid, { fresh: true }) + return ( + remaining !== null && + remaining.every((row) => targetIdentities.get(row.pid) !== windowsIdentity(row)) + ) +} + +export async function terminateCodexTurnProcesses( + rootPid: number, + baseline: CodexTurnProcessSnapshot | null +): Promise { + if (!baseline) { + return false + } + if (baseline.platform === 'win32') { + return terminateWindowsAddedProcesses(rootPid, baseline.identities) + } + const current = await captureDescendantSnapshot(rootPid) + if (!current) { + return false + } + const added = addedPosixDescendants(baseline.snapshot, current) + return terminateDescendantSnapshotAndWait(added) +} diff --git a/src/main/codex/codex-structured-turn-start.ts b/src/main/codex/codex-structured-turn-start.ts new file mode 100644 index 00000000000..ffe4c850d5c --- /dev/null +++ b/src/main/codex/codex-structured-turn-start.ts @@ -0,0 +1,128 @@ +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' +import type { NativeChatBlock } from '../../shared/native-chat-types' +import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import { + isCodexAppServerRequestError, + type CodexAppServerConnection +} from './codex-app-server-connection' +import { isCodexAppServerUnsupportedError } from './codex-app-server-session' +import { readCodexTurnId } from './codex-structured-thread-facts' + +// Starting a Codex turn and learning its id, which are not the same event: +// `turn/start` returns the id on newer builds and acks before it exists on +// older ones, where it arrives as a `turn/started` notification instead. + +/** Codex records the user message first in a turn, so the submission Orca just + * accepted is ordinal 0 of `(threadId, turnId)`. */ +export const CODEX_USER_MESSAGE_ORDINAL = 0 + +/** Past this the turn is real but unnameable, which the journal renders as + * delivery unconfirmed rather than failure. */ +const TURN_ID_WAIT_MS = 10_000 + +/** Keys Codex accepts as per-turn overrides. An unlisted key would otherwise + * become an arbitrary client-controlled `turn/start` parameter. */ +const CODEX_TURN_OPTION_KEYS = new Set([ + 'model', + 'effort', + 'approvalPolicy', + 'approvalsReviewer', + 'personality', + 'serviceTier' +]) + +export function isCodexTurnOptionKey(key: string): boolean { + return CODEX_TURN_OPTION_KEYS.has(key) +} + +/** The session state one turn needs. `turnIdWaiters` is shared with the + * notification handler, which resolves the head of the queue — correct because + * Codex runs one turn per thread, so starts and `turn/started` share an order. */ +export type CodexTurnHost = { + connection: Pick + threadId: string + options: Map + turnIdWaiters: ((turnId: string) => void)[] +} + +function turnInputFor(body: AgentJournalMessageItem): Record[] { + const input: Record[] = [] + for (const block of body.blocks as NativeChatBlock[]) { + if (block.type === 'text' && block.text.length > 0) { + input.push({ type: 'text', text: block.text }) + } else if (block.type === 'image-ref' && block.path) { + input.push({ type: 'localImage', path: block.path }) + } else if (block.type === 'image-ref' && block.url) { + input.push({ type: 'image', url: block.url }) + } + } + return input +} + +/** + * Resolves the turn id, or null when Codex owns a turn it never named. Throws + * only for outcomes the wire must not read as acceptance. + */ +export async function startCodexTurn( + host: CodexTurnHost, + input: { clientMessageId: string; body: AgentJournalMessageItem; timeoutMs?: number } +): Promise { + // Registered BEFORE the call: on builds that ack first, `turn/started` can + // land while the response is still in flight. + let notified: ((turnId: string) => void) | null = null + const fromNotification = new Promise((resolve) => { + notified = resolve + host.turnIdWaiters.push(resolve) + setTimeout(() => resolve(null), TURN_ID_WAIT_MS).unref?.() + }) + try { + const started = await host.connection.request( + 'turn/start', + { + threadId: host.threadId, + clientUserMessageId: input.clientMessageId, + input: turnInputFor(input.body), + ...Object.fromEntries(host.options) + }, + { timeoutMs: input.timeoutMs } + ) + return readCodexTurnId(started) ?? (await fromNotification) + } finally { + const index = notified ? host.turnIdWaiters.indexOf(notified) : -1 + if (index !== -1) { + host.turnIdWaiters.splice(index, 1) + } + } +} + +/** + * One submission's outcome as the wire must read it: accepted names the turn, + * rejected is Codex answering and declining, and unknown covers a turn that is + * real but unnameable — never a failure the user is told their message hit. + */ +export async function dispatchCodexTurn( + session: CodexTurnHost, + input: { clientMessageId: string; body: AgentJournalMessageItem }, + timeoutMs: number | undefined +): Promise { + let turnId: string | null + try { + turnId = await startCodexTurn(session, { ...input, timeoutMs }) + } catch (error) { + if (isCodexAppServerRequestError(error) || isCodexAppServerUnsupportedError(error)) { + return { state: 'rejected', reason: (error as Error).message } + } + throw error + } + return turnId === null + ? { state: 'unknown', reason: 'codex app-server started a turn it did not name in time' } + : { + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: session.threadId, + turnId, + ordinal: CODEX_USER_MESSAGE_ORDINAL + } + } +} diff --git a/src/main/codex/codex-tui-resume-real-binary.integration.test.ts b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts new file mode 100644 index 00000000000..cada4249591 --- /dev/null +++ b/src/main/codex/codex-tui-resume-real-binary.integration.test.ts @@ -0,0 +1,160 @@ +import { spawnSync } from 'node:child_process' +import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import * as pty from 'node-pty' +import { afterEach, describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' +import { resolveCodexCommand } from '../codex-cli/command' +import { openCodexAppServerConnection } from './codex-app-server-connection' +import { openCodexThread } from './codex-structured-thread-open' +import { proveCodexTuiRollout } from './codex-tui-rollout-proof' + +const codexCommand = resolveCodexCommand() +const codexAvailable = spawnSync(codexCommand, ['--version']).status === 0 +const itWithCodex = codexAvailable ? it : it.skip +const tempHomes: string[] = [] + +async function waitForTuiStart(proc: pty.IPty): Promise { + let output = '' + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Codex TUI did not initialize: ${output.slice(-500)}`)), + 15_000 + ) + proc.onData((data) => { + output += data + if (/OpenAI Codex|Welcome to Codex|Sign in with ChatGPT/i.test(output)) { + clearTimeout(timeout) + resolve(output) + } + }) + proc.onExit(({ exitCode }) => { + clearTimeout(timeout) + reject( + new Error(`Codex TUI exited before initialization (${exitCode}): ${output.slice(-500)}`) + ) + }) + }) +} + +async function waitForRollout(path: string, threadId: string): Promise { + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + try { + const rollout = await readFile(path, 'utf8') + if (rollout.includes(threadId)) { + return rollout + } + } catch { + // The rollout is created asynchronously after thread/start. + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + throw new Error('Codex did not materialize the resumed rollout') +} + +afterEach(async () => { + await Promise.all(tempHomes.splice(0).map((home) => rm(home, { recursive: true, force: true }))) +}) + +describe('real Codex structured-to-TUI resume', () => { + itWithCodex( + 'resumes the exact isolated rollout and reaches the initial TUI screen', + async () => { + const codexHome = await mkdtemp(join(tmpdir(), 'orca-codex-tui-resume-')) + tempHomes.push(codexHome) + await writeFile( + join(codexHome, 'config.toml'), + [ + 'model_provider = "orca-integration"', + 'model = "gpt-5"', + '', + '[model_providers.orca-integration]', + 'name = "Orca integration"', + 'base_url = "http://127.0.0.1:9/v1"', + 'wire_api = "responses"', + 'requires_openai_auth = false', + '', + `[projects.${JSON.stringify(process.cwd())}]`, + 'trust_level = "trusted"', + '' + ].join('\n') + ) + const connection = await openCodexAppServerConnection({ + command: codexCommand, + args: ['app-server'], + env: { CODEX_HOME: codexHome } + }) + const opened = await openCodexThread( + connection, + { cwd: process.cwd(), resumeThreadId: null }, + 15_000 + ) + await connection.request( + 'turn/start', + { + threadId: opened.threadId, + clientUserMessageId: 'real-binary-resume-fixture', + input: [{ type: 'text', text: 'materialize the isolated resume fixture' }] + }, + { timeoutMs: 15_000 } + ) + expect(await waitForRollout(opened.historyPath!, opened.threadId)).toContain(opened.threadId) + await connection.close() + + expect(opened.historyPath).toContain(opened.threadId) + expect(opened.historyPath).toContain(join(codexHome, 'sessions')) + const tui = pty.spawn(codexCommand, ['resume', '--no-alt-screen', opened.threadId], { + name: 'xterm-256color', + cols: 100, + rows: 30, + cwd: process.cwd(), + env: { + ...process.env, + CODEX_HOME: codexHome, + ORCA_AGENT_LAUNCH_TOKEN: 'real-binary-resume-proof', + TERM: 'xterm-256color' + } + }) + const tuiExit = new Promise((resolve) => + tui.onExit(({ exitCode }) => resolve(exitCode)) + ) + const kittyKeyboard = new TerminalKittyKeyboardModeTracker() + let tuiOutput = '' + let lastOutputAt: number | null = null + tui.onData((data) => { + tuiOutput += data + lastOutputAt = Date.now() + kittyKeyboard.scan(data) + }) + try { + await expect(waitForTuiStart(tui)).resolves.toMatch(/Codex/i) + const proof = await proveCodexTuiRollout({ + codexHome, + threadId: opened.threadId, + kittyKeyboardFlags: kittyKeyboard.flags, + readOutput: () => ({ text: tuiOutput, lastOutputAt }), + write: (data) => { + tui.write(data) + return true + } + }) + expect(await realpath(proof.transcriptPath)).toBe(await realpath(opened.historyPath!)) + } finally { + try { + tui.kill() + } catch { + // Already exited. + } + await Promise.race([ + tuiExit, + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('Codex TUI did not exit after cleanup')), 5_000) + ) + ]) + } + }, + 30_000 + ) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.test.ts b/src/main/codex/codex-tui-rollout-proof.test.ts new file mode 100644 index 00000000000..2de66c0dc71 --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.test.ts @@ -0,0 +1,205 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { + codexTuiStatusProbeInput, + parseCodexTuiStatusSessionId, + proveCodexTuiRollout, + resolveLiveCodexTuiRollout, + resolvePinnedCodexRolloutProof +} from './codex-tui-rollout-proof' + +const THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70' +const OTHER_THREAD = '019fd900-77aa-7c19-8bd0-2b3c4d5e6f71' + +describe('Codex TUI rollout proof', () => { + it('parses the exact session shown by status', () => { + expect(parseCodexTuiStatusSessionId(`│ Session: ${THREAD} │`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session ID: ${THREAD}`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Session: \u001b[22m${THREAD}\u001b[2m`)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId('Session: not-a-session')).toBeNull() + }) + + it('parses the Codex 0.148 status screen and semantic thread labels', () => { + const status = [ + '\u001b[2m│ >_ OpenAI Codex (v0.148.0) │', + '│ Model: gpt-5.6-sol (reasoning high, summaries auto) │', + `│ Session: \u001b[22m${THREAD}\u001b[2m │` + ].join('\n') + + expect(parseCodexTuiStatusSessionId(status)).toBe(THREAD) + expect(parseCodexTuiStatusSessionId(`Thread ID: ${OTHER_THREAD}`)).toBe(OTHER_THREAD) + }) + + it('uses Kitty Enter only while the TUI negotiated Kitty input', () => { + expect(codexTuiStatusProbeInput(0)).toEqual({ + command: '\u001b[200~/status\u001b[201~', + submit: '\r' + }) + expect(codexTuiStatusProbeInput(1).submit).toBe('\u001b[13u') + expect(codexTuiStatusProbeInput(31).submit).toBe('\u001b[13u') + }) + + it('resolves only the exact session_meta rollout under the pinned account home', async () => { + const files = async function* (): AsyncGenerator { + yield '/pinned/sessions/scratch/rollout-wrong.jsonl' + yield `/other/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + const readSessionMetaId = vi.fn(async () => THREAD) + + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId }) + ).resolves.toBe(`/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl`) + expect(readSessionMetaId).toHaveBeenCalledTimes(1) + }) + + it('accepts Codex rollout ids with a distinct rollout suffix', async () => { + const files = async function* (): AsyncGenerator { + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}_019fd900-77aa-7c19-8bd0-2b3c4d5e6f71.jsonl` + } + const readSessionMetaId = vi.fn(async () => THREAD) + + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { listFiles: files, readSessionMetaId }) + ).resolves.toContain(`rollout-now-${THREAD}_`) + }) + + it('skips a rollout file that vanishes mid-scan instead of aborting the proof', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-rollout-proof-')) + try { + const day = join(root, 'sessions', '2026', '08', '11') + await mkdir(day, { recursive: true }) + const real = join(day, `rollout-now-${THREAD}.jsonl`) + await writeFile( + real, + `${JSON.stringify({ type: 'session_meta', payload: { id: THREAD } })}\n` + ) + // Listed but already deleted by the time the scan reads it — Codex prunes + // and rewrites rollout files while the scan runs. + const vanished = join(day, `rollout-gone-${THREAD}.jsonl`) + const files = async function* (): AsyncGenerator { + yield vanished + yield real + } + + await expect( + resolvePinnedCodexRolloutProof(root, THREAD, { listFiles: files }) + ).resolves.toBe(real) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + + it('rejects a rollout whose session_meta names another thread', async () => { + const files = async function* (): AsyncGenerator { + yield `/pinned/sessions/2026/08/11/rollout-now-${THREAD}.jsonl` + } + await expect( + resolvePinnedCodexRolloutProof('/pinned', THREAD, { + listFiles: files, + readSessionMetaId: async () => OTHER_THREAD + }) + ).resolves.toBeNull() + }) + + it('rejects a different status session after filesystem proof', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: `Session: ${OTHER_THREAD}`, + lastOutputAt: reads++ > 0 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).rejects.toThrow('resumed a different Codex session') + expect(write).toHaveBeenCalledTimes(2) + }) + + it('dismisses status only after the exact session is observed', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + await expect( + proveCodexTuiRollout({ + codexHome: '/pinned', + threadId: THREAD, + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout: async () => '/pinned/sessions/rollout.jsonl', + delay: async () => undefined + }) + ).resolves.toEqual({ transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) + + it('discovers the live thread and resolves its pinned rollout', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + const resolveRollout = vi.fn(async () => '/pinned/sessions/rollout.jsonl') + + await expect( + resolveLiveCodexTuiRollout({ + codexHome: '/pinned', + kittyKeyboardFlags: 0, + readOutput: () => ({ + text: reads++ > 0 ? `Session: ${THREAD}` : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout, + delay: async () => undefined + }) + ).resolves.toEqual({ threadId: THREAD, transcriptPath: '/pinned/sessions/rollout.jsonl' }) + expect(resolveRollout).toHaveBeenCalledWith('/pinned', THREAD) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\r', + '\u001b' + ]) + }) + + it('accepts a proven blank Codex 0.148 session before its lazy rollout exists', async () => { + let reads = 0 + const write = vi.fn((_data: string) => true) + const resolveRollout = vi.fn(async () => null) + + await expect( + resolveLiveCodexTuiRollout({ + codexHome: '/pinned', + kittyKeyboardFlags: 1, + readOutput: () => ({ + text: + reads++ > 0 + ? `│ >_ OpenAI Codex (v0.148.0) │\n│ Session: \u001b[22m${THREAD}\u001b[2m │` + : '', + lastOutputAt: reads > 1 ? 2 : 1 + }), + write, + resolveRollout, + delay: async () => undefined + }) + ).resolves.toEqual({ threadId: THREAD }) + expect(resolveRollout).toHaveBeenCalledTimes(5) + expect(write.mock.calls.map(([data]) => data)).toEqual([ + '\u001b[200~/status\u001b[201~', + '\u001b[13u', + '\u001b' + ]) + }) +}) diff --git a/src/main/codex/codex-tui-rollout-proof.ts b/src/main/codex/codex-tui-rollout-proof.ts new file mode 100644 index 00000000000..d9de2227231 --- /dev/null +++ b/src/main/codex/codex-tui-rollout-proof.ts @@ -0,0 +1,247 @@ +import { open } from 'node:fs/promises' +import { join } from 'node:path' +import { stripAnsiEscapeSequences } from '../../shared/ansi-escape-sequences' +import { relativePathInsideRoot } from '../../shared/cross-platform-path' +import { listCodexSessionJsonlFilesIncrementally } from './codex-session-file-listing' + +const SESSION_ID_PATTERN = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' +const STATUS_SESSION_RE = new RegExp( + `\\b(?:Session|Thread)(?:\\s+ID)?\\s*:\\s*(${SESSION_ID_PATTERN})\\b`, + 'gi' +) +const ROLLOUT_READ_LIMIT = 64 * 1024 +const STATUS_COMMAND_PASTE = '\u001b[200~/status\u001b[201~' +const KITTY_ENTER = '\u001b[13u' +const TAB = '\t' + +export type CodexTuiProofOutput = { + text: string + lastOutputAt: number | null +} + +export type CodexTuiRolloutProofOptions = { + listFiles?: (sessionsRoot: string) => AsyncIterable + readSessionMetaId?: (filePath: string) => Promise +} + +export function parseCodexTuiStatusSessionId(output: string): string | null { + let sessionId: string | null = null + for (const match of stripAnsiEscapeSequences(output).matchAll(STATUS_SESSION_RE)) { + sessionId = match[1] ?? null + } + return sessionId +} + +export function codexTuiStatusSubmitInput(kittyKeyboardFlags: number): string { + return kittyKeyboardFlags > 0 ? KITTY_ENTER : '\r' +} + +export function codexTuiStatusProbeInput(kittyKeyboardFlags: number): { + command: string + submit: string +} { + return { + command: STATUS_COMMAND_PASTE, + submit: codexTuiStatusSubmitInput(kittyKeyboardFlags) + } +} + +export async function resolvePinnedCodexRolloutProof( + codexHome: string, + threadId: string, + options: CodexTuiRolloutProofOptions = {} +): Promise { + const sessionsRoot = join(codexHome, 'sessions') + const listFiles = + options.listFiles ?? + ((root: string) => listCodexSessionJsonlFilesIncrementally(root, { batchSize: 64, yieldMs: 0 })) + const readSessionMetaId = options.readSessionMetaId ?? readCodexRolloutSessionMetaId + const expectedThreadSegment = `-${threadId.toLowerCase()}` + + for await (const filePath of listFiles(sessionsRoot)) { + const relativePath = relativePathInsideRoot(sessionsRoot, filePath)?.replace(/\\/g, '/') + if ( + !relativePath || + !/^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl$/.test(relativePath) || + !(() => { + const lower = relativePath.toLowerCase() + const marker = lower.lastIndexOf(expectedThreadSegment) + if (marker === -1) { + return false + } + const after = lower.slice(marker + expectedThreadSegment.length) + return after === '.jsonl' || (after.startsWith('_') && after.endsWith('.jsonl')) + })() + ) { + continue + } + if ((await readSessionMetaId(filePath)) === threadId) { + return filePath + } + } + return null +} + +export async function proveCodexTuiRollout(input: { + codexHome: string + threadId: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ transcriptPath: string }> { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + const proofDeadline = Date.now() + (input.timeoutMs ?? 15_000) + let transcriptPath: string | null = null + // Codex can rotate or finish flushing the rollout while the resumed TUI is + // starting. Keep the proof retryable inside the same bounded deadline. + while (!transcriptPath && Date.now() < proofDeadline) { + transcriptPath = await resolveRollout(input.codexHome, input.threadId) + if (!transcriptPath) { + await delay(Math.min(100, Math.max(1, proofDeadline - Date.now()))) + } + } + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = proofDeadline + const retrySubmitAt = Date.now() + 750 + let retriedSubmit = false + while (Date.now() < deadline) { + const output = input.readOutput() + if ( + !retriedSubmit && + Date.now() >= retrySubmitAt && + output.text.includes('/status') && + !parseCodexTuiStatusSessionId(output.text) + ) { + retriedSubmit = true + // Newer Codex builds keep the slash-command popup open after a bracketed + // paste. Tab commits the highlighted command as text; the following Enter + // then dispatches it instead of merely selecting the popup row. + if (!input.write(TAB)) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + } + if (output.lastOutputAt !== baselineOutputAt) { + const observedThreadId = parseCodexTuiStatusSessionId(output.text) + if (observedThreadId && observedThreadId !== input.threadId) { + throw new Error('The agent terminal resumed a different Codex session.') + } + if (observedThreadId === input.threadId) { + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + return { transcriptPath } + } + } + await delay(100) + } + throw new Error('The agent terminal did not prove the expected Codex rollout.') +} + +export async function resolveLiveCodexTuiRollout(input: { + codexHome: string + kittyKeyboardFlags: number + readOutput: () => CodexTuiProofOutput + write: (data: string) => boolean + timeoutMs?: number + resolveRollout?: (codexHome: string, threadId: string) => Promise + delay?: (ms: number) => Promise +}): Promise<{ threadId: string; transcriptPath?: string }> { + const baselineOutputAt = input.readOutput().lastOutputAt + const probe = codexTuiStatusProbeInput(input.kittyKeyboardFlags) + if (!input.write(probe.command)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + const delay = input.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + await delay(100) + if (!input.write(probe.submit)) { + throw new Error('The agent terminal could not verify its Codex session.') + } + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const output = input.readOutput() + if (output.lastOutputAt !== baselineOutputAt) { + const threadId = parseCodexTuiStatusSessionId(output.text) + if (threadId) { + const resolveRollout = input.resolveRollout ?? resolvePinnedCodexRolloutProof + let transcriptPath: string | null = null + // Codex 0.148 allocates a session before it writes a rollout; give a just-written + // file a short visibility window without rejecting a genuinely blank conversation. + for (let attempt = 0; attempt < 5 && !transcriptPath; attempt += 1) { + transcriptPath = await resolveRollout(input.codexHome, threadId) + if (!transcriptPath && attempt < 4) { + await delay(100) + } + } + if (!input.write('\u001b')) { + throw new Error('The agent terminal could not finish Codex session verification.') + } + return { threadId, ...(transcriptPath ? { transcriptPath } : {}) } + } + } + await delay(100) + } + throw new Error('The agent terminal did not publish a resumable Codex conversation.') +} + +async function readCodexRolloutSessionMetaId(filePath: string): Promise { + // A listed rollout may vanish before it is read — Codex prunes and rewrites + // these files. One missing file must not abort the whole scan. + let file: Awaited> + try { + file = await open(filePath, 'r') + } catch { + return null + } + try { + const buffer = Buffer.alloc(ROLLOUT_READ_LIMIT) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + const firstLine = buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/, 1)[0]?.trim() + if (!firstLine) { + return null + } + const record = JSON.parse(firstLine) as { + type?: unknown + id?: unknown + session_id?: unknown + thread_id?: unknown + payload?: { id?: unknown; session_id?: unknown; thread_id?: unknown } + } + if (record.type !== 'session_meta') { + return null + } + const id = + record.payload?.id ?? + record.payload?.session_id ?? + record.payload?.thread_id ?? + record.id ?? + record.session_id ?? + record.thread_id + return typeof id === 'string' && id.length > 0 ? id : null + } catch { + return null + } finally { + await file.close() + } +} diff --git a/src/main/daemon/daemon-host-relocation.test.ts b/src/main/daemon/daemon-host-relocation.test.ts index 78e4787c0f6..0d2323fd449 100644 --- a/src/main/daemon/daemon-host-relocation.test.ts +++ b/src/main/daemon/daemon-host-relocation.test.ts @@ -1,15 +1,17 @@ import { + chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, + utimesSync, writeFileSync } from 'node:fs' import os from 'node:os' import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment' @@ -39,8 +41,10 @@ import { collectPinnedDaemonVersions, getRelocatedDaemonHost, materializeRelocatedDaemonHost, - pruneOldDaemonHosts + pruneOldDaemonHosts, + reclaimUnownedDaemonHostDir } from './daemon-host-relocation' +import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' let tempDir: string let installDir: string @@ -109,6 +113,8 @@ beforeEach(() => { }) afterEach(() => { + // A test that fails between spyOn and mockRestore must not leak its mock into later tests. + vi.restoreAllMocks() setProcessProp('platform', originalPlatform) setProcessProp('execPath', originalExecPath) setProcessProp('resourcesPath', originalResourcesPath) @@ -262,24 +268,380 @@ describe('getRelocatedDaemonHost', () => { }) }) +// Why: quarantine refuses records written in the last minute, because an in-flight publish is +// indistinguishable from a torn one. Age a record so it stands for a settled corrupt record. +function ageRecordPastQuarantineFloor(recordPath: string): void { + const aged = new Date(Date.now() - 5 * 60_000) + utimesSync(recordPath, aged, aged) +} + describe('pruneOldDaemonHosts', () => { it('removes unpinned non-current version dirs, keeping current and pinned', () => { const root = join(localAppDataDir, 'Orca', 'daemon-host') for (const v of ['9.9.9', '1.0.0', '2.0.0']) { mkdirSync(join(root, v), { recursive: true }) } - pruneOldDaemonHosts(new Set(['2.0.0'])) + pruneOldDaemonHosts({ + status: 'complete', + versionLiveness: new Map([['2.0.0', { status: 'live' }]]) + }) expect(existsSync(join(root, '9.9.9'))).toBe(true) expect(existsSync(join(root, '2.0.0'))).toBe(true) expect(existsSync(join(root, '1.0.0'))).toBe(false) }) + it('keeps a host when its pid liveness query is permission denied', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '8.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v8.pid'), + JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '8.0.0' }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('access denied'), { code: 'EPERM' }) + }) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'complete', + versionLiveness: new Map([['8.0.0', { status: 'live' }]]) + }) + pruneOldDaemonHosts(evidence) + + expect(existsSync(join(root, '8.0.0'))).toBe(true) + killSpy.mockRestore() + }) + + it('keeps a host when its pid liveness query is unavailable', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '7.0.0'), { recursive: true }) + mkdirSync(join(root, '6.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + writeFileSync( + join(runtimeDir, 'daemon-v7.pid'), + JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '7.0.0' }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }) + }) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'complete', + versionLiveness: new Map([ + ['7.0.0', { status: 'unverifiable', reason: 'the daemon process could not be queried' }] + ]) + }) + pruneOldDaemonHosts(evidence) + + expect(existsSync(join(root, '7.0.0'))).toBe(true) + expect(existsSync(join(root, '6.0.0'))).toBe(false) + killSpy.mockRestore() + }) + + it('prunes nothing and never throws when the evidence is unverifiable', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + for (const v of ['1.0.0', '2.0.0']) { + mkdirSync(join(root, v), { recursive: true }) + } + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + expect(() => + pruneOldDaemonHosts({ + status: 'unverifiable', + reason: 'the daemon runtime directory could not be read' + }) + ).not.toThrow() + + expect(existsSync(join(root, '1.0.0'))).toBe(true) + expect(existsSync(join(root, '2.0.0'))).toBe(true) + // The reason must reach the field log — an unobservable no-op is undiagnosable. + expect(warnSpy).toHaveBeenCalledWith( + '[daemon] Skipping daemon-host prune: the daemon runtime directory could not be read' + ) + warnSpy.mockRestore() + }) + + it('skips pruning when the runtime directory cannot be read', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + + const evidence = collectPinnedDaemonVersions(join(userDataDir, 'daemon-never-created')) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon runtime directory could not be read' + }) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) + + it('keeps a version live when any of its pid records is live', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '7.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // Two protocol generations of the same app version: one daemon live, one exited. The live + // record must win the merged verdict whichever order the directory scan visits them. + writeFileSync( + join(runtimeDir, 'daemon-v7.pid'), + JSON.stringify({ pid: 5001, startedAtMs: null, appVersion: '7.0.0' }) + ) + writeFileSync( + join(runtimeDir, 'daemon-v8.pid'), + JSON.stringify({ pid: 5002, startedAtMs: null, appVersion: '7.0.0' }) + ) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid: number) => { + if (pid === 5001) { + return true + } + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'complete', + versionLiveness: new Map([['7.0.0', { status: 'live' }]]) + }) + pruneOldDaemonHosts(evidence) + + expect(existsSync(join(root, '7.0.0'))).toBe(true) + killSpy.mockRestore() + }) + + it('preserves a host dir for any verdict that is not positively exited', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + // Why: deliberate out-of-contract cast — deletion must require a positive 'exited' match, + // so a future verdict status the prune does not know preserves the host dir, not deletes it. + const futureVerdict = { + status: 'suspended', + reason: 'hypothetical future verdict' + } as unknown as ProcessLivenessVerdict + + pruneOldDaemonHosts({ + status: 'complete', + versionLiveness: new Map([['1.0.0', futureVerdict]]) + }) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + + reclaimUnownedDaemonHostDir(futureVerdict, join(root, '1.0.0')) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + + reclaimUnownedDaemonHostDir({ status: 'exited' }, join(root, '1.0.0')) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + }) + + it('quarantines a record torn inside the pid digits without probing the truncated prefix', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // A tear inside the digits of pid 12345 leaves the prefix 123 — a DIFFERENT pid. Probing + // it would attribute an unrelated (here: dead) process's verdict to this record; the + // writer of a mid-digits tear died mid-write, so quarantine must not consult any probe. + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '{"pid":123') + ageRecordPastQuarantineFloor(pidPath) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('no such process'), { code: 'ESRCH' }) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + expect(killSpy).not.toHaveBeenCalled() + expect(existsSync(pidPath)).toBe(false) + expect(readFileSync(join(runtimeDir, 'daemon-v7.pid.corrupt'), 'utf8')).toBe('{"pid":123') + warnSpy.mockRestore() + killSpy.mockRestore() + }) + + it('never lets an immortal-pid prefix turn a torn record into a permanent prune veto', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // Pid 41234 torn to the prefix 4 — the Windows System pid, which answers probes forever. + // Trusting it would re-create for this one record the eternal veto pruning must not have. + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '{"pid":4') + ageRecordPastQuarantineFloor(pidPath) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('access denied'), { code: 'EPERM' }) + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + expect(killSpy).not.toHaveBeenCalled() + expect(readFileSync(join(runtimeDir, 'daemon-v7.pid.corrupt'), 'utf8')).toBe('{"pid":4') + + // Next launch: the listing is complete again and the unowned host is reclaimed. + pruneOldDaemonHosts(collectPinnedDaemonVersions(runtimeDir)) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + warnSpy.mockRestore() + killSpy.mockRestore() + }) + + it('never quarantines a corrupt record that was just written', () => { + // A live daemon's record is created before it is written (writeFileSync 'wx'), so a + // concurrent launch can read it as empty. Quarantining it would strand the running daemon's + // record and let the NEXT launch reclaim its host image. + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '') + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: + 'the daemon pid file could not be parsed and was written too recently to quarantine: daemon-v7.pid' + }) + expect(existsSync(pidPath)).toBe(true) + expect(existsSync(join(runtimeDir, 'daemon-v7.pid.corrupt'))).toBe(false) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + warnSpy.mockRestore() + }) + + it('never treats a settled empty record as a valid pre-relocation daemon', () => { + // Number('') === 0, so the parser's legacy bare-integer fallback accepts an empty record as + // pid 0 with appVersion null. Skipping it as "pins no host dir" would leave the version + // unpinned and let the prune below reclaim a live daemon's host image. Aged past the + // quarantine floor so this pins the pid guard rather than the freshness guard. + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, ' ') + ageRecordPastQuarantineFloor(pidPath) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + warnSpy.mockRestore() + }) + + it('vetoes pruning while a pid salvaged from a corrupt record still answers', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + // A torn write preserves the pid prefix; the process behind it still answers, so the + // record may belong to a live daemon of unknown version and must keep its veto un-quarantined. + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, '{"pid": 4242, "startedAtMs": 17') + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + expect(evidence).toEqual({ + status: 'unverifiable', + reason: + 'the daemon pid file could not be parsed and salvaged pid 4242 may still be running: daemon-v7.pid' + }) + expect(existsSync(pidPath)).toBe(true) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + warnSpy.mockRestore() + killSpy.mockRestore() + }) + + it('quarantines a corrupt record naming no live pid so pruning resumes next launch', () => { + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, 'not a daemon record') + ageRecordPastQuarantineFloor(pidPath) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Launch with the corrupt record: prune skips once, and the record is quarantined in place + // (bytes preserved) instead of vetoing every future launch. + const evidence = collectPinnedDaemonVersions(runtimeDir) + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be parsed and was quarantined: daemon-v7.pid' + }) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + expect(existsSync(pidPath)).toBe(false) + expect(readFileSync(join(runtimeDir, 'daemon-v7.pid.corrupt'), 'utf8')).toBe( + 'not a daemon record' + ) + + // Next launch: the listing is complete again and the unowned host is reclaimed. + const nextEvidence = collectPinnedDaemonVersions(runtimeDir) + expect(nextEvidence).toEqual({ status: 'complete', versionLiveness: new Map() }) + pruneOldDaemonHosts(nextEvidence) + expect(existsSync(join(root, '1.0.0'))).toBe(false) + warnSpy.mockRestore() + }) + + it('vetoes pruning without quarantine when a pid record cannot be read', (ctx) => { + // The suite mocks process.platform; the chmod trick needs the REAL host to be POSIX. + if (originalPlatform === 'win32') { + return ctx.skip() + } + const root = join(localAppDataDir, 'Orca', 'daemon-host') + const runtimeDir = join(userDataDir, 'daemon') + mkdirSync(join(root, '1.0.0'), { recursive: true }) + mkdirSync(runtimeDir, { recursive: true }) + const pidPath = join(runtimeDir, 'daemon-v7.pid') + writeFileSync(pidPath, JSON.stringify({ pid: 4242, startedAtMs: null, appVersion: '1.0.0' })) + chmodSync(pidPath, 0o000) + try { + readFileSync(pidPath) + return ctx.skip() // Running as root: the permission bit cannot make the read fail. + } catch { + // The read fails as intended. + } + + const evidence = collectPinnedDaemonVersions(runtimeDir) + + // A read failure is transient (AV lock, vanished file): veto this launch, but leave the + // record alone so a launch that can read it re-evaluates from the real bytes. + expect(evidence).toEqual({ + status: 'unverifiable', + reason: 'the daemon pid file could not be read: daemon-v7.pid' + }) + expect(existsSync(pidPath)).toBe(true) + pruneOldDaemonHosts(evidence) + expect(existsSync(join(root, '1.0.0'))).toBe(true) + }) + it('reclaims nothing for a packaged host with no asar root (orcad on win32)', () => { const root = join(localAppDataDir, 'Orca', 'daemon-host') mkdirSync(join(root, '1.0.0'), { recursive: true }) hostApp.appPath = join(installDir, 'resources', 'app') installHostApp() - pruneOldDaemonHosts(new Set()) + pruneOldDaemonHosts({ status: 'complete', versionLiveness: new Map() }) // A Node host owns no daemon-host tree, so deleting under it would be reaching into a // directory layout it never created. expect(existsSync(join(root, '1.0.0'))).toBe(true) @@ -299,7 +661,12 @@ describe('collectPinnedDaemonVersions', () => { JSON.stringify({ pid: 2147483646, startedAtMs: null, appVersion: '6.0.0' }) ) const pinned = collectPinnedDaemonVersions(runtimeDir) - expect(pinned.has('7.0.0')).toBe(true) - expect(pinned.has('6.0.0')).toBe(false) + expect(pinned).toEqual({ + status: 'complete', + versionLiveness: new Map([ + ['7.0.0', { status: 'live' }], + ['6.0.0', { status: 'exited' }] + ]) + }) }) }) diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts index 79e8bcf0ad7..a7e8f2b6db2 100644 --- a/src/main/daemon/daemon-host-relocation.ts +++ b/src/main/daemon/daemon-host-relocation.ts @@ -11,8 +11,10 @@ import { } from 'node:fs' import { dirname, join, win32 as winPath } from 'node:path' import { getAppEnvironment } from '../../shared/app-environment' +import type { ProcessLivenessVerdict } from './daemon-incarnation-evidence-types' import { parseDaemonPidFile } from './daemon-pid-file-parse' -import { startTimeMatches } from './daemon-process-start-time' +import { quarantineCorruptDaemonPidRecord } from './daemon-pid-record-quarantine' +import { inspectProcessLiveness, mergeProcessLivenessVerdict } from './daemon-process-inspection' /** * Relocate the terminal daemon's process image out of the app install dir into LOCAL userData so it @@ -293,53 +295,93 @@ export function materializeRelocatedDaemonHost(): RelocatedDaemonHost | null { return getRelocatedDaemonHost() } -function isDaemonPidAlive(pid: number, startedAtMs: number | null): boolean { - try { - process.kill(pid, 0) - } catch { - return false - } - return startTimeMatches(pid, startedAtMs) -} +export type PinnedDaemonVersionsEvidence = + | { status: 'complete'; versionLiveness: ReadonlyMap } + | { status: 'unverifiable'; reason: string } /** * App versions still pinned by a live daemon (from daemon-v.pid files under `runtimeDir`), whose * host dir must not be reclaimed while alive. On win32 start-time can't verify, so a matching pid pins conservatively. */ -export function collectPinnedDaemonVersions(runtimeDir: string): Set { - const pinned = new Set() +export function collectPinnedDaemonVersions(runtimeDir: string): PinnedDaemonVersionsEvidence { + const versionLiveness = new Map() let entries try { entries = readdirSync(runtimeDir, { withFileTypes: true }) } catch { - return pinned + return { status: 'unverifiable', reason: 'the daemon runtime directory could not be read' } } for (const entry of entries) { if (!entry.isFile() || !/^daemon-v\d+\.pid$/.test(entry.name)) { continue } - let parsed + let contents try { - parsed = parseDaemonPidFile(readFileSync(join(runtimeDir, entry.name), 'utf8')) + contents = readFileSync(join(runtimeDir, entry.name), 'utf8') } catch { - continue + // Read failures (AV lock, vanished file) are transient; the veto re-evaluates next launch. + return { + status: 'unverifiable', + reason: `the daemon pid file could not be read: ${entry.name}` + } + } + const parsed = parseDaemonPidFile(contents) + // Why not just `!parsed`: the parser's legacy bare-integer fallback coerces an empty or + // whitespace-only record to pid 0 (Number('') === 0), which is the exact shape a concurrent + // read sees while a live daemon publishes its record — writeFileSync 'wx' creates the file + // before writing it. Such a record would otherwise pass as a valid pre-relocation daemon, + // skip on appVersion === null, and leave its version unpinned, so the prune below would + // reclaim a running daemon's host image. A pid that is not a positive integer names no + // process — process.kill(0, 0) probes the caller's own process group, never a daemon — so + // it is not liveness evidence and must veto rather than be skipped. + if (!parsed || !Number.isInteger(parsed.pid) || parsed.pid <= 0) { + return { + status: 'unverifiable', + reason: quarantineCorruptDaemonPidRecord(runtimeDir, entry.name, contents) + } } // appVersion null => pre-relocation daemon forked from the install dir; pins no host dir here. - if (parsed && parsed.appVersion !== null && isDaemonPidAlive(parsed.pid, parsed.startedAtMs)) { - pinned.add(parsed.appVersion) + if (parsed.appVersion === null) { + continue } + const verdict = inspectProcessLiveness(parsed.pid) + versionLiveness.set( + parsed.appVersion, + mergeProcessLivenessVerdict(versionLiveness.get(parsed.appVersion), verdict) + ) + } + return { status: 'complete', versionLiveness } +} + +// Why: deletion is the destructive direction and this is a statement position the compiler does +// not police for exhaustiveness — reclaim must be opted into by a positively matched 'exited', +// so any future unhandled verdict status preserves the host dir instead of deleting it. +export function reclaimUnownedDaemonHostDir( + verdict: ProcessLivenessVerdict, + hostDir: string +): void { + if (verdict.status !== 'exited') { + return + } + try { + rmSync(hostDir, { recursive: true, force: true }) + } catch { + // Still locked or already gone — retry on a future launch. } - return pinned } /** * Reclaim daemon-host/ dirs that are neither the current version nor pinned by a live daemon. * Best-effort — never throws; a locked/staging dir is retried on a future launch. */ -export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { +export function pruneOldDaemonHosts(evidence: PinnedDaemonVersionsEvidence): void { if (!isPackagedElectronWin32()) { return } + if (evidence.status === 'unverifiable') { + console.warn(`[daemon] Skipping daemon-host prune: ${evidence.reason}`) + return + } const version = getAppEnvironment().getVersion() const root = hostRootDir() let entries @@ -349,13 +391,11 @@ export function pruneOldDaemonHosts(pinnedVersions: ReadonlySet): void { return } for (const entry of entries) { - if (!entry.isDirectory() || entry.name === version || pinnedVersions.has(entry.name)) { + if (!entry.isDirectory() || entry.name === version) { continue } - try { - rmSync(join(root, entry.name), { recursive: true, force: true }) - } catch { - // Still locked or already gone — retry on a future launch. - } + // A complete runtime-dir listing with no pid record for this version proves it is unowned. + const verdict = evidence.versionLiveness.get(entry.name) ?? { status: 'exited' } + reclaimUnownedDaemonHostDir(verdict, join(root, entry.name)) } } diff --git a/src/main/daemon/daemon-incarnation-evidence-types.ts b/src/main/daemon/daemon-incarnation-evidence-types.ts index 48c61da177f..4b2e1c7a69b 100644 --- a/src/main/daemon/daemon-incarnation-evidence-types.ts +++ b/src/main/daemon/daemon-incarnation-evidence-types.ts @@ -54,6 +54,11 @@ export type DaemonProcessEvidence = export type ProcessSignalEvidence = 'occupied' | 'permission_denied' | 'missing' | 'unavailable' +export type ProcessLivenessVerdict = + | { status: 'live' } + | { status: 'unverifiable'; reason: string } + | { status: 'exited' } + export type LinuxStatEvidence = | { status: 'present'; value: string } | { status: 'missing' } diff --git a/src/main/daemon/daemon-pid-file-parse.test.ts b/src/main/daemon/daemon-pid-file-parse.test.ts new file mode 100644 index 00000000000..5f9a5f10f58 --- /dev/null +++ b/src/main/daemon/daemon-pid-file-parse.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { salvagePidFromCorruptDaemonRecord } from './daemon-pid-file-parse' + +describe('salvagePidFromCorruptDaemonRecord', () => { + it('salvages a pid whose digit run is terminated by a following byte', () => { + expect(salvagePidFromCorruptDaemonRecord('{"pid":4242,"startedAtMs":17')).toBe(4242) + expect(salvagePidFromCorruptDaemonRecord('{"pid": 4242, "startedAtMs"')).toBe(4242) + expect(salvagePidFromCorruptDaemonRecord('{"pid":4242}')).toBe(4242) + }) + + it('refuses digits at end-of-bytes: a tear inside the digits leaves a different pid', () => { + // Pid 42420 torn mid-digits — the surviving prefix 4242 must not be mistaken for a pid. + expect(salvagePidFromCorruptDaemonRecord('{"pid":4242')).toBe(null) + expect(salvagePidFromCorruptDaemonRecord('{"pid":4')).toBe(null) + }) + + it('refuses records with no usable pid field', () => { + expect(salvagePidFromCorruptDaemonRecord('not a daemon record')).toBe(null) + expect(salvagePidFromCorruptDaemonRecord('{"pid":0,"startedAtMs":17')).toBe(null) + expect(salvagePidFromCorruptDaemonRecord('{"pid":-42,')).toBe(null) + }) +}) diff --git a/src/main/daemon/daemon-pid-file-parse.ts b/src/main/daemon/daemon-pid-file-parse.ts index 18e85088eaf..70a9f9649a9 100644 --- a/src/main/daemon/daemon-pid-file-parse.ts +++ b/src/main/daemon/daemon-pid-file-parse.ts @@ -9,6 +9,26 @@ export type ParsedDaemonPid = { spawnerExecPath: string | null } +/** + * Best-effort pid recovery from a record parseDaemonPidFile rejected. The pid is the first key + * JSON.stringify writes, so a torn write usually preserves it; it gates whether a corrupt record + * may be quarantined (a process still answering for this pid keeps its conservative veto). + * + * The digit run must be terminated by a following non-digit byte: a torn write can cut inside + * the digits, and a truncated prefix is a different pid — probing it attributes an unrelated + * process's liveness to this record (a dead prefix would quarantine on false evidence; an + * immortal one, e.g. Windows System pid 4, would veto forever). Digits at end-of-bytes are + * therefore unsalvageable; the writer of such a prefix died mid-write, so no probe is needed. + */ +export function salvagePidFromCorruptDaemonRecord(contents: string): number | null { + const match = /"pid"\s*:\s*(\d+)(?=\D)/.exec(contents) + if (!match) { + return null + } + const pid = Number(match[1]) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null +} + export function parseDaemonPidFile(contents: string): ParsedDaemonPid | null { const trimmed = contents.trim() try { diff --git a/src/main/daemon/daemon-pid-record-quarantine.ts b/src/main/daemon/daemon-pid-record-quarantine.ts new file mode 100644 index 00000000000..577e56578d2 --- /dev/null +++ b/src/main/daemon/daemon-pid-record-quarantine.ts @@ -0,0 +1,61 @@ +import { renameSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { salvagePidFromCorruptDaemonRecord } from './daemon-pid-file-parse' +import { inspectProcessLiveness } from './daemon-process-inspection' + +/** + * A record that was just written is never quarantined: publishDaemonPidFile creates the record + * before writing it (writeFileSync with flag 'wx'), so a concurrent launch can read a LIVE + * daemon's record as empty or torn. Renaming it aside would strand that daemon's record, and the + * next launch — seeing a complete listing with no record for its version — would reclaim the + * running daemon's host image. An in-flight publish is by definition fresh; a record left corrupt + * by a dead writer ages past this floor and is quarantined on a later launch. + */ +const QUARANTINE_MIN_RECORD_AGE_MS = 60_000 + +/** + * A pid record that parses to nothing would otherwise veto daemon-host pruning on every future + * launch: nothing ever rewrites a retired protocol version's pid file, so the veto never expires. + * Quarantine the record (rename in place, bytes kept for diagnosis) so the next launch scans a + * complete listing again — unless a process still answers for a pid salvaged from the corrupt + * bytes, in which case the record may belong to a live daemon and keeps its conservative veto + * until that pid exits. Returns the unverifiable reason; every branch is logged because this + * state suppresses pruning. + */ +export function quarantineCorruptDaemonPidRecord( + runtimeDir: string, + name: string, + contents: string +): string { + const salvagedPid = salvagePidFromCorruptDaemonRecord(contents) + if (salvagedPid !== null && inspectProcessLiveness(salvagedPid).status !== 'exited') { + const reason = `the daemon pid file could not be parsed and salvaged pid ${salvagedPid} may still be running: ${name}` + console.warn(`[daemon] Keeping corrupt daemon pid record: ${reason}`) + return reason + } + const recordPath = join(runtimeDir, name) + let modifiedAtMs: number + try { + modifiedAtMs = statSync(recordPath).mtimeMs + } catch { + const reason = `the daemon pid file could not be parsed or aged: ${name}` + console.warn(`[daemon] ${reason}`) + return reason + } + // A future mtime (clock adjustment) reads as negative age and is treated as fresh. + if (Date.now() - modifiedAtMs < QUARANTINE_MIN_RECORD_AGE_MS) { + const reason = `the daemon pid file could not be parsed and was written too recently to quarantine: ${name}` + console.warn(`[daemon] Keeping corrupt daemon pid record: ${reason}`) + return reason + } + try { + renameSync(recordPath, join(runtimeDir, `${name}.corrupt`)) + } catch { + const reason = `the daemon pid file could not be parsed or quarantined: ${name}` + console.warn(`[daemon] ${reason}`) + return reason + } + const reason = `the daemon pid file could not be parsed and was quarantined: ${name}` + console.warn(`[daemon] ${reason}`) + return reason +} diff --git a/src/main/daemon/daemon-process-inspection.test.ts b/src/main/daemon/daemon-process-inspection.test.ts index 9da55aeb816..41ea4e660b9 100644 --- a/src/main/daemon/daemon-process-inspection.test.ts +++ b/src/main/daemon/daemon-process-inspection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { + mergeProcessLivenessVerdict, queryWindowsProcess, readLinuxProcessStartedAtMs, readMacosProcessStartedAtMs, @@ -114,6 +115,48 @@ describe('daemon process inspection', () => { ).resolves.toBe(1_699_000_010_000) }) + // Several daemon-v.pid records can name the same app version; the merged verdict decides + // whether pruneOldDaemonHosts may delete that version's host dir, so a wrong winner deletes a + // live host. Precedence: live > unverifiable > exited, regardless of record order. + describe('mergeProcessLivenessVerdict', () => { + const unverifiable = { status: 'unverifiable', reason: 'probe failed' } as const + + it('keeps a live verdict when a later record for the same version reports exited', () => { + expect(mergeProcessLivenessVerdict({ status: 'live' }, { status: 'exited' })).toEqual({ + status: 'live' + }) + }) + + it('keeps a live verdict when a later record reports unverifiable', () => { + expect(mergeProcessLivenessVerdict({ status: 'live' }, unverifiable)).toEqual({ + status: 'live' + }) + }) + + it('never lets an exited record downgrade an unverifiable verdict', () => { + expect(mergeProcessLivenessVerdict(unverifiable, { status: 'exited' })).toEqual(unverifiable) + }) + + it('lets a live record supersede an earlier exited or unverifiable verdict', () => { + expect(mergeProcessLivenessVerdict({ status: 'exited' }, { status: 'live' })).toEqual({ + status: 'live' + }) + expect(mergeProcessLivenessVerdict(unverifiable, { status: 'live' })).toEqual({ + status: 'live' + }) + }) + + it('lets an unverifiable record upgrade an earlier exited verdict', () => { + expect(mergeProcessLivenessVerdict({ status: 'exited' }, unverifiable)).toEqual(unverifiable) + }) + + it('adopts the first verdict when there is no prior one', () => { + expect(mergeProcessLivenessVerdict(undefined, { status: 'exited' })).toEqual({ + status: 'exited' + }) + }) + }) + it.each([0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1, Number.NaN])( 'rejects unsafe Windows pid %s before command interpolation', async (pid) => { diff --git a/src/main/daemon/daemon-process-inspection.ts b/src/main/daemon/daemon-process-inspection.ts index 149ea478fb4..4f40090bfee 100644 --- a/src/main/daemon/daemon-process-inspection.ts +++ b/src/main/daemon/daemon-process-inspection.ts @@ -4,6 +4,7 @@ import { promisify } from 'node:util' import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks } from './daemon-process-start-time' import type { LinuxStatEvidence, + ProcessLivenessVerdict, ProcessSignalEvidence, WindowsProcessEvidence } from './daemon-incarnation-evidence-types' @@ -32,6 +33,33 @@ export function inspectProcessSignal(pid: number): ProcessSignalEvidence { } } +export function inspectProcessLiveness(pid: number): ProcessLivenessVerdict { + const signal = inspectProcessSignal(pid) + switch (signal) { + case 'occupied': + case 'permission_denied': + return { status: 'live' } + case 'missing': + return { status: 'exited' } + case 'unavailable': + return { status: 'unverifiable', reason: 'the daemon process could not be queried' } + } +} + +export function mergeProcessLivenessVerdict( + current: ProcessLivenessVerdict | undefined, + next: ProcessLivenessVerdict +): ProcessLivenessVerdict { + switch (next.status) { + case 'live': + return next + case 'unverifiable': + return current?.status === 'live' ? current : next + case 'exited': + return current ?? next + } +} + export async function readLinuxStat(pid: number): Promise { try { return { status: 'present', value: await readFile(`/proc/${pid}/stat`, 'utf8') } diff --git a/src/main/daemon/daemon-pty-session-inventory.ts b/src/main/daemon/daemon-pty-session-inventory.ts index b61e35f78e5..42d728bc994 100644 --- a/src/main/daemon/daemon-pty-session-inventory.ts +++ b/src/main/daemon/daemon-pty-session-inventory.ts @@ -55,6 +55,7 @@ export abstract class DaemonPtySessionInventory extends DaemonPtyProcessInspecti admission.admit({ id: session.sessionId, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + ...(session.pid ? { rootProcessId: session.pid } : {}), // Why: OSC 7 may not arrive before cleanup; spawn cwd is authoritative until the daemon reports a live cwd. cwd: session.cwd ?? this.initialCwds.get(session.sessionId) ?? '', title: 'shell', diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 84ff1adce4c..2f40b0881e8 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -4,7 +4,7 @@ // hour's loss; fsync stops it from happening. import { closeSync, fsyncSync, openSync, rmSync, writeFileSync } from 'node:fs' -import { open, readdir, rename, rm, stat } from 'node:fs/promises' +import { copyFile, open, readdir, rename, rm, stat } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' import { renameFileWithWindowsRetry } from './codex-accounts/fs-utils' @@ -52,6 +52,61 @@ export async function renameDurable(tmpPath: string, finalPath: string): Promise await syncDirectory(dirname(finalPath)) } +/** + * Write `payload` to `tmpPath` and fsync it, WITHOUT publishing it. For callers that must order + * other work between "the new content is durable" and "the new content is visible" — a backup + * rotation that has to happen while the old file is still in place, for instance. + */ +export async function writeTempFileDurable( + tmpPath: string, + payload: string, + mode?: number +): Promise { + const handle = await open(tmpPath, 'w', mode) + try { + await handle.writeFile(payload, 'utf-8') + await handle.sync() + } finally { + await handle.close() + } +} + +/** + * Copy `sourcePath` onto `finalPath` durably: a fresh inode, fsynced, then renamed into place. A + * plain copyFile can be interrupted and leave a torn destination — fatal when the destination is + * the backup someone will fall back to. Returns false when the source does not exist. + */ +export async function copyFileDurable(sourcePath: string, finalPath: string): Promise { + const tmpPath = durableWriteTempPath(finalPath) + let renamed = false + try { + try { + // copyFile stays in the kernel — and clones the extents outright on APFS and btrfs — so + // this does not pull the whole file through the process on every commit. + await copyFile(sourcePath, tmpPath) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false + } + throw error + } + const handle = await open(tmpPath, 'r+') + try { + await handle.sync() + } finally { + await handle.close() + } + await rename(tmpPath, finalPath) + renamed = true + await syncDirectory(dirname(finalPath)) + return true + } finally { + if (!renamed) { + await rm(tmpPath, { force: true }).catch(() => {}) + } + } +} + /** Write `payload` to `tmpPath`, fsync it, then rename onto `finalPath` and fsync the directory. */ export async function writeFileDurable( tmpPath: string, @@ -75,14 +130,8 @@ export async function writeFileDurableIfCurrent( ): Promise { let renamed = false try { - const handle = await open(tmpPath, 'w') - try { - await handle.writeFile(payload, 'utf-8') - // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. - await handle.sync() - } finally { - await handle.close() - } + // Why: fsync BEFORE rename. A rename that lands first can expose a zero-length file. + await writeTempFileDurable(tmpPath, payload) if (!isCurrent()) { return false } diff --git a/src/main/gitlab/client-mr-branch-lookup.test.ts b/src/main/gitlab/client-mr-branch-lookup.test.ts index 948e3510b03..b311882da3b 100644 --- a/src/main/gitlab/client-mr-branch-lookup.test.ts +++ b/src/main/gitlab/client-mr-branch-lookup.test.ts @@ -212,9 +212,9 @@ describe('gitlab client — MR operations', () => { await expect(getMergeRequestForBranch('/repo', 'feature')).resolves.toBeNull() }) - it('falls back to a linked MR iid when the branch lookup misses', async () => { + it('resolves a linked MR by iid without querying the branch', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ iid: 9, title: 'Linked MR', @@ -226,15 +226,64 @@ describe('gitlab client — MR operations', () => { const mr = await getMergeRequestForBranch('/repo', 'local-review-branch', 9) expect(mr?.number).toBe(9) expect(mr?.pipelineStatus).toBe('success') - expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith( - ['api', 'projects/g%2Fp/merge_requests/9'], + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/9?with_merge_status_recheck=true'], { cwd: '/repo' } ) }) - it('preserves merged state when falling back to a linked MR iid', async () => { + it('uses the explicitly linked MR when the branch still matches a different MR', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({ + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 2, + title: 'Replacement linked MR', + state: 'opened', + sha: 'head-2', + head_pipeline: { status: 'pending' } + }) + }) + + const mr = await getMergeRequestForBranch('/repo', 'qa/real-mr', 2) + + expect(mr).toMatchObject({ + number: 2, + title: 'Replacement linked MR', + pipelineStatus: 'pending' + }) + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/2?with_merge_status_recheck=true'], + { cwd: '/repo' } + ) + }) + + it('uses one exact lookup when the linked MR also matches the branch', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 2, + title: 'Already selected MR', + state: 'opened', + sha: 'head-2', + head_pipeline: { status: 'success' } + }) + }) + + await expect( + getMergeRequestForBranch('/repo', 'qa/replacement-mr', 2) + ).resolves.toMatchObject({ number: 2 }) + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/2?with_merge_status_recheck=true'], + { cwd: '/repo' } + ) + }) + + it('preserves merged state when resolving a linked MR iid', async () => { + getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ iid: 10, title: 'Merged linked MR', @@ -353,57 +402,44 @@ describe('gitlab client — MR operations', () => { expect(mr?.state).toBe('closed') }) - it('discards a closed default-branch shadow and refetches the linked MR via the fallback (#9171)', async () => { + it('resolves a linked default-branch MR without consulting a branch shadow (#9171)', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) - glabExecFileAsyncMock - .mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - iid: 7, - title: 'Accidental MR from main', - state: 'closed', - sha: 'stale-main-oid', - head_pipeline: { status: 'success' } - } - ]) - }) - .mockResolvedValueOnce({ - stdout: JSON.stringify({ - iid: 42, - title: 'Linked MR', - state: 'merged', - pipeline: { status: 'success' } - }) + glabExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + iid: 42, + title: 'Linked MR', + state: 'merged', + pipeline: { status: 'success' } }) + }) const mr = await getMergeRequestForBranch('/repo', 'main', 42) expect(mr).toMatchObject({ number: 42, state: 'merged' }) - expect(glabExecFileAsyncMock).toHaveBeenLastCalledWith( - ['api', 'projects/g%2Fp/merge_requests/42'], + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'projects/g%2Fp/merge_requests/42?with_merge_status_recheck=true'], { cwd: '/repo' } ) + expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) - it('keeps a non-open branch match on the default branch when it IS the linked MR', async () => { + it('keeps a linked non-open MR on the default branch', async () => { getProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'g/p' }) glabExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - iid: 7, - title: 'Linked trunk MR', - state: 'merged', - sha: 'abc', - head_pipeline: { status: 'success' } - } - ]) + stdout: JSON.stringify({ + iid: 7, + title: 'Linked trunk MR', + state: 'merged', + sha: 'abc', + head_pipeline: { status: 'success' } + }) }) const mr = await getMergeRequestForBranch('/repo', 'main', 7) expect(mr).toMatchObject({ number: 7, state: 'merged' }) - // Exempted by linked-number match — no fallback refetch needed. - expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(glabExecFileAsyncMock).toHaveBeenCalledOnce() }) it('returns null for an empty / detached-HEAD branch arg', async () => { diff --git a/src/main/gitlab/merge-request-lookup.ts b/src/main/gitlab/merge-request-lookup.ts index fa2909d3aff..96b6f7bad28 100644 --- a/src/main/gitlab/merge-request-lookup.ts +++ b/src/main/gitlab/merge-request-lookup.ts @@ -78,8 +78,8 @@ export async function getMergeRequest( } /** - * Find the merge request whose source branch matches the given name. - * Returns the most recently updated MR for the branch, or null when none exists. + * Find the explicitly linked merge request, or the newest MR whose source branch matches. + * Returns null when neither exists. */ export async function getMergeRequestForBranch( repoPath: string, @@ -103,6 +103,22 @@ export async function getMergeRequestForBranch( } await acquire() try { + if (typeof linkedMRIid === 'number') { + const { stdout } = await glabExecFileAsync( + [ + 'api', + ...glabHostnameArgs(projectRef, connectionId), + `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}?with_merge_status_recheck=true` + ], + glabRepoExecOptions(repoPath, connectionId, localGitOptions) + ) + const raw = JSON.parse(stdout) as Parameters[0] & { + head_pipeline?: { status?: string } | null + pipeline?: { status?: string } | null + } + const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) + return mapMRInfo(raw, pipelineStatus) + } if (branchName) { const { stdout } = await glabExecFileAsync( [ @@ -125,12 +141,10 @@ export async function getMergeRequestForBranch( // Why: older GitLab list payloads expose `pipeline` instead of `head_pipeline`. const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) const info = mapMRInfo(raw, pipelineStatus) - // Why (#9171): discard a non-open implicit branch match on the repo - // default branch and fall through to the linked-iid fallback below. + // Why (#9171): discard a non-open implicit branch match on the repo default branch. const hideOnDefaultBranch = await shouldHideNonOpenReviewOnDefaultBranch({ state: info.state, reviewNumber: info.number, - linkedReviewNumber: linkedMRIid, branchName, repoPath, connectionId, @@ -141,24 +155,7 @@ export async function getMergeRequestForBranch( } } } - if (typeof linkedMRIid !== 'number') { - return null - } - // Why: create-from-MR worktrees may rename the branch; fall back to the durable linked iid. - const { stdout } = await glabExecFileAsync( - [ - 'api', - ...glabHostnameArgs(projectRef, connectionId), - `projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}` - ], - glabRepoExecOptions(repoPath, connectionId, localGitOptions) - ) - const raw = JSON.parse(stdout) as Parameters[0] & { - head_pipeline?: { status?: string } | null - pipeline?: { status?: string } | null - } - const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) - return mapMRInfo(raw, pipelineStatus) + return null } catch (error) { if (throwOnFailure) { throw error diff --git a/src/main/host/deferred-secret-protection-report.test.ts b/src/main/host/deferred-secret-protection-report.test.ts new file mode 100644 index 00000000000..ce0a301e433 --- /dev/null +++ b/src/main/host/deferred-secret-protection-report.test.ts @@ -0,0 +1,218 @@ +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 { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store' + +type Listener = (...args: unknown[]) => void + +const appListeners = new Map() + +vi.mock('electron', () => ({ + app: { + once: (event: string, listener: Listener) => { + const existing = appListeners.get(event) ?? [] + existing.push(listener) + appListeners.set(event, existing) + } + } +})) + +const { scheduleSecretProtectionGapReport } = await import('./deferred-secret-protection-report') + +/** Stands in for the BrowserWindow the app creates first. */ +function fakeWindow(): { once: (event: string, listener: Listener) => void; reveal: () => void } { + const listeners: Listener[] = [] + return { + once: (event, listener) => { + if (event === 'ready-to-show') { + listeners.push(listener) + } + }, + reveal: () => listeners.splice(0).forEach((listener) => listener()) + } +} + +function createWindow(): ReturnType { + const window = fakeWindow() + appListeners + .get('browser-window-created') + ?.splice(0) + .forEach((listener) => listener({}, window)) + return window +} + +describe('scheduleSecretProtectionGapReport', () => { + let dir: string + let dataFile: string + let probes: number + let logged: string[] + + beforeEach(() => { + vi.useFakeTimers() + appListeners.clear() + dir = mkdtempSync(join(tmpdir(), 'orca-deferred-secret-report-')) + dataFile = join(dir, 'orca-data.json') + probes = 0 + logged = [] + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plainText) => Buffer.from(plainText), + decryptString: (cipher) => cipher.toString(), + describeProtectionGap: () => { + // Why count here: this is the call that blocks on the OS keyring. + probes += 1 + return 'The OS keyring is unavailable.' + } + }) + }) + + afterEach(() => { + vi.useRealTimers() + _resetSecretStoreForTests() + rmSync(dir, { recursive: true, force: true }) + }) + + const schedule = (): void => + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: true + }) + + /** Runs an already-queued setImmediate; the 1ms is slack, not a delay under test. */ + const drain = (): void => void vi.advanceTimersByTime(1) + + it('does not probe the keyring while the first window is still being created', () => { + // Why this is the regression: probing here blocked the window for 76s on a locked + // Linux keyring, which reads to the user as "the app will not open" (STA-5765). + schedule() + createWindow() + // Why drain: creation alone must arm nothing. Asserting before the queue drains + // would also pass if the probe were merely queued off `browser-window-created`, + // which on the real path still lands before the window paints. + drain() + expect(probes).toBe(0) + expect(logged).toEqual([]) + }) + + it('probes once the window is ready to show', () => { + schedule() + const window = createWindow() + window.reveal() + // Why: the reveal handler must return before the blocking probe runs, or the paint + // it is waiting on is the thing being blocked. + expect(probes).toBe(0) + drain() + expect(probes).toBe(1) + expect(logged).toEqual(['[secrets] The OS keyring is unavailable.']) + }) + + it('still reports when ready-to-show never fires, so a failed reveal is not silent', () => { + // Why: a GPU/driver failure can keep ready-to-show from ever firing, and headless + // serve has no window at all. + schedule() + createWindow() + // Why bracket the wait instead of running every timer: an unbounded flush passes for + // any fallback delay, including one long enough to never arrive in a real session. + vi.advanceTimersByTime(14_000) + drain() + expect(probes).toBe(0) + vi.advanceTimersByTime(1_100) + drain() + expect(probes).toBe(1) + }) + + it('probes only once when the window reveals and the fallback also elapses', () => { + schedule() + const window = createWindow() + window.reveal() + drain() + // Why assert the timer is gone and not just the probe count: the once-guard alone makes + // the count right, so without this the fallback could stay armed for 15s past the reveal + // and nothing here would notice. + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + vi.advanceTimersByTime(60_000) + vi.runAllTimers() + expect(probes).toBe(1) + }) + + it('does not let the fallback timer hold the process open', () => { + // Why: the report is diagnostics; a referenced 15s timer would keep a quitting app alive + // for up to 15s after its last window closed. + const timers: ReturnType[] = [] + const original = globalThis.setTimeout + const spy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((( + ...args: Parameters + ) => { + const timer = original(...args) + timers.push(timer) + return timer + }) as typeof setTimeout) + try { + schedule() + } finally { + spy.mockRestore() + } + expect(timers).toHaveLength(1) + expect(timers[0]?.hasRef()).toBe(false) + }) + + it('does not probe again when the window reveals after the fallback already reported', () => { + // Why this order and not the reverse: a GPU that presents late reveals the window + // after the fallback has already reported, and a second blocking keyring probe would + // freeze the main thread exactly as the user starts interacting. + schedule() + const window = createWindow() + vi.advanceTimersByTime(15_100) + drain() + expect(probes).toBe(1) + window.reveal() + drain() + expect(probes).toBe(1) + }) + + it('does not turn a throwing report into a fatal main-process error', () => { + // Why: deferred, this runs off whenReady's promise chain, where a throw is an + // uncaughtException the main-process guard re-throws fatally (#9441) — not the + // unhandled rejection the app survives. A diagnostic must never end the process. + let thrown = 0 + scheduleSecretProtectionGapReport({ + dataFile, + log: () => { + thrown += 1 + throw new Error('log sink is broken') + }, + deferUntilFirstWindow: true + }) + const window = createWindow() + window.reveal() + expect(() => drain()).not.toThrow() + expect(probes).toBe(1) + expect(thrown).toBeGreaterThan(0) + }) + + it('reports inline in headless serve, before the runtime is advertised as ready', () => { + // Why not deferred: serve opens no window, so the fallback would fire only after + // clients could already have paired, and a frozen main thread reads as a dead host. + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: false + }) + expect(probes).toBe(1) + expect(logged).toEqual(['[secrets] The OS keyring is unavailable.']) + }) + + it('leaves no timer armed in headless serve', () => { + scheduleSecretProtectionGapReport({ + dataFile, + log: (m) => void logged.push(m), + deferUntilFirstWindow: false + }) + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(probes).toBe(1) + }) +}) diff --git a/src/main/host/deferred-secret-protection-report.ts b/src/main/host/deferred-secret-protection-report.ts new file mode 100644 index 00000000000..4b12ad7e563 --- /dev/null +++ b/src/main/host/deferred-secret-protection-report.ts @@ -0,0 +1,85 @@ +import { app, type BrowserWindow } from 'electron' +import { reportSecretProtectionGap } from './secret-protection-report' + +/** + * Run the at-rest secret protection report once the app is up, never before. + * + * Why deferred: `describeProtectionGap()` asks Electron `safeStorage` whether the OS + * keyring is usable, and on Linux that is a blocking D-Bus round trip to + * `org.freedesktop.secrets`. A keyring that is present but locked with no unlock + * prompter never answers, so the call sits until D-Bus times it out — measured at 76s + * to first window on Ubuntu 24.04, against 1s on the build before the report existed + * (STA-5765). Run before the first window, that reads to the user as "the app will not + * open"; the window is gated behind a diagnostic whose result nothing on the startup + * path consumes. + * + * Why every platform and not just Linux: deferring costs nothing here — no caller reads the + * result, so the only thing that moves is when a console line appears. macOS pays the same + * shape against the Keychain, and a probe that has to answer before a window exists is wrong + * on any host. A deferral that withholds a real secret has to be scoped to the platform that + * needs it; this one withholds nothing. + */ + +// Why a fallback as well as the window event: `ready-to-show` can fail to fire at all +// when the GPU/driver cannot present (see main-window-state-lifecycle), and headless +// serve has no window to wait on. +const REPORT_FALLBACK_MS = 15_000 + +export function scheduleSecretProtectionGapReport({ + deferUntilFirstWindow, + ...options +}: { + dataFile: string + force?: boolean + log?: (message: string) => void + /** + * Why headless serve reports inline instead: it never opens a window, so the fallback + * timer is the only path — and by then the runtime has been advertised as ready and + * clients may have paired. Freezing the main thread under a live client stalls pings + * and PTY pumps, which reads as a dead host. Blocking before anything is advertised is + * the timing serve already had, and the safer of the two. + */ + deferUntilFirstWindow: boolean +}): void { + if (!deferUntilFirstWindow) { + reportSecretProtectionGap(options) + return + } + + // Why swallow here and not in reportSecretProtectionGap: deferred, this no longer runs on + // whenReady's promise chain, where a throw was an unhandled rejection the app survives + // (#9441). Off that chain it is an uncaughtException, and installUncaughtPipeErrorGuard + // re-throws those fatally — killing the app over a diagnostic the module documents as + // deliberately not fatal. Serve still reports inline and keeps the old posture. + const report = (): void => { + try { + reportSecretProtectionGap(options) + } catch (error) { + try { + ;(options.log ?? console.warn)( + `[secrets] could not run the deferred protection report: ${String(error)}` + ) + } catch { + // A throwing log sink must not be what ends the process either. + } + } + } + + let ran = false + const run = (): void => { + if (ran) { + return + } + ran = true + clearTimeout(fallback) + // Why setImmediate: keep the blocking keyring probe off the event handler that + // reveals the window, so the reveal paints first. + setImmediate(report) + } + + const fallback = setTimeout(run, REPORT_FALLBACK_MS) + fallback.unref?.() + app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => { + window.once('ready-to-show', run) + }) +} diff --git a/src/main/index.ts b/src/main/index.ts index 3ed31fadecc..89e3cbf0aca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -36,7 +36,7 @@ import { setSpeechServiceFactories } from './speech/speech-runtime-service' import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal' import { setSecretStore } from '../shared/secret-store' import { ElectronSecretStore } from './host/electron-secret-store' -import { reportSecretProtectionGap } from './host/secret-protection-report' +import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report' import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' @@ -335,6 +335,7 @@ import { AgentAwakeService } from './agent-awake-service' import { normalizeComputerAwakeMode } from '../shared/computer-awake-mode' import { registerSystemResumeBroadcast } from './system-resume-broadcast' import { settleTeardownWithinDeadline, settleWithinMs } from './quit-teardown-deadline' +import { stopStructuredAgentSessionRuntime } from './runtime/structured-agent-session-runtime' import { quitTeardownStartGate } from './quit-teardown-start-gate' import { beginSshShutdown } from './ipc/ssh-shutdown-drain' import { PluginService } from './plugins/plugin-service' @@ -995,6 +996,11 @@ ipcMain.handle('app:awaitFirstWindowStartupServices', async () => { await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) }) +ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { + await Promise.all([firstWindowStartupServicesReady, managedWslCliStartupBarrierReady]) + await runtime?.prepareStructuredAgentSessionStartupRestoration() +}) + ipcMain.handle('app:recoverLegacyWorkerTerminalsForRendererStartup', () => recoverLegacyWorkerTerminalsForRendererStartup({ firstWindowStartupServicesReady, @@ -2347,11 +2353,14 @@ void app.whenReady().then(async () => { dataFile: activeOrcaProfile.dataFile, storageAuthority: isServeMode ? 'runtime' : 'desktop' }) - // Why here and not at install time: the report remembers what it last said, and that - // state lives beside the profile data file, which does not exist until now. - reportSecretProtectionGap({ + // Why armed here and not at install time: the report remembers what it last said, and + // that state lives beside the profile data file, which does not exist until now. + // Why scheduled and not called: the report probes the OS keyring, which blocks on Linux + // and must not gate the first window (STA-5765). + scheduleSecretProtectionGapReport({ dataFile: activeOrcaProfile.dataFile, - force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1' + force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1', + deferUntilFirstWindow: !isServeMode }) // Why here: the host key store is a sidecar of the same profile, and every SSH connect consults // it. Left unbound it reports nothing trusted, which is safe but silently discards our own @@ -2788,6 +2797,11 @@ void app.whenReady().then(async () => { runtimeHome: codexRuntimeHome, systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), + prepareCodexStructuredLaunch: ({ workspacePath, launchEnv }) => + prepareCodexRuntimeHomeForLaunch(undefined, launchEnv, { + launchAgent: 'codex', + workspacePath + }), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, orchestrationEnvironmentTransport, @@ -3524,6 +3538,7 @@ app.on('will-quit', (e) => { pluginMarketplaceInstaller = null const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() const codexBackfillRecoveryShutdown = stopCodexStateDbBackfillRecoveries() + const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime() pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() @@ -3625,6 +3640,7 @@ app.on('will-quit', (e) => { { name: 'skill-uploads', promise: skillUploadShutdown }, { name: 'grok-hooks', promise: grokHookCleanup }, { name: 'codex-backfill-recovery', promise: codexBackfillRecoveryShutdown }, + { name: 'structured-agent-session', promise: structuredAgentSessionShutdown }, { name: 'usage-cache', promise: usageCacheFlush }, { name: 'stats', promise: statsFlush }, { name: 'state', promise: storeFlush } diff --git a/src/main/ipc/ai-vault-resume.ts b/src/main/ipc/ai-vault-resume.ts index a5618c0041d..e8f56516b08 100644 --- a/src/main/ipc/ai-vault-resume.ts +++ b/src/main/ipc/ai-vault-resume.ts @@ -5,8 +5,10 @@ import type { AiVaultSessionResumePreparation } from '../../shared/ai-vault-resume-preparation' import { parseExecutionHostId } from '../../shared/execution-host' +import { assertLegacyAiVaultResumeAllowed } from '../ai-vault/structured-session-ownership' export type AiVaultResumeHandlerOptions = { + ensureStructuredSessionOwnership?: () => Promise prepareSessionResume?: AiVaultSessionResumePreparation prepareRuntimeSessionResume?: ( environmentId: string, @@ -24,6 +26,8 @@ export async function prepareAiVaultSessionResume( args: AiVaultPrepareSessionResumeArgs, options: AiVaultResumeHandlerOptions ): Promise { + await options.ensureStructuredSessionOwnership?.() + assertLegacyAiVaultResumeAllowed(args) const executionHost = parseExecutionHostId(args.executionHostId) if (executionHost?.kind === 'runtime') { if (!options.prepareRuntimeSessionResume) { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 198c5295662..746f469dba7 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -57,6 +57,7 @@ import { resolveAiVaultSessionTitlesByHost, type RuntimeAiVaultSessionTitleResolver } from './ai-vault-session-title-routing' +import { projectStructuredAiVaultSessions } from '../ai-vault/structured-session-ownership' const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 // Why: a remote home with many agent roots routinely needs seconds to walk, @@ -282,7 +283,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo : undefined const controller = listCancellations.begin(event, requestToken) try { - return await listAiVaultSessions(args, { signal: controller?.signal }) + await handlerOptions.ensureStructuredSessionOwnership?.() + const result = await listAiVaultSessions(args, { signal: controller?.signal }) + return projectStructuredAiVaultSessions(result, true) } catch (error) { // Why: superseding a scan is normal control flow, but Electron logs every // rejected handler — report it as a result so the log stays truthful. @@ -317,8 +320,7 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo handleAiVaultGetFirstUserPrompt(args) ) registerAiVaultDeleteHandler(aiVaultDeleteDeps) - // DOM focus/visibility events don't fire in the renderer on macOS app - // activation, so refresh-on-refocus needs this main-process signal. + // macOS app activation skips DOM focus events, so emit the refresh signal here. app.on('browser-window-focus', (_event, window) => { if (!window.isDestroyed()) { window.webContents.send('aiVault:windowFocused') diff --git a/src/main/ipc/desktop-runtime-sender-lifecycle.ts b/src/main/ipc/desktop-runtime-sender-lifecycle.ts new file mode 100644 index 00000000000..b00ab4711fb --- /dev/null +++ b/src/main/ipc/desktop-runtime-sender-lifecycle.ts @@ -0,0 +1,72 @@ +import type { WebContents } from 'electron' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' + +type SenderState = { + connectionId: string + sender: WebContents + subscriptions: Map +} + +type CleanupRuntime = Pick + +export class DesktopRuntimeSenderLifecycle { + private readonly senders = new Map() + private nextConnectionGeneration = 1 + + constructor(private readonly runtime: CleanupRuntime) {} + + connectionIdFor(sender: WebContents): string { + return this.stateFor(sender).connectionId + } + + subscriptionsFor(sender: WebContents): Map { + return this.stateFor(sender).subscriptions + } + + existingSubscriptionsFor(sender: WebContents): Map | null { + const state = this.senders.get(sender.id) + return state?.sender === sender ? state.subscriptions : null + } + + private stateFor(sender: WebContents): SenderState { + const existing = this.senders.get(sender.id) + if (existing?.sender === sender) { + return existing + } + if (existing) { + this.retire(existing, true) + } + const state: SenderState = { + connectionId: this.mintConnectionId(sender.id), + sender, + subscriptions: new Map() + } + this.senders.set(sender.id, state) + const retireDocument = (): void => this.retire(state, false) + sender.on('did-navigate', retireDocument) + sender.on('render-process-gone', retireDocument) + sender.once('destroyed', () => this.retire(state, true)) + return state + } + + private retire(state: SenderState, destroyed: boolean): void { + if (this.senders.get(state.sender.id) !== state) { + return + } + const retiredConnectionId = state.connectionId + if (destroyed) { + this.senders.delete(state.sender.id) + } else { + state.connectionId = this.mintConnectionId(state.sender.id) + } + for (const controller of state.subscriptions.values()) { + controller.abort() + } + state.subscriptions.clear() + this.runtime.cleanupSubscriptionsForConnection(retiredConnectionId) + } + + private mintConnectionId(senderId: number): string { + return `desktop-renderer:${senderId}:${this.nextConnectionGeneration++}` + } +} diff --git a/src/main/ipc/gitlab-repo-access.test.ts b/src/main/ipc/gitlab-repo-access.test.ts new file mode 100644 index 00000000000..cf1ed35e99b --- /dev/null +++ b/src/main/ipc/gitlab-repo-access.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { assertRegisteredRepo } from './gitlab-repo-access' + +const repoPath = '/workspace/repo' +const localRepo = { + id: 'repo-1', + path: repoPath, + displayName: 'local', + badgeColor: '#000', + addedAt: 0 +} +const sshRepo = { ...localRepo, displayName: 'ssh', connectionId: 'ssh-1' } + +function makeStore() { + return { + getRepo: vi.fn(() => localRepo), + getRepos: vi.fn(() => [localRepo, sshRepo]) + } +} + +describe('GitLab repo owner selection', () => { + it('selects the exact owner when ids and paths collide', () => { + expect( + assertRegisteredRepo( + { + repoPath, + repoId: 'repo-1', + repoOwnerExecutionHostId: 'ssh:ssh-1' + }, + makeStore() as never + ) + ).toBe(sshRepo) + }) + + it('fails closed when the explicit owner is absent', () => { + expect(() => + assertRegisteredRepo( + { + repoPath, + repoId: 'repo-1', + repoOwnerExecutionHostId: 'runtime:missing' + }, + makeStore() as never + ) + ).toThrow('Access denied: unknown repository path') + }) +}) diff --git a/src/main/ipc/gitlab-repo-access.ts b/src/main/ipc/gitlab-repo-access.ts index 5bf70e7ff2b..539ae2168fa 100644 --- a/src/main/ipc/gitlab-repo-access.ts +++ b/src/main/ipc/gitlab-repo-access.ts @@ -11,12 +11,25 @@ export type GitLabRepoSelectorArgs = { repoPath: string repoId?: string | null sourceContext?: TaskSourceContext | null + repoOwnerExecutionHostId?: string } function findRegisteredGitLabRepo(args: GitLabRepoSelectorArgs, store: Store): Repo | undefined { const sourceRepoId = args.sourceContext?.provider === 'gitlab' ? args.sourceContext.repoId?.trim() : null const repoId = args.repoId?.trim() || sourceRepoId || null + if (args.repoOwnerExecutionHostId) { + const resolvedRepoPath = resolve(args.repoPath) + const matches = store + .getRepos() + .filter( + (repo) => + (!repoId || repo.id === repoId) && + resolve(repo.path) === resolvedRepoPath && + getRepoExecutionHostId(repo) === args.repoOwnerExecutionHostId + ) + return matches.length === 1 ? matches[0] : undefined + } if (repoId) { const repo = store.getRepo(repoId) if (repo) { diff --git a/src/main/ipc/hosted-review.test.ts b/src/main/ipc/hosted-review.test.ts index de912d740a3..c22e9bbfe26 100644 --- a/src/main/ipc/hosted-review.test.ts +++ b/src/main/ipc/hosted-review.test.ts @@ -314,6 +314,38 @@ describe('registerHostedReviewHandlers', () => { ) }) + it('uses the explicit owner when duplicate repos share an id and path', async () => { + const localRepo = { ...repo, connectionId: undefined } + store.getRepos.mockReturnValue([localRepo, repo]) + getHostedReviewForBranchMock.mockResolvedValueOnce(null) + registerHostedReviewHandlers(store as never, stats as never) + + await handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + repoOwnerExecutionHostId: 'ssh:ssh-1', + branch: 'feature/owner' + }) + + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: 'ssh-1', branch: 'feature/owner' }) + ) + }) + + it('fails closed when an explicit repo owner is missing', async () => { + registerHostedReviewHandlers(store as never, stats as never) + + await expect( + handlers['hostedReview:forBranch'](null, { + repoPath, + repoId: repo.id, + repoOwnerExecutionHostId: 'runtime:missing', + branch: 'feature/owner' + }) + ).rejects.toThrow('Access denied: unknown or ambiguous repository owner') + expect(getHostedReviewForBranchMock).not.toHaveBeenCalled() + }) + it('passes SSH connectionId through create eligibility instead of blocking the worktree', async () => { getHostedReviewCreationEligibilityMock.mockResolvedValueOnce({ provider: 'github', diff --git a/src/main/ipc/hosted-review.ts b/src/main/ipc/hosted-review.ts index 5c54f26175f..8bd9b7cce91 100644 --- a/src/main/ipc/hosted-review.ts +++ b/src/main/ipc/hosted-review.ts @@ -19,6 +19,7 @@ import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache import { listRepoWorktrees } from '../repo-worktrees' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories' +import { getRepoExecutionHostId } from '../../shared/execution-host' function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): Repo { if (repoId) { @@ -36,6 +37,27 @@ function assertRegisteredRepo(repoPath: string, store: Store, repoId?: string): return repo } +function assertRegisteredRepoForBranch(args: HostedReviewForBranchArgs, store: Store): Repo { + if (!args.repoOwnerExecutionHostId) { + return assertRegisteredRepo(args.repoPath, store, args.repoId) + } + const matches = store.getRepos().filter((candidate) => { + const samePath = candidate.connectionId + ? normalizeRemoteHostedReviewPath(candidate.path) === + normalizeRemoteHostedReviewPath(args.repoPath) + : resolve(candidate.path) === resolve(args.repoPath) + return ( + candidate.id === args.repoId && + samePath && + getRepoExecutionHostId(candidate) === args.repoOwnerExecutionHostId + ) + }) + if (matches.length !== 1) { + throw new Error('Access denied: unknown or ambiguous repository owner') + } + return matches[0] +} + async function resolveHostedReviewWorktreePath( repo: Repo, store: Store, @@ -80,7 +102,7 @@ function normalizeRemoteHostedReviewPath(remotePath: string): string { export function registerHostedReviewHandlers(store: Store, stats: StatsCollector): void { ipcMain.handle('hostedReview:forBranch', async (_event, args: HostedReviewForBranchArgs) => { - const repo = assertRegisteredRepo(args.repoPath, store, args.repoId) + const repo = assertRegisteredRepoForBranch(args, store) const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo) const review = await getHostedReviewForBranch({ repoPath: repo.path, diff --git a/src/main/ipc/pty-agent-session-write-gate.test.ts b/src/main/ipc/pty-agent-session-write-gate.test.ts new file mode 100644 index 00000000000..091d5eb4474 --- /dev/null +++ b/src/main/ipc/pty-agent-session-write-gate.test.ts @@ -0,0 +1,290 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, onMock, removeHandlerMock, removeAllListenersMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + removeHandlerMock: vi.fn(), + removeAllListenersMock: vi.fn() +})) + +vi.mock('electron', () => ({ + app: { + isPackaged: true, + getPath: vi.fn().mockReturnValue('/tmp/orca-test-userdata') + }, + ipcMain: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + }, + powerMonitor: { on: vi.fn() } +})) + +vi.mock('fs', () => ({ + existsSync: () => true, + statSync: () => ({ isDirectory: () => true, mode: 0o755 }), + accessSync: () => undefined, + mkdirSync: vi.fn(), + readFileSync: vi.fn(() => ''), + writeFileSync: vi.fn(), + chmodSync: vi.fn(), + constants: { X_OK: 1 } +})) + +vi.mock('node-pty', () => ({ + spawn: vi.fn().mockReturnValue({ + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + process: 'zsh', + pid: 12345 + }) +})) + +vi.mock('../opencode/hook-service', () => ({ + openCodeHookService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } +})) + +vi.mock('../pi/titlebar-extension-service', () => ({ + piTitlebarExtensionService: { buildPtyEnv: () => ({}), clearPty: vi.fn() } +})) + +import { + deletePtyOwnership, + registerPtyHandlers, + registerSshPtyProvider, + setPtyOwnership, + unregisterSshPtyProvider +} from './pty' +import { agentSessionPtyWriteGate } from '../runtime/agent-session-pty-write-gate' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import type { IPtyProvider } from '../providers/types' +import { setPtyHostBindings } from './pty-host-bindings' + +// The renderer IPC path and the runtime controller are the two byte entry points this module owns; +// both are proved to consult the lease, and both are proved to leave an unbound PTY alone. + +const CONNECTION_ID = 'conn-lease' +const PTY_ID = 'ssh:conn-lease@@pty-1' +const SESSION_ID = 'session-alpha-1' + +const handlers = new Map unknown>() +const records = new Map() + +const mainWindow = { + isDestroyed: () => false, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } +} +const mainWindowIpcEvent = { sender: mainWindow.webContents } + +let ptyController: { write: (ptyId: string, data: string) => boolean } | null = null + +function createMockProvider(): IPtyProvider { + return { + spawn: vi.fn().mockResolvedValue({ id: PTY_ID }), + attach: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + listProcesses: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn(), + onData: vi.fn().mockReturnValue(() => {}), + onReplay: vi.fn().mockReturnValue(() => {}), + onExit: vi.fn().mockReturnValue(() => {}) + } as unknown as IPtyProvider +} + +let provider: IPtyProvider + +function publish(lease: AgentSessionLease): void { + records.set(lease.sessionId, agentSessionRecordFixture(lease)) +} + +function enforce(lease: AgentSessionLease = agentSessionLeaseFixture()): void { + publish(lease) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) +} + +function writeFromRenderer(data: string): void { + ;(handlers.get('pty:write') as (event: unknown, args: unknown) => void)(mainWindowIpcEvent, { + id: PTY_ID, + data + }) +} + +async function writeAcceptedFromRenderer(data: string): Promise { + return await ( + handlers.get('pty:writeAccepted') as ( + event: unknown, + args: unknown + ) => boolean | Promise + )(mainWindowIpcEvent, { id: PTY_ID, data }) +} + +function lastRefusal(): { id: string; agentSessionRefusal?: { code: string } } | null { + const call = mainWindow.webContents.send.mock.calls.findLast( + ([channel]) => channel === 'pty:writeUnavailable' + ) + return (call?.[1] as { id: string; agentSessionRefusal?: { code: string } }) ?? null +} + +beforeEach(() => { + handlers.clear() + records.clear() + handleMock.mockReset() + onMock.mockReset() + mainWindow.webContents.send.mockReset() + ptyController = null + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + onMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + setPtyHostBindings({ + ipc: { + handle: handleMock, + on: onMock, + removeHandler: removeHandlerMock, + removeAllListeners: removeAllListenersMock + } as never + }) + const runtime = { + setPtyController: (controller: { write: (ptyId: string, data: string) => boolean }) => { + ptyController = controller + }, + getDriver: () => ({ kind: 'desktop' }) + } + registerPtyHandlers(mainWindow as never, runtime as never) + provider = createMockProvider() + registerSshPtyProvider(CONNECTION_ID, provider) + setPtyOwnership(PTY_ID, CONNECTION_ID) +}) + +afterEach(() => { + setPtyHostBindings({}) + agentSessionPtyWriteGate.detachRecordLookup() + deletePtyOwnership(PTY_ID) + unregisterSshPtyProvider(CONNECTION_ID) +}) + +describe('renderer IPC write path', () => { + it('writes an unbound pty unchanged, which is every shell that exists today', () => { + writeFromRenderer('ls') + + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'ls') + expect(lastRefusal()).toBeNull() + }) + + it('writes when the TUI owner holds a proven-live lease', () => { + enforce() + + writeFromRenderer('ls') + + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('refuses and reports the owner when native chat holds the session', () => { + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + writeFromRenderer('ls') + + expect(provider.write).not.toHaveBeenCalled() + const refusal = lastRefusal() + expect(refusal?.id).toBe(PTY_ID) + expect(refusal?.agentSessionRefusal).toMatchObject({ + code: 'agent_session_conflict', + sessionId: SESSION_ID, + ownerRuntimeKind: 'native', + ownerPid: 4242 + }) + }) + + it('refuses while the lease is unreconciled after a host restart', () => { + enforce(agentSessionLeaseFixture({ unreconciled: true })) + + writeFromRenderer('ls') + + expect(provider.write).not.toHaveBeenCalled() + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('execution_owner_reconciling') + }) + + it('refuses the acknowledged write path too', async () => { + enforce(agentSessionLeaseFixture({ handoffStage: 'preparing' })) + + await expect(writeAcceptedFromRenderer('')).resolves.toBe(false) + + expect(provider.write).not.toHaveBeenCalled() + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_conflict') + }) + + it('leaves the acknowledged write path silent for an unbound pty', async () => { + await writeAcceptedFromRenderer('') + + expect(lastRefusal()).toBeNull() + }) + + it('stops a chunked paste once the fence advances mid-flight', async () => { + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + vi.mocked(provider.write).mockImplementation(() => { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + }) + + writeFromRenderer('x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)) + await vi.waitFor(() => expect(lastRefusal()).not.toBeNull()) + + expect(provider.write).toHaveBeenCalledTimes(1) + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_checkpoint_stale') + }) + + it('lets a chunked paste finish while the same owner holds the fence', async () => { + enforce() + + writeFromRenderer('x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8)) + await vi.waitFor(() => expect(provider.write).toHaveBeenCalledTimes(3)) + + expect(lastRefusal()).toBeNull() + }) +}) + +describe('runtime controller backstop', () => { + it('lets a runtime write through on an unbound pty', () => { + expect(ptyController?.write(PTY_ID, 'reply')).toBe(true) + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'reply') + }) + + it('blocks a runtime write that never passed a typed gate', () => { + // Query replies, followups, and deliveries reach the provider through this one function. + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + expect(ptyController?.write(PTY_ID, 'reply')).toBe(false) + expect(provider.write).not.toHaveBeenCalled() + expect(lastRefusal()?.agentSessionRefusal?.code).toBe('agent_session_conflict') + }) + + it('lets a runtime write through while the TUI owner holds the lease', () => { + enforce() + + expect(ptyController?.write(PTY_ID, 'reply')).toBe(true) + expect(provider.write).toHaveBeenCalledWith(PTY_ID, 'reply') + }) +}) diff --git a/src/main/ipc/pty-startup-swap-window-presence.test.ts b/src/main/ipc/pty-startup-swap-window-presence.test.ts new file mode 100644 index 00000000000..d0a511e86cd --- /dev/null +++ b/src/main/ipc/pty-startup-swap-window-presence.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it, vi } from 'vitest' +import { makeDeferred } from './pty-ipc-test-constants' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { registerPtyHandlers, registerSshPtyProvider } from './pty' +import { ptyOwnership } from './pty/provider/ownership-state' + +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +// During the cold-start daemon swap the installed local provider is still the plain +// in-process LocalPtyProvider; it does not own daemon-restored PTY ids, so its +// "no PTY" is fabricated, not observed. A confident false here tears down live +// panes (shouldReconcileMissingSession reconciles ONLY on false) and blocks +// input-undeliverable remount recovery. These suites pin: while the swap is in +// flight the presence answer is deferred (IPC) or unverifiable-null (sync), and +// the post-swap owner's answer is the one that lands. +describe('registerPtyHandlers daemon-swap-window presence', () => { + const { handlers, mainWindow, installDaemonTestProvider } = setupPtyIpcSuite() + + const registerWithStartupBarrier = ( + barrier: Promise, + runtime?: Record + ): void => { + registerPtyHandlers( + mainWindow as never, + runtime as never, + undefined, + undefined, + undefined, + undefined, + { awaitLocalPtyProviderStartup: () => barrier } + ) + } + + const installRuntimeControllerWithBarrier = ( + barrier: Promise + ): { hasPty: (ptyId: string) => boolean | null } => { + let controller: { hasPty: (ptyId: string) => boolean | null } | undefined + registerWithStartupBarrier(barrier, { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + }) + if (!controller) { + throw new Error('runtime controller was not installed') + } + return controller + } + + it('pty:hasPty defers a restored daemon id until the provider swap lands instead of answering a pre-swap false', async () => { + const barrier = makeDeferred() + registerWithStartupBarrier(barrier.promise) + + const pending = Promise.resolve( + handlers.get('pty:hasPty')!(null, { id: 'daemon-restored-pty' }) + ) as Promise + let settled = false + void pending.then(() => { + settled = true + }) + + await Promise.resolve() + await Promise.resolve() + // Pre-fix this has already resolved false — the pre-swap LocalPtyProvider + // answered for a PTY it does not own, and the renderer reconciler treats + // exactly that false as authority to tear the pane down. + expect(settled).toBe(false) + + installDaemonTestProvider({ hasPty: (id: string) => id === 'daemon-restored-pty' }) + barrier.resolve() + + await expect(pending).resolves.toBe(true) + }) + + it('pty:hasPty answers SSH-owned ids from their provider without waiting on the local swap', async () => { + const barrier = makeDeferred() + const sshHasPty = vi.fn((id: string) => id === 'ssh:ssh-1@@pty-2') + registerSshPtyProvider('ssh-1', { hasPty: sshHasPty } as never) + registerWithStartupBarrier(barrier.promise) + + await expect(handlers.get('pty:hasPty')!(null, { id: 'ssh:ssh-1@@pty-2' })).resolves.toBe(true) + expect(sshHasPty).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') + }) + + it('runtime controller hasPty answers null, not false, while the local provider swap is in flight', async () => { + const barrier = makeDeferred() + const controller = installRuntimeControllerWithBarrier(barrier.promise) + + // Pre-fix: the pre-swap LocalPtyProvider's ptyProcesses.has() answers a + // confident false for a daemon-owned id. terminal.list then records an + // observed absence (verdict forgotten) instead of unverifiable. + expect(controller.hasPty('daemon-restored-pty')).toBe(null) + + installDaemonTestProvider({ hasPty: (id: string) => id === 'daemon-restored-pty' }) + barrier.resolve() + + await vi.waitFor(() => { + expect(controller.hasPty('daemon-restored-pty')).toBe(true) + }) + }) + + it('runtime controller hasPty never answers a paired-runtime handle from the local registry', () => { + // No startup barrier: the remote-handle guard must hold on its own, not + // ride on the swap-window gate. Same routing hazard the async probe and + // pty:hasPty already guard — no locally routed provider can + // authoritatively answer for a remote host's PTY, so remote-scoped ids + // stay unknown, never absent. + let controller: { hasPty: (ptyId: string) => boolean | null } | undefined + registerPtyHandlers( + mainWindow as never, + { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } as never + ) + + expect(controller?.hasPty('remote:environment@@pty-1')).toBe(null) + }) + + it('runtime controller hasPty answers SSH-owned ids without waiting on the local swap', () => { + const barrier = makeDeferred() + const sshHasPty = vi.fn((id: string) => id === 'ssh-live-pty') + registerSshPtyProvider('ssh-1', { hasPty: sshHasPty } as never) + ptyOwnership.set('ssh-live-pty', 'ssh-1') + try { + const controller = installRuntimeControllerWithBarrier(barrier.promise) + + expect(controller.hasPty('ssh-live-pty')).toBe(true) + expect(sshHasPty).toHaveBeenCalledWith('ssh-live-pty') + } finally { + ptyOwnership.delete('ssh-live-pty') + } + }) + + it('pty:inspectProcess defers a restored daemon id until the provider swap lands instead of answering from the non-owning provider', async () => { + const barrier = makeDeferred() + registerWithStartupBarrier(barrier.promise) + + const pending = Promise.resolve( + handlers.get('pty:inspectProcess')!(null, { id: 'daemon-restored-pty' }) + ) + let settled = false + void pending.then(() => { + settled = true + }) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + // Pre-fix this has already resolved — the pre-swap LocalPtyProvider was + // consulted about a PTY it does not own. Its non-ownership happens to read + // as unavailable today only because the inspection funnel consults hasPty + // before the provider's own inspection; completion-sensitive evidence must + // come from the post-swap owner, not from that internal ordering. + expect(settled).toBe(false) + + installDaemonTestProvider({ + hasPty: (id: string) => id === 'daemon-restored-pty', + inspectProcess: vi.fn(async () => ({ + foregroundProcess: 'codex', + hasChildProcesses: true + })) + }) + barrier.resolve() + + await expect(pending).resolves.toEqual({ + foregroundProcess: 'codex', + hasChildProcesses: true + }) + }) + + it('pty:inspectProcess answers SSH-owned ids from their provider without waiting on the local swap', async () => { + const barrier = makeDeferred() + const sshInspect = vi.fn(async () => ({ + foregroundProcess: 'ssh-codex', + hasChildProcesses: true + })) + registerSshPtyProvider('ssh-1', { + hasPty: (id: string) => id === 'ssh:ssh-1@@pty-2', + inspectProcess: sshInspect + } as never) + registerWithStartupBarrier(barrier.promise) + + await expect( + handlers.get('pty:inspectProcess')!(null, { id: 'ssh:ssh-1@@pty-2' }) + ).resolves.toEqual({ foregroundProcess: 'ssh-codex', hasChildProcesses: true }) + expect(sshInspect).toHaveBeenCalledWith('ssh:ssh-1@@pty-2') + }) + + it('keeps the in-process provider authoritative when no startup barrier is configured', async () => { + // Headless/orcad installs the daemon before registerPtyHandlers and passes + // no barrier; the installed provider is then the sole owner (#12393) and + // its false stays an observed absence. + let controller: { hasPty: (ptyId: string) => boolean | null } | undefined + registerPtyHandlers( + mainWindow as never, + { + setPtyController: vi.fn((next) => { + controller = next + }), + registerPty: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } as never + ) + + expect(controller?.hasPty('never-spawned-pty')).toBe(false) + await expect(handlers.get('pty:hasPty')!(null, { id: 'never-spawned-pty' })).resolves.toBe( + false + ) + // The sole owner's inspection answer stays immediate too: with no swap in + // flight there is no window in which its word could be fabricated. + await expect( + handlers.get('pty:inspectProcess')!(null, { id: 'never-spawned-pty' }) + ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, unavailable: true }) + }) +}) diff --git a/src/main/ipc/pty/agent-session-write-refusal-report.ts b/src/main/ipc/pty/agent-session-write-refusal-report.ts new file mode 100644 index 00000000000..488e58825e6 --- /dev/null +++ b/src/main/ipc/pty/agent-session-write-refusal-report.ts @@ -0,0 +1,20 @@ +import type { BrowserWindow } from 'electron' +import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' + +// Why: a lease refusal is never a silent drop — it rides the existing write-unavailable channel +// with an additive field, so old renderers keep their current behavior and new ones can name the +// owner. See docs/reference/remote-wire-compatibility.md. +export function reportAgentSessionWriteRefusal( + mainWindow: BrowserWindow, + id: string, + refusal: AgentSessionPtyWriteRefusal +): void { + if ( + mainWindow.isDestroyed() || + (typeof mainWindow.webContents.isDestroyed === 'function' && + mainWindow.webContents.isDestroyed()) + ) { + return + } + mainWindow.webContents.send('pty:writeUnavailable', { id, agentSessionRefusal: refusal }) +} diff --git a/src/main/ipc/pty/ipc/inspect.ts b/src/main/ipc/pty/ipc/inspect.ts index 96c84b45c74..9f99d1e099c 100644 --- a/src/main/ipc/pty/ipc/inspect.ts +++ b/src/main/ipc/pty/ipc/inspect.ts @@ -29,6 +29,16 @@ export function installPtyInspectIpcHandlers(deps: { const ipcMain = getPtyIpc() const { getLocalPtyProviderStartupPromise } = deps + // Why: wait for daemon startup before selecting the local provider for an id + // the swap may re-own (#7742); ids owned by an SSH connection never wait. + // renderer-kill.ts inlines this — pty:kill's listener teardown is + // ordering-sensitive and must not gain even a no-barrier microtask. + const awaitSwapWindow = async (id: string): Promise => { + await getLocalPtyProviderStartupPromise( + ptyOwnership.get(id) ?? parseAppSshPtyId(id)?.connectionId + ) + } + ipcMain.handle('pty:listSessions', async (): Promise => { const deduped = new Map() const admission = new PtyProcessListAdmission() @@ -117,6 +127,10 @@ export function installPtyInspectIpcHandlers(deps: { // authoritative dead. That is a fabricated answer about another host's PTY. return null } + // Why: the pre-swap LocalPtyProvider does not own restored daemon ids, and + // its "no PTY" is exactly the false the renderer reconciler is allowed to + // close panes on. + await awaitSwapWindow(args.id) const ownedConnectionId = ptyOwnership.get(args.id) const parsedSshId = ownedConnectionId === undefined ? parseAppSshPtyId(args.id) : null const provider = parsedSshId @@ -155,12 +169,14 @@ export function installPtyInspectIpcHandlers(deps: { ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) => { // Why: same routing hazard as pty:hasPty — an unroutable id must read as unavailable, not as a local-provider answer or a raised IPC error. - if ( - typeof args?.id !== 'string' || - !args.id || - args.id.startsWith('remote:') || - !hasPtyProviderForInspection(args.id) - ) { + if (typeof args?.id !== 'string' || !args.id || args.id.startsWith('remote:')) { + return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } + } + // Why: the pre-swap LocalPtyProvider does not own restored daemon ids, so + // nothing it reports about one is an observation; the post-swap owner must + // answer completion-sensitive inspection. + await awaitSwapWindow(args.id) + if (!hasPtyProviderForInspection(args.id)) { return { foregroundProcess: null, hasChildProcesses: false, unavailable: true as const } } return inspectPtyProviderProcessForRenderer(getProviderForPty(args.id), args.id) diff --git a/src/main/ipc/pty/ipc/write-input.ts b/src/main/ipc/pty/ipc/write-input.ts index fbfc09d1d4d..f27604dc9f8 100644 --- a/src/main/ipc/pty/ipc/write-input.ts +++ b/src/main/ipc/pty/ipc/write-input.ts @@ -2,10 +2,15 @@ import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } fro import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import type { IPtyProvider } from '../../../providers/types' import { isPtyWriteUnavailableError } from '../../../providers/pty-write-unavailable-error' +import { + agentSessionPtyWriteGate, + type AgentSessionPtyWriteAdmittance +} from '../../../runtime/agent-session-pty-write-gate' import { isTerminalInputTooLargeWithDeferredMeasurement, iterateTerminalInputChunks } from '../../../../shared/terminal-input' +import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' import { ptyOwnership } from '../provider/ownership-state' import { tryGetProviderForPty } from '../provider/registry' import { @@ -57,10 +62,34 @@ export function createPtyWriteInput(deps: { mainWindow.webContents.send('pty:writeUnavailable', { id }) } + /** Single lease check for every byte-entry point this module owns. */ + const admitAgentSessionPtyWrite = (id: string): AgentSessionPtyWriteAdmittance | null => { + const admission = agentSessionPtyWriteGate.admit(id) + if (admission.admitted) { + return { sessionId: admission.sessionId, runtimeFence: admission.runtimeFence } + } + reportAgentSessionWriteRefusal(mainWindow, id, admission.refusal) + return null + } + + /** Re-check after a yield: the lease can move to another owner between chunks. */ + const readmitAgentSessionPtyWrite = ( + id: string, + admitted: AgentSessionPtyWriteAdmittance + ): boolean => { + const admission = agentSessionPtyWriteGate.readmit(id, admitted) + if (admission.admitted) { + return true + } + reportAgentSessionWriteRefusal(mainWindow, id, admission.refusal) + return false + } + const writePtyProviderInputWithinLimit = ( provider: IPtyProvider, id: string, - data: string + data: string, + admitted: AgentSessionPtyWriteAdmittance ): boolean | Promise => { const chunks = iterateTerminalInputChunks(data) const first = chunks.next() @@ -73,21 +102,27 @@ export function createPtyWriteInput(deps: { provider.write(id, first.value) return true } - return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value) + return writePtyProviderInputChunks(provider, id, chunks, first.value, second.value, admitted) } const writePtyProviderInput = ( provider: IPtyProvider, id: string, - data: string + data: string, + admitted: AgentSessionPtyWriteAdmittance ): boolean | Promise => { try { const tooLarge = isTerminalInputTooLargeWithDeferredMeasurement(data) if (typeof tooLarge === 'boolean') { - return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data) + return tooLarge ? false : writePtyProviderInputWithinLimit(provider, id, data, admitted) } return tooLarge - .then((result) => (result ? false : writePtyProviderInputWithinLimit(provider, id, data))) + .then((result) => { + if (result || !readmitAgentSessionPtyWrite(id, admitted)) { + return false + } + return writePtyProviderInputWithinLimit(provider, id, data, admitted) + }) .catch((error) => { reportUnavailablePtyWrite(id, error) return false @@ -103,12 +138,18 @@ export function createPtyWriteInput(deps: { id: string, chunks: Iterator, firstChunk: string, - secondChunk: string + secondChunk: string, + admitted: AgentSessionPtyWriteAdmittance ): Promise => { try { let chunk: IteratorResult = { done: false, value: firstChunk } let nextChunk: IteratorResult = { done: false, value: secondChunk } + let first = true while (!chunk.done) { + if (!first && !readmitAgentSessionPtyWrite(id, admitted)) { + return false + } + first = false provider.write(id, chunk.value) if (!nextChunk.done) { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -152,6 +193,10 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } + const admitted = admitAgentSessionPtyWrite(args.id) + if (!admitted) { + return false + } const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined if (!provider) { return false @@ -163,7 +208,7 @@ export function createPtyWriteInput(deps: { if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + return writePtyProviderInput(provider, args.id, args.data, admitted) } catch { return false } @@ -173,6 +218,10 @@ export function createPtyWriteInput(deps: { if (runtime?.getDriver(args.id).kind === 'mobile') { return false } + const admitted = admitAgentSessionPtyWrite(args.id) + if (!admitted) { + return false + } // Why: the ack infers Ctrl+C/Escape reached the local PTY; SSH providers are fire-and-forget relay notifications and can't truthfully acknowledge yet. if (ptyOwnership.get(args.id) !== null) { return false @@ -188,7 +237,7 @@ export function createPtyWriteInput(deps: { if (visibleRendererPtys.has(args.id)) { clearHiddenRendererResizeOutput(args.id) } - return writePtyProviderInput(provider, args.id, args.data) + return writePtyProviderInput(provider, args.id, args.data, admitted) } catch { return false } diff --git a/src/main/ipc/pty/runtime/controller.ts b/src/main/ipc/pty/runtime/controller.ts index ff5a0e5ccd0..f74896776a7 100644 --- a/src/main/ipc/pty/runtime/controller.ts +++ b/src/main/ipc/pty/runtime/controller.ts @@ -27,6 +27,7 @@ import { resizePtyFromRuntimeController, serializeProviderBufferFromRuntimeController, waitForRendererSerializerFromRuntimeController, + writePtyAgentSessionProofFromRuntimeController, writePtyFromRuntimeController } from './operations' @@ -41,7 +42,9 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi }, adoptStablePane, spawn: async (args) => spawnPtyFromRuntimeController(deps, args), - write: (ptyId, data) => writePtyFromRuntimeController(ptyId, data), + write: (ptyId, data) => writePtyFromRuntimeController(deps, ptyId, data), + writeAgentSessionProof: (ptyId, data, authority) => + writePtyAgentSessionProofFromRuntimeController(ptyId, data, authority), probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId), // Why: subscriber-driven ingestion for daemon sessions no renderer pane // ever attached. Local daemon sessions only — SSH panes have their own @@ -60,7 +63,7 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi getCwd: (ptyId) => getCwdFromRuntimeController(ptyId), hasChildProcesses: (ptyId) => hasChildProcessesFromRuntimeController(ptyId), clearBuffer: (ptyId) => clearBufferFromRuntimeController(deps, ptyId), - hasPty: (ptyId) => hasPtyFromRuntimeController(ptyId), + hasPty: (ptyId) => hasPtyFromRuntimeController(deps, ptyId), listProcesses: (connectionId, opts) => listProcessesFromRuntimeController(deps, connectionId, opts), listProcessesWithHostScope: (opts) => diff --git a/src/main/ipc/pty/runtime/operations.ts b/src/main/ipc/pty/runtime/operations.ts index bf3ca74af25..00db3380309 100644 --- a/src/main/ipc/pty/runtime/operations.ts +++ b/src/main/ipc/pty/runtime/operations.ts @@ -18,8 +18,21 @@ import { } from '../provider/registry' import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspection' import type { PtyRuntimeControllerDeps } from './controller-deps' +import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate' +import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report' -export function writePtyFromRuntimeController(ptyId: string, data: string): boolean { +export function writePtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string, + data: string +): boolean { + // Why: the backstop for every runtime write path — query replies, followups, deliveries — + // so a caller that forgets the typed gate still cannot reach a provider. + const admission = agentSessionPtyWriteGate.admit(ptyId) + if (!admission.admitted) { + reportAgentSessionWriteRefusal(deps.mainWindow, ptyId, admission.refusal) + return false + } try { getProviderForPty(ptyId).write(ptyId, data) return true @@ -28,6 +41,21 @@ export function writePtyFromRuntimeController(ptyId: string, data: string): bool } } +export function writePtyAgentSessionProofFromRuntimeController( + ptyId: string, + data: string, + authority: { sessionId: string; spawnToken: string } +): boolean { + if (!agentSessionPtyWriteGate.admitProof(ptyId, authority)) { + return false + } + try { + return getProviderForPty(ptyId).write(ptyId, data) !== false + } catch { + return false + } +} + export async function probePtyLivenessFromRuntimeController( deps: PtyRuntimeControllerDeps, ptyId: string @@ -152,8 +180,35 @@ export async function clearBufferFromRuntimeController( } } -export function hasPtyFromRuntimeController(ptyId: string): boolean | null { +const settledLocalPtyProviderStartups = new WeakSet>() +const watchedLocalPtyProviderStartups = new WeakSet>() + +export function hasPtyFromRuntimeController( + deps: PtyRuntimeControllerDeps, + ptyId: string +): boolean | null { try { + // Why: no locally routed provider can authoritatively answer for a + // remote host's PTY, so remote-scoped ids stay unknown, never absent. + if (ptyId.startsWith('remote:')) { + return null + } + const connectionId = ptyOwnership.get(ptyId) ?? parseAppSshPtyId(ptyId)?.connectionId + const startupPromise = deps.getLocalPtyProviderStartupPromise(connectionId) + if (startupPromise && !settledLocalPtyProviderStartups.has(startupPromise)) { + // Why: a sync probe cannot wait out the cold-start daemon swap the way + // probePtyLiveness does, and the pre-swap provider's "no PTY" for a + // daemon-restored id is fabricated — answer unverifiable until the swap + // settles (docs/reference/ssh-execution-boundary.md rule 2). + if (!watchedLocalPtyProviderStartups.has(startupPromise)) { + watchedLocalPtyProviderStartups.add(startupPromise) + const markSettled = (): void => { + settledLocalPtyProviderStartups.add(startupPromise) + } + startupPromise.then(markSettled, markSettled) + } + return null + } return getProviderForPty(ptyId).hasPty?.(ptyId) ?? null } catch { return null diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index a18fbb9a1d3..98cad9b25a6 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -215,6 +215,7 @@ export function registerCoreHandlers( registerRuntimeEnvironmentHandlers(store) registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ + ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/ipc/runtime-subscribe-lifecycle.test.ts b/src/main/ipc/runtime-subscribe-lifecycle.test.ts new file mode 100644 index 00000000000..47f9e6afa4d --- /dev/null +++ b/src/main/ipc/runtime-subscribe-lifecycle.test.ts @@ -0,0 +1,315 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type StreamRecord = { + connectionId: string | undefined + emit: (response: string) => void + settled: boolean + signal: AbortSignal + subscriptionId: string +} + +const { handlers, listeners, streams, unaryConnections } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>(), + streams: [] as StreamRecord[], + unaryConnections: [] as (string | undefined)[] +})) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: vi.fn() }, + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }), + removeAllListeners: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('../runtime/rpc/dispatcher', () => ({ + RpcDispatcher: class { + dispatch(_request: unknown, options: { connectionId?: string }): Promise { + unaryConnections.push(options.connectionId) + return Promise.resolve({ ok: true, result: {} }) + } + + dispatchStreaming( + request: { id: string }, + emit: (response: string) => void, + options: { connectionId?: string; signal: AbortSignal } + ): Promise { + const record: StreamRecord = { + connectionId: options.connectionId, + emit, + settled: false, + signal: options.signal, + subscriptionId: request.id + } + streams.push(record) + return new Promise((resolve) => { + options.signal.addEventListener('abort', () => { + record.settled = true + resolve() + }) + }) + } + } +})) + +import { registerRuntimeHandlers } from './runtime' + +type SenderHarness = { + destroy: () => void + emitDidNavigate: () => void + emitRenderProcessGone: () => void + listenerCount: (eventName: string) => number + sender: { + id: number + isDestroyed: () => boolean + mainFrame: object + on: (eventName: string, callback: () => void) => void + once: (eventName: string, callback: () => void) => void + send: ReturnType + } +} + +function createSender(id: number): SenderHarness { + const senderListeners = new Map void)[]>() + let destroyed = false + const add = (eventName: string, callback: () => void): void => { + const callbacks = senderListeners.get(eventName) ?? [] + callbacks.push(callback) + senderListeners.set(eventName, callbacks) + } + const fire = (eventName: string): void => { + for (const callback of senderListeners.get(eventName) ?? []) { + callback() + } + } + const mainFrame = {} + return { + destroy: () => { + destroyed = true + fire('destroyed') + }, + emitDidNavigate: () => fire('did-navigate'), + emitRenderProcessGone: () => fire('render-process-gone'), + listenerCount: (eventName) => (senderListeners.get(eventName) ?? []).length, + sender: { + id, + isDestroyed: () => destroyed, + mainFrame, + on: add, + once: (eventName, callback) => { + const wrapped = (): void => { + const callbacks = senderListeners.get(eventName) ?? [] + senderListeners.set( + eventName, + callbacks.filter((candidate) => candidate !== wrapped) + ) + callback() + } + add(eventName, wrapped) + }, + send: vi.fn() + } + } +} + +async function call(sender: SenderHarness['sender']): Promise { + const handler = handlers.get('runtime:call') + if (!handler) { + throw new Error('runtime:call handler not registered') + } + await handler({ sender, senderFrame: sender.mainFrame }, { method: 'agentSession.hold' }) +} + +function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const handler = handlers.get('runtime:subscribe') + if (!handler) { + throw new Error('runtime:subscribe handler not registered') + } + handler( + { sender, senderFrame: sender.mainFrame }, + { subscriptionId, method: 'agentSession.watch' } + ) +} + +function streamFor(subscriptionId: string): StreamRecord { + const record = streams.findLast((entry) => entry.subscriptionId === subscriptionId) + if (!record) { + throw new Error(`no stream dispatched for ${subscriptionId}`) + } + return record +} + +const FRAME = JSON.stringify({ ok: true, result: { seq: 1 } }) + +describe('runtime:subscribe renderer lifecycle cleanup', () => { + beforeEach(() => { + handlers.clear() + listeners.clear() + streams.length = 0 + unaryConnections.length = 0 + registerRuntimeHandlers({ cleanupSubscriptionsForConnection: vi.fn() } as never) + }) + + it('aborts a live stream once its sender commits a navigation', () => { + const harness = createSender(1) + subscribe(harness.sender, 'sub-reload') + const stream = streamFor('sub-reload') + + stream.emit(FRAME) + expect(harness.sender.send).toHaveBeenCalledTimes(1) + expect(harness.sender.send).toHaveBeenCalledWith('runtime:subscription:sub-reload', { + ok: true, + result: { seq: 1 } + }) + + harness.emitDidNavigate() + expect(stream.signal.aborted).toBe(true) + expect(stream.settled).toBe(true) + + stream.emit(FRAME) + expect(harness.sender.send).toHaveBeenCalledTimes(1) + }) + + it('aborts a live stream when the renderer process dies without destruction', () => { + const harness = createSender(2) + subscribe(harness.sender, 'sub-crash') + const stream = streamFor('sub-crash') + + harness.emitRenderProcessGone() + expect(stream.signal.aborted).toBe(true) + + stream.emit(FRAME) + expect(harness.sender.send).not.toHaveBeenCalled() + }) + + it('still aborts on sender destruction', () => { + const harness = createSender(3) + subscribe(harness.sender, 'sub-destroyed') + const stream = streamFor('sub-destroyed') + + harness.destroy() + expect(stream.signal.aborted).toBe(true) + + stream.emit(FRAME) + expect(harness.sender.send).not.toHaveBeenCalled() + }) + + it('scopes navigation cleanup to the navigating sender', () => { + const navigating = createSender(4) + const surviving = createSender(5) + subscribe(navigating.sender, 'sub-navigating') + subscribe(surviving.sender, 'sub-surviving') + + navigating.emitDidNavigate() + + expect(streamFor('sub-navigating').signal.aborted).toBe(true) + expect(streamFor('sub-surviving').signal.aborted).toBe(false) + streamFor('sub-surviving').emit(FRAME) + expect(surviving.sender.send).toHaveBeenCalledTimes(1) + }) + + it('streams to a fresh post-reload subscription while the orphan stays dead', async () => { + const harness = createSender(6) + subscribe(harness.sender, 'sub-old') + const orphan = streamFor('sub-old') + + harness.emitDidNavigate() + expect(orphan.signal.aborted).toBe(true) + // The settled stream's cleanup runs off the dispatch promise. + await Promise.resolve() + + subscribe(harness.sender, 'sub-new') + const fresh = streamFor('sub-new') + expect(fresh.signal.aborted).toBe(false) + + fresh.emit(FRAME) + orphan.emit(FRAME) + expect(harness.sender.send).toHaveBeenCalledTimes(1) + expect(harness.sender.send).toHaveBeenCalledWith('runtime:subscription:sub-new', { + ok: true, + result: { seq: 1 } + }) + + // Lifecycle listeners are registered once per sender, not once per subscription. + expect(harness.listenerCount('did-navigate')).toBe(1) + expect(harness.listenerCount('render-process-gone')).toBe(1) + }) + + it('aborts every stream of a sender with several live subscriptions', () => { + const harness = createSender(7) + subscribe(harness.sender, 'sub-a') + subscribe(harness.sender, 'sub-b') + + harness.emitDidNavigate() + + expect(streamFor('sub-a').signal.aborted).toBe(true) + expect(streamFor('sub-b').signal.aborted).toBe(true) + }) + + it('keeps explicit unsubscribe working', () => { + const harness = createSender(8) + subscribe(harness.sender, 'sub-explicit') + const stream = streamFor('sub-explicit') + + const unsubscribe = listeners.get('runtime:unsubscribe') + if (!unsubscribe) { + throw new Error('runtime:unsubscribe listener not registered') + } + unsubscribe({ sender: harness.sender }, { subscriptionId: 'sub-explicit' }) + + expect(stream.signal.aborted).toBe(true) + }) + + it('scopes colliding subscription ids and unsubscribe to their sender', () => { + const firstHarness = createSender(9) + const secondHarness = createSender(10) + subscribe(firstHarness.sender, 'sub-collision') + const first = streams.at(-1)! + subscribe(secondHarness.sender, 'sub-collision') + const second = streams.at(-1)! + + expect(first.signal.aborted).toBe(false) + expect(second.signal.aborted).toBe(false) + + const unsubscribe = listeners.get('runtime:unsubscribe') + if (!unsubscribe) { + throw new Error('runtime:unsubscribe listener not registered') + } + unsubscribe({ sender: firstHarness.sender }, { subscriptionId: 'sub-collision' }) + + expect(first.signal.aborted).toBe(true) + expect(second.signal.aborted).toBe(false) + }) + + it('rotates unary document ownership before sweeping navigation replacements', async () => { + const cleanupSubscriptionsForConnection = vi.fn() + handlers.clear() + listeners.clear() + streams.length = 0 + unaryConnections.length = 0 + registerRuntimeHandlers({ cleanupSubscriptionsForConnection } as never) + const harness = createSender(11) + + await call(harness.sender) + const retired = unaryConnections[0] + harness.emitDidNavigate() + await call(harness.sender) + const replacement = unaryConnections[1] + + expect(retired).toMatch(/^desktop-renderer:11:/) + expect(replacement).toMatch(/^desktop-renderer:11:/) + expect(replacement).not.toBe(retired) + expect(cleanupSubscriptionsForConnection).toHaveBeenCalledWith(retired) + expect(cleanupSubscriptionsForConnection).not.toHaveBeenCalledWith(replacement) + + harness.emitRenderProcessGone() + expect(cleanupSubscriptionsForConnection).toHaveBeenCalledWith(replacement) + }) +}) diff --git a/src/main/ipc/runtime.test.ts b/src/main/ipc/runtime.test.ts index a05dc315bec..dc7f8a01cd7 100644 --- a/src/main/ipc/runtime.test.ts +++ b/src/main/ipc/runtime.test.ts @@ -1,10 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, removeHandlerMock, fromWebContentsMock } = vi.hoisted(() => ({ - handleMock: vi.fn(), - removeHandlerMock: vi.fn(), - fromWebContentsMock: vi.fn() -})) +const { handleMock, onMock, removeAllListenersMock, removeHandlerMock, fromWebContentsMock } = + vi.hoisted(() => ({ + handleMock: vi.fn(), + onMock: vi.fn(), + removeAllListenersMock: vi.fn(), + removeHandlerMock: vi.fn(), + fromWebContentsMock: vi.fn() + })) vi.mock('electron', () => ({ BrowserWindow: { @@ -12,6 +15,8 @@ vi.mock('electron', () => ({ }, ipcMain: { handle: handleMock, + on: onMock, + removeAllListeners: removeAllListenersMock, removeHandler: removeHandlerMock } })) @@ -19,9 +24,24 @@ vi.mock('electron', () => ({ import { registerRuntimeHandlers } from './runtime' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' +function runtimeCallEvent() { + const mainFrame = {} + return { + sender: { + id: 1, + mainFrame, + on: vi.fn(), + once: vi.fn() + }, + senderFrame: mainFrame + } +} + describe('registerRuntimeHandlers', () => { beforeEach(() => { handleMock.mockReset() + onMock.mockReset() + removeAllListenersMock.mockReset() removeHandlerMock.mockReset() fromWebContentsMock.mockReset() }) @@ -107,7 +127,7 @@ describe('registerRuntimeHandlers', () => { expect(callRegistration).toBeTruthy() const handler = callRegistration![1] - const result = await handler({ sender: {} }, { method: 'status.get' }) + const result = await handler(runtimeCallEvent(), { method: 'status.get' }) expect(result).toMatchObject({ ok: true, @@ -130,7 +150,7 @@ describe('registerRuntimeHandlers', () => { expect(callRegistration).toBeTruthy() const handler = callRegistration![1] - const result = await handler({ sender: {} }, { method: 'projectGroup.list' }) + const result = await handler(runtimeCallEvent(), { method: 'projectGroup.list' }) expect(result).toMatchObject({ ok: true, @@ -139,6 +159,14 @@ describe('registerRuntimeHandlers', () => { }) }) + it('registers local runtime streaming subscription lifecycle handlers', () => { + registerRuntimeHandlers({ syncWindowGraph: vi.fn(), getStatus: vi.fn() } as never) + + expect(handleMock.mock.calls.some(([channel]) => channel === 'runtime:subscribe')).toBe(true) + expect(onMock.mock.calls.some(([channel]) => channel === 'runtime:unsubscribe')).toBe(true) + expect(removeAllListenersMock).toHaveBeenCalledWith('runtime:unsubscribe') + }) + it('deduplicates retries while a terminal fit restore is still pending', async () => { const finishRestoreByPtyId = new Map void>() const reclaimTerminalForDesktop = vi.fn( diff --git a/src/main/ipc/runtime.ts b/src/main/ipc/runtime.ts index 6e62a4f5ea5..901d14bfce6 100644 --- a/src/main/ipc/runtime.ts +++ b/src/main/ipc/runtime.ts @@ -10,7 +10,10 @@ import type { import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' import { TERMINAL_FIT_RESTORE_DEADLINE_MS } from '../../shared/terminal-fit-restore-deadline' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' +import { DesktopRuntimeSenderLifecycle } from './desktop-runtime-sender-lifecycle' function boundTerminalFitRestore(pending: Promise): Promise { let timer: ReturnType | undefined @@ -23,9 +26,12 @@ function boundTerminalFitRestore(pending: Promise): Promise { export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { const pendingTerminalFitRestores = new Map>() + const desktopSenders = new DesktopRuntimeSenderLifecycle(runtime) ipcMain.removeHandler('runtime:syncWindowGraph') ipcMain.removeHandler('runtime:getStatus') ipcMain.removeHandler('runtime:call') + ipcMain.removeHandler('runtime:subscribe') + ipcMain.removeAllListeners('runtime:unsubscribe') ipcMain.handle( 'runtime:syncWindowGraph', @@ -53,18 +59,82 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void { ipcMain.handle( 'runtime:call', async ( - _event, + event, args: { method: string; params?: unknown } ): Promise> => { - return (await new RpcDispatcher({ runtime }).dispatch({ - id: 'desktop-ipc', - authToken: 'desktop-ipc', - method: args.method, - params: args.params - })) as RuntimeRpcResponse + if (event.senderFrame !== event.sender.mainFrame) { + throw new Error('Runtime RPC call must originate from the current main frame') + } + return (await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch( + { + id: 'desktop-ipc', + authToken: 'desktop-ipc', + method: args.method, + params: args.params + }, + { + clientId: 'desktop-renderer', + clientKind: 'runtime', + connectionId: desktopSenders.connectionIdFor(event.sender), + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + )) as RuntimeRpcResponse } ) + ipcMain.handle( + 'runtime:subscribe', + ( + event, + args: { subscriptionId: string; method: string; params?: unknown } + ): { subscribed: boolean } => { + if (event.senderFrame !== event.sender.mainFrame) { + throw new Error('Runtime subscription must originate from the current main frame') + } + const senderSubscriptions = desktopSenders.subscriptionsFor(event.sender) + const connectionId = desktopSenders.connectionIdFor(event.sender) + const previous = senderSubscriptions.get(args.subscriptionId) + previous?.abort() + const controller = new AbortController() + senderSubscriptions.set(args.subscriptionId, controller) + const channel = `runtime:subscription:${args.subscriptionId}` + const stop = (): void => { + if (senderSubscriptions.get(args.subscriptionId) === controller) { + senderSubscriptions.delete(args.subscriptionId) + } + } + void new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) + .dispatchStreaming( + { + id: args.subscriptionId, + authToken: 'desktop-ipc', + method: args.method, + params: args.params + }, + (response) => { + if (!controller.signal.aborted && !event.sender.isDestroyed()) { + event.sender.send(channel, JSON.parse(response) as RuntimeRpcResponse) + } + }, + { + signal: controller.signal, + clientId: 'desktop-renderer', + clientKind: 'runtime', + connectionId, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + } + ) + .finally(stop) + return { subscribed: true } + } + ) + + ipcMain.on('runtime:unsubscribe', (event, args: { subscriptionId: string }) => { + const senderSubscriptions = desktopSenders.existingSubscriptionsFor(event.sender) + senderSubscriptions?.get(args.subscriptionId)?.abort() + senderSubscriptions?.delete(args.subscriptionId) + }) + ipcMain.removeHandler('runtime:getTerminalFitOverrides') ipcMain.handle( 'runtime:getTerminalFitOverrides', diff --git a/src/main/ipc/worktrees-listing-fallback-rows.test.ts b/src/main/ipc/worktrees-listing-fallback-rows.test.ts index c33f9e14ef8..6df6a6b0eb5 100644 --- a/src/main/ipc/worktrees-listing-fallback-rows.test.ts +++ b/src/main/ipc/worktrees-listing-fallback-rows.test.ts @@ -144,6 +144,13 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('fails closed when the renderer has not selected a repo yet', async () => { + const listed = await handlers['worktrees:list'](null, undefined) + + expect(listed).toEqual([]) + expect(store.getRepo).not.toHaveBeenCalled() + }) + it('returns reconstructed rows when an SSH provider is unavailable', async () => { const repo = { id: 'repo-ssh', diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index f2388b3ce43..ac98130b5eb 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -149,8 +149,13 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo return getRetiredNameRegistryForRepo(store, repo, store.getRepos(), store.getSettings()) }) - ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => { - const repo = store.getRepo(args.repoId) + ipcMain.handle('worktrees:list', async (_event, args: { repoId: string } | undefined) => { + // Renderer startup can race repo selection; malformed requests must fail closed, not crash the handler. + const repoId = typeof args?.repoId === 'string' ? args.repoId : '' + if (!repoId) { + return [] + } + const repo = store.getRepo(repoId) if (!repo) { return [] } diff --git a/src/main/native-chat/agent-session-journal/journal-blob-store.ts b/src/main/native-chat/agent-session-journal/journal-blob-store.ts new file mode 100644 index 00000000000..0bd921e1df7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-blob-store.ts @@ -0,0 +1,92 @@ +// Content-addressed store for the remainder of a bounded payload. +// +// Blobs are named by their sha256, so writing the same output twice costs one +// file and re-import is idempotent. They live beside the journal (host-side +// per-workspace state, never inside the user's working tree) and share the +// epoch's retention: compaction prunes every blob no retained row references. + +import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write' + +const BLOB_DIR = 'blobs' +const DIGEST_PATTERN = /^[0-9a-f]{64}$/ + +/** A digest arrives back from a row on disk, so it is untrusted by the time it + * reaches the filesystem: anything but a bare sha256 could escape the store. */ +function blobPath(journalDir: string, digest: string): string | null { + return DIGEST_PATTERN.test(digest) ? join(journalDir, BLOB_DIR, digest) : null +} + +/** Persist `payload` under its digest. Returns the digest so the caller can + * stamp it on the row it is about to append. */ +export async function putJournalBlob( + journalDir: string, + digest: string, + payload: string +): Promise { + const target = blobPath(journalDir, digest) + if (!target) { + throw new Error('refusing to write a journal blob under a name that is not a sha256 digest') + } + // Content addressing makes a rewrite pointless: identical digest, identical bytes. + if (await pathExists(target)) { + return digest + } + await mkdir(join(journalDir, BLOB_DIR), { recursive: true }) + await writeFileDurable(durableWriteTempPath(target), target, payload) + return digest +} + +export async function readJournalBlob(journalDir: string, digest: string): Promise { + const source = blobPath(journalDir, digest) + if (!source) { + return null + } + try { + return await readFile(source, 'utf-8') + } catch { + return null + } +} + +/** Remove a blob written speculatively for a row that was rejected. */ +export async function removeJournalBlob(journalDir: string, digest: string): Promise { + const target = blobPath(journalDir, digest) + if (target) { + await rm(target, { force: true }) + } +} + +/** Drop every blob outside `retained`. Called from compaction, under the + * current lease fence, after the snapshot is durable — so a crash mid-prune + * leaves extra blobs rather than dangling references. */ +export async function pruneJournalBlobs( + journalDir: string, + retained: ReadonlySet +): Promise { + let removed = 0 + let names: string[] + try { + names = await readdir(join(journalDir, BLOB_DIR)) + } catch { + return 0 + } + for (const name of names) { + if (retained.has(name)) { + continue + } + await rm(join(journalDir, BLOB_DIR, name), { force: true }).catch(() => {}) + removed += 1 + } + return removed +} + +async function pathExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-compaction.ts b/src/main/native-chat/agent-session-journal/journal-compaction.ts new file mode 100644 index 00000000000..6533ceda5ab --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-compaction.ts @@ -0,0 +1,172 @@ +// Retention and compaction. +// +// The snapshot carries the retained tail with it, so publishing both is ONE +// atomic write and there is no window where the folded state exists without the +// rows a reconnecting client still needs. Truncating the log afterwards is +// idempotent: a crash before it leaves the log a superset of the tail. +// +// The retained tail must cover the longest reconnect window Orca supports, or a +// client that was merely asleep gets a full snapshot reload instead of a resume. + +import { + blobDigestsInBody, + referencedBlobDigests, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import { pruneJournalBlobs } from './journal-blob-store' +import { + rewriteJournalLog, + writeJournalSnapshotFile, + type JournalSnapshotFile +} from './journal-log-file' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { JournalRow } from './journal-row-schema' +import { AgentSessionJournalError } from './journal-write-guards' + +export type JournalCompactionPolicy = { + /** Always keep at least this many rows, however old they are. */ + minTailRows: number + /** Keep every row observed within this window. */ + retainTailMs: number + /** + * `window` honours `retainTailMs` outright. `budget-pressure` lets it yield: + * the alternative is refusing the user's writes until the window ages out, + * and the tail is only a resume optimization — compaction folds every shed + * row into the snapshot before truncating the log, so a client that loses + * its resume point reloads instead of losing conversation. Defaults to + * `window`. + */ + retention?: 'window' | 'budget-pressure' +} + +/** Two hours of tail comfortably covers a phone that slept through a commute, + * which is the longest reconnect Orca resumes rather than reloads. */ +export const DEFAULT_JOURNAL_COMPACTION_POLICY: JournalCompactionPolicy = { + minTailRows: 512, + retainTailMs: 2 * 60 * 60 * 1000 +} + +export type JournalCompactionResult = { + tailRows: JournalRow[] + compactedThrough: number + oldestSequence: number +} + +export async function compactJournal(input: { + journalDir: string + state: JournalReducerState + tailRows: readonly JournalRow[] + policy?: JournalCompactionPolicy + now: number + maxSessionBytes: number +}): Promise { + const policy = input.policy ?? DEFAULT_JOURNAL_COMPACTION_POLICY + const retained = retainTail(input.tailRows, policy, input.now) + const rendered = renderJournalState(input.state) + const compactedThrough = input.state.lastSequence + + const snapshot: JournalSnapshotFile = { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: input.state.epoch, + compactedThrough, + highestFence: input.state.highestFence, + items: rendered.items, + submissions: rendered.submissions, + receipts: [...input.state.receipts.values()].map((receipt) => ({ + clientMessageId: receipt.clientMessageId, + providerItemId: receipt.providerItemId, + epoch: receipt.cursor.epoch, + sequence: receipt.cursor.sequence, + acceptedAt: receipt.acceptedAt + })), + aliases: [...input.state.aliases.entries()].map(([providerItemId, itemId]) => ({ + providerItemId, + itemId + })), + tombstones: [...input.state.tombstones.entries()].map(([itemId, revision]) => ({ + itemId, + revision + })), + tail: retained + } + + const snapshotBytes = Buffer.byteLength(JSON.stringify(snapshot), 'utf8') + if (snapshotBytes > input.maxSessionBytes) { + throw new AgentSessionJournalError( + 'journal_bound_exceeded', + `agent-session journal snapshot reached its ${input.maxSessionBytes}-byte bound` + ) + } + + await writeJournalSnapshotFile(input.journalDir, snapshot) + await rewriteJournalLog(input.journalDir, retained) + // Blobs are pruned last: a crash before this leaks bytes, whereas pruning + // first would strand a snapshot pointing at a payload that no longer exists. + const retainedDigests = referencedBlobDigests(input.state) + for (const row of retained) { + if (row.kind === 'item') { + blobDigestsInBody(row.body, retainedDigests) + } + } + await pruneJournalBlobs(input.journalDir, retainedDigests) + + return { + tailRows: retained, + compactedThrough, + oldestSequence: retained[0]?.seq ?? compactedThrough + 1 + } +} + +function retainTail( + rows: readonly JournalRow[], + policy: JournalCompactionPolicy, + now: number +): JournalRow[] { + if (rows.length <= policy.minTailRows) { + return [...rows] + } + const floor = now - policy.retainTailMs + const byAge = rows.findIndex((row) => row.ts >= floor) + const byCount = rows.length - policy.minTailRows + const start = byAge === -1 ? byCount : Math.min(byAge, byCount) + if (policy.retention !== 'budget-pressure') { + return rows.slice(start) + } + // Halve rather than empty: the newer half keeps live clients resuming, and + // shedding at least one row guarantees the append that triggered this makes + // progress instead of latching the session read-only. + return rows.slice(Math.max(start, Math.ceil(rows.length / 2))) +} + +/** Only when the retention window would actually drop rows: inside it, + * compaction rewrites an identical log, and doing that per append is a full + * state serialization on the hot path. */ +export function journalTailIsReadyToCompact( + tailRows: readonly JournalRow[], + policy: JournalCompactionPolicy, + now: number +): boolean { + if (tailRows.length <= policy.minTailRows * 2) { + return false + } + return (tailRows[0]?.ts ?? now) < now - policy.retainTailMs +} + +/** The policy an append falls back to when the size bound would otherwise + * refuse it: both floors that normally protect the tail step aside. */ +export function budgetPressurePolicy(policy: JournalCompactionPolicy): JournalCompactionPolicy { + return { ...policy, minTailRows: 0, retention: 'budget-pressure' } +} + +/** Budget pressure may need to shed rows before the ordinary batching threshold. + * Pass a `budget-pressure` policy, or a tail wholly inside the retention + * window answers false and the size bound refuses every append until it ages + * out — two hours of a session the user cannot write to. */ +export function journalTailCanShedRows( + tailRows: readonly JournalRow[], + policy: JournalCompactionPolicy, + now: number +): boolean { + return retainTail(tailRows, policy, now).length < tailRows.length +} diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts b/src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts new file mode 100644 index 00000000000..429881e274a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-corruption-quarantine.ts @@ -0,0 +1,72 @@ +// Corruption never deletes history. A journal that cannot be read end to end +// keeps its intact prefix live and moves the unreadable remainder aside, so the +// bytes stay on disk for inspection instead of being rebuilt into an empty epoch. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { + JOURNAL_SNAPSHOT_FILE, + quarantineJournalRemainder, + readJournalLog, + rewriteJournalLog +} from './journal-log-file' +import type { JournalRow } from './journal-row-schema' + +/** Keep the readable prefix and set the unreadable suffix aside. */ +export async function quarantineCorruptSuffix( + journalDir: string, + retainedRows: readonly JournalRow[], + remainder: string | undefined +): Promise { + if (remainder) { + await quarantineJournalRemainder(journalDir, remainder) + } + await rewriteJournalLog(journalDir, retainedRows) +} + +/** Copy everything aside before a read-only journal is rebuilt under a newer + * schema: those rows are unreadable to THIS build, not worthless. The + * snapshot is preserved as raw bytes — a future-version snapshot does not + * parse under this build's schema, and its bytes must survive verbatim. */ +export async function quarantineUnreadableSchema(journalDir: string): Promise { + const snapshot = await readSnapshotBytes(journalDir) + const log = await readJournalLog(journalDir) + const preserved = [ + snapshot ?? '', + log.rows.map((row) => JSON.stringify(row)).join('\n'), + log.remainder ?? '' + ] + .filter(Boolean) + .join('\n') + if (preserved) { + await quarantineJournalRemainder(journalDir, preserved) + } +} + +async function readSnapshotBytes(journalDir: string): Promise { + try { + return await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } +} + +/** The disclosure row for lines that failed to parse. Skipped lines are lost + * rows; counting them silently is the drop this exists to prevent. */ +export function malformedRowsDisclosure(count: number): { + identity: { provider: 'orca'; clientMessageId: string } + body: { kind: 'status'; text: string } +} { + const plural = count === 1 ? '' : 's' + return { + // One stable identity, so a reopen upserts the same row instead of adding one. + identity: { provider: 'orca', clientMessageId: 'journal-malformed-lines' }, + body: { + kind: 'status', + text: `${count} journal line${plural} could not be read and ${count === 1 ? 'was' : 'were'} skipped` + } + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts new file mode 100644 index 00000000000..0f8d85d34fc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -0,0 +1,382 @@ +// The crash boundary: the host wrote a submission row, dispatched, and died +// before it learned whether the provider took the message. Replay must reconcile +// without duplicating the user's message and without losing it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { digestPayload } from './journal-payload-bounds' +import { + reconcileSubmissions, + type ProviderHistoryItem, + type ProviderHistoryWindow +} from './journal-submission-reconciler' +import { openAgentSessionJournal } from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const TURN_ID = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' + +const ACCEPTED_IDENTITY: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: TURN_ID, + ordinal: 0 +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +async function open() { + return openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) +} + +/** A Codex `userMessage` history item; `clientId` is the echoed client message id. */ +function history(input: { + itemId: string + clientId: string | null + text: string + ordinal: number +}): ProviderHistoryItem { + return { + providerItemId: input.itemId, + clientMessageId: input.clientId, + payloadFingerprint: digestPayload(input.text), + identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal } + } +} + +function window( + items: ProviderHistoryItem[], + overrides: Partial = {} +): ProviderHistoryWindow { + return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-')) + clock = 1_000 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('crash between provider accept and journal commit', () => { + it('reconciles the echo into the existing bubble instead of duplicating it', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + // Host dies here: the provider accepted, but no dispatch row was written. + + const restarted = await open() + expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + + const [outcome] = reconcileSubmissions({ + submissions: restarted.submissions(), + history: window([ + history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) + + if (outcome?.outcome !== 'accepted') { + throw new Error('expected the echoed submission to reconcile as accepted') + } + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: outcome.identity, + fence: 2, + recovered: true + }) + // The provider's own copy of the message arrives next, under the identity + // reconciliation adopted. It must land in the bubble the user already sees. + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { fence: 2 }) + + const items = restarted.snapshot().items + expect(items).toHaveLength(1) + expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) + expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(outcome.identity)) + }) + + it('reports a rejected submission as never delivered, and never re-sends it', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('never landed'), + body: userMessage('never landed'), + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + const [outcome] = reconcileSubmissions({ + submissions: restarted.submissions(), + history: window([]) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'rejected', + reason: 'not_delivered' + }) + + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + reason: 'not_delivered', + fence: 2, + recovered: true + }) + // The bubble survives with an explicit terminal state — the message is not + // silently retried and not silently dropped. + expect(restarted.snapshot().items).toHaveLength(1) + expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') + expect(restarted.receiptFor('cm_1')).toBeNull() + }) + + it('survives replay of an already-reconciled journal without changing the answer', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('once'), + body: userMessage('once'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) + const settled = journal.snapshot() + + const reopened = await open() + expect(reopened.snapshot()).toEqual(settled) + expect(reopened.pendingSubmissions()).toHaveLength(0) + expect( + reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) }) + ).toEqual([]) + }) + + it('keeps the receipt after the row that minted it was compacted away', async () => { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + compaction: { minTailRows: 1, retainTailMs: 0 } + }) + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('kept'), + body: userMessage('kept'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) + await journal.compact() + + const reopened = await open() + expect(reopened.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(ACCEPTED_IDENTITY)) + }) +}) + +describe('reconciliation matching', () => { + const submissions = [ + { + clientMessageId: 'cm_1', + fence: 1, + payloadFingerprint: digestPayload('same text'), + dispatchState: 'unknown' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + }, + { + clientMessageId: 'cm_2', + fence: 1, + payloadFingerprint: digestPayload('same text'), + dispatchState: 'unknown' as const, + providerItemId: null, + reason: null, + submittedAt: 2, + resolvedAt: null + } + ] + + it('matches each submission to its own echo when the provider carries client ids', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }), + history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }), + expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' }) + ]) + }) + + it('refuses to guess between two identical payloads with no id to tell them apart', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), + history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 }) + ]) + }) + expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) + expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) + }) + + it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), + history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) + }) + + it('never matches on text alone when the fingerprint disagrees', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([ + { + providerItemId: 'item-1', + clientMessageId: null, + payloadFingerprint: digestPayload('different payload, same rendered text'), + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } + } + ]) + }) + expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) + }) + + it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), + expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) + ]) + }) + + it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => { + const [outcome] = reconcileSubmissions({ + submissions: [{ ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }], + history: window([ + // The provider renumbered its raw id; the identity-derived key did not move. + history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' }) + }) + + it('never hands one provider item to two submissions', () => { + const outcomes = reconcileSubmissions({ + submissions: [ + { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }, + submissions[1]! + ], + // One item, wanted by both passes: cm_1 adopted its key, cm_2 is echoed on + // it. Adopting it twice would render the same provider message twice. + history: window([ + history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), + expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) + ]) + }) + + it('stays unknown when the history boundary cannot be trusted', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([], { boundaryConsistent: false }) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'unknown', + reason: 'history_boundary_inconsistent' + }) + }) + + it('stays unknown while a turn is still running', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([], { turnInFlight: true }) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'unknown', + reason: 'turn_in_flight' + }) + }) + + it('leaves settled submissions alone', () => { + expect( + reconcileSubmissions({ + submissions: [ + { + ...submissions[0]!, + dispatchState: 'accepted', + providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) + }, + { ...submissions[1]!, dispatchState: 'rejected' } + ], + history: window([]) + }) + ).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.test.ts b/src/main/native-chat/agent-session-journal/journal-cursor.test.ts new file mode 100644 index 00000000000..704973e7ebd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-cursor.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' +import { + findSequenceGap, + resolveJournalResume, + sameJournalCursor, + type JournalCursorRange +} from './journal-cursor' + +const RANGE: JournalCursorRange = { epoch: 'e1', lastSequence: 40, oldestSequence: 11 } + +describe('resolveJournalResume', () => { + it('resumes from a cursor inside the retained tail', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 25 })).toEqual({ + ok: true, + afterSequence: 25 + }) + }) + + it('resumes from a cursor sitting exactly on the compaction boundary', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 10 })).toEqual({ + ok: true, + afterSequence: 10 + }) + }) + + it('resumes from a cursor at the tip with nothing to send', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 40 })).toEqual({ + ok: true, + afterSequence: 40 + }) + }) + + it('forces a reload when the epoch rolled', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e0', sequence: 25 })).toEqual({ + ok: false, + reset: 'epoch_changed' + }) + }) + + it('forces a reload when the epoch rolled even at a sequence this epoch also holds', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e0', sequence: 40 }).ok).toBe(false) + }) + + it('forces a reload when the client is ahead of the journal', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 41 })).toEqual({ + ok: false, + reset: 'cursor_ahead' + }) + }) + + it('forces a reload when the cursor fell below the compaction floor', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 9 })).toEqual({ + ok: false, + reset: 'cursor_compacted' + }) + }) + + it('resumes a fresh client from sequence 0 on an uncompacted journal', () => { + const fresh: JournalCursorRange = { epoch: 'e1', lastSequence: 3, oldestSequence: 1 } + expect(resolveJournalResume(fresh, { epoch: 'e1', sequence: 0 })).toEqual({ + ok: true, + afterSequence: 0 + }) + }) + + it('rejects sequence 0 once the journal has compacted past it', () => { + expect(resolveJournalResume(RANGE, { epoch: 'e1', sequence: 0 })).toEqual({ + ok: false, + reset: 'cursor_compacted' + }) + }) +}) + +describe('sameJournalCursor', () => { + it('requires both the epoch and the sequence to match', () => { + expect(sameJournalCursor({ epoch: 'e1', sequence: 3 }, { epoch: 'e1', sequence: 3 })).toBe(true) + expect(sameJournalCursor({ epoch: 'e1', sequence: 3 }, { epoch: 'e2', sequence: 3 })).toBe( + false + ) + expect(sameJournalCursor({ epoch: 'e1', sequence: 3 }, { epoch: 'e1', sequence: 4 })).toBe( + false + ) + }) +}) + +describe('findSequenceGap', () => { + it('accepts a contiguous run', () => { + expect(findSequenceGap([7, 8, 9], 7)).toBeNull() + }) + + it('accepts an empty run', () => { + expect(findSequenceGap([], 7)).toBeNull() + }) + + it('reports the first missing sequence', () => { + expect(findSequenceGap([7, 8, 10], 7)).toEqual({ gapAt: 9 }) + }) + + it('reports a run that starts above the expected first sequence', () => { + expect(findSequenceGap([8, 9], 7)).toEqual({ gapAt: 7 }) + }) + + it('reports a reused sequence', () => { + expect(findSequenceGap([7, 7, 8], 7)).toEqual({ gapAt: 8 }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.ts b/src/main/native-chat/agent-session-journal/journal-cursor.ts new file mode 100644 index 00000000000..bdec8adfd19 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-cursor.ts @@ -0,0 +1,98 @@ +// Epoch-qualified cursor resume. +// +// A cursor is only meaningful inside its epoch: rollover invalidates every one +// of them, and the client takes a clean snapshot reload rather than being +// handed rows that belong to a rebuilt timeline. + +import type { + AgentJournalCursor, + AgentJournalResetReason +} from '../../../shared/agent-session-journal-types' +import type { JournalReadSince } from './journal-store-contracts' +import type { JournalRow } from './journal-row-schema' + +export type JournalCursorRange = { + epoch: string + /** Sequence of the newest appended row; 0 when the epoch is empty. */ + lastSequence: number + /** Lowest sequence still individually replayable after compaction. */ + oldestSequence: number +} + +export type JournalResume = + /** Replay rows with `seq > afterSequence`. */ + { ok: true; afterSequence: number } | { ok: false; reset: AgentJournalResetReason } + +/** Total order over cursors within one epoch; cross-epoch comparison is + * meaningless, so callers must check the epoch first. */ +export function sameJournalCursor(a: AgentJournalCursor, b: AgentJournalCursor): boolean { + return a.epoch === b.epoch && a.sequence === b.sequence +} + +/** + * Decide whether a reconnecting client can resume from `cursor`. + * + * An epoch mismatch, a cursor ahead of the journal (the client saw rows this + * host no longer has — a rolled-back or rebuilt prefix), and a cursor below the + * compaction floor all resolve to a snapshot reload. None of them is recoverable + * by shipping a partial batch. + */ +export function resolveJournalResume( + range: JournalCursorRange, + cursor: AgentJournalCursor +): JournalResume { + if (cursor.epoch !== range.epoch) { + return { ok: false, reset: 'epoch_changed' } + } + if (cursor.sequence > range.lastSequence) { + return { ok: false, reset: 'cursor_ahead' } + } + // `oldestSequence - 1` is the compaction boundary: a client sitting exactly on + // it has seen everything folded into the snapshot and can take the tail. + if (cursor.sequence < range.oldestSequence - 1) { + return { ok: false, reset: 'cursor_compacted' } + } + return { ok: true, afterSequence: cursor.sequence } +} + +/** Contiguity check over a replayed row sequence. A gap means the journal lost + * a row; the reader must treat it as corrupt and force epoch rollover rather + * than render a partial timeline. */ +export function findSequenceGap( + sequences: readonly number[], + expectedFirst: number +): { gapAt: number } | null { + let expected = expectedFirst + for (const sequence of sequences) { + if (sequence !== expected) { + return { gapAt: expected } + } + expected += 1 + } + return null +} + +/** Rows appended after `cursor`, or the reset a client must take instead. A + * read-only journal always resets: this build cannot vouch for what it holds. */ +export function readJournalSince( + source: { + state: { epoch: string; lastSequence: number; oldestSequence: number } + tailRows: readonly JournalRow[] + readOnly: boolean + }, + cursor: AgentJournalCursor, + currentCursor: () => AgentJournalCursor +): JournalReadSince { + if (source.readOnly) { + return { ok: false, reset: 'schema_unreadable' } + } + const resume = resolveJournalResume(source.state, cursor) + if (!resume.ok) { + return { ok: false, reset: resume.reset } + } + return { + ok: true, + rows: source.tailRows.filter((row) => row.seq > resume.afterSequence), + cursor: currentCursor() + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts new file mode 100644 index 00000000000..8cc12c5ee66 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -0,0 +1,103 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { copyFileDurable } from '../../durable-file-write' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { compactJournal, type JournalCompactionPolicy } from './journal-compaction' +import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE, appendJournalRows } from './journal-log-file' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' +import { journalRowByteLength } from './journal-row-schema' +import { assertJournalFence, type JournalAppendBudget } from './journal-write-guards' +import type { JournalLoad } from './journal-open' + +export type JournalReplacementItem = { + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + observedAt?: number +} + +export async function replaceJournalEpoch(input: { + journalDir: string + identity: AgentSessionJournalIdentity + reason: AgentJournalEpochReason + fence: number + items: readonly JournalReplacementItem[] + budget: JournalAppendBudget + compaction: JournalCompactionPolicy + now: () => number + mintEpoch: () => string + onSnapshotPublished: (loaded: JournalLoad) => void +}): Promise { + const stagingDir = await mkdtemp(join(input.journalDir, '.epoch-replacement-')) + try { + const epoch = input.mintEpoch() + const state = createJournalReducerState(input.identity.sessionId, epoch) + const epochRow: JournalRow = { + kind: 'epoch', + reason: input.reason, + providerHandle: input.identity.providerHandle, + ...journalRowBase(epoch, 1, input.fence, input.now()) + } + const rows: JournalRow[] = [epochRow] + applyJournalRow(state, epochRow) + let sizeBytes = journalRowByteLength(epochRow) + await appendJournalRows(stagingDir, [epochRow]) + + for (const item of input.items) { + const appendTime = input.now() + const row = buildJournalItemRow({ + state, + identity: item.identity, + body: item.body, + seq: state.lastSequence + 1, + fence: input.fence, + ts: item.observedAt ?? appendTime + }) + assertJournalFence(row.fence, state.highestFence) + input.budget.assert(row, appendTime, sizeBytes) + await appendJournalRows(stagingDir, [row]) + applyJournalRow(state, row) + rows.push(row) + sizeBytes += journalRowByteLength(row) + } + + const compacted = await compactJournal({ + journalDir: stagingDir, + state, + tailRows: rows, + policy: input.compaction, + now: input.now(), + maxSessionBytes: input.budget.maxSessionBytes + }) + await publishPreparedFile(stagingDir, input.journalDir, JOURNAL_SNAPSHOT_FILE) + state.oldestSequence = compacted.oldestSequence + input.onSnapshotPublished({ + state, + tailRows: compacted.tailRows, + compactedThrough: compacted.compactedThrough, + readOnly: false, + corrupt: false, + malformedRows: 0, + sizeBytes: compacted.tailRows.reduce((total, row) => total + journalRowByteLength(row), 0) + }) + await publishPreparedFile(stagingDir, input.journalDir, JOURNAL_LOG_FILE) + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } +} + +async function publishPreparedFile( + stagingDir: string, + journalDir: string, + fileName: string +): Promise { + const copied = await copyFileDurable(join(stagingDir, fileName), join(journalDir, fileName)) + if (!copied) { + throw new Error(`prepared journal file disappeared before publish: ${fileName}`) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts new file mode 100644 index 00000000000..0cf3f9d6cda --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -0,0 +1,56 @@ +// Opening a new epoch. +// +// The snapshot is what names the live epoch, so it is published BEFORE the log +// is reset. A crash mid-rollover therefore leaves stale-epoch rows behind the +// new snapshot, which `loadJournal` drops — the reverse order would leave a +// journal whose log no longer matches any epoch anyone can name. + +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' +import { compactJournal } from './journal-compaction' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' +import { journalRowByteLength } from './journal-row-schema' +import type { JournalLoad } from './journal-open' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' + +export async function publishNewEpoch(input: { + journalDir: string + sessionId: string + providerHandle: AgentSessionProviderHandle + epoch: string + reason: AgentJournalEpochReason + fence: number + now: number +}): Promise { + const row: JournalRow = { + kind: 'epoch', + reason: input.reason, + providerHandle: input.providerHandle, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: input.epoch, + seq: 1, + fence: input.fence, + ts: input.now + } + const state = createJournalReducerState(input.sessionId, input.epoch) + await compactJournal({ + journalDir: input.journalDir, + state, + tailRows: [row], + policy: { minTailRows: 1, retainTailMs: Number.POSITIVE_INFINITY }, + now: input.now, + maxSessionBytes: DEFAULT_JOURNAL_PAYLOAD_LIMITS.maxSessionBytes + }) + applyJournalRow(state, row) + state.oldestSequence = 1 + return { + state, + tailRows: [row], + compactedThrough: 0, + readOnly: false, + corrupt: false, + malformedRows: 0, + sizeBytes: journalRowByteLength(row) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts new file mode 100644 index 00000000000..0e05fc3fc93 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-identity.test.ts @@ -0,0 +1,299 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' + +// Fixtures mirror the shapes the providers actually emit: a resumed Codex +// thread renumbers its items positionally, and a forked Claude session copies +// history with the ORIGINAL item uuids. + +const THREAD = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' +const TURN_A = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' +const TURN_B = '019fd8cb-1c40-7a02-9f31-0f1a54b7c211' + +describe('codex identity survives positional renumbering', () => { + it('keys the same logical item identically before and after a resume', () => { + // First run: the app server labels the second turn's user message item-3. + const live: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: TURN_B, + ordinal: 0 + } + // After `thread/resume` the same item comes back as item-1 of the replayed + // history. Ordinal-within-turn is unchanged, so the key is unchanged. + const resumed: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: TURN_B, + ordinal: 0 + } + expect(agentJournalItemKey(resumed)).toBe(agentJournalItemKey(live)) + }) + + it('separates two items inside one turn', () => { + const first = agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: TURN_A, + ordinal: 0 + }) + const second = agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: TURN_A, + ordinal: 1 + }) + expect(first).not.toBe(second) + }) + + it('disambiguates a fork that copies turns keeping their original turn ids', () => { + const original = agentJournalItemKey({ + provider: 'codex', + threadId: THREAD, + turnId: TURN_A, + ordinal: 0 + }) + const forked = agentJournalItemKey({ + provider: 'codex', + threadId: '019fd900-77aa-7c19-8bd0-2b3c4d5e6f70', + turnId: TURN_A, + ordinal: 0 + }) + expect(forked).not.toBe(original) + }) +}) + +describe('claude identity', () => { + it('keys on (session id, uuid)', () => { + const key = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + expect(key).toBe( + 'claude:29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88:c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + ) + }) + + it('reconciles a forked transcript onto the parent item rather than duplicating it', () => { + // `--fork-session` mints a new session id but copies records verbatim, so a + // copied record still names the session it was written in. + const parent = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + const copiedIntoFork = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + expect(copiedIntoFork).toBe(parent) + }) + + it('keeps a genuinely new item in the fork distinct', () => { + const parent = agentJournalItemKey({ + provider: 'claude', + sessionId: '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88', + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + const minted = agentJournalItemKey({ + provider: 'claude', + sessionId: '7b1e5d33-0f28-42ac-8d59-9a4c6e2b1f70', + uuid: 'f8b2c9a1-3e77-4c60-b1a2-5d0e7f4a9c33' + }) + expect(minted).not.toBe(parent) + }) +}) + +describe('key encoding', () => { + it('cannot be collided by a separator inside an id', () => { + const a = agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'a:b', + recordId: 'c' + }) + const b = agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: 'a', + recordId: 'b:c' + }) + expect(a).not.toBe(b) + }) + + it('separates the provider namespaces', () => { + const orca = agentJournalItemKey({ provider: 'orca', clientMessageId: 'x' }) + const legacy = agentJournalItemKey({ + provider: 'legacy', + agent: 'claude', + sessionId: 'x', + recordId: 'x' + }) + expect(orca).not.toBe(legacy) + }) + + it('derives the submission slot from the same function the reducer uses', () => { + expect(agentJournalSubmissionKey('cm_42')).toBe( + agentJournalItemKey({ provider: 'orca', clientMessageId: 'cm_42' }) + ) + }) +}) + +describe('bounded component domain separation', () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const keyFor = (turnId: string) => + agentJournalItemKey({ provider: 'codex', threadId: THREAD, turnId, ordinal: 0 }) + + it('separates an oversized component from the raw string matching its digest form', () => { + const oversizedKey = keyFor(oversizedTurnId) + expect(oversizedKey).toBe(`codex:${THREAD}:${digestFormMimic}:0`) + expect(oversizedKey).not.toBe(keyFor(digestFormMimic)) + }) + + it('keeps both persisted key spellings stable through parse and re-key', () => { + for (const turnId of [oversizedTurnId, digestFormMimic]) { + const key = keyFor(turnId) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + } + expect(parseAgentJournalItemKey(keyFor(digestFormMimic))).toEqual({ + provider: 'codex', + threadId: THREAD, + turnId: digestFormMimic, + ordinal: 0 + }) + }) +}) + +describe('oversized identity bounding on Unicode boundaries', () => { + // 39 UTF-16 units of ASCII put the astral character's surrogate pair across + // the 40-unit diagnostic-head cut. Pre-fix the head ended in a lone high + // surrogate and `encodeURIComponent` threw `URIError: URI malformed`. + const STRADDLING = `${'a'.repeat(39)}😀${'x'.repeat(1100)}` + const straddlingIdentity: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: STRADDLING, + ordinal: 0 + } + + it('keys a valid astral id whose character straddles the head cut', () => { + expect(() => agentJournalItemKey(straddlingIdentity)).not.toThrow() + expect(boundJournalKeyComponent(STRADDLING).length).toBeLessThan( + MAX_JOURNAL_KEY_COMPONENT_CHARS + ) + }) + + it('stays deterministic and collision-resistant for straddling ids', () => { + expect(agentJournalItemKey(straddlingIdentity)).toBe(agentJournalItemKey(straddlingIdentity)) + // A different oversized value sharing the same head still gets its own key. + expect(agentJournalItemKey({ ...straddlingIdentity, turnId: `${STRADDLING}y` })).not.toBe( + agentJournalItemKey(straddlingIdentity) + ) + }) + + it('re-deriving from the parsed bounded key is a fixed point', () => { + const key = agentJournalItemKey(straddlingIdentity) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + }) + + it('keeps an astral character that lands entirely inside the head', () => { + const inside = `${'a'.repeat(38)}😀${'x'.repeat(1100)}` + const bounded = boundJournalKeyComponent(inside) + expect(bounded.startsWith(`${'a'.repeat(38)}😀~orca-oversized~`)).toBe(true) + expect(() => encodeURIComponent(bounded)).not.toThrow() + }) + + it('drops only the split surrogate from the straddling head', () => { + const bounded = boundJournalKeyComponent(STRADDLING) + expect(bounded.startsWith(`${'a'.repeat(39)}~orca-oversized~`)).toBe(true) + expect(() => encodeURIComponent(bounded)).not.toThrow() + }) +}) + +describe('ill-formed UTF-16 identity totality', () => { + // JSON.parse admits lone surrogates, so any JSON string is a legal component; + // pre-fix these threw `URIError: URI malformed` in `encodeURIComponent`. + const LONE_HIGH = '\ud83d' + const LONE_LOW = '\ude00' + + it('keys a lone-high-surrogate id without throwing, deterministically', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: LONE_HIGH, + uuid: 'u-1' + } + expect(() => agentJournalItemKey(identity)).not.toThrow() + expect(agentJournalItemKey(identity)).toBe(agentJournalItemKey(identity)) + }) + + it('keys an oversized id carrying a lone low surrogate inside the head', () => { + const identity: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: `${LONE_LOW}${'x'.repeat(1100)}`, + ordinal: 0 + } + expect(() => agentJournalItemKey(identity)).not.toThrow() + }) + + it('keys an oversized id with a lone high surrogate away from the head cut', () => { + const identity: AgentJournalItemIdentity = { + provider: 'codex', + threadId: THREAD, + turnId: `${'a'.repeat(10)}${LONE_HIGH}${'b'.repeat(1100)}`, + ordinal: 0 + } + expect(() => agentJournalItemKey(identity)).not.toThrow() + }) + + it('cannot collide an ill-formed id with its replacement-character spelling', () => { + const keyFor = (sessionId: string) => + agentJournalItemKey({ provider: 'claude', sessionId, uuid: 'u-1' }) + expect(keyFor(LONE_HIGH)).not.toBe(keyFor('�')) + expect(keyFor(LONE_HIGH)).not.toBe(keyFor(LONE_LOW)) + const oversized = (head: string) => `${head}${'x'.repeat(1100)}` + expect(keyFor(oversized(LONE_HIGH))).not.toBe(keyFor(oversized('�'))) + }) + + it('re-deriving from a parsed ill-formed key is a fixed point', () => { + const key = agentJournalItemKey({ provider: 'claude', sessionId: LONE_HIGH, uuid: 'u-1' }) + const parsed = parseAgentJournalItemKey(key) + expect(parsed).not.toBeNull() + expect(agentJournalItemKey(parsed as AgentJournalItemIdentity)).toBe(key) + }) + + it('leaves well-formed short components verbatim', () => { + expect(boundJournalKeyComponent('turn-1')).toBe('turn-1') + expect(boundJournalKeyComponent('😀 café')).toBe('😀 café') + }) +}) + +describe('malformed persisted keys decode to null instead of throwing', () => { + it('returns null for malformed percent sequences', () => { + for (const key of ['%', 'claude:%E0%A4%A:u-1', 'codex:a:b:1%ZZ', 'claude:%ED%A0%BD:u-1']) { + expect(parseAgentJournalItemKey(key)).toBeNull() + } + }) + + it('still round-trips well-formed keys containing the delimiter and spaces', () => { + const identity: AgentJournalItemIdentity = { + provider: 'claude', + sessionId: 's:1', + uuid: 'u 1' + } + expect(parseAgentJournalItemKey(agentJournalItemKey(identity))).toEqual(identity) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts b/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts new file mode 100644 index 00000000000..c7903e604d3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-identity.ts @@ -0,0 +1,87 @@ +// Identity anchors for bridge-era transcript lines. +// +// The transcript decoders return a render model, not an identity, so the import +// reads identity from the SAME raw line the decoder consumed rather than +// inferring it from decoded text. +// +// Claude gets its real identity namespace: the project jsonl IS the provider's +// store, and `uuid` survives `--fork-session` unchanged, so a later structured +// session reconciles against these keys directly. +// +// Codex, Grok, and omp get the `legacy` namespace. A Codex rollout file records +// `response_item` ids (`msg_…`, `rs_…`, `ctc_…`) which are a different namespace +// from the app-server's positional `item-N` ordinals, and rollout records carry +// no turn id at all — so a rollout line cannot be expressed as a stable +// `(threadId, turnId, ordinal)` key without guessing. Legacy items are therefore +// import-scoped, and a later structured resume rolls the epoch and rebuilds. + +import type { AgentType } from '../../../shared/agent-status-types' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { NativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' + +export type LegacyIdentityTracker = { + /** Identity for whatever the decoder emits from this raw line. Called for + * every line in file order, including ones the decoder discards. */ + identify(line: string, lineIndex: number): AgentJournalItemIdentity +} + +export function createLegacyIdentityTracker(input: { + transcriptAgent: NativeChatTranscriptAgent + agent: AgentType + sessionId: string +}): LegacyIdentityTracker { + if (input.transcriptAgent === 'claude') { + return { identify: (line, index) => claudeIdentity(line, index, input.agent, input.sessionId) } + } + return { + identify: (line, index) => ({ + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: legacyRecordId(line, index) + }) + } +} + +function claudeIdentity( + line: string, + lineIndex: number, + agent: AgentType, + sessionId: string +): AgentJournalItemIdentity { + const record = parseRecord(line) + const uuid = stringField(record, 'uuid') + if (!uuid) { + return { provider: 'legacy', agent, sessionId, recordId: `#${lineIndex}` } + } + // The record's own session id wins: a forked transcript keeps the original + // item uuids, and pairing them with the fork's id would mint new identities. + return { provider: 'claude', sessionId: stringField(record, 'sessionId') ?? sessionId, uuid } +} + +/** `payload.id` when the record carries one, else the line's position. Position + * is deterministic for a given import of a given file, which is all the legacy + * namespace promises. */ +function legacyRecordId(line: string, lineIndex: number): string { + const record = parseRecord(line) + const payload = record?.payload + const id = stringField(payload, 'id') ?? stringField(record, 'id') ?? stringField(record, 'uuid') + return id ?? `#${lineIndex}` +} + +function parseRecord(line: string): Record | null { + try { + const parsed: unknown = JSON.parse(line) + return parsed && typeof parsed === 'object' ? (parsed as Record) : null + } catch { + return null + } +} + +function stringField(source: unknown, key: string): string | null { + if (!source || typeof source !== 'object') { + return null + } + const value = (source as Record)[key] + return typeof value === 'string' && value ? value : null +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts new file mode 100644 index 00000000000..dba8bcddd28 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -0,0 +1,507 @@ +// Legacy import runs the existing per-agent transcript decoders and keys the +// results by identity read off the same raw lines. Fixtures are shaped like the +// files the providers actually write. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { readJournalBlob } from './journal-blob-store' +import { createLegacyIdentityTracker } from './journal-legacy-identity' +import { + appendLegacyTranscriptMessages, + importLegacyTranscriptIntoJournal +} from './journal-legacy-import' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { openAgentSessionJournal, type AgentSessionJournal } from './journal-store' + +const CLAUDE_SESSION = '29eb22a4-6a5f-4f21-9b0c-1d7f3a2e5c88' +const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function identity(agent: 'claude' | 'codex', sessionId: string): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: 'ws-1', + hostId: 'host-1', + agent, + providerHandle: + agent === 'claude' + ? { kind: 'claude', sessionId, leafUuid: null } + : { kind: 'codex', threadId: sessionId } + } +} + +async function open( + agent: 'claude' | 'codex', + sessionId: string, + overrides: Partial[0]> = {} +): Promise { + return openAgentSessionJournal({ + identity: identity(agent, sessionId), + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +function legacyKey(recordId: string): string { + return agentJournalItemKey({ + provider: 'legacy', + agent: 'codex', + sessionId: CODEX_SESSION, + recordId + }) +} + +async function writeFixture(name: string, lines: unknown[]): Promise { + const path = join(root, name) + await writeFile(path, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf-8') + return path +} + +const CLAUDE_LINES = [ + { type: 'file-history-snapshot', messageId: 'boot', snapshot: {} }, + { + parentUuid: null, + isSidechain: false, + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'add a retry' }] }, + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04', + timestamp: '2026-08-05T10:00:00.000Z', + cwd: '/Users/dev/project', + sessionId: CLAUDE_SESSION, + version: '2.1.220', + gitBranch: 'main' + }, + { + parentUuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04', + isSidechain: false, + type: 'assistant', + requestId: 'req_01', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'On it.' }], + id: 'msg_ignored_in_favour_of_uuid' + }, + uuid: 'b7c9e1f2-8a30-4d55-91ab-6f0e2c4d8b11', + timestamp: '2026-08-05T10:00:04.000Z', + sessionId: CLAUDE_SESSION, + version: '2.1.220' + }, + { + parentUuid: 'b7c9e1f2-8a30-4d55-91ab-6f0e2c4d8b11', + isSidechain: false, + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_01', name: 'Edit', input: { file_path: 'a.ts' } }] + }, + uuid: 'd2f4a6b8-1c02-4e77-83bd-5a9c7e1f3d20', + timestamp: '2026-08-05T10:00:07.000Z', + sessionId: CLAUDE_SESSION + }, + { + parentUuid: 'd2f4a6b8-1c02-4e77-83bd-5a9c7e1f3d20', + isSidechain: false, + isMeta: true, + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_01', content: 'edited 1 file' }] + }, + uuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31', + timestamp: '2026-08-05T10:00:08.000Z', + sessionId: CLAUDE_SESSION + }, + { type: 'last-prompt', leafUuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31' } +] + +// Shapes taken from a real rollout file: `event_msg` records carry no id, and +// `response_item` records do — which is exactly the split the tracker handles. +const CODEX_LINES = [ + { + type: 'session_meta', + timestamp: '2026-08-05T10:00:00.000Z', + payload: { + id: CODEX_SESSION, + session_id: CODEX_SESSION, + cwd: '/Users/dev/project', + originator: 'codex_cli_rs', + cli_version: '0.146.1' + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:01.000Z', + payload: { type: 'task_started', turn_id: '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } + }, + { + type: 'response_item', + timestamp: '2026-08-05T10:00:04.000Z', + payload: { + type: 'reasoning', + id: 'rs_06235749b04250a3016a7404b3a25c8199b882f2f8288fefd0', + summary: [{ type: 'summary_text', text: 'Checking the retry policy.' }] + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:05.000Z', + payload: { type: 'agent_message', message: 'On it.' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:06.000Z', + payload: { type: 'token_count', info: { total_token_usage: { input_tokens: 12 } } } + } +] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-import-')) + clock = 1_000 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('claude import', () => { + it('keys items by (session id, uuid) from the raw record', async () => { + const filePath = await writeFixture('claude.jsonl', CLAUDE_LINES) + const journal = await open('claude', CLAUDE_SESSION) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath } + }) + + expect(result.ok).toBe(true) + expect(journal.snapshot().items.map((entry) => entry.itemId)).toEqual([ + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }), + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'b7c9e1f2-8a30-4d55-91ab-6f0e2c4d8b11' + }), + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'd2f4a6b8-1c02-4e77-83bd-5a9c7e1f3d20' + }), + agentJournalItemKey({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31' + }) + ]) + }) + + it('stays aligned when the decoder drops lines the tracker still walks', async () => { + const filePath = await writeFixture('claude.jsonl', CLAUDE_LINES) + const journal = await open('claude', CLAUDE_SESSION) + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath } + }) + const items = journal.snapshot().items + // The first record is a file-history snapshot the decoder discards; if the + // anchors were misaligned, the first bubble would carry its identity. + expect(items[0]?.body).toEqual({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'add a retry' }] + }) + expect(items[2]?.body).toMatchObject({ kind: 'tool-call', name: 'Edit' }) + }) + + it('is idempotent: a second import reproduces the same timeline in a new epoch', async () => { + const filePath = await writeFixture('claude.jsonl', CLAUDE_LINES) + const journal = await open('claude', CLAUDE_SESSION) + const options = { filePath } + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options + }) + const first = journal.snapshot() + const firstEpoch = journal.epoch + + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options + }) + const second = journal.snapshot() + + expect(journal.epoch).not.toBe(firstEpoch) + expect(second.items.map((entry) => entry.itemId)).toEqual( + first.items.map((entry) => entry.itemId) + ) + expect(second.items.map((entry) => entry.body)).toEqual(first.items.map((entry) => entry.body)) + }) + + it('reconciles a forked transcript onto the parent uuids rather than duplicating them', () => { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'claude', + agent: 'claude', + // The fork's own session id, which is NOT what the copied records carry. + sessionId: '7b1e5d33-0f28-42ac-8d59-9a4c6e2b1f70' + }) + const copied = JSON.stringify(CLAUDE_LINES[1]) + expect(tracker.identify(copied, 0)).toEqual({ + provider: 'claude', + sessionId: CLAUDE_SESSION, + uuid: 'c1a5f0de-2b44-4a11-9f0e-7c2d31b6aa04' + }) + }) +}) + +describe('codex import', () => { + it('upserts live transcript messages without rolling the structured epoch', async () => { + const journal = await open('codex', CODEX_SESSION) + const epoch = journal.epoch + const message = { + id: 'live-tui-message', + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: 'first version' }], + timestamp: 1_800_000_000_000, + source: 'transcript' as const + } + + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 2, + messages: [message] + }) + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 2, + messages: [{ ...message, blocks: [{ type: 'text', text: 'final version' }] }] + }) + + expect(journal.epoch).toBe(epoch) + expect(journal.snapshot().items).toMatchObject([ + { + itemId: legacyKey('live-tui-message'), + revision: 2, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'final version' }] + } + } + ]) + }) + + it('keys rollout records in the import-scoped namespace, not as app-server ordinals', async () => { + const filePath = await writeFixture('rollout.jsonl', CODEX_LINES) + const journal = await open('codex', CODEX_SESSION) + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 1, + options: { filePath } + }) + + // A rollout record with its own id keeps it; an `event_msg` has none, so it + // falls back to its line position — deterministic for a given file. + expect(journal.snapshot().items.map((entry) => entry.itemId)).toEqual([ + legacyKey('#2'), + legacyKey('rs_06235749b04250a3016a7404b3a25c8199b882f2f8288fefd0'), + legacyKey('#4') + ]) + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([ + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, + { + kind: 'message', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Checking the retry policy.' }] + }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'On it.' }] } + ]) + }) + + it('survives a resumed rollout that renumbers positional item ids', () => { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'codex', + agent: 'codex', + sessionId: CODEX_SESSION + }) + const original = tracker.identify(JSON.stringify(CODEX_LINES[3]), 4) + // Same record replayed at a different position in a resumed file. + const replayed = tracker.identify(JSON.stringify(CODEX_LINES[3]), 11) + expect(replayed).toEqual(original) + expect(original).toMatchObject({ + recordId: 'rs_06235749b04250a3016a7404b3a25c8199b882f2f8288fefd0' + }) + }) + + it('falls back to line position only when a record carries no id', () => { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: 'codex', + agent: 'codex', + sessionId: CODEX_SESSION + }) + expect(tracker.identify(JSON.stringify({ type: 'event_msg', payload: {} }), 3)).toEqual({ + provider: 'legacy', + agent: 'codex', + sessionId: CODEX_SESSION, + recordId: '#3' + }) + }) +}) + +describe('payload bounds on import', () => { + it('marks a clipped tool result and parks the remainder in the blob store', async () => { + const output = 'y'.repeat(64 * 1024) + const filePath = await writeFixture('claude-big.jsonl', [ + { + parentUuid: null, + isSidechain: false, + type: 'user', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_9', content: output }] + }, + uuid: 'aa11bb22-cc33-4d44-8e55-6f7788990011', + timestamp: '2026-08-05T10:00:09.000Z', + sessionId: CLAUDE_SESSION + } + ]) + const journal = await open('claude', CLAUDE_SESSION) + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath, limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 1_024 } } + }) + + const item = journal.snapshot().items[0] + expect(item?.body).toMatchObject({ kind: 'tool-call', state: 'completed' }) + const body = item?.body + if (body?.kind !== 'tool-call' || !body.output) { + throw new Error('expected a bounded tool-call output') + } + expect(body.output.truncated).toBe(true) + expect(body.output.byteLength).toBe(64 * 1024) + expect(body.output.head).toHaveLength(1_024) + expect(await readJournalBlob(root, body.output.digest)).toBe(output) + }) +}) + +describe('import failures', () => { + it('keeps the live epoch intact when a staged rebuild runs out of budget', async () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 2_000 } + const journal = await open('codex', CODEX_SESSION, { limits }) + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 1, + messages: [ + { + id: 'durable-prefix', + role: 'assistant', + blocks: [{ type: 'text', text: 'keep me' }], + timestamp: 1_800_000_000_000, + source: 'transcript' + } + ] + }) + const filePath = await writeFixture('oversized-rollout.jsonl', [ + CODEX_LINES[0], + CODEX_LINES[1], + CODEX_LINES[2], + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:03.000Z', + payload: { type: 'agent_message', message: 'x'.repeat(2_000) } + } + ]) + const epoch = journal.epoch + const snapshotPath = join(root, 'snapshot.json') + const logPath = join(root, 'log.jsonl') + const before = { + snapshot: await readFile(snapshotPath, 'utf-8'), + log: await readFile(logPath, 'utf-8') + } + + await expect( + importLegacyTranscriptIntoJournal({ + journal, + agent: 'codex', + sessionId: CODEX_SESSION, + fence: 1, + options: { filePath, limits } + }) + ).rejects.toMatchObject({ code: 'journal_bound_exceeded' }) + expect(journal.epoch).toBe(epoch) + expect(await readFile(snapshotPath, 'utf-8')).toBe(before.snapshot) + expect(await readFile(logPath, 'utf-8')).toBe(before.log) + expect(journal.snapshot().items[0]?.body).toMatchObject({ + kind: 'message', + blocks: [{ type: 'text', text: 'keep me' }] + }) + }) + + it('reports a missing transcript without touching the journal', async () => { + const journal = await open('claude', CLAUDE_SESSION) + const before = journal.epoch + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'claude', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath: join(root, 'missing.jsonl') } + }) + expect(result).toMatchObject({ ok: false }) + expect(journal.epoch).toBe(before) + }) + + it('rejects an agent with no transcript decoder', async () => { + const journal = await open('claude', CLAUDE_SESSION) + const result = await importLegacyTranscriptIntoJournal({ + journal, + agent: 'gemini', + sessionId: CLAUDE_SESSION, + fence: 1, + options: { filePath: join(root, 'claude.jsonl') } + }) + expect(result).toMatchObject({ ok: false }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts new file mode 100644 index 00000000000..f72e34c937a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -0,0 +1,241 @@ +// Hydrating a journal from a bridge-era transcript. +// +// This reuses the existing per-agent transcript decoders verbatim — a second +// parser would drift from the one the live view already uses. The decoders +// return a render model with no identity, so the import wraps them: the wrapper +// reads an identity anchor off the SAME raw line, then delegates the content. +// +// Import always opens a fresh epoch. The imported timeline is a best-effort +// reconstruction with import-scoped identities for most providers, so it must +// never be spliced into a sequence space that a structured session is also +// writing; a later structured resume rolls the epoch again and rebuilds. + +import { createReadStream } from 'node:fs' +import type { AgentType } from '../../../shared/agent-status-types' +import type { + AgentJournalCursor, + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' +import { resolveSessionFilePath, type ResolveSessionFileOptions } from '../session-file-resolver' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine, + decodeOmpTranscriptLine +} from '../transcript-line-decoders' +import { decodeTranscriptStream } from '../transcript-stream-lines' +import { putJournalBlob } from './journal-blob-store' +import { createLegacyIdentityTracker } from './journal-legacy-identity' +import type { JournalReplacementItem } from './journal-epoch-replacement' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + type JournalPayloadLimits +} from './journal-payload-bounds' +import type { AgentSessionJournal } from './journal-store' + +export type LegacyImportOptions = ResolveSessionFileOptions & { + /** Resolve directly to this file, skipping path discovery. */ + filePath?: string + limits?: JournalPayloadLimits + decodedMessageIdentities?: true +} + +export type LegacyImportResult = + | { ok: true; epoch: string; cursor: AgentJournalCursor; imported: number } + | { ok: false; error: string } + +export async function appendLegacyTranscriptMessages(input: { + journal: AgentSessionJournal + agent: AgentType + sessionId: string + fence: number + messages: NativeChatMessage[] +}): Promise { + let appended = 0 + for (const message of input.messages) { + const mapped = legacyItemBody(message, DEFAULT_JOURNAL_PAYLOAD_LIMITS) + for (const blob of mapped.blobs) { + await putJournalBlob(input.journal.directory, blob.digest, blob.payload) + } + await input.journal.appendItem( + { + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: message.id + }, + mapped.body, + { fence: input.fence, observedAt: message.timestamp ?? undefined } + ) + appended += 1 + } + return appended +} + +export async function importLegacyTranscriptIntoJournal(input: { + journal: AgentSessionJournal + agent: AgentType + sessionId: string + fence: number + options?: LegacyImportOptions +}): Promise { + const options = input.options ?? {} + const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS + const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent) + if (!transcriptAgent) { + return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` } + } + const filePath = + options.filePath ?? (await resolveSessionFilePath(input.agent, input.sessionId, options)) + if (!filePath) { + return { ok: false, error: `No transcript found for ${input.agent} session ${input.sessionId}` } + } + + let decoded: { messages: NativeChatMessage[]; identities: AgentJournalItemIdentity[] } + try { + decoded = await decodeWithIdentities({ + filePath, + transcriptAgent, + agent: input.agent, + sessionId: input.sessionId, + decodedMessageIdentities: options.decodedMessageIdentities + }) + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + + const replacement: JournalReplacementItem[] = [] + for (const [index, message] of decoded.messages.entries()) { + const identity = decoded.identities[index] + if (!identity) { + continue + } + const mapped = legacyItemBody(message, limits) + for (const blob of mapped.blobs) { + await putJournalBlob(input.journal.directory, blob.digest, blob.payload) + } + replacement.push({ + identity, + body: mapped.body, + observedAt: message.timestamp ?? undefined + }) + } + const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, replacement) + return { ok: true, epoch: cursor.epoch, cursor, imported: decoded.messages.length } +} + +const TRANSCRIPT_DECODERS = { + claude: decodeClaudeTranscriptLine, + codex: decodeCodexTranscriptLine, + grok: decodeGrokTranscriptLine, + omp: decodeOmpTranscriptLine +} as const + +/** Run the real decoder while recording an identity anchor per emitted message, + * index-aligned with `messages`. */ +async function decodeWithIdentities(input: { + filePath: string + transcriptAgent: keyof typeof TRANSCRIPT_DECODERS + agent: AgentType + sessionId: string + decodedMessageIdentities?: true +}): Promise<{ messages: NativeChatMessage[]; identities: AgentJournalItemIdentity[] }> { + const tracker = createLegacyIdentityTracker({ + transcriptAgent: input.transcriptAgent, + agent: input.agent, + sessionId: input.sessionId + }) + const decode = TRANSCRIPT_DECODERS[input.transcriptAgent] + const identities: AgentJournalItemIdentity[] = [] + let lineIndex = 0 + + const stream = createReadStream(input.filePath, { encoding: 'utf-8' }) + const { messages } = await decodeTranscriptStream( + stream, + input.filePath, + 0, + (line, fallbackId) => { + const trackedIdentity = tracker.identify(line, lineIndex) + lineIndex += 1 + const message = decode(line, fallbackId) + if (message) { + identities.push( + input.decodedMessageIdentities + ? { + provider: 'legacy', + agent: input.agent, + sessionId: input.sessionId, + recordId: message.id + } + : trackedIdentity + ) + } + return message + }, + true + ) + return { messages, identities } +} + +type MappedLegacyItem = { + body: AgentJournalItemBody + blobs: { digest: string; payload: string }[] +} + +/** + * A message whose only content is a tool invocation becomes a tool-call item so + * the reducer renders it as one. Everything else stays a message item with its + * blocks bounded in place. + */ +function legacyItemBody( + message: NativeChatMessage, + limits: JournalPayloadLimits +): MappedLegacyItem { + const only = message.blocks.length === 1 ? message.blocks[0] : undefined + if (only?.type === 'tool-call') { + return { + body: { kind: 'tool-call', name: only.name, input: only.input, state: 'completed' }, + blobs: [] + } + } + if (only?.type === 'tool-result') { + const output = boundPayload(only.output, limits) + return { + body: { + kind: 'tool-call', + name: 'tool-result', + input: null, + state: only.isError ? 'failed' : 'completed', + output + }, + blobs: output.truncated ? [{ digest: output.digest, payload: only.output }] : [] + } + } + return { + body: { + kind: 'message', + role: message.role, + blocks: message.blocks.map((block) => boundBlock(block, limits)) + }, + blobs: [] + } +} + +/** Inline block text keeps only a bounded head plus an explicit marker. No blob + * is written: the marker carries the digest and byte length, and the source + * transcript remains the full copy — a blob here would be unreferenced by the + * render model and pruned at the next compaction. */ +function boundBlock(block: NativeChatBlock, limits: JournalPayloadLimits): NativeChatBlock { + if (block.type === 'text') { + return { ...block, text: boundInlineText(block.text, limits).text } + } + if (block.type === 'tool-result') { + return { ...block, output: boundInlineText(block.output, limits).text } + } + return block +} diff --git a/src/main/native-chat/agent-session-journal/journal-log-file.test.ts b/src/main/native-chat/agent-session-journal/journal-log-file.test.ts new file mode 100644 index 00000000000..ff710db67ed --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-log-file.test.ts @@ -0,0 +1,374 @@ +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { appendJournalRows, JOURNAL_SNAPSHOT_FILE, readJournalSnapshot } from './journal-log-file' +import type { JournalSnapshotFile } from './journal-log-file' +import type { JournalRow } from './journal-row-schema' +import { openAgentSessionJournal } from './journal-store' +import { + projectStructuredAgentSessionStatus, + projectStructuredItemsToNativeChat +} from '../../../shared/structured-agent-session-projection' + +type FakeDirectoryHandle = { sync: ReturnType; close: ReturnType } + +let openDirectoryHook: ((path: unknown, flags: unknown) => FakeDirectoryHandle | undefined) | null = + null + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + open: (async (...args: Parameters) => { + const fake = openDirectoryHook?.(args[0], args[1]) + return fake ?? actual.open(...args) + }) as typeof actual.open + } +}) + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-log-file-')) + openDirectoryHook = null +}) + +afterEach(async () => { + openDirectoryHook = null + await rm(root, { recursive: true, force: true }) +}) + +function validSnapshot(): JournalSnapshotFile { + return { + v: 1, + epoch: 'epoch-A', + compactedThrough: 2, + highestFence: 1, + items: [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + sequence: 2, + observedAt: 1_000 + } + ], + submissions: [], + receipts: [], + aliases: [], + tombstones: [{ itemId: 'codex:thread-1:turn-1:2', revision: 3 }], + tail: [] + } +} + +async function writeSnapshot(snapshot: unknown): Promise { + await writeFile(join(root, JOURNAL_SNAPSHOT_FILE), JSON.stringify(snapshot), 'utf-8') +} + +describe('readJournalSnapshot validation', () => { + it('accepts a well-formed snapshot, with and without the tombstones collection', async () => { + await writeSnapshot(validSnapshot()) + expect((await readJournalSnapshot(root)).status).toBe('valid') + + const { tombstones: _tombstones, ...withoutTombstones } = validSnapshot() + await writeSnapshot(withoutTombstones) + expect((await readJournalSnapshot(root)).status).toBe('valid') + }) + + it('accepts every canonical item kind and a fully-formed submission', async () => { + const snapshot = validSnapshot() + const payload = { head: 'x', byteLength: 4, digest: 'd'.repeat(64), truncated: true } + snapshot.items = [ + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + { + kind: 'tool-call', + name: 'Read', + input: { path: 'a' }, + state: 'completed', + output: payload + }, + { kind: 'diff', path: 'a.ts', patch: payload }, + { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'a', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { + kind: 'question', + question: 'Deploy?', + options: [{ id: 'a', label: 'Yes' }], + freeTextQuestionId: 'q-free', + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 5 } + }, + { + kind: 'status', + text: 'working', + turnLifecycle: { turnId: 'turn-1', state: 'running' }, + providerFrame: { provider: 'codex', kind: 'raw', payload } + } + ].map((body, index) => ({ + itemId: `codex:thread-1:turn-1:${index + 1}`, + revision: 1, + body: body as JournalSnapshotFile['items'][number]['body'], + sequence: index + 1, + observedAt: 1_000, + ...(index === 0 ? { recovered: true as const } : {}) + })) + snapshot.compactedThrough = snapshot.items.length + snapshot.submissions = [ + { + clientMessageId: 'm-1', + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'accepted', + providerItemId: 'codex:thread-1:turn-1:1', + reason: null, + submittedAt: 1_000, + resolvedAt: 1_001 + } + ] + await writeSnapshot(snapshot) + expect((await readJournalSnapshot(root)).status).toBe('valid') + }) + + it('classifies a JSON-valid non-array tombstones collection as invalid instead of valid', async () => { + await writeSnapshot({ ...validSnapshot(), tombstones: {} }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects tombstone entries that would poison seeding', async () => { + for (const tombstones of [ + [{ itemId: 42, revision: 1 }], + [{ itemId: 'codex:thread-1:turn-1:1', revision: 'one' }], + [{ itemId: 'codex:thread-1:turn-1:1', revision: Number.NaN }], + ['codex:thread-1:turn-1:1'] + ]) { + await writeSnapshot({ ...validSnapshot(), tombstones }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + } + }) + + it('rejects JSON-valid nested item corruption instead of admitting it', async () => { + // A resolved question with `options: null` used to pass shallow admission and + // then throw `TypeError` in the shared projection's `options.map`. + const poisonedQuestion = validSnapshot() + poisonedQuestion.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }, + sequence: 2, + observedAt: 1_000 + } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(poisonedQuestion) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + + // Pending-prompt surfaces read `resolution.state` before anything else. + const nullResolution = validSnapshot() + nullResolution.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'question', question: 'Deploy?', options: [], resolution: null }, + sequence: 2, + observedAt: 1_000 + } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(nullResolution) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects a JSON-valid nested corruption in the retained tail', async () => { + const poisonedTail = validSnapshot() + poisonedTail.tail = [ + { + v: 1, + epoch: 'epoch-A', + seq: 3, + fence: 1, + ts: 1_000, + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + } + } + ] as unknown as JournalSnapshotFile['tail'] + await writeSnapshot(poisonedTail) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects a submission that only carries a client message id', async () => { + const shallowSubmission = validSnapshot() + shallowSubmission.submissions = [ + { clientMessageId: 'm-1' } + ] as unknown as JournalSnapshotFile['submissions'] + await writeSnapshot(shallowSubmission) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) + + it('rejects items and counters that only look shallowly plausible', async () => { + const missingSequence = validSnapshot() + missingSequence.items = [ + { itemId: 'codex:thread-1:turn-1:1', revision: 1, body: { kind: 'status', text: 'x' } } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(missingSequence) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + + await writeSnapshot({ ...validSnapshot(), compactedThrough: Number.NaN }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + }) +}) + +describe('future-version snapshot classification', () => { + it('classifies a future version before shape validation so unknown bodies stay unreadable', async () => { + // The version can only advance because bodies changed, so a future snapshot + // legitimately carries kinds this build cannot parse. That is the + // schema-unreadable contract, not corruption. + const future = validSnapshot() as unknown as Record + future.v = 99 + future.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'future-render-kind', payload: { anything: true } }, + sequence: 2, + observedAt: 1_000 + } + ] + await writeSnapshot(future) + expect((await readJournalSnapshot(root)).status).toBe('unreadable') + }) + + it('classifies a future version as unreadable even when its shapes still parse today', async () => { + await writeSnapshot({ ...validSnapshot(), v: 99 }) + expect((await readJournalSnapshot(root)).status).toBe('unreadable') + }) + + it('treats a non-integer or sub-1 version as invalid, matching row admission', async () => { + for (const v of [0, 1.5]) { + await writeSnapshot({ ...validSnapshot(), v }) + expect((await readJournalSnapshot(root)).status).toBe('invalid') + } + }) +}) + +describe('journal startup isolation from a malformed snapshot', () => { + it('quarantines a JSON-valid malformed snapshot instead of throwing through open', async () => { + await writeSnapshot({ ...validSnapshot(), tombstones: {} }) + + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) + + // Degraded exactly like other corrupt snapshots: quarantined on disk, never + // silently deleted, and the session does not adopt state it cannot trust. + const entries = await readdir(root) + expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true) + expect(entries.includes(JOURNAL_SNAPSHOT_FILE)).toBe(false) + expect(journal.snapshot().items).toEqual([]) + }) +}) + +describe('reopen after a persisted JSON-valid poisoned question', () => { + it('quarantines the snapshot so reopen-to-render cannot throw in projection', async () => { + const poisoned = validSnapshot() + poisoned.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }, + sequence: 2, + observedAt: 1_000 + } + ] as unknown as JournalSnapshotFile['items'] + await writeSnapshot(poisoned) + + const journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) + + // The poisoned item must land in quarantine, not in the reopened state: + // pre-fix it was admitted and the render path below threw + // `TypeError: Cannot read properties of null (reading 'map')`. + const entries = await readdir(root) + expect(entries.some((entry) => entry.startsWith('quarantine-snapshot-'))).toBe(true) + const items = journal.snapshot().items + expect(() => projectStructuredItemsToNativeChat(items)).not.toThrow() + expect(() => projectStructuredAgentSessionStatus(items)).not.toThrow() + expect(items).toEqual([]) + }) +}) + +describe('appendJournalRows directory fsync', () => { + const ROW: JournalRow = { + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + v: 1, + epoch: 'epoch-A', + seq: 1, + fence: 0, + ts: 1_000 + } + + function hookDirectoryOpen(sync: ReturnType): FakeDirectoryHandle { + const fake: FakeDirectoryHandle = { sync, close: vi.fn(async () => undefined) } + openDirectoryHook = (path, flags) => (path === root && flags === 'r' ? fake : undefined) + return fake + } + + it('closes the directory handle when directory fsync fails', async () => { + const fake = hookDirectoryOpen( + vi.fn(async () => { + throw new Error('EINVAL: sync') + }) + ) + + // Tolerating unsupported directory fsync must not turn into a leak. + await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined() + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.close).toHaveBeenCalledTimes(1) + }) + + it('closes the directory handle when directory fsync succeeds', async () => { + const fake = hookDirectoryOpen(vi.fn(async () => undefined)) + + await expect(appendJournalRows(root, [ROW])).resolves.toBeUndefined() + expect(fake.sync).toHaveBeenCalledTimes(1) + expect(fake.close).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-log-file.ts b/src/main/native-chat/agent-session-journal/journal-log-file.ts new file mode 100644 index 00000000000..b556177b889 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-log-file.ts @@ -0,0 +1,336 @@ +// On-disk layout for one session's journal. +// +// /log.jsonl append-only rows, fsynced before the caller is told the write landed +// /snapshot.json folded state at a compaction boundary PLUS the retained tail +// /blobs/ bounded-payload remainders +// +// The snapshot carries its own tail so compaction is one atomic write. A crash +// between publishing the snapshot and truncating the log leaves the log a +// superset of the tail, and recovery unions the two by sequence — never a hole. + +import { appendFile, mkdir, open, readFile, type FileHandle } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { join } from 'node:path' +import { durableWriteTempPath, renameDurable, writeFileDurable } from '../../durable-file-write' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalRenderItem, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { + isAdmissibleAgentJournalRenderItem, + isAdmissibleAgentJournalSubmission +} from '../../../shared/agent-session-journal-schemas' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' + +export const JOURNAL_LOG_FILE = 'log.jsonl' +export const JOURNAL_SNAPSHOT_FILE = 'snapshot.json' + +export type JournalSnapshotFile = { + v: number + epoch: string + /** Highest sequence folded into `items`; the tail starts after it. */ + compactedThrough: number + /** Fence monotonicity survives compaction and restart. */ + highestFence: number + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + /** Receipts outlive the rows that minted them: a client reconnecting after + * compaction must still get the same answer instead of re-sending. */ + receipts: { + clientMessageId: string + providerItemId: string + epoch: string + sequence: number + acceptedAt: number + }[] + /** Provider item id → submission slot, preserved so a post-compaction echo + * still reconciles into the bubble it belongs to. */ + aliases: { providerItemId: string; itemId: string }[] + tombstones: { itemId: string; revision: number }[] + tail: JournalRow[] +} + +export type JournalReadResult = { + rows: JournalRow[] + /** True when a line used a schema version this build cannot read. Reading + * STOPS there — the row must not be skipped — and the host degrades to + * read-only: no writes, no compaction, no deletion. */ + unreadable: boolean + /** Lines that failed to parse for reasons other than schema version. */ + malformed: number + /** Raw suffix beginning at the first malformed line, if any. */ + remainder?: string + /** Distinguishes an absent/empty log from bytes that could not name an epoch. */ + hasBytes: boolean +} + +export type JournalSnapshotReadResult = + | { status: 'missing' } + | { status: 'valid'; snapshot: JournalSnapshotFile } + | { status: 'invalid' } + /** A future schema version: unreadable by this build, not corrupt. The file + * stays authoritative in place and the caller degrades to read-only. */ + | { status: 'unreadable' } + +const NEWLINE_BYTE = 0x0a + +export async function ensureJournalDir(journalDir: string): Promise { + await mkdir(journalDir, { recursive: true }) +} + +export async function readJournalSnapshot(journalDir: string): Promise { + try { + const raw = await readFile(join(journalDir, JOURNAL_SNAPSHOT_FILE), 'utf-8') + const parsed: unknown = JSON.parse(raw) + const version = snapshotSchemaVersion(parsed) + if (version === null) { + return { status: 'invalid' } + } + // Version is classified BEFORE shape validation, matching row admission: a + // version only advances because bodies changed, so a valid newer snapshot + // carries kinds this build cannot parse — unreadable, never corruption. + if (version > AGENT_SESSION_JOURNAL_SCHEMA_VERSION) { + return { status: 'unreadable' } + } + return isJournalSnapshotFile(parsed) + ? { status: 'valid', snapshot: parsed } + : { status: 'invalid' } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { status: 'missing' } + } + if (error instanceof SyntaxError) { + return { status: 'invalid' } + } + throw error + } +} + +export async function quarantineInvalidJournalSnapshot(journalDir: string): Promise { + const source = join(journalDir, JOURNAL_SNAPSHOT_FILE) + const target = join(journalDir, `quarantine-snapshot-${Date.now()}-${randomUUID()}.json`) + await renameDurable(source, target) + return target +} + +export async function writeJournalSnapshotFile( + journalDir: string, + snapshot: JournalSnapshotFile +): Promise { + const target = join(journalDir, JOURNAL_SNAPSHOT_FILE) + await writeFileDurable(durableWriteTempPath(target), target, JSON.stringify(snapshot)) +} + +export async function readJournalLog(journalDir: string): Promise { + let raw: string + try { + raw = await readFile(join(journalDir, JOURNAL_LOG_FILE), 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { rows: [], unreadable: false, malformed: 0, hasBytes: false } + } + throw error + } + const rows: JournalRow[] = [] + let unreadable = false + let malformed = 0 + const lines = raw.split('\n') + let offset = 0 + for (const line of lines) { + if (!line.trim()) { + offset += line.length + 1 + continue + } + const parsed = parseJournalRow(line) + if (parsed.ok) { + rows.push(parsed.row) + offset += line.length + 1 + continue + } + if (parsed.unreadable) { + unreadable = true + return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 } + } + malformed += 1 + return { rows, unreadable, malformed, remainder: raw.slice(offset), hasBytes: raw.length > 0 } + } + return { rows, unreadable, malformed, hasBytes: raw.length > 0 } +} + +/** Row admission requires an integer version of at least 1; a snapshot whose + * version cannot even be read is malformed, not a schema statement. */ +function snapshotSchemaVersion(value: unknown): number | null { + const snapshot = recordOf(value) + const version = snapshot?.v + return typeof version === 'number' && Number.isInteger(version) && version >= 1 ? version : null +} + +function isJournalSnapshotFile(value: unknown): value is JournalSnapshotFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const snapshot = value as Record + return ( + typeof snapshot.v === 'number' && + typeof snapshot.epoch === 'string' && + snapshot.epoch.length > 0 && + Number.isInteger(snapshot.compactedThrough) && + (snapshot.compactedThrough as number) >= 0 && + Number.isInteger(snapshot.highestFence) && + // Deep discriminated admission: a JSON-valid item with a corrupt nested + // shape (e.g. a question whose options are null) must land this snapshot + // in quarantine rather than throw later in projection or prompt render. + arrayOf(snapshot.items, isAdmissibleAgentJournalRenderItem) && + arrayOf(snapshot.submissions, isAdmissibleAgentJournalSubmission) && + arrayOf(snapshot.receipts, isReceipt) && + arrayOf(snapshot.aliases, isAlias) && + // Older snapshots predate tombstones; absence is fine, a non-array is not — + // seeding iterates this collection, so a JSON-valid wrong shape must land + // in quarantine rather than throw through startup restoration. + (snapshot.tombstones === undefined || arrayOf(snapshot.tombstones, isTombstone)) && + arrayOf(snapshot.tail, (row) => parseJournalRow(JSON.stringify(row)).ok) + ) +} + +function arrayOf(value: unknown, predicate: (entry: unknown) => boolean): value is unknown[] { + return Array.isArray(value) && value.every(predicate) +} + +function recordOf(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function isTombstone(value: unknown): boolean { + const tombstone = recordOf(value) + return Boolean( + tombstone && typeof tombstone.itemId === 'string' && Number.isInteger(tombstone.revision) + ) +} + +function isReceipt(value: unknown): boolean { + const receipt = recordOf(value) + return Boolean( + receipt && + typeof receipt.clientMessageId === 'string' && + typeof receipt.providerItemId === 'string' && + typeof receipt.epoch === 'string' && + typeof receipt.sequence === 'number' && + typeof receipt.acceptedAt === 'number' + ) +} + +function isAlias(value: unknown): boolean { + const alias = recordOf(value) + return Boolean( + alias && typeof alias.providerItemId === 'string' && typeof alias.itemId === 'string' + ) +} + +/** + * Append rows and fsync before returning. The caller treats a resolved promise + * as "this row survives a power loss" — the write-ahead submission row depends + * on exactly that, so this must never be relaxed to a buffered write. + */ +export async function appendJournalRows( + journalDir: string, + rows: readonly JournalRow[] +): Promise { + if (rows.length === 0) { + return + } + const path = join(journalDir, JOURNAL_LOG_FILE) + // A process death can leave a final JSON fragment without its newline. Never + // concatenate a new durable row onto that fragment: truncate the torn tail + // first, then fsync the repair before acknowledging this append. + try { + await repairJournalLogTail(path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + // The append below creates a missing log. + } + const payload = `${rows.map(serializeJournalRow).join('\n')}\n` + await appendFile(path, payload, 'utf-8') + const handle = await open(path, 'r+') + try { + await handle.sync() + } finally { + await handle.close() + } + let directory: FileHandle | undefined + try { + directory = await open(journalDir, 'r') + await directory.sync() + } catch { + // Directory fsync is unavailable on some platforms (notably Windows). + } finally { + // The tolerance above must not leak the descriptor when open succeeded + // but sync failed — one leaked handle per append adds up fast. + await directory?.close().catch(() => undefined) + } +} + +/** Repair only a torn final row. The normal append path reads one byte; scanning + * backward is reserved for the crash-recovery case and never rereads the log. */ +async function repairJournalLogTail(path: string): Promise { + const handle = await open(path, 'r+') + try { + const { size } = await handle.stat() + if (size === 0) { + return + } + const lastByte = Buffer.alloc(1) + await handle.read(lastByte, 0, 1, size - 1) + if (lastByte[0] === NEWLINE_BYTE) { + return + } + + const scanChunkBytes = 64 * 1024 + let scanEnd = size + let boundary = -1 + while (scanEnd > 0 && boundary === -1) { + const scanStart = Math.max(0, scanEnd - scanChunkBytes) + const chunk = Buffer.alloc(scanEnd - scanStart) + await handle.read(chunk, 0, chunk.length, scanStart) + const newline = chunk.lastIndexOf(NEWLINE_BYTE) + if (newline !== -1) { + boundary = scanStart + newline + } + scanEnd = scanStart + } + + const lineStart = boundary + 1 + const finalLine = Buffer.alloc(size - lineStart) + await handle.read(finalLine, 0, finalLine.length, lineStart) + // A whole row that merely lost its newline is kept; a real fragment goes. + const complete = parseJournalRow(finalLine.toString('utf-8')).ok + await (complete ? handle.write('\n', size) : handle.truncate(lineStart)) + await handle.sync() + } finally { + await handle.close() + } +} + +export async function quarantineJournalRemainder( + journalDir: string, + remainder: string +): Promise { + const path = join(journalDir, `quarantine-${Date.now()}-${randomUUID()}.jsonl`) + await writeFileDurable(durableWriteTempPath(path), path, remainder) + return path +} + +/** Replace the log with exactly the retained tail. Runs only after the snapshot + * carrying that tail is durable, so a crash here loses nothing. */ +export async function rewriteJournalLog( + journalDir: string, + rows: readonly JournalRow[] +): Promise { + const target = join(journalDir, JOURNAL_LOG_FILE) + const payload = rows.length ? `${rows.map(serializeJournalRow).join('\n')}\n` : '' + await writeFileDurable(durableWriteTempPath(target), target, payload) +} diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts new file mode 100644 index 00000000000..0dbee7b4005 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -0,0 +1,186 @@ +// Loading a journal from disk: snapshot + log → folded state. +// +// The snapshot is authoritative for the current epoch. Log rows belonging to a +// superseded epoch are dropped rather than merged — a crash between publishing +// a rollover snapshot and rewriting the log is the ordinary way that happens. +// A gap in the surviving sequence is corruption, and the caller rolls the epoch +// rather than rendering a partial timeline. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { findSequenceGap } from './journal-cursor' +import { + quarantineInvalidJournalSnapshot, + readJournalLog, + readJournalSnapshot, + type JournalSnapshotFile +} from './journal-log-file' +import { + applyJournalRow, + createJournalReducerState, + type JournalReducerState +} from './journal-reducer' +import { journalRowByteLength, type JournalRow } from './journal-row-schema' + +export type JournalLoad = { + state: JournalReducerState + /** Rows still individually replayable, oldest first. */ + tailRows: JournalRow[] + /** Highest sequence folded into the snapshot; the tail starts after it. */ + compactedThrough: number + /** A future schema version was met: no writes, no compaction, no deletion. */ + readOnly: boolean + /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ + corrupt: boolean + /** Log lines skipped because they failed to parse (schema-version rows are + * `readOnly`, never counted here). The store discloses these in the timeline. */ + malformedRows: number + sizeBytes: number + /** Raw unreadable suffix retained for quarantine instead of deletion. */ + quarantineRemainder?: string +} + +/** Returns null when no journal exists yet for this session. */ +export async function loadJournal( + journalDir: string, + sessionId: string +): Promise { + const snapshotRead = await readJournalSnapshot(journalDir) + if (snapshotRead.status === 'unreadable') { + // Written by a newer schema: not corrupt, so never quarantined. The file + // stays authoritative in place, and this build must not write, compact, + // delete, or render a partial timeline from rows it cannot anchor to the + // snapshot it cannot read. + return emptyReadOnlyLoad(sessionId) + } + if (snapshotRead.status === 'invalid') { + await quarantineInvalidJournalSnapshot(journalDir) + } + const snapshot = snapshotRead.status === 'valid' ? snapshotRead.snapshot : null + const log = await readJournalLog(journalDir) + const epoch = resolveEpoch(snapshot, log.rows) + if (!epoch) { + return snapshotRead.status === 'invalid' || log.hasBytes ? emptyReadOnlyLoad(sessionId) : null + } + + const compactedThrough = snapshot?.epoch === epoch ? snapshot.compactedThrough : 0 + const state = seedState(sessionId, epoch, snapshot?.epoch === epoch ? snapshot : null) + const liveRows = log.rows.filter((row) => row.epoch === epoch) + let tailRows = unionBySequence(snapshot?.epoch === epoch ? snapshot.tail : [], liveRows, epoch) + + const oldest = tailRows[0]?.seq ?? compactedThrough + 1 + const gap = findSequenceGap( + tailRows.map((row) => row.seq), + oldest + ) + // A hole below the snapshot boundary is unrecoverable too: the snapshot only + // covers `compactedThrough`, so a tail that starts above it lost rows. + let corrupt = Boolean(gap) || oldest > compactedThrough + 1 || log.malformed > 0 + let quarantineRemainder = log.remainder + if (gap) { + const firstBad = tailRows.findIndex((row, index) => { + const expected = (tailRows[0]?.seq ?? compactedThrough + 1) + index + return row.seq !== expected + }) + if (firstBad !== -1) { + const suffix = tailRows.slice(firstBad) + tailRows = tailRows.slice(0, firstBad) + quarantineRemainder ??= `${suffix.map((row) => JSON.stringify(row)).join('\n')}\n` + } + } + + for (const row of tailRows) { + if (row.seq > compactedThrough) { + applyJournalRow(state, row) + } + } + state.oldestSequence = oldest + state.lastSequence = Math.max(state.lastSequence, compactedThrough) + + return { + state, + tailRows, + compactedThrough, + // A future-version snapshot never reaches here: it is classified + // unreadable above, so `valid` implies a version this build can write. + readOnly: log.unreadable, + corrupt, + malformedRows: log.malformed, + sizeBytes: tailRows.reduce((total, row) => total + journalRowByteLength(row), 0), + quarantineRemainder + } +} + +function emptyReadOnlyLoad(sessionId: string): JournalLoad { + const state = createJournalReducerState(sessionId, '') + return { + state, + tailRows: [], + compactedThrough: 0, + readOnly: true, + corrupt: false, + malformedRows: 0, + sizeBytes: 0 + } +} + +/** The snapshot names the live epoch; without one, the newest valid row does. */ +function resolveEpoch(snapshot: JournalSnapshotFile | null, rows: JournalRow[]): string | null { + if (snapshot?.epoch) { + return snapshot.epoch + } + return rows.at(-1)?.epoch ?? null +} + +function seedState( + sessionId: string, + epoch: string, + snapshot: JournalSnapshotFile | null +): JournalReducerState { + const state = createJournalReducerState(sessionId, epoch) + if (!snapshot) { + return state + } + for (const item of snapshot.items) { + state.items.set(item.itemId, item) + } + for (const submission of snapshot.submissions) { + state.submissions.set(submission.clientMessageId, { ...submission } as AgentJournalSubmission) + } + for (const receipt of snapshot.receipts) { + state.receipts.set(receipt.clientMessageId, { + clientMessageId: receipt.clientMessageId, + providerItemId: receipt.providerItemId, + cursor: { epoch: receipt.epoch, sequence: receipt.sequence }, + acceptedAt: receipt.acceptedAt + }) + } + for (const alias of snapshot.aliases) { + state.aliases.set(alias.providerItemId, alias.itemId) + } + for (const tombstone of snapshot.tombstones ?? []) { + state.tombstones.set(tombstone.itemId, tombstone.revision) + } + state.highestFence = snapshot.highestFence ?? 0 + state.lastSequence = snapshot.compactedThrough + state.oldestSequence = snapshot.compactedThrough + 1 + return state +} + +/** Merge the snapshot's retained tail with the live log, preferring the log's + * copy of any sequence both hold, and dropping rows from a superseded epoch. */ +function unionBySequence( + retained: readonly JournalRow[], + live: readonly JournalRow[], + epoch: string +): JournalRow[] { + const bySequence = new Map() + for (const row of retained) { + if (row.epoch === epoch) { + bySequence.set(row.seq, row) + } + } + for (const row of live) { + bySequence.set(row.seq, row) + } + return [...bySequence.values()].sort((a, b) => a.seq - b.seq) +} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts new file mode 100644 index 00000000000..87616b25b96 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -0,0 +1,42 @@ +// Where a session journal lives. +// +// Host-side per-workspace state, keyed by workspace id — never inside the +// user's working tree. A journal in the tree would show up in `git status`, +// vanish with `git worktree remove`, and have no defined home in a folder +// workspace that is not a repository at all. Keying by id rather than by path +// makes a worktree, a folder workspace, a WSL distro, and an SSH host identical. +// +// The host environment port supplies the root so desktop Electron and the +// headless/SSH runtime resolve their own durable state directories. + +import { createHash } from 'node:crypto' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { getAppEnvironment } from '../../../shared/app-environment' + +const JOURNAL_DIR_NAME = 'agent-session-journal' + +/** Filesystem-safe, collision-resistant segment for an arbitrary id. Ids come + * from providers and workspaces and can contain path separators or characters + * Windows rejects, so they are hashed rather than sanitized. */ +export function journalPathSegment(value: string): string { + return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) +} + +/** `/agent-session-journal//`. */ +export function journalDirectoryFor( + root: string, + identity: Pick +): string { + return join( + root, + JOURNAL_DIR_NAME, + journalPathSegment(identity.workspaceId), + journalPathSegment(identity.sessionId) + ) +} + +/** Default host state root. */ +export function defaultJournalRoot(): Promise { + return Promise.resolve(getAppEnvironment().getPath('userData')) +} diff --git a/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts b/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts new file mode 100644 index 00000000000..61cb82894a4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-payload-bounds.ts @@ -0,0 +1,86 @@ +// Payload bounds for tool output and diffs. +// +// A looping agent must not be able to fill the host disk, and a 40 MB tool +// result must not be inlined into a row that every reconnecting client +// replays. A bounded payload keeps a head plus the original byte length and +// digest; the remainder lives in the content-addressed blob store under the +// same retention as its epoch. Crossing a bound is always marked — never a +// silent drop. + +import { createHash } from 'node:crypto' +import type { AgentJournalBoundedPayload } from '../../../shared/agent-session-journal-types' + +export type JournalPayloadLimits = { + /** Bytes of the payload kept inline on the row. */ + inlineHeadBytes: number + /** Total bytes of journal rows one session may hold before appends are refused. */ + maxSessionBytes: number + /** Appends allowed inside `appendWindowMs`, bounding a runaway agent's rate. */ + maxAppendsPerWindow: number + appendWindowMs: number +} + +export const DEFAULT_JOURNAL_PAYLOAD_LIMITS: JournalPayloadLimits = { + inlineHeadBytes: 16 * 1024, + maxSessionBytes: 256 * 1024 * 1024, + maxAppendsPerWindow: 5000, + appendWindowMs: 60_000 +} + +/** Marker appended to a clipped inline string so the UI never presents a + * truncated body as complete. Kept in the text itself because block-level + * payloads (tool-result output) have nowhere else to carry the flag. */ +export function journalTruncationMarker(byteLength: number, digest: string): string { + return `\n[Orca: output truncated — ${byteLength} bytes total, digest ${digest.slice(0, 12)}]` +} + +export function digestPayload(payload: string): string { + return createHash('sha256').update(payload, 'utf8').digest('hex') +} + +/** + * Clip `payload` to the inline head. `truncated` means the remainder must be + * written to the blob store under `digest` before the row is appended. + */ +export function boundPayload( + payload: string, + limits: JournalPayloadLimits +): AgentJournalBoundedPayload { + const buffer = Buffer.from(payload, 'utf8') + const digest = digestPayload(payload) + if (buffer.byteLength <= limits.inlineHeadBytes) { + return { head: payload, byteLength: buffer.byteLength, digest, truncated: false } + } + return { + head: clipUtf8(buffer, limits.inlineHeadBytes), + byteLength: buffer.byteLength, + digest, + truncated: true + } +} + +/** Bound a plain string that must stay a string (a tool-result block's output), + * keeping the explicit marker inline. Returns the blob payload to persist. */ +export function boundInlineText( + payload: string, + limits: JournalPayloadLimits +): { text: string; bounded: AgentJournalBoundedPayload } { + const bounded = boundPayload(payload, limits) + if (!bounded.truncated) { + return { text: payload, bounded } + } + return { + text: bounded.head + journalTruncationMarker(bounded.byteLength, bounded.digest), + bounded + } +} + +/** Slice at a byte budget without splitting a multi-byte character. */ +function clipUtf8(buffer: Buffer, maxBytes: number): string { + let end = maxBytes + // A UTF-8 continuation byte is 0b10xxxxxx; walk back off a split sequence. + while (end > 0 && (buffer[end] & 0b1100_0000) === 0b1000_0000) { + end -= 1 + } + return buffer.subarray(0, end).toString('utf8') +} diff --git a/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts new file mode 100644 index 00000000000..76bc00394f2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-pending-submission-recovery.ts @@ -0,0 +1,18 @@ +import type { AgentSessionJournal } from './journal-store' + +export async function markJournalPendingSubmissionsUnknown( + journal: AgentSessionJournal, + fence: number +): Promise { + const pending = journal.pendingSubmissions().map((entry) => entry.clientMessageId) + for (const clientMessageId of pending) { + await journal.resolveDispatch({ + clientMessageId, + state: 'unknown', + reason: 'host_restarted_before_acknowledgement', + fence, + recovered: true + }) + } + return pending +} diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.test.ts b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts new file mode 100644 index 00000000000..832b25097b5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey, + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + applyJournalRow, + createJournalReducerState, + referencedBlobDigests, + renderJournalState, + type JournalReducerState +} from './journal-reducer' +import type { JournalRow } from './journal-row-schema' + +const EPOCH = 'epoch-1' + +function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +function userText(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text: value }] } +} + +function sendFingerprint(body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body } + }) +} + +function base(seq: number): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: 1, epoch: EPOCH, seq, fence: 1, ts: 1_000 + seq } +} + +function fold(rows: JournalRow[]): JournalReducerState { + const state = createJournalReducerState('session-1', EPOCH) + for (const row of rows) { + applyJournalRow(state, row) + } + return state +} + +describe('revisions and tombstones', () => { + it('takes the highest revision', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('first'), ...base(1) }, + { kind: 'item', itemId: 'a', revision: 2, body: text('second'), ...base(2) } + ]) + expect(renderJournalState(state).items[0]?.body).toEqual(text('second')) + }) + + it('drops a late lower revision instead of resurrecting stale content', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 2, body: text('second'), ...base(1) }, + { kind: 'item', itemId: 'a', revision: 1, body: text('first'), ...base(2) } + ]) + expect(renderJournalState(state).items[0]?.body).toEqual(text('second')) + }) + + it('removes an item on a tombstone', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('gone'), ...base(1) }, + { kind: 'tombstone', itemId: 'a', revision: 2, ...base(2) } + ]) + expect(renderJournalState(state).items).toHaveLength(0) + }) + + it('does not let a late lower revision resurrect a tombstoned item', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('gone'), ...base(1) }, + { kind: 'tombstone', itemId: 'a', revision: 3, ...base(2) }, + { kind: 'item', itemId: 'a', revision: 2, body: text('stale'), ...base(3) } + ]) + expect(renderJournalState(state).items).toHaveLength(0) + }) + + it('re-creates an item at a revision above the tombstone', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('gone'), ...base(1) }, + { kind: 'tombstone', itemId: 'a', revision: 2, ...base(2) }, + { kind: 'item', itemId: 'a', revision: 3, body: text('back'), ...base(3) } + ]) + expect(renderJournalState(state).items.map((item) => item.body)).toEqual([text('back')]) + }) +}) + +describe('ordering', () => { + it('orders by the sequence that created an item, not by a later revision', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('a'), ...base(1) }, + { kind: 'item', itemId: 'b', revision: 1, body: text('b'), ...base(2) }, + { kind: 'item', itemId: 'a', revision: 2, body: text('a2'), ...base(3) } + ]) + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual(['a', 'b']) + }) + + it('orders by sequence regardless of the order rows are applied in', () => { + // Live append and replay must render the same list, so the fold cannot lean + // on the order it happens to be handed rows in. + const state = fold([ + { kind: 'item', itemId: 'later', revision: 1, body: text('later'), ...base(9) }, + { kind: 'item', itemId: 'earlier', revision: 1, body: text('earlier'), ...base(3) } + ]) + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual(['earlier', 'later']) + }) + + it('orders by sequence even when the observed timestamp runs backwards', () => { + const state = fold([ + { kind: 'item', itemId: 'late', revision: 1, body: text('late'), ...base(1), ts: 9_000 }, + { + kind: 'item', + itemId: 'recovered', + revision: 1, + body: text('recovered'), + ...base(2), + ts: 10, + recovered: true + } + ]) + const items = renderJournalState(state).items + expect(items.map((item) => item.itemId)).toEqual(['late', 'recovered']) + expect(items[1]?.recovered).toBe(true) + }) + + it('pins observedAt to creation so a revision cannot relocate the row', () => { + // Clients sort the timeline by observedAt. The provider echoing a send revises + // the submission row; if that advanced the timestamp the user's own bubble + // would sort below rows that landed while the turn was in flight. + const state = fold([ + { kind: 'item', itemId: 'send', revision: 0, body: userText('ok thanks'), ...base(1) }, + { kind: 'item', itemId: 'frame', revision: 1, body: text('warning'), ...base(2) }, + { kind: 'item', itemId: 'send', revision: 1, body: userText('ok thanks'), ...base(3) } + ]) + const items = renderJournalState(state).items + expect(items.map((item) => item.itemId)).toEqual(['send', 'frame']) + expect(items.map((item) => item.observedAt)).toEqual([base(1).ts, base(2).ts]) + // The revision still lands — only its ordering keys are ignored. + expect(items[0]?.revision).toBe(1) + }) + + it('never collapses two items that carry identical text', () => { + const state = fold([ + { kind: 'item', itemId: 'a', revision: 1, body: text('run the tests'), ...base(1) }, + { kind: 'item', itemId: 'b', revision: 1, body: text('run the tests'), ...base(2) } + ]) + expect(renderJournalState(state).items).toHaveLength(2) + }) +}) + +describe('submission and dispatch state machine', () => { + const submission: JournalRow = { + kind: 'submission', + clientMessageId: 'cm_1', + payloadFingerprint: 'fp_1', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(1) + } + + it('seeds a pending submission and an optimistic bubble', () => { + const rendered = renderJournalState(fold([submission])) + expect(rendered.submissions[0]?.dispatchState).toBe('pending') + expect(rendered.items.map((item) => item.itemId)).toEqual([agentJournalSubmissionKey('cm_1')]) + }) + + it('mints a receipt and adopts the provider item id on accept', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'accepted', + providerItemId: 'codex:thread-1:turn-1:0', + reason: null, + ...base(2) + } + ]) + expect(state.receipts.get('cm_1')?.cursor).toEqual({ epoch: EPOCH, sequence: 2 }) + expect(state.submissions.get('cm_1')?.providerItemId).toBe('codex:thread-1:turn-1:0') + }) + + it('folds the provider echo into the submission bubble instead of adding a second one', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'accepted', + providerItemId: 'codex:thread-1:turn-1:0', + reason: null, + ...base(2) + }, + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:0', + revision: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...base(3) + } + ]) + const items = renderJournalState(state).items + expect(items).toHaveLength(1) + expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) + // The echo updates content in place; the bubble keeps its original slot. + expect(items[0]?.sequence).toBe(1) + expect(items[0]?.revision).toBe(1) + }) + + it('adopts a provider echo that arrives before dispatch settles', () => { + const body = userText('early echo') + const state = fold([ + { + kind: 'submission', + clientMessageId: 'early-client', + payloadFingerprint: sendFingerprint(body), + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body, + ...base(1) + }, + { + kind: 'item', + itemId: 'codex:thread-1:root-turn:2', + revision: 1, + body, + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'early-client', + state: 'accepted', + providerItemId: 'codex:thread-1:predicted-turn:0', + reason: null, + ...base(3) + } + ]) + + expect(renderJournalState(state).items).toMatchObject([ + { itemId: agentJournalSubmissionKey('early-client'), revision: 1, sequence: 1 } + ]) + }) + + it.each([5, 10])( + 'reconciles %i rapid sends across an interleaved cancel when Codex reuses the root turn', + (count) => { + const rows: JournalRow[] = [] + for (let index = 0; index < count; index += 1) { + const body = userText(`RAPID_${index + 1}`) + rows.push( + { + kind: 'submission', + clientMessageId: `client-${index}`, + payloadFingerprint: sendFingerprint(body), + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + body, + ...base(rows.length + 1) + }, + { + kind: 'dispatch', + clientMessageId: `client-${index}`, + state: 'accepted', + providerItemId: `codex:thread-1:predicted-turn-${index}:0`, + reason: null, + ...base(rows.length + 2) + } + ) + } + rows.push({ + kind: 'item', + itemId: 'orca:cancel-between-sends', + revision: 1, + body: { kind: 'status', text: 'Cancelled an earlier turn.' }, + ...base(rows.length + 1) + }) + for (let index = 0; index < count; index += 1) { + rows.push({ + kind: 'item', + itemId: `codex:thread-1:root-turn:${index}`, + revision: 1, + body: userText(`RAPID_${index + 1}`), + ...base(rows.length + 1) + }) + } + + const messages = renderJournalState(fold(rows)).items.filter( + (item) => item.body.kind === 'message' && item.body.role === 'user' + ) + expect(messages).toHaveLength(count) + expect(messages.map((item) => item.itemId)).toEqual( + Array.from({ length: count }, (_, index) => agentJournalSubmissionKey(`client-${index}`)) + ) + } + ) + + it('treats rejected as terminal', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'rejected', + providerItemId: null, + reason: 'not_delivered', + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'unknown', + providerItemId: null, + reason: 'late', + ...base(3) + } + ]) + expect(state.submissions.get('cm_1')?.dispatchState).toBe('rejected') + expect(state.submissions.get('cm_1')?.reason).toBe('not_delivered') + }) + + it('lets an unknown submission settle later', () => { + const state = fold([ + submission, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'unknown', + providerItemId: null, + reason: 'host_restarted_before_acknowledgement', + ...base(2) + }, + { + kind: 'dispatch', + clientMessageId: 'cm_1', + state: 'accepted', + providerItemId: 'p1', + reason: null, + ...base(3) + } + ]) + expect(state.submissions.get('cm_1')?.dispatchState).toBe('accepted') + expect(state.receipts.get('cm_1')).toBeTruthy() + }) + + it('ignores a dispatch for a submission this epoch never saw', () => { + const state = fold([ + { + kind: 'dispatch', + clientMessageId: 'ghost', + state: 'accepted', + providerItemId: 'p', + reason: null, + ...base(1) + } + ]) + expect(state.submissions.size).toBe(0) + expect(state.receipts.size).toBe(0) + }) +}) + +describe('blob retention', () => { + it('reports the digests live rows still reference', () => { + const state = fold([ + { + kind: 'item', + itemId: 'tool', + revision: 1, + body: { + kind: 'tool-call', + name: 'bash', + input: {}, + state: 'completed', + output: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true } + }, + ...base(1) + }, + { + kind: 'item', + itemId: 'inline', + revision: 1, + body: { + kind: 'tool-call', + name: 'bash', + input: {}, + state: 'completed', + output: { head: 'y', byteLength: 1, digest: 'digest-b', truncated: false } + }, + ...base(2) + } + ]) + expect([...referencedBlobDigests(state)]).toEqual(['digest-a']) + }) + + it('stops referencing a digest once its item is tombstoned', () => { + const state = fold([ + { + kind: 'item', + itemId: 'tool', + revision: 1, + body: { + kind: 'diff', + path: 'a.ts', + patch: { head: 'x', byteLength: 999, digest: 'digest-a', truncated: true } + }, + ...base(1) + }, + { kind: 'tombstone', itemId: 'tool', revision: 2, ...base(2) } + ]) + expect(referencedBlobDigests(state).size).toBe(0) + }) +}) + +describe('malformed persisted item keys', () => { + it('degrades a malformed-percent item id to an opaque key instead of throwing', () => { + // A user-message body drives identity resolution through the key parser; + // pre-fix `parseAgentJournalItemKey('%')` threw `URIError: URI malformed`. + const state = fold([ + { kind: 'item', itemId: '%', revision: 1, body: userText('hi'), ...base(1) } + ]) + expect(renderJournalState(state).items[0]?.itemId).toBe('%') + }) +}) + +describe('bounded item-key collisions', () => { + it('keeps an oversized turn and its raw digest-form mimic as separate items', () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const keyFor = (turnId: string) => + agentJournalItemKey({ provider: 'codex', threadId: 'thread-1', turnId, ordinal: 0 }) + const oversizedKey = keyFor(oversizedTurnId) + const mimicKey = keyFor(digestFormMimic) + + const state = fold([ + { kind: 'item', itemId: oversizedKey, revision: 1, body: text('oversized'), ...base(1) }, + { kind: 'item', itemId: mimicKey, revision: 1, body: text('mimic'), ...base(2) } + ]) + + expect(renderJournalState(state).items.map((item) => item.itemId)).toEqual([ + oversizedKey, + mimicKey + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts new file mode 100644 index 00000000000..85e93676752 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -0,0 +1,257 @@ +// THE reducer. One implementation folds rows into the render model, and both +// the live append path and replay call it — a live-only shortcut is how a +// reconnect starts disagreeing with the screen it replaced. +// +// Rules: highest revision wins, a tombstone removes, a late lower revision is +// dropped rather than resurrecting stale content, and ordering is by the +// sequence of the row that CREATED an item (a later revision updates the body, +// it does not move the bubble). + +import type { + AgentJournalAcceptanceReceipt, + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSnapshot, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { + agentJournalSubmissionKey, + parseAgentJournalItemKey +} from '../../../shared/agent-session-journal-item-key' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import type { JournalRow } from './journal-row-schema' + +export type JournalReducerState = { + sessionId: string + epoch: string + lastSequence: number + /** Lowest sequence still individually replayable; rows below it were compacted. */ + oldestSequence: number + highestFence: number + items: Map + /** Revision of a removed item, so a late lower revision cannot resurrect it. */ + tombstones: Map + submissions: Map + receipts: Map + /** Provider item id → the submission slot that adopted it. Stops an accepted + * echo from appending a second copy of the user's own message. */ + aliases: Map +} + +export function createJournalReducerState(sessionId: string, epoch: string): JournalReducerState { + return { + sessionId, + epoch, + lastSequence: 0, + oldestSequence: 1, + highestFence: 0, + items: new Map(), + tombstones: new Map(), + submissions: new Map(), + receipts: new Map(), + aliases: new Map() + } +} + +export function applyJournalRow(state: JournalReducerState, row: JournalRow): void { + state.lastSequence = Math.max(state.lastSequence, row.seq) + state.highestFence = Math.max(state.highestFence, row.fence) + if (row.kind === 'epoch') { + return + } + if (row.kind === 'item') { + const itemId = resolveJournalItemId(state, row.itemId, row.body) + upsertItem(state, itemId, row.revision, { + itemId, + revision: row.revision, + body: row.body, + sequence: row.seq, + observedAt: row.ts, + ...(row.recovered ? { recovered: row.recovered } : {}) + }) + return + } + if (row.kind === 'tombstone') { + removeItem(state, resolveItemId(state, row.itemId), row.revision) + return + } + if (row.kind === 'submission') { + applySubmission(state, row) + return + } + applyDispatch(state, row) +} + +export function resolveJournalItemId( + state: JournalReducerState, + itemId: string, + body?: AgentJournalRenderItem['body'] +): string { + const aliased = state.aliases.get(itemId) + if (aliased) { + return aliased + } + const identity = parseAgentJournalItemKey(itemId) + if ( + !body || + body.kind !== 'message' || + body.role !== 'user' || + !identity || + identity.provider === 'orca' + ) { + return itemId + } + const fingerprint = structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: state.sessionId, + fields: { body } + }) + // Exact payload plus queue order preserves repeated identical sends one-for-one. + const submission = [...state.submissions.values()] + .sort((left, right) => left.submittedAt - right.submittedAt) + .find((candidate) => { + if (candidate.dispatchState === 'rejected' || candidate.payloadFingerprint !== fingerprint) { + return false + } + return state.items.get(agentJournalSubmissionKey(candidate.clientMessageId))?.revision === 0 + }) + if (!submission) { + return itemId + } + const submissionId = agentJournalSubmissionKey(submission.clientMessageId) + state.aliases.set(itemId, submissionId) + return submissionId +} + +function resolveItemId(state: JournalReducerState, itemId: string): string { + return state.aliases.get(itemId) ?? itemId +} + +function upsertItem( + state: JournalReducerState, + itemId: string, + revision: number, + next: AgentJournalRenderItem +): void { + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + if (!existing) { + state.items.set(itemId, next) + state.tombstones.delete(itemId) + return + } + // Creation sequence is the ordering key; a revision refreshes content only. + // `observedAt` is pinned with it: clients sort the timeline by that timestamp, + // so letting a revision advance it makes the row jump past everything that + // landed in between — the provider's own echo of a send revises the submission + // row, which relocated the user's bubble below later rows. + state.items.set(itemId, { ...next, sequence: existing.sequence, observedAt: existing.observedAt }) + state.tombstones.delete(itemId) +} + +function removeItem(state: JournalReducerState, itemId: string, revision: number): void { + const existing = state.items.get(itemId) + if (existing && revision <= existing.revision) { + return + } + const tombstoned = state.tombstones.get(itemId) + if (tombstoned !== undefined && revision <= tombstoned) { + return + } + state.tombstones.set(itemId, revision) + state.items.delete(itemId) +} + +function applySubmission( + state: JournalReducerState, + row: Extract +): void { + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null + }) + const itemId = agentJournalSubmissionKey(row.clientMessageId) + upsertItem(state, itemId, 0, { + itemId, + revision: 0, + body: row.body, + sequence: row.seq, + observedAt: row.ts + }) +} + +function applyDispatch( + state: JournalReducerState, + row: Extract +): void { + const submission = state.submissions.get(row.clientMessageId) + if (!submission) { + return + } + // `rejected` is terminal; a late `unknown` must not reopen a settled answer. + if (submission.dispatchState === 'rejected' || submission.dispatchState === 'accepted') { + return + } + submission.dispatchState = row.state + submission.providerItemId = row.providerItemId + submission.reason = row.reason + submission.resolvedAt = row.ts + if (row.state !== 'accepted' || !row.providerItemId) { + return + } + state.aliases.set(row.providerItemId, agentJournalSubmissionKey(row.clientMessageId)) + state.receipts.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + providerItemId: row.providerItemId, + cursor: { epoch: row.epoch, sequence: row.seq }, + acceptedAt: row.ts + }) +} + +/** Project the folded state into the client-facing snapshot. */ +export function renderJournalState(state: JournalReducerState): AgentJournalSnapshot { + // Sequence is the sole ordering key; map insertion order is not, because a + // re-created item re-enters the map after the items that followed it. + const items = [...state.items.values()].sort((a, b) => a.sequence - b.sequence) + return { + sessionId: state.sessionId, + cursor: { epoch: state.epoch, sequence: state.lastSequence }, + items, + submissions: [...state.submissions.values()].sort((a, b) => a.submittedAt - b.submittedAt) + } +} + +/** Blob digests one body points at. A retained row can outlive its render item + * (a tombstone drops the item), so compaction reads rows through this too. */ +export function blobDigestsInBody(body: AgentJournalItemBody, into: Set): void { + if (body.kind === 'tool-call' && body.output?.truncated) { + into.add(body.output.digest) + } + if (body.kind === 'diff' && body.patch.truncated) { + into.add(body.patch.digest) + } + if (body.kind === 'status' && body.providerFrame?.payload.truncated) { + into.add(body.providerFrame.payload.digest) + } +} + +/** Digests referenced by live rows, so compaction knows which blobs to keep. */ +export function referencedBlobDigests(state: JournalReducerState): Set { + const digests = new Set() + for (const item of state.items.values()) { + blobDigestsInBody(item.body, digests) + } + return digests +} diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts new file mode 100644 index 00000000000..89a96465187 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -0,0 +1,168 @@ +import type { + AgentJournalDispatchState, + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { JournalReducerState } from './journal-reducer' +import type { + JournalDispatchRow, + JournalItemRow, + JournalSubmissionRow, + JournalTombstoneRow +} from './journal-row-schema' +import type { ResolveDispatchInput } from './journal-store-contracts' + +type RowBuilder = (seq: number, ts: number) => T + +export function journalItemRowBuilder( + state: () => JournalReducerState, + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + options: { fence: number; observedAt?: number; recovered?: true } +): RowBuilder { + return (seq, ts) => + buildJournalItemRow({ + state: state(), + identity, + body, + seq, + fence: options.fence, + ts: options.observedAt ?? ts, + recovered: options.recovered + }) +} + +export function journalTombstoneRowBuilder( + state: () => JournalReducerState, + itemId: string, + fence: number +): RowBuilder { + return (seq, ts) => buildJournalTombstoneRow({ state: state(), itemId, seq, fence, ts }) +} + +export function journalSubmissionRowBuilder( + state: () => JournalReducerState, + providerHandle: AgentSessionProviderHandle, + input: { + clientMessageId: string + payloadFingerprint: string + body: AgentJournalMessageItem + fence: number + } +): RowBuilder { + return (seq, ts) => + buildJournalSubmissionRow({ state: state(), providerHandle, ...input, seq, ts }) +} + +export function journalDispatchRowBuilder( + state: () => JournalReducerState, + input: ResolveDispatchInput +): RowBuilder { + const providerItemId = + input.state === 'accepted' ? agentJournalItemKey(input.providerIdentity) : null + return (seq, ts) => + buildJournalDispatchRow({ + state: state(), + clientMessageId: input.clientMessageId, + dispatchState: input.state, + providerItemId, + reason: input.state === 'accepted' ? null : (input.reason ?? null), + seq, + fence: input.fence, + ts, + recovered: input.recovered + }) +} + +export function journalRowBase( + epoch: string, + seq: number, + fence: number, + ts: number +): { v: number; epoch: string; seq: number; fence: number; ts: number } { + return { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch, seq, fence, ts } +} + +export function buildJournalItemRow(input: { + state: JournalReducerState + identity: AgentJournalItemIdentity + body: AgentJournalItemBody + seq: number + fence: number + ts: number + recovered?: true +}): JournalItemRow { + const itemId = agentJournalItemKey(input.identity) + const resolved = input.state.aliases.get(itemId) ?? itemId + const revision = (input.state.items.get(resolved)?.revision ?? 0) + 1 + return { + kind: 'item', + itemId, + revision, + body: input.body, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + } +} + +export function buildJournalTombstoneRow(input: { + state: JournalReducerState + itemId: string + seq: number + fence: number + ts: number +}): JournalTombstoneRow { + const resolved = input.state.aliases.get(input.itemId) ?? input.itemId + return { + kind: 'tombstone', + itemId: input.itemId, + revision: (input.state.items.get(resolved)?.revision ?? 0) + 1, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function buildJournalSubmissionRow(input: { + state: JournalReducerState + clientMessageId: string + payloadFingerprint: string + providerHandle: AgentSessionProviderHandle + body: AgentJournalMessageItem + seq: number + fence: number + ts: number +}): JournalSubmissionRow { + return { + kind: 'submission', + clientMessageId: input.clientMessageId, + payloadFingerprint: input.payloadFingerprint, + providerHandle: input.providerHandle, + body: input.body, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts) + } +} + +export function buildJournalDispatchRow(input: { + state: JournalReducerState + clientMessageId: string + dispatchState: Exclude + providerItemId: string | null + reason: string | null + seq: number + fence: number + ts: number + recovered?: true +}): JournalDispatchRow { + return { + kind: 'dispatch', + clientMessageId: input.clientMessageId, + state: input.dispatchState, + providerItemId: input.providerItemId, + reason: input.reason, + ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), + ...(input.recovered ? { recovered: input.recovered } : {}) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts new file mode 100644 index 00000000000..5b685a1282a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import { parseJournalRow } from './journal-row-schema' + +const BASE = { v: 1, epoch: 'epoch-1', seq: 1, fence: 1, ts: 1 } + +function parse(row: Record): boolean { + return parseJournalRow(JSON.stringify(row)).ok +} + +describe('journal row validation', () => { + it('accepts every fully-formed row shape this build writes', () => { + expect( + parse({ + ...BASE, + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 't' } + }) + ).toBe(true) + expect( + parse({ + ...BASE, + kind: 'item', + itemId: 'i-1', + revision: 1, + body: { kind: 'status', text: 'x' } + }) + ).toBe(true) + expect(parse({ ...BASE, kind: 'tombstone', itemId: 'i-1', revision: 2 })).toBe(true) + expect( + parse({ + ...BASE, + kind: 'submission', + clientMessageId: 'm-1', + payloadFingerprint: 'a'.repeat(64), + providerHandle: { kind: 'codex', threadId: 't' }, + body: { kind: 'message', role: 'user', blocks: [] } + }) + ).toBe(true) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'accepted', + providerItemId: 'codex:t:turn:0', + reason: null + }) + ).toBe(true) + }) + + it('rejects a dispatch row missing its state or with mistyped fields', () => { + expect(parse({ ...BASE, kind: 'dispatch', clientMessageId: 'm-1' })).toBe(false) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 7, + providerItemId: null, + reason: null + }) + ).toBe(false) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'accepted', + providerItemId: 7, + reason: null + }) + ).toBe(false) + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'rejected', + providerItemId: null, + reason: 7 + }) + ).toBe(false) + }) + + it('rejects a submission row without its fingerprint, handle, or message body', () => { + const submission = { + ...BASE, + kind: 'submission', + clientMessageId: 'm-1', + payloadFingerprint: 'a'.repeat(64), + providerHandle: { kind: 'codex', threadId: 't' }, + body: { kind: 'message', role: 'user', blocks: [] } + } + expect(parse({ ...submission, payloadFingerprint: undefined as never })).toBe(false) + expect(parse({ ...submission, providerHandle: 'codex' })).toBe(false) + expect(parse({ ...submission, body: 'hi' })).toBe(false) + }) + + it('rejects an item row whose body is not a kinded object', () => { + expect(parse({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1 })).toBe(false) + expect(parse({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body: 'text' })).toBe(false) + expect(parse({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body: {} })).toBe(false) + }) + + it('rejects an epoch row without a provider handle', () => { + expect(parse({ ...BASE, kind: 'epoch', reason: 'session_created' })).toBe(false) + }) + + it('rejects JSON-valid nested body corruption that would throw during render', () => { + const item = (body: unknown) => ({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body }) + // A resolved question's options are mapped by the projection; null throws there. + expect( + parse( + item({ + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }) + ) + ).toBe(false) + // Prompt surfaces read `resolution.state` before anything else. + expect( + parse(item({ kind: 'question', question: 'Deploy?', options: [], resolution: null })) + ).toBe(false) + expect(parse(item({ kind: 'message', role: 'user', blocks: 'not-blocks' }))).toBe(false) + expect(parse(item({ kind: 'diff', path: 'a.ts', patch: { head: 'x' } }))).toBe(false) + expect( + parse( + item({ kind: 'approval', title: 't', detail: null, options: [{ id: 1 }], resolution: null }) + ) + ).toBe(false) + // `turnLifecycle.turnId` is read whenever the value is truthy. + expect(parse(item({ kind: 'status', text: 'x', turnLifecycle: true }))).toBe(false) + }) + + it('rejects a submission row whose body is not a message item', () => { + expect( + parse({ + ...BASE, + kind: 'submission', + clientMessageId: 'm-1', + payloadFingerprint: 'a'.repeat(64), + providerHandle: { kind: 'codex', threadId: 't' }, + body: { kind: 'status', text: 'not a message' } + }) + ).toBe(false) + }) + + it('keeps forward compatibility for open string fields and unknown block types', () => { + const item = (body: unknown) => ({ ...BASE, kind: 'item', itemId: 'i-1', revision: 1, body }) + // Renderers select known block types by equality and skip the rest. + expect( + parse(item({ kind: 'message', role: 'user', blocks: [{ type: 'future-block', data: 1 }] })) + ).toBe(true) + // Role and tool-call state are type-checked, never enum-checked. + expect( + parse(item({ kind: 'message', role: 'narrator', blocks: [{ type: 'text', text: 'hi' }] })) + ).toBe(true) + expect(parse(item({ kind: 'tool-call', name: 'Read', input: {}, state: 'paused' }))).toBe(true) + expect( + parse( + item({ + kind: 'question', + question: 'Deploy?', + options: [{ id: 'a', label: 'Yes' }], + resolution: { + state: 'deferred', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + }, + futureField: 'ignored' + }) + ) + ).toBe(true) + }) + + it('keeps forward compatibility for new dispatch states without a version bump', () => { + expect( + parse({ + ...BASE, + kind: 'dispatch', + clientMessageId: 'm-1', + state: 'some-future-state', + providerItemId: null, + reason: null + }) + ).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts new file mode 100644 index 00000000000..5b1bb2fb415 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -0,0 +1,200 @@ +// Persisted journal row shapes plus read-time upcasting. +// +// The journal is append-only, so migration is upcasting on read and never an +// in-place rewrite. A row whose version this build does not understand is +// UNREADABLE, not skippable: the caller must degrade to read-only rather than +// render a partial timeline or compact past a row it cannot interpret. + +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalDispatchState, + type AgentJournalItemBody, + type AgentJournalMessageItem, + type AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { + isAdmissibleAgentJournalItemBody, + isAdmissibleAgentJournalMessageBody +} from '../../../shared/agent-session-journal-schemas' + +type JournalRowBase = { + /** Schema version of THIS row. */ + v: number + epoch: string + seq: number + /** Runtime fence held by the writer that appended the row. */ + fence: number + /** Observed (provider or host) timestamp. Ordering is by `seq`, not by this. */ + ts: number + /** Set when crash reconciliation appended the row after the fact. */ + recovered?: true +} + +/** First row of every epoch: binds the epoch to a provider handle and records why it opened. */ +export type JournalEpochRow = JournalRowBase & { + kind: 'epoch' + reason: AgentJournalEpochReason + providerHandle: AgentSessionProviderHandle +} + +export const AGENT_JOURNAL_EPOCH_REASONS = [ + 'session_created', + 'legacy_import', + 'corruption', + 'unreconcilable_prefix', + 'handle_forked', + 'schema_unreadable' +] as const +export type AgentJournalEpochReason = (typeof AGENT_JOURNAL_EPOCH_REASONS)[number] + +export type JournalItemRow = JournalRowBase & { + kind: 'item' + itemId: string + revision: number + body: AgentJournalItemBody +} + +export type JournalTombstoneRow = JournalRowBase & { + kind: 'tombstone' + itemId: string + revision: number +} + +/** The write-ahead row. Durable BEFORE the adapter dispatches anything; it + * doubles as the optimistic user bubble so an accepted echo has a slot to + * reconcile into instead of appending a second copy. */ +export type JournalSubmissionRow = JournalRowBase & { + kind: 'submission' + clientMessageId: string + payloadFingerprint: string + providerHandle: AgentSessionProviderHandle + body: AgentJournalMessageItem +} + +export type JournalDispatchRow = JournalRowBase & { + kind: 'dispatch' + clientMessageId: string + state: Exclude + /** Provider item identity adopted on accept. */ + providerItemId: string | null + reason: string | null +} + +export type JournalRow = + | JournalEpochRow + | JournalItemRow + | JournalTombstoneRow + | JournalSubmissionRow + | JournalDispatchRow + +export type JournalRowParse = + | { ok: true; row: JournalRow } + /** Malformed JSON or a shape this build rejects outright. */ + | { ok: false; unreadable: false } + /** A future schema version. The host must not write or compact this journal. */ + | { ok: false; unreadable: true } + +const ROW_KINDS = new Set(['epoch', 'item', 'tombstone', 'submission', 'dispatch']) + +export function serializeJournalRow(row: JournalRow): string { + return JSON.stringify(row) +} + +/** + * Parse one persisted line. Older versions are upcast; newer versions are + * reported as unreadable so the caller fails closed. + */ +export function parseJournalRow(line: string): JournalRowParse { + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + return { ok: false, unreadable: false } + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, unreadable: false } + } + const record = parsed as Record + const version = typeof record.v === 'number' ? record.v : null + if (version === null || !Number.isInteger(version) || version < 1) { + return { ok: false, unreadable: false } + } + if (version > AGENT_SESSION_JOURNAL_SCHEMA_VERSION) { + return { ok: false, unreadable: true } + } + const upcast = upcastRow(record, version) + return isJournalRow(upcast) ? { ok: true, row: upcast } : { ok: false, unreadable: false } +} + +/** Read-time upcast chain. Each step raises a row exactly one version. */ +function upcastRow(record: Record, version: number): Record { + let current = record + let at = version + while (at < AGENT_SESSION_JOURNAL_SCHEMA_VERSION) { + // No upcasters yet — v1 is the first shipped schema. New cases go here. + current = { ...current, v: at + 1 } + at += 1 + } + return current +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Open field values are type-checked, never enum-checked: a future build + * adding a dispatch state or handle kind must bump the row version, but this + * build should not misread a same-version row as malformed over a wider enum. + * Render BODIES are the exception and validate against the canonical deep + * schema — their nested shapes are dereferenced unguarded all the way to the + * rendered surface, so a JSON-valid corruption must fail here, not there. */ +function isJournalRow(record: Record): record is JournalRow { + if (typeof record.kind !== 'string' || !ROW_KINDS.has(record.kind)) { + return false + } + if ( + typeof record.epoch !== 'string' || + !record.epoch || + !Number.isInteger(record.seq) || + (record.seq as number) < 1 || + !Number.isInteger(record.fence) || + typeof record.ts !== 'number' + ) { + return false + } + if (record.kind === 'item') { + return ( + typeof record.itemId === 'string' && + Number.isInteger(record.revision) && + isAdmissibleAgentJournalItemBody(record.body) + ) + } + if (record.kind === 'tombstone') { + return typeof record.itemId === 'string' && Number.isInteger(record.revision) + } + if (record.kind === 'submission') { + return ( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) && + isAdmissibleAgentJournalMessageBody(record.body) + ) + } + if (record.kind === 'dispatch') { + return ( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.state === 'string' && + record.state.length > 0 && + (record.providerItemId === null || typeof record.providerItemId === 'string') && + (record.reason === null || typeof record.reason === 'string') + ) + } + return typeof record.reason === 'string' && isPlainObject(record.providerHandle) +} + +/** Approximate on-disk cost of a row, used for the per-session size bound. */ +export function journalRowByteLength(row: JournalRow): number { + return Buffer.byteLength(serializeJournalRow(row), 'utf8') + 1 +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts new file mode 100644 index 00000000000..4a864315c40 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -0,0 +1,42 @@ +import type { + AgentJournalCursor, + AgentJournalItemIdentity, + AgentJournalResetReason, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { JournalCompactionPolicy } from './journal-compaction' +import type { JournalLoad } from './journal-open' +import type { JournalPayloadLimits } from './journal-payload-bounds' +import type { JournalRow } from './journal-row-schema' + +export type AgentSessionJournalOptions = { + identity: AgentSessionJournalIdentity + journalDir: string + limits?: JournalPayloadLimits + compaction?: JournalCompactionPolicy + /** Compact as the tail grows. Defaults on: without it the log never sheds. */ + autoCompact?: boolean + now?: () => number + mintEpoch?: () => string + /** A caller that already loaded the journal can avoid reading the same files again. */ + loaded?: JournalLoad | null +} + +export type JournalReadSince = + | { ok: true; rows: JournalRow[]; cursor: AgentJournalCursor } + | { ok: false; reset: AgentJournalResetReason } + +export type ResolveDispatchInput = { + clientMessageId: string + fence: number + recovered?: true +} & ( + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'rejected' | 'unknown'; reason?: string | null } +) + +export type JournalAppendResult = { + cursor: AgentJournalCursor + itemId: string + revision: number +} diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts new file mode 100644 index 00000000000..6d850b64193 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -0,0 +1,756 @@ +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' +import { readJournalBlob } from './journal-blob-store' +import { JOURNAL_LOG_FILE, JOURNAL_SNAPSHOT_FILE } from './journal-log-file' +import { loadJournal } from './journal-open' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from './journal-payload-bounds' +import { journalDirectoryFor, journalPathSegment } from './journal-paths' +import { + AgentSessionJournalError, + openAgentSessionJournal, + type AgentSessionJournal +} from './journal-store' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(value: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } +} + +async function open(overrides: Partial[0]> = {}) { + return openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-')) + clock = 1_000 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('sequences', () => { + it('assigns a contiguous sequence with no gaps or reuse under concurrent appends', async () => { + const journal = await open() + const results = await Promise.all( + Array.from({ length: 25 }, (_unused, index) => + journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + ) + ) + const sequences = results.map((result) => result.cursor.sequence) + expect(new Set(sequences).size).toBe(25) + expect(sequences.slice().sort((a, b) => a - b)).toEqual( + Array.from({ length: 25 }, (_unused, index) => index + 2) + ) + }) + + it('serializes revisions of one item so the last write wins deterministically', async () => { + const journal = await open() + const results = await Promise.all([ + journal.appendItem(item(0), body('a'), { fence: 1 }), + journal.appendItem(item(0), body('b'), { fence: 1 }), + journal.appendItem(item(0), body('c'), { fence: 1 }) + ]) + expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) + expect(journal.snapshot().items).toHaveLength(1) + expect(journal.snapshot().items[0]?.revision).toBe(3) + }) + + it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const identityFor = (turnId: string): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'thread-1', + turnId, + ordinal: 0 + }) + const oversizedIdentity = identityFor(oversizedTurnId) + const mimicIdentity = identityFor(digestFormMimic) + const journal = await open() + + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { fence: 1 }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { fence: 1 }) + expect(oversized.itemId).not.toBe(mimic.itemId) + expect([oversized.revision, mimic.revision]).toEqual([1, 1]) + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('oversized'), + body('mimic') + ]) + + await reopened.appendTombstone(oversizedIdentity, { fence: 1 }) + const afterTombstoneReopen = await open() + expect(afterTombstoneReopen.snapshot().items.map((entry) => entry.body)).toEqual([ + body('mimic') + ]) + }) +}) + +describe('fences', () => { + it('rejects an append from a writer behind the journal', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 7 }) + await expect(journal.appendItem(item(1), body('b'), { fence: 6 })).rejects.toBeInstanceOf( + AgentSessionJournalError + ) + }) + + it('keeps accepting appends after a rejected one', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 7 }) + await journal.appendItem(item(1), body('b'), { fence: 6 }).catch(() => undefined) + await journal.appendItem(item(2), body('c'), { fence: 7 }) + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) + }) +}) + +describe('replay', () => { + it('adopts a caller-provided load without reading the journal files again', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const loaded = await loadJournal(root, IDENTITY.sessionId) + expect(loaded).not.toBeNull() + + await rm(join(root, JOURNAL_LOG_FILE), { force: true }) + await rm(join(root, JOURNAL_SNAPSHOT_FILE), { force: true }) + + const reopened = await open({ loaded }) + expect(reopened.snapshot()).toEqual(journal.snapshot()) + }) + + it('reopens to the same render model the live writer held', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendItem(item(1), body('b'), { fence: 1 }) + await journal.appendItem(item(0), body('a2'), { fence: 1 }) + await journal.appendTombstone(item(1), { fence: 1 }) + const live = journal.snapshot() + + const reopened = await open() + expect(reopened.snapshot()).toEqual(live) + }) + + it('serves a resume from a cursor and refuses one from a stale epoch', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('b'), { fence: 1 }) + + const resumed = journal.readSince(cursor) + expect(resumed.ok && resumed.rows).toHaveLength(1) + + await journal.rollEpoch('handle_forked', 2) + expect(journal.readSince(cursor)).toEqual({ ok: false, reset: 'epoch_changed' }) + }) + + it('rebuilds from a clean epoch after a rollover', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.rollEpoch('unreconcilable_prefix', 2) + expect(journal.snapshot().items).toHaveLength(0) + + const reopened = await open() + expect(reopened.epoch).toBe(journal.epoch) + expect(reopened.snapshot().items).toHaveLength(0) + }) + + it('preserves the intact prefix and quarantines a corrupt suffix', async () => { + const journal = await open() + for (let index = 0; index < 4; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + const before = journal.epoch + const logPath = join(root, JOURNAL_LOG_FILE) + const lines = (await readFile(logPath, 'utf-8')).split('\n').filter(Boolean) + await writeFile(logPath, `${[...lines.slice(0, 2), ...lines.slice(3)].join('\n')}\n`, 'utf-8') + + const reopened = await open() + expect(reopened.epoch).toBe(before) + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0')]) + const files = await readdir(root) + expect(files.some((name) => name.startsWith('quarantine-'))).toBe(true) + }) +}) + +describe('automatic compaction', () => { + // Production passes no policy and never called compact(), so the log only + // ever grew — until the size bound refused every append for good. + it('compacts on append once the retention window has rows to shed', async () => { + const policy = { minTailRows: 2, retainTailMs: 0 } + const journal = await open({ compaction: policy }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + expect(journal.compactionBoundary).toBeGreaterThan(0) + // The log sheds instead of growing with every append (7 = epoch row + 6). + const log = await readFile(join(root, JOURNAL_LOG_FILE), 'utf-8') + expect(log.trim().split('\n').length).toBeLessThan(7) + // Nothing is lost: the folded prefix is served from the snapshot. + expect(journal.snapshot().items).toHaveLength(6) + }) + + it('does not rewrite the log while every row is inside the retention window', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 60_000 } }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + expect(journal.compactionBoundary).toBe(0) + }) + + it('can be turned off explicitly', async () => { + const journal = await open({ + autoCompact: false, + compaction: { minTailRows: 2, retainTailMs: 0 } + }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + expect(journal.compactionBoundary).toBe(0) + }) + + it('refuses an append when a tail shorter than the row floor cannot make room', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 900 }, + // The tail never reaches the floor, so honouring it would shed nothing. + compaction: { minTailRows: 512, retainTailMs: 10_000 } + }) + let rejected = 0 + for (let index = 0; index < 20; index += 1) { + try { + await journal.appendItem(item(index), body('x'.repeat(96)), { fence: 1 }) + } catch (error) { + expect(error).toMatchObject({ code: 'journal_bound_exceeded' }) + rejected += 1 + } + } + expect(rejected).toBeGreaterThan(0) + }) + + it('refuses once the retained snapshot itself reaches the session bound', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 10_000 }, + compaction: { minTailRows: 10, retainTailMs: 2 * 60 * 60 * 1000 } + }) + let rejected = 0 + for (let index = 0; index < 30; index += 1) { + try { + await journal.appendItem(item(index), body('x'.repeat(128)), { fence: 1 }) + } catch (error) { + expect(error).toMatchObject({ code: 'journal_bound_exceeded' }) + rejected += 1 + } + } + + expect(rejected).toBeGreaterThan(0) + expect(journal.snapshot().items.length).toBeLessThan(30) + }) + + it('keeps the newest rows resumable while shedding under budget pressure', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 10_000 }, + compaction: { minTailRows: 10, retainTailMs: 2 * 60 * 60 * 1000 } + }) + for (let index = 0; index < 30; index += 1) { + await journal.appendItem(item(index), body('x'.repeat(64)), { fence: 1 }) + } + + // The window yields oldest-first, never wholesale: the latest append is + // still in the log, so a client resuming from it does not reload. + const log = (await readFile(join(root, JOURNAL_LOG_FILE), 'utf-8')).trim().split('\n') + expect(log.length).toBeGreaterThan(0) + expect(log.at(-1)).toContain('"seq"') + }) +}) + +describe('compaction and retention', () => { + it('preserves the highest fence across compaction and reopen', async () => { + const journal = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await journal.appendItem(item(0), body('a'), { fence: 7 }) + await journal.compact() + const reopened = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await expect(reopened.appendItem(item(1), body('stale'), { fence: 6 })).rejects.toMatchObject({ + code: 'journal_stale_fence' + }) + }) + + it('preserves tombstones across compaction and reopen', async () => { + const journal = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await journal.appendTombstone(item(0), { fence: 1 }) + await journal.compact() + const reopened = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + await reopened.appendItem(item(0), body('stale'), { fence: 1 }) + expect(reopened.snapshot().items).toHaveLength(0) + }) + + it('folds the prefix into the snapshot and keeps serving the retained tail', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + const rendered = journal.snapshot() + const tip = journal.cursor() + await journal.compact() + + expect(journal.snapshot()).toEqual(rendered) + expect(journal.readSince({ epoch: tip.epoch, sequence: 1 })).toEqual({ + ok: false, + reset: 'cursor_compacted' + }) + const nearTip = journal.readSince({ epoch: tip.epoch, sequence: tip.sequence - 1 }) + expect(nearTip.ok && nearTip.rows).toHaveLength(1) + + const reopened = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + expect(reopened.snapshot()).toEqual(rendered) + expect(reopened.compactionBoundary).toBe(tip.sequence) + }) + + it('publishes the snapshot and its tail as one write, so a crash before the log rewrite loses nothing', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + for (let index = 0; index < 5; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + const rendered = journal.snapshot() + const logBefore = await readFile(join(root, JOURNAL_LOG_FILE), 'utf-8') + await journal.compact() + const persistedSnapshot = JSON.parse( + await readFile(join(root, JOURNAL_SNAPSHOT_FILE), 'utf-8') + ) as { tail: unknown[] } + expect(persistedSnapshot.tail).toHaveLength(2) + // Simulate the crash: the snapshot landed, the truncation did not. + await writeFile(join(root, JOURNAL_LOG_FILE), logBefore, 'utf-8') + + const reopened = await open() + expect(reopened.snapshot()).toEqual(rendered) + expect(reopened.snapshot().items).toHaveLength(5) + }) + + it('prunes blobs no live row references and keeps the ones that survive', async () => { + const journal = await open({ compaction: { minTailRows: 1, retainTailMs: 0 } }) + const kept = boundPayload('k'.repeat(64), { + ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, + inlineHeadBytes: 8 + }) + const dropped = boundPayload('d'.repeat(64), { + ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, + inlineHeadBytes: 8 + }) + const { putJournalBlob } = await import('./journal-blob-store') + await putJournalBlob(root, kept.digest, 'k'.repeat(64)) + await putJournalBlob(root, dropped.digest, 'd'.repeat(64)) + await journal.appendItem( + item(0), + { kind: 'tool-call', name: 'bash', input: {}, state: 'completed', output: kept }, + { fence: 1 } + ) + await journal.compact() + + expect(await readJournalBlob(root, kept.digest)).toBe('k'.repeat(64)) + expect(await readJournalBlob(root, dropped.digest)).toBeNull() + }) + + it('refuses a blob name that is not a bare digest, on either slash', async () => { + const { putJournalBlob } = await import('./journal-blob-store') + // A corrupt or crafted row must not steer a read or a write out of the store. + for (const name of ['../../escape', '..\\..\\escape', 'nested/name', 'NOTHEX']) { + expect(await readJournalBlob(root, name)).toBeNull() + await expect(putJournalBlob(root, name, 'payload')).rejects.toThrow('sha256 digest') + } + }) +}) + +describe('bounds', () => { + it('marks a clipped payload instead of dropping bytes silently', () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 16 } + const bounded = boundPayload('x'.repeat(4_096), limits) + expect(bounded.truncated).toBe(true) + expect(bounded.head).toHaveLength(16) + expect(bounded.byteLength).toBe(4_096) + expect(boundInlineText('x'.repeat(4_096), limits).text).toContain('output truncated') + }) + + it('never splits a multi-byte character across the bound', () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 4 } + // Each character is three bytes, so a naive slice would land mid-sequence. + const bounded = boundPayload('日本語テスト', limits) + expect(bounded.head).toBe('日') + expect(Buffer.byteLength(bounded.head, 'utf8')).toBeLessThanOrEqual(4) + }) + + it('leaves a payload inside the bound untouched', () => { + const bounded = boundPayload('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS) + expect(bounded.truncated).toBe(false) + expect(bounded.head).toBe('small') + expect(boundInlineText('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS).text).toBe('small') + }) + + it('refuses a single row larger than the per-session size bound', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 400 } + }) + // Shedding the whole tail still cannot make room, so the bound holds. + await expect( + journal.appendItem(item(0), body('x'.repeat(4_096)), { fence: 1 }) + ).rejects.toMatchObject({ code: 'journal_bound_exceeded' }) + }) + + it('refuses an append past the per-session size bound when compaction is off', async () => { + const journal = await open({ + autoCompact: false, + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxSessionBytes: 400 } + }) + await expect( + (async () => { + for (let index = 0; index < 50; index += 1) { + await journal.appendItem(item(index), body('x'.repeat(64)), { fence: 1 }) + } + })() + ).rejects.toMatchObject({ code: 'journal_bound_exceeded' }) + }) + + it('refuses an append past the per-window rate bound', async () => { + const journal = await open({ + limits: { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, maxAppendsPerWindow: 3, appendWindowMs: 60_000 } + }) + await expect( + (async () => { + for (let index = 0; index < 10; index += 1) { + await journal.appendItem(item(index), body('x'), { fence: 1 }) + } + })() + ).rejects.toMatchObject({ code: 'journal_rate_exceeded' }) + }) +}) + +describe('schema', () => { + it('quarantines an invalid compacted snapshot without replacing its tail', async () => { + const journal = await open({ compaction: { minTailRows: 2, retainTailMs: 0 } }) + for (let index = 0; index < 6; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { fence: 1 }) + } + await journal.compact() + const epoch = journal.epoch + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const logPath = join(root, JOURNAL_LOG_FILE) + const invalidSnapshot = '{"folded history":' + await writeFile(snapshotPath, invalidSnapshot, 'utf-8') + const retainedTail = await readFile(logPath, 'utf-8') + expect(retainedTail).not.toContain('"kind":"epoch"') + + const reopened = await open() + expect(reopened.epoch).toBe(epoch) + expect(await readFile(logPath, 'utf-8')).toBe(retainedTail) + const quarantined = (await readdir(root)).find((name) => + name.startsWith('quarantine-snapshot-') + ) + expect(quarantined).toBeDefined() + expect(await readFile(join(root, quarantined!), 'utf-8')).toBe(invalidSnapshot) + }) + + it('degrades to read-only on a row from a newer build, without skipping or deleting it', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + const future = JSON.stringify({ + v: 99, + kind: 'item', + epoch: journal.epoch, + seq: 99, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer host' } + }) + const before = await readFile(logPath, 'utf-8') + await writeFile(logPath, `${before}${future}\n`, 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(true) + await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + await expect(reopened.compact()).rejects.toMatchObject({ code: 'journal_read_only' }) + expect(reopened.readSince({ epoch: reopened.epoch, sequence: 0 })).toEqual({ + ok: false, + reset: 'schema_unreadable' + }) + // The unreadable row is still on disk, and nothing was compacted past it. + expect(await readFile(logPath, 'utf-8')).toContain('"v":99') + }) + + it('skips a malformed line without giving up the journal, and discloses the skip', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}{not json\n`, 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(false) + const items = reopened.snapshot().items + // The surviving row is untouched… + expect(items.some((entry) => entry.body.kind === 'message')).toBe(true) + // …and the skip is visible in the timeline instead of silently swallowed. + expect( + items.some( + (entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read') + ) + ).toBe(true) + }) + + it('keeps one disclosure row across reopens instead of stacking duplicates', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}{not json\n`, 'utf-8') + + await open() + const reopened = await open() + expect( + reopened + .snapshot() + .items.filter( + (entry) => entry.body.kind === 'status' && entry.body.text.includes('could not be read') + ) + ).toHaveLength(1) + }) + + it('repairs a torn tail before acknowledging the next append', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + const intact = await readFile(logPath, 'utf-8') + await writeFile(logPath, intact.slice(0, -1), 'utf-8') + + await journal.appendItem(item(1), body('b'), { fence: 1 }) + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('b')]) + }) + + // Transcripts are full of emoji and CJK, so the repair's file offsets must be + // bytes: string indices would truncate mid-character and corrupt the prefix. + it('repairs a torn tail whose rows contain multi-byte characters', async () => { + const journal = await open() + await journal.appendItem(item(0), body('안녕하세요 🌊 café'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + const intact = await readFile(logPath) + // Kill mid-row: keep the complete first row plus a fragment of the second. + const torn = Buffer.concat([intact, Buffer.from('{"seq":2,"kind":"it', 'utf-8')]) + await writeFile(logPath, torn) + + await journal.appendItem(item(1), body('b'), { fence: 1 }) + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('안녕하세요 🌊 café'), + body('b') + ]) + }) + + it('degrades to read-only when the snapshot comes from a newer schema', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(true) + await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + }) + + it('preserves a future-version snapshot with an unknown body kind in place instead of quarantining it', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + // The version advances because bodies changed: a valid newer snapshot + // carries kinds this build cannot parse and must stay unreadable in place. + snapshot.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'future-render-kind', payload: { anything: true } }, + sequence: 1, + observedAt: 1_000 + } + ] + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + + const reopened = await open() + const entries = await readdir(root) + expect(entries.some((name) => name.startsWith('quarantine-'))).toBe(false) + expect(entries.includes(JOURNAL_SNAPSHOT_FILE)).toBe(true) + expect(reopened.isReadOnly).toBe(true) + expect(reopened.snapshot().items).toHaveLength(0) + await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + }) + + it('keeps the future-version snapshot bytes when the schema escape hatch rolls the epoch', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + snapshot.items = [ + { + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body: { kind: 'future-render-kind', payload: { anything: true } }, + sequence: 1, + observedAt: 1_000 + } + ] + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + const reopened = await open() + // Still live in place before the explicit escape hatch runs. + expect((await readdir(root)).some((name) => name.startsWith('quarantine-'))).toBe(false) + + await reopened.rollEpoch('schema_unreadable', 2) + expect(reopened.isReadOnly).toBe(false) + const quarantine = (await readdir(root)).find((name) => name.startsWith('quarantine-')) + expect(quarantine).toBeDefined() + expect(await readFile(join(root, quarantine!), 'utf-8')).toContain('future-render-kind') + }) + + it('reopens a log holding an admitted malformed-percent item id without throwing', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const logPath = join(root, JOURNAL_LOG_FILE) + // `parseJournalRow` admits any string itemId, so replay must degrade a + // malformed percent key to an opaque id instead of throwing URIError. + const malformedKeyRow = JSON.stringify({ + v: 1, + epoch: journal.epoch, + seq: journal.cursor().sequence + 1, + fence: 1, + ts: 1, + kind: 'item', + itemId: '%', + revision: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}${malformedKeyRow}\n`, 'utf-8') + + const reopened = await open() + expect(reopened.isReadOnly).toBe(false) + expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true) + }) + + it('allows the explicit schema-unreadable epoch escape hatch while preserving the old files', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + const snapshotPath = join(root, JOURNAL_SNAPSHOT_FILE) + const snapshot = JSON.parse(await readFile(snapshotPath, 'utf-8')) as Record + snapshot.v = 99 + await writeFile(snapshotPath, JSON.stringify(snapshot), 'utf-8') + const reopened = await open() + + await reopened.rollEpoch('schema_unreadable', 2) + expect(reopened.isReadOnly).toBe(false) + expect(reopened.snapshot().items).toHaveLength(0) + expect((await readdir(root)).some((name) => name.startsWith('quarantine-'))).toBe(true) + }) + + it('keeps the unreadable log suffix in the schema escape quarantine', async () => { + const journal = await open() + const logPath = join(root, JOURNAL_LOG_FILE) + const future = JSON.stringify({ + v: 99, + kind: 'item', + epoch: journal.epoch, + seq: 2, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'preserve these bytes' } + }) + await writeFile(logPath, `${await readFile(logPath, 'utf-8')}${future}\n`, 'utf-8') + const reopened = await open() + + await reopened.rollEpoch('schema_unreadable', 2) + const quarantine = (await readdir(root)).find((name) => name.startsWith('quarantine-')) + expect(quarantine).toBeDefined() + expect(await readFile(join(root, quarantine!), 'utf-8')).toContain('preserve these bytes') + }) +}) + +describe('journal location', () => { + it('keys by workspace and session id rather than by a path in the working tree', () => { + const dir = journalDirectoryFor('/state', { workspaceId: 'ws/1', sessionId: 'sess:2' }) + expect(dir).toBe( + join( + '/state', + 'agent-session-journal', + journalPathSegment('ws/1'), + journalPathSegment('sess:2') + ) + ) + expect(dir).not.toContain('ws/1') + }) + + it('separates two sessions in one workspace', () => { + const a = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'a' }) + const b = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'b' }) + expect(a).not.toBe(b) + }) +}) + +describe('on-disk layout', () => { + it('writes the log and snapshot beside each other', async () => { + const journal: AgentSessionJournal = await open() + await journal.appendItem(item(0), body('a'), { fence: 1 }) + await expect(readFile(join(root, JOURNAL_LOG_FILE), 'utf-8')).resolves.toContain( + '"kind":"item"' + ) + await expect(readFile(join(root, JOURNAL_SNAPSHOT_FILE), 'utf-8')).resolves.toContain('"epoch"') + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts new file mode 100644 index 00000000000..52a47ae2561 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -0,0 +1,361 @@ +// Append-only journal store for one agent session. + +import { randomUUID } from 'node:crypto' +import type { + AgentJournalAcceptanceReceipt, + AgentJournalCursor, + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentJournalSnapshot, + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + budgetPressurePolicy, + compactJournal, + DEFAULT_JOURNAL_COMPACTION_POLICY, + journalTailCanShedRows, + journalTailIsReadyToCompact, + type JournalCompactionPolicy +} from './journal-compaction' +import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' +import { readJournalSince } from './journal-cursor' +import { publishNewEpoch } from './journal-epoch-rollover' +import { appendJournalRows, ensureJournalDir } from './journal-log-file' +import { + malformedRowsDisclosure, + quarantineCorruptSuffix, + quarantineUnreadableSchema +} from './journal-corruption-quarantine' +import { loadJournal, type JournalLoad } from './journal-open' +import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' +import { markJournalPendingSubmissionsUnknown } from './journal-pending-submission-recovery' +import { + applyJournalRow, + createJournalReducerState, + referencedBlobDigests, + renderJournalState, + resolveJournalItemId, + type JournalReducerState +} from './journal-reducer' +import { + journalDispatchRowBuilder, + journalItemRowBuilder, + journalSubmissionRowBuilder, + journalTombstoneRowBuilder +} from './journal-row-builders' +import type { + AgentSessionJournalOptions, + JournalAppendResult, + JournalReadSince, + ResolveDispatchInput +} from './journal-store-contracts' +import { + journalRowByteLength, + type AgentJournalEpochReason, + type JournalRow +} from './journal-row-schema' +import { + assertJournalFence, + assertJournalWritable, + JournalAppendBudget +} from './journal-write-guards' + +export { AgentSessionJournalError } from './journal-write-guards' + +export async function openAgentSessionJournal( + options: AgentSessionJournalOptions +): Promise { + const journal = new AgentSessionJournal(options) + await journal.open() + return journal +} + +export class AgentSessionJournal { + private readonly identity: AgentSessionJournalIdentity + private readonly journalDir: string + private readonly budget: JournalAppendBudget + private readonly compaction: JournalCompactionPolicy + private readonly autoCompact: boolean + private readonly now: () => number + private readonly mintEpoch: () => string + private readonly loaded: JournalLoad | null | undefined + + private state: JournalReducerState + private tailRows: JournalRow[] = [] + private compactedThrough = 0 + private sizeBytes = 0 + private readOnly = false + private malformedRows = 0 + /** Serializes sequence assignment with the durable write behind it. */ + private writes: Promise = Promise.resolve() + + constructor(options: AgentSessionJournalOptions) { + this.identity = options.identity + this.journalDir = options.journalDir + this.budget = new JournalAppendBudget( + options.identity.sessionId, + options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS + ) + this.autoCompact = options.autoCompact ?? true + this.compaction = options.compaction ?? DEFAULT_JOURNAL_COMPACTION_POLICY + this.now = options.now ?? (() => Date.now()) + this.mintEpoch = options.mintEpoch ?? randomUUID + this.loaded = options.loaded + this.state = createJournalReducerState(options.identity.sessionId, '') + } + + get isReadOnly(): boolean { + return this.readOnly + } + + get epoch(): string { + return this.state.epoch + } + + get directory(): string { + return this.journalDir + } + + /** Highest sequence folded into the snapshot; rows at or below it are no + * longer individually replayable. */ + get compactionBoundary(): number { + return this.compactedThrough + } + + async open(): Promise { + await ensureJournalDir(this.journalDir) + const loaded = + this.loaded !== undefined + ? this.loaded + : await loadJournal(this.journalDir, this.identity.sessionId) + if (!loaded) { + await this.startEpoch('session_created', 0) + return + } + this.adoptLoadedJournal(loaded) + if (loaded.corrupt && !loaded.readOnly) { + // The epoch stays put: no intact history is discarded to recover. + await quarantineCorruptSuffix(this.journalDir, this.tailRows, loaded.quarantineRemainder) + } + if (this.malformedRows > 0 && !this.readOnly) { + const disclosure = malformedRowsDisclosure(this.malformedRows) + await this.appendItem(disclosure.identity, disclosure.body, { + fence: this.state.highestFence + }) + } + } + + cursor = (): AgentJournalCursor => ({ + epoch: this.state.epoch, + sequence: this.state.lastSequence + }) + + snapshot = (): AgentJournalSnapshot => renderJournalState(this.state) + + submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()] + + pendingSubmissions = (): AgentJournalSubmission[] => + this.submissions().filter((entry) => entry.dispatchState === 'pending') + + /** The durable answer to "did my send land?" — a reconnecting client asking + * again gets this instead of re-sending. */ + receiptFor(clientMessageId: string): AgentJournalAcceptanceReceipt | null { + return this.state.receipts.get(clientMessageId) ?? null + } + + canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) + + referencedBlobDigests(): Set { + return referencedBlobDigests(this.state) + } + + readSince(cursor: AgentJournalCursor): JournalReadSince { + return readJournalSince( + { state: this.state, tailRows: this.tailRows, readOnly: this.readOnly }, + cursor, + () => this.cursor() + ) + } + + /** Upsert by stable identity. The revision is assigned here so a caller + * cannot accidentally publish a revision the reducer will drop. */ + appendItem( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + options: { fence: number; observedAt?: number; recovered?: true } = { fence: 0 } + ): Promise { + const itemId = agentJournalItemKey(identity) + return this.enqueue(journalItemRowBuilder(() => this.state, identity, body, options)).then( + (row) => ({ + cursor: { epoch: row.epoch, sequence: row.seq }, + itemId, + revision: (row as Extract).revision + }) + ) + } + + appendTombstone( + identity: AgentJournalItemIdentity, + options: { fence: number } + ): Promise { + const itemId = agentJournalItemKey(identity) + return this.enqueue(journalTombstoneRowBuilder(() => this.state, itemId, options.fence)).then( + (row) => ({ epoch: row.epoch, sequence: row.seq }) + ) + } + + /** + * Write-ahead submission row. It is durable before the caller dispatches + * anything, and it doubles as the optimistic user bubble so an accepted echo + * reconciles into an existing slot instead of appending a second copy. + */ + appendSubmission(input: { + clientMessageId: string + payloadFingerprint: string + body: AgentJournalMessageItem + fence: number + }): Promise { + return this.enqueue( + journalSubmissionRowBuilder(() => this.state, this.identity.providerHandle, input) + ).then((row) => ({ epoch: row.epoch, sequence: row.seq })) + } + + /** + * Advance a submission to exactly one of accepted / rejected / unknown. + * + * Accepting REQUIRES the provider identity rather than a free-form id: the + * adopted key is what the provider's echo will upsert into, so a mismatched + * string here would silently give the user a second copy of their own message. + */ + resolveDispatch(input: ResolveDispatchInput): Promise { + return this.enqueue(journalDispatchRowBuilder(() => this.state, input)).then((row) => ({ + epoch: row.epoch, + sequence: row.seq + })) + } + + /** On restart every `pending` submission becomes `unknown` before the session + * accepts a writer. Orca never re-sends on the user's behalf. */ + async markPendingSubmissionsUnknown(fence: number): Promise { + return markJournalPendingSubmissionsUnknown(this, fence) + } + + async compact( + now = this.now(), + policy: JournalCompactionPolicy = this.compaction + ): Promise { + assertJournalWritable(this.readOnly, this.identity.sessionId) + const result = await compactJournal({ + journalDir: this.journalDir, + state: this.state, + tailRows: this.tailRows, + policy, + now, + maxSessionBytes: this.budget.maxSessionBytes + }) + this.tailRows = result.tailRows + this.compactedThrough = result.compactedThrough + this.state.oldestSequence = result.oldestSequence + this.sizeBytes = this.tailRows.reduce((total, row) => total + journalRowByteLength(row), 0) + } + + /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, + * and an unreadable schema. It invalidates every cursor; clients reload. */ + async rollEpoch(reason: AgentJournalEpochReason, fence: number): Promise { + if (reason !== 'schema_unreadable') { + assertJournalWritable(this.readOnly, this.identity.sessionId) + } else if (this.readOnly) { + await quarantineUnreadableSchema(this.journalDir) + } + await this.startEpoch(reason, fence) + this.readOnly = false + return this.cursor() + } + + replaceEpochItems( + reason: AgentJournalEpochReason, + fence: number, + items: readonly JournalReplacementItem[] + ): Promise { + const run = this.writes.then(async () => { + assertJournalWritable(this.readOnly, this.identity.sessionId) + assertJournalFence(fence, this.state.highestFence) + await replaceJournalEpoch({ + journalDir: this.journalDir, + identity: this.identity, + reason, + fence, + items, + budget: this.budget.fork(), + compaction: this.compaction, + now: this.now, + mintEpoch: this.mintEpoch, + onSnapshotPublished: (loaded) => this.adoptLoadedJournal(loaded) + }) + return this.cursor() + }) + this.writes = run.catch(() => undefined) + return run + } + + private async startEpoch(reason: AgentJournalEpochReason, fence: number): Promise { + this.adoptLoadedJournal( + await publishNewEpoch({ + journalDir: this.journalDir, + sessionId: this.identity.sessionId, + providerHandle: this.identity.providerHandle, + epoch: this.mintEpoch(), + reason, + fence, + now: this.now() + }) + ) + } + + private adoptLoadedJournal(loaded: JournalLoad): void { + this.state = loaded.state + this.tailRows = loaded.tailRows + this.compactedThrough = loaded.compactedThrough + this.sizeBytes = loaded.sizeBytes + this.readOnly = loaded.readOnly + this.malformedRows = loaded.malformedRows + } + + /** + * Assign the next sequence, make the row durable, and fold it through the + * SAME reducer replay uses — all inside one serialized step, so concurrent + * callers cannot interleave and mint the same sequence. + */ + private enqueue(build: (seq: number, ts: number) => JournalRow): Promise { + const run = this.writes.then(async () => { + assertJournalWritable(this.readOnly, this.identity.sessionId) + const ts = this.now() + const row = build(this.state.lastSequence + 1, ts) + assertJournalFence(row.fence, this.state.highestFence) + const budgetCompaction = budgetPressurePolicy(this.compaction) + if ( + this.autoCompact && + this.budget.wouldExceedSize(row, this.sizeBytes) && + journalTailCanShedRows(this.tailRows, budgetCompaction, ts) + ) { + await this.compact(ts, budgetCompaction) + } + this.budget.assert(row, ts, this.sizeBytes) + await appendJournalRows(this.journalDir, [row]) + applyJournalRow(this.state, row) + this.tailRows.push(row) + this.sizeBytes += journalRowByteLength(row) + // Nothing else calls compact(), so without this the log only ever grows — + // until the size bound refuses every append for the rest of the session. + if (this.autoCompact && journalTailIsReadyToCompact(this.tailRows, this.compaction, ts)) { + await this.compact(ts) + } + return row + }) + this.writes = run.catch(() => undefined) + return run + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts new file mode 100644 index 00000000000..50a47d02bd1 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-reconciler.ts @@ -0,0 +1,191 @@ +// Restart reconciliation for the crash boundary. +// +// A submission row is durable before dispatch, so after a crash the host knows +// what it TRIED to send but not whether the provider took it. Every surviving +// `pending` becomes `unknown` and is then matched against provider history. +// +// Matching is by identity only — an echoed client message id, else a provider +// item id the journal already adopted, else the payload fingerprint when it +// picks out exactly one unclaimed item. Never by text equality: the same +// question asked twice is two messages, and collapsing them silently loses one. +// +// Orca never re-sends on the user's behalf. An unresolved submission stays +// `unknown` — a displayed state meaning "delivery unconfirmed", neither sent nor +// failed — and the user chooses to resend or discard. + +import type { + AgentJournalItemIdentity, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' + +export type ProviderHistoryItem = { + /** The provider's own id for this item. Used to claim it at most once; the + * journal key comes from `identity`, because a provider id is not stable. */ + providerItemId: string + /** The client message id when the provider echoes one (Codex carries it on + * user messages); null for providers that drop it. */ + clientMessageId: string | null + /** Fingerprint of the submitted payload, when the caller can compute one from + * provider content. Used only to break an otherwise unique tie. */ + payloadFingerprint: string | null + identity: AgentJournalItemIdentity +} + +export type ProviderHistoryWindow = { + /** Provider items observed at or after the journal's last committed item. */ + items: readonly ProviderHistoryItem[] + /** + * The history read actually started at the journal's last committed item. A + * fork, a compacted provider log, or a truncated read makes absence + * meaningless, so a missing submission cannot be called "not delivered". + */ + boundaryConsistent: boolean + /** The provider reports a turn still running: absence proves nothing yet. */ + turnInFlight: boolean +} + +export type SubmissionReconciliation = + | { + clientMessageId: string + outcome: 'accepted' + providerItemId: string + identity: AgentJournalItemIdentity + } + | { clientMessageId: string; outcome: 'rejected'; reason: SubmissionRejectionReason } + | { clientMessageId: string; outcome: 'unknown'; reason: SubmissionUnknownReason } + +export type SubmissionRejectionReason = 'not_delivered' + +export type SubmissionUnknownReason = + | 'history_boundary_inconsistent' + | 'turn_in_flight' + | 'ambiguous_match' + +/** + * Resolve every unsettled submission against provider history. + * + * Passes run strongest-first across ALL submissions before the next begins, so + * a weak fingerprint tie can never claim an item that an echoed client message + * id would have matched exactly. Each history item is claimable once. + */ +export function reconcileSubmissions(input: { + submissions: readonly AgentJournalSubmission[] + history: ProviderHistoryWindow +}): SubmissionReconciliation[] { + const unsettled = input.submissions.filter( + (submission) => submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' + ) + const claimed = new Set() + const matched = new Map() + const ambiguous = new Set() + + claimBy( + unsettled, + input.history.items, + claimed, + matched, + (submission, item) => + // A submission that already adopted a key re-matches on that key, not on the + // provider's raw id — the raw id renumbers, the identity-derived key does not. + Boolean(submission.providerItemId) && + submission.providerItemId === agentJournalItemKey(item.identity) + ) + claimBy( + unsettled, + input.history.items, + claimed, + matched, + (submission, item) => + Boolean(item.clientMessageId) && item.clientMessageId === submission.clientMessageId + ) + + for (const submission of unsettled) { + if (matched.has(submission.clientMessageId)) { + continue + } + const candidates = input.history.items.filter( + (item) => + !claimed.has(item.providerItemId) && + Boolean(item.payloadFingerprint) && + item.payloadFingerprint === submission.payloadFingerprint + ) + const only = candidates.length === 1 ? candidates[0] : undefined + if (only) { + claimed.add(only.providerItemId) + matched.set(submission.clientMessageId, only) + } else if (candidates.length > 1) { + // Two identical payloads and no id to tell them apart: guessing would + // either duplicate the user's message or drop one of them. + ambiguous.add(submission.clientMessageId) + } + } + + return unsettled.map((submission) => resolveOne(submission, matched, ambiguous, input.history)) +} + +function claimBy( + submissions: readonly AgentJournalSubmission[], + items: readonly ProviderHistoryItem[], + claimed: Set, + matched: Map, + matches: (submission: AgentJournalSubmission, item: ProviderHistoryItem) => boolean +): void { + for (const submission of submissions) { + if (matched.has(submission.clientMessageId)) { + continue + } + const item = items.find((candidate) => { + return !claimed.has(candidate.providerItemId) && matches(submission, candidate) + }) + if (item) { + claimed.add(item.providerItemId) + matched.set(submission.clientMessageId, item) + } + } +} + +function resolveOne( + submission: AgentJournalSubmission, + matched: Map, + ambiguous: Set, + history: ProviderHistoryWindow +): SubmissionReconciliation { + const item = matched.get(submission.clientMessageId) + if (item) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'accepted', + providerItemId: item.providerItemId, + identity: item.identity + } + } + if (ambiguous.has(submission.clientMessageId)) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'unknown', + reason: 'ambiguous_match' + } + } + if (!history.boundaryConsistent) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'unknown', + reason: 'history_boundary_inconsistent' + } + } + if (history.turnInFlight) { + return { + clientMessageId: submission.clientMessageId, + outcome: 'unknown', + reason: 'turn_in_flight' + } + } + // Absent from a history we can trust the boundary of, with nothing running: + // the provider never took it. + return { + clientMessageId: submission.clientMessageId, + outcome: 'rejected', + reason: 'not_delivered' + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts new file mode 100644 index 00000000000..83071595f8b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -0,0 +1,86 @@ +// Guards an append clears before it becomes durable. +// +// All four refuse loudly rather than degrade: a silent drop here is a message +// missing from the transcript with nothing to explain it. + +import type { JournalPayloadLimits } from './journal-payload-bounds' +import { journalRowByteLength, type JournalRow } from './journal-row-schema' + +export class AgentSessionJournalError extends Error { + constructor( + readonly code: + | 'journal_read_only' + | 'journal_stale_fence' + | 'journal_bound_exceeded' + | 'journal_rate_exceeded', + message: string + ) { + super(message) + this.name = 'AgentSessionJournalError' + } +} + +/** A journal written by a newer schema is readable but never writable: this + * host cannot represent rows it does not understand. */ +export function assertJournalWritable(readOnly: boolean, sessionId: string): void { + if (readOnly) { + throw new AgentSessionJournalError( + 'journal_read_only', + `agent-session journal for ${sessionId} uses a newer schema; this host is read-only` + ) + } +} + +/** A write from a superseded owner is rejected outright — merging it would let + * two writers share one sequence space. */ +export function assertJournalFence(fence: number, highestFence: number): void { + if (fence < highestFence) { + throw new AgentSessionJournalError( + 'journal_stale_fence', + `fence ${fence} is behind the journal's ${highestFence}` + ) + } +} + +/** Total size and append rate for one session, bounding a runaway agent. */ +export class JournalAppendBudget { + private windowStart = 0 + private appendsInWindow = 0 + + constructor( + private readonly sessionId: string, + private readonly limits: JournalPayloadLimits + ) {} + + fork(): JournalAppendBudget { + return new JournalAppendBudget(this.sessionId, this.limits) + } + + get maxSessionBytes(): number { + return this.limits.maxSessionBytes + } + + wouldExceedSize(row: JournalRow, sizeBytes: number): boolean { + return sizeBytes + journalRowByteLength(row) > this.limits.maxSessionBytes + } + + assert(row: JournalRow, ts: number, sizeBytes: number): void { + if (this.wouldExceedSize(row, sizeBytes)) { + throw new AgentSessionJournalError( + 'journal_bound_exceeded', + `agent-session journal for ${this.sessionId} reached its ${this.limits.maxSessionBytes}-byte bound` + ) + } + if (ts - this.windowStart >= this.limits.appendWindowMs) { + this.windowStart = ts + this.appendsInWindow = 0 + } + this.appendsInWindow += 1 + if (this.appendsInWindow > this.limits.maxAppendsPerWindow) { + throw new AgentSessionJournalError( + 'journal_rate_exceeded', + `agent-session journal for ${this.sessionId} exceeded ${this.limits.maxAppendsPerWindow} appends per ${this.limits.appendWindowMs}ms` + ) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts new file mode 100644 index 00000000000..47db72cb28e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_DELTA_COALESCE_MS, + createAgentSessionDeltaCoalescer +} from './agent-session-delta-coalescer' + +/** Drives the window by hand so the test asserts scheduling, not wall time. */ +function manualClock() { + const pending: { run: () => void; ms: number }[] = [] + return { + schedule: (run: () => void, ms: number) => { + const entry = { run, ms } + pending.push(entry) + return () => { + const index = pending.indexOf(entry) + if (index !== -1) { + pending.splice(index, 1) + } + } + }, + fire: () => { + const due = pending.splice(0) + for (const entry of due) { + entry.run() + } + }, + windows: () => pending.map((entry) => entry.ms), + pendingCount: () => pending.length + } +} + +function coalescer(clock: ReturnType, windowMs?: number) { + const emitted: [string, string][] = [] + const instance = createAgentSessionDeltaCoalescer({ + emit: (key, text) => emitted.push([key, text]), + schedule: clock.schedule, + ...(windowMs === undefined ? {} : { windowMs }) + }) + return { instance, emitted } +} + +describe('agent-session delta coalescer', () => { + it('folds a burst into one emit carrying the full text, on one shared window', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'he') + instance.append('item-1', 'llo') + instance.append('item-2', 'world') + + expect(emitted).toEqual([]) + expect(clock.windows()).toEqual([AGENT_SESSION_DELTA_COALESCE_MS]) + + clock.fire() + expect(emitted).toEqual([ + ['item-1', 'hello'], + ['item-2', 'world'] + ]) + }) + + it('emits the accumulated snapshot again, not the increment, on the next window', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'he') + clock.fire() + instance.append('item-1', 'llo') + clock.fire() + + expect(emitted).toEqual([ + ['item-1', 'he'], + ['item-1', 'hello'] + ]) + }) + + it('does not re-emit a stream with no new text', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'hi') + clock.fire() + instance.flushAll() + + expect(emitted).toEqual([['item-1', 'hi']]) + }) + + it('flushes pending text ahead of a lifecycle event and cancels the window', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'partial') + instance.flushAll() + + expect(emitted).toEqual([['item-1', 'partial']]) + expect(clock.pendingCount()).toBe(0) + }) + + it('drops a forgotten stream without emitting it, because its final body already landed', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'stale') + instance.append('item-2', 'kept') + instance.forget('item-1') + clock.fire() + + expect(emitted).toEqual([['item-2', 'kept']]) + }) + + it('emits nothing after dispose, and leaves no timer behind', () => { + const clock = manualClock() + const { instance, emitted } = coalescer(clock) + + instance.append('item-1', 'gone') + instance.dispose() + clock.fire() + + expect(emitted).toEqual([]) + expect(clock.pendingCount()).toBe(0) + }) + + it('honours an overridden window', () => { + const clock = manualClock() + const { instance } = coalescer(clock, 5) + + instance.append('item-1', 'x') + + expect(clock.windows()).toEqual([5]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts new file mode 100644 index 00000000000..6b230a2c630 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-delta-coalescer.ts @@ -0,0 +1,93 @@ +// Server-side coalescing for streamed assistant text. +// +// Providers emit one notification per token. Journaling each one would write a +// row and wake every subscriber per token, so a single long answer costs +// thousands of appends and thousands of stream frames on a phone. Deltas are +// therefore accumulated and flushed on a short window; the journal row is a +// SNAPSHOT of the text so far, which is also what makes dropping intermediate +// frames safe for a reconnecting client. +// +// The window applies to text only. Lifecycle — an item completing, a turn +// ending, an approval arriving — bypasses it by flushing first, so nothing can +// be journaled ahead of the text that preceded it. + +/** Long enough to fold a burst of tokens into one row, short enough that the + * text still reads as streaming. */ +export const AGENT_SESSION_DELTA_COALESCE_MS = 60 + +export type AgentSessionDeltaCoalescerDeps = { + /** Called with the FULL text accumulated for the key, not the increment. */ + emit: (key: string, text: string) => void + windowMs?: number + /** Injected by tests so a window can be driven without real time. */ + schedule?: (run: () => void, ms: number) => () => void +} + +export type AgentSessionDeltaCoalescer = { + append: (key: string, delta: string) => void + /** Emit one stream now, if it has unflushed text. */ + flush: (key: string) => void + /** Emit every stream now. The lifecycle bypass. */ + flushAll: () => void + /** Drop a stream without emitting — its authoritative body arrived, so the + * accumulated text is now the stale copy. */ + forget: (key: string) => void + dispose: () => void +} + +function defaultSchedule(run: () => void, ms: number): () => void { + const timer = setTimeout(run, ms) + timer.unref?.() + return () => clearTimeout(timer) +} + +export function createAgentSessionDeltaCoalescer( + deps: AgentSessionDeltaCoalescerDeps +): AgentSessionDeltaCoalescer { + const windowMs = deps.windowMs ?? AGENT_SESSION_DELTA_COALESCE_MS + const schedule = deps.schedule ?? defaultSchedule + const streams = new Map() + let cancelTimer: (() => void) | null = null + + const flushKey = (key: string): void => { + const stream = streams.get(key) + if (!stream?.dirty) { + return + } + stream.dirty = false + deps.emit(key, stream.text) + } + + const flushAll = (): void => { + cancelTimer?.() + cancelTimer = null + for (const key of streams.keys()) { + flushKey(key) + } + } + + return { + append: (key, delta) => { + const stream = streams.get(key) ?? { text: '', dirty: false } + stream.text += delta + stream.dirty = true + streams.set(key, stream) + // One timer for every stream: a shared deadline bounds latency the same + // way and costs one wakeup per window instead of one per stream. + cancelTimer ??= schedule(() => { + cancelTimer = null + flushAll() + }, windowMs) + }, + flush: flushKey, + flushAll, + forget: (key) => { + streams.delete(key) + }, + dispose: () => { + cancelTimer?.() + cancelTimer = null + streams.clear() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts new file mode 100644 index 00000000000..51c2835c51d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -0,0 +1,632 @@ +import { appendFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentJournalSubmissionKey, + boundJournalKeyComponent +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import { + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + serializeRemoteRuntimePayload +} from '../../../shared/remote-runtime-memory-limits' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { JOURNAL_LOG_FILE } from '../agent-session-journal/journal-log-file' +import { + serializeJournalRow, + type JournalItemRow, + type JournalRow, + type JournalTombstoneRow +} from '../agent-session-journal/journal-row-schema' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory, resolveHistoryLimit } from './agent-session-history-page' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 +let epochs = 0 +let journal: AgentSessionJournal + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function appendItems(count: number): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-history-')) + clock = 1_000 + epochs = 0 + journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => { + epochs += 1 + return `epoch-${epochs}` + } + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('resolveHistoryLimit', () => { + it('clamps rather than rejecting so a mid-scroll client keeps paging', () => { + expect(resolveHistoryLimit(undefined)).toBe(40) + expect(resolveHistoryLimit(0)).toBe(1) + expect(resolveHistoryLimit(-5)).toBe(1) + expect(resolveHistoryLimit(10_000)).toBe(AGENT_SESSION_HISTORY_MAX_LIMIT) + expect(resolveHistoryLimit(Number.NaN)).toBe(40) + }) +}) + +describe('readAgentSessionHistory', () => { + it('serves the newest page on tail and pages backward from it', async () => { + await appendItems(5) + const tail = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 2 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(tail.page.items.map((entry) => entry.body)).toEqual([body('item-4'), body('item-5')]) + expect(tail.page.hasOlder).toBe(true) + expect(tail.page.hasNewer).toBe(false) + expect(tail.page.liveCursor).toEqual(journal.cursor()) + + const older = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: tail.page.window.nextCursor, + limit: 2 + }) + if (!older.ok) { + throw new Error(`expected a page, got reset ${older.reset}`) + } + expect(older.page.items.map((entry) => entry.body)).toEqual([body('item-2'), body('item-3')]) + expect(older.page.hasNewer).toBe(true) + }) + + it('catches a live reader up from its cursor and stops at the limit', async () => { + await appendItems(2) + const cursor = journal.cursor() + await appendItems(5) + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 2 + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(2) + expect(page.page.hasNewer).toBe(true) + expect(page.page.window.nextCursor.sequence).toBeGreaterThan(cursor.sequence) + }) + + it('carries tombstones in a forward catch-up page', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(0) + expect(page.page.removedItemIds).toEqual(['codex:thread-1:turn-1:1']) + }) + + it('reports a forward read with no cursor as cursor_ahead rather than serving the tail', async () => { + await appendItems(1) + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'after' }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('resets a cursor from a previous epoch', async () => { + await appendItems(1) + const stale = journal.cursor() + await journal.rollEpoch('legacy_import', 2) + for (const direction of ['before', 'after'] as const) { + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction, cursor: stale }) + ).toMatchObject({ ok: false, reset: 'epoch_changed' }) + } + }) + + it('resets a cursor ahead of the journal', async () => { + await appendItems(1) + const ahead = { epoch: journal.epoch, sequence: journal.cursor().sequence + 10 } + expect( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: ahead + }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('carries the submission for a message on the page', async () => { + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'a'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + const page = readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail' }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items[0]?.itemId).toBe(agentJournalSubmissionKey('msg-1')) + expect(page.page.submissions).toHaveLength(1) + expect(page.page.submissions[0]).toMatchObject({ + clientMessageId: 'msg-1', + dispatchState: 'pending' + }) + }) +}) + +describe('history page byte ceiling', () => { + // A legal user message may be 256 KiB; twenty of them serialize past the + // 4 MiB outbound channel cap, which closes the socket on overflow. + const LARGE_TEXT = 'x'.repeat(250 * 1024) + + async function appendLargeItems(count: number): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + } + } + + function pageOf(result: ReturnType) { + if (!result.ok) { + throw new Error(`expected a page, got reset ${result.reset}`) + } + // The actual channel gate: the page must serialize under the outbound cap. + serializeRemoteRuntimePayload(result.page) + return result.page + } + + it('keeps a tail of legal large messages under the channel cap and still pages back to every item', async () => { + await appendLargeItems(20) + + const tail = pageOf( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) + ) + expect(tail.items.length).toBeGreaterThan(0) + expect(tail.hasOlder).toBe(true) + + const seen = tail.items.map((entry) => entry.itemId) + let cursor = tail.window.nextCursor + let hasOlder = tail.hasOlder + let guard = 0 + while (hasOlder) { + guard += 1 + expect(guard).toBeLessThan(30) + const page = pageOf( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor, + limit: 40 + }) + ) + expect(page.items.length).toBeGreaterThan(0) + seen.push(...page.items.map((entry) => entry.itemId)) + cursor = page.window.nextCursor + hasOlder = page.hasOlder + } + expect(new Set(seen).size).toBe(20) + }) + + it('bounds a forward catch-up page by bytes and keeps replaying to the head', async () => { + const start = { epoch: journal.epoch, sequence: 0 } + await appendLargeItems(20) + + const first = pageOf( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: start, + limit: 40 + }) + ) + expect(first.items.length).toBeGreaterThan(0) + expect(first.hasNewer).toBe(true) + + const seen = first.items.map((entry) => entry.itemId) + let cursor = first.window.nextCursor + let hasNewer = first.hasNewer + let guard = 0 + while (hasNewer) { + guard += 1 + expect(guard).toBeLessThan(30) + const page = pageOf( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 40 + }) + ) + expect(page.items.length).toBeGreaterThan(0) + seen.push(...page.items.map((entry) => entry.itemId)) + cursor = page.window.nextCursor + hasNewer = page.hasNewer + } + expect(new Set(seen).size).toBe(20) + }) + + it('degrades a single over-budget item to a visible truncation marker instead of overflowing', async () => { + await journal.appendItem(item(1), body(`1:${'y'.repeat(3 * 1024 * 1024)}`), { fence: 1 }) + + const tail = pageOf( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail', limit: 40 }) + ) + expect(tail.items).toHaveLength(1) + const bodyOnPage = tail.items[0]?.body + expect(bodyOnPage?.kind).toBe('status') + expect(bodyOnPage?.kind === 'status' ? bodyOnPage.text : '').toContain('[Orca: item truncated') + }) +}) + +describe('projectJournalBatch', () => { + it('reports a hole in the row sequence as journal_gap', async () => { + await appendItems(3) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const withHole = since.rows.filter((row) => row.seq !== since.rows[1]?.seq) + expect( + projectJournalBatch({ rows: withHole, snapshot: journal.snapshot(), afterSequence: 0 }) + ).toEqual({ ok: false, reset: 'journal_gap' }) + }) + + it('publishes touched items at their current reduced state, not as a delta', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('revised'), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.items).toHaveLength(1) + expect(projected.batch.items[0]).toMatchObject({ body: body('revised'), revision: 2 }) + expect(projected.batch.cursor).toEqual(journal.cursor()) + }) + + it('lists a tombstoned item as removed', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.removedItemIds).toHaveLength(1) + expect(projected.batch.items).toHaveLength(0) + }) + + it('publishes a mismatched provider echo under its submission slot', async () => { + const message: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued follow-up' }] + } + await journal.appendSubmission({ + clientMessageId: 'client-follow-up', + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body: message } + }), + body: message, + fence: 1 + }) + const cursor = journal.cursor() + await journal.resolveDispatch({ + clientMessageId: 'client-follow-up', + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: 'thread-1', + turnId: 'predicted', + ordinal: 0 + }, + fence: 1 + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'root-turn', ordinal: 2 }, + message, + { fence: 1 } + ) + + const page = readAgentSessionHistory(journal, { + sessionId: IDENTITY.sessionId, + direction: 'after', + cursor + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toMatchObject([ + { itemId: agentJournalSubmissionKey('client-follow-up'), revision: 1 } + ]) + expect(page.page.removedItemIds).toEqual([]) + }) +}) + +/** Serialize through the actual channel gate and hand back the byte length. */ +function serializedPageBytes(value: unknown): number { + return Buffer.byteLength(serializeRemoteRuntimePayload(value), 'utf8') +} + +type RawSeedRow = + | Omit + | Omit + +/** Simulate rows admitted before identity bounding existed: written straight + * into the log, then loaded by a fresh journal instance. */ +async function reopenWithRawRows(rows: readonly RawSeedRow[]): Promise { + const full = rows.map( + (row) => + ({ + ...row, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: journal.epoch, + fence: 1, + ts: tick() + }) as JournalRow + ) + await appendFile( + join(root, JOURNAL_LOG_FILE), + `${full.map(serializeJournalRow).join('\n')}\n`, + 'utf-8' + ) + return openAgentSessionJournal({ identity: IDENTITY, journalDir: root, now: tick }) +} + +describe('pre-existing oversized identities', () => { + // The exact escape from the round-two review: a legal 5 MiB Codex turnId + // admitted before bounding, then tombstoned. Its removal id alone exceeds + // the 4 MiB outbound cap, so no page can ever carry it. + const HUGE_ITEM_ID = `codex:thread-1:${'h'.repeat(5 * 1024 * 1024)}:1` + + it('answers an unfittable pre-existing removal with a bounded reset instead of an unsendable page', async () => { + const seq = journal.cursor().sequence + const reopened = await reopenWithRawRows([ + { kind: 'item', itemId: HUGE_ITEM_ID, revision: 1, seq: seq + 1, body: body('big') }, + { kind: 'tombstone', itemId: HUGE_ITEM_ID, revision: 2, seq: seq + 2 } + ]) + + for (const sequence of [seq, seq + 1]) { + const result = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: { epoch: reopened.epoch, sequence }, + limit: 40 + }) + expect(result.ok).toBe(false) + if (result.ok) { + throw new Error('expected a reset') + } + expect(result.reset).toBe('cursor_compacted') + expect(serializedPageBytes(result.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + // The reset page replaces client state wholesale, so the removal is + // applied without carrying the id, and resuming from the live cursor + // starts past the unsendable row. + expect(result.page.items).toEqual([]) + const liveCursor = result.page.liveCursor + if (!liveCursor) { + throw new Error('expected a live cursor on the reset page') + } + expect(liveCursor.sequence).toBeGreaterThanOrEqual(seq + 2) + + const resumed = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: liveCursor, + limit: 40 + }) + expect(resumed.ok).toBe(true) + } + }) + + it('charges removal ids into the page budget and splits catch-up instead of overflowing', async () => { + const seq = journal.cursor().sequence + const removalIds = Array.from( + { length: 30 }, + (_, index) => `codex:thread-1:${'r'.repeat(250 * 1024)}:${index}` + ) + const reopened = await reopenWithRawRows( + removalIds.map((itemId, index) => ({ + kind: 'tombstone' as const, + itemId, + revision: 1, + seq: seq + 1 + index + })) + ) + + const seen = new Set() + let cursor = { epoch: reopened.epoch, sequence: seq } + let guard = 0 + while (true) { + guard += 1 + expect(guard).toBeLessThan(30) + const result = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 40 + }) + if (!result.ok) { + throw new Error(`expected a page, got reset ${result.reset}`) + } + expect(serializedPageBytes(result.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + for (const removed of result.page.removedItemIds) { + seen.add(removed) + } + cursor = result.page.window.nextCursor + if (!result.page.hasNewer) { + break + } + } + expect(seen).toEqual(new Set(removalIds)) + }) + + it('bounds the truncation marker id for a live oversized-id item', async () => { + const seq = journal.cursor().sequence + const reopened = await reopenWithRawRows([ + { kind: 'item', itemId: HUGE_ITEM_ID, revision: 1, seq: seq + 1, body: body('big') } + ]) + + const tail = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'tail', + limit: 40 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(serializedPageBytes(tail.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(tail.page.items).toHaveLength(1) + const marker = tail.page.items[0] + expect(marker?.body.kind).toBe('status') + expect(marker?.itemId).toBe(boundJournalKeyComponent(HUGE_ITEM_ID)) + expect(marker?.itemId.length).toBeLessThan(2048) + + const forward = readAgentSessionHistory(reopened, { + sessionId: 'session-1', + direction: 'after', + cursor: { epoch: reopened.epoch, sequence: seq }, + limit: 40 + }) + if (!forward.ok) { + throw new Error(`expected a page, got reset ${forward.reset}`) + } + expect(serializedPageBytes(forward.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(forward.page.items[0]?.itemId).toBe(boundJournalKeyComponent(HUGE_ITEM_ID)) + }) +}) + +describe('identity bounding at admission', () => { + it('bounds a new oversized provider identity so its item and removal share one sendable key', async () => { + const oversized: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'T'.repeat(5 * 1024 * 1024), + ordinal: 1 + } + const start = { epoch: journal.epoch, sequence: journal.cursor().sequence } + const appended = await journal.appendItem(oversized, body('bounded'), { fence: 1 }) + expect(appended.itemId.length).toBeLessThan(2048) + expect(appended.itemId).toContain('~orca-oversized~') + + const beforeTombstone = journal.cursor() + await journal.appendTombstone(oversized, { fence: 1 }) + + const created = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: start, + limit: 40 + }) + if (!created.ok) { + throw new Error(`expected a page, got reset ${created.reset}`) + } + expect(serializedPageBytes(created.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(created.page.removedItemIds).toEqual([appended.itemId]) + + const removal = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor: beforeTombstone, + limit: 40 + }) + if (!removal.ok) { + throw new Error(`expected a page, got reset ${removal.reset}`) + } + expect(serializedPageBytes(removal.page)).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + expect(removal.page.removedItemIds).toEqual([appended.itemId]) + }) + + it('keeps bounded keys deterministic and a fixed point of re-derivation', () => { + const oversized = 'x'.repeat(2 * 1024 * 1024) + const bounded = boundJournalKeyComponent(oversized) + expect(bounded).toBe(boundJournalKeyComponent(oversized)) + expect(boundJournalKeyComponent(bounded)).toBe(bounded) + expect(bounded.length).toBeLessThan(2048) + expect(boundJournalKeyComponent(`${oversized}y`)).not.toBe(bounded) + expect(boundJournalKeyComponent('short')).toBe('short') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts new file mode 100644 index 00000000000..aa79ae8ee85 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -0,0 +1,349 @@ +// Paged history over one journal. +// +// `tail` and `before` read the REDUCED timeline, so backward paging keeps +// working after compaction — the folded snapshot still holds every live item. +// `after` is the catch-up direction and must read rows instead: an item created +// early and revised late orders by its creation sequence, so an item-window +// read would silently skip that revision. Rows carry the revision, which is why +// `after` is the only direction that can answer `cursor_compacted`. + +import { + agentJournalSubmissionKey, + boundJournalKeyComponent +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalSnapshot, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import { + AGENT_SESSION_HISTORY_DEFAULT_LIMIT, + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryDirection, + type AgentSessionHistoryPage, + type AgentSessionHistoryRequest, + type AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' + +/** Byte budget for one history page. Half the outbound channel cap, so the RPC + * envelope and page framing always fit beside the items: row counts alone + * cannot protect the channel — forty legal 256 KiB messages serialize past the + * 4 MiB outbound cap, and an overflow closes the client's socket on every + * reopen. Pages degrade to fewer rows instead; `hasOlder`/`hasNewer` keep the + * client paging. */ +export const AGENT_SESSION_HISTORY_MAX_PAGE_BYTES = REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES / 2 + +/** Reserved for everything the page carries beyond its items and removal ids: + * cursors, session/epoch ids, and the RPC envelope. Charged up front so the + * content budget bounds the COMPLETE serialized result, not just the rows. */ +const HISTORY_PAGE_ENVELOPE_RESERVE_BYTES = 64 * 1024 + +const HISTORY_PAGE_CONTENT_BUDGET_BYTES = + AGENT_SESSION_HISTORY_MAX_PAGE_BYTES - HISTORY_PAGE_ENVELOPE_RESERVE_BYTES + +/** Item bytes plus the submission the page would carry alongside it. */ +function historyEntryBytes( + item: AgentJournalRenderItem, + submissionBytes: ReadonlyMap +): number { + return Buffer.byteLength(JSON.stringify(item), 'utf8') + (submissionBytes.get(item.itemId) ?? 0) +} + +function submissionBytesByItemId( + submissions: readonly AgentJournalSubmission[] +): Map { + return new Map( + submissions.map((submission) => [ + agentJournalSubmissionKey(submission.clientMessageId), + Buffer.byteLength(JSON.stringify(submission), 'utf8') + ]) + ) +} + +/** Visible stand-in for an item whose body alone exceeds the page budget. The + * full body stays in the journal — this bounds what ONE PAGE carries, it never + * rewrites the record. */ +function oversizedHistoryItem( + item: AgentJournalRenderItem, + byteLength: number +): AgentJournalRenderItem { + return { + ...item, + // A pre-bounding id can exceed the budget by itself; the stand-in must not + // re-inflate the page it exists to bound. Bounding is deterministic, so + // re-reads keep deduplicating on the same key. + itemId: boundJournalKeyComponent(item.itemId), + body: { + kind: 'status', + text: `[Orca: item truncated — ${byteLength} bytes exceeds the history page budget]` + } + } +} + +/** + * Keep the edge of the window nearest the requested position within the byte + * budget: `newest` for tail/backward pages, `oldest` for forward catch-up. The + * page stays contiguous, so the dropped remainder is exactly what the next page + * serves. Never empties a non-empty window — a single over-budget item degrades + * to a visible marker so the client always makes progress. + */ +function boundHistoryItemsByBytes( + items: AgentJournalRenderItem[], + keep: 'newest' | 'oldest', + submissionBytes: ReadonlyMap, + maxBytes: number +): { items: AgentJournalRenderItem[]; dropped: number } { + const ordered = keep === 'newest' ? items.toReversed() : items + const kept: AgentJournalRenderItem[] = [] + let total = 0 + for (const item of ordered) { + const bytes = historyEntryBytes(item, submissionBytes) + if (kept.length === 0 && bytes > maxBytes) { + kept.push(oversizedHistoryItem(item, bytes)) + break + } + if (total + bytes > maxBytes) { + break + } + kept.push(item) + total += bytes + } + return { + items: keep === 'newest' ? kept.toReversed() : kept, + dropped: items.length - kept.length + } +} + +/** Clamped, never rejected: a client asking for more than the host will serve + * should get a smaller page and keep paging, not an error mid-scroll. */ +export function resolveHistoryLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return AGENT_SESSION_HISTORY_DEFAULT_LIMIT + } + return Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, Math.max(1, Math.floor(limit))) +} + +export function readAgentSessionHistory( + journal: AgentSessionJournal, + request: AgentSessionHistoryRequest +): AgentSessionHistoryResult { + const snapshot = journal.snapshot() + if (journal.isReadOnly) { + return historyReset(snapshot, 'schema_unreadable') + } + const limit = resolveHistoryLimit(request.limit) + if (request.direction === 'after') { + return readForward(journal, snapshot, request.cursor, limit) + } + const cursor = request.direction === 'before' ? request.cursor : undefined + if (cursor) { + if (cursor.epoch !== snapshot.cursor.epoch) { + return historyReset(snapshot, 'epoch_changed') + } + if (cursor.sequence > snapshot.cursor.sequence) { + return historyReset(snapshot, 'cursor_ahead') + } + } + const older = cursor + ? snapshot.items.filter((item) => item.sequence < cursor.sequence) + : snapshot.items + const windowed = older.slice(Math.max(0, older.length - limit)) + const { items, dropped } = boundHistoryItemsByBytes( + windowed, + 'newest', + submissionBytesByItemId(snapshot.submissions), + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ) + return { + ok: true, + page: buildPage({ + snapshot, + direction: request.direction, + items, + hasOlder: older.length > windowed.length || dropped > 0, + hasNewer: older.length < snapshot.items.length, + fallbackCursor: cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, + nextCursor: items[0] + ? { epoch: snapshot.cursor.epoch, sequence: items[0].sequence } + : undefined + }) + } +} + +export function readAgentSessionHydrationPage( + journal: AgentSessionJournal, + fence?: number +): AgentSessionHistoryPage { + return buildHydrationPage(journal.snapshot(), fence) +} + +function buildHydrationPage( + snapshot: AgentJournalSnapshot, + fence?: number +): AgentSessionHistoryPage { + const items = snapshot.items.slice(-AGENT_SESSION_HISTORY_MAX_LIMIT) + const bounded = boundHistoryItemsByBytes( + items, + 'newest', + submissionBytesByItemId(snapshot.submissions), + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ) + return buildPage({ + snapshot, + direction: 'tail', + items: bounded.items, + hasOlder: snapshot.items.length > items.length || bounded.dropped > 0, + hasNewer: false, + fallbackCursor: { epoch: snapshot.cursor.epoch, sequence: 0 }, + nextCursor: bounded.items[0] + ? { epoch: snapshot.cursor.epoch, sequence: bounded.items[0].sequence } + : undefined, + fence + }) +} + +function historyReset( + snapshot: AgentJournalSnapshot, + reset: Extract['reset'] +): AgentSessionHistoryResult { + return { + ok: false, + reset, + page: buildHydrationPage(snapshot) + } +} + +function readForward( + journal: AgentSessionJournal, + snapshot: AgentJournalSnapshot, + cursor: AgentJournalCursor | undefined, + limit: number +): AgentSessionHistoryResult { + if (!cursor) { + // Why: forward paging replays rows after a position; without one there is + // nothing to be after, and silently serving the tail would hand the client + // a page it cannot place. + return historyReset(snapshot, 'cursor_ahead') + } + const since = journal.readSince(cursor) + if (!since.ok) { + return historyReset(snapshot, since.reset) + } + const submissionBytes = submissionBytesByItemId(snapshot.submissions) + // The page cost is EVERYTHING variable it carries: items with their + // submissions AND removal ids — a legal pre-bounding tombstone id can dwarf + // every item on the page. + const pageContentBytes = ( + items: readonly AgentJournalRenderItem[], + removedItemIds: readonly string[] + ): number => + items.reduce((total, item) => total + historyEntryBytes(item, submissionBytes), 0) + + removedItemIds.reduce( + (total, itemId) => total + Buffer.byteLength(JSON.stringify(itemId), 'utf8') + 1, + 0 + ) + // Rows replay forward, so the byte bound shrinks the ROW window rather than + // clipping projected items: dropping an item while advancing the cursor past + // the rows that touched it would lose that revision for good. + let rows = since.rows.slice(0, limit) + let projected = projectJournalBatch({ + rows, + snapshot, + afterSequence: cursor.sequence, + canonicalItemId: (itemId) => journal.canonicalItemId(itemId) + }) + if (!projected.ok) { + return historyReset(snapshot, projected.reset) + } + while ( + rows.length > 1 && + pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ) { + rows = rows.slice(0, Math.ceil(rows.length / 2)) + const shrunk = projectJournalBatch({ + rows, + snapshot, + afterSequence: cursor.sequence, + canonicalItemId: (itemId) => journal.canonicalItemId(itemId) + }) + if (!shrunk.ok) { + return historyReset(snapshot, shrunk.reset) + } + projected = shrunk + } + // One row can still touch an over-budget item; degrade it visibly. + const items = + pageContentBytes(projected.batch.items, projected.batch.removedItemIds) > + HISTORY_PAGE_CONTENT_BUDGET_BYTES + ? projected.batch.items.map((item) => { + const bytes = historyEntryBytes(item, submissionBytes) + return bytes > HISTORY_PAGE_CONTENT_BUDGET_BYTES + ? oversizedHistoryItem(item, bytes) + : item + }) + : projected.batch.items + if (pageContentBytes(items, projected.batch.removedItemIds) > HISTORY_PAGE_CONTENT_BUDGET_BYTES) { + // A single row's semantic payload — in practice a pre-bounding oversized + // removal id — can never fit any page, and truncating a removal id would + // break the client's keying. A bounded tail replaces the client's state + // wholesale, which applies the removal without carrying the id, and the + // client resumes from the live cursor past this row. + return historyReset(snapshot, 'cursor_compacted') + } + const lastSequence = rows.at(-1)?.seq ?? cursor.sequence + return { + ok: true, + page: buildPage({ + snapshot, + direction: 'after', + items, + removedItemIds: projected.batch.removedItemIds, + // Reading after a position means there is something before it. + hasOlder: cursor.sequence > 0, + hasNewer: since.rows.length > rows.length, + fallbackCursor: cursor, + nextCursor: { epoch: cursor.epoch, sequence: lastSequence } + }) + } +} + +function buildPage(input: { + snapshot: AgentJournalSnapshot + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + removedItemIds?: string[] + hasOlder: boolean + hasNewer: boolean + fallbackCursor: AgentJournalCursor + nextCursor: AgentJournalCursor | undefined + fence?: number +}): AgentSessionHistoryPage { + const epoch = input.snapshot.cursor.epoch + const pageItemIds = new Set(input.items.map((item) => item.itemId)) + const oldest = input.items[0] + const newest = input.items.at(-1) + return { + sessionId: input.snapshot.sessionId, + epoch, + ...(input.fence !== undefined ? { fence: input.fence } : {}), + direction: input.direction, + items: input.items, + removedItemIds: input.removedItemIds ?? [], + submissions: input.snapshot.submissions.filter((submission) => + pageItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ), + window: { + oldest: oldest ? { epoch, sequence: oldest.sequence } : null, + newest: newest ? { epoch, sequence: newest.sequence } : null, + nextCursor: input.nextCursor ?? input.fallbackCursor + }, + liveCursor: input.snapshot.cursor, + hasOlder: input.hasOlder, + hasNewer: input.hasNewer + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts new file mode 100644 index 00000000000..61c811f6762 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -0,0 +1,82 @@ +// Rows appended since a cursor, projected into what a subscriber must apply. +// +// The batch carries each touched item at its CURRENT reduced state rather than +// the raw rows: a client that applies the same batch twice converges, and a +// provider echo that was adopted into a submission slot arrives under the slot +// key instead of appearing as a second copy of the user's own message. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { findSequenceGap } from '../agent-session-journal/journal-cursor' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' + +export type JournalBatchProjection = + | { ok: true; batch: AgentSessionJournalBatch } + /** A missing sequence means the tail lost a row; the subscriber reloads + * rather than rendering a timeline with a hole in it. */ + | { ok: false; reset: 'journal_gap' } + +export function projectJournalBatch(input: { + rows: readonly JournalRow[] + snapshot: AgentJournalSnapshot + /** Sequence the subscriber has already applied. */ + afterSequence: number + canonicalItemId?: (itemId: string) => string +}): JournalBatchProjection { + const gap = findSequenceGap( + input.rows.map((row) => row.seq), + input.afterSequence + 1 + ) + if (gap) { + return { ok: false, reset: 'journal_gap' } + } + const aliases = submissionAliases(input.snapshot) + const touchedItemIds = new Set() + const touchedClientMessageIds = new Set() + for (const row of input.rows) { + if (row.kind === 'item' || row.kind === 'tombstone') { + touchedItemIds.add( + input.canonicalItemId?.(row.itemId) ?? aliases.get(row.itemId) ?? row.itemId + ) + continue + } + if (row.kind === 'submission' || row.kind === 'dispatch') { + touchedClientMessageIds.add(row.clientMessageId) + touchedItemIds.add(agentJournalSubmissionKey(row.clientMessageId)) + } + } + + const live = new Map(input.snapshot.items.map((item) => [item.itemId, item])) + const items = [...touchedItemIds] + .map((itemId) => live.get(itemId)) + .filter((item) => item !== undefined) + .sort((a, b) => a.sequence - b.sequence) + return { + ok: true, + batch: { + cursor: input.snapshot.cursor, + items, + removedItemIds: [...touchedItemIds].filter((itemId) => !live.has(itemId)), + submissions: input.snapshot.submissions.filter((submission) => + touchedClientMessageIds.has(submission.clientMessageId) + ) + } + } +} + +/** + * Provider item id → the submission slot that adopted it, rebuilt from the + * snapshot's own accepted submissions. This mirrors the alias the reducer + * writes on an accepted dispatch; deriving it here keeps the projection a pure + * function of published state instead of reaching into reducer internals. + */ +function submissionAliases(snapshot: AgentJournalSnapshot): Map { + const aliases = new Map() + for (const submission of snapshot.submissions) { + if (submission.dispatchState === 'accepted' && submission.providerItemId) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + return aliases +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts new file mode 100644 index 00000000000..7fe7e6ac29a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -0,0 +1,176 @@ +// Recovery drives the real journal loader against real on-disk damage: a hole +// punched in the log, and a row stamped with a schema this host cannot read. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + providerHistoryId, + recoveryJournalDir +} from './agent-session-journal-recovery' + +const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: CODEX_SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_SESSION } +} + +const CODEX_LINES = [ + { + type: 'session_meta', + timestamp: '2026-08-05T10:00:00.000Z', + payload: { + id: CODEX_SESSION, + session_id: CODEX_SESSION, + cwd: '/Users/dev/project', + originator: 'codex_cli_rs', + cli_version: '0.146.1' + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:05.000Z', + payload: { type: 'agent_message', message: 'On it.' } + } +] + +let root: string +let journalDir: string +let historyFilePath: string + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal } +} + +/** Fills a journal with `count` items and hands back the raw log lines. */ +async function seedJournal(count: number): Promise { + const journal = await openAgentSessionJournal({ identity: IDENTITY, journalDir }) + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem( + item(ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, + { fence: 1 } + ) + } + const raw = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + return raw.split('\n').filter((line) => line.trim().length > 0) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-recovery-')) + journalDir = join(root, 'journal') + historyFilePath = join(root, 'rollout.jsonl') + await writeFile( + historyFilePath, + `${CODEX_LINES.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf-8' + ) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('providerHistoryId', () => { + it('uses the provider handle, never the Orca session id', () => { + expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') + expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( + 'sess-9' + ) + }) +}) + +describe('openAgentSessionJournalWithRecovery', () => { + it('opens a healthy journal untouched', async () => { + await seedJournal(2) + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toBeNull() + expect(opened.journal.snapshot().items).toHaveLength(2) + }) + + it('rebuilds a holed journal in place on a fresh epoch', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 1) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + expect(opened.journal.isReadOnly).toBe(false) + // The rebuilt timeline is the only content of its epoch — nothing from the + // damaged prefix survives into it. + const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) + expect(texts.some((text) => text.includes('item-1'))).toBe(false) + expect(texts.some((text) => text.includes('add a retry'))).toBe(true) + }) + + it('reconstructs a future-schema journal into a schema-scoped sibling, never in place', async () => { + const lines = await seedJournal(1) + await writeFile( + join(journalDir, 'log.jsonl'), + `${lines.join('\n')}\n${JSON.stringify({ v: 99, seq: 2, epoch: 'e', kind: 'item' })}\n`, + 'utf-8' + ) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ + trigger: 'schema_unreadable', + reset: 'schema_unreadable' + }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + + // The unreadable journal is left exactly as found; a newer host still owns it. + const untouched = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + expect(untouched).toContain('"v":99') + const sibling = await readFile(join(recoveryJournalDir(journalDir), 'log.jsonl'), 'utf-8') + expect(sibling).toContain('add a retry') + }) + + it('still opens the session when provider history cannot be read', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 2) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath: join(root, 'missing.jsonl') + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) + expect(opened.recovery?.error).toBeTruthy() + // A missing provider transcript must not clear the intact journal prefix. + expect(opened.journal.snapshot().items).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts new file mode 100644 index 00000000000..19b39c5bded --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -0,0 +1,114 @@ +// Journal recovery: rehydrate the timeline from provider history. +// +// Two triggers, and they need different destinations. A journal whose prefix is +// unusable is writable, so it is rebuilt in place on a fresh epoch. A journal +// written by a NEWER schema is not writable by this host at all — rebuilding it +// in place would fork the sequence space a newer host still owns — so the +// reconstruction goes to a schema-scoped sibling directory that is only ever +// written by hosts at this version and is never merged back. + +import type { AgentType } from '../../../shared/agent-status-types' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalResetReason, + type AgentSessionJournalIdentity, + type AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { loadJournal } from '../agent-session-journal/journal-open' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' + +export type AgentSessionJournalRecovery = { + trigger: 'journal_corrupt' | 'schema_unreadable' + /** What subscribers are told; both force a clean snapshot reload. */ + reset: AgentJournalResetReason + epoch: string + imported: number + /** Set when provider history could not be read; the intact journal prefix remains live. */ + error?: string +} + +export type AgentSessionJournalOpened = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null +} + +/** Where a reconstruction lands when the real journal cannot be written. */ +export function recoveryJournalDir(journalDir: string): string { + return `${journalDir}-recovered-v${AGENT_SESSION_JOURNAL_SCHEMA_VERSION}` +} + +/** The provider's own session id, which is what the transcript readers index + * by — never the Orca session id. */ +export function providerHistoryId(handle: AgentSessionProviderHandle): string { + if (handle.kind === 'codex') { + return handle.threadId + } + return handle.kind === 'claude' ? handle.sessionId : handle.value +} + +export async function openAgentSessionJournalWithRecovery(input: { + identity: AgentSessionJournalIdentity + journalDir: string + fence: number + /** Resolve directly to a transcript instead of discovering it by session id. */ + historyFilePath?: string | null +}): Promise { + const probe = await loadJournal(input.journalDir, input.identity.sessionId) + if (probe?.readOnly) { + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: recoveryJournalDir(input.journalDir) + }) + return { + journal, + recovery: await rehydrate({ ...input, journal, trigger: 'schema_unreadable' }) + } + } + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: input.journalDir + }) + if (!probe?.corrupt) { + return { journal, recovery: null } + } + // `open()` quarantines the unusable suffix; a successful import rolls once + // more so the rebuilt timeline is the only content of its epoch. + return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } +} + +async function rehydrate(input: { + identity: AgentSessionJournalIdentity + journal: AgentSessionJournal + fence: number + historyFilePath?: string | null + trigger: AgentSessionJournalRecovery['trigger'] +}): Promise { + const reset: AgentJournalResetReason = + input.trigger === 'schema_unreadable' ? 'schema_unreadable' : 'epoch_changed' + const result = await importLegacyTranscriptIntoJournal({ + journal: input.journal, + agent: input.identity.agent satisfies AgentType, + sessionId: providerHistoryId(input.identity.providerHandle), + fence: input.fence, + ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {}) + }) + if (!result.ok) { + return { + trigger: input.trigger, + reset, + epoch: input.journal.epoch, + imported: 0, + error: result.error + } + } + return { + trigger: input.trigger, + reset, + epoch: result.epoch, + imported: result.imported + } +} diff --git a/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts new file mode 100644 index 00000000000..4f3ef118af5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/claude-stream-json-frame-schema.ts @@ -0,0 +1,48 @@ +// SDKMessage discriminators from Claude Agent SDK 0.3.231 / Claude Code 2.1.231. +export const CLAUDE_STREAM_JSON_FRAME_KINDS = [ + 'message:assistant', + 'message:user', + 'message:result', + 'message:system:init', + 'message:stream_event:message_start', + 'message:stream_event:message_delta', + 'message:stream_event:message_stop', + 'message:stream_event:content_block_start', + 'message:stream_event:content_block_delta', + 'message:stream_event:content_block_stop', + 'message:system:compact_boundary', + 'message:system:status', + 'message:system:api_retry', + 'message:system:control_request_progress', + 'message:system:model_refusal_fallback', + 'message:system:model_refusal_no_fallback', + 'message:system:local_command_output', + 'message:system:hook_started', + 'message:system:hook_progress', + 'message:system:hook_response', + 'message:system:plugin_install', + 'message:tool_progress', + 'message:auth_status', + 'message:system:task_notification', + 'message:system:task_started', + 'message:system:task_updated', + 'message:system:task_progress', + 'message:system:background_tasks_changed', + 'message:system:thinking_tokens', + 'message:system:session_state_changed', + 'message:system:worker_shutting_down', + 'message:system:commands_changed', + 'message:system:notification', + 'message:system:files_persisted', + 'message:tool_use_summary', + 'message:system:memory_recall', + 'message:rate_limit_event', + 'message:system:elicitation_complete', + 'message:system:permission_denied', + 'message:prompt_suggestion', + 'message:system:mirror_error', + 'message:system:informational', + 'message:conversation_reset' +] as const + +export type ClaudeStreamJsonFrameKind = (typeof CLAUDE_STREAM_JSON_FRAME_KINDS)[number] diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts new file mode 100644 index 00000000000..ad9ca66c52a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_APP_SERVER_NOTIFICATION_METHODS } from '../../codex/codex-app-server-notification-schema' +import { CLAUDE_STREAM_JSON_FRAME_KINDS } from './claude-stream-json-frame-schema' +import { + classifyProviderFrame, + isDeltaShapedProviderFrameKind, + PROVIDER_FRAME_CLASSIFICATIONS +} from './provider-frame-disposition' + +describe('provider frame classification catalog', () => { + it('classifies every pinned Codex app-server notification method', () => { + expect(Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.codex)).toEqual([ + ...CODEX_APP_SERVER_NOTIFICATION_METHODS + ]) + }) + + it('classifies every pinned Claude stream-json frame kind', () => { + expect(Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.claude)).toEqual([ + ...CLAUDE_STREAM_JSON_FRAME_KINDS + ]) + }) + + it('classifies every pinned delta kind as stream-into-item', () => { + const deltaKinds = [ + ...Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.codex), + ...Object.keys(PROVIDER_FRAME_CLASSIFICATIONS.claude) + ].filter(isDeltaShapedProviderFrameKind) + + expect(deltaKinds.length).toBeGreaterThan(0) + for (const kind of deltaKinds) { + const provider = kind.startsWith('message:') ? 'claude' : 'codex' + expect(classifyProviderFrame(provider, kind, {}), kind).toBe('stream-into-item') + } + }) + + it('suppresses benign hook lifecycle and Codex progress frames', () => { + expect(classifyProviderFrame('codex', 'notification:hook/started', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('codex', 'notification:hook/completed', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('codex', 'notification:account/rateLimits/updated', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('codex', 'notification:turn/diff/updated', {})).toBe( + 'suppressed-benign' + ) + expect(classifyProviderFrame('claude', 'message:system:hook_started', {})).toBe( + 'suppressed-benign' + ) + }) + + it('promotes payload failures over a benign catalog classification', () => { + expect( + classifyProviderFrame('codex', 'notification:hook/completed', { + run: { status: 'failed' } + }) + ).toBe('error-surface') + expect( + classifyProviderFrame('claude', 'message:system:hook_response', { + outcome: 'error', + stderr: 'hook failed' + }) + ).toBe('error-surface') + }) + + it('keeps unknown future frames on the substantive bounded fallback path', () => { + expect(classifyProviderFrame('codex', 'notification:future/event', {})).toBe( + 'timeline-substantive' + ) + expect(classifyProviderFrame('claude', 'message:future_event', {})).toBe('timeline-substantive') + }) + + it('structurally diverts unknown future delta kinds from generic rows', () => { + expect(classifyProviderFrame('codex', 'notification:item/newThing/outputDelta', {})).toBe( + 'stream-into-item' + ) + expect(classifyProviderFrame('claude', 'message:future_delta', {})).toBe('stream-into-item') + }) + + it('dispositions codex item-form frames, which the method catalog never matches', () => { + // `thread/compacted` is already chrome; its item form is the same event and + // must not leak `codex · item:contextCompaction` into the transcript. + expect(classifyProviderFrame('codex', 'item:contextCompaction', {})).toBe('status-chrome') + expect(classifyProviderFrame('codex', 'notification:thread/compacted', {})).toBe( + 'status-chrome' + ) + // An item type nobody has dispositioned still falls through visibly. + expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts new file mode 100644 index 00000000000..8d11df995a6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/provider-frame-disposition.ts @@ -0,0 +1,245 @@ +import type { CodexAppServerNotificationMethod } from '../../codex/codex-app-server-notification-schema' +import type { ClaudeStreamJsonFrameKind } from './claude-stream-json-frame-schema' + +export type ProviderFrameClassification = + | 'timeline-substantive' + | 'stream-into-item' + | 'status-chrome' + | 'suppressed-benign' + | 'error-surface' + +type ProviderFrameClassificationTable = { + codex: Record + claude: Record +} + +export const PROVIDER_FRAME_CLASSIFICATIONS = { + codex: { + error: 'error-surface', + 'thread/started': 'status-chrome', + 'thread/status/changed': 'status-chrome', + 'thread/archived': 'status-chrome', + 'thread/deleted': 'status-chrome', + 'thread/unarchived': 'status-chrome', + 'thread/closed': 'status-chrome', + 'skills/changed': 'status-chrome', + 'thread/name/updated': 'status-chrome', + 'thread/goal/updated': 'status-chrome', + 'thread/goal/cleared': 'status-chrome', + 'thread/environment/connected': 'status-chrome', + 'thread/environment/disconnected': 'status-chrome', + 'thread/settings/updated': 'status-chrome', + 'thread/tokenUsage/updated': 'status-chrome', + 'turn/started': 'status-chrome', + 'hook/started': 'suppressed-benign', + 'turn/completed': 'status-chrome', + 'hook/completed': 'suppressed-benign', + 'turn/diff/updated': 'suppressed-benign', + 'turn/plan/updated': 'timeline-substantive', + 'item/started': 'timeline-substantive', + 'item/autoApprovalReview/started': 'status-chrome', + 'item/autoApprovalReview/completed': 'status-chrome', + 'item/completed': 'timeline-substantive', + 'rawResponseItem/completed': 'suppressed-benign', + 'rawResponse/completed': 'suppressed-benign', + 'item/agentMessage/delta': 'stream-into-item', + 'item/plan/delta': 'stream-into-item', + 'command/exec/outputDelta': 'stream-into-item', + 'process/outputDelta': 'stream-into-item', + 'process/exited': 'timeline-substantive', + 'item/commandExecution/outputDelta': 'stream-into-item', + 'item/commandExecution/terminalInteraction': 'stream-into-item', + 'item/fileChange/outputDelta': 'stream-into-item', + 'item/fileChange/patchUpdated': 'stream-into-item', + 'serverRequest/resolved': 'suppressed-benign', + 'item/mcpToolCall/progress': 'status-chrome', + 'mcpServer/oauthLogin/completed': 'status-chrome', + 'mcpServer/startupStatus/updated': 'status-chrome', + 'account/updated': 'status-chrome', + 'account/rateLimits/updated': 'suppressed-benign', + 'app/list/updated': 'status-chrome', + 'remoteControl/status/changed': 'status-chrome', + 'externalAgentConfig/import/progress': 'status-chrome', + 'externalAgentConfig/import/completed': 'status-chrome', + 'fs/changed': 'suppressed-benign', + 'item/reasoning/summaryTextDelta': 'stream-into-item', + 'item/reasoning/summaryPartAdded': 'stream-into-item', + 'item/reasoning/textDelta': 'stream-into-item', + 'thread/compacted': 'status-chrome', + 'model/rerouted': 'status-chrome', + 'model/verification': 'status-chrome', + 'turn/moderationMetadata': 'suppressed-benign', + 'model/safetyBuffering/updated': 'status-chrome', + warning: 'error-surface', + guardianWarning: 'error-surface', + deprecationNotice: 'error-surface', + configWarning: 'error-surface', + 'fuzzyFileSearch/sessionUpdated': 'suppressed-benign', + 'fuzzyFileSearch/sessionCompleted': 'suppressed-benign', + 'thread/realtime/started': 'status-chrome', + 'thread/realtime/itemAdded': 'timeline-substantive', + 'thread/realtime/transcript/delta': 'stream-into-item', + 'thread/realtime/transcript/done': 'timeline-substantive', + 'thread/realtime/outputAudio/delta': 'stream-into-item', + 'thread/realtime/sdp': 'suppressed-benign', + 'thread/realtime/error': 'error-surface', + 'thread/realtime/closed': 'status-chrome', + 'windows/worldWritableWarning': 'error-surface', + 'windowsSandbox/setupCompleted': 'status-chrome', + 'account/login/completed': 'status-chrome' + }, + claude: { + 'message:assistant': 'timeline-substantive', + 'message:user': 'timeline-substantive', + 'message:result': 'status-chrome', + 'message:system:init': 'status-chrome', + 'message:stream_event:message_start': 'status-chrome', + 'message:stream_event:message_delta': 'stream-into-item', + 'message:stream_event:message_stop': 'status-chrome', + 'message:stream_event:content_block_start': 'status-chrome', + 'message:stream_event:content_block_delta': 'stream-into-item', + 'message:stream_event:content_block_stop': 'status-chrome', + 'message:system:compact_boundary': 'status-chrome', + 'message:system:status': 'status-chrome', + 'message:system:api_retry': 'status-chrome', + 'message:system:control_request_progress': 'status-chrome', + 'message:system:model_refusal_fallback': 'status-chrome', + 'message:system:model_refusal_no_fallback': 'error-surface', + 'message:system:local_command_output': 'timeline-substantive', + 'message:system:hook_started': 'suppressed-benign', + 'message:system:hook_progress': 'suppressed-benign', + 'message:system:hook_response': 'suppressed-benign', + 'message:system:plugin_install': 'status-chrome', + 'message:tool_progress': 'status-chrome', + 'message:auth_status': 'status-chrome', + 'message:system:task_notification': 'status-chrome', + 'message:system:task_started': 'status-chrome', + 'message:system:task_updated': 'status-chrome', + 'message:system:task_progress': 'status-chrome', + 'message:system:background_tasks_changed': 'status-chrome', + 'message:system:thinking_tokens': 'status-chrome', + 'message:system:session_state_changed': 'status-chrome', + 'message:system:worker_shutting_down': 'status-chrome', + 'message:system:commands_changed': 'status-chrome', + 'message:system:notification': 'status-chrome', + 'message:system:files_persisted': 'status-chrome', + 'message:tool_use_summary': 'timeline-substantive', + 'message:system:memory_recall': 'timeline-substantive', + 'message:rate_limit_event': 'status-chrome', + 'message:system:elicitation_complete': 'status-chrome', + 'message:system:permission_denied': 'error-surface', + 'message:prompt_suggestion': 'status-chrome', + 'message:system:mirror_error': 'error-surface', + 'message:system:informational': 'timeline-substantive', + 'message:conversation_reset': 'status-chrome' + } +} as const satisfies ProviderFrameClassificationTable + +const ERROR_VARIANT_KEYS = new Set(['type', 'status', 'state', 'subtype', 'outcome']) +const ERROR_VALUE_KEYS = new Set(['error', 'failureReason', 'failure_reason']) + +function isErrorVariant(value: unknown): boolean { + if (typeof value !== 'string') { + return false + } + const normalized = value.replace(/[_\s-]/g, '').toLowerCase() + return ( + normalized.startsWith('error') || normalized.startsWith('fail') || normalized === 'systemerror' + ) +} + +function hasProviderError(payload: unknown): boolean { + const pending = [payload] + const seen = new WeakSet() + while (pending.length > 0) { + const value = pending.pop() + if (typeof value !== 'object' || value === null || seen.has(value)) { + continue + } + seen.add(value) + if (Array.isArray(value)) { + pending.push(...value) + continue + } + for (const [key, nested] of Object.entries(value)) { + if ((key === 'isError' || key === 'is_error') && nested === true) { + return true + } + if (key === 'success' && nested === false) { + return true + } + if (ERROR_VARIANT_KEYS.has(key) && isErrorVariant(nested)) { + return true + } + if (ERROR_VALUE_KEYS.has(key) && nested !== null && nested !== false && nested !== '') { + return true + } + pending.push(nested) + } + } + return false +} + +/** Codex thread-item types with no typed renderer, dispositioned by hand so a + * new item type cannot leak `codex · item:` into the transcript. The + * notification catalog above is keyed by METHOD and never matches these. */ +const CODEX_ITEM_CLASSIFICATIONS: Record = { + // The `thread/compacted` notification is already chrome; its item form is the + // same event and must not read as a mysterious opcode row. + contextCompaction: 'status-chrome' +} + +function notificationKind(kind: string): string { + return kind.startsWith('notification:') ? kind.slice('notification:'.length) : kind +} + +function itemKind(kind: string): string | null { + return kind.startsWith('item:') ? kind.slice('item:'.length) : null +} + +export function isDeltaShapedProviderFrameKind(kind: string): boolean { + return notificationKind(kind).toLowerCase().endsWith('delta') +} + +function catalogClassification( + provider: string, + kind: string +): ProviderFrameClassification | undefined { + if (provider === 'codex') { + const item = itemKind(kind) + if (item !== null) { + return CODEX_ITEM_CLASSIFICATIONS[item] + } + return PROVIDER_FRAME_CLASSIFICATIONS.codex[ + notificationKind(kind) as CodexAppServerNotificationMethod + ] + } + if (provider === 'claude') { + return PROVIDER_FRAME_CLASSIFICATIONS.claude[kind as ClaudeStreamJsonFrameKind] + } + return undefined +} + +export function classifyProviderFrame( + provider: string, + kind: string, + payload: unknown +): ProviderFrameClassification { + // Payload failure inspection outranks the name-shape heuristic below: an + // unknown frame that reports an error must reach the user even when its + // method name happens to look like a stream delta. + if (hasProviderError(payload)) { + return 'error-surface' + } + if (isDeltaShapedProviderFrameKind(kind)) { + return 'stream-into-item' + } + if (provider === 'claude' && kind === 'message:result') { + const subtype = + typeof payload === 'object' && payload !== null + ? (payload as Record).subtype + : undefined + return subtype === 'success' ? 'status-chrome' : 'error-surface' + } + return catalogClassification(provider, kind) ?? 'timeline-substantive' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts new file mode 100644 index 00000000000..6340e12a265 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -0,0 +1,348 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'legacy-session' +const CREATE_OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +const RESUME_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function attachParams( + operationId: string, + expectedRuntimeFence: number | null +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: operationId, + expectedRuntimeFence, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'legacy-thread' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +function adapter(input: { + origin: 'created' | 'resumed' + options?: AgentSessionOptionsResult +}): StructuredAgentSessionAdapter { + return { + acquire: vi + .fn() + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `${input.origin}-link`, + handle: { provider: 'codex', threadId: 'legacy-thread' }, + origin: input.origin, + mintedAtFence: fence, + observedAt: NOW + } + })), + ...(input.options ? { readOptions: vi.fn(async () => input.options!) } : {}), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } +} + +function expectSettledAttachLease(record: AgentSessionRecord | null): void { + expect(record).not.toBeNull() + const lease = record!.lease + const durableState = lease.handoffStage ?? lease.claimStatus + expect(['live', 'released', 'recovering', 'manual-recovery']).toContain(durableState) + expect(lease.handoffStage).not.toBe('new-owner-proving') +} + +describe('structured session acquisition options', () => { + it('persists provider options before proving a resumed legacy record', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + + const created = await performAttach({ + store, + adapter: adapter({ origin: 'created' }), + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: CREATE_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(CREATE_OPERATION, null), + now: () => NOW, + onAttached: () => {} + }) + expect(created).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.options).toBeUndefined() + await store.replaceSessionOptions({ + sessionId: SESSION, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 0, + options: { approvalPolicy: 'on-request', personality: 'concise' }, + now: NOW + }) + + const resumedStore = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local' + }) + await resumedStore.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + const releasedFence = resumedStore.getRecord(SESSION)?.lease.runtimeFence ?? 0 + const resumed = await performAttach({ + store: resumedStore, + adapter: adapter({ + origin: 'resumed', + options: { + current: { model: 'gpt-5.6-terra', effort: 'medium' }, + models: [] + } + }), + journalRoot: root, + authority: { + spawnToken: 'spawn-b', + claimKeyId: 'key-1', + handoffOperationId: RESUME_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(RESUME_OPERATION, releasedFence), + now: () => NOW + 1, + onAttached: () => {} + }) + + expect(resumed).toMatchObject({ ok: true }) + const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + expect(reopened.getRecord(SESSION)?.options).toEqual({ + approvalPolicy: 'on-request', + personality: 'concise', + model: 'gpt-5.6-terra', + effort: 'medium' + }) + }) + + it('releases an acquisition when provider options cannot be read', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const releaseAcquisition = vi.fn(async () => true) + const failingAdapter: StructuredAgentSessionAdapter = { + ...adapter({ origin: 'created' }), + readOptions: vi.fn(async () => { + throw new Error('model list unavailable') + }), + releaseAcquisition + } + + await expect( + performAttach({ + store, + adapter: failingAdapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: CREATE_OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(CREATE_OPERATION, null), + now: () => NOW, + onAttached: () => {} + }) + ).rejects.toThrow('model list unavailable') + expect(releaseAcquisition).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease.ownerProcess).toBeNull() + }) + + describe.each([ + ['adapter acquire', 'acquire'], + ['options read', 'options'], + ['identity commit', 'identity'], + ['owner proof', 'proof'], + ['journal attach', 'journal'] + ] as const)('%s failure', (_label, failurePoint) => { + it.each([ + ['proven cleanup', true], + ['unproven cleanup', false], + ['cleanup error', 'throws'] + ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { + const exitProven = cleanup === true + root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const base = adapter({ + origin: 'created', + options: { current: { model: 'gpt-5.6-terra' }, models: [] } + }) + const injected = new Error(`${failurePoint} failed`) + const acquire = vi.mocked(base.acquire) + const readOptions = vi.mocked(base.readOptions!) + if (failurePoint === 'acquire') { + acquire.mockRejectedValueOnce(injected) + } else if (failurePoint === 'options') { + readOptions.mockRejectedValueOnce(injected) + } else if (failurePoint === 'identity') { + vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(injected) + } else if (failurePoint === 'proof') { + vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(injected) + } else if (failurePoint === 'journal') { + acquire.mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: 'legacy-thread' }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + } + const releaseAcquisition = vi.fn(async () => { + if (cleanup === 'throws') { + throw new Error('cleanup failed') + } + return cleanup + }) + const failingAdapter = { + ...base, + acquire, + readOptions, + releaseAcquisition, + ...(failurePoint === 'journal' + ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) } + : {}) + } + const perform = ( + target: AgentSessionRecordStore, + operationId: string, + fence: number | null + ) => + performAttach({ + store: target, + adapter: failingAdapter, + journalRoot: root!, + authority: { + spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', + claimKeyId: 'key-1', + handoffOperationId: operationId, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(operationId, fence), + now: () => NOW, + onAttached: () => {} + }) + + await expect(perform(store, CREATE_OPERATION, null)).rejects.toThrow( + exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' + ) + + const reopened = await AgentSessionRecordStore.open({ + directory: storeDir, + hostId: 'local' + }) + const failedRecord = reopened.getRecord(SESSION) + expectSettledAttachLease(failedRecord) + expect( + reopened.listOperationRows().find((row) => row.operationId === CREATE_OPERATION)?.outcome + ).toMatchObject({ status: 'failed' }) + + await reopened.reconcileOnRestart({ + probe: async (record) => + exitProven || record.lease.ownerProcess === null + ? exitProven + ? { outcome: 'reservation-unused' } + : { outcome: 'indeterminate', reason: 'owner identity was never committed' } + : { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, + now: NOW + 1 + }) + + if (exitProven) { + expect(failedRecord?.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null + }) + await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) + expectSettledAttachLease(reopened.getRecord(SESSION)) + } else { + expect(failedRecord?.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', + handoffStage: + failurePoint === 'proof' || failurePoint === 'journal' + ? 'recovering' + : 'manual-recovery', + // The settled operation must not stay named by the lease as an in-flight transfer. + handoffOperationId: null, + reservedSpawnToken: 'spawn-a' + }) + await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts new file mode 100644 index 00000000000..5f67240c1c6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + AgentSessionAcquisitionExitUnprovenError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' + +describe('failed agent-session acquisition cleanup', () => { + it('preserves the acquisition failure after proven cleanup', async () => { + const cause = new Error('proof failed') + + await expect( + rethrowAfterAgentSessionAcquisitionCleanup( + { releaseAcquisition: vi.fn(async () => true) }, + 'session-1', + cause + ) + ).rejects.toBe(cause) + }) + + it('reports unproven exit when cleanup returns false', async () => { + await expect( + rethrowAfterAgentSessionAcquisitionCleanup( + { releaseAcquisition: vi.fn(async () => false) }, + 'session-1', + new Error('proof failed') + ) + ).rejects.toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + }) + + it('reports unproven exit when cleanup throws', async () => { + const error = await rethrowAfterAgentSessionAcquisitionCleanup( + { + releaseAcquisition: vi.fn(async () => { + throw new Error('cleanup failed') + }) + }, + 'session-1', + new Error('proof failed') + ).catch((cause: unknown) => cause) + + expect(error).toBeInstanceOf(AgentSessionAcquisitionExitUnprovenError) + expect(error).toMatchObject({ cause: expect.any(AggregateError) }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts new file mode 100644 index 00000000000..b9017ddee24 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -0,0 +1,149 @@ +// What the wire needs from a provider adapter. +// +// Phase 2 implements this over the Codex app-server and the Claude Agent SDK; +// nothing here starts, resumes, or talks to a process. The wire owns the +// journal and the lease, so an adapter only has to answer "did the provider +// take this?" — and it answers `unknown` rather than guessing, because the +// journal renders that as delivery unconfirmed instead of as failure. + +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionExecutionLocation, + AgentSessionProcessIdentity +} from '../../../shared/agent-session-record' +import type { + AgentSessionOptionsResult, + AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' + +export class AgentSessionAcquisitionRefusal extends Error { + constructor( + message: string, + readonly code: AgentSessionWireRefusalCode = 'agent_session_operation_invalid' + ) { + super(message) + this.name = 'AgentSessionAcquisitionRefusal' + } +} + +export class AgentSessionAcquisitionExitUnprovenError extends Error { + constructor(cause: unknown) { + super('agent_session_acquisition_exit_unproven', { cause }) + this.name = 'AgentSessionAcquisitionExitUnprovenError' + } +} + +/** What a reservation turns into once something is actually running under it: + * the process the host can probe, and the provider handle it was minted with. */ +export type AgentSessionAcquisition = { + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink +} + +/** Acquisition validation failed before the adapter attempted to spawn. */ +export class AgentSessionPreSpawnError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'AgentSessionPreSpawnError' + } +} + +export function isAgentSessionPreSpawnError(error: unknown): error is AgentSessionPreSpawnError { + return error instanceof Error && error.name === 'AgentSessionPreSpawnError' +} + +export type AgentSessionDispatchOutcome = + /** The provider owns the turn now, under this identity. */ + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'rejected'; reason: string } + /** The call did not settle. Never re-send on the user's behalf. */ + | { state: 'unknown'; reason: string } + +export type StructuredAgentSessionAcquireInput = { + identity: AgentSessionJournalIdentity + fence: number + spawnToken: string + options?: Readonly> + /** Provider events may begin before acquisition returns. */ + events?: StructuredAgentSessionEventSink +} + +export type StructuredAgentSessionSetOptionInput = { + sessionId: string + key: string + value: string + fence: number +} + +export type StructuredAgentSessionAdapter = { + /** Provider-aware capability check for hosts that route more than one adapter. */ + supportsCreate?(location: AgentSessionExecutionLocation, agent: string): boolean + /** Provider/runtime support, kept here so remote enablement changes adapter data, not UI logic. */ + supportsLocation?(location: AgentSessionExecutionLocation): boolean + /** Makes the reservation real. Called once per reservation, with the spawn + * token the lease was reserved under and the fence the handle must be minted + * at — the store rejects a link minted at any other fence. */ + acquire(input: StructuredAgentSessionAcquireInput): Promise + /** Reaps an acquired provider when the host cannot commit or prove its lease. + * Returns true only after provider child exit is proven. */ + releaseAcquisition?(input: { sessionId: string }): Promise + dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + }): Promise + /** Cancels one turn, not the session: a session-wide interrupt would also kill + * a turn the client never asked to stop. */ + cancelTurn(input: { + sessionId: string + turnId: string + fence: number + }): Promise<{ cancelled: boolean }> + /** Fires the provider callback for an approval or a question. The wire calls + * this only after the durable compare-and-set won, so it runs exactly once. */ + answerPrompt(input: { + sessionId: string + itemId: string + kind: 'approval' | 'question' + optionId: string + fence: number + }): Promise + setOption( + input: StructuredAgentSessionSetOptionInput + ): Promise>> + readOptions?(input: { sessionId: string; fence: number }): Promise + /** Transcript path for journal recovery. Omit to let the existing session-file + * resolver discover it from the provider session id. */ + historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise + /** Gracefully stops the structured owner after its event stream is drained. */ + /** Returns true only after the provider child exit is proven. */ + closeSession?(sessionId: string): Promise + /** Stops a provider child for teardown without requiring a future-resume cursor. */ + disposeSession?(sessionId: string): Promise +} + +export async function rethrowAfterAgentSessionAcquisitionCleanup( + adapter: Pick, + sessionId: string, + cause: unknown +): Promise { + let released: boolean + try { + released = (await adapter.releaseAcquisition?.({ sessionId })) === true + } catch (cleanupError) { + throw new AgentSessionAcquisitionExitUnprovenError( + new AggregateError([cause, cleanupError], 'agent session acquisition cleanup failed') + ) + } + if (released) { + throw cause + } + throw new AgentSessionAcquisitionExitUnprovenError(cause) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts new file mode 100644 index 00000000000..7113be8d54b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-context.ts @@ -0,0 +1,34 @@ +// What attaching needs from the host, named explicitly. +// +// Passing the host itself would let this quietly grow new dependencies; an explicit context makes +// each one a deliberate addition and keeps the orchestration testable without constructing a host. + +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +export type StructuredAgentSessionAttachContext = { + deps: StructuredAgentSessionHostDeps + runtimeState: StructuredAgentSessionHostRuntimeState + sessions: Map + subscribers: { + reset: ( + sessionId: string, + journal: AgentSessionJournal, + reset: AgentJournalResetReason, + fence: number + ) => void + snapshot: (sessionId: string, journal: AgentSessionJournal, fence: number) => void + publish: (sessionId: string, journal: AgentSessionJournal) => void + } + tasks: StructuredAgentSessionTaskQueue + reconcileLeases: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts new file mode 100644 index 00000000000..f8959d5f01a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -0,0 +1,300 @@ +// The attach transition end to end: reserve the lease, make the reservation +// real, open the journal. +// +// Split out of the host so the sequence reads in one place. The host still owns +// the decisions that must not be client-supplied — the spawn token, the claim +// key, the owner probe — and passes them in. + +import { isDeepStrictEqual } from 'node:util' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + admitAttachOrRefuse, + attachJournal, + classifyStoreFailure, + journalIdentityFor, + reserveRequestFor, + type AgentSessionAttachAuthority, + type AgentSessionAttachParams, + type AttachedJournal +} from './structured-agent-session-attach' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { + AgentSessionAcquisitionExitUnprovenError, + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + isAgentSessionPreSpawnError, + rethrowAfterAgentSessionAcquisitionCleanup +} from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { readAgentSessionHydrationPage } from './agent-session-history-page' + +export type AttachFlowInput = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + authority: AgentSessionAttachAuthority + callerKey: string + params: AgentSessionAttachParams + now: () => number + /** Registers the opened journal and fans out to subscribers before the caller + * sees the result, so no client can send against a session the host has not + * finished publishing. */ + onAttached: (attached: AttachedJournal) => void + /** Handed to the adapter so it can journal what the provider streams. The + * host owns it and binds it to the journal inside `onAttached`. */ + eventSink?: StructuredAgentSessionEventSink + /** Stops acquisition-window events targeting the superseded journal. */ + onAcquiring?: () => Promise | void + /** Settles writes already captured by the superseded journal before opening another. */ + beforeJournalOpen?: () => Promise | void + /** Removes any partial host publication after journal attachment fails. */ + onAttachFailed?: () => void +} + +export async function performAttach( + input: AttachFlowInput +): Promise> { + const { params, store } = input + const sessionId = params.envelope.sessionId + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + + let record: AgentSessionRecord + let reservedRecord: AgentSessionRecord | null = null + let replayed = false + try { + const reserved = await store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) + record = reserved.record + replayed = reserved.disposition === 'replayed' + if ( + replayed && + reserved.operationRow.outcome.status !== 'pending' && + reserved.operationRow.outcome.status !== 'succeeded' + ) { + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: reserved.operationRow.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return { ok: false, refusal: replay.refusal } + } + } + reservedRecord = record + if (!agentSessionLeaseAdmitsWriter(record.lease)) { + record = await acquireOwner(input, record) + } + } catch (error) { + const spawnToken = reservedRecord?.lease.reservedSpawnToken + if (reservedRecord && spawnToken) { + // A pre-spawn failure is its own processless proof; the settlement records the + // evidence and the failed operation in one durable transaction. + const exitProof = isAgentSessionPreSpawnError(error) + ? 'processless' + : error instanceof AgentSessionAcquisitionExitUnprovenError + ? 'unproven' + : 'exit-proven' + const outcome = + error instanceof AgentSessionAcquisitionExitUnprovenError + ? { + status: 'failed' as const, + code: 'agent_session_ownership_unknown', + message: error.message + } + : error instanceof AgentSessionAcquisitionRefusal + ? { + status: 'failed' as const, + code: error.code, + message: error.message + } + : { + status: 'failed' as const, + code: 'agent_session_operation_invalid', + message: error instanceof Error ? error.message : String(error) + } + try { + await store.settleFailedAcquisition({ + sessionId, + fence: reservedRecord.lease.runtimeFence, + spawnToken, + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome, + exitProof, + now: input.now() + }) + } catch (settlementError) { + throw new AggregateError( + [error, settlementError], + 'agent session acquisition failure settlement failed' + ) + } + } + if (error instanceof AgentSessionAcquisitionRefusal) { + return { ok: false, refusal: { code: error.code, message: error.message } } + } + return { + ok: false, + refusal: classifyStoreFailure( + error, + store.getRecord(sessionId)?.lease.runtimeFence ?? null, + store.getRecord(sessionId) + ) + } + } + + let attached: AttachedJournal + try { + await input.beforeJournalOpen?.() + attached = await attachJournal({ + record, + params, + journalRoot: input.journalRoot, + adapter: input.adapter + }) + input.onAttached(attached) + await store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + } catch (error) { + return settlePostAcquisitionAttachFailure(input, record, error) + } + + const fence = record.lease.runtimeFence + return { + ok: true, + replayed, + fence, + cursor: attached.journal.cursor(), + value: { + sessionId, + fence, + page: readAgentSessionHydrationPage(attached.journal, fence), + unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds + } + } +} + +async function settlePostAcquisitionAttachFailure( + input: AttachFlowInput, + record: AgentSessionRecord, + cause: unknown +): Promise { + let cleanupError: unknown = cause + let exitProof: 'exit-proven' | 'unproven' = 'unproven' + try { + await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause) + } catch (error) { + cleanupError = error + exitProof = + error instanceof AgentSessionAcquisitionExitUnprovenError ? 'unproven' : 'exit-proven' + } + input.onAttachFailed?.() + try { + await input.store.settleFailedPostAcquisitionAttachment({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + spawnToken: record.lease.reservedSpawnToken ?? '', + callerKey: input.callerKey, + operationId: input.params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + message: cause instanceof Error ? cause.message : String(cause) + }, + exitProof, + now: input.now() + }) + } catch (settlementError) { + throw new AggregateError( + [cleanupError, settlementError], + 'agent session post-acquisition attachment failure settlement failed' + ) + } + throw cleanupError +} + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } + // Pre-spawn proof is single-use: this retry may create a child after the durable clear. + try { + try { + record = await input.store.setReservationProcesslessProof({ + sessionId: record.sessionId, + fence, + spawnToken, + processlessAt: null, + now: input.now() + }) + await input.onAcquiring?.() + } catch (error) { + throw new AgentSessionPreSpawnError(error) + } + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record, input.params), + fence, + // Retries must recover the original reservation, not mint a second child. + spawnToken, + ...(record.options ? { options: record.options } : {}), + ...(input.eventSink ? { events: input.eventSink } : {}) + }) + const options = await readNativeSessionOptions({ + adapter: input.adapter, + sessionId: record.sessionId, + fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } + return await input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now(), + ...(options ? { options } : {}) + }) + } catch (error) { + if (isAgentSessionPreSpawnError(error)) { + throw error + } + return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts new file mode 100644 index 00000000000..74cb78d78b9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -0,0 +1,93 @@ +// The host's attach, lifted out of the host class. +// +// Attach is the one operation that touches every collaborator the host owns — the lease +// reconciler, the recovery resolver, the event sink, the journal, the subscriber set and the task +// queue — so leaving it inline made the host grow every time any of them did. The host keeps the +// state; this owns the ordering between them. + +import { randomUUID } from 'node:crypto' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { + pinnedAgentSessionLaunchArgs, + pinnedAgentSessionLaunchEnv +} from './structured-agent-session-launch-env' +import { refuseAgentSessionMutation } from './structured-agent-session-mutation-admission' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' + +export function attachStructuredAgentSession( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise> { + const sessionId = params.envelope.sessionId + const attaching = context.serialize(sessionId, async () => { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return refuseAgentSessionMutation(unreconciled) + } + await context.runtimeState.resolveRecovery(sessionId) + const eventSink = context.runtimeState.eventSinkFor(sessionId) + const attached = await performAttach({ + store: context.deps.store, + adapter: context.deps.adapter, + journalRoot: context.deps.journalRoot, + eventSink: eventSink.sink, + onAcquiring: () => eventSink.unbind(), + beforeJournalOpen: async () => { + eventSink.unbind() + await eventSink.drained() + }, + authority: { + spawnToken: () => context.deps.mintSpawnToken?.() ?? randomUUID(), + claimKeyId: context.deps.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe: await context.runtimeState.probeOwner(sessionId), + ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), + ...(await pinnedAgentSessionLaunchEnv(context.deps.resolveLaunchEnv, params)) + }, + callerKey, + params, + now: () => context.now(), + onAttachFailed: () => { + context.sessions.delete(sessionId) + eventSink.close() + context.runtimeState.discardEventSink(sessionId) + }, + onAttached: (attached) => { + const fence = context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + const previousFence = context.sessions.get(sessionId)?.fence + context.sessions.set(sessionId, { + journal: attached.journal, + params, + fence, + hasProviderChild: true + }) + if (attached.recovery) { + context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + } else if (previousFence !== undefined && previousFence !== fence) { + context.subscribers.snapshot(sessionId, attached.journal, fence) + } else { + context.subscribers.publish(sessionId, attached.journal) + } + eventSink.bind({ + journal: attached.journal, + fence, + publish: () => context.subscribers.publish(sessionId, attached.journal) + }) + } + }) + // Why: a failed attach that left no session behind must not strand a bound sink; the runtime + // caches one per session id and would hand this same closed instance to the next attempt. + if (!attached.ok && !context.sessions.has(sessionId)) { + eventSink.close() + context.runtimeState.discardEventSink(sessionId) + } + return attached + }) + return context.tasks.trackAttach(attaching) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts new file mode 100644 index 00000000000..cfdbf786e14 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -0,0 +1,222 @@ +// Attach: reserve the session record, then open its journal. +// +// `create` and `ensure` are the same transition with a different starting +// point — a null expected fence means "no session exists yet". Both go through +// the record store's compare-and-swap, which also owns the idempotency row, so +// a retried attach replays instead of reserving a second owner. + +import type { AgentType } from '../../../shared/agent-status-types' +import type { + AgentSessionJournalIdentity, + AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation, + AgentSessionLaunchArgs, + AgentSessionLaunchEnv, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + type AgentSessionJournalRecovery +} from './agent-session-journal-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { structuredAgentSessionRefusalMessage } from './structured-agent-session-refusal-message' + +/** + * Everything a client may declare about the session it wants. Deliberately no + * spawn token, claim key, or owner probe: those are host observations, and a + * client that could assert "the previous owner is dead" could steal a live + * session. The host fills them in. + */ +export type AgentSessionAttachParams = { + envelope: AgentSessionMutationEnvelope + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + agent: AgentType + accountHome: AgentSessionAccountHome + runtimeKind: AgentSessionOwnerRuntimeKind + /** Omitted only for create-by-intent; the adapter proves the durable handle. */ + providerHandle?: Exclude +} + +/** Host-supplied half of the reservation. */ +export type AgentSessionAttachAuthority = { + spawnToken: string | (() => string) + claimKeyId: string + handoffOperationId: string | null + probe: AgentSessionOwnerProbe + launchArgs?: AgentSessionLaunchArgs + launchEnv?: AgentSessionLaunchEnv +} + +/** The fields that define WHICH session this call would attach to. Deliberately + * excludes the spawn token and the probe: those differ between a first attempt + * and its retry, and a retry must replay rather than conflict. */ +export function attachFingerprintFields(params: AgentSessionAttachParams): Record { + return { + location: params.location, + provider: params.provider, + agent: params.agent, + accountHome: params.accountHome, + runtimeKind: params.runtimeKind, + providerHandle: params.providerHandle, + expectedRuntimeFence: params.envelope.expectedRuntimeFence + } +} + +/** Recomputes the fingerprint the client declared and refuses a mismatch before + * anything reaches the store. */ +export function admitAttachOrRefuse( + params: AgentSessionAttachParams +): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + if (params.providerHandle && params.providerHandle.kind !== params.provider) { + return { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: `A ${params.provider} session requires a ${params.provider} provider handle.` + } + } + } + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) + return conflict ? { ok: false, refusal: conflict } : { ok: true, fingerprint } +} + +export function journalIdentityFor( + record: AgentSessionRecord, + params: AgentSessionAttachParams +): AgentSessionJournalIdentity { + const head = agentSessionProviderHandleChainHead(record.providerHandleChain) + const providerHandle: AgentSessionProviderHandle = + head?.handle.provider === 'codex' + ? { kind: 'codex', threadId: head.handle.threadId } + : head?.handle.provider === 'claude' + ? { + kind: 'claude', + sessionId: head.handle.sessionId, + leafUuid: head.handle.leafUuid + } + : (params.providerHandle ?? { kind: 'opaque', agent: params.agent, value: 'pending' }) + return { + sessionId: record.sessionId, + workspaceId: params.location.workspaceId, + hostId: params.location.executionHostId, + agent: params.agent, + providerHandle + } +} + +export type AttachedJournal = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null + /** Submissions the crash boundary settled as `unknown` on this open. */ + unconfirmedClientMessageIds: string[] +} + +/** + * Open the session's journal, recovering it when the stored one is unusable, + * and settle every submission left in flight by a previous process. Orca never + * re-sends those; they surface as delivery unconfirmed. + */ +export async function attachJournal(input: { + record: AgentSessionRecord + params: AgentSessionAttachParams + journalRoot: string + adapter: StructuredAgentSessionAdapter +}): Promise { + const identity = journalIdentityFor(input.record, input.params) + const fence = input.record.lease.runtimeFence + const historyFilePath = input.adapter.historyFilePath + ? await input.adapter.historyFilePath({ identity }) + : null + const opened = await openAgentSessionJournalWithRecovery({ + identity, + journalDir: journalDirectoryFor(input.journalRoot, { + workspaceId: identity.workspaceId, + sessionId: identity.sessionId + }), + fence, + historyFilePath + }) + return { + ...opened, + unconfirmedClientMessageIds: await opened.journal.markPendingSubmissionsUnknown(fence) + } +} + +export function reserveRequestFor(input: { + sessionId: string + params: AgentSessionAttachParams + authority: AgentSessionAttachAuthority + callerKey: string + fingerprint: string + now: number +}): Parameters[0] { + const { params, authority } = input + return { + sessionId: input.sessionId, + location: params.location, + provider: params.provider, + accountHome: params.accountHome, + ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), + ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), + runtimeKind: params.runtimeKind, + expectedFence: params.envelope.expectedRuntimeFence, + spawnToken: authority.spawnToken, + claimKeyId: authority.claimKeyId, + handoffOperationId: authority.handoffOperationId, + probe: authority.probe, + operation: { + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + fingerprint: input.fingerprint + }, + now: input.now + } +} + +/** The store signals refusals by throwing the refusal code. Anything not in the + * known set is a defect, not a client error, and is rethrown. */ +export function classifyStoreFailure( + error: unknown, + currentFence: number | null, + record: AgentSessionRecord | null = null +): AgentSessionWireRefusal { + const rawCode = error instanceof Error ? error.message : String(error) + if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(rawCode)) { + throw error + } + const code = rawCode as AgentSessionWireRefusalCode + return { + code, + // Why: a latched session is exactly where a bare store code strands the user. + message: + structuredAgentSessionRefusalMessage(code, record) ?? + `The session store refused this call: ${code}.`, + ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts new file mode 100644 index 00000000000..1255cc85ff3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-tui-recovery.ts @@ -0,0 +1,35 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' + +export async function recoverDeadTuiHandoffStatus(input: { + store: AgentSessionRecordStore + now: () => number + record: AgentSessionRecord + expectedFence: number + probe: AgentSessionOwnerProbe +}): Promise { + const { expectedFence, now, probe, record, store } = input + if ( + record.lease.runtimeFence !== expectedFence || + record.lease.runtimeKind !== 'tui' || + record.lease.handoffStage !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + return null + } + const recovered = await recoverStoredDeadTuiOwnerForHandoff(store, { + sessionId: record.sessionId, + expectedFence, + operationId: createStructuredAgentSessionOperationId(randomUUID, now()), + probe, + now: now() + }) + return idleStructuredHandoffStatus(recovered) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts new file mode 100644 index 00000000000..6407a98f7f2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + createDeferredStructuredAgentSessionEventSink, + type StructuredAgentSessionEventTarget +} from './structured-agent-session-event-sink' + +const BODY: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'hi' }] +} + +function identity(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +type Recorded = { call: string; fence?: number; ordinal?: number } + +function target( + fence: number, + log: Recorded[], + failOn?: number +): StructuredAgentSessionEventTarget { + const journal = { + appendItem: vi.fn(async (id: AgentJournalItemIdentity, _body: AgentJournalItemBody) => { + const ordinal = id.provider === 'codex' ? id.ordinal : -1 + if (ordinal === failOn) { + throw new Error(`refused ${ordinal}`) + } + log.push({ call: 'appendItem', fence, ordinal }) + return { cursor: { epoch: 'e', sequence: ordinal } } + }), + appendTombstone: vi.fn(async (id: AgentJournalItemIdentity) => { + log.push({ + call: 'appendTombstone', + fence, + ordinal: id.provider === 'codex' ? id.ordinal : -1 + }) + return { epoch: 'e', sequence: 0 } + }) + } as unknown as AgentSessionJournal + return { journal, fence, publish: () => log.push({ call: 'publish', fence }) } +} + +describe('deferred structured agent-session event sink', () => { + it('buffers writes made before the journal exists and drains them in arrival order', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + + deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendItem(identity(1), BODY) + deferred.sink.publish() + expect(log).toEqual([]) + + deferred.bind(target(7, log)) + await deferred.drained() + + expect(log).toEqual([ + { call: 'appendItem', fence: 7, ordinal: 0 }, + { call: 'appendItem', fence: 7, ordinal: 1 }, + { call: 'publish', fence: 7 } + ]) + }) + + it('writes at the fence bound at submission time, so a rebind cannot backdate a write', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(target(1, log)) + + deferred.sink.appendItem(identity(0), BODY) + // The re-attach that raised the fence. + deferred.bind(target(2, log)) + deferred.sink.appendItem(identity(1), BODY) + await deferred.drained() + + expect(log).toEqual([ + { call: 'appendItem', fence: 1, ordinal: 0 }, + { call: 'appendItem', fence: 2, ordinal: 1 } + ]) + }) + + it('buffers replacement-acquisition events while unbound', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + deferred.bind(target(1, log)) + deferred.unbind() + + deferred.sink.appendItem(identity(0), BODY) + expect(log).toEqual([]) + deferred.bind(target(2, log)) + await deferred.drained() + + expect(log).toEqual([{ call: 'appendItem', fence: 2, ordinal: 0 }]) + }) + + it('drops buffered and later writes once closed, and refuses to rebind', async () => { + const log: Recorded[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink() + + deferred.sink.appendItem(identity(0), BODY) + deferred.close() + deferred.bind(target(3, log)) + deferred.sink.appendItem(identity(1), BODY) + await deferred.drained() + + expect(log).toEqual([]) + }) + + it('reports a refused append and keeps draining the rest', async () => { + const log: Recorded[] = [] + const errors: unknown[] = [] + const deferred = createDeferredStructuredAgentSessionEventSink({ + onError: (error) => errors.push(error) + }) + deferred.bind(target(4, log, 0)) + + deferred.sink.appendItem(identity(0), BODY) + deferred.sink.appendTombstone(identity(1)) + await deferred.drained() + + expect(errors).toHaveLength(1) + expect((errors[0] as Error).message).toBe('refused 0') + expect(log).toEqual([{ call: 'appendTombstone', fence: 4, ordinal: 1 }]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts new file mode 100644 index 00000000000..3662da11577 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts @@ -0,0 +1,144 @@ +// Where an adapter writes the provider events it did not synchronously return. +// +// A provider starts streaming the moment its process exists, and that moment is +// INSIDE `adapter.acquire` — before the journal is open and before the host has +// registered the session. So the sink an adapter receives is deferred: writes +// queue in arrival order and drain once the journal exists. +// +// One sink lives for the session, not for one acquisition: a re-attach opens a +// NEW journal object at a NEW fence, and rebinding re-points the same sink at +// it. That keeps a single identity for the adapter to hold across a re-acquire, +// and the adapter closes the superseded child, so nothing writes behind a fence +// that has already moved. + +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { putJournalBlob, removeJournalBlob } from '../agent-session-journal/journal-blob-store' + +export type StructuredAgentSessionJournalBlob = { digest: string; payload: string } + +/** The only journal surface an adapter gets: append and publish, no reads. An + * adapter that could read the journal would start reconciling against it, and + * reconciliation is the wire's job, not the provider's. */ +export type StructuredAgentSessionEventSink = { + appendItem( + identity: AgentJournalItemIdentity, + body: AgentJournalItemBody, + blobs?: readonly StructuredAgentSessionJournalBlob[] + ): void + appendTombstone(identity: AgentJournalItemIdentity): void + /** Fan the journal out to subscribers. Cheap and idempotent. */ + publish(): void +} + +export type StructuredAgentSessionEventTarget = { + journal: AgentSessionJournal + /** Fence the sink writes at. Fixed for the life of the sink: a new fence + * means a new acquisition, which gets its own sink. */ + fence: number + publish: () => void +} + +export type DeferredStructuredAgentSessionEventSink = { + sink: StructuredAgentSessionEventSink + /** Drains everything buffered so far, in order, then writes through. Called + * again on every re-attach to re-point the sink at the new journal. */ + bind(target: StructuredAgentSessionEventTarget): void + /** Queues new provider events until a replacement journal is bound. */ + unbind(): void + /** Permanently stops the sink. Queued writes are dropped rather than landing + * in a journal the host has already let go of. */ + close(): void + /** Resolves once every write queued so far has landed. */ + drained(): Promise +} + +type SinkOperation = (target: StructuredAgentSessionEventTarget) => Promise | void + +export function createDeferredStructuredAgentSessionEventSink( + deps: { + /** A rejected append. Unset drops it: throwing here would surface inside the + * provider's notification callback and take the connection down, and the + * lease already guarantees a stale writer's rows are refused. */ + onError?: (error: unknown) => void + } = {} +): DeferredStructuredAgentSessionEventSink { + let target: StructuredAgentSessionEventTarget | null = null + let closed = false + const buffered: SinkOperation[] = [] + let chain: Promise = Promise.resolve() + + const enqueue = (operation: SinkOperation): void => { + const bound = target + chain = chain.then(async () => { + try { + await operation(bound as StructuredAgentSessionEventTarget) + } catch (error) { + deps.onError?.(error) + } + }) + } + + const submit = (operation: SinkOperation): void => { + if (closed) { + return + } + if (!target) { + buffered.push(operation) + return + } + enqueue(operation) + } + + return { + sink: { + appendItem: (identity, body: AgentJournalItemBody, blobs = []) => { + submit(async (bound) => { + const persisted: string[] = [] + try { + for (const blob of blobs) { + await putJournalBlob(bound.journal.directory, blob.digest, blob.payload) + persisted.push(blob.digest) + } + await bound.journal.appendItem(identity, body, { fence: bound.fence }) + } catch (error) { + const retained = bound.journal.referencedBlobDigests?.() ?? new Set() + for (const digest of persisted) { + if (!retained.has(digest)) { + await removeJournalBlob(bound.journal.directory, digest) + } + } + throw error + } + }) + }, + appendTombstone: (identity) => { + submit((bound) => bound.journal.appendTombstone(identity, { fence: bound.fence })) + }, + publish: () => { + submit((bound) => bound.publish()) + } + }, + bind: (next) => { + if (closed) { + return + } + target = next + const pending = buffered.splice(0) + for (const operation of pending) { + enqueue(operation) + } + }, + unbind: () => { + target = null + }, + close: () => { + closed = true + buffered.length = 0 + }, + drained: () => chain + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts new file mode 100644 index 00000000000..f34caba3593 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction-deadline.ts @@ -0,0 +1,51 @@ +// A bound on how long teardown may take, expressed as a wrapper around the eviction steps rather +// than a change to them. +// +// The step list is ordered and abort-on-failure for reasons that have nothing to do with time, and +// a deadline must not disturb either. Wrapping each step's `run` keeps the order, and a timeout +// surfaces as that step failing — which is exactly the behavior wanted here: the rest of the +// eviction aborts, the session stays indexed, and the child stays LOADED. A stuck app-server that +// is still holding a conversation is a better outcome than one killed out from under it; the next +// close retries. + +import type { StructuredAgentSessionEvictionStep } from './structured-agent-session-eviction' + +export const STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS = 10_000 + +export class StructuredAgentSessionEvictionTimeoutError extends Error { + constructor( + readonly step: string, + readonly timeoutMs: number + ) { + super(`agent session eviction step "${step}" did not finish within ${timeoutMs}ms`) + this.name = 'StructuredAgentSessionEvictionTimeoutError' + } +} + +export function withStructuredAgentSessionEvictionDeadline( + steps: readonly StructuredAgentSessionEvictionStep[], + timeoutMs = STRUCTURED_AGENT_SESSION_EVICTION_STEP_TIMEOUT_MS +): readonly StructuredAgentSessionEvictionStep[] { + return steps.map((step) => ({ + name: step.name, + run: async (context) => { + let timer: ReturnType | undefined + try { + await Promise.race([ + Promise.resolve(step.run(context)), + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new StructuredAgentSessionEvictionTimeoutError(step.name, timeoutMs)), + timeoutMs + ) + timer.unref?.() + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } + } + })) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts new file mode 100644 index 00000000000..7ddae0aa48a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from 'vitest' +import { + evictStructuredAgentSession, + StructuredAgentSessionEvictionError, + STRUCTURED_AGENT_SESSION_EVICTION_STEPS, + type StructuredAgentSessionEvictionContext +} from './structured-agent-session-eviction' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' + +function context(): StructuredAgentSessionEvictionContext & { order: string[] } { + const order: string[] = [] + return { + order, + sessionId: 'session-1', + eventSink: { + unbind: vi.fn(() => order.push('unbind')), + drained: vi.fn(async () => { + order.push('drained') + }), + close: vi.fn(() => order.push('close')) + } as unknown as StructuredAgentSessionEvictionContext['eventSink'], + adapter: { + closeSession: vi.fn(async () => { + order.push('closeSession') + return true + }) + } as unknown as StructuredAgentSessionEvictionContext['adapter'], + forget: vi.fn(() => order.push('forget')), + discardSink: vi.fn(() => order.push('discardSink')), + releaseLease: vi.fn(async () => { + order.push('releaseLease') + }) + } +} + +function runtimeState(): StructuredAgentSessionHostRuntimeState { + return new StructuredAgentSessionHostRuntimeState({ + store: {} as never, + adapter: {} as never + } as never) +} + +describe('structured agent session eviction', () => { + it('stops the child before it lets the sink go, then forgets the session', async () => { + const ctx = context() + await evictStructuredAgentSession(ctx) + expect(ctx.order).toEqual([ + 'closeSession', + 'drained', + 'unbind', + 'close', + 'discardSink', + 'releaseLease', + 'forget' + ]) + }) + + it('uses disposal rather than handoff close when the chat is removed', async () => { + const ctx = context() + const closeSession = vi.fn(async () => { + throw new Error('resume cursor unavailable') + }) + const disposeSession = vi.fn(async () => true) + ctx.adapter = { ...ctx.adapter, closeSession, disposeSession } + + await evictStructuredAgentSession(ctx) + + expect(disposeSession).toHaveBeenCalledWith('session-1') + expect(closeSession).not.toHaveBeenCalled() + }) + + it('names every step, so a half-finished eviction says which one failed', () => { + expect(STRUCTURED_AGENT_SESSION_EVICTION_STEPS.map((step) => step.name)).toEqual([ + 'stop-provider-child', + 'drain-published', + 'stop-publishing', + 'close-sink', + 'discard-sink', + 'release-lease', + 'forget-session' + ]) + }) +}) + +// Closing the codex child is not silent: the adapter emits its `ended` event and flushes coalesced +// text as it shuts down, and those rows are what clear the running-turn marker. If the sink is +// already closed the journal keeps claiming the agent is working, forever. +describe('rows the provider emits while closing', () => { + it('still reach the journal', async () => { + const state = runtimeState() + const sessionId = 'session-closing-rows' + const sink = state.eventSinkFor(sessionId) + const published: string[] = [] + sink.bind({ journal: {} as never, fence: 1, publish: () => published.push('final-flush') }) + + await evictStructuredAgentSession({ + sessionId, + eventSink: sink, + adapter: { + closeSession: async () => { + // What codex-structured-session-close does on its way out. + sink.sink.publish() + return true + } + } as never, + forget: () => {}, + discardSink: () => state.discardEventSink(sessionId), + releaseLease: async () => {} + }) + + expect(published).toEqual(['final-flush']) + }) +}) + +// `closeSession` returning false means the adapter could not prove the child exited and has kept +// the session indexed on purpose so a retry can reach it. +describe('a child that will not stop', () => { + it('aborts without forgetting the session, so the next close is a real retry', async () => { + const ctx = context() + ctx.adapter.closeSession = vi.fn(async () => false) + + await expect(evictStructuredAgentSession(ctx)).rejects.toMatchObject({ + step: 'stop-provider-child' + }) + expect(ctx.forget).not.toHaveBeenCalled() + expect(ctx.discardSink).not.toHaveBeenCalled() + expect(ctx.order).toEqual([]) + }) + + it('reports the failing step and leaves the sink usable for the retry', async () => { + const ctx = context() + ctx.adapter.closeSession = vi.fn(async () => { + throw new Error('child would not stop') + }) + + await expect(evictStructuredAgentSession(ctx)).rejects.toBeInstanceOf( + StructuredAgentSessionEvictionError + ) + expect(ctx.eventSink.close).not.toHaveBeenCalled() + expect(ctx.forget).not.toHaveBeenCalled() + }) +}) + +// The runtime caches ONE sink per session id and hands the same instance to the next attach, so an +// eviction that closes without discarding leaves a reopened chat wired to a permanently closed +// sink — it accepts every provider event and publishes none. +describe('eviction against the real sink cache', () => { + it('lets the session publish again after it is evicted and reattached', async () => { + const state = runtimeState() + const sessionId = 'session-reattach' + await evictStructuredAgentSession({ + sessionId, + eventSink: state.eventSinkFor(sessionId), + adapter: { closeSession: async () => true } as never, + forget: () => {}, + discardSink: () => state.discardEventSink(sessionId), + releaseLease: async () => {} + }) + + const published: string[] = [] + const reattached = state.eventSinkFor(sessionId) + reattached.bind({ journal: {} as never, fence: 2, publish: () => published.push('published') }) + reattached.sink.publish() + await reattached.drained() + + expect(published).toEqual(['published']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts new file mode 100644 index 00000000000..f1d73c25281 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts @@ -0,0 +1,102 @@ +// Releasing one structured session's resources. +// +// Teardown is a DATA list, not a method body, for the reason this file exists at all: the host +// tracked which sessions were live in a map, and tore them down at three unrelated call sites +// (app quit, handoff to a TUI, and error cleanup). Closing a chat was never wired to any of them, +// so a provider child outlived the chat that owned it for the whole app session. +// +// ORDER. The provider child stops FIRST. Closing it is not silent: the codex adapter emits its +// `ended` event and flushes coalesced text as part of shutting down, and those are the rows that +// clear the running-turn marker. Draining or closing the sink ahead of that drops them, which +// leaves the durable journal claiming the agent is still working — a worse outcome than the leak +// this teardown exists to fix. So: stop the child, drain what it emitted on its way out, then let +// the sink go. +// +// FAILURE. A step that fails ABORTS the rest. `closeSession` returning false means the child's +// exit was not proven and the adapter has deliberately kept the session indexed so a retry can +// reach it; forgetting it anyway stranded the process forever and reported success. Leaving the +// session in place is what makes the next close a real retry instead of a no-op. + +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' + +export type StructuredAgentSessionEvictionContext = { + sessionId: string + hasProviderChild?: boolean + eventSink: DeferredStructuredAgentSessionEventSink + adapter: StructuredAgentSessionAdapter + forget: () => void + /** Drops the cached sink so a later attach mints a fresh one. */ + discardSink: () => void + /** Hands the lease back now that this host's child is proven gone. No-ops when the record is + * not this host's to release. */ + releaseLease: () => Promise +} + +export type StructuredAgentSessionEvictionStep = { + name: string + run: (context: StructuredAgentSessionEvictionContext) => Promise | void +} + +export const STRUCTURED_AGENT_SESSION_EVICTION_STEPS: readonly StructuredAgentSessionEvictionStep[] = + [ + { + name: 'stop-provider-child', + run: async (context) => { + if (context.hasProviderChild === false) { + return + } + // An adapter with no close has nothing to stop; anything else must PROVE the exit. + const stop = context.adapter.disposeSession ?? context.adapter.closeSession + if (stop) { + const stopped = await stop.call(context.adapter, context.sessionId) + if (stopped !== true) { + throw new Error('provider child exit was not proven') + } + } + } + }, + { name: 'drain-published', run: (context) => context.eventSink.drained() }, + { name: 'stop-publishing', run: (context) => context.eventSink.unbind() }, + { name: 'close-sink', run: (context) => context.eventSink.close() }, + // Why: the runtime caches one sink per session id and hands the SAME instance to the next + // attach. Closing without discarding leaves a reopened chat bound to a closed sink, which + // accepts every provider event and publishes none. Attach's own failure path already pairs + // these two; eviction has to as well. + { name: 'discard-sink', run: (context) => context.discardSink() }, + // Why here and not last: the durable lease still names a process this host just stopped, and a + // record left claiming a live owner is one nothing can resume — the next surface to open the + // chat would find a session it may not acquire. Placed BEFORE forget so a release that cannot + // be written aborts while the session is still indexed, which is what makes the retry real. + { name: 'release-lease', run: (context) => context.releaseLease() }, + { name: 'forget-session', run: (context) => context.forget() } + ] + +export class StructuredAgentSessionEvictionError extends Error { + constructor( + readonly step: string, + readonly sessionId: string, + override readonly cause: unknown + ) { + super(`agent session eviction failed at step "${step}" for ${sessionId}`) + this.name = 'StructuredAgentSessionEvictionError' + } +} + +/** + * Runs the eviction steps in order, stopping at the first failure. The step name travels with the + * error because the caller's only useful response is to retry, and a retry is only safe when the + * session is still indexed — which is exactly what aborting preserves. + */ +export async function evictStructuredAgentSession( + context: StructuredAgentSessionEvictionContext, + steps: readonly StructuredAgentSessionEvictionStep[] = STRUCTURED_AGENT_SESSION_EVICTION_STEPS +): Promise { + for (const step of steps) { + try { + await step.run(context) + } catch (error) { + throw new StructuredAgentSessionEvictionError(step.name, context.sessionId, error) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts new file mode 100644 index 00000000000..02bf6259e20 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-flow-context.ts @@ -0,0 +1,86 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import { switchingStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function createStructuredHandoffFlowContext(input: { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + requireRecord: (sessionId: string) => AgentSessionRecord +}): StructuredAgentSessionHandoffFlowContext { + const publishStage: StructuredAgentSessionHandoffFlowContext['publishStage'] = ( + record, + direction + ) => { + input.setStatus( + record.sessionId, + switchingStructuredHandoffStatus(record, direction, input.deps.transport?.hostLabel) + ) + } + return { + ...input, + publishStage, + enterPreparing: async (record, operationId, direction) => { + const prepared = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: 'preparing', + handoffOperationId: operationId, + now: input.deps.now() + }) + publishStage(prepared, direction) + } + } +} + +export function requireStructuredHandoffRecord( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string +): AgentSessionRecord { + const record = deps.store.getRecord(sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + return record +} + +export async function markStructuredHandoffManualRecovery( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + _operationId: string +): Promise { + const record = context.requireRecord(sessionId) + await setStoredAgentSessionHandoffStage(context.deps.store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + // A live TUI is still recoverable without an active operation; other records retain the + // failed operation so native/manual proof retries remain idempotent. + handoffOperationId: + record.lease.runtimeKind === 'tui' && record.lease.claimStatus === 'live' + ? null + : _operationId, + now: context.deps.now() + }) +} + +export async function stopStructuredNativeTurn( + deps: StructuredAgentSessionHandoffDeps, + sessionId: string, + turnId: string +): Promise { + const record = deps.store.getRecord(sessionId) + if (!record || record.lease.runtimeKind !== 'native') { + return false + } + const session = deps.session(sessionId) + return (await deps.acquireNativeStop?.(sessionId, turnId, session.fence)) ?? false +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts new file mode 100644 index 00000000000..53c5903197c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-forward.ts @@ -0,0 +1,216 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + rollbackStoredAgentSessionHandoffPreparation, + reserveStoredAgentSessionHandoffOwner, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { AgentSessionAcquisitionExitUnprovenError } from './structured-agent-session-adapter' +import { markStructuredHandoffManualRecovery } from './structured-agent-session-handoff-flow-context' +import type { + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' +import { StructuredTuiLaunchCleanupError } from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToTui( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + if (retry && record.lease.handoffStage === 'old-owner-stopped') { + record = await recoverNativeAfterTuiFailure(context, sessionId, operationId) + } + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-tui') + } else if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + record = context.requireRecord(sessionId) + // A kill is a request. Everything below advances the fence and hands the + // provider session to a TUI, so an unproven exit must stop here rather than + // create a second live writer on the same thread. + let nativeSuspend + try { + nativeSuspend = await deps.suspendNative(sessionId) + } catch (error) { + await rollbackPreparingNativeOwner(context, sessionId, operationId) + throw error + } + if (nativeSuspend.state === 'live') { + await rollbackPreparingNativeOwner(context, sessionId, operationId) + throw new Error('agent_session_owner_exit_unproven') + } + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + if (nativeSuspend.state === 'stopped-cleanup-failed') { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw nativeSuspend.error + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-tui') + let owner: StructuredTuiOwner | null = null + let processIdentityCommitted = false + try { + await deps.prepareTuiHistoryCatchup?.(sessionId, record.lease.runtimeFence) + owner = await deps.transport!.launchTui({ + record, + fence: record.lease.runtimeFence, + spawnToken, + onSpawned: async (spawnedOwner) => { + owner = spawnedOwner + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: spawnedOwner.process, + now: deps.now() + }) + processIdentityCommitted = true + } + }) + if (!processIdentityCommitted) { + await deps.store.commitProcessIdentity({ + sessionId, + fence: record.lease.runtimeFence, + process: owner.process, + now: deps.now() + }) + } + record = await deps.store.proveOwner({ + sessionId, + fence: record.lease.runtimeFence, + link: owner.link, + now: deps.now() + }) + } catch (error) { + deps.stopTuiHistoryCatchup?.(sessionId) + if (!owner && error instanceof StructuredTuiLaunchCleanupError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + if (owner) { + if (!deps.transport?.stopFailedTuiLaunch) { + context.retainOwner(sessionId, owner) + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + try { + await deps.transport.stopFailedTuiLaunch(owner) + } catch (stopError) { + context.retainOwner(sessionId, owner) + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw new AggregateError( + [error, stopError], + 'The failed terminal launch could not be proven stopped.' + ) + } + } + await recoverNativeAfterTuiFailure(context, sessionId, operationId) + throw error + } + context.retainOwner(sessionId, owner) + await deps.activateTuiHistoryCatchup?.(sessionId) + context.setStatus(sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) +} + +async function rollbackPreparingNativeOwner( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +): Promise { + const { deps } = context + const current = context.requireRecord(sessionId) + if ( + current.lease.handoffStage === 'preparing' && + current.lease.handoffOperationId === operationId && + current.lease.claimStatus === 'live' + ) { + await rollbackStoredAgentSessionHandoffPreparation(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + now: deps.now() + }) + } +} + +async function recoverNativeAfterTuiFailure( + context: StructuredAgentSessionHandoffFlowContext, + sessionId: string, + operationId: string +) { + const { deps } = context + let record = context.requireRecord(sessionId) + if (record.lease.handoffStage === 'new-owner-proving') { + record = await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + try { + return await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + if (error instanceof AgentSessionAcquisitionExitUnprovenError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'native', + now: deps.now() + }) + } + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts new file mode 100644 index 00000000000..56cbe4cdc53 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-owner-close.ts @@ -0,0 +1,34 @@ +import { evictAgentSessionOwner } from '../../runtime/agent-session-lease-transitions' +import type { + StructuredAgentSessionHandoffDeps, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export async function closeRetainedTuiOwner(input: { + sessionId: string + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + requireRecord: (sessionId: string) => { lease: { runtimeFence: number } } + releaseOwner: (sessionId: string) => void +}): Promise { + const owner = input.owner(input.sessionId) + if (!owner) { + return false + } + const close = input.deps.transport?.closeTuiOwner ?? input.deps.transport?.waitForTuiExit + if (!close) { + throw new Error('The owning agent terminal could not be stopped.') + } + await close(owner) + const record = input.requireRecord(input.sessionId) + await input.deps.store.transitionHandoff(input.sessionId, (current) => + evictAgentSessionOwner({ + record: current, + expectedFence: record.lease.runtimeFence, + probe: { outcome: 'exit-observed' }, + now: input.deps.now() + }) + ) + input.releaseOwner(input.sessionId) + return true +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts new file mode 100644 index 00000000000..c16ac681226 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart-tui.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' +import { + abandonStoredAgentSessionHandoffAttempt, + stopStoredAgentSessionOwnerForHandoff, + stopStoredRecoveringTuiOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export type StructuredAgentSessionRestartAccess = { + deps: StructuredAgentSessionHandoffDeps + requireRecord: (sessionId: string) => AgentSessionRecord + flowContext: () => StructuredAgentSessionHandoffFlowContext + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void +} + +type ContinueHandoff = ( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord +) => Promise + +export async function recoverUnavailableTuiAsNative( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord, + continueHandoff: ContinueHandoff +): Promise { + await input.deps.transport!.stopRecoveredOwner(record) + if (record.lease.handoffStage === 'preparing') { + const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + now: input.deps.now() + }) + await continueHandoff(input, stopped) + return + } + if (record.lease.handoffStage === 'new-owner-proving') { + const abandoned = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + recoverableRuntimeKind: 'native', + now: input.deps.now() + }) + await continueHandoff(input, abandoned) + return + } + const operationId = createStructuredAgentSessionOperationId(randomUUID, input.deps.now()) + const stopped = await stopStoredRecoveringTuiOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +export async function recoverTuiOwnerOrContinue( + input: StructuredAgentSessionRestartAccess, + record: AgentSessionRecord, + continueHandoff: ContinueHandoff +): Promise { + try { + const owner = await input.deps.transport!.recoverTuiOwner(record) + const reproved = await input.deps.transport!.reproveTuiOwner({ record, owner }) + await persistReprovedTuiOwner(input, record.sessionId, reproved) + return reproved + } catch (error) { + const ownerState = await input.deps.transport!.probeRecoveredOwner?.(record) + if (ownerState !== 'dead') { + throw error + } + await recoverUnavailableTuiAsNative(input, record, continueHandoff) + return null + } +} + +export async function persistReprovedTuiOwner( + input: StructuredAgentSessionRestartAccess, + sessionId: string, + owner: StructuredTuiOwner +): Promise { + if (owner.link.origin === 'resumed') { + await input.deps.persistTuiProviderHandle?.({ + sessionId, + link: owner.link, + now: input.deps.now() + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts new file mode 100644 index 00000000000..13a26f2a7a9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-restart.ts @@ -0,0 +1,306 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { handoffStructuredSessionToTui } from './structured-agent-session-handoff-forward' +import { handoffStructuredSessionToNative } from './structured-agent-session-handoff-reverse' +import { + idleStructuredHandoffStatus, + structuredTuiRecoveryProofIsAdmissible +} from './structured-agent-session-handoff-status' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' +import { + persistReprovedTuiOwner, + recoverTuiOwnerOrContinue, + recoverUnavailableTuiAsNative, + type StructuredAgentSessionRestartAccess +} from './structured-agent-session-handoff-restart-tui' + +type RestartAccess = StructuredAgentSessionRestartAccess + +export async function restoreStructuredAgentSessionHandoff( + input: RestartAccess, + sessionId: string +): Promise { + const initial = input.requireRecord(sessionId) + const operationId = initial.lease.handoffOperationId + const initialStage = initial.lease.handoffStage + if ( + (initialStage === 'recovering' || initialStage === 'manual-recovery') && + !canRestoreLiveTuiOwner(initial) + ) { + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_ownership_unknown' } + }) + } + input.setStatus(sessionId, idleStructuredHandoffStatus(initial)) + return + } + let lastError: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await restoreOnce(input, input.requireRecord(sessionId)) + const settled = input.requireRecord(sessionId) + if (settled.lease.handoffStage !== null || settled.lease.handoffOperationId !== null) { + throw new Error('Restart handoff reconciliation did not settle the transfer.') + } + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'succeeded', sessionId } + }) + } + return + } catch (error) { + lastError = error + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 100 * 2 ** attempt)) + } + } + } + const current = input.requireRecord(sessionId) + const failed = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId, + fence: current.lease.runtimeFence, + stage: 'manual-recovery', + // Keep a live TUI retryable after restart; other records retain the failed operation. + handoffOperationId: + current.lease.runtimeKind === 'tui' && current.lease.claimStatus === 'live' + ? null + : operationId, + now: input.deps.now() + }) + const status = idleStructuredHandoffStatus(failed) + if (operationId) { + await input.deps.store.recordOperationOutcome({ + operationId, + outcome: { status: 'failed', code: 'agent_session_handoff_failed' } + }) + } + input.setStatus(sessionId, { + ...status, + ...(status.error + ? { + error: { + ...status.error, + details: lastError instanceof Error ? lastError.message : String(lastError) + } + } + : {}) + }) +} + +async function restoreOnce(input: RestartAccess, record: AgentSessionRecord): Promise { + if ( + record.lease.handoffStage === null && + record.lease.claimStatus === 'released' && + record.lease.ownerProcess === null + ) { + // Reconcile already proved the owner gone: no transfer is in flight and there is no process to + // recover, whatever kind the last owner was. Falling through would latch manual recovery on a + // host with no TUI transport — a state a user then has to clear by hand, for nothing. (Startup + // used to reach this path only for sessions it had not eagerly resumed, which is why removing + // that resume is what made it visible.) + return + } + if (canRestoreLiveTuiOwner(record)) { + await restoreRecoverableLiveTui(input, record) + return + } + if (!input.deps.transport) { + if (record.lease.handoffStage !== null || record.lease.runtimeKind === 'tui') { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + return + } + if (record.lease.handoffStage === null && record.lease.runtimeKind === 'tui') { + await restoreLiveTui(input, record) + return + } + if (!record.lease.handoffOperationId) { + return + } + if (record.lease.handoffStage === 'preparing') { + await restorePreparing(input, record) + return + } + if (record.lease.handoffStage === 'new-owner-proving') { + await restoreProving(input, record) + return + } + if (record.lease.handoffStage === 'old-owner-stopped') { + await continueHandoff(input, record) + } +} + +export function canRestoreLiveTuiOwner(record: AgentSessionRecord): boolean { + return structuredTuiRecoveryProofIsAdmissible(record) +} + +async function restoreRecoverableLiveTui( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + if (!input.deps.transport) { + throw new Error('Agent TUI handoff recovery is unavailable on this host.') + } + let owner: StructuredTuiOwner + try { + const recovered = await input.deps.transport.recoverTuiOwner(record) + owner = await input.deps.transport.reproveTuiOwner({ record, owner: recovered }) + await persistReprovedTuiOwner(input, record.sessionId, owner) + } catch (error) { + const ownerState = await input.deps.transport.probeRecoveredOwner?.(record) + if (ownerState !== 'dead') { + throw error + } + await recoverUnavailableTuiAsNative(input, record, continueHandoff) + return + } + let settled = input.deps.store.getRecord(record.sessionId) ?? record + if (settled.lease.claimStatus === 'reserved') { + settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: 'new-owner-proving', + handoffOperationId: null, + now: input.deps.now() + }) + settled = await input.deps.store.proveOwner({ + sessionId: settled.sessionId, + fence: settled.lease.runtimeFence, + link: owner.link, + now: input.deps.now() + }) + } else { + settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + } + input.retainOwner(record.sessionId, owner) + await startRecoveredTuiCatchup(input, record) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport.hostLabel + }) +} + +async function restoreLiveTui(input: RestartAccess, record: AgentSessionRecord): Promise { + const owner = await input.deps.transport!.recoverTuiOwner(record) + await persistReprovedTuiOwner(input, record.sessionId, owner) + input.retainOwner(record.sessionId, owner) + await startRecoveredTuiCatchup(input, record) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: owner.terminal, + hostLabel: input.deps.transport?.hostLabel + }) +} + +async function restorePreparing(input: RestartAccess, record: AgentSessionRecord): Promise { + if (record.lease.runtimeKind === 'tui') { + const owner = await recoverTuiOwnerOrContinue(input, record, continueHandoff) + if (!owner) { + return + } + const settled = await setStoredAgentSessionHandoffStage(input.deps.store, { + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + stage: null, + handoffOperationId: null, + now: input.deps.now() + }) + await restoreLiveTui(input, settled) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await stopStoredAgentSessionOwnerForHandoff(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId: record.lease.handoffOperationId!, + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function restoreProving(input: RestartAccess, record: AgentSessionRecord): Promise { + const operationId = record.lease.handoffOperationId! + if (record.lease.runtimeKind === 'tui') { + const reproved = await recoverTuiOwnerOrContinue(input, record, continueHandoff) + if (!reproved) { + return + } + await input.deps.store.proveOwner({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + link: reproved.link, + now: input.deps.now() + }) + input.retainOwner(record.sessionId, reproved) + await startRecoveredTuiCatchup(input, record) + input.setStatus(record.sessionId, { + owner: 'tui', + direction: null, + phase: 'idle', + stage: null, + operationId: null, + terminal: reproved.terminal, + hostLabel: input.deps.transport?.hostLabel + }) + return + } + await input.deps.transport!.stopRecoveredOwner(record) + const stopped = await abandonStoredAgentSessionHandoffAttempt(input.deps.store, { + sessionId: record.sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: input.deps.now() + }) + await continueHandoff(input, stopped) +} + +async function startRecoveredTuiCatchup( + input: RestartAccess, + record: AgentSessionRecord +): Promise { + await input.deps.recoverTuiHistoryCatchup?.(record.sessionId, record.lease.runtimeFence) + await input.deps.activateTuiHistoryCatchup?.(record.sessionId) +} + +async function continueHandoff(input: RestartAccess, record: AgentSessionRecord): Promise { + const direction = record.lease.runtimeKind === 'native' ? 'to-tui' : 'to-native' + const operationId = record.lease.handoffOperationId! + const params: AgentSessionHandoffRequest = { + envelope: { + sessionId: record.sessionId, + clientOperationId: operationId, + expectedRuntimeFence: record.lease.runtimeFence, + payloadFingerprint: 'restart-reconciliation' + }, + direction, + mode: 'now', + action: 'retry' + } + await (direction === 'to-tui' + ? handoffStructuredSessionToTui(input.flowContext(), params, true) + : handoffStructuredSessionToNative(input.flowContext(), params, true)) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts new file mode 100644 index 00000000000..0030760b957 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-reverse.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'node:crypto' +import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire' +import { + abandonStoredAgentSessionHandoffAttempt, + reserveStoredAgentSessionHandoffOwner, + rollbackStoredAgentSessionHandoffPreparation, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { AgentSessionAcquisitionExitUnprovenError } from './structured-agent-session-adapter' +import { markStructuredHandoffManualRecovery } from './structured-agent-session-handoff-flow-context' +import type { StructuredAgentSessionHandoffFlowContext } from './structured-agent-session-handoff-types' + +export async function handoffStructuredSessionToNative( + context: StructuredAgentSessionHandoffFlowContext, + params: AgentSessionHandoffRequest, + retry: boolean, + tuiAlreadyExited = false +): Promise { + const { deps } = context + const sessionId = params.envelope.sessionId + const operationId = params.envelope.clientOperationId + let record = context.requireRecord(sessionId) + let owner = context.owner(sessionId) + let transcriptPath = owner?.transcriptPath + if (!retry || record.lease.handoffStage === 'preparing' || record.lease.handoffStage === null) { + if (record.lease.handoffStage === null) { + await context.enterPreparing(record, operationId, 'to-native') + } + record = context.requireRecord(sessionId) + try { + if (!owner) { + throw new Error('The owning agent terminal could not be identified.') + } + if (!tuiAlreadyExited) { + owner = await deps.transport!.reproveTuiOwner({ record, owner }) + context.retainOwner(sessionId, owner) + if (owner.link.origin === 'resumed') { + await deps.persistTuiProviderHandle?.({ sessionId, link: owner.link, now: deps.now() }) + } + } + transcriptPath = owner.transcriptPath ?? transcriptPath + if (owner.link.handle.provider === 'codex' && !transcriptPath) { + throw new Error( + 'The Codex terminal has not written a durable rollout yet. Send a prompt before switching to structured chat.' + ) + } + context.setStatus(sessionId, { + owner: 'tui', + direction: 'to-native', + phase: 'waiting-for-exit', + stage: 'preparing', + operationId, + terminal: owner.terminal, + hostLabel: deps.transport?.hostLabel + }) + const exited = deps.transport!.closeTuiOwner + ? await deps.transport!.closeTuiOwner(owner) + : await deps.transport!.waitForTuiExit(owner) + transcriptPath = exited.transcriptPath ?? owner.transcriptPath + } catch (error) { + const current = context.requireRecord(sessionId) + if ( + current.lease.handoffStage === 'preparing' && + current.lease.handoffOperationId === operationId && + current.lease.claimStatus === 'live' && + current.lease.ownerProcess + ) { + await rollbackStoredAgentSessionHandoffPreparation(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + now: deps.now() + }) + } + throw error + } + record = await stopStoredAgentSessionOwnerForHandoff(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + operationId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + } else if ( + record.lease.handoffStage !== 'old-owner-stopped' || + record.lease.handoffOperationId !== operationId + ) { + throw new Error('agent_session_operation_conflict') + } + deps.stopTuiHistoryCatchup?.(sessionId) + if (owner?.historySource !== 'provider-resume') { + await deps.importTuiHistory({ + sessionId, + fence: record.lease.runtimeFence, + ...(transcriptPath ? { transcriptPath } : {}) + }) + } + const spawnToken = randomUUID() + record = await reserveStoredAgentSessionHandoffOwner(deps.store, { + sessionId, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'native', + spawnToken, + operationId, + claimKeyId: deps.claimKeyId, + now: deps.now() + }) + context.publishStage(record, 'to-native') + try { + record = await deps.acquireNative({ + sessionId, + fence: record.lease.runtimeFence, + spawnToken + }) + } catch (error) { + if (error instanceof AgentSessionAcquisitionExitUnprovenError) { + await markStructuredHandoffManualRecovery(context, sessionId, operationId) + throw error + } + const current = context.requireRecord(sessionId) + if (current.lease.handoffStage === 'new-owner-proving') { + await abandonStoredAgentSessionHandoffAttempt(deps.store, { + sessionId, + expectedFence: current.lease.runtimeFence, + operationId, + recoverableRuntimeKind: 'tui', + now: deps.now() + }) + } + throw error + } + context.releaseOwner(sessionId) + deps.transport?.revealNativeSession?.({ + workspaceId: record.location.workspaceId, + sessionId, + agent: record.provider, + ...(owner?.adoptedTerminal ? { adoptedTerminal: true } : {}) + }) + context.setStatus(sessionId, { + owner: 'native', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts new file mode 100644 index 00000000000..2917fca2fe2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-state.ts @@ -0,0 +1,38 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { idleStructuredHandoffStatus } from './structured-agent-session-handoff-status' +import type { StructuredTuiOwner } from './structured-agent-session-handoff-types' + +export class StructuredAgentSessionHandoffState { + private readonly statuses = new Map() + private readonly tuiOwners = new Map() + + constructor( + private readonly deps: { + requireRecord: (sessionId: string) => AgentSessionRecord + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + hostLabel?: string + } + ) {} + + status = (sessionId: string): AgentSessionHandoffStatus => { + const value = this.statuses.get(sessionId) + return value ?? idleStructuredHandoffStatus(this.deps.requireRecord(sessionId)) + } + + cachedStatus = (sessionId: string): AgentSessionHandoffStatus | undefined => + this.statuses.get(sessionId) + + owner = (sessionId: string): StructuredTuiOwner | undefined => this.tuiOwners.get(sessionId) + + retainOwner = (sessionId: string, owner: StructuredTuiOwner): void => { + this.tuiOwners.set(sessionId, owner) + } + + releaseOwner = (sessionId: string): void => void this.tuiOwners.delete(sessionId) + + setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => { + this.statuses.set(sessionId, status) + this.deps.publish(sessionId, status) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts new file mode 100644 index 00000000000..7d519a558b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-status.ts @@ -0,0 +1,166 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionHandoffRequest, + AgentSessionHandoffStatus +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +export function structuredTuiStatus( + owner: StructuredTuiOwner | undefined, + transport: StructuredAgentSessionHandoffTransport | undefined +): 'idle' | 'busy' { + return owner ? (transport?.tuiStatus(owner) ?? 'busy') : 'busy' +} + +export function idleStructuredHandoffStatus(record: AgentSessionRecord): AgentSessionHandoffStatus { + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.handoffOperationId + ) { + return persistedFailedStructuredHandoffStatus(record) + } + if (record.lease.handoffStage === 'manual-recovery') { + const canRetryProof = structuredTuiRecoveryProofIsAdmissible(record) + return { + owner: 'none', + direction: record.lease.runtimeKind === 'tui' ? 'to-tui' : 'to-native', + phase: 'failed', + stage: 'manual-recovery', + operationId: record.lease.handoffOperationId, + error: { + message: "Couldn't verify which runtime owns this session — manual recovery is required", + recoverableOwner: 'none', + ...(canRetryProof ? { canRetryProof: true } : {}) + } + } + } + if (record.lease.handoffStage) { + const direction = + record.lease.handoffStage === 'preparing' + ? record.lease.runtimeKind === 'native' + ? 'to-tui' + : 'to-native' + : record.lease.runtimeKind === 'tui' + ? 'to-tui' + : 'to-native' + return { + owner: + record.lease.claimStatus === 'live' && record.lease.ownerProcess + ? record.lease.runtimeKind + : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } + } + return { + owner: record.lease.claimStatus === 'live' ? record.lease.runtimeKind : 'none', + direction: null, + phase: 'idle', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId + } +} + +function persistedFailedStructuredHandoffStatus( + record: AgentSessionRecord +): AgentSessionHandoffStatus { + const recoverableOwner = record.lease.runtimeKind + const direction = recoverableOwner === 'native' ? 'to-tui' : 'to-native' + return { + owner: recoverableOwner, + direction, + phase: 'failed', + stage: 'old-owner-stopped', + operationId: record.lease.handoffOperationId, + error: { + message: + direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + recoverableOwner + } + } +} + +export function structuredSessionHasPendingPrompt(journal: AgentSessionJournal): boolean { + return journal + .snapshot() + .items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) +} + +export function switchingStructuredHandoffStatus( + record: AgentSessionRecord, + direction: 'to-tui' | 'to-native', + hostLabel?: string +): AgentSessionHandoffStatus { + return { + owner: record.lease.ownerProcess ? record.lease.runtimeKind : 'none', + direction, + phase: 'switching', + stage: record.lease.handoffStage, + operationId: record.lease.handoffOperationId, + ...(hostLabel ? { hostLabel } : {}) + } +} + +export function failedStructuredHandoffStatus( + record: AgentSessionRecord, + params: AgentSessionHandoffRequest, + error: unknown, + hostLabel?: string +): AgentSessionHandoffStatus { + const recoverableOwner = + record.lease.handoffStage === 'manual-recovery' + ? 'none' + : record.lease.handoffStage === 'old-owner-stopped' && record.lease.claimStatus === 'released' + ? record.lease.runtimeKind + : record.lease.ownerProcess + ? record.lease.runtimeKind + : params.direction === 'to-tui' && record.lease.runtimeKind === 'native' + ? 'native' + : 'none' + const canRetryProof = structuredTuiRecoveryProofIsAdmissible(record) + return { + owner: recoverableOwner, + direction: params.direction, + phase: 'failed', + stage: record.lease.handoffStage, + operationId: params.envelope.clientOperationId, + ...(hostLabel ? { hostLabel } : {}), + error: { + message: + recoverableOwner === 'none' + ? "Couldn't verify which runtime owns this session — manual recovery is required" + : params.direction === 'to-tui' + ? "Couldn't open the agent terminal — chat still owns this session" + : "Couldn't resume chat — the agent terminal still owns this session", + details: error instanceof Error ? error.message : String(error), + recoverableOwner, + ...(canRetryProof ? { canRetryProof: true } : {}) + } + } +} + +/** A latched TUI with a recorded process can be re-proved, regardless of which acquisition + * phase was interrupted. The operation ledger, not the lease, records the failed attempt. */ +export function structuredTuiRecoveryProofIsAdmissible(record: AgentSessionRecord): boolean { + return ( + (record.lease.handoffStage === 'recovering' || + record.lease.handoffStage === 'manual-recovery') && + record.lease.runtimeKind === 'tui' && + record.lease.ownerProcess !== null && + ((record.lease.claimStatus === 'reserved' && record.lease.handoffOperationId !== null) || + (record.lease.claimStatus === 'live' && record.lease.handoffOperationId === null)) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts new file mode 100644 index 00000000000..27769c8d5b4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff-types.ts @@ -0,0 +1,112 @@ +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +export type StructuredTuiOwner = { + terminal: { handle: string; tabId: string; paneKey: string; ptyId: string } + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink + transcriptPath?: string + /** Codex app-server resume, not row-by-row legacy import, restores this owner's history. */ + historySource?: 'provider-resume' + /** This owner came from an existing terminal view rather than a structured-session tab. */ + adoptedTerminal?: true +} + +export class StructuredTuiLaunchCleanupError extends Error { + constructor( + launchError: unknown, + readonly cleanupError: unknown + ) { + super('The failed terminal launch could not be proven stopped.', { cause: launchError }) + this.name = 'StructuredTuiLaunchCleanupError' + } +} + +export type StructuredAgentSessionHandoffTransport = { + hostLabel: string + launchTui(input: { + record: AgentSessionRecord + fence: number + spawnToken: string + onSpawned?: (owner: StructuredTuiOwner) => Promise + }): Promise + reproveTuiOwner(input: { + record: AgentSessionRecord + owner: StructuredTuiOwner + }): Promise + recoverTuiOwner(record: AgentSessionRecord): Promise + probeRecoveredOwner?(record: AgentSessionRecord): Promise<'live' | 'dead' | 'unknown'> + stopRecoveredOwner(record: AgentSessionRecord): Promise + closeTuiOwner?(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> + revealNativeSession?(input: { + workspaceId: string + sessionId: string + agent?: 'claude' | 'codex' + adoptedTerminal?: true + }): void + waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }> + waitForTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> + tuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' + stopFailedTuiLaunch?(owner: StructuredTuiOwner): Promise +} + +export type StructuredNativeSuspendResult = + | { state: 'live' } + | { state: 'stopped' } + | { state: 'stopped-cleanup-failed'; error: unknown } + +export type StructuredAgentSessionHandoffDeps = { + store: AgentSessionRecordStore + claimKeyId: string + transport?: StructuredAgentSessionHandoffTransport + session: (sessionId: string) => { journal: AgentSessionJournal; fence: number } + suspendNative: (sessionId: string) => Promise + acquireNative: (input: { + sessionId: string + fence: number + spawnToken: string + }) => Promise + acquireNativeStop?: (sessionId: string, turnId: string, fence: number) => Promise + importTuiHistory: (input: { + sessionId: string + fence: number + transcriptPath?: string + }) => Promise + prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise + activateTuiHistoryCatchup?: (sessionId: string) => Promise + stopTuiHistoryCatchup?: (sessionId: string) => void + publish: (sessionId: string, status: AgentSessionHandoffStatus) => void + schedule: (sessionId: string, task: () => Promise) => Promise + now: () => number + /** Persist a provider handle observed while re-proving a TUI owner. */ + persistTuiProviderHandle?: (input: { + sessionId: string + link: AgentSessionProviderHandleLink + now: number + }) => Promise +} + +export type StructuredAgentSessionHandoffFlowContext = { + deps: StructuredAgentSessionHandoffDeps + owner: (sessionId: string) => StructuredTuiOwner | undefined + retainOwner: (sessionId: string, owner: StructuredTuiOwner) => void + releaseOwner: (sessionId: string) => void + setStatus: (sessionId: string, status: AgentSessionHandoffStatus) => void + enterPreparing: ( + record: AgentSessionRecord, + operationId: string, + direction: 'to-tui' | 'to-native' + ) => Promise + publishStage: (record: AgentSessionRecord, direction: 'to-tui' | 'to-native') => void + requireRecord: (sessionId: string) => AgentSessionRecord +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts new file mode 100644 index 00000000000..4c9f1e69609 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.test.ts @@ -0,0 +1,384 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + reserveStoredAgentSessionHandoffOwner, + setStoredAgentSessionHandoffStage, + stopStoredAgentSessionOwnerForHandoff +} from '../../runtime/agent-session-handoff-record-transitions' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-handoff' +const PLAIN_RESIDUE = 'session-plain-residue' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let store: AgentSessionRecordStore +let journal: Awaited> +let coordinator: StructuredAgentSessionHandoffCoordinator +let statuses: AgentSessionHandoffStatus[] +type TransportMock = ReturnType< + typeof vi.fn< + Extract, (...args: never[]) => unknown> + > +> +let launchTui: TransportMock<'launchTui'> +let waitForTuiExit: TransportMock<'waitForTuiExit'> +let closeTuiOwner: TransportMock<'closeTuiOwner'> +let waitForTuiIdleOrExit: TransportMock<'waitForTuiIdleOrExit'> +let reproveTuiOwner: TransportMock<'reproveTuiOwner'> +let stopFailedTuiLaunch: TransportMock<'stopFailedTuiLaunch'> +let acquireNativeStop: ReturnType Promise>> +let acquireNativeCalls: number +let stopRecoveredOwner: TransportMock<'stopRecoveredOwner'> +let operations: number +type HistoryCatchup = (sessionId: string, fence: number) => Promise +let prepareTuiHistoryCatchup: ReturnType> +let recoverTuiHistoryCatchup: ReturnType> +let activateTuiHistoryCatchup: ReturnType Promise>> +let stopTuiHistoryCatchup: ReturnType void>> + +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function process(spawnToken: string, pid: number) { + return { + hostId: 'local', + pid, + processStartTimeMs: NOW - 1_000, + spawnToken + } +} + +function link(fence: number, id: string) { + return { + linkId: id, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } +} + +async function establishNativeOwner(): Promise { + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'native-initial', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'initial' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: process('native-initial', 4100), + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { ...link(fence, 'initial-link'), origin: 'created' }, + now: NOW + }) +} + +function makeTuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { + handle: 'term-tui', + tabId: 'tab-tui', + paneKey: 'tab-tui:leaf-tui', + ptyId: 'pty-tui' + }, + process: process(spawnToken, 4200), + link: link(fence, `tui-link-${fence}`), + transcriptPath: join(root, 'rollout.jsonl') + } +} + +function createCoordinator(): StructuredAgentSessionHandoffCoordinator { + return new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui, + reproveTuiOwner, + recoverTuiOwner: async (record) => { + const owner = makeTuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ) + return { ...owner, process: record.lease.ownerProcess ?? owner.process } + }, + probeRecoveredOwner: async () => 'dead', + stopRecoveredOwner, + closeTuiOwner, + waitForTuiExit, + waitForTuiIdleOrExit, + tuiStatus: () => 'idle', + stopFailedTuiLaunch + }, + session: () => ({ + journal, + fence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1 + }), + suspendNative: vi.fn(async () => ({ state: 'stopped' as const })), + acquireNative: async (input) => { + acquireNativeCalls += 1 + await store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: process(input.spawnToken, 4300 + acquireNativeCalls), + now: NOW + }) + return store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: link(input.fence, `native-link-${input.fence}`), + now: NOW + }) + }, + acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId), + importTuiHistory: async ({ fence }) => { + await journal.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'tui-turn', ordinal: 0 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'from tui' }] }, + { fence, recovered: true } + ) + }, + prepareTuiHistoryCatchup, + recoverTuiHistoryCatchup, + activateTuiHistoryCatchup, + stopTuiHistoryCatchup, + publish: (_sessionId, status) => statuses.push(status), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-')) + operations = 0 + statuses = [] + acquireNativeCalls = 0 + prepareTuiHistoryCatchup = vi.fn(async () => undefined) + recoverTuiHistoryCatchup = vi.fn(async () => undefined) + activateTuiHistoryCatchup = vi.fn(async () => undefined) + stopTuiHistoryCatchup = vi.fn() + stopRecoveredOwner = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await establishNativeOwner() + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) + waitForTuiExit = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) + closeTuiOwner = vi.fn(async (owner) => ({ transcriptPath: owner.transcriptPath })) + waitForTuiIdleOrExit = vi.fn(async () => 'idle') + reproveTuiOwner = vi.fn(async ({ owner }) => owner) + stopFailedTuiLaunch = vi.fn(async () => undefined) + acquireNativeStop = vi.fn(async () => true) + coordinator = createCoordinator() +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +// The direction-agnostic restore path is the crash-during-acquisition recovery every +// plain direct launch depends on: restart adjudication parks a crashed acquire at a +// handoff stage, and restore() is what un-strands it. The interactive handoff request +// flow itself is deliberately absent from this build. +describe('structured session ownership recovery on restore', () => { + it('continues a persisted preparing stage after restart instead of stranding it', async () => { + const operation = operationId() + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + }) + + it('finishes a live new-owner-proving stage after restart', async () => { + const operation = operationId() + let record = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: operation, + now: NOW + }) + record = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + operationId: operation, + now: NOW + }) + const spawnToken = 'restarted-tui' + record = await reserveStoredAgentSessionHandoffOwner(store, { + sessionId: SESSION, + expectedFence: record.lease.runtimeFence, + runtimeKind: 'tui', + spawnToken, + operationId: operation, + claimKeyId: 'key-1', + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: record.lease.runtimeFence, + process: process(spawnToken, 4400), + now: NOW + }) + coordinator = createCoordinator() + + await coordinator.restore(SESSION) + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + expect(coordinator.status(SESSION)).toMatchObject({ owner: 'tui', phase: 'idle' }) + expect(recoverTuiHistoryCatchup).toHaveBeenCalledWith( + SESSION, + store.getRecord(SESSION)?.lease.runtimeFence + ) + }) + + it('continues only the persisted TUI handoff after a store restart', async () => { + const plainOperation = operationId() + await store.reserveOwner({ + sessionId: PLAIN_RESIDUE, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'plain-residue-token', + claimKeyId: 'key-1', + handoffOperationId: plainOperation, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: plainOperation, fingerprint: 'plain-attach' }, + now: NOW + }) + const handoffOperation = operationId() + let interrupted = await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: 1, + stage: 'preparing', + handoffOperationId: handoffOperation, + now: NOW + }) + interrupted = await stopStoredAgentSessionOwnerForHandoff(store, { + sessionId: SESSION, + expectedFence: interrupted.lease.runtimeFence, + operationId: handoffOperation, + now: NOW + }) + const interruptedFence = interrupted.lease.runtimeFence + + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + await store.reconcileOnRestart({ + probe: async (record) => + record.sessionId === PLAIN_RESIDUE + ? { outcome: 'indeterminate', reason: 'plain reservation cannot be attributed' } + : { outcome: 'pid-absent' }, + now: NOW + 1_000 + }) + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + launchTui = vi.fn(async ({ fence, spawnToken }) => makeTuiOwner(fence, spawnToken)) + coordinator = createCoordinator() + + await coordinator.restore(PLAIN_RESIDUE) + expect(launchTui).not.toHaveBeenCalled() + expect(acquireNativeCalls).toBe(0) + expect(store.getRecord(PLAIN_RESIDUE)?.lease).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 1, + handoffStage: 'manual-recovery', + claimStatus: 'reserved', + reservedSpawnToken: 'plain-residue-token' + }) + expect( + store.listOperationRows().find((row) => row.operationId === plainOperation)?.outcome + ).toMatchObject({ status: 'failed', code: 'agent_session_ownership_unknown' }) + + await coordinator.restore(SESSION) + + expect(launchTui).toHaveBeenCalledOnce() + expect(acquireNativeCalls).toBe(1) + expect(launchTui.mock.calls[0]?.[0]).toMatchObject({ + record: { + sessionId: SESSION, + lease: { handoffOperationId: handoffOperation } + }, + fence: interruptedFence + 3 + }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + runtimeFence: interruptedFence + 3, + handoffStage: null, + handoffOperationId: null, + claimStatus: 'live' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts new file mode 100644 index 00000000000..0e213921609 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-handoff.ts @@ -0,0 +1,63 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire' +import { + createStructuredHandoffFlowContext, + requireStructuredHandoffRecord +} from './structured-agent-session-handoff-flow-context' +import { restoreStructuredAgentSessionHandoff } from './structured-agent-session-handoff-restart' +import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close' +import type { + StructuredAgentSessionHandoffDeps, + StructuredAgentSessionHandoffFlowContext +} from './structured-agent-session-handoff-types' +import { StructuredAgentSessionHandoffState } from './structured-agent-session-handoff-state' + +export class StructuredAgentSessionHandoffCoordinator { + private readonly state: StructuredAgentSessionHandoffState + + constructor(private readonly deps: StructuredAgentSessionHandoffDeps) { + // oxfmt-ignore + this.state = new StructuredAgentSessionHandoffState({ requireRecord: (sessionId) => this.requireRecord(sessionId), publish: deps.publish, hostLabel: deps.transport?.hostLabel }) + } + + status = (sessionId: string) => this.state.status(sessionId) + + closeRetainedTuiOwner = (sessionId: string): Promise => + closeRetainedTuiOwner({ + sessionId, + deps: this.deps, + owner: this.state.owner, + requireRecord: this.requireRecord, + releaseOwner: this.state.releaseOwner + }) + + setStatus = (sessionId: string, status: AgentSessionHandoffStatus): void => + this.state.setStatus(sessionId, status) + + async restore(sessionId: string): Promise { + await restoreStructuredAgentSessionHandoff( + { + deps: this.deps, + requireRecord: (id) => this.requireRecord(id), + flowContext: () => this.flowContext(), + retainOwner: this.state.retainOwner, + setStatus: this.state.setStatus + }, + sessionId + ) + } + + private flowContext(): StructuredAgentSessionHandoffFlowContext { + return createStructuredHandoffFlowContext({ + deps: this.deps, + owner: this.state.owner, + retainOwner: this.state.retainOwner, + releaseOwner: this.state.releaseOwner, + setStatus: this.state.setStatus, + requireRecord: (sessionId) => this.requireRecord(sessionId) + }) + } + + private requireRecord = (sessionId: string): AgentSessionRecord => + requireStructuredHandoffRecord(this.deps, sessionId) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts new file mode 100644 index 00000000000..70fc9a43ed2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -0,0 +1,40 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { + AgentSessionHistoryRequest, + AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' + +function providerSessionMetadata( + record: AgentSessionRecord | null +): AgentProviderSessionMetadata | undefined { + const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null + return head + ? { + key: 'session_id', + id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + } + : undefined +} + +export function readStructuredAgentSessionHistoryResult(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + request: AgentSessionHistoryRequest +}): AgentSessionHistoryResult { + const result = readAgentSessionHistory(input.journal, input.request) + const fence = input.record?.lease.runtimeFence + const providerSession = providerSessionMetadata(input.record) + if (fence === undefined) { + return providerSession ? { ...result, providerSession } : result + } + return { + ...result, + page: { ...result.page, fence }, + ...(result.ok ? {} : { fence }), + ...(providerSession ? { providerSession } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts new file mode 100644 index 00000000000..5ebdfed0114 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-hold-resume.ts @@ -0,0 +1,52 @@ +// Giving a held session its provider child back. +// +// This is the replacement for the startup resume, and the difference is only in WHO asks: the same +// eligibility rule, run when a surface binds instead of when the app launches. A write-capable hold +// must fail when acquisition is refused so the surface never mistakes a readable journal for a live +// provider child. + +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumeOperationId, + structuredAgentSessionResumeParams +} from './structured-agent-session-resume-eligibility' + +export async function resumeHeldStructuredAgentSession(input: { + sessionId: string + deps: StructuredAgentSessionHostDeps + now: () => number + attach: ( + params: AgentSessionAttachParams + ) => Promise> +}): Promise { + const record = input.deps.store.getRecord(input.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + if (!adapterSupportsRecord(input.deps.adapter, record)) { + throw new Error('structured_agent_session_unsupported') + } + const params = structuredAgentSessionResumeParams( + record, + structuredAgentSessionResumeOperationId(input.now()) + ) + if (!params) { + throw new Error( + record.lease.unreconciled + ? 'execution_owner_reconciling' + : record.lease.claimStatus === 'conflicted' + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + ) + } + const attached = await input.attach(params) + if (!attached.ok) { + throw new Error(attached.refusal.code) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts new file mode 100644 index 00000000000..0771f288b65 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holders.ts @@ -0,0 +1,49 @@ +// Who WANTS this session alive, as a set of ids rather than a count. +// +// A refcount is the obvious shape and the wrong one. Every path that decrements it — a chat tab +// closing, a transport dying, a client retrying a release it already sent — can fire twice or not +// at all, and an integer cannot tell those apart: a duplicate release evicts a session somebody is +// still looking at, and a lost one leaks the child forever. A set answers both idempotently, +// because it records WHICH surface holds the session, not how many do. + +export class StructuredAgentSessionHolders { + private readonly bySession = new Map>() + + /** True when the session gained its FIRST holder — the edge that ends a pending release. */ + add(sessionId: string, holderId: string): boolean { + const holders = this.bySession.get(sessionId) + if (!holders) { + this.bySession.set(sessionId, new Set([holderId])) + return true + } + holders.add(holderId) + return false + } + + /** True when the session lost its LAST holder — the edge that starts one. */ + remove(sessionId: string, holderId: string): boolean { + const holders = this.bySession.get(sessionId) + if (!holders?.delete(holderId) || holders.size > 0) { + return false + } + this.bySession.delete(sessionId) + return true + } + + isHeld(sessionId: string): boolean { + return (this.bySession.get(sessionId)?.size ?? 0) > 0 + } + + has(sessionId: string, holderId: string): boolean { + return this.bySession.get(sessionId)?.has(holderId) ?? false + } + + holderIds(sessionId: string): string[] { + return [...(this.bySession.get(sessionId) ?? [])] + } + + /** Drops every holder of one session without evaluating the edge, for a session that is gone. */ + forget(sessionId: string): void { + this.bySession.delete(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts new file mode 100644 index 00000000000..88f4079fc7f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.test.ts @@ -0,0 +1,231 @@ +// The parts a session's lifetime is assembled from: the holder set, the release clock, and the +// deadline that keeps teardown from hanging. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { StructuredAgentSessionHolders } from './structured-agent-session-holders' +import { StructuredAgentSessionReleaseClock } from './structured-agent-session-release-clock' +import { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import { + STRUCTURED_AGENT_SESSION_EVICTION_STEPS, + evictStructuredAgentSession +} from './structured-agent-session-eviction' +import { + StructuredAgentSessionEvictionTimeoutError, + withStructuredAgentSessionEvictionDeadline +} from './structured-agent-session-eviction-deadline' + +const clocks: StructuredAgentSessionReleaseClock[] = [] + +function clock(deps: { + isTurnActive?: () => boolean + isHeld?: () => boolean + evict: (sessionId: string) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void +}): StructuredAgentSessionReleaseClock { + const created = new StructuredAgentSessionReleaseClock({ + isTurnActive: deps.isTurnActive ?? (() => false), + isHeld: deps.isHeld ?? (() => false), + evict: deps.evict, + ...(deps.onError ? { onError: deps.onError } : {}), + graceMs: 1 + }) + clocks.push(created) + return created +} + +afterEach(() => { + for (const created of clocks.splice(0)) { + created.dispose() + } +}) + +describe('the holder set', () => { + it('reports the first and last holder, and nothing in between', () => { + const holders = new StructuredAgentSessionHolders() + + expect(holders.add('session-1', 'a')).toBe(true) + expect(holders.add('session-1', 'b')).toBe(false) + expect(holders.remove('session-1', 'a')).toBe(false) + expect(holders.remove('session-1', 'b')).toBe(true) + expect(holders.isHeld('session-1')).toBe(false) + }) + + // The reason this is a set and not a count: every release path can fire twice or not at all. + it('absorbs a duplicate hold and a duplicate release', () => { + const holders = new StructuredAgentSessionHolders() + + holders.add('session-1', 'a') + holders.add('session-1', 'a') + expect(holders.remove('session-1', 'a')).toBe(true) + expect(holders.remove('session-1', 'a')).toBe(false) + expect(holders.holderIds('session-1')).toEqual([]) + }) + + it('keeps one session holders out of another session holders', () => { + const holders = new StructuredAgentSessionHolders() + + holders.add('session-1', 'a') + holders.add('session-2', 'a') + holders.remove('session-1', 'a') + + expect(holders.isHeld('session-1')).toBe(false) + expect(holders.isHeld('session-2')).toBe(true) + }) +}) + +describe('the release clock', () => { + it('waits out a running turn instead of evicting into it', async () => { + const evict = vi.fn(async () => {}) + let turnRunning = true + const releasing = clock({ isTurnActive: () => turnRunning, evict }) + + releasing.arm('session-1') + await new Promise((resolve) => setTimeout(resolve, 30)) + expect(evict).not.toHaveBeenCalled() + + turnRunning = false + await vi.waitFor(() => expect(evict).toHaveBeenCalledWith('session-1')) + }) + + it('stands down when a holder arrives during the wait', async () => { + const evict = vi.fn(async () => {}) + const releasing = clock({ isHeld: () => true, evict }) + + releasing.arm('session-1') + await new Promise((resolve) => setTimeout(resolve, 30)) + + expect(evict).not.toHaveBeenCalled() + }) + + it('reports a failed eviction rather than swallowing it', async () => { + const onError = vi.fn() + const releasing = clock({ + evict: async () => { + throw new Error('child would not stop') + }, + onError + }) + + releasing.arm('session-1') + + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-1', + error: expect.objectContaining({ message: 'child would not stop' }) + }) + ) + }) +}) + +describe('holds', () => { + it('resumes a session on its first hold and not on a retained one', async () => { + let child = false + const resume = vi.fn(async () => { + child = true + }) + const holds = new StructuredAgentSessionHolds({ + resume, + hasProviderChild: () => child, + isTurnActive: () => false, + evict: async () => {}, + graceMs: 1 + }) + + await holds.hold('session-1', 'stream-1', { resume: false }) + expect(resume).not.toHaveBeenCalled() + + await holds.hold('session-1', 'chat-1') + expect(resume).toHaveBeenCalledOnce() + + child = true + await holds.hold('session-1', 'chat-2') + expect(resume).toHaveBeenCalledOnce() + holds.dispose() + }) + + it('never arms the clock for a session with nothing to stop', async () => { + const evict = vi.fn(async () => {}) + const holds = new StructuredAgentSessionHolds({ + resume: async () => {}, + hasProviderChild: () => false, + isTurnActive: () => false, + evict, + graceMs: 1 + }) + + await holds.hold('session-1', 'chat-1', { resume: false }) + holds.release('session-1', 'chat-1') + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(evict).not.toHaveBeenCalled() + expect(holds.isReleasePending('session-1')).toBe(false) + holds.dispose() + }) + + it('fails a write-capable hold when resume proves no provider child', async () => { + const holds = new StructuredAgentSessionHolds({ + resume: async () => {}, + hasProviderChild: () => false, + isTurnActive: () => false, + evict: async () => {}, + graceMs: 1 + }) + + await expect(holds.hold('session-1', 'chat-1')).rejects.toThrow( + 'agent_session_ownership_unknown' + ) + expect(holds.isHeld('session-1')).toBe(false) + holds.dispose() + }) +}) + +describe('the teardown deadline', () => { + it('leaves the child loaded instead of forcing it, and keeps the session indexed', async () => { + const forget = vi.fn() + const releaseLease = vi.fn(async () => {}) + + await expect( + evictStructuredAgentSession( + { + sessionId: 'session-1', + eventSink: { + unbind: vi.fn(), + drained: vi.fn(async () => {}), + close: vi.fn() + } as never, + adapter: { closeSession: () => new Promise(() => {}) } as never, + forget, + discardSink: vi.fn(), + releaseLease + }, + withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS, 5) + ) + ).rejects.toMatchObject({ step: 'stop-provider-child' }) + + expect(forget).not.toHaveBeenCalled() + expect(releaseLease).not.toHaveBeenCalled() + }) + + it('names the step that ran out of time', async () => { + const [step] = withStructuredAgentSessionEvictionDeadline( + [{ name: 'slow-step', run: () => new Promise(() => {}) }], + 5 + ) + + await expect(step?.run({} as never)).rejects.toBeInstanceOf( + StructuredAgentSessionEvictionTimeoutError + ) + }) + + it('does not delay a step that finishes', async () => { + const ran: string[] = [] + const steps = withStructuredAgentSessionEvictionDeadline( + [{ name: 'fast-step', run: () => void ran.push('fast-step') }], + 5_000 + ) + + await steps[0]?.run({} as never) + + expect(ran).toEqual(['fast-step']) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts new file mode 100644 index 00000000000..eac3554783f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts @@ -0,0 +1,103 @@ +// The lifetime of a structured session, tied to the surfaces that want one. +// +// Nothing used to tell the host that a chat WANTED a session, and nothing told it when a chat +// stopped wanting one. Both halves of that gap cost real processes: sessions nobody had opened got +// an app-server at every launch, and sessions the user closed kept theirs until the app quit. +// +// A surface takes a hold when it binds and drops it when it goes away. The first hold on a session +// with no child resumes it — that, and not the shape of a lease on disk, is what makes a provider +// process exist. The last hold leaving starts the release clock. Transport close is the BACKSTOP, +// not the mechanism: a client that vanishes mid-flight never sends its release, so the caller +// registers one against the connection and the holder set absorbs the duplicate. + +import { + StructuredAgentSessionReleaseClock, + type StructuredAgentSessionReleaseClockDeps +} from './structured-agent-session-release-clock' +import { StructuredAgentSessionHolders } from './structured-agent-session-holders' + +export type StructuredAgentSessionHoldsDeps = { + /** Acquires a provider child for a session that has none. A no-op when one is already live. */ + resume: (sessionId: string) => Promise + /** Whether evicting this session would actually free anything. */ + hasProviderChild: (sessionId: string) => boolean + isTurnActive: (sessionId: string) => boolean + evict: (sessionId: string) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + graceMs?: number +} + +export type StructuredAgentSessionHoldOptions = { + /** False for a hold that only RETAINS — a subscription stream, which must not make a child + * exist just by reading history. */ + resume?: boolean +} + +export class StructuredAgentSessionHolds { + private readonly holders = new StructuredAgentSessionHolders() + private readonly clock: StructuredAgentSessionReleaseClock + + constructor(private readonly deps: StructuredAgentSessionHoldsDeps) { + const clockDeps: StructuredAgentSessionReleaseClockDeps = { + isTurnActive: deps.isTurnActive, + isHeld: (sessionId) => this.holders.isHeld(sessionId), + evict: (sessionId) => this.deps.evict(sessionId), + ...(deps.onError ? { onError: deps.onError } : {}), + ...(deps.graceMs === undefined ? {} : { graceMs: deps.graceMs }) + } + this.clock = new StructuredAgentSessionReleaseClock(clockDeps) + } + + async hold( + sessionId: string, + holderId: string, + options: StructuredAgentSessionHoldOptions = {} + ): Promise { + const alreadyHeld = this.holders.has(sessionId, holderId) + this.holders.add(sessionId, holderId) + // Unconditional, not only on the first-holder edge: a second surface arriving during the grace + // window must cancel the pending release too. + this.clock.cancel(sessionId) + if (options.resume === false || this.deps.hasProviderChild(sessionId)) { + return + } + try { + await this.deps.resume(sessionId) + if (!this.deps.hasProviderChild(sessionId)) { + throw new Error('agent_session_ownership_unknown') + } + } catch (error) { + if (!alreadyHeld) { + this.holders.remove(sessionId, holderId) + } + throw error + } + } + + release(sessionId: string, holderId: string): void { + if (!this.holders.remove(sessionId, holderId)) { + return + } + if (this.deps.hasProviderChild(sessionId)) { + this.clock.arm(sessionId) + } + } + + /** Drops the holders of a session that is gone, whoever evicted it. */ + forget(sessionId: string): void { + this.clock.cancel(sessionId) + this.holders.forget(sessionId) + } + + isHeld(sessionId: string): boolean { + return this.holders.isHeld(sessionId) + } + + isReleasePending(sessionId: string): boolean { + return this.clock.isArmed(sessionId) + } + + dispose(): void { + this.clock.dispose() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts new file mode 100644 index 00000000000..245cdec9f17 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.test.ts @@ -0,0 +1,30 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { structuredTuiTranscriptImportOptions } from './structured-agent-session-host-handoff' + +function importRecord(provider: 'claude' | 'codex', accountHome: string): AgentSessionRecord { + return { + provider, + accountHome: { + variable: provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME', + path: accountHome + } + } as AgentSessionRecord +} + +describe('structured TUI transcript import roots', () => { + it('uses the managed Claude account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('claude', '/managed/claude'))).toEqual( + { + claudeProjectsDir: join('/managed/claude', 'projects') + } + ) + }) + + it('uses the managed Codex account home when no live transcript path remains', () => { + expect(structuredTuiTranscriptImportOptions(importRecord('codex', '/managed/codex'))).toEqual({ + codexSessionsDirs: [join('/managed/codex', 'sessions')] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts new file mode 100644 index 00000000000..e52197f14db --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-handoff.ts @@ -0,0 +1,225 @@ +import { join } from 'node:path' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { LegacyImportOptions } from '../agent-session-journal/journal-legacy-import' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { journalIdentityFor } from './structured-agent-session-attach' +import { rethrowAfterAgentSessionAcquisitionCleanup } from './structured-agent-session-adapter' +import { canRestoreLiveTuiOwner } from './structured-agent-session-handoff-restart' +import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' +import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui-recovery' +import { readNativeSessionOptions } from './structured-agent-session-option-restoration' +import type { AgentSessionSubscribers } from './structured-agent-session-subscribers' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' + +type HostHandoffAccess = { + session: (sessionId: string) => StructuredAgentSessionHostSession + eventSink: (sessionId: string) => DeferredStructuredAgentSessionEventSink + flush: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + subscribers: AgentSessionSubscribers + now: () => number +} + +export type StructuredAgentSessionHostHandoff = StructuredAgentSessionHandoffCoordinator & { + stopTuiHistoryCatchup: () => void + recoverDeadTuiOwner: ( + sessionId: string, + expectedFence: number, + probe: AgentSessionOwnerProbe + ) => Promise +} + +export async function refreshRecoverableStructuredHandoffStatus( + handoff: StructuredAgentSessionHostHandoff, + store: StructuredAgentSessionHostDeps['store'], + sessionId: string +) { + const record = store.getRecord(sessionId) + if (record && canRestoreLiveTuiOwner(record)) { + await handoff.restore(sessionId) + } + return handoff.status(sessionId) +} + +export function createStructuredAgentSessionHostHandoff( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess +): StructuredAgentSessionHostHandoff { + const tuiHistoryCatchup = new StructuredTuiTranscriptCatchup({ + store: deps.store, + session: host.session, + schedule: host.serialize, + publish: (sessionId) => { + const session = host.session(sessionId) + host.subscribers.publish(sessionId, session.journal) + }, + reset: (sessionId, fence) => { + const session = host.session(sessionId) + host.subscribers.reset(sessionId, session.journal, 'epoch_changed', fence) + }, + ...(deps.onEventSinkError ? { onError: deps.onEventSinkError } : {}) + }) + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store: deps.store, + claimKeyId: deps.claimKeyId, + ...(deps.handoffTransport ? { transport: deps.handoffTransport } : {}), + session: host.session, + suspendNative: async (sessionId) => { + if (!deps.adapter.closeSession) { + return { state: 'live' } + } + const exited = await deps.adapter.closeSession(sessionId) + if (exited !== true) { + // Report the unproven exit; the forward handoff refuses on it. + return { state: 'live' } + } + host.session(sessionId).hasProviderChild = false + try { + await host.flush(sessionId) + host.eventSink(sessionId).unbind() + return { state: 'stopped' } + } catch (error) { + return { state: 'stopped-cleanup-failed', error } + } + }, + acquireNative: (input) => acquireNativeHandoffOwner(deps, host, input), + acquireNativeStop: async (sessionId, turnId, fence) => + (await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled, + importTuiHistory: (input) => importTuiHistory(deps, host, input), + prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence), + recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence), + activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId), + stopTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.stop(sessionId), + publish: (sessionId, status) => { + const session = host.session(sessionId) + const fence = deps.store.getRecord(sessionId)?.lease.runtimeFence ?? session.fence + host.subscribers.handoff(sessionId, fence, status) + }, + schedule: host.serialize, + now: host.now, + ...(deps.persistTuiProviderHandle + ? { persistTuiProviderHandle: deps.persistTuiProviderHandle } + : {}) + }) + return Object.assign(coordinator, { + stopTuiHistoryCatchup: () => tuiHistoryCatchup.stopAll(), + recoverDeadTuiOwner: async ( + sessionId: string, + expectedFence: number, + probe: AgentSessionOwnerProbe + ) => { + const record = deps.store.getRecord(sessionId) + if (!record) { + return + } + const status = await recoverDeadTuiHandoffStatus({ + store: deps.store, + now: host.now, + record, + expectedFence, + probe + }) + if (status) { + coordinator.setStatus(sessionId, status) + } + } + }) +} + +async function importTuiHistory( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; transcriptPath?: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + const head = record?.providerHandleChain.at(-1) + if (!record || !head) { + throw new Error('agent_session_identity_required') + } + const options = structuredTuiTranscriptImportOptions(record, input.transcriptPath) + const providerSessionId = + head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + const imported = await importLegacyTranscriptIntoJournal({ + journal: session.journal, + agent: head.handle.provider, + sessionId: providerSessionId, + fence: input.fence, + options + }) + if (!imported.ok) { + throw new Error(imported.error) + } + host.subscribers.reset(input.sessionId, session.journal, 'epoch_changed', input.fence) +} + +export function structuredTuiTranscriptImportOptions( + record: AgentSessionRecord, + transcriptPath?: string +): LegacyImportOptions { + if (transcriptPath) { + return { filePath: transcriptPath } + } + return record.provider === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } +} + +async function acquireNativeHandoffOwner( + deps: StructuredAgentSessionHostDeps, + host: HostHandoffAccess, + input: { sessionId: string; fence: number; spawnToken: string } +): Promise { + const session = host.session(input.sessionId) + const record = deps.store.getRecord(input.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const eventSink = host.eventSink(input.sessionId) + eventSink.unbind() + await eventSink.drained() + const acquired = await deps.adapter.acquire({ + identity: journalIdentityFor(record, session.params), + fence: input.fence, + spawnToken: input.spawnToken, + ...(record.options ? { options: record.options } : {}), + events: eventSink.sink + }) + let proved: AgentSessionRecord + try { + const options = await readNativeSessionOptions({ + adapter: deps.adapter, + sessionId: input.sessionId, + fence: input.fence, + ...(record.options ? { priorOptions: record.options } : {}) + }) + await deps.store.commitProcessIdentity({ + sessionId: input.sessionId, + fence: input.fence, + process: acquired.process, + now: host.now() + }) + proved = await deps.store.proveOwner({ + sessionId: input.sessionId, + fence: input.fence, + link: acquired.link, + now: host.now(), + ...(options ? { options } : {}) + }) + } catch (error) { + return rethrowAfterAgentSessionAcquisitionCleanup(deps.adapter, input.sessionId, error) + } + session.hasProviderChild = true + session.fence = proved.lease.runtimeFence + eventSink.bind({ + journal: session.journal, + fence: proved.lease.runtimeFence, + publish: () => host.subscribers.publish(input.sessionId, session.journal) + }) + host.subscribers.snapshot(input.sessionId, session.journal, proved.lease.runtimeFence) + return proved +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts new file mode 100644 index 00000000000..5f3bbc5c731 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -0,0 +1,87 @@ +// The host's half of a session's lifetime: what a close does, and what a hold is wired to. +// +// Lifted out of the host for the same reason attaching was — the host is a coordinator, and the +// sequence that stops a provider child and hands its lease back reads better next to the holder +// bookkeeping that decides when to run it than buried among the twenty other things a session can +// do. + +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection' +import { + evictStructuredAgentSession, + STRUCTURED_AGENT_SESSION_EVICTION_STEPS, + type StructuredAgentSessionEvictionContext +} from './structured-agent-session-eviction' +import { withStructuredAgentSessionEvictionDeadline } from './structured-agent-session-eviction-deadline' +import { StructuredAgentSessionHolds } from './structured-agent-session-holds' +import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release' + +export type StructuredAgentSessionLifetimeContext = { + deps: StructuredAgentSessionHostDeps + runtimeState: StructuredAgentSessionHostRuntimeState + sessions: Map + now: () => number +} + +function hasProviderChild( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): boolean { + return context.sessions.get(sessionId)?.hasProviderChild === true +} + +/** Runs the eviction steps under a deadline. A step that fails — or runs out of time — aborts the + * rest, which leaves the session indexed and the child loaded so the next close is a real retry. */ +export async function evictHeldStructuredAgentSession( + context: StructuredAgentSessionLifetimeContext, + sessionId: string +): Promise { + if (!context.sessions.has(sessionId)) { + return + } + const eviction: StructuredAgentSessionEvictionContext = { + sessionId, + hasProviderChild: hasProviderChild(context, sessionId), + eventSink: context.runtimeState.eventSinkFor(sessionId), + adapter: context.deps.adapter, + forget: () => context.sessions.delete(sessionId), + discardSink: () => context.runtimeState.discardEventSink(sessionId), + releaseLease: () => + releaseStoredStructuredAgentSessionOwner({ + store: context.deps.store, + sessionId, + hasProviderChild: hasProviderChild(context, sessionId), + now: context.now() + }) + } + await evictStructuredAgentSession( + eviction, + withStructuredAgentSessionEvictionDeadline(STRUCTURED_AGENT_SESSION_EVICTION_STEPS) + ) +} + +export function createStructuredAgentSessionHolds( + context: StructuredAgentSessionLifetimeContext, + input: { + resume: (sessionId: string) => Promise + evict: (sessionId: string) => Promise + } +): StructuredAgentSessionHolds { + return new StructuredAgentSessionHolds({ + resume: input.resume, + evict: input.evict, + hasProviderChild: (sessionId) => hasProviderChild(context, sessionId), + isTurnActive: (sessionId) => { + const session = context.sessions.get(sessionId) + return session + ? activeStructuredAgentSessionTurnId(session.journal.snapshot().items) !== null + : false + }, + onError: (error) => context.deps.onEventSinkError?.(error), + ...(context.deps.releaseGraceMs === undefined ? {} : { graceMs: context.deps.releaseGraceMs }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts new file mode 100644 index 00000000000..c9afa06e525 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -0,0 +1,115 @@ +// Everything a client can ask an ALREADY-ATTACHED session to do: send a turn, cancel one, answer a +// prompt, change an option, read the options back. +// +// They share one shape — admit the envelope against the lease, run a plan, publish the journal — so +// they share one path here rather than five copies in the host. The host keeps attach, holds and +// teardown; this is the surface that assumes those already happened. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionOptionResult, + AgentSessionOptionsResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission' +import { + cancelPlan, + promptPlan, + sendPlan, + setOptionPlan, + type MutationPlan +} from './structured-agent-session-mutation-plans' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' + +export type StructuredAgentSessionMutationContext = { + deps: StructuredAgentSessionHostDeps + sessions: Map + publish: (sessionId: string, journal: StructuredAgentSessionHostSession['journal']) => void + requireSession: (sessionId: string) => StructuredAgentSessionHostSession + serialize: (sessionId: string, task: () => Promise) => Promise + now: () => number +} + +function mutate( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan +): Promise> { + return context.serialize(envelope.sessionId, () => + admitAndRunAgentSessionMutation({ + store: context.deps.store, + adapter: context.deps.adapter, + callerKey: caller.callerKey, + envelope, + plan, + journal: context.sessions.get(envelope.sessionId)?.journal, + publish: (journal) => context.publish(envelope.sessionId, journal), + now: () => context.now() + }) + ) +} + +export function sendStructuredAgentSessionTurn( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + retryUnknown?: true + beforeRun?: () => void + } +): Promise> { + return mutate(context, caller, params.envelope, sendPlan(params)) +} + +export function cancelStructuredAgentSessionTurn( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; turnId: string } +): Promise> { + return mutate(context, caller, params.envelope, cancelPlan(params)) +} + +export function respondToStructuredAgentSessionPrompt( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { + envelope: AgentSessionMutationEnvelope + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string + } +): Promise> { + return mutate(context, caller, params.envelope, promptPlan(params)) +} + +export function setStructuredAgentSessionOption( + context: StructuredAgentSessionMutationContext, + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } +): Promise> { + return mutate(context, caller, params.envelope, setOptionPlan(params)) +} + +export function readStructuredAgentSessionOptions( + context: StructuredAgentSessionMutationContext, + sessionId: string +): Promise { + return context.serialize(sessionId, async () => { + const session = context.requireSession(sessionId) + if (!context.deps.adapter.readOptions) { + throw new Error('structured_agent_session_options_unsupported') + } + return context.deps.adapter.readOptions({ sessionId, fence: session.fence }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts new file mode 100644 index 00000000000..80e4da5370b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' + +const NOW = 1_800_000_000_000 + +function reservedRecord(): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: 'session-probe', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + lease: { + sessionId: 'session-probe', + runtimeKind: 'native', + runtimeFence: 3, + handoffStage: 'new-owner-proving', + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: 'spawn-probe', + leaseDeadlineAt: NOW + 30_000, + lastRenewedAt: NOW, + handoffOperationId: 'op-1', + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'reserved', + unreconciled: false, + deathEvidence: null + }, + createdAt: NOW, + updatedAt: NOW + } +} + +function runtimeState( + record: AgentSessionRecord | null, + probeOwner: NonNullable +) { + const deps = { + store: { getRecord: () => record } as unknown as AgentSessionRecordStore, + adapter: {}, + journalRoot: '/tmp', + claimKeyId: 'key-1', + probeOwner + } as StructuredAgentSessionHostDeps + return new StructuredAgentSessionHostRuntimeState(deps) +} + +describe('host runtime-state owner probe', () => { + it('routes an ownerless reservation through the strict probe instead of fabricating proof', async () => { + // Fabricating `reservation-unused` here skipped the processless-proof rule the runtime + // probe enforces — the exact answer that mints a second writer on one provider session. + const probeOwner = vi.fn(async () => ({ + outcome: 'indeterminate' as const, + reason: 'reservation named no process' + })) + const state = runtimeState(reservedRecord(), probeOwner) + + await expect(state.probeOwner('session-probe')).resolves.toEqual({ + outcome: 'indeterminate', + reason: 'reservation named no process' + }) + expect(probeOwner).toHaveBeenCalledTimes(1) + }) + + it('skips the probe for a released ownerless record acquisition never consults it for', async () => { + const released = reservedRecord() + released.lease.claimStatus = 'released' + released.lease.handoffStage = null + released.lease.reservedSpawnToken = null + const probeOwner = vi.fn(async () => ({ outcome: 'indeterminate' as const, reason: 'x' })) + const state = runtimeState(released, probeOwner) + + await expect(state.probeOwner('session-probe')).resolves.toEqual({ + outcome: 'reservation-unused' + }) + expect(probeOwner).not.toHaveBeenCalled() + }) + + it('treats a session with no record at all as an unused reservation', async () => { + const probeOwner = vi.fn(async () => ({ outcome: 'indeterminate' as const, reason: 'x' })) + const state = runtimeState(null, probeOwner) + + await expect(state.probeOwner('session-probe')).resolves.toEqual({ + outcome: 'reservation-unused' + }) + expect(probeOwner).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts new file mode 100644 index 00000000000..d1db05df872 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.ts @@ -0,0 +1,99 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + createDeferredStructuredAgentSessionEventSink, + type DeferredStructuredAgentSessionEventSink +} from './structured-agent-session-event-sink' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' +import { resolveStructuredSessionRecovery } from './structured-agent-session-recovery-resolution' + +export class StructuredAgentSessionHostRuntimeState { + private readonly eventSinks = new Map() + private readonly leaseRenewer: StructuredAgentSessionLeaseRenewer + + constructor( + private readonly deps: StructuredAgentSessionHostDeps, + onLeaseRenewed?: (record: AgentSessionRecord) => Promise, + onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise + ) { + this.leaseRenewer = new StructuredAgentSessionLeaseRenewer({ + store: deps.store, + probe: (record) => this.probeRecord(record), + ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), + now: () => deps.now?.() ?? Date.now(), + ...(onLeaseRenewed ? { onRenewed: onLeaseRenewed } : {}), + ...(onDeadTuiOwner ? { onDeadTuiOwner } : {}), + onError: ({ sessionId, error }) => deps.onEventSinkError?.({ sessionId, error }) + }) + } + + startLeaseRenewal(): void { + this.leaseRenewer.start() + } + + stopLeaseRenewal(): void { + this.leaseRenewer.stop() + } + + eventSinkFor(sessionId: string): DeferredStructuredAgentSessionEventSink { + const existing = this.eventSinks.get(sessionId) + if (existing) { + return existing + } + const created = createDeferredStructuredAgentSessionEventSink({ + onError: (error) => this.deps.onEventSinkError?.({ sessionId, error }) + }) + this.eventSinks.set(sessionId, created) + return created + } + + discardEventSink(sessionId: string): void { + this.eventSinks.delete(sessionId) + } + + flushEventSink(sessionId: string): Promise { + return this.eventSinks.get(sessionId)?.drained() ?? Promise.resolve() + } + + async flushAllEventSinks(): Promise { + await Promise.all([...this.eventSinks.values()].map((sink) => sink.drained())) + } + + /** Exit from a latched recovery stage when present-time evidence permits one. */ + resolveRecovery(sessionId: string): Promise<'resolved' | 'unresolved' | 'not-applicable'> { + return resolveStructuredSessionRecovery( + { + store: this.deps.store, + probeRecord: (record) => this.probeRecord(record), + now: () => this.deps.now?.() ?? Date.now(), + ...(this.deps.stopOwnerProcess ? { stopOwnerProcess: this.deps.stopOwnerProcess } : {}) + }, + sessionId + ) + } + + probeOwner(sessionId: string): Promise { + const record = this.deps.store.getRecord(sessionId) + if ( + !record || + (record.lease.ownerProcess === null && record.lease.claimStatus !== 'reserved') + ) { + // Acquisition only consults the probe against a recorded owner or a live reservation. + return Promise.resolve({ outcome: 'reservation-unused' }) + } + // A live reservation goes through the strict probe: calling it unused without its + // processless proof is the answer that mints a second writer. + return this.probeRecord(record) + } + + probeRecord(record: AgentSessionRecord): Promise { + return ( + this.deps.probeOwner?.(record) ?? + Promise.resolve({ + outcome: 'indeterminate', + reason: 'This host cannot probe structured session owners.' + }) + ) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts new file mode 100644 index 00000000000..122907c4737 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -0,0 +1,20 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +export type StructuredAgentSessionTab = { + sessionId: string + workspaceId: string + agent: AgentSessionRecord['provider'] +} + +export function listStructuredAgentSessionTabs( + sessions: ReadonlyMap< + string, + { params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } } + > +): StructuredAgentSessionTab[] { + return [...sessions.entries()].map(([sessionId, session]) => ({ + sessionId, + workspaceId: session.params.location.workspaceId, + agent: session.params.provider + })) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts new file mode 100644 index 00000000000..69e6d029d60 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data.ts @@ -0,0 +1,63 @@ +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +export const HOST_TEST_NOW = 1_800_000_000_000 +export const HOST_TEST_SESSION = 'session-alpha' +export const HOST_TEST_THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +export const HOST_TEST_LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +let operations = 0 + +export function resetHostTestOperationIds(): void { + operations = 0 +} + +export function hostTestOperationId(): string { + operations += 1 + return `${HOST_TEST_NOW}-${operations.toString(16).padStart(32, '0')}` +} + +export function hostTestMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +export function hostTestAttachParams( + expectedRuntimeFence: number | null, + overrides: Partial = {} +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: HOST_TEST_SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence, + payloadFingerprint: '0'.repeat(64) + }, + location: HOST_TEST_LOCATION, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: HOST_TEST_THREAD }, + ...overrides + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts new file mode 100644 index 00000000000..f4d7ed7608b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -0,0 +1,53 @@ +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +export type StructuredAgentSessionCaller = { callerKey: string } + +export type StructuredAgentSessionHostSession = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number + /** Whether THIS host generation is running the provider process behind the session. A journal + * restored for reading has none, and neither has a session a TUI owns — so neither may be + * evicted to free a child, and neither may have its lease released as an observed exit. */ + hasProviderChild: boolean +} + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + probeOwners?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + /** Recovery-exit stop requests only; a lease moves only on a later proven-absent probe. */ + stopOwnerProcess?: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void + /** Host spawn-token process scan; null means the platform cannot enumerate, never "none". */ + scanSpawnTokenProcesses?: () => Promise + mintSpawnToken?: () => string + resolveLaunchArgs?: ( + provider: AgentSessionRecord['provider'] + ) => Promise | string[] | undefined + resolveLaunchEnv?: ( + provider: AgentSessionRecord['provider'] + ) => Promise | undefined> | Record | undefined + now?: () => number + persistTuiProviderHandle?: (input: { + sessionId: string + link: AgentSessionProviderHandleLink + now: number + }) => Promise + /** How long a session outlives its last surface. Tests drive this; production takes the default. */ + releaseGraceMs?: number + onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts new file mode 100644 index 00000000000..c32958a1abc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -0,0 +1,879 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +function envelope( + method: string, + fields: Record, + overrides: Partial = {} +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }), + ...overrides + } +} + +const attachParams = ( + overrides: Partial = {} +): AgentSessionAttachParams => hostTestAttachParams(null, overrides) + +const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let releaseAcquisition: Mock> +let dispatch: Mock +let cancelTurn: Mock +let answerPrompt: Mock +let setOption: Mock +let ordinal = 0 + +function accepted(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition, + dispatch, + cancelTurn, + answerPrompt, + setOption + } +} + +async function attach(): Promise { + const result = await host.attach(CALLER, attachParams()) + expect(result.ok).toBe(true) + return store.getRecord(SESSION) +} + +/** Puts a pending approval in the journal BEFORE attach, which is the only way + * 1d can stage one: the adapter that would emit it is phase 2's. */ +async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } + const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir + }) + const appended = await journal.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) + resetHostTestOperationIds() + ordinal = 0 + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + releaseAcquisition = vi.fn(async () => true) + dispatch = vi.fn(async () => accepted()) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + answerPrompt = vi.fn(async () => undefined) + setOption = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('attach', () => { + it('reserves the lease, spawns through the adapter, and opens the journal', async () => { + const result = await host.attach(CALLER, attachParams()) + expect(result).toMatchObject({ ok: true, replayed: false }) + const record = store.getRecord(SESSION) + expect(record?.lease.ownerProcess?.pid).toBe(4242) + expect(record?.lease.handoffStage).toBeNull() + }) + + it('refuses a payload the client fingerprinted wrong', async () => { + const params = attachParams() + const result = await host.attach(CALLER, { + ...params, + envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + }) + + it('refuses a provider handle that belongs to a different provider', async () => { + const params = attachParams({ + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + }) + + expect(await host.attach(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('refuses a second create against a live session', async () => { + await attach() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) + }) + + it('replays a retried attach instead of reserving a second owner', async () => { + const params = attachParams() + await host.attach(CALLER, params) + const retry = await host.attach(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + }) + + it('retires a failed proved acquisition before admitting a fresh operation', async () => { + const acquire = vi + .fn() + .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: 'stale-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence + 1, + observedAt: NOW + } + })) + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = attachParams() + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent_session_provider_handle_stale_fence' + ) + expect(await host.attach(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(await host.attach(CALLER, ensureParams(releasedFence))).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) + }) + + it('reaps an acquisition when process identity commit fails', async () => { + vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed')) + + await expect(host.attach(CALLER, attachParams())).rejects.toThrow('commit failed') + + expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) + }) + + it('drains writes captured by the old journal before acquiring its replacement', async () => { + const record = await attach() + const events = acquire.mock.calls[0]?.[0].events + const oldJournal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const appendGate = Promise.withResolvers() + const originalAppend = oldJournal.appendItem.bind(oldJournal) + const append = vi.spyOn(oldJournal, 'appendItem').mockImplementationOnce(async (...args) => { + await appendGate.promise + return originalAppend(...args) + }) + events?.appendItem( + { provider: 'orca', clientMessageId: 'old-journal-write' }, + { kind: 'status', text: 'old journal write' } + ) + await vi.waitFor(() => expect(append).toHaveBeenCalledOnce()) + const released = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: record?.lease.runtimeFence ?? 1, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + const replacement = host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + await new Promise((resolve) => setImmediate(resolve)) + expect(acquire).toHaveBeenCalledTimes(1) + + appendGate.resolve() + await expect(replacement).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + }) +}) + +describe('send', () => { + it('writes the submission before dispatching and resolves it accepted', async () => { + await attach() + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!result.ok) { + throw new Error(`expected a send, got ${result.refusal.code}`) + } + expect(result.value.submission.dispatchState).toBe('accepted') + expect(dispatch).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(1) + expect(page.ok && page.page.fence).toBe(1) + expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) + }) + + it('settles a thrown dispatch as unknown, never as a rejection', async () => { + await attach() + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + }) + + it('replays a retried send from the journal without dispatching twice', async () => { + await attach() + const body = hostTestMessage('add a retry') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const retry = await host.send(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('redispatches an explicitly retried durable unknown without appending a second submission', async () => { + await attach() + dispatch + .mockRejectedValueOnce(new Error('socket closed')) + .mockImplementationOnce(async () => accepted()) + const body = hostTestMessage('possibly delivered') + const params = { envelope: envelope('agentSession.send', { body }), body } + + const first = await host.send(CALLER, params) + expect(first).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'unknown' } } + }) + const retried = await host.send(CALLER, { ...params, retryUnknown: true }) + + expect(retried).toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(2) + const state = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(state.ok && state.page.submissions).toHaveLength(1) + }) + + it('advances an explicit retry after a ledger-unknown send is reconciled in the journal', async () => { + await attach() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + vi.spyOn(journal, 'resolveDispatch').mockRejectedValueOnce(new Error('journal resolve failed')) + const body = hostTestMessage('possibly delivered before persistence failed') + const params = { envelope: envelope('agentSession.send', { body }), body } + + await expect(host.send(CALLER, params)).rejects.toThrow('journal resolve failed') + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ?.outcome + ).toEqual({ status: 'unknown' }) + expect(dispatch).toHaveBeenCalledTimes(1) + + await journal.markPendingSubmissionsUnknown(store.getRecord(SESSION)?.lease.runtimeFence ?? 1) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + + await expect(host.send(CALLER, { ...params, retryUnknown: true })).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(journal.submissions()).toHaveLength(1) + }) + + it('refuses a stale fence and hands back the current one', async () => { + const record = await attach() + const body = hostTestMessage('add a retry') + const result = await host.send(CALLER, { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } + }) + }) + + it('does not let a refused call leave a ledger row that replays past the fence', async () => { + const record = await attach() + const body = hostTestMessage('add a retry') + const params = { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + } + await host.send(CALLER, params) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('refuses any mutation against a session this host has not attached', async () => { + const body = hostTestMessage('add a retry') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + }) +}) + +describe('cancel', () => { + it('records the outcome as a status item keyed by the operation id', async () => { + await attach() + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items[0]?.body).toMatchObject({ + kind: 'status', + text: 'Turn cancelled.' + }) + expect(JSON.stringify(page.ok && page.page.items[0]?.body)).not.toContain('turn-1') + }) + + it('reports an unconfirmed cancellation rather than failing the call', async () => { + await attach() + cancelTurn.mockRejectedValueOnce(new Error('no answer')) + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + }) + + it('never interrupts twice on a replay', async () => { + await attach() + const params = { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + } + await host.cancel(CALLER, params) + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) +}) + +describe('respondToPrompt', () => { + it('commits the answer before the provider callback', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: 'allow' } } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses a second answer to one prompt and says which answer won', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + const loser = await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + } + ) + expect(loser).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + resolution: { selectedOptionId: 'allow' } + } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses an option the prompt does not offer', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } + expect( + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) + expect(answerPrompt).not.toHaveBeenCalled() + }) + + it("does not turn a recorded refusal into another client's successful answer", async () => { + const prompt = await seedApproval() + await attach() + const rejectedFields = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'deny' + } + const rejected = { + envelope: envelope('agentSession.respondTo:approval', rejectedFields), + kind: 'approval' as const, + ...rejectedFields + } + await host.respondToPrompt(CALLER, rejected) + + const acceptedFields = { ...rejectedFields, optionId: 'allow' } + await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', acceptedFields), + kind: 'approval', + ...acceptedFields + } + ) + + expect(await host.respondToPrompt(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + }) + + it('keeps the answer and reports it undelivered when the provider callback throws', async () => { + const prompt = await seedApproval() + await attach() + answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result.ok).toBe(true) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const statusId = agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${prompt.itemId}#delivery` + }) + expect(page.ok && page.page.items.some((entry) => entry.itemId === statusId)).toBe(true) + }) +}) + +describe('setOption', () => { + it('goes to the provider and writes nothing to the journal', async () => { + await attach() + setOption.mockResolvedValueOnce({ model: 'gpt-5', effort: 'high' }) + const fields = { key: 'model', value: 'gpt-5' } + const params = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + const result = await host.setOption(CALLER, params) + expect(result).toMatchObject({ + ok: true, + value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + }) + expect(await host.setOption(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { ...fields, options: { model: 'gpt-5', effort: 'high' } } + }) + expect(setOption).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.options).toEqual({ model: 'gpt-5', effort: 'high' }) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(0) + }) + + it('does not turn an unknown provider outcome into a successful replay', async () => { + await attach() + setOption.mockRejectedValueOnce(new Error('reply lost')) + const fields = { key: 'model', value: 'gpt-5' } + const params = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(setOption).toHaveBeenCalledTimes(1) + }) +}) + +describe('restart', () => { + /** A restarted process: the same directories, a new store and a new host over + * them. Every lease loads unreconciled, so this is the state that decides + * whether a persisted session is reachable at all. */ + async function reboot( + probeOwner: (record: AgentSessionRecord) => Promise + ) { + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-b', + probeOwner, + now: () => NOW + }) + } + + /** The refusal a restarted host owes a client holding the dead generation's + * fence: stale, with the live fence attached so the retry can succeed. */ + async function staleFenceFrom(held: number): Promise { + const refused = await host.attach(CALLER, ensureParams(held)) + if (refused.ok) { + throw new Error('a fence from the previous host generation was accepted') + } + expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') + const current = refused.refusal.currentFence + expect(current).toBeGreaterThan(held) + return current ?? 0 + } + + it('adjudicates the leases it loaded before deciding who may write', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'pid-absent' })) + + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) + }) + + it('restores durable journals for read-only history without acquiring a provider', async () => { + await attach() + const body = hostTestMessage('persisted conversation') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + await reboot(async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' })) + acquire.mockClear() + const listRecords = vi.spyOn(store, 'listRecords') + + await host.restoreReadableSessions() + const restoreReads = listRecords.mock.calls.length + await host.restoreReadableSessions() + + expect(host.listSessionTabs()).toEqual([ + { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } + ]) + const history = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(history.ok && history.page.items).not.toHaveLength(0) + expect(acquire).not.toHaveBeenCalled() + expect(listRecords).toHaveBeenCalledTimes(restoreReads) + }) + + it('clears stale TUI recovery at restart, and reacquires the native owner when a surface holds it', async () => { + await attach() + await store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { + ...record.lease, + runtimeKind: 'tui', + handoffStage: 'manual-recovery' + } + })) + await reboot(async () => ({ outcome: 'pid-absent' })) + acquire.mockClear() + + await host.restoreReadableSessions() + // The recovery stage clears on evidence at startup; the child comes back only once a surface + // holds the session (see structured-agent-session-surface-lifetime.test.ts). + await host.hold(SESSION, 'surface-1') + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + handoffOperationId: null + }) + await expect(host.handoffStatus(SESSION)).resolves.toMatchObject({ + owner: 'native', + phase: 'idle', + stage: null + }) + }) + + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { + await attach() + const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) + + expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + }) + + it('does not remember a failed adjudication as done', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + const probe = vi + .fn<(record: AgentSessionRecord) => Promise>() + .mockRejectedValueOnce(new Error('probe exploded')) + .mockResolvedValue({ outcome: 'pid-absent' }) + await reboot(probe) + + await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(probe).toHaveBeenCalledTimes(2) + }) +}) + +describe('subscribe', () => { + it('opens with a snapshot and then streams cursor-qualified batches', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + const dispose = host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = hostTestMessage('add a retry') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + expect(events[0]?.type).toBe('snapshot') + const batches = events.filter((event) => event.type === 'batch') + expect(batches.length).toBeGreaterThan(0) + const last = batches.at(-1) + expect(last?.type === 'batch' && last.batch.cursor.sequence).toBeGreaterThan(0) + + dispose() + expect(events.at(-1)?.type).toBe('end') + }) + + it('resumes from a client cursor with only the rows it missed', async () => { + await attach() + const body = hostTestMessage('add a retry') + const first = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!first.ok) { + throw new Error(`expected a send, got ${first.refusal.code}`) + } + + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-2', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: first.cursor + }) + expect(events[0]).toMatchObject({ type: 'batch', handoff: { owner: 'native', phase: 'idle' } }) + + const second = hostTestMessage('and a timeout') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: second }), + body: second + }) + expect(events.some((event) => event.type === 'batch')).toBe(true) + expect(events.some((event) => event.type === 'snapshot')).toBe(false) + }) + + it('drops a failed transport without aborting the mutation or other subscribers', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'dead-sub', + sessionId: SESSION, + emit: () => { + throw new Error('socket closed') + } + }) + host.subscribe({ + id: 'live-sub', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = hostTestMessage('survive subscriber failure') + + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(events.some((event) => event.type === 'batch')).toBe(true) + }) + + it('resets a subscriber whose epoch is gone', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-3', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } + }) + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) + }) + + it('publishes the replacement fence when the owner generation changes', async () => { + const record = await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-4', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const released = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: record?.lease.runtimeFence ?? 1, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + if (!replacement.ok) { + throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) + } + expect(events.at(-1)).toMatchObject({ type: 'snapshot', fence: replacement.fence }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts new file mode 100644 index 00000000000..47276ea1ba0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -0,0 +1,299 @@ +// Structured agent-session host: where the lease, journal, and provider adapter meet. +// Mutations share one durable admission path and serialize per session. + +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { + AgentSessionAttachResult, + AgentSessionHistoryRequest, + AgentSessionHistoryResult, + AgentSessionHandoffStatus, + AgentSessionMutationResult, + AgentSessionOptionsResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import { + AgentSessionSubscribers, + type AgentSessionSubscribeInput +} from './structured-agent-session-subscribers' +import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import * as providerSupport from './structured-agent-session-provider-support' +import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import { + createStructuredAgentSessionHostHandoff, + refreshRecoverableStructuredHandoffStatus, + type StructuredAgentSessionHostHandoff +} from './structured-agent-session-host-handoff' +import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' +import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { + createStructuredAgentSessionHolds, + evictHeldStructuredAgentSession, + type StructuredAgentSessionLifetimeContext +} from './structured-agent-session-host-lifetime' +import type { + StructuredAgentSessionHolds, + StructuredAgentSessionHoldOptions +} from './structured-agent-session-holds' +import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs' +import { + cancelStructuredAgentSessionTurn, + readStructuredAgentSessionOptions, + respondToStructuredAgentSessionPrompt, + sendStructuredAgentSessionTurn, + setStructuredAgentSessionOption, + type StructuredAgentSessionMutationContext +} from './structured-agent-session-host-mutations' +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHostDeps, + StructuredAgentSessionHostSession +} from './structured-agent-session-host-types' +import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' +export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' + +export class StructuredAgentSessionHost { + private readonly sessions = new Map() + private readonly subscribers = new AgentSessionSubscribers() + private readonly tasks = new StructuredAgentSessionTaskQueue() + private readonly runtimeState: StructuredAgentSessionHostRuntimeState + private readonly reconcileLeases: (sessionId: string) => Promise + private readonly handoffs: StructuredAgentSessionHostHandoff + private readonly readableRestorer: StructuredAgentSessionReadableRestorer + private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate() + private readonly holds: StructuredAgentSessionHolds + + constructor(readonly deps: StructuredAgentSessionHostDeps) { + this.runtimeState = new StructuredAgentSessionHostRuntimeState( + deps, + (record) => this.restoreRenewedHandoff(record.sessionId), + (record, probe) => + this.sessions.has(record.sessionId) + ? this.serialize(record.sessionId, () => + this.handoffs.recoverDeadTuiOwner(record.sessionId, record.lease.runtimeFence, probe) + ) + : Promise.resolve() + ) + this.reconcileLeases = createRestartReconciler({ + store: deps.store, + probe: (record) => this.runtimeState.probeRecord(record), + ...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}), + now: () => this.now() + }) + this.handoffs = createStructuredAgentSessionHostHandoff(deps, { + session: (sessionId) => this.requireSession(sessionId), + eventSink: (sessionId) => this.runtimeState.eventSinkFor(sessionId), + flush: (sessionId) => this.flushStreamedEvents(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + subscribers: this.subscribers, + now: this.now + }) + this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), { + resume: (sessionId) => this.resumeForHold(sessionId), + evict: (sessionId) => this.close(sessionId) + }) + this.readableRestorer = new StructuredAgentSessionReadableRestorer({ + store: deps.store, + journalRoot: deps.journalRoot, + supportsRecord: (record) => providerSupport.adapterSupportsRecord(deps.adapter, record), + reconcile: this.reconcileLeases, + resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + hasSession: (sessionId) => this.sessions.has(sessionId), + onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored), + restoreHandoff: (sessionId) => this.handoffs.restore(sessionId) + }) + this.runtimeState.startLeaseRenewal() + } + + private now = (): number => this.deps.now?.() ?? Date.now() + + hasSession = (sessionId: string): boolean => this.sessions.has(sessionId) + + /** A surface bound to this session and wants it live. The FIRST hold on a session with no + * provider child is what resumes one; a retained hold (a subscription) only keeps it. */ + hold = ( + sessionId: string, + holderId: string, + options?: StructuredAgentSessionHoldOptions + ): Promise => this.holds.hold(sessionId, holderId, options) + + /** That surface is gone. The child outlives it by the release grace, and by any running turn. */ + release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId) + + isHeld = (sessionId: string): boolean => this.holds.isHeld(sessionId) + + private async resumeForHold(sessionId: string): Promise { + const unreconciled = await this.reconcileLeases(sessionId) + if (unreconciled) { + throw new Error(unreconciled.code) + } + await this.runtimeState.resolveRecovery(sessionId) + await resumeHeldStructuredAgentSession({ + sessionId, + deps: this.deps, + now: () => this.now(), + attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params) + }) + } + + private lifetimeContext(): StructuredAgentSessionLifetimeContext { + return { + deps: this.deps, + runtimeState: this.runtimeState, + sessions: this.sessions, + now: () => this.now() + } + } + + /** The host's half of attaching, named so it cannot grow dependencies unnoticed. */ + private attachContext(): StructuredAgentSessionAttachContext { + return { + deps: this.deps, + runtimeState: this.runtimeState, + sessions: this.sessions, + subscribers: this.subscribers, + tasks: this.tasks, + reconcileLeases: (sessionId) => this.reconcileLeases(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + now: () => this.now() + } + } + + /** Releases a session's resources without ending the conversation: the record and journal stay + * on disk, so the same session can be attached again. */ + close(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + await this.handoffs.closeRetainedTuiOwner(sessionId) + await evictHeldStructuredAgentSession(this.lifetimeContext(), sessionId) + // Whoever asked for the close, the surfaces that were holding this session are looking at a + // session that no longer exists. A failed eviction throws above and keeps them. + this.holds.forget(sessionId) + }) + } + + supportsCreate = (location: AgentSessionExecutionLocation, agent: string): boolean => + providerSupport.adapterSupportsCreate(this.deps.adapter, location, agent) + + listSessionTabs() { + return listStructuredAgentSessionTabs(this.sessions) + } + + reconcileRestartLeases = async (): Promise => { + const refusal = await this.reconcileLeases('startup') + if (refusal) { + throw new Error(refusal.code) + } + } + + restoreReadableSessions = (sessionIds?: readonly string[]): Promise => + this.restartRestore.run(() => this.readableRestorer.restore(sessionIds)) + + private serialize = (sessionId: string, task: () => Promise): Promise => + this.tasks.serialize(sessionId, task) + + private restoreRenewedHandoff(sessionId: string): Promise { + return this.serialize(sessionId, async () => { + if (this.sessions.has(sessionId)) { + await refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + } + }) + } + + attach( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams + ): Promise> { + return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) + } + + flushStreamedEvents = (sessionId: string): Promise => + this.runtimeState.flushEventSink(sessionId) + + async flushAllStreamedEvents(): Promise { + this.holds.dispose() + this.runtimeState.stopLeaseRenewal() + this.handoffs.stopTuiHistoryCatchup() + await this.tasks.drainAttaches() + await this.runtimeState.flushAllEventSinks() + } + + private mutationContext(): StructuredAgentSessionMutationContext { + return { + deps: this.deps, + sessions: this.sessions, + publish: (sessionId, journal) => this.subscribers.publish(sessionId, journal), + requireSession: (sessionId) => this.requireSession(sessionId), + serialize: (sessionId, task) => this.serialize(sessionId, task), + now: () => this.now() + } + } + + send = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + sendStructuredAgentSessionTurn(this.mutationContext(), caller, params) + + cancel = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + cancelStructuredAgentSessionTurn(this.mutationContext(), caller, params) + + respondToPrompt = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + respondToStructuredAgentSessionPrompt(this.mutationContext(), caller, params) + + setOption = ( + caller: StructuredAgentSessionCaller, + params: Parameters[2] + ): ReturnType => + setStructuredAgentSessionOption(this.mutationContext(), caller, params) + + readOptions = (sessionId: string): Promise => + readStructuredAgentSessionOptions(this.mutationContext(), sessionId) + + async handoffStatus(sessionId: string): Promise { + this.requireSession(sessionId) + return this.serialize(sessionId, () => + refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId) + ) + } + + history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + return readStructuredAgentSessionHistoryResult({ + journal: this.requireSession(request.sessionId).journal, + record: this.deps.store.getRecord(request.sessionId), + request + }) + } + + subscribe(input: AgentSessionSubscribeInput): () => void { + const session = this.requireSession(input.sessionId) + const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0 + return this.subscribers.open({ + ...input, + journal: session.journal, + fence, + handoff: this.handoffs.status(input.sessionId) + }) + } + + unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id) + + private requireSession(sessionId: string): StructuredAgentSessionHostSession { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(AGENT_SESSION_NOT_ATTACHED.code) + } + return session + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts new file mode 100644 index 00000000000..c3c89fbf09d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { + pinnedAgentSessionLaunchArgs, + pinnedAgentSessionLaunchEnv +} from './structured-agent-session-launch-env' + +describe('pinnedAgentSessionLaunchEnv', () => { + it('layers the pinned account home over the shell environment', async () => { + await expect( + pinnedAgentSessionLaunchEnv( + async () => ({ EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' }), + hostTestAttachParams(null) + ) + ).resolves.toEqual({ + launchEnv: { + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + } + }) + }) + + it('copies the host-resolved provider arguments into reservation authority', async () => { + await expect( + pinnedAgentSessionLaunchArgs(async () => ['--profile', 'review'], hostTestAttachParams(null)) + ).resolves.toEqual({ launchArgs: ['--profile', 'review'] }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts new file mode 100644 index 00000000000..5ce67d7f4df --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-launch-env.ts @@ -0,0 +1,33 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' + +type LaunchEnvResolver = ( + provider: AgentSessionRecord['provider'] +) => Promise | undefined> | Record | undefined + +type LaunchArgsResolver = ( + provider: AgentSessionRecord['provider'] +) => Promise | string[] | undefined + +export async function pinnedAgentSessionLaunchEnv( + resolver: LaunchEnvResolver | undefined, + params: AgentSessionAttachParams +): Promise<{ launchEnv: Record } | Record> { + if (!resolver) { + return {} + } + return { + launchEnv: { + ...(await resolver(params.provider)), + [params.accountHome.variable]: params.accountHome.path + } + } +} + +export async function pinnedAgentSessionLaunchArgs( + resolver: LaunchArgsResolver | undefined, + params: AgentSessionAttachParams +): Promise<{ launchArgs: string[] } | Record> { + const launchArgs = await resolver?.(params.provider) + return launchArgs ? { launchArgs: [...launchArgs] } : {} +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts new file mode 100644 index 00000000000..63d29754f61 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-release.ts @@ -0,0 +1,32 @@ +// Handing the durable lease back after eviction stopped this host's child. +// +// Guarded on `hasProviderChild` for a reason that is not bookkeeping: a session restored only for +// reading, or one a TUI owns, names an owner process this host never started and may still be +// alive. Writing `exit-observed` against that record would release a lease out from under a running +// process and let a second writer in. + +import { + isSurfaceReleasableAgentSessionRecord, + releaseStoredAgentSessionOwnerAfterSurfaceClose +} from '../../runtime/agent-session-surface-release-transition' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +export async function releaseStoredStructuredAgentSessionOwner(input: { + store: AgentSessionRecordStore + sessionId: string + hasProviderChild: boolean + now: number +}): Promise { + if (!input.hasProviderChild) { + return + } + const record = input.store.getRecord(input.sessionId) + if (!record || !isSurfaceReleasableAgentSessionRecord(record)) { + return + } + await releaseStoredAgentSessionOwnerAfterSurfaceClose(input.store, { + sessionId: input.sessionId, + expectedFence: record.lease.runtimeFence, + now: input.now + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts new file mode 100644 index 00000000000..e5b97dea189 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -0,0 +1,292 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' + +const NOW = 1_800_000_000_000 +const roots: string[] = [] + +async function liveStore(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: 'session-renewal', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: root }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-renewal', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + await store.commitProcessIdentity({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-renewal' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: 'session-renewal', + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'link-renewal', + handle: { provider: 'codex', threadId: 'thread-renewal' }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + return store +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured agent-session lease renewal', () => { + it('isolates renewal failures per live record', async () => { + const records = ['a', 'b'].map((suffix, index) => { + const sessionId = `session-${suffix}` + return agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeKind: 'native', + runtimeFence: index + 1, + ownerProcess: { + hostId: 'local', + pid: 4200 + index, + processStartTimeMs: NOW - 1_000, + spawnToken: `spawn-${suffix}` + }, + reservedSpawnToken: `spawn-${suffix}`, + lastRenewedAt: NOW, + leaseDeadlineAt: NOW + 30_000 + }) + ) + }) + const renewLeases = vi.fn(async (renewals: readonly { sessionId: string }[]) => + renewals.map((renewal) => records.find((record) => record.sessionId === renewal.sessionId)!) + ) + const probeMany = vi.fn( + async () => + new Map( + records.map((record) => [ + record.sessionId, + { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const + ]) + ) + ) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store: { listRecords: () => records, renewLeases } as unknown as AgentSessionRecordStore, + probe: vi.fn(), + probeMany, + now: () => NOW + 10_000 + }) + + await renewer.renewNow() + + expect(probeMany).toHaveBeenCalledOnce() + expect(renewLeases).toHaveBeenCalledOnce() + expect(renewLeases.mock.calls[0]?.[0]).toHaveLength(2) + }) + + it('keeps a healthy lease alive when a sibling renewal is superseded', async () => { + const records = ['a', 'b'].map((suffix, index) => + agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: `session-${suffix}`, + runtimeKind: 'native', + runtimeFence: index + 1, + ownerProcess: { + hostId: 'local', + pid: 4200 + index, + processStartTimeMs: NOW - 1_000, + spawnToken: `spawn-${suffix}` + }, + reservedSpawnToken: `spawn-${suffix}`, + lastRenewedAt: NOW, + leaseDeadlineAt: NOW + 30_000 + }) + ) + ) + const renewLeases = vi.fn(async () => { + throw new Error('agent_session_checkpoint_stale') + }) + const renewLease = vi.fn(async (renewal: { sessionId: string }) => { + if (renewal.sessionId === 'session-b') { + throw new Error('agent_session_checkpoint_stale') + } + return records[0]! + }) + const onRenewed = vi.fn() + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store: { + listRecords: () => records, + renewLeases, + renewLease + } as unknown as AgentSessionRecordStore, + probe: async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['spawn-token' as const] + }), + now: () => NOW + 10_000, + onRenewed, + onError + }) + + await renewer.renewNow() + + expect(renewLeases).toHaveBeenCalledOnce() + expect(onRenewed).toHaveBeenCalledOnce() + expect(renewLease).toHaveBeenCalledTimes(2) + expect(onRenewed).toHaveBeenCalledWith(records[0]) + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-b', + error: expect.objectContaining({ message: 'agent_session_checkpoint_stale' }) + }) + }) + + it('drives renewal on the production interval', async () => { + vi.useFakeTimers() + const store = await liveStore() + let now = NOW + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ + outcome: 'identity-matched', + matchedOn: ['process-start-time'] + }), + now: () => now + }) + try { + renewer.start() + now += 10_000 + await vi.advanceTimersByTimeAsync(10_000) + await vi.waitFor(() => + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(now) + ) + } finally { + renewer.stop() + vi.useRealTimers() + } + }) + + it('renews every live owner only after re-proving its child identity', async () => { + const store = await liveStore() + const probe = vi.fn(async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + })) + const onRenewed = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + now: () => NOW + 10_000, + onRenewed + }) + + await renewer.renewNow() + + expect(probe).toHaveBeenCalledOnce() + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW + 10_000) + expect(onRenewed).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-renewal' }) + ) + }) + + it('stops extending the lease when child proof is no longer sufficient', async () => { + const store = await liveStore() + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ outcome: 'indeterminate', reason: 'probe unavailable' }), + now: () => NOW + 10_000, + onError + }) + + await renewer.renewNow() + + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + expect(onError).toHaveBeenCalledWith({ + sessionId: 'session-renewal', + error: expect.any(Error) + }) + }) + + it('never extends the lease of a native record parked in recovery', async () => { + // The host cannot vouch for a native child it holds no transport to; renewing while + // recovering keeps an orphan pid's lease alive and reads as a healthy owner. + const store = await liveStore() + await store.transitionHandoff('session-renewal', (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: 'recovering' } + })) + const probe = vi.fn(async () => ({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + })) + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe, + now: () => NOW + 10_000 + }) + + await renewer.renewNow() + + expect(probe).not.toHaveBeenCalled() + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + }) + + it('routes a proven dead TUI owner into handoff recovery', async () => { + const store = await liveStore() + await store.transitionHandoff('session-renewal', (record) => ({ + ...record, + lease: { ...record.lease, runtimeKind: 'tui' } + })) + const onDeadTuiOwner = vi.fn(async () => undefined) + const onError = vi.fn() + const renewer = new StructuredAgentSessionLeaseRenewer({ + store, + probe: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + 10_000, + onDeadTuiOwner, + onError + }) + + await renewer.renewNow() + + expect(onDeadTuiOwner).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'session-renewal' }), + { outcome: 'pid-absent' } + ) + expect(store.getRecord('session-renewal')?.lease.lastRenewedAt).toBe(NOW) + expect(onError).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts new file mode 100644 index 00000000000..e31ea8c95a7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -0,0 +1,156 @@ +import { + isProvenDeadProbe, + type AgentSessionOwnerProbe +} from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from '../../runtime/agent-session-record-store' + +const RENEW_INTERVAL_MS = Math.floor(AGENT_SESSION_LEASE_TTL_MS / 3) + +export class StructuredAgentSessionLeaseRenewer { + private timer: ReturnType | null = null + private running = false + + constructor( + private readonly input: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: () => number + onRenewed?: (record: AgentSessionRecord) => Promise + onDeadTuiOwner?: (record: AgentSessionRecord, probe: AgentSessionOwnerProbe) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + intervalMs?: number + } + ) {} + + start(): void { + if (this.timer) { + return + } + this.timer = setInterval(() => void this.renewNow(), this.input.intervalMs ?? RENEW_INTERVAL_MS) + this.timer.unref?.() + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + async renewNow(): Promise { + if (this.running) { + return + } + this.running = true + try { + const records = this.input.store.listRecords().filter( + (record) => + !record.lease.unreconciled && + record.lease.claimStatus === 'live' && + record.lease.ownerProcess !== null && + // A native record parked in recovery has no transport the host can vouch + // for; renewing it keeps an orphan pid's lease reading as a healthy owner. + !( + record.lease.runtimeKind === 'native' && + (record.lease.handoffStage === 'recovering' || + record.lease.handoffStage === 'manual-recovery') + ) + ) + const probes = await this.probe(records) + const renewals: { + sessionId: string + fence: number + childProbe: AgentSessionOwnerProbe + now: number + }[] = [] + const now = this.input.now() + for (const record of records) { + const probe = probes.get(record.sessionId) + if (!probe) { + continue + } + if ( + record.lease.runtimeKind === 'tui' && + isProvenDeadProbe(probe) && + this.input.onDeadTuiOwner + ) { + try { + await this.input.onDeadTuiOwner(record, probe) + } catch (error) { + this.input.onError?.({ sessionId: record.sessionId, error }) + } + continue + } + renewals.push({ + sessionId: record.sessionId, + fence: record.lease.runtimeFence, + childProbe: probe, + now + }) + } + // The store persists the whole record file per transaction, so keep the healthy path to + // one commit. If one renewal is superseded, retrying individually preserves isolation. + let results: PromiseSettledResult[] + try { + const renewed = await this.input.store.renewLeases(renewals) + await Promise.all( + renewed.map(async (record) => { + await this.input.onRenewed?.(record) + }) + ) + results = renewed.map((record) => ({ status: 'fulfilled', value: record }) as const) + } catch { + results = await Promise.allSettled( + renewals.map(async (renewal) => { + const renewed = await this.input.store.renewLease(renewal) + await this.input.onRenewed?.(renewed) + return renewed + }) + ) + } + results.forEach((result, index) => { + if (result.status === 'rejected') { + const renewal = renewals[index] + if (renewal) { + this.input.onError?.({ sessionId: renewal.sessionId, error: result.reason }) + } + } + }) + } finally { + this.running = false + } + } + + private async probe( + records: readonly AgentSessionRecord[] + ): Promise> { + try { + if (this.input.probeMany) { + return await this.input.probeMany(records) + } + const settled = await Promise.allSettled(records.map((record) => this.input.probe(record))) + const probes = new Map() + for (const [index, result] of settled.entries()) { + const record = records[index] + if (result.status === 'fulfilled') { + probes.set(record.sessionId, result.value) + } else { + this.input.onError?.({ sessionId: record.sessionId, error: result.reason }) + } + } + return probes + } catch (error) { + for (const record of records) { + this.input.onError?.({ sessionId: record.sessionId, error }) + } + return new Map() + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts new file mode 100644 index 00000000000..90b68194c4f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-live-tui-restart-survival.test.ts @@ -0,0 +1,246 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { setStoredAgentSessionHandoffStage } from '../../runtime/agent-session-handoff-record-transitions' +import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-live-tui-restart' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('structured session live TUI restart survival', () => { + it('does not stop a daemon-owned toggle TUI when restart adoption is unavailable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-live-tui-restart-')) + roots.push(root) + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'toggle-tui-spawn', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000000`, + fingerprint: 'create' + }, + now: NOW + }) + const process = { + hostId: 'local', + pid: 4200, + processStartTimeMs: NOW - 1_000, + spawnToken: 'toggle-tui-spawn' + } + await store.commitProcessIdentity({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + process, + now: NOW + }) + const record = await store.proveOwner({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'toggle-tui-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + await setStoredAgentSessionHandoffStage(store, { + sessionId: SESSION, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + const stopRecoveredOwner = vi.fn(async () => undefined) + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: vi.fn(), + recoverTuiOwner: vi.fn(async () => ({ + terminal: { + handle: 'term-toggle', + tabId: 'tab-toggle', + paneKey: 'tab-toggle:leaf-toggle', + ptyId: 'pty-toggle' + }, + process, + link: record.providerHandleChain.at(-1)! + })), + reproveTuiOwner: vi.fn(async () => { + throw new Error('The owning terminal is not hydrated yet.') + }), + probeRecoveredOwner: async () => 'live', + stopRecoveredOwner, + waitForTuiExit: vi.fn(), + waitForTuiIdleOrExit: vi.fn(), + tuiStatus: () => 'busy' + }, + session: vi.fn() as never, + suspendNative: vi.fn(), + acquireNative: vi.fn(), + importTuiHistory: vi.fn(), + publish: vi.fn(), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) + + await coordinator.restore(SESSION) + + expect(stopRecoveredOwner).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: 'manual-recovery', + ownerProcess: process + }) + }) + + it('persists the provider leaf returned by Claude re-proof before clearing recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-claude-tui-restart-')) + roots.push(root) + const sessionId = 'session-claude-live-tui-restart' + const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'claude-tui-spawn', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'create' + }, + now: NOW + }) + const process = { + hostId: 'local', + pid: 4201, + processStartTimeMs: NOW - 1_000, + spawnToken: 'claude-tui-spawn' + } + await store.commitProcessIdentity({ + sessionId, + fence: reserved.record.lease.runtimeFence, + process, + now: NOW + }) + const record = await store.proveOwner({ + sessionId, + fence: reserved.record.lease.runtimeFence, + link: { + linkId: 'claude-created-link', + handle: { provider: 'claude', sessionId: 'claude-session', leafUuid: 'leaf-before' }, + origin: 'created', + mintedAtFence: reserved.record.lease.runtimeFence, + observedAt: NOW + }, + now: NOW + }) + await setStoredAgentSessionHandoffStage(store, { + sessionId, + fence: record.lease.runtimeFence, + stage: 'manual-recovery', + handoffOperationId: null, + now: NOW + }) + + const reproofed = { + terminal: { + handle: 'term-claude', + tabId: 'tab-claude', + paneKey: 'tab-claude:leaf-claude', + ptyId: 'pty-claude' + }, + process, + link: { + linkId: 'claude-resumed-link', + handle: { + provider: 'claude' as const, + sessionId: 'claude-session', + leafUuid: 'leaf-after' + }, + origin: 'resumed' as const, + mintedAtFence: record.lease.runtimeFence, + observedAt: NOW + 1 + }, + transcriptPath: join(root, 'claude-home', 'projects', 'session.jsonl') + } + const persistTuiProviderHandle = vi.fn(async () => undefined) + const coordinator = new StructuredAgentSessionHandoffCoordinator({ + store, + claimKeyId: 'key-1', + transport: { + hostLabel: 'Test host', + launchTui: vi.fn(), + recoverTuiOwner: vi.fn(async () => ({ + ...reproofed, + link: record.providerHandleChain.at(-1)! + })), + reproveTuiOwner: vi.fn(async () => reproofed), + probeRecoveredOwner: async () => 'live', + stopRecoveredOwner: vi.fn(), + waitForTuiExit: vi.fn(), + waitForTuiIdleOrExit: vi.fn(), + tuiStatus: () => 'idle' + }, + persistTuiProviderHandle, + session: vi.fn() as never, + suspendNative: vi.fn(), + acquireNative: vi.fn(), + importTuiHistory: vi.fn(), + publish: vi.fn(), + schedule: async (_sessionId, task) => task(), + now: () => NOW + }) + + await coordinator.restore(sessionId) + + expect(persistTuiProviderHandle).toHaveBeenCalledWith({ + sessionId, + link: reproofed.link, + now: NOW + }) + expect(coordinator.status(sessionId)).toMatchObject({ owner: 'tui', phase: 'idle' }) + expect(store.getRecord(sessionId)?.lease).toMatchObject({ + runtimeKind: 'tui', + claimStatus: 'live', + handoffStage: null + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts new file mode 100644 index 00000000000..18298f28892 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -0,0 +1,148 @@ +// The one route every mutating agent-session call takes: recompute the +// fingerprint, admit through the durable operation ledger, check the lease, then +// run the plan. It lives outside the host so that no method can quietly grow its +// own admission rules by sitting next to the call site. + +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' + +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = { + code: 'agent_session_ownership_unknown', + message: 'This host holds no attached session by that id.' +} + +export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { ok: false, refusal } +} + +export type AgentSessionMutationRequest = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + callerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + /** Journal of the attached session; absent when this host holds none. */ + journal: AgentSessionJournal | undefined + publish: (journal: AgentSessionJournal) => void + now: () => number +} + +export async function admitAndRunAgentSessionMutation( + request: AgentSessionMutationRequest +): Promise> { + const { envelope, plan, journal } = request + const record = request.store.getRecord(envelope.sessionId) + if (!journal || !record) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const conflict = agentSessionFingerprintConflict(envelope, hostFingerprint) + if (conflict) { + return refuseAgentSessionMutation(conflict) + } + const admission = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: await request.store.admitOperation({ + callerKey: request.callerKey, + operationId: envelope.clientOperationId, + fingerprint: hostFingerprint, + now: request.now() + }), + lease: record.lease + }) + if (admission.decision === 'refused') { + return refuseAgentSessionMutation(admission.refusal) + } + + const fence = record.lease.runtimeFence + const context = turnContext(request, journal, fence) + if (admission.decision === 'replay') { + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: admission.row.outcome, + reconstruct: () => plan.replay(context, admission.row.outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context) + }) + if (replay.decision === 'refuse') { + return refuseAgentSessionMutation(replay.refusal) + } + if (replay.decision === 'replay') { + return { ok: true, replayed: true, fence, cursor: journal.cursor(), value: replay.value } + } + // Nothing durable landed, so this id is about to run for the first time. A + // refused call leaves its ledger row behind, and replaying past the lease and + // the fence would let a resend act under an owner that has since changed — so + // a first run pays the full admission price either way. + const rerun = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: { decision: 'admit', row: admission.row }, + lease: record.lease + }) + if (rerun.decision === 'refused') { + return refuseAgentSessionMutation(rerun.refusal) + } + } + + plan.beforeRun?.() + const outcome = await runSettledAgentSessionMutation({ + store: request.store, + callerKey: request.callerKey, + envelope, + plan, + context + }) + return outcome.ok + ? { ok: true, replayed: false, fence, cursor: journal.cursor(), value: outcome.value } + : refuseAgentSessionMutation(outcome.refusal) +} + +function turnContext( + request: AgentSessionMutationRequest, + journal: AgentSessionJournal, + fence: number +): AgentSessionTurnContext { + const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options + return { + sessionId: request.envelope.sessionId, + journal, + fence, + adapter: request.adapter, + ...(persistedOptions ? { persistedOptions } : {}), + persistOptions: (options) => + request.store + .replaceSessionOptions({ + sessionId: request.envelope.sessionId, + fence, + options, + now: request.now() + }) + .then(() => undefined), + resolvedBy: request.callerKey, + publish: () => request.publish(journal), + now: () => request.now() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts new file mode 100644 index 00000000000..99835da7cea --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -0,0 +1,139 @@ +// One plan per mutating method: what it fingerprints, what it does, and how its +// answer is rebuilt on a replay. +// +// The replay half matters more than it looks. The ledger records only that an +// operation happened, so the durable answer has to come back out of the journal. +// A plan that cannot find its effect returns null, and the call runs for real — +// which is exactly right when the crash landed before the journal write. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import { + performCancel, + performPrompt, + performSend, + performSetOption, + type AgentSessionTurnContext, + type TurnOutcome +} from './structured-agent-session-turns' + +export type MutationPlan = { + method: string + fields: Record + beforeRun?: () => void + run: (ctx: AgentSessionTurnContext) => Promise> + replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null + rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean +} + +export function sendPlan(params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + retryUnknown?: true + beforeRun?: () => void +}): MutationPlan { + // The operation id IS the client message id: one send, one durable row, one + // key the client reconciles its optimistic bubble against. + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.send', + // A control signal is not payload; only the matching durable unknown unlocks redispatch. + fields: { body: params.body }, + ...(params.beforeRun ? { beforeRun: params.beforeRun } : {}), + rerunWhenReplayMissing: (ctx) => + params.retryUnknown === true && + ctx.journal + .submissions() + .some( + (entry) => entry.clientMessageId === clientMessageId && entry.dispatchState === 'unknown' + ), + run: (ctx) => + performSend(ctx, { + clientMessageId, + payloadFingerprint: params.envelope.payloadFingerprint, + body: params.body, + retryUnknown: params.retryUnknown + }), + replay: (ctx) => { + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + return submission && !(params.retryUnknown && submission.dispatchState === 'unknown') + ? { clientMessageId, submission } + : null + } + } +} + +export function cancelPlan(params: { + envelope: AgentSessionMutationEnvelope + turnId: string +}): MutationPlan { + return { + method: 'agentSession.cancel', + fields: { turnId: params.turnId }, + run: (ctx) => + performCancel(ctx, { + clientOperationId: params.envelope.clientOperationId, + turnId: params.turnId + }), + // Interrupting twice would kill a turn the client never asked to stop, so a + // replay reports the turn as already handled instead. + replay: () => ({ turnId: params.turnId, cancelled: false }) + } +} + +export function promptPlan(params: { + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string +}): MutationPlan { + return { + method: `agentSession.respondTo:${params.kind}`, + fields: { + itemId: params.itemId, + expectedRevision: params.expectedRevision, + optionId: params.optionId + }, + run: (ctx) => performPrompt(ctx, params), + replay: (ctx) => { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === params.itemId) + const body = item?.body + if (!item || !body || (body.kind !== 'approval' && body.kind !== 'question')) { + return null + } + return body.resolution.state === 'pending' + ? null + : { itemId: item.itemId, revision: item.revision, resolution: body.resolution } + } + } +} + +export function setOptionPlan(params: { + key: string + value: string +}): MutationPlan { + return { + method: 'agentSession.setOption', + fields: { key: params.key, value: params.value }, + run: (ctx) => performSetOption(ctx, params), + // A pending row may have crashed before the adapter call. Reapplying the + // same assignment is safe; only a settled success can be answered directly. + replay: (ctx, outcome) => + outcome.status === 'succeeded' + ? { + key: params.key, + value: params.value, + ...(ctx.persistedOptions ? { options: { ...ctx.persistedOptions } } : {}) + } + : null + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts new file mode 100644 index 00000000000..4cb24518c69 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -0,0 +1,33 @@ +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runSettledAgentSessionMutation(input: { + store: AgentSessionRecordStore + callerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + context: AgentSessionTurnContext +}): Promise> { + const settle = ( + outcome: Parameters[0]['outcome'] + ) => + input.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.envelope.clientOperationId, + outcome + }) + try { + const outcome = await input.plan.run(input.context) + await settle( + outcome.ok + ? { status: 'succeeded', sessionId: input.envelope.sessionId } + : { status: 'failed', code: outcome.refusal.code } + ) + return outcome + } catch (error) { + await settle({ status: 'unknown' }) + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts new file mode 100644 index 00000000000..607db1e6c4b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-error.ts @@ -0,0 +1,13 @@ +/** Option validation failed before the provider session was mutated. */ +export class AgentSessionOptionRejectedError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'AgentSessionOptionRejectedError' + } +} + +export function isAgentSessionOptionRejectedError( + error: unknown +): error is AgentSessionOptionRejectedError { + return error instanceof Error && error.name === 'AgentSessionOptionRejectedError' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts new file mode 100644 index 00000000000..b9ba03ff327 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-restoration.ts @@ -0,0 +1,20 @@ +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export async function readNativeSessionOptions(input: { + adapter: Pick + sessionId: string + fence: number + priorOptions?: Readonly> +}): Promise> | undefined> { + const { adapter, sessionId, fence, priorOptions } = input + const reported = await adapter.readOptions?.({ sessionId, fence }) + if (!reported) { + return undefined + } + const { model: _model, effort: _effort, ...restored } = priorOptions ?? {} + return { + ...restored, + model: reported.current.model, + ...(reported.current.effort ? { effort: reported.current.effort } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts new file mode 100644 index 00000000000..41054d10ece --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -0,0 +1,266 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import type { + StructuredAgentSessionHandoffTransport, + StructuredTuiOwner +} from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } +const DEFAULT_MODEL = 'gpt-default' +const PICKED_MODEL = 'gpt-picked' + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let router: StructuredAgentSessionAdapter +let acquire: Mock +let closeNativeSession: Mock> +let activeModel: string +let activeEffort: string | null +let transcriptPath: string +let optionFailure: Error | null +let tuiLaunchFailure: Error | null +/** What the adapter's closeSession reports about the child's exit. */ +let closeSessionExit = true +const dispatchedModels: string[] = [] +const launchedOptions: (Readonly> | undefined)[] = [] +const closedTuiOwners: StructuredTuiOwner[] = [] + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function tuiOwner(fence: number, spawnToken: string): StructuredTuiOwner { + return { + terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui', ptyId: 'pty-tui' }, + process: { + hostId: 'local', + pid: 5200, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `tui-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: NOW + }, + transcriptPath + } +} + +function handoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: 'Test host', + launchTui: async ({ record, fence, spawnToken }) => { + if (tuiLaunchFailure) { + const error = tuiLaunchFailure + tuiLaunchFailure = null + throw error + } + launchedOptions.push(record.options) + return tuiOwner(fence, spawnToken) + }, + reproveTuiOwner: async ({ owner }) => owner, + recoverTuiOwner: async (record) => + tuiOwner( + record.lease.runtimeFence, + record.lease.ownerProcess?.spawnToken ?? record.lease.reservedSpawnToken ?? 'recovered' + ), + stopRecoveredOwner: async () => undefined, + closeTuiOwner: async (owner) => { + closedTuiOwners.push(owner) + return { transcriptPath: owner.transcriptPath } + }, + waitForTuiExit: async (owner) => ({ transcriptPath: owner.transcriptPath }), + waitForTuiIdleOrExit: async () => 'idle', + tuiStatus: () => 'idle' + } +} + +function adapter(): StructuredAgentSessionAdapter { + acquire = vi.fn(async ({ fence, spawnToken, options }) => { + activeModel = options?.model ?? DEFAULT_MODEL + activeEffort = options?.effort ?? null + return { + process: { + hostId: 'local', + pid: 4200 + acquire.mock.calls.length, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: `native-link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: acquire.mock.calls.length === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + closeNativeSession = vi.fn(async () => { + activeModel = DEFAULT_MODEL + return closeSessionExit + }) + return { + supportsLocation: () => true, + acquire, + dispatch: vi.fn(async () => { + dispatchedModels.push(activeModel) + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } + }), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async ({ key, value }) => { + if (optionFailure) { + const error = optionFailure + optionFailure = null + throw error + } + if (key === 'model') { + activeModel = value + } else if (key === 'effort') { + activeEffort = value + } + return { + model: activeModel, + ...(activeEffort ? { effort: activeEffort } : {}) + } + }), + readOptions: vi.fn(async () => ({ + current: { model: activeModel, ...(activeEffort ? { effort: activeEffort } : {}) }, + models: [] + })), + closeSession: closeNativeSession + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-handoff-options-')) + resetHostTestOperationIds() + activeModel = DEFAULT_MODEL + activeEffort = null + optionFailure = null + tuiLaunchFailure = null + closeSessionExit = true + dispatchedModels.length = 0 + launchedOptions.length = 0 + closedTuiOwners.length = 0 + const accountHome = join(root, 'codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '12') + transcriptPath = join(sessionsDir, `rollout-2026-08-12T10-00-00-${THREAD}.jsonl`) + await mkdir(sessionsDir, { recursive: true }) + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-12T10:00:00.000Z', + payload: { id: THREAD, session_id: THREAD } + })}\n`, + 'utf8' + ) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + router = adapter() + host = new StructuredAgentSessionHost({ + store, + adapter: router, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-native', + handoffTransport: handoffTransport(), + now: () => NOW + }) + const attached = await host.attach( + CALLER, + hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) + ) + expect(attached).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('structured session options and close', () => { + it('settles a pre-mutation rejection so a fresh retry can succeed', async () => { + optionFailure = new AgentSessionOptionRejectedError('model list unavailable') + const fields = { key: 'model', value: PICKED_MODEL } + const rejected = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'model list unavailable' } + }) + expect(await host.setOption(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect( + await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + ).toMatchObject({ ok: true, value: { options: { model: PICKED_MODEL } } }) + expect(store.getRecord(SESSION)?.options).toEqual({ model: PICKED_MODEL }) + }) + + // Closing a chat used to leave its provider child resident for the whole app session: the host's + // session map had no delete and the only teardown was app quit. + it('stops the provider child and forgets the session when the chat closes', async () => { + expect(host.hasSession(SESSION)).toBe(true) + + await host.close(SESSION) + + expect(closeNativeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'exit-observed' } + }) + expect(host.hasSession(SESSION)).toBe(false) + + await expect(host.close(SESSION)).resolves.toBeUndefined() + expect(closeNativeSession).toHaveBeenCalledOnce() + }) + + it('is a no-op for a session it does not hold', async () => { + await host.close(SESSION) + closeNativeSession.mockClear() + + await expect(host.close(SESSION)).resolves.toBeUndefined() + expect(closeNativeSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts new file mode 100644 index 00000000000..a1b6b39f5e0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -0,0 +1,192 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` +const NEXT_OPERATION = `${NOW}-${'2'.padStart(32, '0')}` +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +function attachParams( + operationId = OPERATION, + expectedRuntimeFence: number | null = null +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: operationId, + expectedRuntimeFence, + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + } +} + +describe('processless structured session reservation', () => { + it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const adapter = { + acquire: vi.fn(async () => { + throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) + }) + } as unknown as StructuredAgentSessionAdapter + const processlessProof = vi.spyOn(store, 'setReservationProcesslessProof') + const settlement = vi.spyOn(store, 'settleFailedAcquisition') + + await expect( + performAttach({ + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + }) + ).rejects.toThrow('workspace no longer exists') + expect(settlement).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ exitProof: 'processless', spawnToken: 'spawn-a' }) + ) + // No separate durable proof write: the only proof call is acquisition's single-use clear. + expect(processlessProof).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ processlessAt: null }) + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + runtimeFence: 2, + processlessAt: null, + reservedSpawnToken: null, + deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } + }) + expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) + + const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), + now: NOW + 1 + }) + expect(reopened.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 2, + reservedSpawnToken: null + }) + }) + + it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) + const storeDir = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const adapter = { + acquire: vi + .fn() + .mockRejectedValueOnce(new AgentSessionPreSpawnError(new Error('launch not ready'))) + .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken + }, + link: { + linkId: 'link-1', + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })), + releaseAcquisition: vi.fn(async () => true) + } as unknown as StructuredAgentSessionAdapter + const input = { + store, + adapter, + journalRoot: root, + authority: { + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: OPERATION, + probe: { outcome: 'reservation-unused' as const } + }, + callerKey: 'client-1', + params: attachParams(), + now: () => NOW, + onAttached: () => {} + } + + await expect(performAttach(input)).rejects.toThrow('launch not ready') + await expect(performAttach(input)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(adapter.acquire).toHaveBeenCalledOnce() + + await expect( + performAttach({ + ...input, + authority: { + ...input.authority, + spawnToken: 'spawn-b', + handoffOperationId: NEXT_OPERATION + }, + params: attachParams(NEXT_OPERATION, 2) + }) + ).resolves.toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 3, + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts new file mode 100644 index 00000000000..cfcae081b88 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -0,0 +1,100 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW, + HOST_TEST_SESSION, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CLAUDE_SESSION = 'claude-session' +const hosts: StructuredAgentSessionHost[] = [] +let root = '' + +function claudeAdapter(): StructuredAgentSessionAdapter { + return { + supportsCreate: (_location, agent) => agent === 'claude', + acquire: async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: HOST_TEST_NOW + } + }), + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } +} + +function createHost( + store: AgentSessionRecordStore, + probeOwner?: StructuredAgentSessionHost['deps']['probeOwner'] +): StructuredAgentSessionHost { + const host = new StructuredAgentSessionHost({ + store, + adapter: claudeAdapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + probeOwner, + now: () => HOST_TEST_NOW + }) + hosts.push(host) + return host +} + +afterEach(async () => { + await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents())) + await rm(root, { recursive: true, force: true }) + root = '' +}) + +describe('structured session provider restore', () => { + it('restores a durable Claude session tab with its recorded provider', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) + resetHostTestOperationIds() + const storeDirectory = join(root, 'store') + const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const host = createHost(store) + const attached = await host.attach( + { callerKey: 'client-1' }, + hostTestAttachParams(null, { + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandle: { kind: 'claude', sessionId: CLAUDE_SESSION, leafUuid: null } + }) + ) + expect(attached).toMatchObject({ ok: true }) + + const reopenedStore = await AgentSessionRecordStore.open({ + directory: storeDirectory, + hostId: 'local' + }) + const restarted = createHost(reopenedStore, async () => ({ + outcome: 'indeterminate', + reason: 'read does not need ownership' + })) + + await restarted.restoreReadableSessions() + + expect(restarted.listSessionTabs()).toEqual([ + { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts new file mode 100644 index 00000000000..99958a2bcb0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-support.ts @@ -0,0 +1,25 @@ +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export function adapterSupportsCreate( + adapter: StructuredAgentSessionAdapter, + location: AgentSessionExecutionLocation, + agent: string +): boolean { + return ( + adapter.supportsCreate?.(location, agent) ?? + (agent === 'codex' && (adapter.supportsLocation?.(location) ?? false)) + ) +} + +export function adapterSupportsRecord( + adapter: StructuredAgentSessionAdapter, + record: AgentSessionRecord +): boolean { + return adapter.supportsCreate + ? adapter.supportsCreate(record.location, record.provider) + : record.provider === 'codex' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts new file mode 100644 index 00000000000..3b4ffa95e54 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -0,0 +1,88 @@ +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { loadJournal } from '../agent-session-journal/journal-open' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { + attachFingerprintFields, + journalIdentityFor, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' + +export type RestoredStructuredAgentSessionRead = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number + hasProviderChild: false +} + +export async function restoreStructuredAgentSessionRead( + store: AgentSessionRecordStore, + journalRoot: string, + sessionId: string +): Promise { + const record = store.getRecord(sessionId) + if (!record) { + return null + } + const params = attachParamsForRecord(record, { + clientOperationId: `read-restore:${record.sessionId}`, + expectedRuntimeFence: record.lease.runtimeFence + }) + const journalDir = journalDirectoryFor(journalRoot, { + workspaceId: record.location.workspaceId, + sessionId + }) + const loaded = await loadJournal(journalDir, sessionId) + if (!loaded || loaded.corrupt) { + return null + } + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor(record, params), + journalDir, + loaded + }) + // Read restore opens the journal and nothing else: no adapter call, so no provider child. + return { journal, params, fence: record.lease.runtimeFence, hasProviderChild: false } +} + +export function attachParamsForRecord( + record: AgentSessionRecord, + input: { + clientOperationId: string + expectedRuntimeFence: number + runtimeKind?: AgentSessionOwnerRuntimeKind + } +): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: record.sessionId, + clientOperationId: input.clientOperationId, + expectedRuntimeFence: input.expectedRuntimeFence, + payloadFingerprint: '' + }, + location: record.location, + provider: record.provider, + agent: record.provider, + accountHome: record.accountHome, + runtimeKind: input.runtimeKind ?? record.lease.runtimeKind + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: record.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts new file mode 100644 index 00000000000..8859365b117 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) + +vi.mock('./structured-agent-session-restart-restore', () => ({ + restoreStructuredAgentSessionsOnRestart: restoreOnRestart +})) + +import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' + +describe('StructuredAgentSessionReadableRestorer', () => { + beforeEach(() => { + restoreOnRestart.mockReset().mockResolvedValue(undefined) + }) + + it('passes targeted records to the restore pool in visible-first order', async () => { + const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map( + (sessionId) => ({ sessionId }) as AgentSessionRecord + ) + const restorer = new StructuredAgentSessionReadableRestorer({ + store: { listRecords: () => records } as never, + journalRoot: '/tmp/journals', + supportsRecord: () => true, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined, + restoreHandoff: async () => undefined + }) + + await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b']) + + expect(restoreOnRestart).toHaveBeenCalledOnce() + expect(restoreOnRestart.mock.calls[0][0].records.map((record) => record.sessionId)).toEqual([ + 'visible-a', + 'visible-b', + 'background-a', + 'background-b' + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts new file mode 100644 index 00000000000..f4e09509b07 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.ts @@ -0,0 +1,52 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { RestoredStructuredAgentSessionRead } from './structured-agent-session-read-restore' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +export class StructuredAgentSessionReadableRestorer { + private restorePromise: Promise | null = null + + constructor( + private readonly input: { + store: AgentSessionRecordStore + journalRoot: string + supportsRecord: (record: AgentSessionRecord) => boolean + reconcile: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + hasSession: (sessionId: string) => boolean + onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + restoreHandoff: (sessionId: string) => Promise + } + ) {} + + restore(sessionIds?: readonly string[]): Promise { + this.restorePromise ??= this.restoreReadableSessions(sessionIds).catch((error: unknown) => { + this.restorePromise = null + throw error + }) + return this.restorePromise + } + + private async restoreReadableSessions(sessionIds?: readonly string[]): Promise { + const targetOrder = sessionIds + ? new Map(sessionIds.map((sessionId, index) => [sessionId, index])) + : null + const records = this.input.store + .listRecords() + .filter( + (record) => + this.input.supportsRecord(record) && (!targetOrder || targetOrder.has(record.sessionId)) + ) + if (targetOrder) { + records.sort( + (left, right) => targetOrder.get(left.sessionId)! - targetOrder.get(right.sessionId)! + ) + } + await restoreStructuredAgentSessionsOnRestart({ + ...this.input, + records + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts new file mode 100644 index 00000000000..a5927dc0c14 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -0,0 +1,344 @@ +// End-to-end exits from latched recovery states: no shape a user cannot get out of. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' +import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-runtime' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +const spawnedOwners = new Set>() +const supersededHosts = new Set() + +async function spawnOwner(spawnToken: string) { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', 'setInterval(() => {}, 1_000)'], + env: { ...process.env, [CODEX_SPAWN_TOKEN_ENV]: spawnToken } + }) + spawnedOwners.add(child) + const pid = child.pid + if (!pid) { + throw new Error('owner process did not start') + } + const processStartTimeMs = await readProcessStartTimeMs(pid) + if (processStartTimeMs === null) { + throw new Error('owner process start time was unavailable') + } + return { + child, + process: { hostId: 'local', pid, processStartTimeMs, spawnToken } + } +} + +async function stopOwner(child: ReturnType): Promise { + if (child.exitCode === null && child.signalCode === null) { + const closed = new Promise((resolve) => child.once('close', () => resolve())) + child.kill('SIGTERM') + await closed + } + spawnedOwners.delete(child) +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition: vi.fn(async () => undefined), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter +} + +function openHost(overrides: Partial = {}): void { + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW, + ...overrides + }) +} + +async function reopenStore(): Promise { + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-recovery-exits-')) + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await Promise.all([...supersededHosts].map((superseded) => superseded.flushAllStreamedEvents())) + supersededHosts.clear() + await Promise.all([...spawnedOwners].map((child) => stopOwner(child))) + await rm(root, { recursive: true, force: true }) +}) + +describe('recovery exits', () => { + it('keeps an ownerless unproven acquisition in manual recovery across restart', async () => { + acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) + await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( + 'agent_session_acquisition_exit_unproven' + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'manual-recovery', + handoffOperationId: null, + ownerProcess: null, + runtimeFence: 1, + reservedSpawnToken: 'spawn-a' + }) + + await reopenStore() + openHost({ mintSpawnToken: () => 'spawn-b' }) + + const refused = await host.attach(CALLER, hostTestAttachParams(1)) + expect(refused).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + expect(acquire).toHaveBeenCalledOnce() + }) + + it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { + // A real session first, so restart restore has a journal to read and runs the + // handoff restorer over the residue instead of skipping the record. + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + await reopenStore() + + // The resume fails at owner proof and cleanup cannot prove exit: the settlement + // keeps the reservation latched at `recovering` with its committed owner identity. + vi.spyOn(store, 'proveOwner').mockRejectedValueOnce(new Error('handle proof lost')) + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + await expect(host.attach(CALLER, hostTestAttachParams(2))).rejects.toThrow( + 'agent_session_acquisition_exit_unproven' + ) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'recovering', + handoffOperationId: null, + ownerProcess: { spawnToken: 'spawn-b' }, + runtimeFence: 3, + reservedSpawnToken: 'spawn-b' + }) + + // The unproven owner dies before the next launch; reconciliation and handoff restore run. + await reopenStore() + openHost({ + mintSpawnToken: () => 'spawn-c', + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + await host.restoreReadableSessions() + + // Death proof releases the residue outright: no handoff continuation, no spawned child, + // no stage a user would have to clear by hand. + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null, + runtimeFence: 4 + }) + + // The ordinary native recovery path remains: the first surface hold resumes it. + await host.hold(SESSION, 'surface-1') + expect(acquire).toHaveBeenCalledTimes(3) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 5, + ownerProcess: { spawnToken: 'spawn-c' } + }) + }) + + it('stops a surviving native child after restart instead of readopting its dead transport', async () => { + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + await reopenStore() + + let orphanAlive = true + const stopOwnerProcess = vi.fn((_pid: number, _signal: 'SIGTERM' | 'SIGKILL') => { + orphanAlive = false + }) + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => + orphanAlive + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + }) + + const stale = await host.attach(CALLER, hostTestAttachParams(1)) + expect(stale).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: 2 } + }) + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + const retried = await host.attach(CALLER, hostTestAttachParams(2)) + expect(retried).toMatchObject({ ok: true }) + // A fresh child was spawned; the orphan pid's lease did not survive as the owner. + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + handoffStage: null + }) + }) + + it('heals a stranded native owner during startup restore, and spawns nothing until a surface asks', async () => { + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + await reopenStore() + + let orphanAlive = true + const stopOwnerProcess = vi.fn(() => { + orphanAlive = false + }) + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => + orphanAlive + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' }, + stopOwnerProcess + }) + + await host.restoreReadableSessions() + + // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but + // nothing has asked to look at this session, so no replacement child exists yet. + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + expect(acquire).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null + }) + + await host.hold(SESSION, 'surface-1') + + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + handoffStage: null, + ownerProcess: { spawnToken: 'spawn-b' } + }) + }) + + it('recovers when the outgoing runtime writes after the replacement probes its dying owner', async () => { + const outgoing = await spawnOwner('spawn-a') + acquire.mockResolvedValueOnce({ + process: outgoing.process, + link: { + linkId: 'link-outgoing', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW + } + }) + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + + const outgoingHost = host + const outgoingStore = store + supersededHosts.add(outgoingHost) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + const realProbe = createStructuredAgentSessionOwnerProbe('local') + let overlapDriven = false + openHost({ + mintSpawnToken: () => 'spawn-b', + probeOwner: async (record) => { + const probe = await realProbe(record) + if (!overlapDriven) { + overlapDriven = true + await outgoingStore.renewLease({ + sessionId: SESSION, + fence: 1, + childProbe: probe, + now: NOW + 1 + }) + await stopOwner(outgoing.child) + } + return probe + } + }) + + await host.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + ownerProcess: null + }) + expect(acquire).toHaveBeenCalledOnce() + + const replacement = await spawnOwner('spawn-b') + acquire.mockResolvedValueOnce({ + process: replacement.process, + link: { + linkId: 'link-replacement', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed', + mintedAtFence: 3, + observedAt: NOW + 2 + } + }) + + await host.hold(SESSION, 'surface-overlap') + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + handoffStage: null, + ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } + }) + expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts new file mode 100644 index 00000000000..4f83a1855f3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -0,0 +1,300 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + resolveStructuredSessionRecovery, + type StructuredSessionRecoveryResolutionDeps +} from './structured-agent-session-recovery-resolution' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-recovery' +const roots: string[] = [] +let operations = 0 + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function openStore(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-recovery-resolution-')) + roots.push(root) + return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) +} + +async function reserve(store: AgentSessionRecordStore, runtimeKind: 'native' | 'tui' = 'native') { + operations += 1 + return store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + runtimeKind, + expectedFence: null, + spawnToken: 'spawn-recovery', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-${String(operations).padStart(32, '0')}`, + fingerprint: 'create' + }, + now: NOW + }) +} + +async function liveOwner(store: AgentSessionRecordStore, runtimeKind: 'native' | 'tui' = 'native') { + const reserved = await reserve(store, runtimeKind) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-recovery' + }, + now: NOW + }) + return store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'link-recovery', + handle: { provider: 'codex', threadId: 'thread-recovery' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +async function latch(store: AgentSessionRecordStore, stage: 'recovering' | 'manual-recovery') { + return store.transitionHandoff(SESSION, (record) => ({ + ...record, + lease: { ...record.lease, handoffStage: stage } + })) +} + +function deps( + store: AgentSessionRecordStore, + probe: (calls: number) => AgentSessionOwnerProbe, + overrides: Partial = {} +): StructuredSessionRecoveryResolutionDeps & { probes: () => number } { + let calls = 0 + return { + store, + probeRecord: async () => { + calls += 1 + return probe(calls) + }, + now: () => NOW + 10_000, + delay: async () => {}, + probes: () => calls, + ...overrides + } +} + +describe('structured session recovery resolution', () => { + it('does not release an ownerless native reservation without processless proof', async () => { + const store = await openStore() + await reserve(store) + await latch(store, 'recovering') + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'indeterminate', reason: 'no scan' })), + SESSION + ) + + expect(result).toBe('unresolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'recovering', + runtimeFence: 1, + reservedSpawnToken: 'spawn-recovery' + }) + }) + + it('evicts a latched owner the probe now proves dead, without a stop request', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'manual-recovery') + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' }), { stopOwnerProcess }), + SESSION + ) + + expect(result).toBe('resolved') + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2, + ownerProcess: null + }) + }) + + it('stops a live identity-matched orphan and evicts only after absence is proven', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'recovering') + let alive = true + const stopOwnerProcess = vi.fn(() => { + alive = false + }) + + const result = await resolveStructuredSessionRecovery( + deps( + store, + () => + alive + ? { outcome: 'identity-matched', matchedOn: ['process-start-time'] } + : { outcome: 'pid-absent' }, + { stopOwnerProcess } + ), + SESSION + ) + + expect(result).toBe('resolved') + expect(stopOwnerProcess).toHaveBeenCalledTimes(1) + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + runtimeFence: 2 + }) + }) + + it('escalates the stop request but never evicts an owner that stays alive', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'recovering') + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }), { + stopOwnerProcess + }), + SESSION + ) + + expect(result).toBe('unresolved') + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGKILL') + // The latch is preserved verbatim: no fence move, no cleared owner, no lost state. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: 'recovering', + runtimeFence: 1, + ownerProcess: { pid: 4242 } + }) + }) + + it('leaves an unverifiable owner latched and requests no stop', async () => { + const store = await openStore() + await liveOwner(store) + await latch(store, 'recovering') + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'indeterminate', reason: 'probe timed out' }), { + stopOwnerProcess + }), + SESSION + ) + + expect(result).toBe('unresolved') + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: 'recovering', + runtimeFence: 1 + }) + }) + + it('leaves a TUI record that still names an owner to its own recovery transport', async () => { + const store = await openStore() + await liveOwner(store, 'tui') + await latch(store, 'recovering') + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' })), + SESSION + ) + ).toBe('not-applicable') + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + }) + + it('resolves a TUI reservation that names nobody, because nothing else can', async () => { + // The TUI carve-out exists because a TUI owner has its own recovery transport, and that + // transport needs a process to talk to. A reservation that crashed before `commitProcessIdentity` + // names none, so skipping it here left the session with no exit at all. + const store = await openStore() + await reserve(store, 'tui') + await latch(store, 'recovering') + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'reservation-unused' })), + SESSION + ) + ).toBe('resolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + claimStatus: 'released' + }) + }) + + it('frees a conflicted claim once its named owner is proven gone', async () => { + const store = await openStore() + await liveOwner(store) + await store.markClaimConflicted(SESSION, NOW) + + expect( + await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'pid-absent' })), + SESSION + ) + ).toBe('resolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + handoffStage: null, + claimStatus: 'released', + deathEvidence: { kind: 'pid-absent' } + }) + }) + + it('never stops the process a conflicted claim names, and keeps the conflict without proof', async () => { + const store = await openStore() + await liveOwner(store) + await store.markClaimConflicted(SESSION, NOW) + const stopOwnerProcess = vi.fn() + + const result = await resolveStructuredSessionRecovery( + deps(store, () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }), { + stopOwnerProcess + }), + SESSION + ) + + // Ownership was never settled, so the process on the other side of the conflict is not + // Orca's to kill; only the user can decide which claimant wins. + expect(stopOwnerProcess).not.toHaveBeenCalled() + expect(result).toBe('unresolved') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts new file mode 100644 index 00000000000..c570db24333 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.ts @@ -0,0 +1,124 @@ +/** + * Exits from latched recovery stages. A session lands in `recovering` / `manual-recovery` + * when evidence about its owner was UNAVAILABLE; this module re-asks with present-time + * evidence and releases the lease only on proof. A stop is a request — the lease moves only + * after a later probe proves the process absent, never on a timeout. + */ + +import { + isProvenAliveProbe, + isProvenDeadProbe, + type AgentSessionOwnerProbe +} from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' + +export type StructuredSessionRecoveryStopSignal = 'SIGTERM' | 'SIGKILL' + +export type StructuredSessionRecoveryResolutionDeps = { + store: AgentSessionRecordStore + probeRecord: (record: AgentSessionRecord) => Promise + now: () => number + stopOwnerProcess?: (pid: number, signal: StructuredSessionRecoveryStopSignal) => void + delay?: (ms: number) => Promise +} + +const STOP_PROBES_PER_SIGNAL = 4 +const STOP_PROBE_INTERVAL_MS = 250 + +const UNRESOLVED_REFUSALS: ReadonlySet = new Set([ + 'agent_session_ownership_unknown', + 'agent_session_checkpoint_stale', + 'execution_owner_reconciling', + 'agent_session_identity_required' +]) + +/** Which latched records this module may re-ask about. */ +export function structuredSessionRecoveryIsResolvable(record: AgentSessionRecord): boolean { + const { claimStatus, handoffStage, ownerProcess, runtimeKind } = record.lease + if (handoffStage !== 'recovering' && handoffStage !== 'manual-recovery') { + return false + } + if (claimStatus === 'conflicted') { + // A conflict names one process. Re-asking is only meaningful against that name; with none + // recorded there is nothing present-time evidence could settle, and the user decides. + return ownerProcess !== null + } + // A TUI owner has its own recovery transport — but that transport needs a process to talk to + // (`structuredManualRecoveryIsAdmissible` requires one). A TUI reservation that crashed before + // its identity was committed names nobody, so nothing else in the system can exit it. + return runtimeKind === 'native' || ownerProcess === null +} + +/** Stopping a matched owner is only Orca's call when Orca owned its transport. A conflicted claim + * means ownership was never settled, and a TUI child is the user's foreground agent. */ +function recoveryMayStopOwner(record: AgentSessionRecord): boolean { + return record.lease.runtimeKind === 'native' && record.lease.claimStatus !== 'conflicted' +} + +export async function resolveStructuredSessionRecovery( + deps: StructuredSessionRecoveryResolutionDeps, + sessionId: string +): Promise<'resolved' | 'unresolved' | 'not-applicable'> { + const record = deps.store.getRecord(sessionId) + if (!record || !structuredSessionRecoveryIsResolvable(record)) { + return 'not-applicable' + } + let probe = await deps.probeRecord(record) + const owner = record.lease.ownerProcess + if ( + owner && + owner.hostId === deps.store.hostId && + isProvenAliveProbe(probe) && + recoveryMayStopOwner(record) + ) { + // The owner is a live child of a runtime that no longer exists; its transport cannot be + // reconstructed, so the only way forward is to stop it and prove it gone. + probe = await stopOwnerAndReprobe(deps, record, owner.pid) + } + try { + await deps.store.evictProvenDeadOwner({ + sessionId, + expectedFence: record.lease.runtimeFence, + probe, + now: deps.now() + }) + return 'resolved' + } catch (error) { + const code = error instanceof Error ? error.message : String(error) + if (UNRESOLVED_REFUSALS.has(code)) { + // No proof yet; the record is preserved untouched and the next attempt re-asks. + return 'unresolved' + } + throw error + } +} + +async function stopOwnerAndReprobe( + deps: StructuredSessionRecoveryResolutionDeps, + record: AgentSessionRecord, + pid: number +): Promise { + const stop = deps.stopOwnerProcess ?? defaultStopOwnerProcess + const delay = deps.delay ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'owner stop requested' } + for (const signal of ['SIGTERM', 'SIGKILL'] as const) { + stop(pid, signal) + for (let attempt = 0; attempt < STOP_PROBES_PER_SIGNAL; attempt += 1) { + probe = await deps.probeRecord(record) + if (isProvenDeadProbe(probe)) { + return probe + } + await delay(STOP_PROBE_INTERVAL_MS) + } + } + return probe +} + +function defaultStopOwnerProcess(pid: number, signal: StructuredSessionRecoveryStopSignal): void { + try { + process.kill(pid, signal) + } catch { + // Already gone or not ours to signal; the next probe supplies the actual proof. + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts new file mode 100644 index 00000000000..f9e67c0efad --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-message.ts @@ -0,0 +1,45 @@ +/** + * Human-readable text for a lease refusal. + * + * A latched session is the one place a bare code is worst: the user is looking at a chat that will + * not open, and `agent_session_ownership_unknown` tells them neither what Orca could not prove nor + * what they can do about it. Every message here names the specific evidence that is missing and + * the action that supplies it. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusalCode } from '../../../shared/agent-session-wire' + +function ownerDescription(record: AgentSessionRecord): string { + const owner = record.lease.ownerProcess + return owner ? `process ${owner.pid} on ${owner.hostId}` : 'a process it never got to record' +} + +function latchedMessage(record: AgentSessionRecord): string { + const owner = record.lease.ownerProcess + if (record.lease.claimStatus === 'conflicted') { + return owner + ? `Two runtimes claimed this session and Orca cannot yet prove that ${ownerDescription(record)} has exited. Quit that process, or reopen this chat once it is gone, and Orca will take the session back.` + : 'Two runtimes claimed this session and the record names no process to check. Quit any other Orca or agent process using this workspace, then reopen this chat.' + } + return owner + ? `Orca cannot prove that ${ownerDescription(record)} — the previous owner of this session — has exited, so it will not start a second agent on the same conversation. Quit that process and reopen this chat.` + : 'Orca cannot tell whether an agent started for this session before the app stopped, so it will not start a second one on the same conversation. Quit any leftover agent process for this workspace and reopen this chat.' +} + +/** Null when the code has no session-specific story to tell; the caller keeps its own wording. */ +export function structuredAgentSessionRefusalMessage( + code: AgentSessionWireRefusalCode, + record: AgentSessionRecord | null +): string | null { + if (!record) { + return null + } + if (code === 'agent_session_ownership_unknown' || code === 'agent_session_conflict') { + return latchedMessage(record) + } + if (code === 'execution_owner_reconciling') { + return 'Orca is still working out who owns this session on this machine. Reopen the chat in a moment.' + } + return null +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts new file mode 100644 index 00000000000..758ed520ee4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -0,0 +1,372 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import { AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT } from '../../../shared/agent-session-operation-ledger' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionMutationEnvelope, + type AgentSessionMutationResult, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import { + agentSessionRefusalOperationState, + type AgentSessionRefusalOperationState +} from '../../../shared/agent-session-refusal-retry' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from './structured-agent-session-host-test-data' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' + +const CALLER = { callerKey: 'client-1' } +const METHODS = ['agentSession.setOption', 'agentSession.send'] as const +type Method = (typeof METHODS)[number] +type Pair = `${Method}:${AgentSessionWireRefusalCode}` + +type CallSpec = { + method: Method + operationId: string + expectedRuntimeFence?: number + payloadFingerprint?: string +} + +type Harness = { + root: string + store: AgentSessionRecordStore + host: StructuredAgentSessionHost + setOption: Mock +} + +const harnesses: Harness[] = [] +let operationSequence = 1_000 + +function operationId(timestamp = NOW): string { + operationSequence += 1 + return `${timestamp}-${operationSequence.toString(16).padStart(32, '0')}` +} + +function handoffTransport(): StructuredAgentSessionHandoffTransport { + const unused = async (): Promise => { + throw new Error('unused handoff transport') + } + return { + hostLabel: 'test-host', + launchTui: unused, + reproveTuiOwner: unused, + recoverTuiOwner: unused, + stopRecoveredOwner: async () => undefined, + waitForTuiExit: unused, + waitForTuiIdleOrExit: unused, + tuiStatus: () => 'idle' + } +} + +async function createHarness(options: { attached?: boolean; transport?: boolean } = {}) { + const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) + const store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const setOption = vi.fn(async () => undefined) + const adapter: StructuredAgentSessionAdapter = { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption + } + const host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW, + ...(options.transport ? { handoffTransport: handoffTransport() } : {}) + }) + const harness = { root, store, host, setOption } + harnesses.push(harness) + if (options.attached !== false) { + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + } + return harness +} + +afterEach(async () => { + const completed = harnesses.splice(0) + await Promise.all(completed.map(async ({ host }) => host.flushAllStreamedEvents())) + await Promise.all(completed.map(async ({ root }) => rm(root, { recursive: true }))) +}) + +function callFields(spec: CallSpec): Record { + if (spec.method === 'agentSession.send') { + return { body: hostTestMessage('host oracle') } + } + return { key: 'model', value: 'gpt-5' } +} + +function envelope(harness: Harness, spec: CallSpec): AgentSessionMutationEnvelope { + const fields = callFields(spec) + return { + sessionId: SESSION, + clientOperationId: spec.operationId, + expectedRuntimeFence: + spec.expectedRuntimeFence ?? harness.store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: + spec.payloadFingerprint ?? + computeAgentSessionPayloadFingerprint({ method: spec.method, sessionId: SESSION, fields }) + } +} + +function invoke(harness: Harness, spec: CallSpec): Promise> { + const fields = callFields(spec) + const mutationEnvelope = envelope(harness, spec) + if (spec.method === 'agentSession.send') { + return harness.host.send(CALLER, { + envelope: mutationEnvelope, + body: fields.body as ReturnType + }) + } + return harness.host.setOption(CALLER, { + envelope: mutationEnvelope, + key: fields.key as string, + value: fields.value as string + }) +} + +function operationState(harness: Harness, operation: string) { + return harness.store + .listOperationRows() + .find((row) => row.callerKey === CALLER.callerKey && row.operationId === operation)?.outcome +} + +async function assertHostAgreement( + harness: Harness, + spec: CallSpec, + code: AgentSessionWireRefusalCode, + retryOnFreshHost = false +): Promise { + try { + const result = await invoke(harness, spec) + expect(result, `${spec.method}:${code}`).toMatchObject({ ok: false, refusal: { code } }) + } catch (error) { + expect(error).toMatchObject({ message: code }) + } + const outcome = operationState(harness, spec.operationId) + let oracle: AgentSessionRefusalOperationState + if (outcome?.status === 'failed') { + oracle = 'settled-rejected' + } else if (outcome?.status === 'unknown') { + oracle = 'unknown' + } else if (outcome?.status === 'pending') { + oracle = 'pending-admission' + } else { + const retryHarness = retryOnFreshHost ? await createHarness() : harness + await invoke(retryHarness, spec) + oracle = operationState(retryHarness, spec.operationId) + ? 'pending-admission' + : 'settled-rejected' + } + expect(agentSessionRefusalOperationState(spec.method, code), `${spec.method}:${code}`).toBe( + oracle + ) + return `${spec.method}:${code}` +} + +async function setLease( + harness: Harness, + update: (record: AgentSessionRecord) => AgentSessionRecord +): Promise { + await harness.store.transitionHandoff(SESSION, update) +} + +async function fillOperationLedger(harness: Harness): Promise { + while ( + harness.store.listOperationRows().filter((row) => row.callerKey === CALLER.callerKey).length < + AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT + ) { + await harness.store.admitOperation({ + callerKey: CALLER.callerKey, + operationId: operationId(), + fingerprint: 'capacity-fixture', + now: NOW + }) + } +} + +// sendPlan and setOptionPlan have no unsupported branch; only handoff checked transport. +const UNREACHABLE = new Set([ + 'agentSession.send:structured_agent_session_unsupported', + 'agentSession.setOption:structured_agent_session_unsupported', + // performPrompt is the sole producer of prompt revision and resolution refusals. + 'agentSession.setOption:agent_session_item_revision_stale', + 'agentSession.send:agent_session_item_revision_stale', + 'agentSession.setOption:agent_session_already_resolved', + 'agentSession.send:agent_session_already_resolved', + // StructuredAgentSessionHost.mutate maps an absent record to AGENT_SESSION_NOT_ATTACHED. + 'agentSession.setOption:agent_session_identity_required', + 'agentSession.send:agent_session_identity_required', + // No structured-agent-session host branch emits agent_session_journal_unreadable. + 'agentSession.setOption:agent_session_journal_unreadable', + 'agentSession.send:agent_session_journal_unreadable' +]) + +describe('agentSessionRefusalOperationState host oracle', () => { + // 26 real host round trips, each committing the store — and every commit now also rotates a + // durable backup, so this does substantially more fsync work than the budget was set for. + it('agrees with every refusal the real host path can produce', { timeout: 90_000 }, async () => { + const produced = new Set() + const record = (pair: Pair) => produced.add(pair) + + const stale = await createHarness() + for (const method of METHODS) { + record( + await assertHostAgreement( + stale, + { + method, + operationId: operationId(), + expectedRuntimeFence: 99 + }, + 'agent_session_checkpoint_stale' + ) + ) + } + + const conflict = await createHarness({ transport: true }) + await setLease(conflict, (current) => ({ + ...current, + lease: { ...current.lease, runtimeKind: 'tui' } + })) + for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + record( + await assertHostAgreement( + conflict, + { method, operationId: operationId() }, + 'agent_session_conflict' + ) + ) + } + + const absent = await createHarness({ attached: false }) + for (const method of METHODS) { + record( + await assertHostAgreement( + absent, + { method, operationId: operationId() }, + 'agent_session_ownership_unknown', + true + ) + ) + } + + const operationConflict = await createHarness() + for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + record( + await assertHostAgreement( + operationConflict, + { + method, + operationId: operationId(), + payloadFingerprint: 'wrong' + }, + 'agent_session_operation_conflict' + ) + ) + } + const ledgerRefusals = await createHarness() + for (const [code, timestamp] of [ + ['agent_session_operation_expired', NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1], + ['agent_session_operation_invalid', null] + ] as const) { + for (const method of METHODS) { + record( + await assertHostAgreement( + ledgerRefusals, + { + method, + operationId: timestamp === null ? 'invalid-operation-id' : operationId(timestamp) + }, + code + ) + ) + } + } + + const capacity = await createHarness() + await fillOperationLedger(capacity) + for (const method of METHODS) { + record( + await assertHostAgreement( + capacity, + { method, operationId: operationId() }, + 'agent_session_operation_capacity', + true + ) + ) + } + + const unknown = await createHarness() + unknown.setOption.mockRejectedValueOnce(new Error('reply lost')) + const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } + await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') + record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) + + const appendFailure = vi + .spyOn(AgentSessionJournal.prototype, 'appendSubmission') + .mockRejectedValueOnce(new Error('journal write failed')) + const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } + await expect(invoke(unknown, sendUnknown)).rejects.toThrow('journal write failed') + appendFailure.mockRestore() + record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) + + const reconciling = await createHarness() + await setLease(reconciling, (current) => ({ + ...current, + lease: { ...current.lease, unreconciled: true } + })) + for (const method of ['agentSession.setOption', 'agentSession.send'] as const) { + record( + await assertHostAgreement( + reconciling, + { method, operationId: operationId() }, + 'execution_owner_reconciling' + ) + ) + } + + const allPairs = METHODS.flatMap((method) => + AGENT_SESSION_WIRE_REFUSAL_CODES.map((code) => `${method}:${code}` as Pair) + ) + expect(new Set([...produced, ...UNREACHABLE])).toEqual(new Set(allPairs)) + expect([...produced].filter((pair) => UNREACHABLE.has(pair))).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts new file mode 100644 index 00000000000..b7a26bb8e3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts @@ -0,0 +1,18 @@ +// Where the RPC layer finds the host. +// +// The runtime service is already far past its size budget, so structured +// sessions hang off a module-level slot instead of another field on it — the +// same shape the native-chat RPC methods use to reach their own collaborators. +// Tests install a host with a stub adapter and clear it on teardown. + +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +let host: StructuredAgentSessionHost | null = null + +export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | null): void { + host = next +} + +export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { + return host +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts new file mode 100644 index 00000000000..452e6a6fae0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-release-clock.ts @@ -0,0 +1,75 @@ +// The delay between "nothing holds this session" and "stop its provider child". +// +// TWO reasons it is not immediate. A surface that reconnects — a mobile socket dropping on a +// network switch, a renderer remounting a tab — releases and re-holds within a second, and killing +// an app-server in that window costs the user a respawn plus a resume for nothing. And a turn the +// user already asked for must finish: the provider is mid-answer, the journal has an open turn +// marker, and stopping the child there strands both. +// +// So the clock arms when the last holder leaves, and a tick that finds a turn still running RE-ARMS +// instead of evicting. That is what makes the wait start at the later of the two events rather than +// at whichever came first. + +export const STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS = 15_000 + +export type StructuredAgentSessionReleaseClockDeps = { + /** Never evict mid-turn; a true answer re-arms the clock instead. */ + isTurnActive: (sessionId: string) => boolean + /** Re-checked at fire time: a holder may have arrived while the timer ran. */ + isHeld: (sessionId: string) => boolean + evict: (sessionId: string) => Promise + onError?: (input: { sessionId: string; error: unknown }) => void + graceMs?: number +} + +export class StructuredAgentSessionReleaseClock { + private readonly timers = new Map>() + private readonly graceMs: number + + constructor(private readonly deps: StructuredAgentSessionReleaseClockDeps) { + this.graceMs = deps.graceMs ?? STRUCTURED_AGENT_SESSION_RELEASE_GRACE_MS + } + + arm(sessionId: string): void { + this.cancel(sessionId) + const timer = setTimeout(() => { + this.timers.delete(sessionId) + this.fire(sessionId) + }, this.graceMs) + // A pending release must never be the reason a process stays alive at quit. + timer.unref?.() + this.timers.set(sessionId, timer) + } + + cancel(sessionId: string): void { + const timer = this.timers.get(sessionId) + if (timer) { + clearTimeout(timer) + this.timers.delete(sessionId) + } + } + + isArmed(sessionId: string): boolean { + return this.timers.has(sessionId) + } + + dispose(): void { + for (const timer of this.timers.values()) { + clearTimeout(timer) + } + this.timers.clear() + } + + private fire(sessionId: string): void { + if (this.deps.isHeld(sessionId)) { + return + } + if (this.deps.isTurnActive(sessionId)) { + this.arm(sessionId) + return + } + void this.deps.evict(sessionId).catch((error: unknown) => { + this.deps.onError?.({ sessionId, error }) + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts new file mode 100644 index 00000000000..a15cd9bf8be --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -0,0 +1,60 @@ +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' + +export type AgentSessionReplayOutcomeDecision = + | { decision: 'replay'; value: TValue } + | { decision: 'rerun' } + | { decision: 'refuse'; refusal: AgentSessionWireRefusal } + +export function resolveAgentSessionReplayOutcome(input: { + operationId: string + outcome: AgentSessionOperationOutcome + reconstruct: () => TValue | null + rerunWhenReplayMissing?: boolean +}): AgentSessionReplayOutcomeDecision { + const { operationId, outcome } = input + if (outcome.status === 'failed') { + const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) + ? (outcome.code as AgentSessionWireRefusalCode) + : 'agent_session_operation_invalid' + return { + decision: 'refuse', + refusal: { + code, + message: outcome.message ?? `Operation ${operationId} was already refused: ${outcome.code}.` + } + } + } + if (outcome.status === 'unknown') { + if (input.rerunWhenReplayMissing) { + return { decision: 'rerun' } + } + return { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `The outcome of operation ${operationId} is unknown; it was not run again.` + } + } + } + const recorded = input.reconstruct() + if (recorded) { + return { decision: 'replay', value: recorded } + } + if (input.rerunWhenReplayMissing) { + return { decision: 'rerun' } + } + return outcome.status === 'succeeded' + ? { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + } + } + : { decision: 'rerun' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts new file mode 100644 index 00000000000..e61587a6598 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' + +describe('createRestartReconciler', () => { + it('reruns after an external store refresh introduces unreconciled leases', async () => { + let record = { sessionId: 'session-1', lease: { unreconciled: true } } as AgentSessionRecord + const reconcileOnRestart = vi.fn(async () => { + record = { ...record, lease: { ...record.lease, unreconciled: false } } + return new Map() + }) + const store = { + listRecords: () => [record], + getRecord: () => record, + reconcileOnRestart + } as unknown as AgentSessionRecordStore + const reconcile = createRestartReconciler({ + store, + probe: async () => ({ outcome: 'pid-absent' }), + now: () => 1 + }) + + expect(await reconcile('session-1')).toBeNull() + record = { ...record, lease: { ...record.lease, unreconciled: true } } + expect(await reconcile('session-1')).toBeNull() + expect(reconcileOnRestart).toHaveBeenCalledTimes(2) + }) + + it('passes every pending record through the batch owner probe', async () => { + let records = [ + { sessionId: 'session-1', lease: { unreconciled: true } }, + { sessionId: 'session-2', lease: { unreconciled: true } } + ] as AgentSessionRecord[] + const probe = vi.fn(async () => ({ outcome: 'pid-absent' as const })) + const probeMany = vi.fn(async (pending: readonly AgentSessionRecord[]) => { + return new Map( + pending.map((record) => [record.sessionId, { outcome: 'pid-absent' as const }]) + ) + }) + const reconcileOnRestart = vi.fn( + async (args: { + probeMany?: ( + pending: readonly AgentSessionRecord[] + ) => Promise> + }) => { + await args.probeMany?.(records) + records = records.map((record) => ({ + ...record, + lease: { ...record.lease, unreconciled: false } + })) + return new Map() + } + ) + const store = { + listRecords: () => records, + getRecord: (sessionId: string) => + records.find((record) => record.sessionId === sessionId) ?? null, + reconcileOnRestart + } as unknown as AgentSessionRecordStore + + await expect( + createRestartReconciler({ store, probe, probeMany, now: () => 1 })('session-1') + ).resolves.toBeNull() + + expect(probeMany).toHaveBeenCalledOnce() + expect(probeMany.mock.calls[0]?.[0].map((record) => record.sessionId)).toEqual([ + 'session-1', + 'session-2' + ]) + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts new file mode 100644 index 00000000000..3b6cc30271e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -0,0 +1,68 @@ +// Every lease loads from disk unreconciled: the process that wrote it may still +// be alive, so nothing the store persisted grants a writer until this host has +// adjudicated it. Without this an attach after a restart is refused forever with +// `execution_owner_reconciling`, and the session becomes unreachable. + +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { classifyStoreFailure } from './structured-agent-session-attach' + +const MAX_RECONCILIATION_PASSES = 8 + +/** Adjudicates leases loaded by this process or refreshed from another writer. + * Answers with the refusal attach owes its caller, or null once settled. */ +export function createRestartReconciler(deps: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: () => number +}): (sessionId: string) => Promise { + let pending: Promise | null = null + return async (sessionId) => { + if (!deps.store.listRecords().some((record) => record.lease.unreconciled)) { + return null + } + if (!pending) { + const run = reconcileCurrentLeases(deps) + pending = run.finally(() => { + pending = null + }) + } + try { + await pending + return null + } catch (error) { + return classifyStoreFailure( + error, + deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null, + deps.store.getRecord(sessionId) + ) + } + } +} + +async function reconcileCurrentLeases(deps: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: () => number +}): Promise { + for (let pass = 0; pass < MAX_RECONCILIATION_PASSES; pass += 1) { + await deps.store.reconcileOnRestart({ + probe: deps.probe, + ...(deps.probeMany ? { probeMany: deps.probeMany } : {}), + now: deps.now() + }) + if (!deps.store.listRecords().some((record) => record.lease.unreconciled)) { + return + } + } + // An outgoing runtime can still be writing during restart; preserve the record and retry later. + throw new Error('execution_owner_reconciling') +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts new file mode 100644 index 00000000000..4567d84d5c0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' + +describe('StructuredAgentSessionRestartRestoreGate', () => { + it('runs one successful restore across concurrent and later discovery', async () => { + const gate = new StructuredAgentSessionRestartRestoreGate() + const restore = vi.fn(async () => undefined) + + await Promise.all([gate.run(restore), gate.run(restore), gate.run(restore)]) + await gate.run(restore) + + expect(restore).toHaveBeenCalledOnce() + }) + + it('allows a failed restore to be retried', async () => { + const gate = new StructuredAgentSessionRestartRestoreGate() + const restore = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('restore failed')) + .mockResolvedValue(undefined) + + await expect(gate.run(restore)).rejects.toThrow('restore failed') + await expect(gate.run(restore)).resolves.toBeUndefined() + + expect(restore).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts new file mode 100644 index 00000000000..dd4237dcb28 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore-gate.ts @@ -0,0 +1,17 @@ +export class StructuredAgentSessionRestartRestoreGate { + private current: Promise | null = null + + run(restore: () => Promise): Promise { + if (this.current) { + return this.current + } + const tracked = restore().catch((error: unknown) => { + if (this.current === tracked) { + this.current = null + } + throw error + }) + this.current = tracked + return tracked + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts new file mode 100644 index 00000000000..d0dcd38b986 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +const { restoreRead } = vi.hoisted(() => ({ + restoreRead: vi.fn() +})) + +vi.mock('./structured-agent-session-read-restore', () => ({ + restoreStructuredAgentSessionRead: restoreRead +})) + +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +describe('restart journal restoration', () => { + beforeEach(() => restoreRead.mockReset()) + + it('bounds historical journal parsing to four sessions at a time', async () => { + const gate = Promise.withResolvers() + let active = 0 + let peak = 0 + restoreRead.mockImplementation(async (_store, _root, sessionId: string) => { + active += 1 + peak = Math.max(peak, active) + await gate.promise + active -= 1 + return { + journal: {}, + params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, + fence: 1, + hasProviderChild: false, + sessionId + } + }) + const records = Array.from( + { length: 12 }, + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + + const restoration = restoreStructuredAgentSessionsOnRestart({ + store: {} as never, + journalRoot: '/tmp/journals', + records, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined, + restoreHandoff: async () => undefined + }) + + await vi.waitFor(() => expect(active).toBe(4)) + expect(restoreRead).toHaveBeenCalledTimes(4) + gate.resolve() + await restoration + + expect(restoreRead).toHaveBeenCalledTimes(records.length) + expect(peak).toBe(4) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts new file mode 100644 index 00000000000..6eb6fe15059 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -0,0 +1,60 @@ +// What a restart owes a persisted session, and what it does NOT. +// +// It owes reconciliation — every lease loaded from disk names an owner from a process generation +// that no longer exists, and adjudicating that is startup's job. It owes an exit from any recovery +// stage the evidence now permits. And it owes a READABLE session: the journal open, history +// answerable, the tab restorable. +// +// It does not owe a provider child. This used to resume every record whose lease was `released` +// with no handoff in flight, which is the normal end state of a chat the user closed cleanly — so a +// healthy profile started an app-server per session it had ever used, in parallel, at every launch, +// with no client attached and nothing on screen. A child now exists because a surface asked for the +// session (see `structured-agent-session-holds`), not because a record survived on disk. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { mapWithConcurrency } from '../../../shared/map-with-concurrency' +import { + restoreStructuredAgentSessionRead, + type RestoredStructuredAgentSessionRead +} from './structured-agent-session-read-restore' + +const JOURNAL_RESTORE_CONCURRENCY = 4 + +export async function restoreStructuredAgentSessionsOnRestart(input: { + store: AgentSessionRecordStore + journalRoot: string + records: AgentSessionRecord[] + reconcile: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + serialize: (sessionId: string, task: () => Promise) => Promise + hasSession: (sessionId: string) => boolean + onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void + restoreHandoff: (sessionId: string) => Promise +}): Promise { + await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { + const unreconciled = await input.reconcile(sessionId) + if (!unreconciled) { + // A session latched in recovery exits here at startup, without waiting for a client. + await input.resolveRecovery(sessionId) + } + await input.serialize(sessionId, async () => { + if (input.hasSession(sessionId)) { + // A surface that took a hold mid-restore already attached this one. + await input.restoreHandoff(sessionId) + return + } + const restored = await restoreStructuredAgentSessionRead( + input.store, + input.journalRoot, + sessionId + ) + if (!restored) { + return + } + input.onReadable(sessionId, restored) + await input.restoreHandoff(sessionId) + }) + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts new file mode 100644 index 00000000000..0baf6fe9952 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resume-eligibility.ts @@ -0,0 +1,41 @@ +// When a record may be handed back a provider child. +// +// This used to be inline in the restart restore, which is what made it a STARTUP rule: every record +// whose lease looked like this got a child, at launch, whether or not anything was going to look at +// it. `released` + no handoff is the normal end state of a chat the user closed cleanly, so a +// healthy profile respawned everything it had ever opened. The predicate itself was never wrong — +// it answers "may this be resumed", not "should it be" — so it lives here now and the caller that +// knows a surface is asking is the only one that acts on it. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { randomUUID } from 'node:crypto' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-read-restore' + +export function isResumableStructuredAgentSessionRecord(record: AgentSessionRecord): boolean { + return ( + !record.lease.unreconciled && + record.lease.claimStatus === 'released' && + record.lease.handoffStage === null + ) +} + +/** Attach params for a resume, or null when this record's lease is somebody else's problem. */ +export function structuredAgentSessionResumeParams( + record: AgentSessionRecord, + clientOperationId: string +): AgentSessionAttachParams | null { + if (!isResumableStructuredAgentSessionRecord(record)) { + return null + } + return attachParamsForRecord(record, { + clientOperationId, + expectedRuntimeFence: record.lease.runtimeFence, + runtimeKind: 'native' + }) +} + +/** `<13-digit ms>-<32 hex>`, the only operation-id shape the durable ledger admits. */ +export function structuredAgentSessionResumeOperationId(now: number): string { + return `${Math.trunc(now).toString().padStart(13, '0')}-${randomUUID().replaceAll('-', '')}` +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts new file mode 100644 index 00000000000..8b6a73fd57b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -0,0 +1,81 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' + +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-send-idempotency-')) + journal = await openAgentSessionJournal({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('structured send idempotency', () => { + it('does not redispatch one send id reused across caller ledgers', async () => { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'one durable send' }] + } + const dispatch = vi.fn(async () => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 0 + } + })) + const context: AgentSessionTurnContext = { + sessionId: 'session-1', + journal, + fence: 1, + adapter: { dispatch } as unknown as StructuredAgentSessionAdapter, + persistOptions: async () => undefined, + resolvedBy: 'caller', + publish: vi.fn(), + now: () => 1 + } + const input = { + clientMessageId: 'shared-send-id', + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body } + }), + body + } + + await performSend(context, input) + const replay = await performSend(context, input) + + expect(replay).toMatchObject({ + ok: true, + value: { clientMessageId: 'shared-send-id', submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledOnce() + expect(journal.submissions()).toHaveLength(1) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts new file mode 100644 index 00000000000..24dc81f4684 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -0,0 +1,392 @@ +// What a client's retry sees around a failed attach settlement: a crash between +// reserve and settlement replays into the original reservation, and a settled +// failure refuses sends without ever re-dispatching on the user's behalf. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type * as DurableFileWrite from '../../durable-file-write' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } + +const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) + +vi.mock('../../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + renameDurable: async (tmpPath: string, finalPath: string) => { + if (finalPath.endsWith('agent-sessions.json')) { + publishFault.publishCount += 1 + } + if ( + finalPath.endsWith('agent-sessions.json') && + publishFault.publishCount === publishFault.failOnPublish + ) { + throw new Error('simulated crash before failed-settlement publish') + } + return actual.renameDurable(tmpPath, finalPath) + } + } +}) + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let releaseAcquisition: Mock> +let dispatch: Mock + +function accepted(): AgentSessionDispatchOutcome { + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + releaseAcquisition, + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } +} + +function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-settled-attach-retry-')) + publishFault.failOnPublish = 0 + publishFault.publishCount = 0 + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + releaseAcquisition = vi.fn(async () => true) + dispatch = vi.fn(async () => accepted()) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + publishFault.failOnPublish = 0 + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('settled attach retry', () => { + it('settles a post-acquisition journal failure and retries without a restart', async () => { + const historyFilePath = vi + .fn>() + .mockRejectedValueOnce(new Error('journal path unavailable')) + .mockResolvedValue(null) + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), historyFilePath }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const first = hostTestAttachParams(null) + + await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') + expect(releaseAcquisition).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + runtimeFence: 2 + }) + expect( + store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) + ?.outcome + ).toMatchObject({ status: 'failed' }) + + await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 3 + }) + }) + + it('continues a safe same-host pending replay with the reserved token', async () => { + const spawnTokens: string[] = [] + acquire.mockImplementation(async ({ fence, spawnToken }) => { + spawnTokens.push(spawnToken) + if (spawnTokens.length === 1) { + throw new Error('reply lost') + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + const mintSpawnToken = vi.fn(() => 'spawn-safe') + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken, + now: () => NOW + }) + const params = hostTestAttachParams(null) + publishFault.failOnPublish = 2 + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent session acquisition failure settlement failed' + ) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(mintSpawnToken).toHaveBeenCalledOnce() + expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) + }) + + it('fences a crash-interrupted reservation replay until positive recovery', async () => { + const spawnTokens: string[] = [] + acquire.mockImplementation(async ({ fence, spawnToken }) => { + spawnTokens.push(spawnToken) + if (spawnTokens.length === 1) { + throw new Error('reply lost') + } + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + let token = 0 + const mintSpawnToken = vi.fn(() => `spawn-${++token}`) + let reservationUnused = false + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken, + probeOwner: async () => + reservationUnused + ? { outcome: 'reservation-unused' } + : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + now: () => NOW + }) + const params = hostTestAttachParams(null) + publishFault.failOnPublish = 2 + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent session acquisition failure settlement failed' + ) + expect(publishFault.publishCount).toBe(2) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + runtimeFence: 1, + reservedSpawnToken: 'spawn-1', + ownerProcess: null + }) + + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken, + probeOwner: async () => + reservationUnused + ? { outcome: 'reservation-unused' } + : { outcome: 'indeterminate', reason: 'spawn token scan unavailable' }, + now: () => NOW + }) + + const refused = await host.attach(CALLER, params) + if (refused.ok) { + throw new Error('expected the replayed reservation to stay fenced') + } + expect(refused.refusal.code).toBe('agent_session_ownership_unknown') + expect(acquire).toHaveBeenCalledTimes(1) + expect(releaseAcquisition).toHaveBeenCalledTimes(1) + expect(mintSpawnToken).toHaveBeenCalledTimes(1) + expect(spawnTokens).toEqual(['spawn-1']) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'reserved', + handoffStage: 'manual-recovery', + runtimeFence: 1, + reservedSpawnToken: 'spawn-1', + ownerProcess: null + }) + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ?.outcome + ).toEqual({ status: 'pending' }) + + reservationUnused = true + await host.hold(SESSION, 'desktop-chat:retry') + expect(mintSpawnToken).toHaveBeenCalledTimes(2) + expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + runtimeKind: 'native', + runtimeFence: 3, + handoffStage: null, + handoffOperationId: null, + ownerProcess: { spawnToken: 'spawn-2' } + }) + }) + + it('restores an unknown submission without redispatch before a distinct send', async () => { + expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = hostTestMessage('possibly delivered') + const unknownParams = { + envelope: envelope('agentSession.send', { body }), + body + } + const first = await host.send(CALLER, unknownParams) + expect(first).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-restarted', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + await host.restoreReadableSessions() + await host.hold(SESSION, 'desktop-chat:restart') + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + runtimeFence: 3 + }) + expect(dispatch).toHaveBeenCalledTimes(1) + + const newBody = hostTestMessage('are you there?') + const sent = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: newBody }), + body: newBody + }) + if (!sent.ok) { + throw new Error(`unexpected restored send refusal: ${sent.refusal.message}`) + } + expect(dispatch).toHaveBeenCalledTimes(2) + const restoredHistory = host.history({ sessionId: SESSION, direction: 'tail' }) + if (!restoredHistory.ok) { + throw new Error(`unexpected restored history reset: ${restoredHistory.reset}`) + } + expect( + restoredHistory.page.submissions.find( + (submission) => submission.clientMessageId === unknownParams.envelope.clientOperationId + )?.dispatchState + ).toBe('unknown') + + const explicitRetry = await host.send(CALLER, { + ...unknownParams, + envelope: { + ...unknownParams.envelope, + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 3 + }, + retryUnknown: true + }) + expect(explicitRetry).toMatchObject({ + ok: true, + value: { submission: { dispatchState: 'accepted' } } + }) + expect(dispatch).toHaveBeenCalledTimes(3) + }) + + it('records proven acquisition cleanup as durable death evidence', async () => { + acquire.mockRejectedValueOnce(new Error('resume rejected')) + + await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow('resume rejected') + + expect(releaseAcquisition).toHaveBeenCalledTimes(1) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: { + kind: 'exit-observed', + detail: 'acquisition cleanup proved no provider child remains' + } + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts new file mode 100644 index 00000000000..18d2853a9f9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -0,0 +1,195 @@ +import { appendFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionHandoffStatus, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, + serializeRemoteRuntimePayload +} from '../../../shared/remote-runtime-memory-limits' +import { JOURNAL_LOG_FILE } from '../agent-session-journal/journal-log-file' +import { serializeJournalRow, type JournalRow } from '../agent-session-journal/journal-row-schema' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const SESSION = 'subscriber-session' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-subscribers-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('AgentSessionSubscribers', () => { + it('publishes the current fence when a resumed cursor is already caught up', async () => { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'checkpoint-journal') + }) + const events: AgentSessionSubscribeEvent[] = [] + + new AgentSessionSubscribers().open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 7, + cursor: journal.cursor(), + emit: (event) => events.push(event) + }) + + expect(events).toEqual([ + { + type: 'batch', + sessionId: SESSION, + batch: { + cursor: journal.cursor(), + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 7 + } + ]) + }) + + it('publishes handoff-only changes without serializing a transcript snapshot', async () => { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: join(root, 'journal') + }) + const subscribers = new AgentSessionSubscribers() + const events: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 1, + emit: (event) => events.push(event) + }) + const handoff: AgentSessionHandoffStatus = { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'handoff-1' + } + + subscribers.handoff(SESSION, 2, handoff) + + expect(events.at(-1)).toEqual({ + type: 'batch', + sessionId: SESSION, + batch: { + cursor: journal.cursor(), + items: [], + removedItemIds: [], + submissions: [] + }, + fence: 2, + handoff + }) + }) + + it('catches a subscriber up past a pre-existing unsendable removal with a bounded reset', async () => { + const journalDir = join(root, 'oversized-removal-journal') + const seeded = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + // A row admitted before identity bounding: its removal id alone exceeds + // the outbound cap, so no catch-up batch can ever carry it. + const hugeItemId = `codex:thread-1:${'h'.repeat(5 * 1024 * 1024)}:1` + const resumeCursor = seeded.cursor() + const seq = resumeCursor.sequence + const rows: JournalRow[] = [ + { + kind: 'item', + itemId: hugeItemId, + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'big' }] }, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: seeded.epoch, + seq: seq + 1, + fence: 1, + ts: 2_000 + }, + { + kind: 'tombstone', + itemId: hugeItemId, + revision: 2, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: seeded.epoch, + seq: seq + 2, + fence: 1, + ts: 2_001 + } + ] + await appendFile( + join(journalDir, JOURNAL_LOG_FILE), + `${rows.map(serializeJournalRow).join('\n')}\n`, + 'utf-8' + ) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir + }) + + const subscribers = new AgentSessionSubscribers() + const events: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'subscriber-1', + sessionId: SESSION, + journal, + fence: 1, + cursor: resumeCursor, + emit: (event) => events.push(event) + }) + + // Every event a remote subscriber receives must fit the outbound channel. + for (const event of events) { + expect(Buffer.byteLength(serializeRemoteRuntimePayload(event), 'utf8')).toBeLessThanOrEqual( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + } + const reset = events.find((event) => event.type === 'reset') + expect(reset).toBeDefined() + + // Forward progress: the reset advanced the subscriber past the unsendable + // row, so the next publish has nothing stale to re-deliver. + const settled = events.length + subscribers.publish(SESSION, journal) + expect(events.slice(settled).filter((event) => event.type === 'reset')).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts new file mode 100644 index 00000000000..3fa80b28d85 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -0,0 +1,233 @@ +// Per-subscriber cursors over one session's journal. +// +// Each subscriber advances independently: a client that connected two epochs +// ago gets a reset while a caught-up one gets a batch from the same publish. +// Nothing raw reaches a subscriber — every event carries reducer output. + +import type { + AgentJournalCursor, + AgentJournalResetReason +} from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHandoffStatus, + type AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + readAgentSessionHistory, + readAgentSessionHydrationPage +} from './agent-session-history-page' + +export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void +export type AgentSessionSubscribeInput = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor +} + +type Subscriber = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor: AgentJournalCursor + fence: number +} + +export class AgentSessionSubscribers { + private readonly bySession = new Map>() + + /** Opens the stream with a bounded tail page or, when the client's cursor + * still resolves, with the rows it missed. Returns the disposer. */ + open(input: { + id: string + sessionId: string + journal: AgentSessionJournal + fence: number + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor + handoff?: AgentSessionHandoffStatus + }): () => void { + const liveCursor = input.journal.cursor() + const subscriber: Subscriber = { + id: input.id, + sessionId: input.sessionId, + emit: input.emit, + cursor: input.cursor ?? { epoch: liveCursor.epoch, sequence: 0 }, + fence: input.fence + } + const session = this.bySession.get(input.sessionId) ?? new Map() + session.set(input.id, subscriber) + this.bySession.set(input.sessionId, session) + + if (input.cursor) { + this.deliver(subscriber, input.journal, input.handoff, true) + } else { + const page = readAgentSessionHydrationPage(input.journal, input.fence) + this.emit(subscriber, { + type: 'snapshot', + sessionId: input.sessionId, + page, + fence: input.fence, + ...(input.handoff ? { handoff: input.handoff } : {}) + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + } + return () => this.close(input.sessionId, input.id) + } + + close(sessionId: string, id: string): void { + const session = this.bySession.get(sessionId) + const subscriber = session?.get(id) + if (!session || !subscriber) { + return + } + this.drop(subscriber) + try { + subscriber.emit({ type: 'end' }) + } catch { + // The transport is already gone; teardown must remain idempotent. + } + } + + /** Fan out whatever each subscriber has not yet seen. */ + publish(sessionId: string, journal: AgentSessionJournal): void { + for (const subscriber of this.subscribers(sessionId)) { + this.deliver(subscriber, journal) + } + } + + /** Force every subscriber back to a bounded tail page — recovery, epoch + * rollover, an unreadable schema. */ + reset( + sessionId: string, + journal: AgentSessionJournal, + reason: AgentJournalResetReason, + fence: number + ): void { + const page = readAgentSessionHydrationPage(journal, fence) + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'reset', sessionId, reset: reason, page, fence }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + subscriber.fence = fence + } + } + + snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void { + const page = readAgentSessionHydrationPage(journal, fence) + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'snapshot', sessionId, page, fence }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + subscriber.fence = fence + } + } + + handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void { + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { + type: 'batch', + sessionId, + batch: { + cursor: subscriber.cursor, + items: [], + removedItemIds: [], + submissions: [] + }, + fence, + handoff + }) + subscriber.fence = fence + } + } + + private subscribers(sessionId: string): Subscriber[] { + return [...(this.bySession.get(sessionId)?.values() ?? [])] + } + + private deliver( + subscriber: Subscriber, + journal: AgentSessionJournal, + handoff?: AgentSessionHandoffStatus, + emitCheckpoint = false + ): void { + while (true) { + const result = readAgentSessionHistory(journal, { + sessionId: subscriber.sessionId, + direction: 'after', + cursor: subscriber.cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + if (!result.ok) { + const page = { ...result.page, fence: subscriber.fence } + this.emit(subscriber, { + type: 'reset', + sessionId: subscriber.sessionId, + reset: result.reset, + page, + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) + }) + subscriber.cursor = page.liveCursor ?? page.window.nextCursor + return + } + const page = result.page + const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence + if (!advanced) { + if (handoff || emitCheckpoint) { + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: [], + removedItemIds: [], + submissions: [] + }, + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) + }) + } + return + } + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: { + cursor: page.window.nextCursor, + items: page.items, + removedItemIds: page.removedItemIds, + submissions: page.submissions + }, + fence: subscriber.fence, + ...(handoff ? { handoff } : {}) + }) + subscriber.cursor = page.window.nextCursor + if (!page.hasNewer || !this.isActive(subscriber)) { + return + } + } + } + + private isActive(subscriber: Subscriber): boolean { + return this.bySession.get(subscriber.sessionId)?.get(subscriber.id) === subscriber + } + + /** A dead transport cannot be allowed to turn a durable mutation into an + * unknown outcome or poison every later publication. */ + private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + try { + subscriber.emit(event) + } catch { + this.drop(subscriber) + } + } + + private drop(subscriber: Subscriber): void { + const session = this.bySession.get(subscriber.sessionId) + session?.delete(subscriber.id) + if (session?.size === 0) { + this.bySession.delete(subscriber.sessionId) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts new file mode 100644 index 00000000000..04170a3c840 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -0,0 +1,250 @@ +// The lifetime of a provider child, from the surfaces that hold the session. +// +// Two leaks meet here and each has to be tested against the real host, not a double: a chat that +// closes without stopping its app-server, and a launch that starts one for every record on disk. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const SURFACE = 'desktop-chat:1' +/** Short enough to keep the suite fast, long enough that an eviction is a decision and not a race. */ +const GRACE_MS = 5 + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock +let closeSession: Mock> +let sink: StructuredAgentSessionEventSink | null +let hostErrors: unknown[] +function adapter(): StructuredAgentSessionAdapter { + return { + acquire, + closeSession, + releaseAcquisition: vi.fn(async () => true), + dispatch: vi.fn(async () => ({ state: 'rejected' as const, reason: 'unused' })), + cancelTurn: vi.fn(async () => ({ cancelled: false })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + } +} + +function openHost( + probeOwner?: (record: never) => Promise, + handoffTransport?: StructuredAgentSessionHandoffTransport +): void { + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, + releaseGraceMs: GRACE_MS, + now: () => NOW, + onEventSinkError: ({ error }) => hostErrors.push(error), + ...(probeOwner ? { probeOwner: probeOwner as never } : {}), + ...(handoffTransport ? { handoffTransport } : {}) + }) +} + +/** A fresh app generation over the same durable store, with its owner proven gone. */ +async function reboot(): Promise { + await host.flushAllStreamedEvents() + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost(async () => ({ outcome: 'pid-absent' })) + acquire.mockClear() + closeSession.mockClear() +} + +async function attach(): Promise { + expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) +} + +function emitTurnLifecycle(state: 'running' | 'completed', ordinal: number): void { + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, + { kind: 'status', text: state, turnLifecycle: { turnId: 'turn-1', state } } + ) +} + +/** Eviction is a sequence, not an event: the child stops first and the session is forgotten last. */ +function waitForEviction(): Promise { + return vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + }) +} + +/** Long enough for several grace windows to elapse, so "not evicted" means the clock declined. */ +function waitOutSeveralGraceWindows(): Promise { + return new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-surface-lifetime-')) + resetHostTestOperationIds() + sink = null + hostErrors = [] + acquire = vi.fn(async ({ fence, spawnToken, events }) => { + sink = events ?? null + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + } + } + }) + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + openHost() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat that closes', () => { + it('releases the provider child it was holding', async () => { + await attach() + await host.hold(SESSION, SURFACE) + + host.release(SESSION, SURFACE) + + await waitForEviction() + expect(hostErrors).toEqual([]) + // The record and its journal stay; only the process and the claim on it go. + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'exit-observed' } + }) + }) + + it('keeps the child while another surface still holds the session', async () => { + await attach() + await host.hold(SESSION, SURFACE) + await host.hold(SESSION, 'paired-phone:1') + + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) + + it('does not lose the session to a release the client sent twice', async () => { + await attach() + await host.hold(SESSION, SURFACE) + await host.hold(SESSION, 'paired-phone:1') + + // A retried release must retire ONE holder, which is what a set gets right and a count does not. + host.release(SESSION, SURFACE) + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) +}) + +describe('a session with a turn in flight', () => { + it('is not evicted while the turn runs, and is once it ends', async () => { + await attach() + await host.hold(SESSION, SURFACE) + emitTurnLifecycle('running', 1) + await host.flushStreamedEvents(SESSION) + + host.release(SESSION, SURFACE) + await waitOutSeveralGraceWindows() + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + + emitTurnLifecycle('completed', 2) + await host.flushStreamedEvents(SESSION) + + await waitForEviction() + }) +}) + +describe('startup', () => { + it('restores a session for reading without spawning a provider child', async () => { + await attach() + await reboot() + + await host.restoreReadableSessions() + + // The record is readable — the tab comes back, history answers — and nothing is running. + expect(acquire).not.toHaveBeenCalled() + expect(host.listSessionTabs()).toEqual([ + { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } + ]) + expect(host.history({ sessionId: SESSION, direction: 'tail' }).ok).toBe(true) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) + + it('gives the child back to a chat a surface actually opens', async () => { + await attach() + await reboot() + await host.restoreReadableSessions() + + await host.hold(SESSION, SURFACE) + + expect(acquire).toHaveBeenCalledOnce() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'live', + runtimeKind: 'native', + ownerProcess: { pid: 4242 } + }) + }) +}) + +describe('a session evicted and opened again', () => { + it('publishes provider events to the reattached chat', async () => { + await attach() + await host.hold(SESSION, SURFACE) + host.release(SESSION, SURFACE) + await waitForEviction() + + await host.hold(SESSION, 'desktop-chat:2') + const events: AgentSessionSubscribeEvent[] = [] + const unsubscribe = host.subscribe({ + id: 'subscriber-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'back again' }] } + ) + sink?.publish() + await host.flushStreamedEvents(SESSION) + unsubscribe() + + expect(JSON.stringify(events)).toContain('back again') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts new file mode 100644 index 00000000000..520db02570b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' + +function pendingChainCount(queue: StructuredAgentSessionTaskQueue): number { + return (queue as unknown as { chains: Map> }).chains.size +} + +describe('StructuredAgentSessionTaskQueue', () => { + it('deletes a successful settled tail', async () => { + const queue = new StructuredAgentSessionTaskQueue() + + await expect(queue.serialize('session-1', async () => 'done')).resolves.toBe('done') + await Promise.resolve() + + expect(pendingChainCount(queue)).toBe(0) + }) + + it('deletes a rejected settled tail without poisoning the next task', async () => { + const queue = new StructuredAgentSessionTaskQueue() + + await expect( + queue.serialize('session-1', async () => { + throw new Error('failed') + }) + ).rejects.toThrow('failed') + await expect(queue.serialize('session-1', async () => 'recovered')).resolves.toBe('recovered') + await Promise.resolve() + + expect(pendingChainCount(queue)).toBe(0) + }) + + it('does not let an earlier tail cleanup delete an overlapping replacement', async () => { + const queue = new StructuredAgentSessionTaskQueue() + const firstGate = Promise.withResolvers() + const secondGate = Promise.withResolvers() + const order: string[] = [] + const first = queue.serialize('session-1', async () => { + order.push('first-start') + await firstGate.promise + order.push('first-end') + }) + const second = queue.serialize('session-1', async () => { + order.push('second-start') + await secondGate.promise + order.push('second-end') + }) + + firstGate.resolve() + await first + expect(pendingChainCount(queue)).toBe(1) + await vi.waitFor(() => expect(order).toEqual(['first-start', 'first-end', 'second-start'])) + + secondGate.resolve() + await second + await Promise.resolve() + expect(order).toEqual(['first-start', 'first-end', 'second-start', 'second-end']) + expect(pendingChainCount(queue)).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts new file mode 100644 index 00000000000..f0237835c75 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-task-queue.ts @@ -0,0 +1,25 @@ +import { runKeyedSerializedOperation } from '../../cli/keyed-promise-queue' + +export class StructuredAgentSessionTaskQueue { + private readonly chains = new Map>() + private readonly attaching = new Set>() + + serialize(sessionId: string, task: () => Promise): Promise { + return runKeyedSerializedOperation(this.chains, sessionId, task) + } + + trackAttach(operation: Promise): Promise { + this.attaching.add(operation) + void operation.then( + () => this.attaching.delete(operation), + () => this.attaching.delete(operation) + ) + return operation + } + + async drainAttaches(): Promise { + while (this.attaching.size > 0) { + await Promise.allSettled(this.attaching) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts new file mode 100644 index 00000000000..23a237311f3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -0,0 +1,290 @@ +// The effects behind send / cancel / respond / setOption. +// +// Admission (lease, fence, idempotency) has already passed by the time anything +// here runs; these functions own only the journal writes and the adapter call, +// in that order. Journal first is deliberate: a crash between the two leaves a +// row the next attach settles as `unknown`, whereas the reverse would lose a +// turn the provider already accepted. + +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalResolution +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { decodeCodexQuestionOptionId } from '../../codex/codex-structured-prompt-replies' +import type { + AgentSessionCancelResult, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { isAgentSessionOptionRejectedError } from './structured-agent-session-option-error' + +export type AgentSessionTurnContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + persistedOptions?: Readonly> + persistOptions: (options: Readonly>) => Promise + /** Opaque client identity recorded as the resolver of a prompt. */ + resolvedBy: string + publish: () => void + now: () => number +} + +export type TurnOutcome = + | { ok: true; value: TValue } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +/** A thrown adapter error is indistinguishable from a lost reply, so it settles + * as `unknown` rather than as a rejection. */ +async function dispatchSafely( + ctx: AgentSessionTurnContext, + clientMessageId: string, + body: AgentJournalMessageItem +): Promise { + try { + return await ctx.adapter.dispatch({ + sessionId: ctx.sessionId, + clientMessageId, + body, + fence: ctx.fence + }) + } catch (error) { + return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } + } +} + +async function appendStatus( + ctx: AgentSessionTurnContext, + clientMessageId: string, + text: string +): Promise { + await ctx.journal.appendItem( + { provider: 'orca', clientMessageId }, + { kind: 'status', text }, + { fence: ctx.fence } + ) + ctx.publish() +} + +export async function performSend( + ctx: AgentSessionTurnContext, + input: { + clientMessageId: string + payloadFingerprint: string + body: AgentJournalMessageItem + retryUnknown?: true + } +): Promise> { + const existing = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === input.clientMessageId) + if (existing && existing.payloadFingerprint !== input.payloadFingerprint) { + return invalid(`Message id ${input.clientMessageId} was already used for another send.`) + } + if (existing && !(input.retryUnknown && existing.dispatchState === 'unknown')) { + return { + ok: true, + value: { clientMessageId: input.clientMessageId, submission: existing } + } + } + if (!(input.retryUnknown && existing?.dispatchState === 'unknown')) { + await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) + ctx.publish() + } + + const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body) + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : { + clientMessageId: input.clientMessageId, + state: outcome.state, + reason: outcome.reason, + fence: ctx.fence + } + ) + ctx.publish() + + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === input.clientMessageId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + return { ok: true, value: { clientMessageId: input.clientMessageId, submission } } +} + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { clientOperationId: string; turnId: string } +): Promise> { + let cancelled = false + let note = 'Turn cancelled.' + try { + cancelled = ( + await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + turnId: input.turnId, + fence: ctx.fence + }) + ).cancelled + if (!cancelled) { + note = 'The provider had already finished this turn.' + } + } catch (error) { + note = `Cancellation was not confirmed: ${ + error instanceof Error ? error.message : String(error) + }` + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await appendStatus(ctx, input.clientOperationId, note) + return { ok: true, value: { turnId: input.turnId, cancelled } } +} + +function promptBodyOf(body: AgentJournalItemBody): { + options: readonly { id: string }[] + freeTextQuestionId?: string + resolution: AgentJournalResolution +} | null { + return body.kind === 'approval' || body.kind === 'question' ? body : null +} + +/** + * Durable compare-and-set on (itemId, revision) plus the pending state. The + * journal write commits before the provider callback fires, so two clients + * answering one prompt produce exactly one callback and the loser is told which + * answer won. + */ +export async function performPrompt( + ctx: AgentSessionTurnContext, + input: { + itemId: string + expectedRevision: number + optionId: string + kind: 'approval' | 'question' + } +): Promise> { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = promptBodyOf(item.body) + if (!prompt || item.body.kind !== input.kind) { + return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + const freeText = decodeCodexQuestionOptionId(input.optionId) + const acceptsFreeText = + item.body.kind === 'question' && + prompt.freeTextQuestionId !== undefined && + freeText?.questionId === prompt.freeTextQuestionId && + freeText.answer.trim().length > 0 + if (!acceptsFreeText && !prompt.options.some((option) => option.id === input.optionId)) { + return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + } + const identity = parseAgentJournalItemKey(input.itemId) + if (!identity) { + return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + } + + const resolution: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: input.optionId, + resolvedBy: ctx.resolvedBy, + resolvedAt: ctx.now() + } + const appended = await ctx.journal.appendItem( + identity, + { ...item.body, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + + try { + await ctx.adapter.answerPrompt({ + sessionId: ctx.sessionId, + itemId: input.itemId, + kind: input.kind, + optionId: input.optionId, + fence: ctx.fence + }) + } catch (error) { + // The answer is committed and will not be offered again; say so rather than + // reopening the prompt and risking a second callback. + await appendStatus( + ctx, + `${input.itemId}#delivery`, + `Your answer was recorded but the agent did not confirm it: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + return { + ok: true, + value: { itemId: appended.itemId, revision: appended.revision, resolution } + } +} + +/** Options live on the provider, not in the journal, so this writes nothing. */ +export async function performSetOption( + ctx: AgentSessionTurnContext, + input: { key: string; value: string } +): Promise> { + let applied: void | Readonly> + try { + applied = await ctx.adapter.setOption({ + sessionId: ctx.sessionId, + ...input, + fence: ctx.fence + }) + } catch (error) { + if (isAgentSessionOptionRejectedError(error)) { + return invalid(error.message) + } + throw error + } + await ctx.persistOptions(applied ?? { [input.key]: input.value }) + return { ok: true, value: { ...input, ...(applied ? { options: { ...applied } } : {}) } } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts new file mode 100644 index 00000000000..4af342227c2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -0,0 +1,334 @@ +// The profiles already shipped into a dead end. +// +// Every record here is a shape taken from a real wedged store: a lease that no acquisition, no +// handoff restore, and no manual recovery can move, so the chat behind it never opens again. The +// contract is that loading the record under this build makes it usable WITHOUT losing the +// conversation — the journal, the provider handle chain, and the recorded evidence all survive. +// +// "Usable" means ACQUIRABLE, not acquired. Startup no longer resumes a provider child for a record +// nobody is looking at; a surface taking a hold is what spawns one. So the migration's job is to +// leave the lease in a state a hold can claim, and these tests prove that by adjudicating it rather +// than by reading fields off it. + +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication' +import type { + AgentSessionClaimStatus, + AgentSessionHandoffStage, + AgentSessionOwnerRuntimeKind, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { + HOST_TEST_LOCATION as LOCATION, + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' + +const CALLER = { callerKey: 'client-1' } +const DEAD_OWNER: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 12_546, + processStartTimeMs: 1_786_772_085_000, + spawnToken: 'spawn-dead' +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let acquire: Mock + +type WedgeOverrides = { + claimStatus: AgentSessionClaimStatus + handoffStage: AgentSessionHandoffStage | null + runtimeKind?: AgentSessionOwnerRuntimeKind + ownerProcess?: AgentSessionProcessIdentity | null + reservedSpawnToken?: string | null + handoffOperationId?: string | null +} + +/** A record in the wedged shape, with real history behind it. */ +function wedgedRecord(overrides: WedgeOverrides): AgentSessionRecord { + const fence = 13 + return { + schemaVersion: 2, + sessionId: SESSION, + location: LOCATION, + provider: 'codex', + providerHandleChain: [ + { + linkId: `codex-${fence}-link`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW - 10_000 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + createdAt: NOW - 100_000, + updatedAt: NOW - 10_000, + lease: { + sessionId: SESSION, + runtimeKind: overrides.runtimeKind ?? 'native', + runtimeFence: fence, + handoffStage: overrides.handoffStage, + provenHandleLinkId: `codex-${fence}-link`, + ownerProcess: overrides.ownerProcess ?? null, + reservedSpawnToken: overrides.reservedSpawnToken ?? null, + leaseDeadlineAt: NOW - 9_000, + lastRenewedAt: NOW - 10_000, + handoffOperationId: overrides.handoffOperationId ?? null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: overrides.claimStatus, + unreconciled: false, + deathEvidence: null + } + } +} + +async function seedStore(record: AgentSessionRecord): Promise { + const directory = join(root, 'store') + await mkdir(directory, { recursive: true }) + await writeFile( + join(directory, AGENT_SESSION_STORE_FILE_NAME), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [record.sessionId]: record }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }), + 'utf-8' + ) + store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +/** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ +function openHost(overrides: Partial = {}): void { + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => undefined), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + } as unknown as StructuredAgentSessionAdapter, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-new', + now: () => NOW, + probeOwner: async () => ({ outcome: 'pid-absent' }), + ...overrides + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wedged-profile-')) + resetHostTestOperationIds() + acquire = vi.fn(async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-new' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } + })) +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** The property that matters: a hold taken now would be granted a lease. */ +function isAcquirable(lease: NonNullable>['lease']): boolean { + return ( + evaluateAgentSessionAcquisition({ + lease, + expectedFence: lease.runtimeFence, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }).decision === 'granted' + ) +} + +describe('already-wedged profiles become usable on load', () => { + it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => { + // A crash can leave a conflicted current-schema row in manual recovery; positive death proof + // must make it acquirable again without discarding the provider handle. + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + openHost() + + await host.restoreReadableSessions() + + const lease = store.getRecord(SESSION)!.lease + expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) + expect(isAcquirable(lease)).toBe(true) + // A real re-adjudication, not a no-op: the eviction minted a new generation. + expect(lease?.runtimeFence).toBeGreaterThan(13) + // The conversation survived: the codex thread was resumed, not recreated. + expect(store.getRecord(SESSION)?.providerHandleChain[0]).toMatchObject({ + linkId: 'codex-13-link', + handle: { threadId: THREAD } + }) + // Why NOT acquired here: startup spawning a provider child for every recovered record is the + // accumulation this stack removed. Unlatching is the migration's job; spawning is a hold's. + expect(acquire).not.toHaveBeenCalled() + }) + + it('leaves a conflicted record alone while its owner cannot be proven gone', async () => { + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + openHost({ + probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + }) + + await host.restoreReadableSessions() + + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: { pid: DEAD_OWNER.pid } + }) + }) + + it('unlatches a released record that reloaded into recovery with nothing outstanding', async () => { + // An evicted lease has no owner and no token, so a restart has nothing to probe. Treating that + // as an unproven reservation re-latched it to `recovering` on every single boot. + await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })) + openHost() + + await host.restoreReadableSessions() + + const lease = store.getRecord(SESSION)!.lease + expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) + expect(isAcquirable(lease)).toBe(true) + }) + + it('exits a TUI reservation that crashed before its identity was committed', async () => { + // The reviewer's shape: a TUI child launched, the runtime died before `commitProcessIdentity`, + // and restart adjudication could not answer, so the lease latched at `recovering` with a null + // owner. Handoff restore cannot help (no owner to talk to) and manual recovery requires one, + // so recovery resolution is the ONLY exit — and it used to skip every TUI record. + await seedStore( + wedgedRecord({ + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + runtimeKind: 'tui', + reservedSpawnToken: 'spawn-tui', + handoffOperationId: 'handoff-op-1' + }) + ) + // Restart adjudication runs while the host still cannot enumerate the token; the later + // recovery pass gets a real answer. + let probes = 0 + openHost({ + probeOwner: async () => { + probes += 1 + return probes === 1 + ? { outcome: 'indeterminate', reason: 'host could not enumerate spawn tokens' } + : { outcome: 'reservation-unused' } + } + }) + + await host.restoreReadableSessions() + + const lease = store.getRecord(SESSION)!.lease + expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) + expect(isAcquirable(lease)).toBe(true) + }) + + it('does not infer orphan ownership from a host-global token scan', async () => { + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + const order: string[] = [] + const scan = vi.fn( + async () => + new Map([ + ['spawn-lost', [31_337]], + [DEAD_OWNER.spawnToken, [12_546]] + ]) + ) + acquire.mockImplementation(async ({ fence }) => { + order.push('acquire') + return { + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-new' }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: 'resumed' as const, + mintedAtFence: fence, + observedAt: NOW + } + } + }) + openHost({ + scanSpawnTokenProcesses: scan, + stopOwnerProcess: (pid) => order.push(`stop:${pid}`) + }) + + await host.restoreReadableSessions() + expect(order).toEqual([]) + expect(scan).not.toHaveBeenCalled() + await host.hold(SESSION, 'holder-1') + + expect(order).toEqual(['acquire']) + }) + + it('names the missing evidence when a latched record still cannot be freed', async () => { + await seedStore( + wedgedRecord({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + ownerProcess: DEAD_OWNER + }) + ) + openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no answer' }) }) + await host.restoreReadableSessions() + + const refused = await host.attach(CALLER, hostTestAttachParams(13)) + + expect(refused.ok).toBe(false) + const message = refused.ok ? '' : refused.refusal.message + expect(message).toContain('process 12546 on local') + expect(message).not.toContain('The session store refused this call') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts new file mode 100644 index 00000000000..3e71b414cbc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -0,0 +1,160 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' +import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' +import { AgentSessionSubscribers } from './structured-agent-session-subscribers' + +const SESSION = 'wire-admission-session' +const LARGE_TEXT = 'x'.repeat(250 * 1024) + +let root: string +let journal: AgentSessionJournal + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-admission-')) + journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + journalDir: root, + autoCompact: false + }) + for (let ordinal = 1; ordinal <= 20; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) + } +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('structured agent-session outbound admission', () => { + it('admits bounded initial, handoff, epoch, compaction, and history recovery frames', async () => { + expect(Buffer.byteLength(JSON.stringify(journal.snapshot()), 'utf8')).toBeGreaterThan( + REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES + ) + + const subscribers = new AgentSessionSubscribers() + const initial: AgentSessionSubscribeEvent[] = [] + const dispose = subscribers.open({ + id: 'initial', + sessionId: SESSION, + journal, + fence: 1, + emit: (event) => initial.push(event) + }) + expect(initial).toHaveLength(1) + expect(initial[0]).toMatchObject({ type: 'snapshot', page: { hasOlder: true } }) + expectAdmitted(initial[0]) + + subscribers.handoff(SESSION, 2, { + owner: 'native', + direction: 'to-tui', + phase: 'switching', + stage: 'preparing', + operationId: 'handoff-1' + }) + subscribers.snapshot(SESSION, journal, 2) + expect(initial.slice(1)).toHaveLength(2) + initial.slice(1).forEach(expectAdmitted) + + const epochReset: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'old-epoch', + sessionId: SESSION, + journal, + fence: 2, + cursor: { epoch: 'retired-epoch', sequence: 1 }, + emit: (event) => epochReset.push(event) + }) + expect(epochReset[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed' }) + expectAdmitted(epochReset[0]) + const epochHistory = readAgentSessionHistory(journal, { + sessionId: SESSION, + direction: 'before', + cursor: { epoch: 'retired-epoch', sequence: 1 } + }) + expect(epochHistory).toMatchObject({ ok: false, reset: 'epoch_changed' }) + expectAdmitted(epochHistory) + + await journal.compact(Date.now() + 1, { minTailRows: 0, retainTailMs: 0 }) + const compactedReset: AgentSessionSubscribeEvent[] = [] + subscribers.open({ + id: 'compacted', + sessionId: SESSION, + journal, + fence: 2, + cursor: { epoch: journal.epoch, sequence: 0 }, + emit: (event) => compactedReset.push(event) + }) + expect(compactedReset[0]).toMatchObject({ type: 'reset', reset: 'cursor_compacted' }) + expectAdmitted(compactedReset[0]) + + const history = readAgentSessionHistory(journal, { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: journal.epoch, sequence: 0 } + }) + expect(history).toMatchObject({ ok: false, reset: 'cursor_compacted' }) + expectAdmitted(history) + expect(initial.some((event) => event.type === 'end')).toBe(false) + dispose() + }) + + it('splits valid-cursor catch-up into admitted batch frames', () => { + const subscribers = new AgentSessionSubscribers() + const catchup: AgentSessionSubscribeEvent[] = [] + const firstItemSequence = journal.snapshot().items[0]!.sequence + + subscribers.open({ + id: 'catchup', + sessionId: SESSION, + journal, + fence: 2, + cursor: { epoch: journal.epoch, sequence: firstItemSequence }, + emit: (event) => catchup.push(event) + }) + + expect(catchup.length).toBeGreaterThan(1) + catchup.forEach(expectAdmitted) + expect( + catchup.flatMap((event) => (event.type === 'batch' ? event.batch.items : [])) + ).toHaveLength(19) + expect(catchup.at(-1)).toMatchObject({ + type: 'batch', + batch: { cursor: journal.cursor() }, + fence: 2 + }) + }) +}) + +function expectAdmitted(value: unknown): void { + const frame = JSON.stringify({ id: 'request-1', result: value }) + const bytes = mobileE2EETextPayloadAdmissionBytes(frame) + expect(Number.isFinite(bytes)).toBe(true) + expect(bytes).toBeLessThanOrEqual(REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES) +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} diff --git a/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts new file mode 100644 index 00000000000..ad211902433 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-provider-session-ownership.ts @@ -0,0 +1,24 @@ +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' + +export type StructuredProviderSessionOwnership = { + sessionId: string + workspaceId: string + provider: 'claude' | 'codex' + providerSessionId: string + lease: AgentSessionLease +} + +export function listStructuredProviderSessionOwnership( + records: readonly AgentSessionRecord[] +): StructuredProviderSessionOwnership[] { + return records.flatMap((record) => + record.providerHandleChain.map((link) => ({ + sessionId: record.sessionId, + workspaceId: record.location.workspaceId, + provider: record.provider, + providerSessionId: + link.handle.provider === 'codex' ? link.handle.threadId : link.handle.sessionId, + lease: record.lease + })) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts new file mode 100644 index 00000000000..704f05fad60 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-boundary.ts @@ -0,0 +1,60 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { durableWriteTempPath, writeFileDurable } from '../../durable-file-write' + +const BOUNDARY_FILE = 'structured-tui-transcript-boundary.json' +const BOUNDARY_SCHEMA_VERSION = 1 + +export type StructuredTuiTranscriptBoundary = { + providerSessionId: string + runtimeFence: number + filePath: string | null + offset: number +} + +function boundaryPath(journalDirectory: string): string { + return join(journalDirectory, BOUNDARY_FILE) +} + +export async function readStructuredTuiTranscriptBoundary( + journalDirectory: string +): Promise { + let parsed: unknown + try { + parsed = JSON.parse(await readFile(boundaryPath(journalDirectory), 'utf8')) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') { + return null + } + const value = parsed as Record + if ( + value.schemaVersion !== BOUNDARY_SCHEMA_VERSION || + typeof value.providerSessionId !== 'string' || + !Number.isSafeInteger(value.runtimeFence) || + (value.filePath !== null && typeof value.filePath !== 'string') || + !Number.isSafeInteger(value.offset) || + (value.offset as number) < 0 + ) { + return null + } + return { + providerSessionId: value.providerSessionId, + runtimeFence: value.runtimeFence as number, + filePath: value.filePath as string | null, + offset: value.offset as number + } +} + +export async function writeStructuredTuiTranscriptBoundary( + journalDirectory: string, + boundary: StructuredTuiTranscriptBoundary +): Promise { + const filePath = boundaryPath(journalDirectory) + await writeFileDurable( + durableWriteTempPath(filePath), + filePath, + JSON.stringify({ schemaVersion: BOUNDARY_SCHEMA_VERSION, ...boundary }) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts new file mode 100644 index 00000000000..b94d671fa9a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.test.ts @@ -0,0 +1,162 @@ +import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-catchup' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +let root: string +let store: AgentSessionRecordStore + +function rolloutLine(message: string): string { + return `${JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-11T10:00:00.000Z', + payload: { type: 'agent_message', message } + })}\n` +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-tui-catchup-')) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function createCatchupFixture() { + const accountHome = join(root, 'isolated-codex-home') + const sessionsDir = join(accountHome, 'sessions', '2026', '08', '11') + const rollout = join(sessionsDir, `rollout-2026-08-11T10-00-00-${THREAD}.jsonl`) + await mkdir(sessionsDir, { recursive: true }) + await writeFile(rollout, rolloutLine('before handoff'), 'utf8') + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: accountHome }, + runtimeKind: 'tui', + expectedFence: null, + spawnToken: 'tui-token', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'test', + operationId: `${NOW}-${'1'.padStart(32, '0')}`, + fingerprint: 'initial' + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4200, + processStartTimeMs: NOW - 1_000, + spawnToken: 'tui-token' + }, + now: NOW + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'tui-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir: join(root, 'journal') + }) + return { fence, journal, rollout } +} + +function createCatchup(input: Awaited>) { + return new StructuredTuiTranscriptCatchup({ + store, + session: () => ({ + hasProviderChild: false, + journal: input.journal, + params: {} as never, + fence: input.fence + }), + schedule: async (_sessionId, task) => task(), + publish: vi.fn(), + reset: vi.fn() + }) +} + +describe('StructuredTuiTranscriptCatchup', () => { + it('tails only TUI-era appends from the durable account home', async () => { + const fixture = await createCatchupFixture() + const catchup = createCatchup(fixture) + + await catchup.prepare(SESSION, fixture.fence) + await catchup.activate(SESSION) + expect(fixture.journal.snapshot().items).toEqual([]) + + await appendFile(fixture.rollout, rolloutLine('during TUI'), 'utf8') + await vi.waitFor(() => + expect(fixture.journal.snapshot().items.map((item) => item.body)).toContainEqual({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'during TUI' }] + }) + ) + + catchup.stop(SESSION) + await appendFile(fixture.rollout, rolloutLine('after stop'), 'utf8') + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(fixture.journal.snapshot().items).toHaveLength(1) + }) + + it('replays transcript writes made while the host watcher was down', async () => { + const fixture = await createCatchupFixture() + const beforeCrash = createCatchup(fixture) + await beforeCrash.prepare(SESSION, fixture.fence) + await beforeCrash.activate(SESSION) + await appendFile(fixture.rollout, rolloutLine('before host crash'), 'utf8') + await vi.waitFor(() => expect(fixture.journal.snapshot().items).toHaveLength(1)) + beforeCrash.stopAll() + + await appendFile(fixture.rollout, rolloutLine('while host was down'), 'utf8') + const recovered = createCatchup(fixture) + await recovered.recover(SESSION, fixture.fence) + await recovered.activate(SESSION) + + const text = fixture.journal + .snapshot() + .items.flatMap((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + expect(text).toEqual(['before host crash', 'while host was down']) + recovered.stopAll() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts new file mode 100644 index 00000000000..cc343c9231c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-tui-transcript-catchup.ts @@ -0,0 +1,266 @@ +import { stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { NativeChatMessage } from '../../../shared/native-chat-types' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + appendLegacyTranscriptMessages, + importLegacyTranscriptIntoJournal +} from '../agent-session-journal/journal-legacy-import' +import { resolveSessionFilePath } from '../session-file-resolver' +import { + readIncrementalTranscriptMessages, + type IncrementalTranscriptState +} from '../transcript-incremental-reader' +import { nativeChatLineDecoderForAgent } from '../transcript-tail-reader' +import { + subscribeNativeChatTranscript, + type NativeChatTranscriptSubscription +} from '../transcript-watch' +import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { + readStructuredTuiTranscriptBoundary, + writeStructuredTuiTranscriptBoundary +} from './structured-tui-transcript-boundary' + +type CatchupState = { + active: boolean + fence: number + agent: AgentSessionHandleProvider + providerSessionId: string + pending: NativeChatMessage[] + seen: Set + subscription: NativeChatTranscriptSubscription | null +} + +export class StructuredTuiTranscriptCatchup { + private readonly states = new Map() + + constructor( + private readonly input: { + store: AgentSessionRecordStore + session: (sessionId: string) => StructuredAgentSessionHostSession + schedule: (sessionId: string, task: () => Promise) => Promise + publish: (sessionId: string) => void + reset: (sessionId: string, fence: number) => void + onError?: (input: { sessionId: string; error: unknown }) => void + } + ) {} + + async prepare(sessionId: string, fence: number): Promise { + await this.start(sessionId, fence, false) + } + + async recover(sessionId: string, fence: number): Promise { + await this.start(sessionId, fence, true) + } + + private async start(sessionId: string, fence: number, recovering: boolean): Promise { + this.stop(sessionId) + const record = this.input.store.getRecord(sessionId) + const head = record?.providerHandleChain.at(-1) + if ( + !record || + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { + return + } + const agent = head.handle.provider + const providerSessionId = agent === 'claude' ? head.handle.sessionId : head.handle.threadId + const journal = this.input.session(sessionId).journal + const transcriptOptions = + agent === 'claude' + ? { claudeProjectsDir: join(record.accountHome.path, 'projects') } + : { codexSessionsDirs: [join(record.accountHome.path, 'sessions')] } + const boundary = recovering + ? await readStructuredTuiTranscriptBoundary(journal.directory) + : null + const filePath = await resolveSessionFilePath(agent, providerSessionId, { + ...transcriptOptions, + ...(boundary?.filePath ? { transcriptPath: boundary.filePath } : {}) + }) + let initialReady: (() => void) | null = null + let baselineOffset = 0 + const ready = filePath ? new Promise((resolve) => (initialReady = resolve)) : null + const state: CatchupState = { + active: false, + fence, + agent, + providerSessionId, + pending: [], + seen: new Set(), + subscription: null + } + const receive = (messages: NativeChatMessage[]) => this.receive(sessionId, state, messages) + this.states.set(sessionId, state) + try { + state.subscription = await subscribeNativeChatTranscript({ + agent, + sessionId: providerSessionId, + ...transcriptOptions, + ...(filePath ? { filePath, initialLimit: 0 } : {}), + onInitialSnapshot: (messages, _hasMore, beforeOffset) => { + baselineOffset = beforeOffset + receive(messages) + initialReady?.() + initialReady = null + }, + onAppend: receive + }) + await ready + if (!recovering) { + await writeStructuredTuiTranscriptBoundary(journal.directory, { + providerSessionId, + runtimeFence: fence, + filePath, + offset: baselineOffset + }) + } else if ( + filePath && + boundary?.providerSessionId === providerSessionId && + boundary.runtimeFence === fence && + boundary.filePath === filePath + ) { + await this.readRecoveryGap(sessionId, state, filePath, boundary.offset) + } else if (filePath) { + const imported = await importLegacyTranscriptIntoJournal({ + journal, + agent, + sessionId: providerSessionId, + fence, + options: { filePath, decodedMessageIdentities: true } + }) + if (!imported.ok) { + throw new Error(imported.error) + } + this.input.reset(sessionId, fence) + } + } catch (error) { + if (this.states.get(sessionId) === state) { + this.states.delete(sessionId) + } + state.subscription?.unsubscribe() + throw error + } + } + + private async readRecoveryGap( + sessionId: string, + state: CatchupState, + filePath: string, + offset: number + ): Promise { + let size: number + try { + size = (await stat(filePath)).size + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + const start = offset <= size ? offset : 0 + const incremental: IncrementalTranscriptState = { + offset: start, + pendingChunks: [], + pendingStart: start, + pendingBytes: 0, + droppingOversizedRecord: false + } + const decode = nativeChatLineDecoderForAgent(state.agent) + if (!decode) { + throw new Error('Transcript unavailable') + } + let messages: NativeChatMessage[] + try { + messages = await readIncrementalTranscriptMessages(filePath, incremental, decode) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + this.receive(sessionId, state, messages) + } + + async activate(sessionId: string): Promise { + const state = this.states.get(sessionId) + if (!state) { + return + } + state.active = true + const pending = state.pending.splice(0) + await this.append(sessionId, state, pending) + } + + stop(sessionId: string): void { + const state = this.states.get(sessionId) + this.states.delete(sessionId) + state?.subscription?.unsubscribe() + } + + stopAll(): void { + for (const sessionId of this.states.keys()) { + this.stop(sessionId) + } + } + + private receive(sessionId: string, state: CatchupState, messages: NativeChatMessage[]): void { + if (this.states.get(sessionId) !== state) { + return + } + if (!state.active) { + state.pending.push(...messages) + return + } + void this.input + .schedule(sessionId, () => this.append(sessionId, state, messages)) + .catch((error) => this.input.onError?.({ sessionId, error })) + } + + private async append( + sessionId: string, + state: CatchupState, + messages: NativeChatMessage[] + ): Promise { + if (this.states.get(sessionId) !== state) { + return + } + const record = this.input.store.getRecord(sessionId) + if ( + !record || + record.lease.runtimeKind !== 'tui' || + record.lease.claimStatus !== 'live' || + record.lease.runtimeFence !== state.fence + ) { + return + } + const ids = new Set(state.seen) + const fresh = messages.filter((message) => { + if (ids.has(message.id)) { + return false + } + ids.add(message.id) + return true + }) + if (fresh.length === 0) { + return + } + try { + await appendLegacyTranscriptMessages({ + journal: this.input.session(sessionId).journal, + agent: state.agent, + sessionId: state.providerSessionId, + fence: state.fence, + messages: fresh + }) + for (const message of fresh) { + state.seen.add(message.id) + } + this.input.publish(sessionId) + } catch (error) { + this.input.onError?.({ sessionId, error }) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts new file mode 100644 index 00000000000..d287838a13c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' +import { projectStructuredItemToNativeChat } from '../../../shared/structured-agent-session-projection' +import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame' + +describe('unhandled provider frame journal fallback', () => { + it('keeps a compact label and bounds the expandable payload without dropping it', () => { + const item = unhandledProviderFrameJournalItem( + 'future-provider', + 'notification:new/event', + { body: 'abcdefghij' }, + { + inlineHeadBytes: 8, + maxSessionBytes: 1024, + maxAppendsPerWindow: 10, + appendWindowMs: 1000 + } + ) + + expect(item).not.toBeNull() + if (!item) { + throw new Error('expected substantive provider frame') + } + expect(item.body).toMatchObject({ + kind: 'status', + text: 'future-provider · notification:new/event', + providerFrame: { + provider: 'future-provider', + kind: 'notification:new/event', + payload: { byteLength: 21, truncated: true } + } + }) + expect( + Buffer.byteLength(item.body.providerFrame?.payload.head ?? '', 'utf8') + ).toBeLessThanOrEqual(8) + expect(item.blobs).toEqual([ + { + digest: item.body.providerFrame?.payload.digest, + payload: '{"body":"abcdefghij"}' + } + ]) + }) + + it('turns an unserializable message-shaped payload into an explicit visible value', () => { + const cyclic: { warning?: unknown } = {} + cyclic.warning = cyclic + + const item = unhandledProviderFrameJournalItem('codex', 'frame', cyclic) + + expect(item?.body.text).toBe('codex · frame') + expect(item?.body.providerFrame?.payload.head).toContain('unserializable payload') + }) + + it('routes provider lifecycle, startup, and status frames away from the timeline', () => { + expect(unhandledProviderFrameJournalItem('codex', 'notification:thread/started', {})).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', {}) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:remoteControl/status/changed', {}) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:thread/tokenUsage/updated', {}) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:thread/goal/cleared', {}) + ).toBeNull() + expect(unhandledProviderFrameJournalItem('claude', 'message:system:init', {})).toBeNull() + expect( + unhandledProviderFrameJournalItem('claude', 'message:result', { + subtype: 'success', + is_error: false + }) + ).toBeNull() + }) + + it('never creates generic rows for delta-shaped frames that report no failure', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:item/commandExecution/outputDelta', { + itemId: 'exec-1', + delta: 'x' + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', 'notification:item/future/outputDelta', { + itemId: 'future-1', + delta: 'y' + }) + ).toBeNull() + }) + + it('surfaces an unknown delta-shaped frame whose payload reports an error', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:item/future/outputDelta', { + error: 'stream broke mid-item' + }) + + expect(row).not.toBeNull() + expect(row?.classification).toBe('error-surface') + expect(row?.body.providerFrame).toMatchObject({ + provider: 'codex', + kind: 'notification:item/future/outputDelta' + }) + }) + + it('renders codex systemError and Claude error result variants', () => { + const codex = unhandledProviderFrameJournalItem('codex', 'notification:thread/status/changed', { + threadId: 'thread-1', + status: { type: 'systemError' } + }) + const claude = unhandledProviderFrameJournalItem('claude', 'message:result', { + subtype: 'error_during_execution', + is_error: true, + result: 'Provider request failed' + }) + + expect(codex?.body.providerFrame).toMatchObject({ + provider: 'codex', + kind: 'notification:thread/status/changed' + }) + expect(claude?.body.providerFrame).toMatchObject({ + provider: 'claude', + kind: 'message:result' + }) + expect( + claude + ? projectStructuredItemToNativeChat({ + itemId: 'claude-error', + revision: 1, + sequence: 1, + observedAt: 1, + body: claude.body + }) + : null + ).toMatchObject({ + role: 'system', + blocks: [ + expect.objectContaining({ + providerFrame: expect.objectContaining({ kind: 'message:result' }) + }) + ] + }) + }) + + it('keeps failed startup variants visible while suppressing startup progress', () => { + const kind = 'notification:mcpServer/startupStatus/updated' + + expect( + unhandledProviderFrameJournalItem('codex', kind, { + name: 'filesystem', + status: 'starting', + error: null, + failureReason: null + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', kind, { + name: 'filesystem', + status: 'failed', + error: 'server exited', + failureReason: null + }) + ).not.toBeNull() + }) + + it('surfaces a failed hook completion while suppressing successful hook lifecycle', () => { + const kind = 'notification:hook/completed' + + expect( + unhandledProviderFrameJournalItem('codex', kind, { + run: { id: 'hook-1', status: 'completed' } + }) + ).toBeNull() + expect( + unhandledProviderFrameJournalItem('codex', kind, { + run: { id: 'hook-1', status: 'failed' } + }) + ).not.toBeNull() + }) + + it('keeps unknown substantive frames visible for both providers', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:future/event', {}) + ).not.toBeNull() + expect(unhandledProviderFrameJournalItem('claude', 'message:future/event', {})).not.toBeNull() + }) + + it('leads with the provider sentence instead of naming the opcode', () => { + const row = unhandledProviderFrameJournalItem('codex', 'notification:warning', { + message: 'Your plan limit resets in 2 hours.' + }) + expect(row?.body.text).toBe('Your plan limit resets in 2 hours.') + // The raw frame stays available behind the row's disclosure. + expect(row?.body.providerFrame?.kind).toBe('notification:warning') + }) + + it('bounds a provider sentence inline', () => { + const message = 'abcdefghij' + const row = unhandledProviderFrameJournalItem( + 'codex', + 'notification:warning', + { message }, + { + inlineHeadBytes: 8, + maxSessionBytes: 1024, + maxAppendsPerWindow: 10, + appendWindowMs: 1000 + } + ) + + expect(row?.body.text).toContain('abcdefgh') + expect(row?.body.text).toContain('[Orca: output truncated') + }) + + it('unwraps a nested sentence and falls back to the opcode when there is none', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:warning', { + warning: { text: 'Sandbox is degraded.' } + })?.body.text + ).toBe('Sandbox is degraded.') + expect( + unhandledProviderFrameJournalItem('codex', 'notification:future/event', { count: 3 })?.body + .text + ).toBe('codex \u00b7 notification:future/event') + }) +}) + +describe('a failed provider dependency', () => { + it('leads with the failure the provider reported, not the method name', () => { + const item = unhandledProviderFrameJournalItem( + 'codex', + 'notification:mcpServer/startupStatus/updated', + { + threadId: 'thread-1', + name: 'codex_apps', + status: 'failed', + error: 'MCP client for `codex_apps` failed to start: authentication token invalidated', + failureReason: 'reauthenticationRequired' + } + ) + expect(item?.classification).toBe('error-surface') + expect(item?.body.text).toContain('failed to start') + expect(item?.body.text).not.toContain('notification:mcpServer') + }) + + it('stays out of the timeline while the dependency is merely starting', () => { + expect( + unhandledProviderFrameJournalItem('codex', 'notification:mcpServer/startupStatus/updated', { + threadId: 'thread-1', + name: 'codex_apps', + status: 'starting' + }) + ).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts new file mode 100644 index 00000000000..b4651cfc952 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/unhandled-provider-frame.ts @@ -0,0 +1,104 @@ +import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS, + type JournalPayloadLimits +} from '../agent-session-journal/journal-payload-bounds' +import { classifyProviderFrame } from './provider-frame-disposition' + +export type UnhandledProviderFrameJournalItem = { + body: AgentJournalStatusItem + blobs: { digest: string; payload: string }[] + /** Why the frame surfaced. Error frames are exempt from generic-row caps. */ + classification: 'timeline-substantive' | 'error-surface' +} + +function serializeProviderPayload(payload: unknown): string { + try { + const serialized = JSON.stringify(payload) + return serialized === undefined ? String(payload) : serialized + } catch (error) { + return `[unserializable payload: ${error instanceof Error ? error.message : String(error)}]` + } +} + +/** Fields providers use for the human-facing sentence on a frame, most specific + * first. Nested one level because warnings arrive wrapped as often as not. */ +const MESSAGE_KEYS = [ + 'message', + 'text', + 'warning', + 'detail', + 'description', + 'reason', + // `error` is how a failed dependency reports itself — an MCP server that could not start says + // so here and nowhere else. Without it the row falls back to the bare method name, which is how + // "MCP server X failed to start: auth expired" reached users as `notification:mcpServer/...`. + 'error' +] as const + +function directReadableMessage(payload: unknown): string | null { + if (typeof payload === 'string') { + return payload.trim() || null + } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return null + } + const record = payload as Record + for (const key of MESSAGE_KEYS) { + const value = record[key] + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim() + } + } + return null +} + +function readableMessage(payload: unknown): string | null { + const direct = directReadableMessage(payload) + if (direct || typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return direct + } + const record = payload as Record + for (const key of MESSAGE_KEYS) { + const nested = directReadableMessage(record[key]) + if (nested) { + return nested + } + } + return null +} + +/** Substantive adapter fallbacks become visible, bounded journal rows. */ +export function unhandledProviderFrameJournalItem( + provider: string, + kind: string, + payload: unknown, + limits: JournalPayloadLimits = DEFAULT_JOURNAL_PAYLOAD_LIMITS +): UnhandledProviderFrameJournalItem | null { + const classification = classifyProviderFrame(provider, kind, payload) + if ( + classification === 'stream-into-item' || + classification === 'status-chrome' || + classification === 'suppressed-benign' + ) { + return null + } + const serialized = serializeProviderPayload(payload) + const bounded = boundPayload(serialized, limits) + // Why: the opcode alone ("codex · notification:warning") tells the user nothing + // and reads as protocol noise. Lead with the provider's own sentence when it has + // one; the raw frame stays behind the row's disclosure either way. + const message = readableMessage(payload) + const display = message ? boundInlineText(message, limits) : null + return { + body: { + kind: 'status', + text: display?.text ?? `${provider} · ${kind}`, + providerFrame: { provider, kind, payload: bounded } + }, + blobs: bounded.truncated ? [{ digest: bounded.digest, payload: serialized }] : [], + classification: classification === 'error-surface' ? 'error-surface' : 'timeline-substantive' + } +} diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 98aa1f4abad..584d8a25a9d 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -3,7 +3,8 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { resolveSessionFilePath } from './session-file-resolver' +import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' +import { readClaudeTranscriptLeafUuid, resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -27,6 +28,117 @@ function restoreEnv(key: string, previous: string | undefined): void { } describe('resolveSessionFilePath', () => { + it('reads Claude last-prompt leaf metadata as the durable branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'leaf-old', parentUuid: null, sessionId: 'session-1' }, + { + type: 'assistant', + uuid: 'leaf-current', + parentUuid: 'leaf-old', + sessionId: 'session-1' + }, + { type: 'last-prompt', leafUuid: 'leaf-current', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'leaf-old')).resolves.toBe( + 'leaf-current' + ) + }) + + it('fails closed when a Claude transcript has no branch marker', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-no-leaf-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + '{"type":"assistant","uuid":"not-a-leaf","parentUuid":null,"sessionId":"session-1"}\n', + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toThrow( + 'missing last-prompt marker' + ) + }) + + it('distinguishes an incomplete final Claude JSONL record from durable malformed content', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-torn-tail-') + const transcript = join(root, 'session.jsonl') + await writeFile(transcript, '{"type":"last-prompt"', 'utf8') + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + + await writeFile(transcript, '{"type":"last-prompt"\n', 'utf8') + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1')).rejects.not.toBeInstanceOf( + ClaudeTranscriptTailIncompleteError + ) + }) + + it('refuses a Claude marker on a sibling branch', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-sibling-') + const transcript = join(root, 'session.jsonl') + await writeFile( + transcript, + [ + { type: 'user', uuid: 'root', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'expected', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'system', uuid: 'sibling', parentUuid: 'root', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'sibling', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(transcript, 'session-1', 'expected')).rejects.toThrow( + 'sibling branch' + ) + }) + + it('refuses missing and cyclic Claude parent chains', async () => { + const root = await makeRoot('orca-native-chat-resolve-claude-invalid-ancestry-') + const missing = join(root, 'missing.jsonl') + const cycle = join(root, 'cycle.jsonl') + await writeFile( + missing, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'leaf', parentUuid: 'absent', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'leaf', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + await writeFile( + cycle, + [ + { type: 'user', uuid: 'expected', parentUuid: null, sessionId: 'session-1' }, + { type: 'assistant', uuid: 'left', parentUuid: 'right', sessionId: 'session-1' }, + { type: 'system', uuid: 'right', parentUuid: 'left', sessionId: 'session-1' }, + { type: 'last-prompt', leafUuid: 'right', sessionId: 'session-1' } + ] + .map((record) => JSON.stringify(record)) + .join('\n'), + 'utf8' + ) + + await expect(readClaudeTranscriptLeafUuid(missing, 'session-1', 'expected')).rejects.toThrow( + 'missing ancestor absent' + ) + await expect(readClaudeTranscriptLeafUuid(cycle, 'session-1', 'expected')).rejects.toThrow( + 'cycle in parentUuid ancestry' + ) + }) + it('globs Claude project subdirs for .jsonl', async () => { const root = await makeRoot('orca-native-chat-resolve-claude-') const claudeProjectsDir = join(root, 'claude-projects') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index a45850f2d46..aa3d781fb2d 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -17,6 +17,7 @@ import { import { toHostReadableTranscriptPath, wslCodexSessionsDirs } from './host-readable-transcript-path' import { findWslCodexSessionPath } from './wsl-codex-session-path-scan' import { wslTranscriptFsRefusal, type WslTranscriptFsError } from './wsl-transcript-fs-gate' +import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -123,6 +124,21 @@ export async function resolveSessionFilePath( return resolved } +/** Read and validate Claude's authoritative transcript branch marker. */ +export async function readClaudeTranscriptLeafUuid( + transcriptPath: string, + providerSessionId: string, + previousLeafUuid: string | null = null +): Promise { + return ( + await proveClaudeTranscriptBranch({ + transcriptPath, + providerSessionId, + previousLeafUuid + }) + ).leafUuid +} + async function resolveSessionFileById( transcriptAgent: NativeChatTranscriptAgent, sessionId: string, diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts index c6a5ff3adda..a7a86662809 100644 --- a/src/main/plugins/plugin-host-methods.test.ts +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods' +import { AgentSessionPtyWriteRefusedError } from '../../shared/agent-session-pty-write-admission' function createServices(storageSet: PluginHostServices['storage']['set']): PluginHostServices { return { @@ -232,3 +233,34 @@ describe('terminal.sendText explicit worktree routing', () => { expect(delegate.listTerminals).toHaveBeenCalledTimes(1) }) }) + +describe('terminal.sendText under a refusing agent-session lease', () => { + it('reports who holds the session instead of an accepted-looking result', async () => { + const { delegate, services } = createTerminalHarness(['terminal:local:one']) + vi.mocked(delegate.sendTerminal).mockRejectedValue( + new AgentSessionPtyWriteRefusedError({ + code: 'agent_session_conflict', + sessionId: 'session-alpha-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7 + }) + ) + + const outcome = await sendTerminalText(services, 'terminal:local:one') + + expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) + expect(outcome.ok ? '' : outcome.error).toContain('session-alpha-1') + expect(outcome.ok ? '' : outcome.error).toContain('native chat') + }) + + it('sends unchanged when no lease refuses, which is every plugin send today', async () => { + const { delegate, services } = createTerminalHarness(['terminal:local:one']) + + const outcome = await sendTerminalText(services, 'terminal:local:one') + + expect(outcome).toEqual({ ok: true, value: { accepted: true } }) + expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts index 266b819dbae..10dd2b87e56 100644 --- a/src/main/plugins/plugin-host-service-bindings.ts +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -3,6 +3,10 @@ import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-hos import type { PluginHostServices } from './plugin-host-methods' import { PluginSecretsStore } from './plugin-secrets-store' import { PluginKvStore } from './plugin-storage-store' +import { + describeAgentSessionPtyWriteRefusal, + isAgentSessionPtyWriteRefusedError +} from '../../shared/agent-session-pty-write-admission' /** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ export type PluginRuntimeDelegate = { @@ -59,8 +63,17 @@ export function bindPluginHostServices(input: { .map((terminal) => ({ id: terminal.handle })) }, sendTerminalText: async (terminalId, action) => { - const result = await delegate.sendTerminal(terminalId, action) - return { accepted: result.accepted } + try { + const result = await delegate.sendTerminal(terminalId, action) + return { accepted: result.accepted } + } catch (error) { + // Why: the plugin API carries only `accepted`, so a lease refusal would read as a silent + // drop; restate it as the message idiom plugin methods already surface to callers. + if (isAgentSessionPtyWriteRefusedError(error)) { + throw new Error(describeAgentSessionPtyWriteRefusal(error.refusal)) + } + throw error + } }, dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), storage: { diff --git a/src/main/providers/agent-foreground-process-pi.test.ts b/src/main/providers/agent-foreground-process-pi.test.ts index e5f92038f8d..691ee63e748 100644 --- a/src/main/providers/agent-foreground-process-pi.test.ts +++ b/src/main/providers/agent-foreground-process-pi.test.ts @@ -21,7 +21,7 @@ describe('Pi Windows foreground recognition', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) }) diff --git a/src/main/providers/agent-foreground-process.test.ts b/src/main/providers/agent-foreground-process.test.ts index 76de68fa0e8..d1784318df5 100644 --- a/src/main/providers/agent-foreground-process.test.ts +++ b/src/main/providers/agent-foreground-process.test.ts @@ -80,7 +80,7 @@ describe('resolveAgentForegroundProcess', () => { // Why: the Windows rows reader caches across calls (500ms TTL), so each // case's rows must not be answered by the previous case's snapshot. __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) platform = Object.getOwnPropertyDescriptor(process, 'platform') diff --git a/src/main/providers/pty-process-info.ts b/src/main/providers/pty-process-info.ts index a34746a6267..4700ab71059 100644 --- a/src/main/providers/pty-process-info.ts +++ b/src/main/providers/pty-process-info.ts @@ -4,6 +4,8 @@ import type { PtyIncarnationId } from '../../shared/pty-incarnation' export type PtyProcessInfo = { id: string incarnationId?: PtyIncarnationId + /** Root process owned by this exact PTY incarnation, when the provider can prove it. */ + rootProcessId?: number cwd: string title: string /** Owning worktree when the provider can report it authoritatively. */ diff --git a/src/main/providers/pty-process-list-admission.ts b/src/main/providers/pty-process-list-admission.ts index bca3bf4fa18..3ba9ca79456 100644 --- a/src/main/providers/pty-process-list-admission.ts +++ b/src/main/providers/pty-process-list-admission.ts @@ -60,6 +60,8 @@ export class PtyProcessListAdmission { worktreeIdBytes === null || terminalHandleBytes === null || wslDistroBytes === null || + (value.rootProcessId !== undefined && + (!Number.isSafeInteger(value.rootProcessId) || value.rootProcessId <= 0)) || (value.incarnationId !== undefined && !isPtyIncarnationId(value.incarnationId)) || (value.agentSessionOwners !== undefined && !Array.isArray(value.agentSessionOwners)) ) { @@ -108,6 +110,7 @@ export class PtyProcessListAdmission { cwd: value.cwd, title: value.title, ...(value.incarnationId !== undefined ? { incarnationId: value.incarnationId } : {}), + ...(value.rootProcessId !== undefined ? { rootProcessId: value.rootProcessId } : {}), ...(value.worktreeId !== undefined ? { worktreeId: value.worktreeId } : {}), ...(value.terminalHandle !== undefined ? { terminalHandle: value.terminalHandle } : {}), ...(value.wslDistro !== undefined ? { wslDistro: value.wslDistro } : {}), diff --git a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts index 6362f01f310..de6ffa94f6d 100644 --- a/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts +++ b/src/main/providers/windows-agent-foreground-process-scan-volume.test.ts @@ -62,7 +62,7 @@ describe('windows agent foreground inspection process-table scan volume', () => platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) vi.useFakeTimers({ toFake: ['Date'] }) diff --git a/src/main/providers/windows-foreground-process-rows.test.ts b/src/main/providers/windows-foreground-process-rows.test.ts index 3fa694e27b7..924c81789ce 100644 --- a/src/main/providers/windows-foreground-process-rows.test.ts +++ b/src/main/providers/windows-foreground-process-rows.test.ts @@ -53,7 +53,7 @@ describe('windows process rows', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) }) diff --git a/src/main/pty-descendant-exit-verification.ts b/src/main/pty-descendant-exit-verification.ts new file mode 100644 index 00000000000..c0ed223704a --- /dev/null +++ b/src/main/pty-descendant-exit-verification.ts @@ -0,0 +1,78 @@ +import { + DESCENDANT_KILL_GRACE_MS, + DESCENDANT_SNAPSHOT_TIMEOUT_MS, + hasUnambiguousStartIdentity, + readProcessTable, + readProcessTableBeforeDeadline, + sendDescendantSignal, + type DescendantSnapshot, + type ProcessTableRow, + type TerminateDeps +} from './pty-descendant-termination' + +export const DESCENDANT_KILL_VERIFY_MS = 3_500 + +function waitForDelay(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms) + timer.unref?.() + }) +} + +function matchingSnapshotRows( + snapshot: DescendantSnapshot, + table: readonly ProcessTableRow[] +): ProcessTableRow[] { + const expected = new Map(snapshot.descendants.map((row) => [row.pid, row])) + return table.filter((live) => { + const row = expected.get(live.pid) + return row?.startedAt === live.startedAt && row.pgid === live.pgid + }) +} + +type VerificationDeps = TerminateDeps & { + verifyMs?: number +} + +/** An unreadable process table is never proof that a stopped descendant exited. */ +export async function terminateDescendantSnapshotAndWait( + snapshot: DescendantSnapshot, + deps: VerificationDeps = {} +): Promise { + const sendSignal = deps.sendSignal ?? sendDescendantSignal + const readTable = deps.readTable ?? readProcessTable + const graceMs = deps.graceMs ?? DESCENDANT_KILL_GRACE_MS + const verifyMs = deps.verifyMs ?? DESCENDANT_KILL_VERIFY_MS + const deadline = Date.now() + verifyMs + for (const row of snapshot.descendants) { + sendSignal(row.pid, 'SIGTERM') + } + let forced = false + while (Date.now() < deadline) { + const capture = await readProcessTableBeforeDeadline( + readTable, + deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS + ) + if (!capture) { + return false + } + const live = matchingSnapshotRows(snapshot, capture.rows) + if (live.length === 0) { + return true + } + if (!forced && Date.now() >= deadline - verifyMs + graceMs) { + forced = true + for (const row of live) { + if (hasUnambiguousStartIdentity(row, snapshot.capturedAtMs)) { + sendSignal(row.pid, 'SIGKILL') + } + } + } + await waitForDelay(50) + } + const finalCapture = await readProcessTableBeforeDeadline( + readTable, + deps.timeoutMs ?? DESCENDANT_SNAPSHOT_TIMEOUT_MS + ) + return finalCapture !== null && matchingSnapshotRows(snapshot, finalCapture.rows).length === 0 +} diff --git a/src/main/pty-descendant-termination.test.ts b/src/main/pty-descendant-termination.test.ts index 85950d8e738..e1255a678d8 100644 --- a/src/main/pty-descendant-termination.test.ts +++ b/src/main/pty-descendant-termination.test.ts @@ -15,6 +15,7 @@ import { type ProcessTableCapture, type ProcessTableRow } from './pty-descendant-termination' +import { terminateDescendantSnapshotAndWait } from './pty-descendant-exit-verification' const CAPTURED_AT_MS = Date.parse('Tue Jul 14 12:00:00 2026') @@ -299,6 +300,49 @@ describe('terminateDescendantSnapshot', () => { }) }) +describe('terminateDescendantSnapshotAndWait', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('escalates an identity-matched survivor and verifies its exit', async () => { + const survivor = row(20, 10, 20) + const sendSignal = vi.fn() + const readTable = vi + .fn() + .mockResolvedValueOnce(tableCapture([survivor])) + .mockResolvedValueOnce(tableCapture([])) + + const pending = terminateDescendantSnapshotAndWait(snapshot([survivor]), { + sendSignal, + readTable, + graceMs: 0, + verifyMs: 200 + }) + await vi.advanceTimersByTimeAsync(50) + + await expect(pending).resolves.toBe(true) + expect(sendSignal.mock.calls).toEqual([ + [20, 'SIGTERM'], + [20, 'SIGKILL'] + ]) + }) + + it('does not claim exit when the verification table is unavailable', async () => { + const sendSignal = vi.fn() + const result = await terminateDescendantSnapshotAndWait(snapshot([row(20, 10, 20)]), { + sendSignal, + readTable: vi.fn().mockRejectedValue(new Error('ps exploded')) + }) + + expect(result).toBe(false) + expect(sendSignal).toHaveBeenCalledWith(20, 'SIGTERM') + }) +}) + describe('createProcessTableSnapshotReader', () => { it('coalesces same-turn teardown requests onto one fresh scan', async () => { const capture = tableCapture([row(10, 1, 10)]) diff --git a/src/main/pty-descendant-termination.ts b/src/main/pty-descendant-termination.ts index cb39f8ae027..c91bbdd9a20 100644 --- a/src/main/pty-descendant-termination.ts +++ b/src/main/pty-descendant-termination.ts @@ -120,9 +120,9 @@ export function createProcessTableSnapshotReader( } } -const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable) +export const readProcessTable = createProcessTableSnapshotReader(readFreshProcessTable) -function readProcessTableBeforeDeadline( +export function readProcessTableBeforeDeadline( readTable: ProcessTableReader, timeoutMs: number ): Promise { @@ -298,7 +298,7 @@ export async function killWithDescendantSweep( } } -function defaultSendSignal(pid: number, signal: NodeJS.Signals): void { +export function sendDescendantSignal(pid: number, signal: NodeJS.Signals): void { try { process.kill(pid, signal) } catch { @@ -306,14 +306,14 @@ function defaultSendSignal(pid: number, signal: NodeJS.Signals): void { } } -type TerminateDeps = { +export type TerminateDeps = { readTable?: ProcessTableReader sendSignal?: SignalSender graceMs?: number timeoutMs?: number } -function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { +export function hasUnambiguousStartIdentity(row: ProcessTableRow, capturedAtMs: number): boolean { const startedAtMs = Date.parse(row.startedAt) if (!Number.isFinite(startedAtMs)) { return false @@ -333,7 +333,7 @@ export function terminateDescendantSnapshot( snapshot: DescendantSnapshot, deps: TerminateDeps = {} ): void { - const sendSignal = deps.sendSignal ?? defaultSendSignal + const sendSignal = deps.sendSignal ?? sendDescendantSignal const readTable = deps.readTable ?? readProcessTable for (const row of snapshot.descendants) { sendSignal(row.pid, 'SIGTERM') diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts new file mode 100644 index 00000000000..7ad20397813 --- /dev/null +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -0,0 +1,144 @@ +import { + agentSessionOperationKey, + settleAgentSessionOperation, + type AgentSessionOperationOutcome +} from '../../shared/agent-session-operation-ledger' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { assertFence, withLease } from './agent-session-lease-transitions' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export type AgentSessionFailedAcquisitionSettlement = { + sessionId: string + fence: number + spawnToken: string + callerKey: string + operationId: string + outcome: Extract + exitProof: 'exit-proven' | 'processless' | 'unproven' + now: number +} + +export type AgentSessionFailedPostAcquisitionAttachmentSettlement = + AgentSessionFailedAcquisitionSettlement + +/** Liveness invariant: a settled attach never leaves its reservation in new-owner-proving. */ +export function settleFailedAgentSessionAcquisition( + state: AgentSessionStoreState, + args: AgentSessionFailedAcquisitionSettlement +): AgentSessionRecord { + const operation = state.operations.get(agentSessionOperationKey(args.callerKey, args.operationId)) + if (!operation || operation.outcome.status !== 'pending') { + throw new Error('agent_session_operation_conflict') + } + const record = state.records.get(args.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + const next = settleFailedLease(record, args) + state.records.set(args.sessionId, next) + state.operations = settleAgentSessionOperation(state.operations, args) + return next +} + +/** A proved native owner still is not publishable until its journal attaches. */ +export function settleFailedAgentSessionPostAcquisitionAttachment( + state: AgentSessionStoreState, + args: AgentSessionFailedPostAcquisitionAttachmentSettlement +): AgentSessionRecord { + const operation = state.operations.get(agentSessionOperationKey(args.callerKey, args.operationId)) + if (!operation || operation.outcome.status !== 'pending') { + throw new Error('agent_session_operation_conflict') + } + const record = state.records.get(args.sessionId) + if (!record) { + throw new Error('agent_session_identity_required') + } + assertFence(record.lease, args.fence) + if ( + record.lease.runtimeKind !== 'native' || + record.lease.claimStatus !== 'live' || + record.lease.handoffStage !== null || + record.lease.ownerProcess?.spawnToken !== args.spawnToken || + record.lease.reservedSpawnToken !== args.spawnToken || + record.lease.provenHandleLinkId === null + ) { + throw new Error('agent_session_ownership_unknown') + } + const next = + args.exitProof === 'unproven' + ? withLease(record, { + ...record.lease, + handoffStage: 'recovering', + handoffOperationId: null, + lastRenewedAt: args.now + }) + : withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: { + kind: 'exit-observed', + detail: 'post-acquisition cleanup proved no provider child remains', + observedAt: args.now + } + }) + state.records.set(args.sessionId, next) + state.operations = settleAgentSessionOperation(state.operations, args) + return next +} + +function settleFailedLease( + record: AgentSessionRecord, + args: AgentSessionFailedAcquisitionSettlement +): AgentSessionRecord { + assertFence(record.lease, args.fence) + if ( + record.lease.claimStatus !== 'reserved' || + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.reservedSpawnToken !== args.spawnToken || + record.lease.handoffOperationId !== args.operationId + ) { + throw new Error('agent_session_ownership_unknown') + } + if (args.exitProof === 'unproven') { + return withLease(record, { + ...record.lease, + handoffStage: record.lease.ownerProcess ? 'recovering' : 'manual-recovery', + // The operation is durably settled failed below; a lease still naming it would + // read as an in-flight transfer to every consumer that keys on the stage + id pair. + handoffOperationId: null, + lastRenewedAt: args.now + }) + } + return withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: + args.exitProof === 'processless' + ? { + kind: 'pid-absent', + detail: 'reservation failed before spawn', + observedAt: args.now + } + : { + // Cleanup proved no child of this attempt remains; it may never have spawned. + kind: 'exit-observed', + detail: 'acquisition cleanup proved no provider child remains', + observedAt: args.now + } + }) +} diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts new file mode 100644 index 00000000000..88c4ad8d720 --- /dev/null +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -0,0 +1,43 @@ +// Recovering the agent-session store from its backup, without minting a second writer. +// +// The backup is the previous committed generation. The commit that never landed may have granted a +// fence one higher than anything the backup records show, and `isAgentSessionFenceCurrent` compares +// with STRICT EQUALITY — so a next-fence of `recordFence + 1` would *equal* that lost grant and +// accept a writer holding it. `+2` strictly dominates it. +// +// The bound "one lost commit can advance a session's fence by at most 1" is what makes +2 enough. +// It holds because every mint site routes through `nextAgentSessionFence` and each performs one +// transition per transaction, and because the save path aborts rather than letting the primary +// advance past a stale backup. A batching refactor would break it silently, so it is pinned by a +// test. +// +// This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current +// fence is what an earlier version did, and it corrupted exactly the records it meant to save: a +// `live` lease means a provider handle proven at exactly `lease.runtimeFence`, asserted by +// `isValidAgentSessionRecord`, so a fence bumped without a re-proof — which cannot happen offline — +// made the record invalid, quarantined it on the next load, and dropped back to the same backup. +// +// Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), +// `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process +// that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading +// already marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe +// once transactions are admitted. Nulling that evidence is how you get two writers on one provider +// session; the fence protects the store, not the provider session. + +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +/** Strictly above any fence the lost commit could have granted for that session. */ +export const AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN = 2 + +export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { + for (const [sessionId, record] of state.records) { + const floor = record.lease.runtimeFence + AGENT_SESSION_BACKUP_RECOVERY_FENCE_MARGIN + state.records.set(sessionId, { + ...record, + lease: { + ...record.lease, + minimumNextFence: Math.max(floor, record.lease.minimumNextFence ?? 0) + } + }) + } +} diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts new file mode 100644 index 00000000000..18d1ec00bda --- /dev/null +++ b/src/main/runtime/agent-session-backup-recovery.test.ts @@ -0,0 +1,308 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { evaluateAgentSessionAcquisition } from '../../shared/agent-session-lease-adjudication' +import { isAgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + agentSessionStorePath, + loadAgentSessionStore, + saveAgentSessionStore +} from './agent-session-record-store-file' + +/** Reserve, observe the spawn, prove the handle: the only path to a `live` lease, whose invariant + * is that the head handle was minted at exactly the current fence. One store instance throughout, + * because reopening marks every lease unreconciled and refuses the next two steps. */ +async function seedLiveSession(sessionId: string): Promise { + const store = await openStore() + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'seed-live', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed-live' }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken: 'seed-live' + }, + now: NOW + }) + await store.proveOwner({ + sessionId, + fence, + link: { + linkId: 'link-live-1', + origin: 'created', + mintedAtFence: fence, + observedAt: NOW, + handle: { provider: 'codex', threadId: `thread-${sessionId}` } + }, + now: NOW + }) + return fence +} + +let root: string +let storePath: string + +const NOW = 1_800_000_000_000 + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-')) + storePath = agentSessionStorePath(root) + operations = 0 +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +async function openStore(): Promise { + return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) +} + +let operations = 0 + +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +async function seedSession(sessionId: string): Promise { + const store = await openStore() + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'seed', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed' }, + now: NOW + }) + return reserved.record.lease.runtimeFence +} + +describe('crash-safe store writes', () => { + it('never leaves the live path absent, and keeps a whole backup', async () => { + await seedSession('session-a') + const afterFirst = await readFile(storePath, 'utf-8') + expect(JSON.parse(afterFirst)).toBeTruthy() + + await seedSession('session-b') + // Both candidates parse: the previous generation was COPIED aside, not moved. + expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() + expect(JSON.parse(await readFile(`${storePath}.bak`, 'utf-8'))).toBeTruthy() + }) + + it('leaves no orphaned temp file behind', async () => { + await seedSession('session-a') + await seedSession('session-b') + const { readdir } = await import('node:fs/promises') + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it('aborts the save rather than advancing the primary past a stale backup', async () => { + await seedSession('session-a') + const committed = await readFile(storePath, 'utf-8') + const loaded = await loadAgentSessionStore(storePath, 'local') + // A directory in the backup's place makes the rotation fail the way a full or read-only + // disk would. The save must not publish a primary the backup can no longer match. + await rm(`${storePath}.bak`, { force: true }) + const { mkdir } = await import('node:fs/promises') + await mkdir(`${storePath}.bak`) + + await expect( + saveAgentSessionStore(storePath, loaded.state, { + primaryStatus: 'validated' + }) + ).rejects.toBeTruthy() + expect(await readFile(storePath, 'utf-8')).toBe(committed) + expect((await stat(`${storePath}.bak`)).isDirectory()).toBe(true) + }) +}) + +describe('recovery from the committed backup', () => { + it('completes the next transaction instead of refusing forever', async () => { + await seedSession('session-a') + await seedSession('session-b') + // The exact shape a real profile wedged in: backup only, no live file. + await rm(storePath, { force: true }) + + await expect(seedSession('session-c')).resolves.toBeGreaterThan(0) + expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() + }) + + it('never grants the fence the lost commit could already have handed out', async () => { + const fence = await seedSession('session-a') + // A second commit is what produces the backup; the first write has nothing to rotate. + await seedSession('session-b') + await rm(storePath, { force: true }) + + const store = await openStore() + // The floor lands in the first transaction after recovery, not at load. + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const record = store.getRecord('session-a') + expect(record).toBeTruthy() + + // The lost commit could have granted fence + 1. Adjudication must skip it, or two writers + // end up holding the same number under strict-equality comparison. + const granted = evaluateAgentSessionAcquisition({ + lease: { ...record!.lease, unreconciled: false, ownerProcess: null, claimStatus: 'released' }, + expectedFence: record!.lease.runtimeFence, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }) + expect(granted.decision).toBe('granted') + expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) + }) + + it('leaves recovered records valid, so the next load does not quarantine them', async () => { + await seedLiveSession('session-a') + await seedSession('session-b') + await rm(storePath, { force: true }) + + const store = await openStore() + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const record = store.getRecord('session-a') + expect(record?.lease.claimStatus).toBe('live') + // A `live` lease means a provider handle proven at exactly lease.runtimeFence. Recovery that + // rewrote the fence broke that, so the record failed validation, was quarantined on the next + // load, and dropped straight back to the same backup. + expect(isAgentSessionRecord(record)).toBe(true) + }) + + it('carries ownership evidence forward verbatim', async () => { + await seedSession('session-a') + await seedSession('session-b') + const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( + 'session-a' + )! + await rm(storePath, { force: true }) + + const store = await openStore() + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const after = store.getRecord('session-a')! + expect(after.lease.claimStatus).toBe(before.lease.claimStatus) + expect(after.lease.ownerProcess).toEqual(before.lease.ownerProcess) + expect(after.lease.handoffStage).toBe(before.lease.handoffStage) + // "Not currently owned" is expressed by unreconciled, not by erasing the evidence. + expect(after.lease.unreconciled).toBe(true) + }) + + // Recovery restores the previous COMMITTED generation; the lost commit is lost by definition. + // What must not happen is reconciliation dropping anything the backup did hold. + it('drops nothing the committed backup held', async () => { + await seedSession('session-a') + await seedSession('session-b') + const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state + await rm(storePath, { force: true }) + + const store = await openStore() + await store.retireClaimKey(`retire-${operationId()}`, NOW) + const after = (await loadAgentSessionStore(storePath, 'local')).state + expect([...after.records.keys()].sort()).toEqual([...before.records.keys()].sort()) + expect(after.operations.size).toBe(before.operations.size) + // Everything the backup held is still there; the transaction that drove recovery adds its own. + for (const key of before.retiredClaimKeys) { + expect(after.retiredClaimKeys).toContainEqual(key) + } + expect([...after.unreadableRecords.keys()]).toEqual([...before.unreadableRecords.keys()]) + }) +}) + +describe('a transient primary read failure is not recovery', () => { + it('refuses rather than falling back to a usable but older backup', async () => { + await seedSession('session-a') + // The second commit leaves a VALID backup, so a fallback would silently succeed with + // older state — the failure this guard exists to prevent. + await seedSession('session-b') + expect( + (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.has('session-a') + ).toBe(true) + + // A directory at the primary path fails the read with EISDIR, not ENOENT — the shape of a + // permission or IO fault. It says nothing about the primary's contents. + await rm(storePath, { force: true }) + const { mkdir } = await import('node:fs/promises') + await mkdir(storePath) + + await expect(loadAgentSessionStore(storePath, 'local')).rejects.toThrow( + 'agent_session_store_corrupt' + ) + }) +}) + +describe('the fence-step bound the +2 floor rests on', () => { + it('advances a session fence by exactly one per grant when no floor is set', () => { + const granted = evaluateAgentSessionAcquisition({ + lease: agentSessionLeaseFixture({ + runtimeFence: 7, + claimStatus: 'released', + ownerProcess: null, + provenHandleLinkId: null + }), + expectedFence: 7, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }) + // If a grant could ever advance by more than one, the +2 recovery floor would stop dominating + // the highest fence a single lost commit can have handed out. + expect(granted.decision === 'granted' && granted.nextFence).toBe(8) + }) + + it('honours a recovery floor that sits above the next step', () => { + const granted = evaluateAgentSessionAcquisition({ + lease: agentSessionLeaseFixture({ + runtimeFence: 7, + minimumNextFence: 9, + claimStatus: 'released', + ownerProcess: null, + provenHandleLinkId: null + }), + expectedFence: 7, + handoffOperationId: null, + probe: { outcome: 'reservation-unused' } + }) + expect(granted.decision === 'granted' && granted.nextFence).toBe(9) + }) +}) + +describe('a store that never existed', () => { + it('does not claim recovery', async () => { + await writeFile(join(root, 'unrelated.txt'), 'x', 'utf-8') + const loaded = await loadAgentSessionStore(storePath, 'local') + expect(loaded.storeFound).toBe(false) + expect(loaded.recoveredFromBackup).toBe(false) + }) +}) diff --git a/src/main/runtime/agent-session-claim-key-state.ts b/src/main/runtime/agent-session-claim-key-state.ts new file mode 100644 index 00000000000..f9fe77cbdc5 --- /dev/null +++ b/src/main/runtime/agent-session-claim-key-state.ts @@ -0,0 +1,46 @@ +import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export function isVerifiable( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): boolean { + const retired = state.retiredClaimKeys.find((entry) => entry.keyId === keyId) + return !retired || now - retired.retiredAt <= retentionMs +} + +export function markConflicted(record: AgentSessionRecord, now: number): AgentSessionRecord { + return { + ...record, + updatedAt: now, + // A conflicted key must remain conflicted after its observing process exits. + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + } +} + +export function retire( + state: AgentSessionStoreState, + keyId: string, + now: number, + retentionMs: number +): void { + if (!state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { + state.retiredClaimKeys.push({ keyId, retiredAt: now }) + } + state.retiredClaimKeys = state.retiredClaimKeys.filter( + (entry) => now - entry.retiredAt <= retentionMs + ) +} + +export function listOrphanSpawnTokens( + records: readonly AgentSessionRecord[], + observedTokens: readonly string[] +): string[] { + const leases = records.map((record) => record.lease) + return observedTokens.filter( + (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' + ) +} diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.test.ts b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts new file mode 100644 index 00000000000..5bd232c3041 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { recoverDeadTuiOwnerForHandoff } from './agent-session-handoff-lease-transitions' +import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' + +describe('agent session handoff restart transitions', () => { + it('turns a proven dead TUI owner into one durable retry owner', () => { + const operationId = '1800000000000-00000000000000000000000000000001' + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ runtimeKind: 'tui', runtimeFence: 3 }) + ) + + const next = recoverDeadTuiOwnerForHandoff({ + record, + expectedFence: 3, + operationId, + probe: { outcome: 'pid-absent' }, + now: 1_800_000_001_000 + }) + + expect(next.lease).toMatchObject({ + runtimeKind: 'tui', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId: operationId, + claimStatus: 'released', + ownerProcess: null, + deathEvidence: { kind: 'pid-absent' } + }) + }) + + it('preserves the stopped owner and operation for durable retry', () => { + const handoffOperationId = '1800000000000-00000000000000000000000000000001' + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null, + unreconciled: true + }) + ) + + const next = applyAgentSessionRestartAdjudication({ + record, + probe: { outcome: 'reservation-unused' }, + now: 1_800_000_001_000 + }) + + expect(next.lease).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'old-owner-stopped', + handoffOperationId, + claimStatus: 'released', + ownerProcess: null, + unreconciled: false + }) + }) +}) diff --git a/src/main/runtime/agent-session-handoff-lease-transitions.ts b/src/main/runtime/agent-session-handoff-lease-transitions.ts new file mode 100644 index 00000000000..894d7643abc --- /dev/null +++ b/src/main/runtime/agent-session-handoff-lease-transitions.ts @@ -0,0 +1,187 @@ +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { + assertFence, + evictAgentSessionOwner, + reserveAgentSessionOwner, + withLease +} from './agent-session-lease-transitions' + +export function recoverDeadTuiOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + probe: AgentSessionOwnerProbe + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.runtimeKind !== 'tui' || + record.lease.handoffStage !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + const evicted = evictAgentSessionOwner(args) + return withLease(evicted, { + ...evicted.lease, + handoffStage: 'old-owner-stopped', + handoffOperationId: args.operationId + }) +} + +export function stopAgentSessionOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'observed process exit', + observedAt: args.now + } + }) +} + +export function rollbackAgentSessionHandoffPreparation(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'preparing' || + record.lease.handoffOperationId !== args.operationId || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + lastRenewedAt: args.now + }) +} + +export function stopRecoveringTuiOwnerForHandoff(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + (record.lease.handoffStage !== 'recovering' && + record.lease.handoffStage !== 'manual-recovery') || + record.lease.runtimeKind !== 'tui' || + record.lease.handoffOperationId !== null || + record.lease.claimStatus !== 'live' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: 'old-owner-stopped', + handoffOperationId: args.operationId, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'recovery proved TUI process exit', + observedAt: args.now + } + }) +} + +export function reserveAgentSessionHandoffOwner(args: { + record: AgentSessionRecord + expectedFence: number + runtimeKind: AgentSessionOwnerRuntimeKind + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs: number +}): AgentSessionRecord { + return reserveAgentSessionOwner({ + record: args.record, + expectedFence: args.expectedFence, + probe: { outcome: 'reservation-unused' }, + reservation: { + runtimeKind: args.runtimeKind, + spawnToken: args.spawnToken, + claimKeyId: args.claimKeyId, + handoffOperationId: args.operationId, + leaseTtlMs: args.leaseTtlMs, + now: args.now + } + }).record +} + +export function abandonAgentSessionHandoffAttempt(args: { + record: AgentSessionRecord + expectedFence: number + operationId: string + recoverableRuntimeKind: AgentSessionOwnerRuntimeKind + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if ( + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.handoffOperationId !== args.operationId + ) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeKind: args.recoverableRuntimeKind, + runtimeFence: nextAgentSessionFence(record.lease), + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'handoff launch attempt stopped', + observedAt: args.now + } + }) +} diff --git a/src/main/runtime/agent-session-handoff-record-transitions.ts b/src/main/runtime/agent-session-handoff-record-transitions.ts new file mode 100644 index 00000000000..dd8e25531b7 --- /dev/null +++ b/src/main/runtime/agent-session-handoff-record-transitions.ts @@ -0,0 +1,109 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionHandoffStage } from '../../shared/agent-session-record' +import { + abandonAgentSessionHandoffAttempt, + recoverDeadTuiOwnerForHandoff, + reserveAgentSessionHandoffOwner, + rollbackAgentSessionHandoffPreparation, + stopAgentSessionOwnerForHandoff, + stopRecoveringTuiOwnerForHandoff +} from './agent-session-handoff-lease-transitions' +import { setAgentSessionHandoffStage } from './agent-session-lease-transitions' +import { + AGENT_SESSION_LEASE_TTL_MS, + type AgentSessionRecordStore +} from './agent-session-record-store' + +export function setStoredAgentSessionHandoffStage( + store: AgentSessionRecordStore, + args: { + sessionId: string + fence: number + stage: AgentSessionHandoffStage | null + handoffOperationId: string | null + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + setAgentSessionHandoffStage({ ...args, record }) + ) +} + +export function recoverStoredDeadTuiOwnerForHandoff( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + operationId: string + probe: AgentSessionOwnerProbe + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + recoverDeadTuiOwnerForHandoff({ ...args, record }) + ) +} + +export function stopStoredAgentSessionOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopAgentSessionOwnerForHandoff({ ...args, record }) + ) +} + +export function rollbackStoredAgentSessionHandoffPreparation( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + rollbackAgentSessionHandoffPreparation({ ...args, record }) + ) +} + +export function stopStoredRecoveringTuiOwnerForHandoff( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; operationId: string; now: number } +) { + return store.transitionHandoff(args.sessionId, (record) => + stopRecoveringTuiOwnerForHandoff({ ...args, record }) + ) +} + +export function reserveStoredAgentSessionHandoffOwner( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + runtimeKind: 'native' | 'tui' + spawnToken: string + operationId: string + claimKeyId: string + now: number + leaseTtlMs?: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + reserveAgentSessionHandoffOwner({ + ...args, + record, + leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS + }) + ) +} + +export function abandonStoredAgentSessionHandoffAttempt( + store: AgentSessionRecordStore, + args: { + sessionId: string + expectedFence: number + operationId: string + recoverableRuntimeKind: 'native' | 'tui' + now: number + } +) { + return store.transitionHandoff(args.sessionId, (record) => + abandonAgentSessionHandoffAttempt({ ...args, record }) + ) +} diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts new file mode 100644 index 00000000000..97b0f95f2b6 --- /dev/null +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -0,0 +1,94 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-launch-env' +let directory: string + +function request(overrides: Partial = {}): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW, + ...overrides + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('agent session launch environment admission', () => { + it('does not persist ambient launch variables', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reserveOwner(request()) + await store.reserveOwner( + request({ + expectedFence: 1, + spawnToken: 'spawn-b', + launchEnv: { + PATH: '/custom/bin:/usr/bin', + OPENAI_API_KEY: 'fixture-token' + }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000002`, + fingerprint: 'fp-2' + } + }) + ) + + const raw = await readFile(join(directory, 'agent-sessions.json'), 'utf-8') + expect(raw).not.toContain('OPENAI_API_KEY') + expect(raw).not.toContain('"PATH"') + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(reopened.getRecord(SESSION)).not.toHaveProperty('launchEnv') + }) + + it('rejects an environment that could not be validated before writing', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const launchEnv = Object.fromEntries( + Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) + ) + + await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( + 'agent_session_launch_env_invalid' + ) + expect(store.getRecord(SESSION)).toBeNull() + }) + + it('rejects an overlong environment key before writing it', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + + await expect( + store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) + ).rejects.toThrow('agent_session_launch_env_invalid') + expect(store.getRecord(SESSION)).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts new file mode 100644 index 00000000000..0c1c88db003 --- /dev/null +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -0,0 +1,120 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' + +const NOW = 1_800_000_000_000 +const MATCHED = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const +const directories: string[] = [] + +async function establishOwner( + store: AgentSessionRecordStore, + directory: string, + suffix: string +): Promise { + const sessionId = `session-${suffix}` + const spawnToken = `spawn-${suffix}` + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: directory }, + runtimeKind: 'native', + expectedFence: null, + spawnToken, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'renewal-test', + operationId: `${NOW}-${suffix.padStart(32, '0')}`, + fingerprint: `create-${suffix}` + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken }, + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: { + linkId: `link-${suffix}`, + handle: { provider: 'codex', threadId: `thread-${suffix}` }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +async function liveStore(): Promise<{ directory: string; store: AgentSessionRecordStore }> { + const directory = await mkdtemp(join(tmpdir(), 'orca-lease-renewal-batch-')) + directories.push(directory) + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await establishOwner(store, directory, 'a') + await establishOwner(store, directory, 'b') + return { directory, store } +} + +afterEach(async () => { + await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true }))) +}) + +describe('agent-session lease renewal batch', () => { + it('refuses a stale fence without changing the record', async () => { + const { store } = await liveStore() + + await expect( + store.renewLeases([ + { sessionId: 'session-a', fence: 0, childProbe: MATCHED, now: NOW + 10_000 } + ]) + ).rejects.toThrow('agent_session_checkpoint_stale') + expect(store.getRecord('session-a')?.lease.lastRenewedAt).toBe(NOW) + }) + + it('leaves every session durable when a later renewal was superseded', async () => { + const { directory, store } = await liveStore() + await store.evictProvenDeadOwner({ + sessionId: 'session-b', + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + now: NOW + 5_000 + }) + const beforeDisk = await readFile(agentSessionStorePath(directory), 'utf-8') + const beforeFirst = store.getRecord('session-a') + + await expect( + store.renewLeases([ + { sessionId: 'session-a', fence: 1, childProbe: MATCHED, now: NOW + 10_000 }, + { sessionId: 'session-b', fence: 1, childProbe: MATCHED, now: NOW + 10_000 } + ]) + ).rejects.toThrow('agent_session_checkpoint_stale') + + expect(store.getRecord('session-a')).toEqual(beforeFirst) + expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(beforeDisk) + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect( + reopened + .listRecords() + .map((record) => record.sessionId) + .sort() + ).toEqual(['session-a', 'session-b']) + expect(reopened.listRecords().every((record) => record.providerHandleChain.length > 0)).toBe( + true + ) + }) +}) diff --git a/src/main/runtime/agent-session-lease-renewal.ts b/src/main/runtime/agent-session-lease-renewal.ts new file mode 100644 index 00000000000..1a1f90dd7e1 --- /dev/null +++ b/src/main/runtime/agent-session-lease-renewal.ts @@ -0,0 +1,38 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { renewAgentSessionLease } from './agent-session-lease-transitions' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export type AgentSessionLeaseRenewal = { + sessionId: string + fence: number + childProbe: AgentSessionOwnerProbe + now: number + leaseTtlMs?: number +} + +export function renewAgentSessionLeases( + state: AgentSessionStoreState, + renewals: readonly AgentSessionLeaseRenewal[], + defaultLeaseTtlMs: number +): AgentSessionRecord[] { + return renewals.map((args) => { + const record = state.records.get(args.sessionId) + if (!record) { + throw new Error( + state.unreadableRecords.has(args.sessionId) + ? 'execution_owner_reconciling' + : 'agent_session_identity_required' + ) + } + const renewed = renewAgentSessionLease({ + record, + fence: args.fence, + childProbe: args.childProbe, + now: args.now, + leaseTtlMs: args.leaseTtlMs ?? defaultLeaseTtlMs + }) + state.records.set(args.sessionId, renewed) + return renewed + }) +} diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts new file mode 100644 index 00000000000..97fc48f139b --- /dev/null +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -0,0 +1,289 @@ +/** + * Pure lease state transitions. Every function returns the next record or throws a typed error; + * the store applies them inside one durable transaction so a rejected transition never lands. + * + * The invariant they exist to enforce: a session admits a writer only after a reservation, an + * observed process identity, and a proved provider handle — in that order, at one fence. + */ + +import { + adjudicateAgentSessionRestart, + evaluateAgentSessionAcquisition, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import { + appendAgentSessionProviderHandleLink, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { + AgentSessionJournalCheckpoint, + AgentSessionHandoffStage, + AgentSessionLease, + AgentSessionOwnerRuntimeKind, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' + +export type AgentSessionReservation = { + runtimeKind: AgentSessionOwnerRuntimeKind + spawnToken: string + claimKeyId: string + handoffOperationId: string | null + leaseTtlMs: number + now: number +} + +export function withLease( + record: AgentSessionRecord, + lease: AgentSessionLease +): AgentSessionRecord { + return { ...record, lease, updatedAt: lease.lastRenewedAt } +} + +export function assertFence(lease: AgentSessionLease, fence: number): void { + if (lease.runtimeFence !== fence) { + throw new Error('agent_session_checkpoint_stale') + } + if (lease.unreconciled) { + throw new Error('execution_owner_reconciling') + } +} + +/** + * Compare-and-swap reservation. Writes the intent at fence + 1 before any process exists, so the + * loser of a concurrent swap is refused and never spawns. + */ +export function reserveAgentSessionOwner(args: { + record: AgentSessionRecord + expectedFence: number + probe: AgentSessionOwnerProbe + reservation: AgentSessionReservation +}): { record: AgentSessionRecord; disposition: 'reserved' | 'retry-reservation' } { + const { record, reservation } = args + const decision = evaluateAgentSessionAcquisition({ + lease: record.lease, + expectedFence: args.expectedFence, + handoffOperationId: reservation.handoffOperationId, + probe: args.probe + }) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision === 'retry-reservation') { + return { record, disposition: 'retry-reservation' } + } + return { + disposition: 'reserved', + record: withLease(record, { + ...record.lease, + runtimeKind: reservation.runtimeKind, + runtimeFence: decision.nextFence, + // Why: a reserved owner is not yet a writer; it may only talk to the provider to prove resume. + handoffStage: 'new-owner-proving', + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: reservation.spawnToken, + processlessAt: null, + leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, + lastRenewedAt: reservation.now, + handoffOperationId: reservation.handoffOperationId, + claimKeyId: reservation.claimKeyId, + claimStatus: 'reserved', + deathEvidence: null + }) + } +} + +/** Step 4 of acquisition: write the observed identity back into the same lease row. */ +export type AgentSessionProcessIdentityCommit = { + sessionId: string + fence: number + process: AgentSessionProcessIdentity + now: number +} + +export function commitAgentSessionProcessIdentity( + args: AgentSessionProcessIdentityCommit & { record: AgentSessionRecord } +): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if (record.lease.claimStatus !== 'reserved' || record.lease.ownerProcess !== null) { + throw new Error('agent_session_ownership_unknown') + } + if (record.lease.reservedSpawnToken !== args.process.spawnToken) { + // Why: a child that cannot echo the reserved token is not the process Orca started. + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + ownerProcess: args.process, + processlessAt: null, + lastRenewedAt: args.now + }) +} + +/** + * The new runtime proved it resumed the expected provider handle. Only now does the session have + * a writer. + */ +export function proveAgentSessionOwner(args: { + record: AgentSessionRecord + fence: number + link: AgentSessionProviderHandleLink + now: number + leaseTtlMs: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if ( + record.lease.claimStatus !== 'reserved' || + record.lease.handoffStage !== 'new-owner-proving' || + record.lease.ownerProcess === null + ) { + throw new Error('agent_session_ownership_unknown') + } + if (args.link.handle.provider !== record.provider) { + throw new Error('agent_session_provider_handle_provider_mismatch') + } + if (args.link.mintedAtFence !== args.fence) { + throw new Error('agent_session_provider_handle_stale_fence') + } + const providerHandleChain = appendAgentSessionProviderHandleLink( + record.providerHandleChain, + args.link + ) + const head = providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + ...record, + providerHandleChain, + lease: { + ...record.lease, + handoffStage: null, + provenHandleLinkId: head.linkId, + claimStatus: 'live', + leaseDeadlineAt: args.now + args.leaseTtlMs, + lastRenewedAt: args.now, + handoffOperationId: null + }, + updatedAt: args.now + } +} + +/** + * A renewal asserts two things at once: the host is running its loop, and the child still matches + * the recorded identity. A host that cannot re-verify the child stops renewing rather than + * extending a lease it can no longer vouch for. + */ +export function renewAgentSessionLease(args: { + record: AgentSessionRecord + fence: number + childProbe: AgentSessionOwnerProbe + now: number + leaseTtlMs: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if (record.lease.ownerProcess === null) { + throw new Error('agent_session_ownership_unknown') + } + if (args.childProbe.outcome !== 'identity-matched' || args.childProbe.matchedOn.length === 0) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + leaseDeadlineAt: args.now + args.leaseTtlMs, + lastRenewedAt: args.now + }) +} + +/** Proven eviction — the only other thing besides acquisition that may move the fence. */ +export function evictAgentSessionOwner(args: { + record: AgentSessionRecord + expectedFence: number + probe: AgentSessionOwnerProbe + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe: args.probe, + observedAt: args.now + }) + if (adjudication.disposition === 'free') { + // Nothing outstanding to evict; clearing the latched stage IS the resolution, and no new + // generation was granted, so the fence and the recorded evidence both stay put. + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + processlessAt: null, + lastRenewedAt: args.now + }) + } + if (adjudication.disposition !== 'evicted') { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: adjudication.nextFence, + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: adjudication.evidence + }) +} + +export function setAgentSessionHandoffStage(args: { + record: AgentSessionRecord + fence: number + stage: AgentSessionHandoffStage | null + handoffOperationId: string | null + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + if ( + record.lease.handoffOperationId !== null && + args.handoffOperationId !== null && + args.handoffOperationId !== record.lease.handoffOperationId + ) { + throw new Error('agent_session_operation_conflict') + } + return withLease(record, { + ...record.lease, + handoffStage: args.stage, + handoffOperationId: args.handoffOperationId, + lastRenewedAt: args.now + }) +} + +export function setAgentSessionJournalCheckpoint(args: { + record: AgentSessionRecord + fence: number + checkpoint: AgentSessionJournalCheckpoint + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.fence) + const current = record.lease.journalCheckpoint + if ( + current && + (current.epoch > args.checkpoint.epoch || + (current.epoch === args.checkpoint.epoch && current.sequence > args.checkpoint.sequence)) + ) { + throw new Error('agent_session_checkpoint_stale') + } + return withLease(record, { + ...record.lease, + journalCheckpoint: args.checkpoint, + lastRenewedAt: args.now + }) +} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts new file mode 100644 index 00000000000..520e75a03c3 --- /dev/null +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -0,0 +1,33 @@ +// Ledger admission for mutations that are not reservations — send, cancel, an +// approval answer. Split from the store so the store keeps only the transaction. + +import { + agentSessionOperationKey, + evaluateAgentSessionOperation, + pruneAgentSessionOperationRows, + type AgentSessionOperationDecision, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' + +export type AgentSessionOperationAdmission = { + callerKey: string + operationId: string + fingerprint: string + now: number +} + +type OperationRows = Map + +/** Prune, evaluate, and (on admit) place the row. The caller runs this inside one + * transaction, so two concurrent copies of an operation id cannot both admit. */ +export function admitAgentSessionOperationRow( + rows: OperationRows, + args: AgentSessionOperationAdmission +): { rows: OperationRows; decision: AgentSessionOperationDecision } { + const pruned = pruneAgentSessionOperationRows(rows, args.now) + const decision = evaluateAgentSessionOperation({ rows: pruned, ...args }) + if (decision.decision === 'admit') { + pruned.set(agentSessionOperationKey(args.callerKey, args.operationId), decision.row) + } + return { rows: pruned, decision } +} diff --git a/src/main/runtime/agent-session-orphan-child-reaper.test.ts b/src/main/runtime/agent-session-orphan-child-reaper.test.ts new file mode 100644 index 00000000000..eb837ac4a22 --- /dev/null +++ b/src/main/runtime/agent-session-orphan-child-reaper.test.ts @@ -0,0 +1,68 @@ +// A child spawned under a reservation whose record was lost is invisible to the lease. Reaping +// is bounded cleanup only; it never replaces the lease proof required to grant another writer. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' + +function storeWithLeasedTokens(tokens: readonly string[]) { + return { + listOrphanSpawnTokens: (observed: readonly string[]) => + observed.filter((token) => !tokens.includes(token)) + } as Pick +} + +describe('orphan agent-session child reaper', () => { + it('stops every process whose spawn token no lease claims', async () => { + const stop = vi.fn() + + const stopped = await stopOrphanAgentSessionChildren({ + store: storeWithLeasedTokens(['token-owned']), + scan: async () => + new Map([ + ['token-owned', [101]], + ['token-lost', [202, 203]] + ]), + stop + }) + + expect(stopped).toEqual([202, 203]) + expect(stop).toHaveBeenCalledWith(202, 'SIGTERM') + expect(stop).toHaveBeenCalledWith(203, 'SIGTERM') + expect(stop).not.toHaveBeenCalledWith(101, 'SIGTERM') + }) + + it('stops nothing on a host that cannot enumerate spawn tokens', async () => { + const stop = vi.fn() + + // Null is "cannot answer", never "no tokens" — treating it as an empty scan would be a + // license to signal nothing, but a future empty-map reading would be a license to signal + // whatever the caller guessed. + const stopped = await stopOrphanAgentSessionChildren({ + store: { + listOrphanSpawnTokens: () => { + throw new Error('the reaper must not ask when the host could not answer') + } + }, + scan: async () => null, + stop + }) + + expect(stopped).toEqual([]) + expect(stop).not.toHaveBeenCalled() + }) + + it('surfaces a failure to signal an observed orphan', async () => { + const failure = Object.assign(new Error('not permitted'), { code: 'EPERM' }) + + await expect( + stopOrphanAgentSessionChildren({ + store: storeWithLeasedTokens([]), + scan: async () => new Map([['token-lost', [202]]]), + stop: () => { + throw failure + } + }) + ).rejects.toBe(failure) + }) +}) diff --git a/src/main/runtime/agent-session-orphan-child-reaper.ts b/src/main/runtime/agent-session-orphan-child-reaper.ts new file mode 100644 index 00000000000..68d9c899024 --- /dev/null +++ b/src/main/runtime/agent-session-orphan-child-reaper.ts @@ -0,0 +1,48 @@ +/** + * Best-effort stop for provider children that carry an Orca spawn token no lease claims. + * + * A child spawned under a reservation whose record was lost — the primary store file went with it, + * or the crash beat the durable write — is unreachable but still connected to the provider session. + * Only a token match justifies signalling a process; neither age nor CPU is evidence, and a host + * that cannot enumerate tokens stops nothing. This never proves process exit or licenses a new + * owner; lease adjudication remains the single-writer boundary. + */ + +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + scanAgentSessionSpawnTokenProcesses, + type AgentSessionSpawnTokenScan +} from './agent-session-spawn-token-process-scan' + +export type AgentSessionOrphanStopSignal = 'SIGTERM' | 'SIGKILL' + +function defaultStop(pid: number, signal: AgentSessionOrphanStopSignal): void { + try { + process.kill(pid, signal) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + } +} + +/** Returns the pids signalled, so the caller can report what it reaped. */ +export async function stopOrphanAgentSessionChildren(input: { + store: Pick + scan?: () => Promise + stop?: (pid: number, signal: AgentSessionOrphanStopSignal) => void +}): Promise { + const observed = await (input.scan ?? scanAgentSessionSpawnTokenProcesses)() + if (observed === null || observed.size === 0) { + return [] + } + const stop = input.stop ?? defaultStop + const stopped: number[] = [] + for (const token of input.store.listOrphanSpawnTokens([...observed.keys()])) { + for (const pid of observed.get(token) ?? []) { + stop(pid, 'SIGTERM') + stopped.push(pid) + } + } + return stopped +} diff --git a/src/main/runtime/agent-session-process-identity-probe.test.ts b/src/main/runtime/agent-session-process-identity-probe.test.ts new file mode 100644 index 00000000000..17737de05d7 --- /dev/null +++ b/src/main/runtime/agent-session-process-identity-probe.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { + PROCESS_START_TIME_TOLERANCE_MS, + probeAgentSessionProcessIdentities, + probeAgentSessionProcessIdentity, + probeAgentSessionReservation, + readProcessStartTimeMs, + type AgentSessionProcessProbeDeps +} from './agent-session-process-identity-probe' + +const START_TIME = 1_700_000_000_000 + +const IDENTITY: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 4242, + processStartTimeMs: START_TIME, + spawnToken: 'spawn-a' +} + +function deps(overrides: AgentSessionProcessProbeDeps = {}): AgentSessionProcessProbeDeps { + return { + isPidPresent: () => true, + readProcessStartTimeMs: async () => START_TIME, + readEchoedSpawnToken: async () => 'spawn-a', + platform: 'linux', + ...overrides + } +} + +describe('owner identity probe', () => { + it('shares one process-table read across a batch without weakening identity checks', async () => { + const readProcessStartTimes = vi.fn( + async () => + new Map([ + [4242, START_TIME], + [4243, START_TIME + PROCESS_START_TIME_TOLERANCE_MS + 1] + ]) + ) + const probes = await probeAgentSessionProcessIdentities({ + identities: [IDENTITY, { ...IDENTITY, pid: 4243, spawnToken: 'spawn-b' }], + deps: { + isPidPresent: () => true, + readEchoedSpawnToken: async () => null, + readProcessStartTimesMs: readProcessStartTimes, + platform: 'darwin' + } + }) + + expect(readProcessStartTimes).toHaveBeenCalledOnce() + expect(readProcessStartTimes).toHaveBeenCalledWith([4242, 4243], 'darwin') + expect(probes).toEqual([ + { outcome: 'identity-matched', matchedOn: ['process-start-time'] }, + { outcome: 'identity-mismatch', field: 'process-start-time' } + ]) + }) + + it('reports an observed exit without touching the host', () => { + return expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + observedExit: true, + deps: deps({ + isPidPresent: () => { + throw new Error('must not probe after an observed exit') + } + }) + }) + ).resolves.toEqual({ outcome: 'exit-observed' }) + }) + + it('reports an absent pid as proven death', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ isPidPresent: () => false }) + }) + ).resolves.toEqual({ outcome: 'pid-absent' }) + }) + + it('does not call an unexpected host probe error proof of death', async () => { + const kill = vi.spyOn(process, 'kill').mockImplementation(() => { + throw Object.assign(new Error('host probe unavailable'), { code: 'EIO' }) + }) + try { + const probe = await probeAgentSessionProcessIdentity({ + identity: { ...IDENTITY, processStartTimeMs: null }, + deps: { readEchoedSpawnToken: async () => null } + }) + expect(probe.outcome).toBe('indeterminate') + } finally { + kill.mockRestore() + } + }) + + it('catches pid reuse through the spawn token', async () => { + // The pid is live and started at the recorded time, but it is a different process. + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ readEchoedSpawnToken: async () => 'spawn-other' }) + }) + ).resolves.toEqual({ outcome: 'identity-mismatch', field: 'spawn-token' }) + }) + + it('catches pid reuse through the start time when no token comes back', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ + readEchoedSpawnToken: async () => null, + readProcessStartTimeMs: async () => START_TIME + PROCESS_START_TIME_TOLERANCE_MS + 1 + }) + }) + ).resolves.toEqual({ outcome: 'identity-mismatch', field: 'process-start-time' }) + }) + + it('fails closed when an exact token and the reconstructed start time disagree', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ + readProcessStartTimeMs: async () => START_TIME + PROCESS_START_TIME_TOLERANCE_MS + 1 + }) + }) + ).resolves.toEqual({ + outcome: 'indeterminate', + reason: 'process identity evidence contradicted' + }) + }) + + it('tolerates start-time jitter inside the tolerance', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ + readEchoedSpawnToken: async () => null, + readProcessStartTimeMs: async () => START_TIME + PROCESS_START_TIME_TOLERANCE_MS + }) + }) + ).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['process-start-time'] }) + }) + + it('reports every element it could actually verify', async () => { + await expect( + probeAgentSessionProcessIdentity({ identity: IDENTITY, deps: deps() }) + ).resolves.toEqual({ + outcome: 'identity-matched', + matchedOn: ['spawn-token', 'process-start-time'] + }) + }) + + it('fails closed when the pid is live but nothing PID-reuse-safe could be checked', async () => { + // The Windows case: no start time recorded and no token echo, so a bare pid match is all the + // host has — and a bare pid match is what mints a second writer. + const probe = await probeAgentSessionProcessIdentity({ + identity: { ...IDENTITY, processStartTimeMs: null }, + deps: deps({ readEchoedSpawnToken: async () => null, platform: 'win32' }) + }) + expect(probe.outcome).toBe('indeterminate') + }) + + it('fails closed when the host errors instead of answering', async () => { + const probe = await probeAgentSessionProcessIdentity({ + identity: { ...IDENTITY, processStartTimeMs: null }, + deps: deps({ + readEchoedSpawnToken: async () => { + throw new Error('handshake unavailable') + } + }) + }) + expect(probe.outcome).toBe('indeterminate') + }) + + it('still proves life from the token when the start time is unreadable', async () => { + await expect( + probeAgentSessionProcessIdentity({ + identity: IDENTITY, + deps: deps({ readProcessStartTimeMs: async () => null }) + }) + ).resolves.toEqual({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + }) + + it('reads a process-table start time on Windows when running there', async () => { + const observed = await readProcessStartTimeMs(process.pid, 'win32') + expect(observed === null).toBe(process.platform !== 'win32') + }) + + it('reads a start time for the current process on this platform', async () => { + const observed = await readProcessStartTimeMs(process.pid) + if ( + process.platform === 'linux' || + process.platform === 'darwin' || + process.platform === 'win32' + ) { + expect(observed).not.toBeNull() + expect(Math.abs((observed as number) - (Date.now() - process.uptime() * 1000))).toBeLessThan( + 60_000 + ) + } else { + expect(observed).toBeNull() + } + }) +}) + +describe('reservation probe', () => { + it('declares a reservation unused only with positive proof nothing started', async () => { + await expect( + probeAgentSessionReservation({ + spawnToken: 'spawn-a', + findProcessesWithSpawnToken: async () => [], + hasProviderActivitySinceReservation: async () => false + }) + ).resolves.toEqual({ outcome: 'reservation-unused' }) + }) + + it.each([ + ['a process still carries the token', async () => [999], async () => false], + ['the host cannot enumerate', async () => null, async () => false], + ['provider activity is unknown', async () => [], async () => null], + ['the provider saw activity', async () => [], async () => true] + ] as const)('stays indeterminate when %s', async (_name, findProcesses, hasActivity) => { + const probe = await probeAgentSessionReservation({ + spawnToken: 'spawn-a', + findProcessesWithSpawnToken: findProcesses, + hasProviderActivitySinceReservation: hasActivity + }) + expect(probe.outcome).toBe('indeterminate') + }) + + it('stays indeterminate when enumeration throws', async () => { + const probe = await probeAgentSessionReservation({ + spawnToken: 'spawn-a', + findProcessesWithSpawnToken: async () => { + throw new Error('ps unavailable') + }, + hasProviderActivitySinceReservation: async () => false + }) + expect(probe.outcome).toBe('indeterminate') + }) +}) diff --git a/src/main/runtime/agent-session-process-identity-probe.ts b/src/main/runtime/agent-session-process-identity-probe.ts new file mode 100644 index 00000000000..51577048d31 --- /dev/null +++ b/src/main/runtime/agent-session-process-identity-probe.ts @@ -0,0 +1,273 @@ +/** + * PID-reuse-safe process identity probe for the single-writer lease. + * + * Pids are reused within minutes on a busy host, and reuse happens precisely in the recovery + * case, so a bare pid match is never proof. Every element of the identity tuple is unavailable + * somewhere — start time costs a CIM query on Windows and is missing in some containers, /proc + * does not exist on macOS — so an exact but unanswerable identity stays fenced in `recovering`; + * an ownerless, unattributable reservation enters `manual-recovery`. + */ + +import { readFile } from 'node:fs/promises' +import type { + AgentSessionIdentityMatchField, + AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { runProcess } from '../../shared/child-process/run-process' +import { readWindowsProcessTableFresh } from '../windows/windows-process-table' + +/** Start times drift by scheduler granularity and clock reads; compare with a tolerance. */ +export const PROCESS_START_TIME_TOLERANCE_MS = 2_000 + +const PROCESS_START_TIME_TIMEOUT_MS = 5_000 + +export type AgentSessionProcessProbeDeps = { + /** ESRCH means gone; EPERM means present but owned by another user. */ + isPidPresent?: (pid: number) => boolean + readProcessStartTimeMs?: (pid: number, platform?: NodeJS.Platform) => Promise + /** Token the running child echoed back through the adapter handshake or provider hook. */ + readEchoedSpawnToken?: (identity: AgentSessionProcessIdentity) => Promise + platform?: NodeJS.Platform +} + +function defaultIsPidPresent(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + // Why: only ESRCH proves absence; permission and transient host failures must fail closed. + return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' + } +} + +async function readLinuxProcessStartTimeMs(pid: number): Promise { + try { + const [stat, systemStat] = await Promise.all([ + readFile(`/proc/${pid}/stat`, 'utf-8'), + readFile('/proc/stat', 'utf-8') + ]) + // Field 22 is starttime in clock ticks; the comm field can contain spaces, so cut past ") ". + const fields = stat.slice(stat.lastIndexOf(') ') + 2).split(' ') + const ticks = Number(fields[19]) + const bootTimeSeconds = Number(/^btime\s+(\d+)$/m.exec(systemStat)?.[1]) + if (!Number.isFinite(ticks) || !Number.isFinite(bootTimeSeconds)) { + return null + } + return Math.round(bootTimeSeconds * 1000 + (ticks / 100) * 1000) + } catch { + return null + } +} + +async function readDarwinProcessStartTimeMs(pid: number): Promise { + try { + const result = await runProcess({ + program: 'ps', + args: ['-o', 'lstart=', '-p', String(pid)], + timeoutMs: PROCESS_START_TIME_TIMEOUT_MS + }) + if (result.timedOut || result.code !== 0) { + return null + } + const parsed = Date.parse(result.stdout.trim()) + return Number.isFinite(parsed) ? parsed : null + } catch { + return null + } +} + +async function readDarwinProcessStartTimesMs( + pids: readonly number[] +): Promise> { + const observed = new Map() + if (pids.length === 0) { + return observed + } + try { + const result = await runProcess({ + program: 'ps', + args: ['-o', 'pid=,lstart=', '-p', pids.join(',')], + timeoutMs: PROCESS_START_TIME_TIMEOUT_MS + }) + if (result.timedOut || result.code !== 0) { + return observed + } + for (const line of result.stdout.split('\n')) { + const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line) + if (!match) { + continue + } + const pid = Number(match[1]) + const parsed = Date.parse(match[2]) + if (Number.isSafeInteger(pid) && Number.isFinite(parsed)) { + observed.set(pid, parsed) + } + } + } catch { + // A missing process table is unknown, never evidence that every owner exited. + } + return observed +} + +async function readWindowsProcessStartTimeMs(pid: number): Promise { + try { + const row = (await readWindowsProcessTableFresh()).find((candidate) => candidate.pid === pid) + return row?.creationTimeMs ?? null + } catch { + return null + } +} + +/** + * Process start time is the cross-platform PID-reuse guard when no provider hook can echo the + * spawn token back to the owner probe. + */ +export async function readProcessStartTimeMs( + pid: number, + platform: NodeJS.Platform = process.platform +): Promise { + if (platform === 'linux') { + return readLinuxProcessStartTimeMs(pid) + } + if (platform === 'darwin') { + return readDarwinProcessStartTimeMs(pid) + } + if (platform === 'win32') { + return readWindowsProcessStartTimeMs(pid) + } + return null +} + +export async function readProcessStartTimesMs( + pids: readonly number[], + platform: NodeJS.Platform = process.platform +): Promise> { + const uniquePids = [...new Set(pids)] + if (platform === 'darwin') { + const table = await readDarwinProcessStartTimesMs(uniquePids) + return new Map(uniquePids.map((pid) => [pid, table.get(pid) ?? null])) + } + return new Map( + await Promise.all( + uniquePids.map(async (pid) => [pid, await readProcessStartTimeMs(pid, platform)] as const) + ) + ) +} + +export type AgentSessionProcessBatchProbeDeps = Omit< + AgentSessionProcessProbeDeps, + 'readProcessStartTimeMs' +> & { + readProcessStartTimesMs?: ( + pids: readonly number[], + platform?: NodeJS.Platform + ) => Promise> +} + +export async function probeAgentSessionProcessIdentities(args: { + identities: readonly AgentSessionProcessIdentity[] + deps?: AgentSessionProcessBatchProbeDeps +}): Promise { + const deps = args.deps ?? {} + const platform = deps.platform ?? process.platform + const pids = args.identities + .filter((identity) => identity.processStartTimeMs !== null) + .map((identity) => identity.pid) + const readStartTimes = deps.readProcessStartTimesMs ?? readProcessStartTimesMs + const startTimes = await readStartTimes(pids, platform).catch(() => new Map()) + return Promise.all( + args.identities.map((identity) => + probeAgentSessionProcessIdentity({ + identity, + deps: { + ...deps, + platform, + readProcessStartTimeMs: async (pid) => startTimes.get(pid) ?? null + } + }) + ) + ) +} + +/** + * Probe one recorded owner. `observedExit` short-circuits everything: Orca watching that exact + * process exit is the strongest evidence available. + */ +export async function probeAgentSessionProcessIdentity(args: { + identity: AgentSessionProcessIdentity + observedExit?: boolean + deps?: AgentSessionProcessProbeDeps +}): Promise { + const { identity } = args + const deps = args.deps ?? {} + if (args.observedExit) { + return { outcome: 'exit-observed' } + } + const isPidPresent = deps.isPidPresent ?? defaultIsPidPresent + if (!isPidPresent(identity.pid)) { + return { outcome: 'pid-absent' } + } + const matchedOn: AgentSessionIdentityMatchField[] = [] + const echoedToken = await deps.readEchoedSpawnToken?.(identity).catch(() => null) + if (echoedToken !== null && echoedToken !== undefined) { + if (echoedToken !== identity.spawnToken) { + return { outcome: 'identity-mismatch', field: 'spawn-token' } + } + matchedOn.push('spawn-token') + } + if (identity.processStartTimeMs !== null) { + const readStartTime = deps.readProcessStartTimeMs ?? readProcessStartTimeMs + const observed = await readStartTime(identity.pid, deps.platform ?? process.platform).catch( + () => null + ) + if (observed !== null) { + if (Math.abs(observed - identity.processStartTimeMs) > PROCESS_START_TIME_TOLERANCE_MS) { + if (matchedOn.includes('spawn-token')) { + // Why: contradictory evidence cannot prove that a token-authenticated child is dead. + return { outcome: 'indeterminate', reason: 'process identity evidence contradicted' } + } + return { outcome: 'identity-mismatch', field: 'process-start-time' } + } + matchedOn.push('process-start-time') + } + } + if (matchedOn.length === 0) { + // Why: the pid exists and nothing PID-reuse-safe could be checked. Reporting a match here is + // exactly the case that produces two writers on one provider session. + return { + outcome: 'indeterminate', + reason: 'pid present but neither spawn token nor start time could be verified' + } + } + return { outcome: 'identity-matched', matchedOn } +} + +/** + * Probe a reservation that has no recorded process. `reservation-unused` requires positive proof + * that nothing started — no process carrying the token and no provider-side activity after the + * reservation — not an assumption that the crash beat the spawn. + */ +export async function probeAgentSessionReservation(args: { + spawnToken: string + findProcessesWithSpawnToken: (spawnToken: string) => Promise + hasProviderActivitySinceReservation: () => Promise +}): Promise { + const pids = await args.findProcessesWithSpawnToken(args.spawnToken).catch(() => null) + if (pids === null) { + return { outcome: 'indeterminate', reason: 'host could not enumerate spawn tokens' } + } + if (pids.length > 0) { + return { + outcome: 'indeterminate', + reason: `reservation spawn token is live on ${pids.length} process(es)` + } + } + const providerActivity = await args.hasProviderActivitySinceReservation().catch(() => null) + if (providerActivity === null) { + return { outcome: 'indeterminate', reason: 'provider activity since reservation is unknown' } + } + return providerActivity + ? { outcome: 'indeterminate', reason: 'provider saw activity after the reservation' } + : { outcome: 'reservation-unused' } +} diff --git a/src/main/runtime/agent-session-processless-reservation.ts b/src/main/runtime/agent-session-processless-reservation.ts new file mode 100644 index 00000000000..9415ba8fcb7 --- /dev/null +++ b/src/main/runtime/agent-session-processless-reservation.ts @@ -0,0 +1,44 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export type AgentSessionReservationProcesslessProof = { + sessionId: string + fence: number + spawnToken: string + now: number +} + +function assertReservation( + record: AgentSessionRecord, + args: AgentSessionReservationProcesslessProof +): void { + if (record.lease.runtimeFence !== args.fence || record.lease.unreconciled) { + throw new Error('agent_session_checkpoint_stale') + } + if ( + record.lease.claimStatus !== 'reserved' || + record.lease.reservedSpawnToken !== args.spawnToken + ) { + throw new Error('agent_session_ownership_unknown') + } +} + +export function setAgentSessionReservationProcesslessProof( + args: AgentSessionReservationProcesslessProof & { + record: AgentSessionRecord + processlessAt: number | null + } +): AgentSessionRecord { + const { record } = args + assertReservation(record, args) + if (args.processlessAt === null && record.lease.processlessAt == null) { + return record + } + if (record.lease.ownerProcess !== null) { + throw new Error('agent_session_ownership_unknown') + } + return { + ...record, + lease: { ...record.lease, processlessAt: args.processlessAt }, + updatedAt: args.now + } +} diff --git a/src/main/runtime/agent-session-provider-handle-transition.test.ts b/src/main/runtime/agent-session-provider-handle-transition.test.ts new file mode 100644 index 00000000000..19131562366 --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' + +function resumedLink(fence: number): AgentSessionProviderHandleLink { + return { + linkId: 'link-2', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'leaf-2' }, + origin: 'resumed', + mintedAtFence: fence, + observedAt: 4_000 + } +} + +describe('recordAgentSessionProviderHandle', () => { + it('advances a live Claude chain head and its proof', () => { + const record = agentSessionRecordFixture() + const next = recordAgentSessionProviderHandle({ + record, + fence: record.lease.runtimeFence, + link: resumedLink(record.lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease.provenHandleLinkId).toBe('link-2') + }) + + it('records a leaf during proof without granting ownership', () => { + const lease = agentSessionLeaseFixture({ + runtimeFence: 8, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + provenHandleLinkId: null + }) + const next = recordAgentSessionProviderHandle({ + record: agentSessionRecordFixture(lease), + fence: lease.runtimeFence, + link: resumedLink(lease.runtimeFence), + now: 4_000 + }) + expect(next.providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'leaf-2' }) + expect(next.lease).toMatchObject({ claimStatus: 'reserved', provenHandleLinkId: null }) + }) +}) diff --git a/src/main/runtime/agent-session-provider-handle-transition.ts b/src/main/runtime/agent-session-provider-handle-transition.ts new file mode 100644 index 00000000000..474e4be7135 --- /dev/null +++ b/src/main/runtime/agent-session-provider-handle-transition.ts @@ -0,0 +1,41 @@ +import { + appendAgentSessionProviderHandleLink, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function recordAgentSessionProviderHandle(args: { + record: AgentSessionRecord + fence: number + link: AgentSessionProviderHandleLink + now: number +}): AgentSessionRecord { + const { record } = args + if (record.lease.runtimeFence !== args.fence) { + throw new Error('agent_session_stale_fence') + } + if (args.link.handle.provider !== record.provider || args.link.mintedAtFence !== args.fence) { + throw new Error('agent_session_provider_handle_invalid') + } + if (record.lease.claimStatus !== 'live' && record.lease.handoffStage !== 'new-owner-proving') { + throw new Error('agent_session_ownership_unknown') + } + const providerHandleChain = appendAgentSessionProviderHandleLink( + record.providerHandleChain, + args.link + ) + const head = providerHandleChain.at(-1) + if (!head) { + throw new Error('agent_session_provider_handle_invalid') + } + return { + ...record, + providerHandleChain, + lease: { + ...record.lease, + ...(record.lease.claimStatus === 'live' ? { provenHandleLinkId: head.linkId } : {}), + lastRenewedAt: args.now + }, + updatedAt: args.now + } +} diff --git a/src/main/runtime/agent-session-pty-write-enforcement.test.ts b/src/main/runtime/agent-session-pty-write-enforcement.test.ts new file mode 100644 index 00000000000..e81275f90ed --- /dev/null +++ b/src/main/runtime/agent-session-pty-write-enforcement.test.ts @@ -0,0 +1,368 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { TERMINAL_INPUT_CHUNK_MAX_BYTES } from '../../shared/terminal-input' +import { AGENT_PROMPT_SUBMIT } from '../../shared/agent-prompt-injection' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +// The runtime send paths are the choke point every RPC, plugin, and orchestration write funnels +// through, so each one is proved to consult the lease and to leave unbound PTYs untouched. + +const WORKTREE_ID = 'repo-1::/tmp/lease-worktree' +const LEAF_ID = '22222222-2222-4222-8222-222222222222' +const RUN_ID = 'run-1' +const PTY_ID = 'pty-agent-session' +const SESSION_ID = 'session-alpha-1' + +function makeStore() { + const session: WorkspaceSessionState = getDefaultWorkspaceSession() + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [ + { + id: 'repo-1', + path: '/tmp/lease-worktree', + displayName: 'lease', + badgeColor: '#000000', + addedAt: 0 + } + ]), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +const records = new Map() + +function publish(lease: AgentSessionLease): void { + records.set(lease.sessionId, agentSessionRecordFixture(lease)) +} + +async function makeRuntime(options: { onWrite?: (ptyId: string, data: string) => void } = {}) { + const runtime = new OrcaRuntimeService(makeStore() as never) + const write = vi.fn((ptyId: string, data: string) => { + options.onWrite?.(ptyId, data) + // A real agent starts working when it receives the submit, and the prompt path now waits for + // that transition before it reports success. Without it every happy path here reads as stalled. + if (data === AGENT_PROMPT_SUBMIT) { + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now()) + } + return true + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'never' })), + write, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: vi.fn(async () => []), + hasPty: () => true + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + title: 'Agent', + activeLeafId: LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneRuntimeId: 1, + ptyId: PTY_ID, + paneTitle: null, + title: '' + } + ] + }) + const { terminals } = await runtime.listTerminals(`id:${WORKTREE_ID}`) + return { runtime, handle: terminals[0].handle, write } +} + +function enforce(lease: AgentSessionLease = agentSessionLeaseFixture()): void { + publish(lease) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) +} + +afterEach(() => { + agentSessionPtyWriteGate.detachRecordLookup() + records.clear() +}) + +describe('exemption: nothing that exists today is enforced', () => { + it('sends normally when no session record is bound to the pty', async () => { + const { runtime, handle, write } = await makeRuntime() + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('sends normally when the store is attached but this pty is a legacy agent terminal', async () => { + const { runtime, handle, write } = await makeRuntime() + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + agentSessionPtyWriteGate.bindPty('some-other-pty', SESSION_ID) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('sends agent prompts normally on an unbound pty', async () => { + const { runtime, handle, write } = await makeRuntime() + + await expect(runtime.sendTerminalAgentPrompt(handle, 'go')).resolves.toBeDefined() + + expect(write).toHaveBeenCalled() + }) +}) + +describe('terminal.send path', () => { + it('writes when the TUI owner holds a proven-live lease', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce() + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledWith(PTY_ID, 'ls') + }) + + it('refuses and writes nothing when native chat owns the session', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses while a handoff is in flight', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ handoffStage: 'new-owner-proving' })) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses while the lease is unreconciled after a host restart', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ unreconciled: true })) + + await expect(runtime.sendTerminal(handle, { text: 'ls' })).rejects.toThrow( + 'execution_owner_reconciling' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses an interrupt, which carries no text of its own', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + await expect(runtime.sendTerminal(handle, { interrupt: true })).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('refuses before the mobile floor is reserved', async () => { + const { runtime, handle } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + const reserveWrite = vi.fn() + + await expect(runtime.sendTerminal(handle, { text: 'ls' }, { reserveWrite })).rejects.toThrow() + + expect(reserveWrite).not.toHaveBeenCalled() + }) + + it('refuses when an async send guard outlives the admitted fence', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect( + runtime.sendTerminal( + handle, + { text: 'ls' }, + { + beforeWrite: async () => { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + await Promise.resolve() + } + } + ) + ).rejects.toThrow('agent_session_checkpoint_stale') + + expect(write).not.toHaveBeenCalled() + }) +}) + +describe('agent prompt path', () => { + it('refuses the whole paste when another runtime owns the session', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce(agentSessionLeaseFixture({ runtimeKind: 'native' })) + + await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).rejects.toThrow( + 'agent_session_conflict' + ) + + expect(write).not.toHaveBeenCalled() + }) + + it('writes the prompt when the TUI owner still holds the lease', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce() + + await expect(runtime.sendTerminalAgentPrompt(handle, 'do the thing')).resolves.toBeDefined() + + expect(write).toHaveBeenCalled() + }) + + it('does not terminate a partial paste after its admitted fence moved', async () => { + const { runtime, handle, write } = await makeRuntime({ + onWrite: () => publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect( + runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2)) + ).rejects.toThrow('agent_session_checkpoint_stale') + + expect(write).toHaveBeenCalledTimes(1) + }) +}) + +describe('lease transition against an in-flight write', () => { + const CHUNKED_TEXT = 'x'.repeat(TERMINAL_INPUT_CHUNK_MAX_BYTES * 2 + 8) + + it('stops a paste mid-flight once the fence advances under it', async () => { + let written = 0 + const { runtime, handle, write } = await makeRuntime({ + onWrite: () => { + written += 1 + if (written === 1) { + // A handoff completed between the first and second chunk. + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect(runtime.sendTerminal(handle, { text: CHUNKED_TEXT })).rejects.toThrow( + 'agent_session_checkpoint_stale' + ) + + expect(write).toHaveBeenCalledTimes(1) + }) + + it('lets a paste finish while the same owner holds the fence', async () => { + const { runtime, handle, write } = await makeRuntime() + enforce() + + await expect(runtime.sendTerminal(handle, { text: CHUNKED_TEXT })).resolves.toMatchObject({ + accepted: true + }) + + expect(write).toHaveBeenCalledTimes(3) + }) + + it('fences preview paste chunks to the lease admitted before the first chunk', async () => { + let written = 0 + const { runtime, write } = await makeRuntime({ + onWrite: () => { + written += 1 + if (written === 1) { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect(runtime.writeTerminalPreviewInput(PTY_ID, CHUNKED_TEXT)).resolves.toBe(false) + + expect(write).toHaveBeenCalledTimes(1) + }) + + it('withholds the submit when the lease moves during the text/suffix pause', async () => { + const { runtime, handle, write } = await makeRuntime({ + onWrite: (_ptyId, data) => { + if (data === 'ls') { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + + await expect(runtime.sendTerminal(handle, { text: 'ls', enter: true })).rejects.toThrow( + 'agent_session_checkpoint_stale' + ) + + expect(write).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalledWith(PTY_ID, '\r') + }) + + it('withholds orchestration Enter after the pointer lease fence moves', async () => { + vi.useFakeTimers() + try { + const { runtime, handle, write } = await makeRuntime({ + onWrite: (_ptyId, data) => { + if (data.includes('orca orchestration check')) { + publish(agentSessionLeaseFixture({ runtimeFence: 8 })) + } + } + }) + let messages: { id: string; sequence: number; type: string }[] = [] + // Why run-scoped: pointer delivery only serves `run:` mailboxes, and it stages the + // batch as delivered before writing — a fake missing either makes the fence + // assertion below vacuous because nothing is ever written. + runtime.setOrchestrationDb({ + getUndeliveredUnreadMessages: () => messages, + getCurrentRunForPane: () => ({ id: RUN_ID }), + getRun: () => ({ id: RUN_ID, coordinator_handle: handle }), + markAsDelivered: () => undefined + } as never) + runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) + runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2) + enforce(agentSessionLeaseFixture({ runtimeFence: 7 })) + messages = [{ id: 'msg-1', sequence: 1, type: 'status' }] + + runtime.deliverPendingMessagesForHandle(`run:${RUN_ID}`) + expect(write).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(500) + + expect(write.mock.calls.filter(([, data]) => data === '\r')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/agent-session-pty-write-gate.test.ts b/src/main/runtime/agent-session-pty-write-gate.test.ts new file mode 100644 index 00000000000..94ad79c051b --- /dev/null +++ b/src/main/runtime/agent-session-pty-write-gate.test.ts @@ -0,0 +1,277 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { isAgentSessionPtyWriteRefusedError } from '../../shared/agent-session-pty-write-admission' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' + +const PTY_ID = 'pty-1' +const SESSION_ID = 'session-alpha-1' + +let gate: AgentSessionPtyWriteGate +let records: Map + +function publish(lease: AgentSessionLease): void { + records.set(lease.sessionId, agentSessionRecordFixture(lease)) +} + +beforeEach(() => { + gate = new AgentSessionPtyWriteGate() + records = new Map() +}) + +describe('capability invisibility', () => { + it('admits every PTY while nothing is bound, which is the shape of today builds', () => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + expect(gate.enforcing).toBe(false) + expect(gate.admit('any-shell')).toEqual({ + admitted: true, + sessionId: null, + runtimeFence: null + }) + }) + + it('admits an unbound PTY even once another PTY is bound and refusing', () => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.admit(PTY_ID).admitted).toBe(false) + expect(gate.admit('ordinary-shell').admitted).toBe(true) + }) + + it('admits a bound PTY while no store is attached, so a half-wired host cannot refuse', () => { + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.enforcing).toBe(false) + expect(gate.admit(PTY_ID).admitted).toBe(true) + }) +}) + +describe('binding lifecycle', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + }) + + it('reports the session a PTY is bound to', () => { + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(gate.boundSessionId('other')).toBeNull() + }) + + it('returns an unbound PTY to the exempt path when it is unbound', () => { + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + gate.bindPty(PTY_ID, SESSION_ID) + expect(gate.admit(PTY_ID).admitted).toBe(false) + gate.unbindPty(PTY_ID) + expect(gate.admit(PTY_ID).admitted).toBe(true) + }) + + it('drops every binding when the store detaches', () => { + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + gate.bindPty(PTY_ID, SESSION_ID) + gate.detachRecordLookup() + expect(gate.enforcing).toBe(false) + expect(gate.admit(PTY_ID).admitted).toBe(true) + }) +}) + +describe('overlapping adoption attempts on one pane', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + }) + + it('keeps the newer attempt bound when the one it superseded gives up', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-b')).toBe(true) + + // Both attempts carry the same session, so only the spawn token can tell this release apart. + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-b')).toBe(true) + expect(gate.boundSessionId(PTY_ID)).toBeNull() + }) + + it('leaves a settled owner pane alone when a later attempt fails', () => { + gate.bindPty(PTY_ID, SESSION_ID) + + expect(gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-late')).toBe(false) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-late')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }) + + it('settles a proven attempt so no later attempt can release its pane', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(true) + + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.releasePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBe(SESSION_ID) + }) + + it('settles nothing once the pane is gone', () => { + gate.bindPtyForAttempt(PTY_ID, SESSION_ID, 'spawn-a') + gate.unbindPty(PTY_ID) + + expect(gate.settlePtyAttempt(PTY_ID, 'spawn-a')).toBe(false) + expect(gate.boundSessionId(PTY_ID)).toBeNull() + }) +}) + +describe('admission through the store', () => { + beforeEach(() => { + gate.attachRecordLookup((sessionId) => records.get(sessionId) ?? null) + gate.bindPty(PTY_ID, SESSION_ID) + }) + + it('admits a proven-live TUI owner and reports the fence it was admitted under', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 11 })) + expect(gate.admit(PTY_ID)).toEqual({ + admitted: true, + sessionId: SESSION_ID, + runtimeFence: 11 + }) + }) + + it.each(['preparing', 'old-owner-stopped', 'new-owner-proving'] as const)( + 'refuses TUI writes while handoff stage %s is active', + (handoffStage) => { + publish(agentSessionLeaseFixture({ runtimeKind: 'tui', handoffStage })) + const admission = gate.admit(PTY_ID) + expect(admission.admitted).toBe(false) + if (!admission.admitted) { + expect(admission.refusal).toMatchObject({ + code: 'agent_session_conflict', + handoffStage, + ownerRuntimeKind: 'tui' + }) + } + } + ) + + it('admits only the reserved TUI proof token while the new process is proving', () => { + publish( + agentSessionLeaseFixture({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'proof-token' + }, + reservedSpawnToken: 'proof-token' + }) + ) + expect(gate.admit(PTY_ID).admitted).toBe(false) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe(true) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + expect( + gate.admitProof(PTY_ID, { sessionId: 'session-beta-2', spawnToken: 'proof-token' }) + ).toBe(false) + }) + + it('refuses proof input that does not match the committed process identity', () => { + publish( + agentSessionLeaseFixture({ + runtimeKind: 'tui', + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'other-token' + }, + reservedSpawnToken: 'proof-token' + }) + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( + false + ) + }) + + it('refuses proof input after ownership is live', () => { + publish(agentSessionLeaseFixture({ reservedSpawnToken: 'proof-token' })) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'proof-token' })).toBe( + false + ) + }) + + it('admits proof input for the exact proven live owner during restore', () => { + publish( + agentSessionLeaseFixture({ + ownerProcess: { + hostId: 'local', + pid: 4200, + processStartTimeMs: 10, + spawnToken: 'live-proof-token' + }, + reservedSpawnToken: 'live-proof-token' + }) + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'live-proof-token' })).toBe( + true + ) + expect(gate.admitProof(PTY_ID, { sessionId: SESSION_ID, spawnToken: 'wrong-token' })).toBe( + false + ) + }) + + it('refuses when the record vanished from the store', () => { + const admission = gate.admit(PTY_ID) + expect(admission.admitted).toBe(false) + }) + + it('throws the typed refusal from assertAdmitted', () => { + publish(agentSessionLeaseFixture({ runtimeKind: 'native' })) + let thrown: unknown = null + try { + gate.assertAdmitted(PTY_ID) + } catch (error) { + thrown = error + } + expect(isAgentSessionPtyWriteRefusedError(thrown)).toBe(true) + if (!isAgentSessionPtyWriteRefusedError(thrown)) { + return + } + expect(thrown.refusal.code).toBe('agent_session_conflict') + expect(thrown.refusal.ownerRuntimeKind).toBe('native') + }) + + it('returns the admittance from assertAdmitted so later chunks can be fenced', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 3 })) + expect(gate.assertAdmitted(PTY_ID)).toEqual({ sessionId: SESSION_ID, runtimeFence: 3 }) + }) + + it('throws from assertReadmitted once the lease moved under an in-flight write', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 3 })) + const admitted = gate.assertAdmitted(PTY_ID) + publish(agentSessionLeaseFixture({ runtimeFence: 4 })) + expect(() => gate.assertReadmitted(PTY_ID, admitted)).toThrowError( + 'agent_session_checkpoint_stale' + ) + }) + + it('lets an in-flight write finish while the lease is unchanged', () => { + publish(agentSessionLeaseFixture({ runtimeFence: 3 })) + const admitted = gate.assertAdmitted(PTY_ID) + expect(() => gate.assertReadmitted(PTY_ID, admitted)).not.toThrow() + }) + + it('leaves an in-flight write on an exempt PTY alone', () => { + const admitted = gate.assertAdmitted('ordinary-shell') + expect(admitted).toEqual({ sessionId: null, runtimeFence: null }) + expect(() => gate.assertReadmitted('ordinary-shell', admitted)).not.toThrow() + }) + + it('refuses an exempt write that acquired a refusing binding mid-flight', () => { + const admitted = gate.assertAdmitted('late-bound') + publish(agentSessionLeaseFixture({ sessionId: 'session-beta-2', runtimeKind: 'native' })) + gate.bindPty('late-bound', 'session-beta-2') + expect(() => gate.assertReadmitted('late-bound', admitted)).toThrow() + }) +}) diff --git a/src/main/runtime/agent-session-pty-write-gate.ts b/src/main/runtime/agent-session-pty-write-gate.ts new file mode 100644 index 00000000000..8fb078eb3b9 --- /dev/null +++ b/src/main/runtime/agent-session-pty-write-gate.ts @@ -0,0 +1,179 @@ +/** + * Host-side registry that maps a live PTY to the durable agent session it belongs to, and answers + * whether bytes may enter that PTY right now. + * + * It lives on the execution host that owns the process, never in a client: a paired desktop, a + * mobile client, and an SSH-attached Orca all reach the same gate through the host that spawned + * the PTY. With nothing bound — the state of every build until a later part registers records — + * `admit` short-circuits to admitted, so no existing terminal path changes behavior or cost. + */ + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + AgentSessionPtyWriteRefusedError, + evaluateAgentSessionPtyWriteAdmission, + reevaluateAgentSessionPtyWriteAdmission, + type AgentSessionPtyBinding, + type AgentSessionPtyWriteAdmission +} from '../../shared/agent-session-pty-write-admission' + +export type AgentSessionRecordLookup = (sessionId: string) => AgentSessionRecord | null + +/** What an admitted write carries forward so its later chunks can be fenced against the same lease. */ +export type AgentSessionPtyWriteAdmittance = { + sessionId: string | null + runtimeFence: number | null +} + +const ADMITTED_UNBOUND: AgentSessionPtyWriteAdmission = { + admitted: true, + sessionId: null, + runtimeFence: null +} + +/** + * A pane binding, plus the adoption attempt that is still proving it. + * + * `attemptToken` is null once an owner is proven — a settled binding no attempt may take away. + * While it is non-null the binding belongs to that one in-flight attempt, which is the only thing + * that tells two overlapping adoptions apart: both carry the same sessionId. + */ +type BoundPane = { sessionId: string; attemptToken: string | null } + +export class AgentSessionPtyWriteGate { + private readonly panesByPtyId = new Map() + private lookup: AgentSessionRecordLookup | null = null + + /** Point the gate at the durable store. Until this is called nothing can be enforced. */ + attachRecordLookup(lookup: AgentSessionRecordLookup): void { + this.lookup = lookup + } + + detachRecordLookup(): void { + this.lookup = null + this.panesByPtyId.clear() + } + + bindPty(ptyId: string, sessionId: string): void { + this.panesByPtyId.set(ptyId, { sessionId, attemptToken: null }) + } + + unbindPty(ptyId: string): void { + this.panesByPtyId.delete(ptyId) + } + + /** + * Claim the pane for one adoption attempt, identified by the spawn token its reservation minted. + * A settled owner keeps the pane; another attempt's claim is superseded, because the newest + * reservation is the one the record now names. Callers must already have refused a pane bound to + * a different session. + */ + bindPtyForAttempt(ptyId: string, sessionId: string, attemptToken: string): boolean { + const current = this.panesByPtyId.get(ptyId) + if (current && current.attemptToken === null) { + return false + } + this.panesByPtyId.set(ptyId, { sessionId, attemptToken }) + return true + } + + /** Promote this attempt's claim to a settled binding once its owner is proven. */ + settlePtyAttempt(ptyId: string, attemptToken: string): boolean { + const current = this.panesByPtyId.get(ptyId) + if (current?.attemptToken !== attemptToken) { + return false + } + this.panesByPtyId.set(ptyId, { sessionId: current.sessionId, attemptToken: null }) + return true + } + + /** + * Compare-and-clear: hand the pane back only while this attempt still holds it. A losing attempt + * that unbinds by pty alone rips the pane out from under the attempt that superseded it, and then + * that one's proof is refused too — both fail where one should have won. + */ + releasePtyAttempt(ptyId: string, attemptToken: string): boolean { + if (this.panesByPtyId.get(ptyId)?.attemptToken !== attemptToken) { + return false + } + this.panesByPtyId.delete(ptyId) + return true + } + + boundSessionId(ptyId: string): string | null { + return this.panesByPtyId.get(ptyId)?.sessionId ?? null + } + + /** False while no PTY is bound, which is every write path in today's builds. */ + get enforcing(): boolean { + return this.lookup !== null && this.panesByPtyId.size > 0 + } + + admit(ptyId: string): AgentSessionPtyWriteAdmission { + if (!this.enforcing) { + return ADMITTED_UNBOUND + } + return evaluateAgentSessionPtyWriteAdmission(this.binding(ptyId)) + } + + /** Narrow pre-ownership input for the reserved TUI's provider identity probe. */ + admitProof(ptyId: string, authority: { sessionId: string; spawnToken: string }): boolean { + const binding = this.binding(ptyId) + const lease = binding?.record?.lease + const provingReservation = + lease?.claimStatus === 'reserved' && + lease.handoffStage === 'new-owner-proving' && + lease.reservedSpawnToken === authority.spawnToken && + (lease.ownerProcess === null || lease.ownerProcess.spawnToken === authority.spawnToken) + const reprovingLiveOwner = + lease?.claimStatus === 'live' && + lease.handoffStage === null && + lease.ownerProcess?.spawnToken === authority.spawnToken && + lease.provenHandleLinkId !== null + return Boolean( + binding?.sessionId === authority.sessionId && + binding.record?.sessionId === authority.sessionId && + lease?.runtimeKind === 'tui' && + (provingReservation || reprovingLiveOwner) && + !lease.unreconciled + ) + } + + /** Re-check a write already in flight against the fence it was admitted under. */ + readmit(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): AgentSessionPtyWriteAdmission { + if (admitted.sessionId === null && !this.enforcing) { + return ADMITTED_UNBOUND + } + return reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: this.binding(ptyId) }) + } + + /** Admit or throw the typed refusal. Used where the caller already reports errors to a client. */ + assertAdmitted(ptyId: string): AgentSessionPtyWriteAdmittance { + const admission = this.admit(ptyId) + if (!admission.admitted) { + throw new AgentSessionPtyWriteRefusedError(admission.refusal) + } + return { sessionId: admission.sessionId, runtimeFence: admission.runtimeFence } + } + + assertReadmitted(ptyId: string, admitted: AgentSessionPtyWriteAdmittance): void { + const admission = this.readmit(ptyId, admitted) + if (!admission.admitted) { + throw new AgentSessionPtyWriteRefusedError(admission.refusal) + } + } + + private binding(ptyId: string): AgentSessionPtyBinding | null { + const pane = this.panesByPtyId.get(ptyId) + if (pane === undefined) { + return null + } + return { sessionId: pane.sessionId, record: this.lookup?.(pane.sessionId) ?? null } + } +} + +/** + * One gate per host process. Both the runtime's send paths and the PTY IPC layer consult this same + * instance, so a write cannot reach a provider by entering through the other door. + */ +export const agentSessionPtyWriteGate = new AgentSessionPtyWriteGate() diff --git a/src/main/runtime/agent-session-reconciliation-target.ts b/src/main/runtime/agent-session-reconciliation-target.ts new file mode 100644 index 00000000000..03fd77be7dd --- /dev/null +++ b/src/main/runtime/agent-session-reconciliation-target.ts @@ -0,0 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function agentSessionReconciliationTargetMatches( + current: AgentSessionRecord, + probed: AgentSessionRecord +): boolean { + return isDeepStrictEqual(current, probed) +} diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts new file mode 100644 index 00000000000..a1dfb9ccdef --- /dev/null +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' +import { AgentSessionRecordStore } from './agent-session-record-store' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-options' +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-options-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +it('fails option hydration before ownership can be proved', async () => { + await expect( + readNativeSessionOptions({ + adapter: { + readOptions: async () => { + throw new Error('model list unavailable') + } + }, + sessionId: SESSION, + fence: 2 + }) + ).rejects.toThrow('model list unavailable') +}) + +it('persists resumed provider options atomically with owner proof', async () => { + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reserved = await store.reserveOwner({ + sessionId: SESSION, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-options', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'new session' }, + operation: { + callerKey: 'client-1', + operationId: '1800000000000-00000000000000000000000000000000', + fingerprint: 'options-create' + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: SESSION, + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1, + spawnToken: 'spawn-options' + }, + now: NOW + }) + const options = await readNativeSessionOptions({ + adapter: { + readOptions: async () => ({ + models: [], + current: { model: 'gpt-tui', effort: 'low' } + }) + }, + sessionId: SESSION, + fence + }) + await store.proveOwner({ + sessionId: SESSION, + fence, + link: { + linkId: 'codex-options-1', + handle: { provider: 'codex', threadId: 'thread-options' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW, + ...(options ? { options } : {}) + }) + + const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) +}) diff --git a/src/main/runtime/agent-session-record-options.ts b/src/main/runtime/agent-session-record-options.ts new file mode 100644 index 00000000000..8cf88d685f3 --- /dev/null +++ b/src/main/runtime/agent-session-record-options.ts @@ -0,0 +1,14 @@ +import type { + AgentSessionOptionsReplacement, + AgentSessionRecord +} from '../../shared/agent-session-record' + +export function replaceAgentSessionRecordOptions( + record: AgentSessionRecord, + replacement: AgentSessionOptionsReplacement +): AgentSessionRecord { + if (record.lease.runtimeFence !== replacement.fence || record.lease.claimStatus !== 'live') { + throw new Error('agent_session_ownership_unknown') + } + return { ...record, options: { ...replacement.options }, updatedAt: replacement.now } +} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts new file mode 100644 index 00000000000..8b8ef6cc4e4 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -0,0 +1,343 @@ +/** + * On-disk layer for the durable agent-session store. + * + * Every mutation is a whole-file atomic transaction — temp write, fsync, rename — so a SIGKILL + * at any point leaves either the previous committed state or the next one, never a torn lease. + * That matters because this host restarts its runtime often; a half-written lease would be + * indistinguishable from an owner whose identity cannot be verified. + */ + +import { createHash } from 'node:crypto' +import { chmod, mkdir, readFile, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + agentSessionOperationKey, + isAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + isAgentSessionRecord, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { + copyFileDurable, + durableWriteTempPath, + renameDurable, + writeTempFileDurable +} from '../durable-file-write' + +export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const + +export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' + +export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } + +export type AgentSessionStoreState = { + schemaVersion: number + hostId: string + records: Map + operations: Map + retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Rows this build cannot validate, kept with a durable refusal reason. */ + unreadableRecords: Map +} + +export type LoadedAgentSessionStore = { + state: AgentSessionStoreState + storeFound: boolean + /** True when the file was written by a newer schema; this host reads but never writes it. */ + readOnly: boolean + /** True when the primary file was unusable and the previous committed copy was used. */ + recoveredFromBackup: boolean + /** True when the normalized current-schema quarantine must be persisted. */ + needsRewrite: boolean +} + +export function agentSessionStorePath(directory: string): string { + return join(directory, AGENT_SESSION_STORE_FILE_NAME) +} + +function backupPath(filePath: string): string { + return `${filePath}.bak` +} + +function emptyState(hostId: string): AgentSessionStoreState { + return { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId, + records: new Map(), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map() + } +} + +export function agentSessionStoreRevision(state: AgentSessionStoreState): string { + return createHash('sha256') + .update(String(state.schemaVersion)) + .update('\0') + .update(serializeState(state)) + .digest('hex') +} + +function parseState( + raw: string, + hostId: string +): { state: AgentSessionStoreState; needsRewrite: boolean } | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const file = parsed as { + schemaVersion?: unknown + hostId?: unknown + records?: unknown + operations?: unknown + retiredClaimKeys?: unknown + unusableRecords?: unknown + } + if ( + !Number.isSafeInteger(file.schemaVersion) || + (file.schemaVersion as number) < 0 || + typeof file.hostId !== 'string' + ) { + return null + } + const schemaVersion = file.schemaVersion as number + if (schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + if ( + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && + (typeof file.records !== 'object' || file.records === null || Array.isArray(file.records)) + ) { + return null + } + if ( + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && + (typeof file.operations !== 'object' || + file.operations === null || + Array.isArray(file.operations) || + !Array.isArray(file.retiredClaimKeys) || + typeof file.unusableRecords !== 'object' || + file.unusableRecords === null || + Array.isArray(file.unusableRecords)) + ) { + return null + } + const state = emptyState(hostId) + state.schemaVersion = schemaVersion + state.hostId = file.hostId + let needsRewrite = false + if (typeof file.records === 'object' && file.records !== null) { + for (const [sessionId, value] of Object.entries(file.records)) { + const record = isAgentSessionRecord(value) ? value : null + if (record?.sessionId === sessionId) { + state.records.set(sessionId, record) + } else { + const valueSchemaVersion = + typeof value === 'object' && + value !== null && + (value as { schemaVersion?: unknown }).schemaVersion + const reason = record + ? 'record_key_session_id_mismatch' + : valueSchemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION + ? 'current_shape_invalid' + : 'unsupported_schema' + state.unreadableRecords.set(sessionId, { reason, raw: value }) + needsRewrite ||= schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION + } + } + } + if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) { + for (const [sessionId, value] of Object.entries(file.unusableRecords)) { + if (typeof value !== 'object' || value === null) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + const unusable = value as { reason?: unknown; raw?: unknown } + if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw }) + } + } + if (typeof file.operations === 'object' && file.operations !== null) { + for (const [key, value] of Object.entries(file.operations)) { + if (!isAgentSessionOperationRow(value)) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.operations.set(key, value) + } + } + if (Array.isArray(file.retiredClaimKeys)) { + for (const entry of file.retiredClaimKeys) { + const key = entry as Partial + if ( + typeof key?.keyId !== 'string' || + key.keyId.length === 0 || + key.keyId.length > 512 || + !Number.isSafeInteger(key.retiredAt) || + (key.retiredAt as number) < 0 + ) { + if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) + } + } + return { state, needsRewrite } +} + +/** A record the primary retained as unreadable may still have a valid copy in the previous + * committed state. Adopting it keeps the session reachable — the lease is re-adjudicated + * like any other — while the unreadable bytes stay quarantined verbatim. */ +async function salvageUnreadableRecordsFromBackup( + state: AgentSessionStoreState, + backupFilePath: string, + hostId: string +): Promise { + const missing = [...state.unreadableRecords.keys()].filter( + (sessionId) => !state.records.has(sessionId) + ) + if (missing.length === 0) { + return + } + let raw: string + try { + raw = await readFile(backupFilePath, 'utf-8') + } catch { + return + } + const backup = parseState(raw, hostId) + if (!backup) { + return + } + for (const sessionId of missing) { + const record = backup.state.records.get(sessionId) + if (record) { + state.records.set(sessionId, record) + } + } +} + +export async function loadAgentSessionStore( + filePath: string, + hostId: string +): Promise { + let unusableStoreFound = false + for (const [candidate, recoveredFromBackup] of [ + [filePath, false], + [backupPath(filePath), true] + ] as const) { + let raw: string + try { + raw = await readFile(candidate, 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // Only a missing or unparseable primary means "fall back". A transient read failure + // (EACCES, EIO, EMFILE) says nothing about the primary's contents, and treating it as + // recovery would replace newer state with a stale backup and latch the recovery path. + if (!recoveredFromBackup) { + throw new Error('agent_session_store_corrupt') + } + unusableStoreFound = true + } + continue + } + const parsed = parseState(raw, hostId) + if (!parsed) { + unusableStoreFound = true + continue + } + if (!recoveredFromBackup) { + await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) + } + return { + state: parsed.state, + storeFound: true, + readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, + recoveredFromBackup, + needsRewrite: parsed.needsRewrite + } + } + if (unusableStoreFound) { + throw new Error('agent_session_store_corrupt') + } + return { + state: emptyState(hostId), + storeFound: false, + readOnly: false, + recoveredFromBackup: false, + needsRewrite: false + } +} + +function serializeState(state: AgentSessionStoreState): string { + const records: Record = Object.create(null) + for (const [sessionId, record] of state.records) { + records[sessionId] = record + } + return JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: state.hostId, + records, + operations: Object.fromEntries(state.operations), + retiredClaimKeys: state.retiredClaimKeys, + unusableRecords: Object.fromEntries(state.unreadableRecords) + }) +} + +/** + * Commit the whole state. The live path is never absent: the new content is made durable in a temp + * file first, a validated primary is COPIED to the backup, and only then does the rename publish it. + * Backup recovery keeps the known-good backup in place while publishing the repaired primary. + * + * The old ordering renamed the live file aside before writing the new one, so a death in that + * window left the profile with a backup and no primary — which is exactly the state that wedged a + * real profile. Copy, don't move. + */ +export async function saveAgentSessionStore( + filePath: string, + state: AgentSessionStoreState, + options: { primaryStatus: 'validated' | 'unusable-or-absent' } +): Promise { + const directory = dirname(filePath) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await chmod(directory, 0o700) + const tmpPath = durableWriteTempPath(filePath) + try { + await writeTempFileDurable(tmpPath, serializeState(state), 0o600) + // Only a primary parsed under the transaction lock may replace the backup. During recovery the + // primary is corrupt or absent, so the known-good backup must survive until publication. + if (options.primaryStatus === 'validated') { + await copyFileDurable(filePath, backupPath(filePath)) + } + await renameDurable(tmpPath, filePath) + } catch (error) { + await rm(tmpPath, { force: true }).catch(() => {}) + throw error + } +} diff --git a/src/main/runtime/agent-session-record-store-security.test.ts b/src/main/runtime/agent-session-record-store-security.test.ts new file mode 100644 index 00000000000..f70145cf5a2 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-security.test.ts @@ -0,0 +1,62 @@ +import { chmod, mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +let directory: string + +function reserveRequest(): AgentSessionReserveRequest { + return { + sessionId: 'session-created', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-created', + workspaceKind: 'folder' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/accounts/created' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-created', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-security-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('agent session record store security', () => { + it.skipIf(process.platform === 'win32')( + 'creates the directory and store owner-only', + async () => { + const nestedDirectory = join(directory, 'agent-sessions') + await chmod(directory, 0o755) + const store = await AgentSessionRecordStore.open({ + directory: nestedDirectory, + hostId: 'local' + }) + await store.reserveOwner(reserveRequest()) + + expect((await stat(nestedDirectory)).mode & 0o777).toBe(0o700) + expect((await stat(agentSessionStorePath(nestedDirectory))).mode & 0o777).toBe(0o600) + } + ) +}) diff --git a/src/main/runtime/agent-session-record-store-security.ts b/src/main/runtime/agent-session-record-store-security.ts new file mode 100644 index 00000000000..cc9b302ebfa --- /dev/null +++ b/src/main/runtime/agent-session-record-store-security.ts @@ -0,0 +1,40 @@ +import { chmod, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { + loadAgentSessionStore, + type LoadedAgentSessionStore +} from './agent-session-record-store-file' +import { withAgentSessionStoreTransactionLock } from './agent-session-store-transaction-lock' + +const OWNER_DIRECTORY_MODE = 0o700 +const OWNER_FILE_MODE = 0o600 + +async function chmodIfPresent(path: string, mode: number): Promise { + try { + await chmod(path, mode) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + } +} + +export async function hardenAgentSessionStorePermissions(filePath: string): Promise { + const directory = dirname(filePath) + await mkdir(directory, { recursive: true, mode: OWNER_DIRECTORY_MODE }) + await chmod(directory, OWNER_DIRECTORY_MODE) + await Promise.all([ + chmodIfPresent(filePath, OWNER_FILE_MODE), + chmodIfPresent(`${filePath}.bak`, OWNER_FILE_MODE) + ]) +} + +export async function loadProtectedAgentSessionStore( + filePath: string, + hostId: string +): Promise { + return withAgentSessionStoreTransactionLock(filePath, async () => { + await hardenAgentSessionStorePermissions(filePath) + return loadAgentSessionStore(filePath, hostId) + }) +} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts new file mode 100644 index 00000000000..4b325fa0ff3 --- /dev/null +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -0,0 +1,879 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { + AgentSessionExecutionLocation, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { setStoredAgentSessionHandoffStage } from './agent-session-handoff-record-transitions' +import { + AGENT_SESSION_CLAIM_KEY_RETENTION_MS, + AgentSessionRecordStore +} from './agent-session-record-store' +import { + agentSessionStorePath, + AGENT_SESSION_STORE_FILE_NAME +} from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 + +const NATIVE: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const WSL: AgentSessionExecutionLocation = { ...NATIVE, wslDistro: 'Ubuntu-22.04' } +const SSH: AgentSessionExecutionLocation = { ...NATIVE, executionHostId: 'ssh:build-box' } +const FOLDER: AgentSessionExecutionLocation = { + ...NATIVE, + workspaceId: 'workspace-2', + workspaceKind: 'folder' +} + +const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } +const UNUSED: AgentSessionOwnerProbe = { outcome: 'reservation-unused' } +const BAD_OP_STORE = '{"schemaVersion":0,"hostId":"","records":{},"operations":{"x":0}}' +const BAD_KEY_STORE = + '{"schemaVersion":1,"hostId":"","records":{},"operations":{},"retiredClaimKeys":[0]}' + +let counter = 0 + +function operationId(now = NOW): string { + counter += 1 + return `${now}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: NATIVE, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: INDETERMINATE, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +function processIdentity( + overrides: Partial = {} +): AgentSessionProcessIdentity { + return { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-a', + ...overrides + } +} + +function handleLink( + overrides: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, + origin: 'created', + mintedAtFence: 1, + observedAt: NOW, + ...overrides + } +} + +let directory: string + +async function open(hostId = 'local'): Promise { + return AgentSessionRecordStore.open({ directory, hostId }) +} + +/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ +async function establishOwner( + store: AgentSessionRecordStore, + overrides: Partial = {} +): Promise { + const reserved = await store.reserveOwner(reserveRequest(overrides)) + const fence = reserved.record.lease.runtimeFence + const sessionId = reserved.record.sessionId + await store.commitProcessIdentity({ + sessionId, + fence, + process: processIdentity({ spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' }), + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: handleLink({ mintedAtFence: fence }), + now: NOW + }) +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('acquisition path', () => { + it('admits a writer only after reservation, observed identity, and a proved handle', async () => { + const store = await open() + const reserved = await store.reserveOwner(reserveRequest()) + expect(reserved.disposition).toBe('created') + expect(reserved.record.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: 'reserved', + handoffStage: 'new-owner-proving', + ownerProcess: null, + reservedSpawnToken: 'spawn-a' + }) + + // Proving before the spawn is observed is refused. + await expect( + store.proveOwner({ sessionId: 'session-alpha', fence: 1, link: handleLink(), now: NOW }) + ).rejects.toThrow('agent_session_ownership_unknown') + + await store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: 1, + process: processIdentity(), + now: NOW + }) + const proved = await store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink(), + now: NOW + }) + expect(proved.lease).toMatchObject({ + claimStatus: 'live', + handoffStage: null, + provenHandleLinkId: 'link-1', + runtimeFence: 1 + }) + expect(proved.providerHandleChain).toHaveLength(1) + }) + + it('refuses a child that cannot echo the reserved spawn token', async () => { + const store = await open() + await store.reserveOwner(reserveRequest()) + await expect( + store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: 1, + process: processIdentity({ spawnToken: 'spawn-other' }), + now: NOW + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('accepts provider proof only for the reserved provider at the current fence', async () => { + const store = await open() + await store.reserveOwner(reserveRequest()) + await store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: 1, + process: processIdentity(), + now: NOW + }) + + await expect( + store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink({ + handle: { provider: 'codex', threadId: 'thread-1' } + }), + now: NOW + }) + ).rejects.toThrow('agent_session_provider_handle_provider_mismatch') + await expect( + store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink({ mintedAtFence: 2 }), + now: NOW + }) + ).rejects.toThrow('agent_session_provider_handle_stale_fence') + }) + + it('does not let an established owner re-enter the proof transition', async () => { + const store = await open() + await establishOwner(store) + + await expect( + store.proveOwner({ + sessionId: 'session-alpha', + fence: 1, + link: handleLink({ + linkId: 'link-2', + origin: 'resumed', + observedAt: NOW + 1 + }), + now: NOW + 1 + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('refuses a create that carries a fence and a re-create that does not', async () => { + const store = await open() + await expect(store.reserveOwner(reserveRequest({ expectedFence: 0 }))).rejects.toThrow( + 'agent_session_checkpoint_stale' + ) + await establishOwner(store) + await expect(store.reserveOwner(reserveRequest({ expectedFence: null }))).rejects.toThrow( + 'agent_session_conflict' + ) + }) + + it.each([ + [ + 'provider', + { provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' } } + ], + ['account', { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-other' } }] + ] as const)("refuses to change a session's pinned %s", async (_name, overrides) => { + const store = await open() + await establishOwner(store) + await expect( + store.reserveOwner( + reserveRequest({ + ...overrides, + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + }) +}) + +describe('concurrent claims', () => { + it('lets only one store instance reserve a session from the same disk snapshot', async () => { + const [first, second] = await Promise.all([open(), open()]) + const results = await Promise.allSettled([ + first.reserveOwner(reserveRequest()), + second.reserveOwner(reserveRequest()) + ]) + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const refused = results.find((result) => result.status === 'rejected') + expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_conflict') + + const persisted = await open() + expect(persisted.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + expect(persisted.listOperationRows()).toHaveLength(1) + }) + + it('lets exactly one of two concurrent reservations win and never spawns the loser', async () => { + const store = await open() + await establishOwner(store) + const request = () => + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + spawnToken: 'spawn-b', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + const results = await Promise.allSettled([ + store.reserveOwner(request()), + store.reserveOwner(request()) + ]) + const granted = results.filter((result) => result.status === 'fulfilled') + expect(granted).toHaveLength(1) + const refused = results.find((result) => result.status === 'rejected') + expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_checkpoint_stale') + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) + }) + + it('serializes concurrent creates of the same session id', async () => { + const store = await open() + const results = await Promise.allSettled([ + store.reserveOwner(reserveRequest()), + store.reserveOwner(reserveRequest()) + ]) + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + }) + + it('replays a retried operation id instead of reserving twice', async () => { + const store = await open() + const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } + const first = await store.reserveOwner(reserveRequest({ operation })) + const second = await store.reserveOwner(reserveRequest({ operation })) + expect(second.disposition).toBe('replayed') + expect(second.record.lease.runtimeFence).toBe(first.record.lease.runtimeFence) + expect(store.listOperationRows()).toHaveLength(1) + }) + + it('refuses the same operation id carrying different parameters', async () => { + const store = await open() + const operationId_ = operationId() + await store.reserveOwner( + reserveRequest({ + operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-1' } + }) + ) + await expect( + store.reserveOwner( + reserveRequest({ + sessionId: 'session-beta', + operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-9' } + }) + ) + ).rejects.toThrow('agent_session_operation_conflict') + }) + + it('rolls the in-memory state back when a transaction throws', async () => { + const store = await open() + await establishOwner(store) + const before = store.getRecord('session-alpha') + await expect( + store.reserveOwner(reserveRequest({ expectedFence: 1, probe: INDETERMINATE })) + ).rejects.toThrow('agent_session_ownership_unknown') + expect(store.getRecord('session-alpha')).toEqual(before) + expect(store.listOperationRows()).toHaveLength(1) + }) + + it('never keeps a change in memory that failed to commit to disk', async () => { + const store = await open() + await establishOwner(store) + const before = store.getRecord('session-alpha') + // Losing both committed copies must not reset the live store to empty authority. + await rm(directory, { recursive: true, force: true }) + await expect( + store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 9, sequence: 9 }, + now: NOW + }) + ).rejects.toThrow() + expect(store.getRecord('session-alpha')).toEqual(before) + expect(store.getRecord('session-alpha')?.lease.journalCheckpoint).toBeNull() + }) +}) + +describe('expiry is not eviction', () => { + it('never grants a second owner on a lapsed deadline alone', async () => { + const store = await open() + const owned = await establishOwner(store) + const wellPastDeadline = owned.lease.leaseDeadlineAt + 60 * 60 * 1000 + for (const probe of [INDETERMINATE, MATCHED] as const) { + await expect( + store.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe, + now: wellPastDeadline, + operation: { + callerKey: 'client-1', + operationId: operationId(wellPastDeadline), + fingerprint: 'fp-2' + } + }) + ) + ).rejects.toThrow(/agent_session_(ownership_unknown|conflict)/) + } + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + + // Only proof of death moves it. + const evicted = await store.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + now: wellPastDeadline + }) + expect(evicted.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released' }) + expect(evicted.lease.deathEvidence?.kind).toBe('pid-absent') + }) + + it('refuses to evict an owner it cannot prove dead', async () => { + const store = await open() + await establishOwner(store) + await expect( + store.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: INDETERMINATE, + now: NOW + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('stops renewing a lease it can no longer vouch for', async () => { + const store = await open() + await establishOwner(store) + const renewed = await store.renewLease({ + sessionId: 'session-alpha', + fence: 1, + childProbe: MATCHED, + now: NOW + 5_000 + }) + expect(renewed.lease.lastRenewedAt).toBe(NOW + 5_000) + await expect( + store.renewLease({ + sessionId: 'session-alpha', + fence: 1, + childProbe: { outcome: 'identity-matched', matchedOn: [] }, + now: NOW + 10_000 + }) + ).rejects.toThrow('agent_session_ownership_unknown') + }) +}) + +describe('restart reconciliation', () => { + it('survives a restart and grants no writer until adjudicated', async () => { + const first = await open() + await establishOwner(first) + + const reopened = await open() + const loaded = reopened.getRecord('session-alpha') + expect(loaded?.lease.unreconciled).toBe(true) + expect(loaded?.providerHandleChain).toHaveLength(1) + expect(loaded?.accountHome).toEqual({ + variable: 'CLAUDE_CONFIG_DIR', + path: '/home/dev/.claude-work' + }) + // Every mutating path is closed while unreconciled. + await expect( + reopened.renewLease({ sessionId: 'session-alpha', fence: 1, childProbe: MATCHED, now: NOW }) + ).rejects.toThrow('execution_owner_reconciling') + await expect( + reopened.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('execution_owner_reconciling') + }) + + it('re-adopts a live owner without moving the fence', async () => { + const first = await open() + await establishOwner(first) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 1_000 }) + const record = reopened.getRecord('session-alpha') + expect(record?.lease).toMatchObject({ + unreconciled: false, + runtimeFence: 1, + claimStatus: 'live' + }) + expect(record?.lease.provenHandleLinkId).toBe('link-1') + }) + + it('never applies a stale restart probe to a replacement owner', async () => { + const writer = await open() + await establishOwner(writer) + const reconciler = await open() + let releaseProbe!: (probe: AgentSessionOwnerProbe) => void + let markProbeStarted!: () => void + const probeStarted = new Promise((resolve) => (markProbeStarted = resolve)) + const probeResult = new Promise((resolve) => (releaseProbe = resolve)) + const reconciliation = reconciler.reconcileOnRestart({ + probe: async () => { + markProbeStarted() + return probeResult + }, + now: NOW + 1_000 + }) + + await probeStarted + await writer.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + now: NOW + 100 + }) + const replacement = await writer.reserveOwner( + reserveRequest({ + expectedFence: 2, + probe: UNUSED, + spawnToken: 'spawn-b', + operation: { + callerKey: 'client-1', + operationId: operationId(NOW + 200), + fingerprint: 'fp-2' + }, + now: NOW + 200 + }) + ) + await writer.commitProcessIdentity({ + sessionId: 'session-alpha', + fence: replacement.record.lease.runtimeFence, + process: processIdentity({ pid: 5252, spawnToken: 'spawn-b' }), + now: NOW + 300 + }) + await writer.proveOwner({ + sessionId: 'session-alpha', + fence: replacement.record.lease.runtimeFence, + link: handleLink({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }), + now: NOW + 300 + }) + + releaseProbe({ outcome: 'pid-absent' }) + expect(await reconciliation).toEqual(new Map()) + const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + expect(persisted.records['session-alpha'].lease).toMatchObject({ + runtimeFence: 3, + claimStatus: 'live', + ownerProcess: { pid: 5252, spawnToken: 'spawn-b' }, + unreconciled: false + }) + }) + + it('keeps the fence monotonic across a restart and never reuses a retired fence', async () => { + const first = await open() + await establishOwner(first) + await first.evictProvenDeadOwner({ + sessionId: 'session-alpha', + expectedFence: 1, + probe: { outcome: 'exit-observed' }, + now: NOW + }) + + const second = await open() + await second.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) + const afterRestart = second.getRecord('session-alpha')?.lease.runtimeFence ?? 0 + expect(afterRestart).toBeGreaterThanOrEqual(2) + + const reacquired = await second.reserveOwner( + reserveRequest({ + expectedFence: afterRestart, + probe: UNUSED, + spawnToken: 'spawn-b', + operation: { + callerKey: 'client-1', + operationId: operationId(NOW + 1_000), + fingerprint: 'fp-2' + }, + now: NOW + 1_000 + }) + ) + expect(reacquired.record.lease.runtimeFence).toBe(afterRestart + 1) + + const third = await open() + expect(third.getRecord('session-alpha')?.lease.runtimeFence).toBe(afterRestart + 1) + }) + + it('sends an unverifiable owner to recovery rather than releasing it', async () => { + const first = await open() + await establishOwner(first) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => INDETERMINATE, now: NOW + 1_000 }) + const lease = reopened.getRecord('session-alpha')?.lease + expect(lease).toMatchObject({ handoffStage: 'recovering', runtimeFence: 1 }) + expect(lease?.ownerProcess).not.toBeNull() + await expect( + reopened.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { + callerKey: 'client-1', + operationId: operationId(NOW + 1_000), + fingerprint: 'fp-2' + }, + now: NOW + 1_000 + }) + ) + ).rejects.toThrow('agent_session_ownership_unknown') + }) + + it('keeps a conflict conflicted across a restart that proves nothing', async () => { + const first = await open() + await establishOwner(first) + await first.markClaimConflicted('session-alpha', NOW) + + const reopened = await open() + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'indeterminate', reason: 'no answer' }), + now: NOW + }) + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'conflicted', + handoffStage: 'manual-recovery' + }) + await expect( + reopened.reserveOwner( + reserveRequest({ + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + }) + + it('releases a conflict whose named owner is proven gone at restart', async () => { + // A conflict with no exit is a session the user can never open again; present-time proof that + // the process the conflict names has exited leaves no claimant left to protect. + const first = await open() + await establishOwner(first) + await first.markClaimConflicted('session-alpha', NOW) + + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW }) + + const lease = reopened.getRecord('session-alpha')?.lease + expect(lease).toMatchObject({ + claimStatus: 'released', + handoffStage: null, + deathEvidence: { kind: 'pid-absent' } + }) + const reacquired = await reopened.reserveOwner( + reserveRequest({ + expectedFence: lease?.runtimeFence ?? null, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + expect(reacquired.disposition).toBe('reserved') + }) + + it('frees a reservation that provably never spawned', async () => { + const first = await open() + await first.reserveOwner(reserveRequest()) + const reopened = await open() + await reopened.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'released', + runtimeFence: 2, + reservedSpawnToken: null + }) + }) + + it('carries the operation ledger across a restart so a retry is still a replay', async () => { + const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } + const first = await open() + await first.reserveOwner(reserveRequest({ operation })) + await first.recordOperationOutcome({ + callerKey: operation.callerKey, + operationId: operation.operationId, + outcome: { status: 'succeeded', sessionId: 'session-alpha' } + }) + + const reopened = await open() + expect(reopened.listOperationRows()).toHaveLength(1) + const replayed = await reopened.reserveOwner(reserveRequest({ operation })) + expect(replayed.disposition).toBe('replayed') + expect(replayed.record.sessionId).toBe('session-alpha') + }) +}) + +describe('host and workspace isolation', () => { + it.each([ + ['WSL', WSL], + ['SSH', SSH], + ['another workspace', FOLDER], + ['another workspace kind', { ...NATIVE, workspaceKind: 'folder' }] + ] as const)('refuses to move one session id to %s', async (_name, location) => { + const store = await open() + await establishOwner(store) + await expect( + store.reserveOwner( + reserveRequest({ + location, + expectedFence: 1, + probe: { outcome: 'pid-absent' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } + }) + ) + ).rejects.toThrow('agent_session_conflict') + }) + + it('keeps native, WSL, and SSH sessions in separate scopes', async () => { + const store = await open() + await establishOwner(store, { sessionId: '__proto__' }) + await establishOwner(store, { sessionId: 'session-wsl', location: WSL, spawnToken: 'spawn-b' }) + await establishOwner(store, { sessionId: 'session-ssh', location: SSH, spawnToken: 'spawn-c' }) + await establishOwner(store, { + sessionId: 'session-folder', + location: FOLDER, + spawnToken: 'spawn-d' + }) + + expect(store.listByScope(NATIVE).map((record) => record.sessionId)).toEqual(['__proto__']) + expect(store.listByScope(WSL).map((record) => record.sessionId)).toEqual(['session-wsl']) + expect(store.listByScope(SSH).map((record) => record.sessionId)).toEqual(['session-ssh']) + expect(store.listByScope(FOLDER).map((record) => record.sessionId)).toEqual(['session-folder']) + expect((await open()).getRecord('__proto__')).not.toBeNull() + }) + + it('preserves the workspace kind so a folder workspace is never read back as a worktree', async () => { + const first = await open() + await establishOwner(first, { sessionId: 'session-folder', location: FOLDER }) + const reopened = await open() + expect(reopened.getRecord('session-folder')?.location).toEqual(FOLDER) + }) +}) + +describe('orphans, claim keys, checkpoints, and unreadable rows', () => { + it('calls a spawn token with no lease an orphan', async () => { + const store = await open() + await establishOwner(store) + expect(store.listOrphanSpawnTokens(['spawn-a', 'spawn-z'])).toEqual(['spawn-z']) + }) + + it('keeps a retired claim key verifiable for the retention window', async () => { + const store = await open() + await store.retireClaimKey('key-1', NOW) + expect(store.isClaimKeyVerifiable('key-1', NOW + AGENT_SESSION_CLAIM_KEY_RETENTION_MS)).toBe( + true + ) + expect( + store.isClaimKeyVerifiable('key-1', NOW + AGENT_SESSION_CLAIM_KEY_RETENTION_MS + 1) + ).toBe(false) + expect(store.isClaimKeyVerifiable('key-unknown', NOW)).toBe(true) + }) + + it('refuses a journal checkpoint that moves backwards', async () => { + const store = await open() + await establishOwner(store) + await store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 2, sequence: 10 }, + now: NOW + }) + await expect( + store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 2, sequence: 9 }, + now: NOW + }) + ).rejects.toThrow('agent_session_checkpoint_stale') + await expect( + store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 1, sequence: 999 }, + now: NOW + }) + ).rejects.toThrow('agent_session_checkpoint_stale') + const advanced = await store.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 3, sequence: 0 }, + now: NOW + }) + expect(advanced.lease.journalCheckpoint).toEqual({ epoch: 3, sequence: 0 }) + }) + + it('rejects a handoff stage change under a different operation id', async () => { + const store = await open() + await establishOwner(store) + await setStoredAgentSessionHandoffStage(store, { + sessionId: 'session-alpha', + fence: 1, + stage: 'preparing', + handoffOperationId: 'op-1', + now: NOW + }) + await expect( + setStoredAgentSessionHandoffStage(store, { + sessionId: 'session-alpha', + fence: 1, + stage: 'old-owner-stopped', + handoffOperationId: 'op-2', + now: NOW + }) + ).rejects.toThrow('agent_session_operation_conflict') + }) + + it.each([ + [ + 'invalid checkpoint', + (record: AgentSessionRecord) => + Object.assign(record.lease, { journalCheckpoint: { epoch: 'bad', sequence: 1 } }) + ], + [ + 'missing live proof', + (record: AgentSessionRecord) => Object.assign(record.lease, { provenHandleLinkId: null }) + ] + ])('quarantines a record with %s', async (_name, corrupt) => { + const first = await open() + await establishOwner(first) + const filePath = agentSessionStorePath(directory) + const raw = JSON.parse(await readFile(filePath, 'utf-8')) + corrupt(raw.records['session-alpha']) + await writeFile(filePath, JSON.stringify(raw)) + expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) + }) + + it('recovers the previous committed state when the primary file is corrupt', async () => { + const first = await open() + await establishOwner(first) + // A second commit leaves the first as the backup. + await first.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 1, sequence: 1 }, + now: NOW + }) + await writeFile(join(directory, AGENT_SESSION_STORE_FILE_NAME), '{ truncated') + + const reopened = await open() + expect(reopened.recoveredFromBackup).toBe(true) + expect(reopened.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + + // The next transaction completes. It used to reject forever: the latch that guarded against + // the lost commit's fence had no exit, so a profile in this state could never write again. + await expect(reopened.retireClaimKey('key-2', NOW)).resolves.not.toThrow() + // Safety is kept by recording a FLOOR the next grant must clear, not by rewriting the current + // fence: `live` means a handle proven at exactly that number, so moving it would invalidate the + // record. The floor dominates the highest fence the lost commit could have granted (1 + 1). + const recovered = reopened.getRecord('session-alpha') + expect(recovered?.lease.runtimeFence).toBe(1) + expect(recovered?.lease.minimumNextFence).toBe(3) + }) + + it.each([ + ['corrupt', ['{ truncated']], + ['missing required collections', ['{"schemaVersion":1,"hostId":"local"}']], + ['invalid operation row', [BAD_OP_STORE]], + ['invalid retired key', [BAD_KEY_STORE]], + ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] + ])('fails closed when the store is %s', async (_name, copies) => { + const filePath = agentSessionStorePath(directory) + await writeFile(filePath, copies[0]) + if (copies[1]) { + await writeFile(`${filePath}.bak`, copies[1]) + } + await expect(open()).rejects.toThrow('agent_session_store_corrupt') + }) + + it('refuses to write a store written by a newer schema', async () => { + const filePath = agentSessionStorePath(directory) + await writeFile( + filePath, + JSON.stringify({ schemaVersion: 99, hostId: 'local', records: {}, operations: {} }) + ) + const store = await open() + expect(store.readOnly).toBe(true) + await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( + 'agent_session_legacy_required' + ) + }) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts new file mode 100644 index 00000000000..85f77596010 --- /dev/null +++ b/src/main/runtime/agent-session-record-store.ts @@ -0,0 +1,328 @@ +/** Durable single-writer session records and their operation ledger. */ + +import { + agentSessionOperationKey, + settleAgentSessionOperation, + type AgentSessionOperationDecision, + type AgentSessionOperationOutcome, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + admitAgentSessionOperationRow, + type AgentSessionOperationAdmission +} from './agent-session-operation-admission' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { + agentSessionScopeKey, + type AgentSessionExecutionLocation, + type AgentSessionJournalCheckpoint, + type AgentSessionOptionsReplacement, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { + commitAgentSessionProcessIdentity, + evictAgentSessionOwner, + proveAgentSessionOwner, + setAgentSessionJournalCheckpoint, + type AgentSessionProcessIdentityCommit +} from './agent-session-lease-transitions' +import { + settleFailedAgentSessionAcquisition, + settleFailedAgentSessionPostAcquisitionAttachment, + type AgentSessionFailedAcquisitionSettlement, + type AgentSessionFailedPostAcquisitionAttachmentSettlement +} from './agent-session-acquisition-failure-settlement' +import { + renewAgentSessionLeases, + type AgentSessionLeaseRenewal +} from './agent-session-lease-renewal' +import { + applyAgentSessionRestartProbes, + collectAgentSessionRestartProbes, + type AgentSessionRestartProbeArgs +} from './agent-session-restart-reconciliation' +import { replaceAgentSessionRecordOptions } from './agent-session-record-options' +import { + setAgentSessionReservationProcesslessProof, + type AgentSessionReservationProcesslessProof +} from './agent-session-processless-reservation' +import { + admitPendingAgentSessionReservationReplay, + applyAgentSessionReservation, + evaluateAgentSessionReserveOperation, + requireAgentSessionRecordForReplay, + type AgentSessionReserveRequest, + type AgentSessionReserveResult +} from './agent-session-reservation-admission' +import { + agentSessionStoreRevision, + agentSessionStorePath, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' +import { + AgentSessionStoreTransactionQueue, + markAgentSessionStoreLeasesUnreconciled +} from './agent-session-store-transaction-queue' + +export const AGENT_SESSION_LEASE_TTL_MS = 30_000, + AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 +/** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */ +export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 + +export class AgentSessionRecordStore { + private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} + + static async open(args: { directory: string; hostId: string }): Promise { + const filePath = agentSessionStorePath(args.directory) + const loaded = await loadProtectedAgentSessionStore(filePath, args.hostId) + // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart + // grants no writer on the strength of what the previous process wrote. + const diskRevision = agentSessionStoreRevision(loaded.state) + markAgentSessionStoreLeasesUnreconciled(loaded.state) + const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( + filePath, + args.hostId, + loaded, + diskRevision + ) + if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { + await transactions.persistLoadedRewrite() + } + return new AgentSessionRecordStore(transactions) + } + + private get state(): AgentSessionStoreState { + return this.transactions.state + } + + get readOnly(): boolean { + return this.transactions.readOnly + } + + get recoveredFromBackup(): boolean { + return this.transactions.recoveredFromBackup + } + + get hostId(): string { + return this.state.hostId + } + + getRecord = (sessionId: string): AgentSessionRecord | null => + this.state.records.get(sessionId) ?? null + + listRecords = (): AgentSessionRecord[] => [...this.state.records.values()] + + listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { + const scope = agentSessionScopeKey(location) + return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope) + } + + /** A record this build cannot validate: readable as present, never grantable as a writer. */ + isSessionUnreadable(sessionId: string): boolean { + return this.state.unreadableRecords.has(sessionId) + } + + listOperationRows = (): AgentSessionOperationRow[] => [...this.state.operations.values()] + + isClaimKeyVerifiable(keyId: string, now: number): boolean { + const retired = this.state.retiredClaimKeys.find((entry) => entry.keyId === keyId) + return !retired || now - retired.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS + } + + /** Spawn tokens observed on the host with no matching lease. Stop them; never adopt them. */ + listOrphanSpawnTokens(observedTokens: readonly string[]): string[] { + const leases = this.listRecords().map((record) => record.lease) + return observedTokens.filter( + (spawnToken) => classifyObservedAgentSessionSpawnToken({ spawnToken, leases }) === 'orphan' + ) + } + + /** + * Compare-and-swap reservation plus its client-operation row, committed together. A replayed + * operation returns the recorded outcome and never reaches the reservation. + */ + async reserveOwner(request: AgentSessionReserveRequest): Promise { + return this.transact(() => { + const decision = evaluateAgentSessionReserveOperation(this.state, request) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision === 'replay') { + let record = requireAgentSessionRecordForReplay(this.state, decision.row, request.sessionId) + if (decision.row.outcome.status === 'pending' && request.handoffOperationId !== null) { + record = admitPendingAgentSessionReservationReplay(record, request) + } + return { record, disposition: 'replayed' as const, operationRow: decision.row } + } + const result = applyAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) + this.state.operations.set( + agentSessionOperationKey(request.operation.callerKey, request.operation.operationId), + decision.row + ) + this.state.records.set(result.record.sessionId, result.record) + return { ...result, operationRow: decision.row } + }) + } + + async commitProcessIdentity( + args: AgentSessionProcessIdentityCommit + ): Promise { + return this.mutate(args.sessionId, (record) => + commitAgentSessionProcessIdentity({ ...args, record }) + ) + } + + setReservationProcesslessProof = ( + args: AgentSessionReservationProcesslessProof & { processlessAt: number | null } + ): Promise => + this.mutate(args.sessionId, (record) => + setAgentSessionReservationProcesslessProof({ ...args, record }) + ) + + async proveOwner(args: { + sessionId: string + fence: number + link: AgentSessionProviderHandleLink + now: number + leaseTtlMs?: number + options?: Readonly> + }): Promise { + return this.mutate(args.sessionId, (record) => { + const proved = proveAgentSessionOwner({ + record, + fence: args.fence, + link: args.link, + now: args.now, + leaseTtlMs: args.leaseTtlMs ?? AGENT_SESSION_LEASE_TTL_MS + }) + return args.options + ? replaceAgentSessionRecordOptions(proved, { ...args, options: args.options }) + : proved + }) + } + + /** Settle the failed attach and its reservation in one durable transaction. */ + settleFailedAcquisition = (args: AgentSessionFailedAcquisitionSettlement) => + this.transact(() => settleFailedAgentSessionAcquisition(this.state, args)) + + settleFailedPostAcquisitionAttachment = ( + args: AgentSessionFailedPostAcquisitionAttachmentSettlement + ) => this.transact(() => settleFailedAgentSessionPostAcquisitionAttachment(this.state, args)) + + async renewLease(args: AgentSessionLeaseRenewal): Promise { + const [renewed] = await this.renewLeases([args]) + return renewed + } + + async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { + return this.transact(() => + renewAgentSessionLeases(this.state, renewals, AGENT_SESSION_LEASE_TTL_MS) + ) + } + + async evictProvenDeadOwner(args: { + sessionId: string + expectedFence: number + probe: AgentSessionOwnerProbe + now: number + }): Promise { + return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record })) + } + + async transitionHandoff( + sessionId: string, + transition: (record: AgentSessionRecord) => AgentSessionRecord + ): Promise { + return this.mutate(sessionId, transition) + } + + async setJournalCheckpoint(args: { + sessionId: string + fence: number + checkpoint: AgentSessionJournalCheckpoint + now: number + }): Promise { + return this.mutate(args.sessionId, (record) => + setAgentSessionJournalCheckpoint({ ...args, record }) + ) + } + + /** Adjudicate every lease this host loaded. No lease grants a writer until it appears here. */ + async reconcileOnRestart( + args: AgentSessionRestartProbeArgs + ): Promise> { + const pending = this.listRecords().filter((record) => record.lease.unreconciled) + const probes = await collectAgentSessionRestartProbes(pending, args) + return this.transact(() => applyAgentSessionRestartProbes(this.state, probes, args.now)) + } + + /** Admits one non-reservation mutation through the durable ledger. */ + async admitOperation( + args: AgentSessionOperationAdmission + ): Promise { + return this.transact(() => { + const admitted = admitAgentSessionOperationRow(this.state.operations, args) + this.state.operations = admitted.rows + return admitted.decision + }) + } + + async recordOperationOutcome(args: { + callerKey?: string + operationId: string + outcome: AgentSessionOperationOutcome + }): Promise { + await this.transact(() => { + this.state.operations = settleAgentSessionOperation(this.state.operations, args) + }) + } + + async markClaimConflicted(sessionId: string, now: number): Promise { + return this.mutate(sessionId, (record) => ({ + ...record, + updatedAt: now, + // Why: a conflicted key must stay conflicted across a restart; it cannot resolve to free + // merely because the process that observed the conflict is gone. + lease: { ...record.lease, claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + })) + } + + replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => + this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) + + async retireClaimKey(keyId: string, now: number): Promise { + await this.transact(() => { + if (!this.state.retiredClaimKeys.some((entry) => entry.keyId === keyId)) { + this.state.retiredClaimKeys.push({ keyId, retiredAt: now }) + } + this.state.retiredClaimKeys = this.state.retiredClaimKeys.filter( + (entry) => now - entry.retiredAt <= AGENT_SESSION_CLAIM_KEY_RETENTION_MS + ) + }) + } + + private async mutate( + sessionId: string, + apply: (record: AgentSessionRecord) => AgentSessionRecord + ): Promise { + return this.transact(() => { + const record = this.state.records.get(sessionId) + if (!record) { + throw new Error( + this.isSessionUnreadable(sessionId) + ? 'execution_owner_reconciling' + : 'agent_session_identity_required' + ) + } + const next = apply(record) + this.state.records.set(sessionId, next) + return next + }) + } + + /** Serialize every mutation against the latest committed disk state. */ + private transact = (apply: () => T): Promise => this.transactions.transact(apply) +} diff --git a/src/main/runtime/agent-session-record-unsupported-schema.test.ts b/src/main/runtime/agent-session-record-unsupported-schema.test.ts new file mode 100644 index 00000000000..1f807870682 --- /dev/null +++ b/src/main/runtime/agent-session-record-unsupported-schema.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +const SESSION_ID = 'session-alpha-1' +let directory: string + +function reserveRequest(): AgentSessionReserveRequest { + return { + sessionId: SESSION_ID, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-new', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-00000000000000000000000000000001`, + fingerprint: 'fp-1' + }, + now: NOW + } +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-unsupported-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('unsupported agent session record schema', () => { + it('quarantines without upgrading and keeps the session fail-closed', async () => { + const filePath = agentSessionStorePath(directory) + const unsupported = { ...agentSessionRecordFixture(), schemaVersion: 1 } + const payload = JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: 'local', + records: { [SESSION_ID]: unsupported }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + await Promise.all([writeFile(filePath, payload), writeFile(`${filePath}.bak`, payload)]) + + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + + expect(store.getRecord(SESSION_ID)).toBeNull() + expect(store.isSessionUnreadable(SESSION_ID)).toBe(true) + await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( + 'execution_owner_reconciling' + ) + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted.records).not.toHaveProperty(SESSION_ID) + expect(persisted.unusableRecords[SESSION_ID]).toMatchObject({ + reason: 'unsupported_schema', + raw: { schemaVersion: 1 } + }) + }) + + it('rejects an ad-hoc store schema without rewriting it', async () => { + const filePath = agentSessionStorePath(directory) + const payload = JSON.stringify({ + schemaVersion: 1, + hostId: 'local', + records: {}, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + await writeFile(filePath, payload) + + await expect(AgentSessionRecordStore.open({ directory, hostId: 'local' })).rejects.toThrow( + 'agent_session_store_corrupt' + ) + await expect(readFile(filePath, 'utf-8')).resolves.toBe(payload) + }) +}) diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts new file mode 100644 index 00000000000..37ad172bdfa --- /dev/null +++ b/src/main/runtime/agent-session-recovery-publish-fault.test.ts @@ -0,0 +1,83 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as DurableFileWrite from '../durable-file-write' +import { + agentSessionStoreRevision, + loadAgentSessionStore, + saveAgentSessionStore, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue' + +const publishFault = vi.hoisted(() => ({ armed: false })) + +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + renameDurable: async (tmpPath: string, finalPath: string) => { + if (publishFault.armed) { + publishFault.armed = false + throw new Error('simulated death before primary publish') + } + return actual.renameDurable(tmpPath, finalPath) + } + } +}) + +let root: string +let storePath: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-publish-')) + storePath = join(root, 'agent-sessions.json') +}) + +afterEach(async () => { + publishFault.armed = false + await rm(root, { recursive: true, force: true }) +}) + +function state(generation: number): AgentSessionStoreState { + return { + schemaVersion: 2, + hostId: 'local', + records: new Map(), + operations: new Map(), + retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], + unreadableRecords: new Map() + } +} + +describe('backup recovery publication', () => { + it('keeps the valid backup when publication fails after a corrupt primary was recovered', async () => { + await saveAgentSessionStore(storePath, state(1), { primaryStatus: 'unusable-or-absent' }) + await saveAgentSessionStore(storePath, state(2), { + primaryStatus: 'validated' + }) + await writeFile(storePath, '{corrupt-primary', 'utf-8') + const knownGoodBackup = await readFile(`${storePath}.bak`, 'utf-8') + + const recovered = await loadAgentSessionStore(storePath, 'local') + expect(recovered.recoveredFromBackup).toBe(true) + expect(recovered.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') + const queue = AgentSessionStoreTransactionQueue.fromLoadedStore( + storePath, + 'local', + recovered, + agentSessionStoreRevision(recovered.state) + ) + + publishFault.armed = true + await expect(queue.persistLoadedRewrite()).rejects.toThrow( + 'simulated death before primary publish' + ) + + expect(await readFile(`${storePath}.bak`, 'utf-8')).toBe(knownGoodBackup) + const afterFault = await loadAgentSessionStore(storePath, 'local') + expect(afterFault.recoveredFromBackup).toBe(true) + expect(afterFault.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') + }) +}) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts new file mode 100644 index 00000000000..1735d67e62c --- /dev/null +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -0,0 +1,211 @@ +/** + * Reservation admission: what a reserve request means against the persisted state. + * + * Pure over a store snapshot so the compare-and-swap, the idempotency replay, and the + * location-immutability check can be reasoned about without touching the disk. The store applies + * the result inside one transaction; nothing here mutates. + */ + +import { + evaluateAgentSessionOperation, + pruneAgentSessionOperationRows, + type AgentSessionOperationDecision, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { + evaluateAgentSessionAcquisition, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + agentSessionExecutionLocationsEqual, + isAgentSessionLaunchArgs, + isAgentSessionLaunchEnv, + type AgentSessionAccountHome, + type AgentSessionExecutionLocation, + type AgentSessionLaunchArgs, + type AgentSessionLaunchEnv, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import { + reserveAgentSessionOwner, + type AgentSessionReservation +} from './agent-session-lease-transitions' +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export type AgentSessionReserveRequest = { + sessionId: string + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + accountHome: AgentSessionAccountHome + /** Arguments pinned on first reservation so owner replacement repeats the same launch. */ + launchArgs?: AgentSessionLaunchArgs + /** Current launch input validated here but never written to the durable record. */ + launchEnv?: AgentSessionLaunchEnv + runtimeKind: AgentSessionReservation['runtimeKind'] + /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ + expectedFence: number | null + /** A supplier is invoked only when this operation wins a new reservation. */ + spawnToken: string | (() => string) + claimKeyId: string + handoffOperationId: string | null + probe: AgentSessionOwnerProbe + operation: { callerKey: string; operationId: string; fingerprint: string } + now: number + leaseTtlMs?: number +} + +export type AgentSessionReserveDisposition = + | 'created' + | 'reserved' + | 'retry-reservation' + | 'replayed' + +export type AgentSessionReserveResult = { + record: AgentSessionRecord + disposition: AgentSessionReserveDisposition + operationRow: AgentSessionOperationRow +} + +export function evaluateAgentSessionReserveOperation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest +): AgentSessionOperationDecision { + state.operations = pruneAgentSessionOperationRows(state.operations, request.now) + return evaluateAgentSessionOperation({ + rows: state.operations, + callerKey: request.operation.callerKey, + operationId: request.operation.operationId, + fingerprint: request.operation.fingerprint, + now: request.now + }) +} + +export function requireAgentSessionRecordForReplay( + state: AgentSessionStoreState, + row: AgentSessionOperationRow, + sessionId: string +): AgentSessionRecord { + const replayedId = row.outcome.status === 'succeeded' ? row.outcome.sessionId : sessionId + const record = state.records.get(replayedId) + if (!record) { + // Why: the recorded effect is no longer reconstructable, and re-running it would be a second + // spawn rather than a replay. + throw new Error('agent_session_ownership_unknown') + } + return record +} + +export function admitPendingAgentSessionReservationReplay( + record: AgentSessionRecord, + request: AgentSessionReserveRequest +): AgentSessionRecord { + const decision = evaluateAgentSessionAcquisition({ + lease: record.lease, + expectedFence: record.lease.runtimeFence, + handoffOperationId: request.handoffOperationId, + probe: request.probe + }) + if (decision.decision === 'refused') { + throw new Error(decision.code) + } + if (decision.decision !== 'retry-reservation') { + // A replay may continue only its still-present reservation; recovery requires a fresh intent. + throw new Error('agent_session_ownership_unknown') + } + return record +} + +export function applyAgentSessionReservation( + state: AgentSessionStoreState, + request: AgentSessionReserveRequest, + leaseTtlMs: number +): { + record: AgentSessionRecord + disposition: Exclude +} { + if (request.launchEnv && !isAgentSessionLaunchEnv(request.launchEnv)) { + throw new Error('agent_session_launch_env_invalid') + } + if (request.launchArgs && !isAgentSessionLaunchArgs(request.launchArgs)) { + throw new Error('agent_session_launch_args_invalid') + } + const reservation: AgentSessionReservation = { + runtimeKind: request.runtimeKind, + spawnToken: + typeof request.spawnToken === 'function' ? request.spawnToken() : request.spawnToken, + claimKeyId: request.claimKeyId, + handoffOperationId: request.handoffOperationId, + leaseTtlMs: request.leaseTtlMs ?? leaseTtlMs, + now: request.now + } + const existing = state.records.get(request.sessionId) + if (!existing) { + if (state.unreadableRecords.has(request.sessionId)) { + throw new Error('execution_owner_reconciling') + } + if (request.expectedFence !== null) { + throw new Error('agent_session_checkpoint_stale') + } + return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } + } + if ( + !agentSessionExecutionLocationsEqual(existing.location, request.location) || + existing.provider !== request.provider || + existing.accountHome.variable !== request.accountHome.variable || + existing.accountHome.path !== request.accountHome.path + ) { + // Why: location, provider, and account are the session identity; changing one is a fork. + throw new Error('agent_session_conflict') + } + if (request.expectedFence === null) { + throw new Error('agent_session_conflict') + } + const pinned = { + ...existing, + ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), + ...(!existing.launchArgs && request.launchArgs ? { updatedAt: request.now } : {}) + } + return reserveAgentSessionOwner({ + record: pinned, + expectedFence: request.expectedFence, + probe: request.probe, + reservation + }) +} + +function createAgentSessionRecord( + request: AgentSessionReserveRequest, + reservation: AgentSessionReservation +): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: request.sessionId, + location: request.location, + provider: request.provider, + providerHandleChain: [], + accountHome: request.accountHome, + ...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), + createdAt: request.now, + updatedAt: request.now, + lease: { + sessionId: request.sessionId, + runtimeKind: reservation.runtimeKind, + // Why: fence 1 is the first reservation; 0 is reserved for "no owner has ever existed". + runtimeFence: 1, + handoffStage: 'new-owner-proving', + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: reservation.spawnToken, + leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, + lastRenewedAt: reservation.now, + handoffOperationId: reservation.handoffOperationId, + journalCheckpoint: null, + claimKeyId: reservation.claimKeyId, + claimStatus: 'reserved', + unreconciled: false, + deathEvidence: null + } + } +} diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts new file mode 100644 index 00000000000..0a4e3c39a54 --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionRecord +} from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' + +const NOW = 1_800_000_000_000 + +function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: 'session-restart', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'codex', + providerHandleChain: [], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, + lease: { + sessionId: 'session-restart', + runtimeKind: stage === 'preparing' ? 'native' : 'tui', + runtimeFence: 4, + handoffStage: stage, + provenHandleLinkId: null, + ownerProcess: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW - 1_000, + spawnToken: 'spawn-restart' + }, + reservedSpawnToken: 'spawn-restart', + leaseDeadlineAt: NOW + 30_000, + lastRenewedAt: NOW, + handoffOperationId: 'handoff-op-1', + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: stage === 'preparing' ? 'live' : 'reserved', + unreconciled: true, + deathEvidence: null + }, + createdAt: NOW, + updatedAt: NOW + } +} + +describe('restarted handoff adjudication', () => { + it('continues a dead preparing owner at old-owner-stopped under the same operation', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('preparing'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1', + claimStatus: 'released', + ownerProcess: null + }) + }) + + it('routes an ownerless indeterminate reservation to manual recovery at the same fence', () => { + const abandoned = record('new-owner-proving') + abandoned.lease.runtimeKind = 'native' + abandoned.lease.ownerProcess = null + expect( + adjudicateRestartedAgentSessionHandoff( + abandoned, + { outcome: 'indeterminate', reason: 'no spawn-token scan' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 4, + handoffStage: 'manual-recovery', + handoffOperationId: 'handoff-op-1', + claimStatus: 'reserved', + ownerProcess: null, + reservedSpawnToken: 'spawn-restart' + }) + }) + + it('releases a proving reservation only with durable processless proof', () => { + const processless = record('new-owner-proving') + processless.lease.runtimeKind = 'native' + processless.lease.ownerProcess = null + processless.lease.processlessAt = NOW + expect( + adjudicateRestartedAgentSessionHandoff( + processless, + { outcome: 'reservation-unused' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: null, + handoffOperationId: null, + claimStatus: 'released', + reservedSpawnToken: null + }) + }) + + it.each([ + ['dead pid', { outcome: 'pid-absent' } as const], + [ + 'reused pid with a different start time', + { outcome: 'identity-mismatch', field: 'process-start-time' } as const + ] + ])('releases a proving owner with exact %s proof', (_name, probe) => { + const proving = record('new-owner-proving') + proving.lease.runtimeKind = 'native' + expect(adjudicateRestartedAgentSessionHandoff(proving, probe, NOW + 1_000).lease).toMatchObject( + { + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: null, + handoffOperationId: null, + claimStatus: 'released', + ownerProcess: null, + reservedSpawnToken: null + } + ) + }) + + it('preserves the existing TUI handoff rollback after proving its target dead', () => { + expect( + adjudicateRestartedAgentSessionHandoff( + record('new-owner-proving'), + { outcome: 'pid-absent' }, + NOW + 1_000 + ).lease + ).toMatchObject({ + runtimeKind: 'native', + runtimeFence: 5, + handoffStage: 'old-owner-stopped', + handoffOperationId: 'handoff-op-1' + }) + }) + + it.each([ + [ + 'live exact identity', + { outcome: 'identity-matched', matchedOn: ['process-start-time'] } as const, + 'recovering' + ], + [ + 'indeterminate identity', + { outcome: 'indeterminate', reason: 'start time unavailable' } as const, + 'recovering' + ] + ] as const)('keeps the fence and token for a %s', (_name, probe, expectedStage) => { + const proving = record('new-owner-proving') + proving.lease.runtimeKind = 'native' + expect(adjudicateRestartedAgentSessionHandoff(proving, probe, NOW + 1_000).lease).toMatchObject( + { + runtimeFence: 4, + handoffStage: expectedStage, + handoffOperationId: 'handoff-op-1', + claimStatus: 'reserved', + ownerProcess: { pid: 4242, processStartTimeMs: NOW - 1_000 }, + reservedSpawnToken: 'spawn-restart' + } + ) + }) +}) diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.ts new file mode 100644 index 00000000000..99849248ace --- /dev/null +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.ts @@ -0,0 +1,76 @@ +import { + adjudicateAgentSessionRestart, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +export function adjudicateRestartedAgentSessionHandoff( + record: AgentSessionRecord, + probe: AgentSessionOwnerProbe, + now: number +): AgentSessionRecord { + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe, + observedAt: now + }) + if (adjudication.disposition === 'readopt') { + return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now }) + } + if (adjudication.disposition === 'free') { + return updateLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + processlessAt: null, + unreconciled: false, + lastRenewedAt: now + }) + } + if (adjudication.disposition !== 'evicted') { + return updateLease(record, { + ...record.lease, + handoffStage: + adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage, + claimStatus: + adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + unreconciled: false, + lastRenewedAt: now + }) + } + if (record.lease.handoffStage === 'new-owner-proving' && record.lease.runtimeKind === 'native') { + // The attempted new owner is proven absent; no writer remains to roll back or readopt. + return updateLease(record, { + ...record.lease, + runtimeFence: adjudication.nextFence, + handoffStage: null, + handoffOperationId: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: now, + deathEvidence: adjudication.evidence + }) + } + const provingTuiTarget = + record.lease.handoffStage === 'new-owner-proving' && record.lease.runtimeKind === 'tui' + return updateLease(record, { + ...record.lease, + runtimeKind: provingTuiTarget ? 'native' : record.lease.runtimeKind, + runtimeFence: adjudication.nextFence, + handoffStage: 'old-owner-stopped', + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: now, + deathEvidence: adjudication.evidence + }) +} + +function updateLease(record: AgentSessionRecord, lease: AgentSessionRecord['lease']) { + return { ...record, lease, updatedAt: lease.lastRenewedAt } +} diff --git a/src/main/runtime/agent-session-restart-lease-transitions.ts b/src/main/runtime/agent-session-restart-lease-transitions.ts new file mode 100644 index 00000000000..93e6c4333a7 --- /dev/null +++ b/src/main/runtime/agent-session-restart-lease-transitions.ts @@ -0,0 +1,90 @@ +/** + * Turning a restart adjudication into the next record. + * + * Applies one restart verdict to one loaded lease. Kept apart from the acquisition transitions + * because it is the only one that moves a lease WITHOUT a new owner proving anything — which is + * exactly the polarity that has to be read carefully. + */ + +import { + adjudicateAgentSessionRestart, + type AgentSessionOwnerProbe +} from '../../shared/agent-session-lease-adjudication' +import type { + AgentSessionHandoffStage, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' +import { withLease } from './agent-session-lease-transitions' + +/** Apply one restart adjudication. Never consults deadlines — only proof moves a lease. */ +export function applyAgentSessionRestartAdjudication(args: { + record: AgentSessionRecord + probe: AgentSessionOwnerProbe + now: number +}): AgentSessionRecord { + const { record } = args + if ( + record.lease.handoffStage === 'old-owner-stopped' && + record.lease.claimStatus === 'released' && + record.lease.ownerProcess === null + ) { + return withLease(record, { + ...record.lease, + unreconciled: false, + lastRenewedAt: args.now + }) + } + if ( + record.lease.handoffStage === 'preparing' || + record.lease.handoffStage === 'new-owner-proving' + ) { + return adjudicateRestartedAgentSessionHandoff(record, args.probe, args.now) + } + const adjudication = adjudicateAgentSessionRestart({ + lease: record.lease, + probe: args.probe, + observedAt: args.now + }) + if (adjudication.disposition === 'readopt') { + // Why: re-adoption is not a new generation, so the fence does not move. + return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now }) + } + if (adjudication.disposition === 'free') { + // Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing + // the stage restores it without moving the fence or touching the recorded death evidence. + return withLease(record, { + ...record.lease, + handoffStage: null, + handoffOperationId: null, + processlessAt: null, + unreconciled: false, + lastRenewedAt: args.now + }) + } + if (adjudication.disposition === 'evicted') { + return withLease(record, { + ...record.lease, + runtimeFence: adjudication.nextFence, + handoffStage: null, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + unreconciled: false, + lastRenewedAt: args.now, + handoffOperationId: null, + deathEvidence: adjudication.evidence + }) + } + const stage: AgentSessionHandoffStage = + adjudication.disposition === 'conflicted' ? 'manual-recovery' : adjudication.stage + return withLease(record, { + ...record.lease, + handoffStage: stage, + claimStatus: + adjudication.disposition === 'conflicted' ? 'conflicted' : record.lease.claimStatus, + unreconciled: false, + lastRenewedAt: args.now + }) +} diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts new file mode 100644 index 00000000000..b3447ec85c8 --- /dev/null +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -0,0 +1,57 @@ +import { pruneAgentSessionOperationRows } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' +import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' + +export type AgentSessionRestartProbeArgs = { + probe: (record: AgentSessionRecord) => Promise + probeMany?: ( + records: readonly AgentSessionRecord[] + ) => Promise> + now: number +} + +type RestartProbe = { record: AgentSessionRecord; probe: AgentSessionOwnerProbe } + +export async function collectAgentSessionRestartProbes( + records: readonly AgentSessionRecord[], + args: AgentSessionRestartProbeArgs +): Promise> { + const probes = new Map() + const batched = args.probeMany ? await args.probeMany(records) : null + for (const record of records) { + probes.set(record.sessionId, { + record, + probe: + batched?.get(record.sessionId) ?? + (batched + ? { outcome: 'indeterminate', reason: 'owner batch probe returned no result' } + : await args.probe(record)) + }) + } + return probes +} + +export function applyAgentSessionRestartProbes( + state: AgentSessionStoreState, + probes: ReadonlyMap, + now: number +): Map { + const reconciled = new Map() + for (const [sessionId, probed] of probes) { + const record = state.records.get(sessionId) + if ( + !record?.lease.unreconciled || + !agentSessionReconciliationTargetMatches(record, probed.record) + ) { + continue + } + const next = applyAgentSessionRestartAdjudication({ record, probe: probed.probe, now }) + state.records.set(sessionId, next) + reconciled.set(sessionId, next) + } + state.operations = pruneAgentSessionOperationRows(state.operations, now) + return reconciled +} diff --git a/src/main/runtime/agent-session-spawn-token-process-scan.test.ts b/src/main/runtime/agent-session-spawn-token-process-scan.test.ts new file mode 100644 index 00000000000..ba0fa2fe7c4 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-process-scan.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { + findAgentSessionSpawnTokenProcesses, + scanAgentSessionSpawnTokenProcesses +} from './agent-session-spawn-token-process-scan' + +describe('agent-session spawn token process scan', () => { + it('answers null on platforms that cannot read another process environment', async () => { + // macOS and Windows have no `/proc//environ`. Answering "none" there would free a + // reservation whose child is alive and mint a second writer on the same provider session. + expect(await scanAgentSessionSpawnTokenProcesses('darwin')).toBeNull() + expect(await scanAgentSessionSpawnTokenProcesses('win32')).toBeNull() + }) + + it('propagates the host non-answer rather than reporting an empty pid list', async () => { + expect(await findAgentSessionSpawnTokenProcesses('token-1', async () => null)).toBeNull() + }) + + it('reports the pids carrying one token', async () => { + const scan = async () => new Map([['token-1', [11, 12]]]) + + expect(await findAgentSessionSpawnTokenProcesses('token-1', scan)).toEqual([11, 12]) + expect(await findAgentSessionSpawnTokenProcesses('token-2', scan)).toEqual([]) + }) +}) diff --git a/src/main/runtime/agent-session-spawn-token-process-scan.ts b/src/main/runtime/agent-session-spawn-token-process-scan.ts new file mode 100644 index 00000000000..6db1c069110 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-process-scan.ts @@ -0,0 +1,78 @@ +/** + * Host scan for processes carrying an Orca agent-session spawn token. + * + * The token is the only PID-reuse-safe identity element a child is guaranteed to carry, and it + * lives in the child's environment — which only Linux lets another process read (`/proc//environ`). + * macOS and Windows answer `null`, meaning "this host cannot enumerate", NEVER "no process carries + * it": reporting an empty result there would free a reservation whose child is alive and hand a + * second writer to the same provider session. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' +import { spawnTokenFromEnvironBlock } from './agent-session-spawn-token-readback' + +export type AgentSessionSpawnTokenScan = ReadonlyMap + +export type AgentSessionSpawnTokenScanEvidence = + | { status: 'verified'; processes: AgentSessionSpawnTokenScan } + | { status: 'unverifiable'; processes: null; platform: NodeJS.Platform } + +/** Tokens observed on this host, or null when the platform cannot answer at all. */ +export async function scanAgentSessionSpawnTokenProcesses( + platform: NodeJS.Platform = process.platform, + variable: string = CODEX_SPAWN_TOKEN_ENV +): Promise { + if (platform !== 'linux') { + return null + } + let entries: string[] + try { + entries = await readdir('/proc') + } catch { + return null + } + const observed = new Map() + for (const entry of entries) { + const pid = Number(entry) + if (!Number.isSafeInteger(pid) || pid <= 0) { + continue + } + let token: string | null + try { + token = spawnTokenFromEnvironBlock(await readFile(`/proc/${pid}/environ`, 'utf-8'), variable) + } catch { + // A process that exited mid-scan, or one this user may not read, is not evidence either way. + continue + } + if (token === null) { + continue + } + observed.set(token, [...(observed.get(token) ?? []), pid]) + } + return observed +} + +/** + * Diagnostic evidence only. A null result is deliberately typed as + * `unverifiable`, not as an empty process set; callers must never use this + * Linux read-back as ownership or orphan-reaping proof. + */ +export async function scanAgentSessionSpawnTokenEvidence( + platform: NodeJS.Platform = process.platform, + variable: string = CODEX_SPAWN_TOKEN_ENV +): Promise { + const processes = await scanAgentSessionSpawnTokenProcesses(platform, variable) + return processes === null + ? { status: 'unverifiable', processes: null, platform } + : { status: 'verified', processes } +} + +/** Pids carrying one specific token, or null when the host could not enumerate. */ +export async function findAgentSessionSpawnTokenProcesses( + spawnToken: string, + scan: () => Promise = scanAgentSessionSpawnTokenProcesses +): Promise { + const observed = await scan() + return observed === null ? null : [...(observed.get(spawnToken) ?? [])] +} diff --git a/src/main/runtime/agent-session-spawn-token-readback.test.ts b/src/main/runtime/agent-session-spawn-token-readback.test.ts new file mode 100644 index 00000000000..d86eb0d1658 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-readback.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { + readEchoedAgentSessionSpawnToken, + spawnTokenFromEnvironBlock +} from './agent-session-spawn-token-readback' + +const IDENTITY = { + hostId: 'local', + pid: process.pid, + processStartTimeMs: null, + spawnToken: 'spawn-a' +} + +describe('spawn token read-back', () => { + it('finds the token in a NUL-separated environ block', () => { + const block = [ + 'PATH=/usr/bin', + 'ORCA_AGENT_SESSION_SPAWN_TOKEN=tok-123', + 'HOME=/home/dev' + ].join('\0') + expect(spawnTokenFromEnvironBlock(block)).toBe('tok-123') + }) + + it('answers null for an absent or empty token instead of guessing', () => { + expect(spawnTokenFromEnvironBlock(['PATH=/usr/bin', 'HOME=/home/dev'].join('\0'))).toBeNull() + expect(spawnTokenFromEnvironBlock('ORCA_AGENT_SESSION_SPAWN_TOKEN=')).toBeNull() + // A prefix collision is not a match. + expect(spawnTokenFromEnvironBlock('ORCA_AGENT_SESSION_SPAWN_TOKEN_EXTRA=x')).toBeNull() + }) + + it('answers null on platforms that hide process environments', async () => { + await expect(readEchoedAgentSessionSpawnToken(IDENTITY, 'darwin')).resolves.toBeNull() + await expect(readEchoedAgentSessionSpawnToken(IDENTITY, 'win32')).resolves.toBeNull() + }) + + it('answers null when the environ file is unreadable', async () => { + await expect( + readEchoedAgentSessionSpawnToken({ ...IDENTITY, pid: 2 ** 30 }, 'linux') + ).resolves.toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-spawn-token-readback.ts b/src/main/runtime/agent-session-spawn-token-readback.ts new file mode 100644 index 00000000000..23f435fec89 --- /dev/null +++ b/src/main/runtime/agent-session-spawn-token-readback.ts @@ -0,0 +1,37 @@ +/** + * Reads the spawn token a live child carries in its environment, giving the owner probe a + * PID-reuse-safe identity element even when no start time was recorded. Only Linux exposes + * another process's environment (/proc//environ); macOS and Windows answer null, which + * the probe treats as "no answer" — never as proof in either direction. + */ + +import { readFile } from 'node:fs/promises' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' + +export function spawnTokenFromEnvironBlock( + block: string, + variable: string = CODEX_SPAWN_TOKEN_ENV +): string | null { + for (const entry of block.split('\0')) { + if (entry.startsWith(`${variable}=`)) { + const value = entry.slice(variable.length + 1) + return value.length > 0 ? value : null + } + } + return null +} + +export async function readEchoedAgentSessionSpawnToken( + identity: AgentSessionProcessIdentity, + platform: NodeJS.Platform = process.platform +): Promise { + if (platform !== 'linux') { + return null + } + try { + return spawnTokenFromEnvironBlock(await readFile(`/proc/${identity.pid}/environ`, 'utf-8')) + } catch { + return null + } +} diff --git a/src/main/runtime/agent-session-store-transaction-lock.ts b/src/main/runtime/agent-session-store-transaction-lock.ts new file mode 100644 index 00000000000..3326d563c79 --- /dev/null +++ b/src/main/runtime/agent-session-store-transaction-lock.ts @@ -0,0 +1,27 @@ +import { chmod, mkdir } from 'node:fs/promises' +import { dirname } from 'node:path' +import { lock } from 'proper-lockfile' + +const LOCK_RETRIES = { + retries: 20, + factor: 1.3, + minTimeout: 10, + maxTimeout: 250, + randomize: true +} + +/** Serialize whole-file transactions across Orca processes sharing one execution host. */ +export async function withAgentSessionStoreTransactionLock( + filePath: string, + apply: () => Promise +): Promise { + const directory = dirname(filePath) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await chmod(directory, 0o700) + const release = await lock(filePath, { realpath: false, retries: LOCK_RETRIES }) + try { + return await apply() + } finally { + await release() + } +} diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts new file mode 100644 index 00000000000..43266c0f113 --- /dev/null +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -0,0 +1,169 @@ +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStoreRevision, + loadAgentSessionStore, + saveAgentSessionStore, + type AgentSessionStoreState, + type LoadedAgentSessionStore +} from './agent-session-record-store-file' +import { withAgentSessionStoreTransactionLock } from './agent-session-store-transaction-lock' + +function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { + for (const [sessionId, record] of state.records) { + state.records.set(sessionId, { + ...record, + lease: { ...record.lease, unreconciled: true } + }) + } +} + +function mapEntriesMatch(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) { + return false + } + for (const [key, value] of left) { + if (right.get(key) !== value) { + return false + } + } + return true +} + +function agentSessionStoreStateChanged( + state: AgentSessionStoreState, + records: ReadonlyMap, + operations: ReadonlyMap, + retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], + unreadableRecords: AgentSessionStoreState['unreadableRecords'] +): boolean { + return ( + !mapEntriesMatch(state.records, records) || + !mapEntriesMatch(state.operations, operations) || + !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || + state.retiredClaimKeys.length !== retiredClaimKeys.length || + state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) + ) +} + +export class AgentSessionStoreTransactionQueue { + private queue: Promise = Promise.resolve() + private diskRecoveredFromBackup: boolean + + constructor( + private readonly filePath: string, + readonly hostId: string, + readonly readOnly: boolean, + readonly recoveredFromBackup: boolean, + private diskStoreFound: boolean, + public state: AgentSessionStoreState, + private diskRevision: string, + private needsRewrite: boolean + ) { + this.diskRecoveredFromBackup = recoveredFromBackup + } + + static fromLoadedStore( + filePath: string, + hostId: string, + loaded: LoadedAgentSessionStore, + diskRevision: string + ): AgentSessionStoreTransactionQueue { + return new AgentSessionStoreTransactionQueue( + filePath, + hostId, + loaded.readOnly, + loaded.recoveredFromBackup, + loaded.storeFound, + loaded.state, + diskRevision, + loaded.needsRewrite + ) + } + + transact(apply: () => T): Promise { + const run = this.queue.then(() => + withAgentSessionStoreTransactionLock(this.filePath, async () => { + if (this.readOnly) { + throw new Error('agent_session_legacy_required') + } + await this.refreshExternallyChangedState() + const records = new Map(this.state.records) + const operations = new Map(this.state.operations) + const retiredClaimKeys = [...this.state.retiredClaimKeys] + const unreadableRecords = new Map(this.state.unreadableRecords) + try { + // The lost commit may have granted a higher fence than the backup records show. Rather + // than refuse forever, raise every recovered fence clear of anything that commit could + // have minted, then continue in the same transaction. + const recovering = this.diskRecoveredFromBackup + if (recovering) { + raiseAgentSessionFencesAfterBackupRecovery(this.state) + } + const result = apply() + if ( + !recovering && + !this.needsRewrite && + !agentSessionStoreStateChanged( + this.state, + records, + operations, + retiredClaimKeys, + unreadableRecords + ) + ) { + return result + } + await saveAgentSessionStore(this.filePath, this.state, { + primaryStatus: this.diskStoreFound && !recovering ? 'validated' : 'unusable-or-absent' + }) + this.state.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + this.diskRevision = agentSessionStoreRevision(this.state) + this.diskRecoveredFromBackup = false + this.diskStoreFound = true + this.needsRewrite = false + return result + } catch (error) { + this.state.records = records + this.state.operations = operations + this.state.retiredClaimKeys = retiredClaimKeys + this.state.unreadableRecords = unreadableRecords + throw error + } + }) + ) + this.queue = run.catch(() => {}) + return run + } + + persistLoadedRewrite(): Promise { + return this.transact(() => undefined) + } + + private async refreshExternallyChangedState(): Promise { + const loaded = await loadAgentSessionStore(this.filePath, this.hostId) + if (this.diskStoreFound && !loaded.storeFound) { + throw new Error('agent_session_store_corrupt') + } + this.diskStoreFound ||= loaded.storeFound + const diskRevision = agentSessionStoreRevision(loaded.state) + this.diskRecoveredFromBackup = loaded.recoveredFromBackup + if (diskRevision === this.diskRevision) { + this.needsRewrite ||= loaded.needsRewrite + return + } + if (loaded.readOnly) { + throw new Error('agent_session_legacy_required') + } + markLoadedLeasesUnreconciled(loaded.state) + this.state = loaded.state + this.diskRevision = diskRevision + this.needsRewrite = loaded.needsRewrite + } +} + +export function markAgentSessionStoreLeasesUnreconciled(state: AgentSessionStoreState): void { + markLoadedLeasesUnreconciled(state) +} diff --git a/src/main/runtime/agent-session-surface-release-transition.ts b/src/main/runtime/agent-session-surface-release-transition.ts new file mode 100644 index 00000000000..d4da43f1933 --- /dev/null +++ b/src/main/runtime/agent-session-surface-release-transition.ts @@ -0,0 +1,60 @@ +// Releasing the lease when the LAST surface lets go of a session. +// +// Every other release in the wire needs a probe, because every other release is about a process +// somebody else started and nobody watched die. This one is different: the host stopped its own +// child through the adapter and the adapter proved the exit before this runs, so the evidence is +// `exit-observed` rather than an adjudicated absence. +// +// The fence still moves. A released lease at the old fence would let a mutation a client queued +// against the dead generation land on the next one. + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { assertFence, withLease } from './agent-session-lease-transitions' +import type { AgentSessionRecordStore } from './agent-session-record-store' + +/** Whether this record is one THIS host may release on its own proof. A TUI owner, a session + * mid-handoff, and a lease nobody holds are all somebody else's transition. */ +export function isSurfaceReleasableAgentSessionRecord(record: AgentSessionRecord): boolean { + return ( + record.lease.runtimeKind === 'native' && + record.lease.claimStatus === 'live' && + record.lease.handoffStage === null && + record.lease.ownerProcess !== null + ) +} + +export function releaseAgentSessionOwnerAfterSurfaceClose(args: { + record: AgentSessionRecord + expectedFence: number + now: number +}): AgentSessionRecord { + const { record } = args + assertFence(record.lease, args.expectedFence) + if (!isSurfaceReleasableAgentSessionRecord(record)) { + throw new Error('agent_session_ownership_unknown') + } + return withLease(record, { + ...record.lease, + runtimeFence: record.lease.runtimeFence + 1, + ownerProcess: null, + reservedSpawnToken: null, + processlessAt: null, + claimStatus: 'released', + lastRenewedAt: args.now, + deathEvidence: { + kind: 'exit-observed', + detail: 'the last surface holding this session released it', + observedAt: args.now + } + }) +} + +/** Applied through the store's generic transition, the same way handoff records move. */ +export function releaseStoredAgentSessionOwnerAfterSurfaceClose( + store: AgentSessionRecordStore, + args: { sessionId: string; expectedFence: number; now: number } +): Promise { + return store.transitionHandoff(args.sessionId, (record) => + releaseAgentSessionOwnerAfterSurfaceClose({ ...args, record }) + ) +} diff --git a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts new file mode 100644 index 00000000000..0d44d88d82b --- /dev/null +++ b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts @@ -0,0 +1,168 @@ +// Unreadable session records: quarantine when nothing can vouch for the session, salvage +// when the previous committed state can. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const NOW = 1_800_000_000_000 +let directory: string +let counter = 0 + +function operationId(): string { + counter += 1 + return `${NOW}-${String(counter) + .padStart(32, '0') + .replaceAll(/[^0-9a-f]/g, '0')}` +} + +function reserveRequest( + overrides: Partial = {} +): AgentSessionReserveRequest { + return { + sessionId: 'session-alpha', + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, + runtimeKind: 'native', + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'indeterminate', reason: 'no answer' }, + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, + now: NOW, + ...overrides + } +} + +async function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ +async function establishOwner(store: AgentSessionRecordStore): Promise { + const reserved = await store.reserveOwner(reserveRequest()) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId: 'session-alpha', + fence, + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' + }, + now: NOW + }) + return store.proveOwner({ + sessionId: 'session-alpha', + fence, + link: { + linkId: 'link-1', + handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +async function corruptPrimaryRecord(): Promise { + const filePath = agentSessionStorePath(directory) + const raw = JSON.parse(await readFile(filePath, 'utf-8')) + raw.records['session-alpha'].lease.runtimeFence = 'not-a-number' + await writeFile(filePath, JSON.stringify(raw)) + return filePath +} + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-salvage-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +describe('unreadable session records', () => { + it('quarantines an unreadable record with no committed copy and refuses to own it', async () => { + const first = await open() + await establishOwner(first) + const filePath = await corruptPrimaryRecord() + // No previous committed state survives, so nothing can vouch for the session. + await rm(`${filePath}.bak`, { force: true }) + + const reopened = await open() + expect(reopened.getRecord('session-alpha')).toBeNull() + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + await expect(reopened.reserveOwner(reserveRequest())).rejects.toThrow( + 'execution_owner_reconciling' + ) + // The row stays verbatim inside an explicit unusable envelope. + await reopened.retireClaimKey('key-2', NOW) + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted.records).not.toHaveProperty('session-alpha') + expect(persisted.unusableRecords['session-alpha']).toMatchObject({ + reason: 'current_shape_invalid', + raw: { lease: { runtimeFence: 'not-a-number' } } + }) + }) + + it('salvages the last committed copy of a record the primary retains as unreadable', async () => { + const first = await open() + await establishOwner(first) + // One more commit leaves the proven-owner record in the backup file. + await first.setJournalCheckpoint({ + sessionId: 'session-alpha', + fence: 1, + checkpoint: { epoch: 1, sequence: 1 }, + now: NOW + }) + const filePath = await corruptPrimaryRecord() + + const reopened = await open() + // The previous committed state vouches for the session; its lease is re-adjudicated + // like any other, and the unreadable bytes stay quarantined verbatim. + expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: 'live' + }) + expect(reopened.recoveredFromBackup).toBe(false) + expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) + await reopened.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: NOW + 1 + }) + expect(reopened.getRecord('session-alpha')?.lease.claimStatus).toBe('released') + + // Ownership is reachable again instead of refused with execution_owner_reconciling. + const reserved = await reopened.reserveOwner( + reserveRequest({ expectedFence: 2, spawnToken: 'spawn-b' }) + ) + expect(reserved.record.lease.claimStatus).toBe('reserved') + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted.records['session-alpha'].lease.claimStatus).toBe('reserved') + expect(persisted.unusableRecords['session-alpha']).toMatchObject({ + reason: 'current_shape_invalid' + }) + + // Salvage only fills gaps: with the live record readable again, a reload must never + // let the stale backup copy clobber it. + const reloaded = await open() + expect(reloaded.getRecord('session-alpha')?.lease).toMatchObject({ + claimStatus: 'reserved', + runtimeFence: 3 + }) + }) +}) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 63957f874c0..5d0faab64f9 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -141,4 +141,10 @@ describe('mobile RPC allowlist', () => { ) ).toEqual([]) }) + + it('does not expose structured agent sessions to mobile credentials', () => { + expect( + [...mobileRpcAllowlist()].filter((method) => method.startsWith('agentSession.')) + ).toEqual([]) + }) }) diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 56fa18cbb5b..9199e46783a 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -129,6 +129,26 @@ describe('agent-session create operation ledger', () => { expect(createTerminal).not.toHaveBeenCalled() }) + it('waits for Codex shell launch preparation before a structured resume', async () => { + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + + await runtime.ensureAgentSession({ + kind: 'explicit', + worktree: 'id:worktree-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'provider-session-1' } + }) + + expect(createTerminal).toHaveBeenCalledWith( + 'id:worktree-1', + expect.objectContaining({ + command: expect.stringContaining("'resume' 'provider-session-1'"), + startupCommandDelivery: 'shell-ready' + }) + ) + }) + it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts new file mode 100644 index 00000000000..083c677e39f --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' + +describe('structured agent-session create intent', () => { + it('pins the selected Codex launch home after normal launch preparation', async () => { + const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') + const runtime = new OrcaRuntimeService( + { + getSettings: () => ({ + agentDefaultEnv: { codex: { CODEX_HOME: '/configured/home' } } + }) + } as never, + undefined, + { prepareCodexStructuredLaunch } + ) + vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({ + supported: true + }) + const internal = runtime as unknown as { + resolveStructuredAgentSessionLocation: (selector: string) => Promise<{ + executionHostId: string + wslDistro: null + workspaceId: string + workspaceKind: 'git-worktree' + }> + resolveRuntimeFileTarget: (selector: string) => Promise<{ + worktree: { path: string } + }> + } + internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({ + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + })) + internal.resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { path: '/repos/workspace-1' } + })) + + const intent = await runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, + worktree: 'id:workspace-1', + agent: 'codex' + }) + + expect(prepareCodexStructuredLaunch).toHaveBeenCalledWith({ + workspacePath: '/repos/workspace-1', + launchEnv: expect.objectContaining({ CODEX_HOME: '/configured/home' }) + }) + expect(intent.accountHome).toEqual({ + variable: 'CODEX_HOME', + path: '/accounts/selected/home' + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts new file mode 100644 index 00000000000..39275ebedc1 --- /dev/null +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -0,0 +1,242 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { OrcaRuntimeService } from './orca-runtime' + +afterEach(() => setStructuredAgentSessionHost(null)) + +describe('structured session cold restoration', () => { + it('skips every heavy recovery step when no durable session store exists', async () => { + const runtime = new OrcaRuntimeService() + const refresh = vi.fn(async () => new Set()) + const ensureHost = vi.fn(async () => undefined) + const reconcileRestartLeases = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => false + internal.refreshMobileSessionPtyRecords = refresh + internal.ensureStructuredAgentSessionHost = ensureHost + setStructuredAgentSessionHost({ reconcileRestartLeases } as never) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + + expect(ensureHost).not.toHaveBeenCalled() + expect(refresh).not.toHaveBeenCalled() + expect(reconcileRestartLeases).not.toHaveBeenCalled() + }) + + it('keeps historical journal parsing outside the terminal-safety fence', async () => { + const runtime = new OrcaRuntimeService() + const refresh = vi.fn(async () => new Set()) + const ensureHost = vi.fn(async () => undefined) + const reconcileRestartLeases = vi.fn(async () => undefined) + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.refreshMobileSessionPtyRecords = refresh + internal.ensureStructuredAgentSessionHost = ensureHost + setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + + expect(ensureHost).toHaveBeenCalledOnce() + expect(refresh).toHaveBeenCalledOnce() + expect(reconcileRestartLeases).toHaveBeenCalledOnce() + expect(restoreReadableSessions).not.toHaveBeenCalled() + }) + + it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { + const runtime = new OrcaRuntimeService() + const hydrate = vi.fn() + const refresh = vi.fn(async () => new Set()) + const ensureHost = vi.fn(async () => undefined) + const reconcileRestartLeases = vi.fn(async () => undefined) + const restoreReadableSessions = vi.fn(async () => undefined) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession( + worktreeId?: string, + options?: { allowAttachedWindow?: boolean; onlyRuntimeOwnedTerminals?: boolean } + ): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrate + internal.refreshMobileSessionPtyRecords = refresh + internal.ensureStructuredAgentSessionHost = ensureHost + setStructuredAgentSessionHost({ + reconcileRestartLeases, + restoreReadableSessions, + listSessionTabs: () => [] + } as never) + + const first = runtime.restoreStructuredAgentSessionTabs() + const second = runtime.restoreStructuredAgentSessionTabs() + expect(second).toBe(first) + await Promise.all([first, second]) + + expect(hydrate).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(hydrate).toHaveBeenCalledWith() + expect(refresh).toHaveBeenCalledOnce() + expect(reconcileRestartLeases).toHaveBeenCalledOnce() + expect(restoreReadableSessions).toHaveBeenCalledOnce() + expect(ensureHost).toHaveBeenCalledOnce() + expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( + refresh.mock.invocationCallOrder[0] ?? Infinity + ) + expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( + reconcileRestartLeases.mock.invocationCallOrder[0] ?? Infinity + ) + expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( + restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity + ) + expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( + hydrate.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('normalizes a restored tab id and removes it when closed', async () => { + const runtime = new OrcaRuntimeService() + const closeSessionTab = vi.fn(async () => undefined) + runtime.setNotifier({ closeSessionTab } as never) + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + refreshMobileSessionPtyRecords(): Promise | null> + ensureStructuredAgentSessionHost(): Promise + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set() + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() + internal.refreshMobileSessionPtyRecords = async () => new Set() + internal.ensureStructuredAgentSessionHost = async () => undefined + setStructuredAgentSessionHost({ + reconcileRestartLeases: async () => undefined, + restoreReadableSessions: async () => undefined, + listSessionTabs: () => [ + { + sessionId: 'agent-session:agent-session:restored-session', + workspaceId: 'workspace-1', + agent: 'codex' + } + ] + } as never) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: 'workspace-1', + publicationEpoch: 'renderer-restored', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }], + tabs: [ + { + type: 'terminal', + id: 'terminal-tab::leaf-1', + parentTabId: 'terminal-tab', + leafId: 'leaf-1', + title: 'Terminal', + isActive: true + }, + { + type: 'terminal', + id: 'terminal-tab::leaf-2', + parentTabId: 'terminal-tab', + leafId: 'leaf-2', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + + await runtime.restoreStructuredAgentSessionTabs() + + const restored = await runtime.listMobileSessionTabs('id:workspace-1') + expect(restored.tabs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'terminal', + id: 'terminal-tab::leaf-1' + }), + expect.objectContaining({ + type: 'terminal', + id: 'terminal-tab::leaf-2' + }), + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:restored-session', + sessionId: 'restored-session' + }) + ]) + ) + expect(restored.tabs).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'agent-session', + id: 'agent-session:agent-session:restored-session' + }) + ]) + ) + expect(restored.tabGroups?.[0]?.tabOrder).toEqual([ + 'terminal-tab', + 'agent-session:restored-session' + ]) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:restored-session', { + reason: 'user' + }) + + expect(closeSessionTab).toHaveBeenCalledWith( + 'structured-agent-session-restored-session', + 'workspace-1' + ) + + const closed = await runtime.listMobileSessionTabs('id:workspace-1') + expect(closed.tabs.map((tab) => tab.id)).toEqual([ + 'terminal-tab::leaf-1', + 'terminal-tab::leaf-2' + ]) + expect(closed.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) + }) + + it('commits the host close when the renderer already removed the structured tab', async () => { + const runtime = new OrcaRuntimeService() + runtime.setNotifier({ + closeSessionTab: vi.fn(async () => { + throw new Error('session_tab_not_found') + }) + } as never) + runtime.publishStructuredAgentSessionTab({ + workspaceId: 'workspace-1', + sessionId: 'session-1', + agent: 'codex', + activate: true + }) + + await runtime.closeMobileSessionTab('id:workspace-1', 'agent-session:session-1', { + reason: 'user' + }) + + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs).toEqual([]) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index be6bfe922ad..7049620ab27 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1241,6 +1241,10 @@ class InMemoryOrchestrationMessages { return [...this.runs.values()].find((run) => run.coordinator_pane_key === paneKey) } + listWorkerTerminalReleaseBacklog(): never[] { + return [] + } + hasUndeliveredDirectMessageForRun(runId: string, directHandle: string): boolean { return this.messages.some( (message) => diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 532cd1acadc..cb691745771 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -63,6 +63,7 @@ import type { AgentHookAuthorityAttestation } from '../agent-hooks/server' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, + AgentSessionOwnerBinding, AgentSessionSurfaceBinding, AgentLaunchPreferences, RuntimeAgentSessionRpcCaller, @@ -81,6 +82,54 @@ import { createEphemeralAgentSessionClaimSigner, type AgentSessionClaimSigner } from './agent-session-claim-identity' +import { + ensureStructuredAgentSessionHost as installStructuredAgentSessionHost, + hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk +} from './structured-agent-session-runtime' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { + StructuredTuiLaunchCleanupError, + type StructuredAgentSessionHandoffTransport, + type StructuredTuiOwner +} from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionProviderHandleRoot, + agentSessionProviderHandlesEqual +} from '../../shared/agent-session-provider-handle' +import { SESSION_TAB_NOT_FOUND_ERROR } from '../../shared/session-tab-close' +import { + agentSessionOwnerBindingsEqual, + cloneAgentSessionOwnerBinding, + scopedAgentSessionClaimsEqual +} from '../../shared/claimed-agent-pty-owner-snapshot' +import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' +import { claudeProviderHandleLink } from '../claude/claude-structured-owner-identity' +import { readCodexResumeProcessIdentity } from '../codex/codex-resume-process-proof' +import { + proveCodexTuiRollout, + resolvePinnedCodexRolloutProof +} from '../codex/codex-tui-rollout-proof' +import { + PROCESS_START_TIME_TOLERANCE_MS, + probeAgentSessionProcessIdentity +} from './agent-session-process-identity-probe' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' +import { + readClaudeTranscriptLeafUuid, + resolveSessionFilePath +} from '../native-chat/session-file-resolver' +import { ClaudeTranscriptTailIncompleteError } from '../claude/claude-transcript-branch-proof' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' +import { evaluateStructuredTuiRecoveryClaim } from './structured-tui-recovery-claim-match' +import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' +import { getSystemCodexHomePath } from '../codex/codex-home-paths' +import { + agentSessionPtyWriteGate, + type AgentSessionPtyWriteAdmittance +} from './agent-session-pty-write-gate' import { hasCompatibleAgentTitleIdentity, normalizeCompatibleAgentStatusEntryForOwner, @@ -129,8 +178,8 @@ import { import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../../shared/git-fetch-auto-maintenance' import { createHash, randomUUID } from 'node:crypto' -import { homedir } from 'node:os' -import { dirname, isAbsolute, join, resolve } from 'node:path' +import { homedir, hostname } from 'node:os' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' @@ -237,6 +286,8 @@ import { import { ORCHESTRATION_MESSAGE_WAIT_DEFAULT_TIMEOUT_MS } from '../../shared/orchestration-message-wait-timeout' import { shouldForwardHeadlessTerminalQueryReply } from './headless-terminal-query-reply-policy' import type { TerminalRevealIdentity } from '../../shared/terminal-reveal-identity' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import type { OrchestrationCompatibilityEvidence, OrchestrationCompatibilityHostStamp @@ -501,6 +552,7 @@ import { type RuntimeMarkdownReadTabResult, type RuntimeMarkdownSaveTabResult, type RuntimeMobileSessionCreateTerminalResult, + type RuntimeMobileSessionAgentTab, type RuntimeMobileSessionClientTab, type RuntimeMobileSessionMarkdownTab, type RuntimeMobileSessionTabMove, @@ -599,6 +651,7 @@ import { resolveTuiAgentLaunchArgs, resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults' +import { resolveCodexStructuredAppServerArgs } from '../codex/codex-structured-app-server-args' import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' import { getTuiAgentLaunchCommand, @@ -1305,6 +1358,11 @@ function sanitizeNestedRepoRuntimeImportError(context: string, error: unknown): return 'Repository could not be imported' } +function isPathWithinDirectory(directory: string, candidate: string): boolean { + const relativePath = relative(resolve(directory), resolve(candidate)) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) +} + type RuntimeAccountServices = { claudeAccounts: ClaudeAccountService codexAccounts: CodexAccountService @@ -1575,6 +1633,7 @@ type RuntimePtyWorktreeRecord = { // Why: provider PTY IDs can be reused; launch identity belongs only to the process that received the token. launchIncarnationId: PtyIncarnationId | null launchAgent: TuiAgent | null + agentSessionOwners: AgentSessionOwnerBinding[] foregroundAgent: TuiAgent | null connected: boolean disconnectedAt: number | null @@ -1656,6 +1715,7 @@ type TerminalCreateOptions = { // Why: only the host-derived structured resume path may attach provider // identity; opaque terminal.create commands remain ordinary shells. agentSessionClaim?: AgentSessionExecutionClaim + structuredAgentSessionId?: string agentSessionCreateOperationId?: string signal?: AbortSignal // Why: idempotent create operations must retain their fence after the PTY @@ -2025,12 +2085,18 @@ type RuntimePtyController = { } }): Promise<{ id: string + pid?: number incarnationId?: PtyIncarnationId wslDistro?: string stablePaneOwner?: { handle: string; tabId: string; leafId: string } agentSessionEnsure?: AgentSessionClaimedSpawnResult }> write(ptyId: string, data: string): boolean + writeAgentSessionProof?( + ptyId: string, + data: string, + authority: { sessionId: string; spawnToken: string } + ): boolean writeWithSettlement?(ptyId: string, data: string): Promise /** Attach-only adoption of a live local daemon session so its output streams * to main without a renderer pane; never creates, resizes, or focuses. @@ -3175,6 +3241,8 @@ export class OrcaRuntimeService { private headlessGraphFallbackAvailable = false private pendingHeadlessPromotionWindowId: number | null = null private rendererGeneration: string | null = null + private mobileSessionTabsChangeSequence = 0 + private pendingMobileSessionTabsChangeSequenceByWorktree = new Map() private readonly graphReloadLifecycle = new RuntimeGraphReloadLifecycle({ timeoutMs: RUNTIME_GRAPH_RELOAD_TIMEOUT_MS, onSettled: ({ revision, windowId, outcome, durationMs }) => { @@ -3252,7 +3320,7 @@ export class OrcaRuntimeService { } >() private mobileSessionTabListeners = new Set<{ - listener: (snapshot: RuntimeMobileSessionTabsResult) => void + listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void clientNavigationId?: string }>() // Why: one watermark per repo replaces per-closed-pane fences while preserving stale-write safety. @@ -3268,7 +3336,7 @@ export class OrcaRuntimeService { // second. Emit reads the latest snapshot, so only the freshest version ships. private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = createMobileSessionTabsNotifyCoalescer((worktreeId) => - this.notifyMobileSessionTabsChangedNow(worktreeId) + this.flushScheduledMobileSessionTabsChanged(worktreeId) ) private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat = createMobileSessionTabsAgentStatusHeartbeat( @@ -3283,6 +3351,8 @@ export class OrcaRuntimeService { new TerminalFocusNavigationCoalescer() private pendingMobileSessionPtyAggregateInventoryRefresh: Promise | null = null + private structuredAgentSessionTabRestorePromise: Promise | null = null + private structuredAgentSessionStartupRestorePromise: Promise | null = null private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -3805,6 +3875,12 @@ export class OrcaRuntimeService { private readonly prepareAiVaultSessionResumeFn: | ((args: AiVaultPrepareSessionResumeArgs) => Promise) | null + private readonly prepareCodexStructuredLaunchFn: + | ((input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null | Promise) + | null private readonly agentSessionClaimSigner: AgentSessionClaimSigner private readonly agentSessionCreateOperations = new Map() private readonly orchestrationCompatibilitySshAttachments = new Map< @@ -3893,6 +3969,10 @@ export class OrcaRuntimeService { prepareAiVaultSessionResume?: ( args: AiVaultPrepareSessionResumeArgs ) => Promise + prepareCodexStructuredLaunch?: (input: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | null | Promise buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner @@ -3953,6 +4033,7 @@ export class OrcaRuntimeService { this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.prepareAiVaultSessionResumeFn = deps?.prepareAiVaultSessionResume ?? null + this.prepareCodexStructuredLaunchFn = deps?.prepareCodexStructuredLaunch ?? null this.agentSessionClaimSigner = deps?.agentSessionClaimSigner ?? createEphemeralAgentSessionClaimSigner(this.runtimeId) this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null @@ -6770,7 +6851,7 @@ export class OrcaRuntimeService { ...next, snapshotVersion: snapshot.snapshotVersion + 1 }) - this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + this.scheduleMobileSessionTabsChanged(worktreeId) return } } @@ -6958,7 +7039,7 @@ export class OrcaRuntimeService { return } for (const worktreeId of worktreeIds) { - this.notifyMobileSessionTabsChangedNow(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) } } @@ -7365,7 +7446,7 @@ export class OrcaRuntimeService { } for (const worktreeId of changedMobileWorktrees) { if (this.mobileSessionTabsByWorktree.has(worktreeId)) { - this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + this.scheduleMobileSessionTabsChanged(worktreeId) } } // Why: only the authoritative window grants inventory authority; headless qualifies because it becomes authoritative before its next sync. @@ -7492,12 +7573,21 @@ export class OrcaRuntimeService { async listAllMobileSessionTabs( clientNavigationId?: string ): Promise { - return (await this.collectAllMobileSessionTabs(clientNavigationId)).snapshots + return (await this.listAllMobileSessionTabsWithChangeSequence(clientNavigationId)).snapshots + } + + async listAllMobileSessionTabsWithChangeSequence(clientNavigationId?: string): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + changeSequence: number + }> { + const inventory = await this.collectAllMobileSessionTabs(clientNavigationId) + return { snapshots: inventory.snapshots, changeSequence: inventory.changeSequence } } private async collectAllMobileSessionTabs(clientNavigationId?: string): Promise<{ snapshots: RuntimeMobileSessionTabsResult[] ptyInventory: PtyControllerInventory | null + changeSequence: number }> { for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { @@ -7508,21 +7598,32 @@ export class OrcaRuntimeService { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() const ptyInventory = await this.refreshMobileSessionPtyInventory() this.restoreLivePairedRendererSessionOwnedMobileTerminals(null) - return { - snapshots: [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => - this.projectMobileSessionTabsForClient( - this.toMobileSessionTabsResult(snapshot), - clientNavigationId - ) - ), - ptyInventory - } + const snapshots = [...this.mobileSessionTabsByWorktree.values()].map((snapshot) => + this.projectMobileSessionTabsForClient( + this.toMobileSessionTabsResult(snapshot), + clientNavigationId + ) + ) + return { snapshots, ptyInventory, changeSequence: this.mobileSessionTabsChangeSequence } } async listAllMobileSessionTabsInventory( clientNavigationId?: string, signal?: AbortSignal ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + const { snapshots, authoritative } = + await this.listAllMobileSessionTabsInventoryWithChangeSequence(clientNavigationId, signal) + return { snapshots, ...(authoritative ? { authoritative } : {}) } + } + + async listAllMobileSessionTabsInventoryWithChangeSequence( + clientNavigationId?: string, + signal?: AbortSignal + ): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true + changeSequence: number + }> { this.assertSessionTabsInventoryRequestActive(signal) const primedPublicationEpoch = this.getAuthoritativeSessionTabsInventoryEpoch() const primed = await this.collectAllMobileSessionTabs(clientNavigationId) @@ -7556,17 +7657,26 @@ export class OrcaRuntimeService { inventory: { snapshots: RuntimeMobileSessionTabsResult[] ptyInventory: PtyControllerInventory | null + changeSequence: number }, clientNavigationId?: string, signal?: AbortSignal - ): Promise<{ snapshots: RuntimeMobileSessionTabsResult[]; authoritative?: true }> { + ): Promise<{ + snapshots: RuntimeMobileSessionTabsResult[] + authoritative?: true + changeSequence: number + }> { if (this.isCompleteSessionTabsPtyCensus(inventory.ptyInventory)) { - return { snapshots: inventory.snapshots, authoritative: true } + return { + snapshots: inventory.snapshots, + authoritative: true, + changeSequence: inventory.changeSequence + } } const retried = await this.collectAllMobileSessionTabs(clientNavigationId) this.assertSessionTabsInventoryRequestActive(signal) // Why: the retry ran outside the epoch fence, so it never claims authority. - return { snapshots: retried.snapshots } + return { snapshots: retried.snapshots, changeSequence: retried.changeSequence } } supportsAuthoritativeSessionTabsInventory(): boolean { @@ -8603,7 +8713,7 @@ export class OrcaRuntimeService { } // Why: title/status flips several times a second under spinner-in-title // agents. Coalesce the emit instead of fanning out every version. - this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + this.scheduleMobileSessionTabsChanged(worktreeId) } /** Republish the workspace snapshot after a pane's hook status changed. @@ -9330,9 +9440,11 @@ export class OrcaRuntimeService { return } const result = this.toMobileSessionTabsResult(snapshot) + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId) + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence ) } } @@ -10013,6 +10125,21 @@ export class OrcaRuntimeService { } await this.notifier.closeSessionTab(tab.id, worktreeId) } + } else if (tab.type === 'agent-session') { + if (this.notifier?.closeSessionTab) { + try { + await this.notifier.closeSessionTab( + structuredAgentSessionTabId(tab.sessionId), + worktreeId + ) + } catch (error) { + // The renderer already having removed the tab is an idempotent close, not a veto. + if (!(error instanceof Error && error.message === SESSION_TAB_NOT_FOUND_ERROR)) { + throw error + } + } + } + this.closeStructuredAgentSessionTab(worktreeId, snapshot, tab) } else { if (!this.notifier?.closeSessionTab) { throw new Error('runtime_unavailable') @@ -10118,6 +10245,30 @@ export class OrcaRuntimeService { return true } + private closeStructuredAgentSessionTab( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionAgentTab + ): void { + const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== tab.id) + const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null + const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { + ...snapshot, + snapshotVersion: snapshot.snapshotVersion + 1, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + tabGroups: (snapshot.tabGroups ?? []).map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => id !== tab.id), + activeTabId: group.activeTabId === tab.id ? null : group.activeTabId, + recentTabIds: group.recentTabIds?.filter((id) => id !== tab.id) + })), + tabs: nextTabs + } + this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.emitMobileSessionTabsSnapshot(nextSnapshot) + } + private markHeadlessBrowserSessionTabActive( worktreeId: string | undefined, browserPageId: string, @@ -10744,6 +10895,25 @@ export class OrcaRuntimeService { // Why: clients reorder the sanitized session.tabs.list model; raw groups // can still contain stale browser ids hidden from paired web clients. .filter((tabId) => returnedIds.has(tabId)) + const structuredIds = expected.filter((tabId) => + snapshot?.tabs.some((tab) => tab.type === 'agent-session' && tab.id === tabId) + ) + if (structuredIds.some((tabId) => !seen.has(tabId))) { + if (structuredIds.some((tabId) => seen.has(tabId))) { + throw new Error('invalid_tab_order') + } + const visibleExpected = expected.filter((tabId) => !structuredIds.includes(tabId)) + if ( + normalized.length !== visibleExpected.length || + visibleExpected.some((tabId) => !seen.has(tabId)) + ) { + throw new Error('invalid_tab_order') + } + for (const tabId of structuredIds) { + normalized.splice(Math.min(expected.indexOf(tabId), normalized.length), 0, tabId) + } + return normalized + } // Why: reorder is a pure permutation of one existing group. Missing or // extra ids would let a paired web client silently move/lose host tabs. if (normalized.length !== expected.length || expected.some((tabId) => !seen.has(tabId))) { @@ -11138,6 +11308,1113 @@ export class OrcaRuntimeService { getRuntimeGitRemoteCommitUrl: RuntimeGitCommands['getRuntimeGitRemoteCommitUrl'] = this.gitCommands.getRuntimeGitRemoteCommitUrl.bind(this.gitCommands) + /** + * Installs the structured agent-session host on first use. Lazy for the same + * reason the orchestration DB is: the profile's user-data path is not final + * until the app is ready, and a runtime nobody drives a chat session on + * should never open the record store. + */ + async ensureStructuredAgentSessionHost(): Promise { + await installStructuredAgentSessionHost({ + stateDirectory: getProfileUserDataPath(), + hostId: LOCAL_EXECUTION_HOST_ID, + claimKeyId: this.agentSessionClaimSigner.keyId, + // Resolves folder workspaces as well as git worktrees, so a chat session + // in a plain folder lands in the folder rather than failing to resolve. + resolveWorkspacePath: async (workspaceId) => + (await this.resolveRuntimeFileTarget(`id:${workspaceId}`)).worktree.path, + resolveLaunchArgs: () => this.resolveConfiguredCodexStructuredArgs(), + resolveLaunchEnvOverlay: () => + resolveTuiAgentLaunchEnv('codex', this.requireStore().getSettings().agentDefaultEnv), + handoffTransport: this.createStructuredAgentSessionHandoffTransport() + }) + } + + private resolveConfiguredCodexStructuredArgs(): string[] { + const settings = this.requireStore().getSettings() + const shell = resolveLocalWindowsAgentStartupShell({ + platform: process.platform, + isRemote: false, + terminalWindowsShell: settings.terminalWindowsShell + }) + return resolveCodexStructuredAppServerArgs( + resolveTuiAgentLaunchArgs('codex', settings.agentDefaultArgs), + shell ?? 'posix' + ) + } + + private createStructuredAgentSessionHandoffTransport(): StructuredAgentSessionHandoffTransport { + return { + hostLabel: hostname(), + launchTui: async ({ record, fence, spawnToken, onSpawned }) => { + const head = record.providerHandleChain.at(-1) + if (!head || (head.handle.provider !== 'codex' && head.handle.provider !== 'claude')) { + throw new Error('agent_session_identity_required') + } + const provider = head.handle.provider + const providerSessionId = + provider === 'claude' ? head.handle.sessionId : head.handle.threadId + const launchStartedAt = Date.now() + const launched = await this.ensureAgentSession( + { + kind: 'explicit', + worktree: `id:${record.location.workspaceId}`, + agent: provider, + providerSession: { key: 'session_id', id: providerSessionId }, + ...(record.options ? { launchPreferences: record.options } : {}), + presentation: 'background' + }, + {}, + { spawnToken, providerRoot: record.accountHome.path, sessionId: record.sessionId } + ) + const terminal = launched.terminal + let spawnedOwner: StructuredTuiOwner | null = null + let ptyId: string | undefined + try { + if (!terminal.processId || !terminal.paneKey || !terminal.tabId || !terminal.ptyId) { + throw new Error('The resumed terminal did not publish a process identity.') + } + ptyId = terminal.ptyId + spawnedOwner = this.refreshStructuredTuiOwnerBinding({ + terminal: { + handle: terminal.handle, + tabId: terminal.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: + provider === 'codex' + ? await readCodexResumeProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + threadId: head.handle.threadId + }) + : await readStructuredTuiProcessIdentity({ + hostId: record.location.executionHostId, + rootPid: terminal.processId, + spawnToken, + agent: provider + }), + link: + provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }) + }) + await onSpawned?.(spawnedOwner) + await this.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 30_000 + }) + const proof = + provider === 'codex' + ? await this.waitForAdoptedStructuredTuiProof({ + owner: spawnedOwner, + threadId: head.handle.threadId, + codexHome: record.accountHome.path + }) + : await this.waitForStructuredClaudeTuiProof({ + handle: terminal.handle, + paneKey: terminal.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects'), + spawnToken, + minimumProviderSessionReceivedAt: launchStartedAt + }) + const revealed = await this.focusTerminal(terminal.handle) + return this.refreshStructuredTuiOwnerBinding({ + ...spawnedOwner, + link: + provider === 'claude' + ? claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid ?? head.handle.leafUuid, + resumed: true, + fence, + observedAt: Date.now() + }) + : spawnedOwner.link, + terminal: { + handle: terminal.handle, + tabId: revealed.tabId, + paneKey: terminal.paneKey, + ptyId: terminal.ptyId + }, + process: spawnedOwner.process, + ...(proof.transcriptPath ? { transcriptPath: proof.transcriptPath } : {}), + historySource: 'provider-resume' + }) + } catch (error) { + let closeError: unknown = null + try { + await this.closeTerminal(terminal.handle) + } catch (cleanupFailure) { + closeError = cleanupFailure + } + try { + // closeTerminal may retire the renderer handle before the PTY exit is + // observed. Prove the provider child (or, before identity publication, + // the PTY) through the same exit path used by handoff recovery. + if (spawnedOwner) { + await this.waitForStructuredTuiOwnerExit(spawnedOwner) + } else if (ptyId) { + await this.waitForStructuredTuiPtyExit(ptyId) + } else { + throw new Error('The failed terminal did not publish a PTY identity.') + } + } catch (exitFailure) { + throw new StructuredTuiLaunchCleanupError( + error, + closeError === null + ? exitFailure + : new AggregateError( + [closeError, exitFailure], + 'Structured TUI cleanup could not prove process exit.' + ) + ) + } + throw error + } + }, + waitForTuiExit: async (owner) => { + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + }, + waitForTuiIdleOrExit: async (owner, signal) => { + return this.waitForStructuredTuiIdleOrExit(owner, signal) + }, + reproveTuiOwner: async ({ record, owner }) => { + const current = this.refreshStructuredTuiOwnerBinding(owner) + const persisted = record.lease.ownerProcess + if ( + !persisted || + persisted.hostId !== current.process.hostId || + persisted.pid !== current.process.pid || + persisted.processStartTimeMs !== current.process.processStartTimeMs || + persisted.spawnToken !== current.process.spawnToken + ) { + throw new Error('The owning terminal does not match the persisted launch identity.') + } + const proof = await probeAgentSessionProcessIdentity({ identity: current.process }) + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error( + `The owning ${current.link.handle.provider} child process could not be re-proved.` + ) + } + const head = record.providerHandleChain.at(-1) + const sameProviderIdentity = + head && + (current.link.handle.provider === 'claude' + ? agentSessionProviderHandleRoot(current.link.handle) === + agentSessionProviderHandleRoot(head.handle) + : (record.lease.provenHandleLinkId === null || + current.link.linkId === record.lease.provenHandleLinkId) && + agentSessionProviderHandlesEqual(current.link.handle, head.handle)) + if (!sameProviderIdentity) { + throw new Error('agent_session_identity_required') + } + if (current.link.handle.provider === 'claude' && head.handle.provider === 'claude') { + const proof = await this.waitForStructuredClaudeTuiProof({ + handle: current.terminal.handle, + paneKey: current.terminal.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects') + }) + return { + ...current, + link: claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + } + if (current.transcriptPath || current.link.handle.provider !== 'codex') { + return current + } + if (head.handle.provider !== 'codex') { + return current + } + const threadId = head.handle.threadId + const transcriptPath = await resolvePinnedCodexRolloutProof( + record.accountHome.path, + threadId + ) + return transcriptPath ? { ...current, transcriptPath } : current + }, + recoverTuiOwner: async (record) => { + const identity = record.lease.ownerProcess + const head = record.providerHandleChain.at(-1) + if ( + !identity || + !head || + (head.handle.provider !== 'codex' && head.handle.provider !== 'claude') + ) { + throw new Error('agent_session_identity_required') + } + const provider = head.handle.provider + const providerSessionId = + provider === 'claude' ? head.handle.sessionId : head.handle.threadId + let candidate = [...this.ptysById.values()].find( + (pty) => + pty.connected && + pty.launchToken === identity.spawnToken && + pty.launchAgent === provider && + pty.tabId && + pty.paneKey + ) + let handle = candidate ? this.issueStructuredTuiPtyHandle(candidate) : null + let durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } | undefined + if (!candidate) { + const workspace = await this.resolveTerminalWorkspaceLaunchScope( + `id:${record.location.workspaceId}` + ) + const baseNamespace = this.getAgentSessionExecutionNamespace(workspace, provider) + if ( + !baseNamespace || + !runtimeWorktreeIdsEqual(workspace.id, record.location.workspaceId) + ) { + throw new Error('agent_session_identity_required') + } + const claim = this.agentSessionClaimSigner.createClaim({ + namespace: { ...baseNamespace, providerRoot: record.accountHome.path }, + identity: canonicalizeAgentSessionIdentity(provider, { + key: 'session_id', + id: providerSessionId + }), + canonicalWorktreeId: workspace.id + }) + const candidateEvaluations = [...this.ptysById.values()].flatMap((pty) => + pty.agentSessionOwners.map((owner) => { + const session = this.getWorkspaceSessionForWorktree(owner.surface.worktreeId) + const sessionWorktreeId = session + ? resolveTerminalSessionWorktreeId(session, owner.surface.worktreeId) + : null + const persistedTab = sessionWorktreeId + ? session?.tabsByWorktree[sessionWorktreeId]?.find( + (candidate) => candidate.id === owner.surface.tabId + ) + : null + const paneKey = makePaneKey(owner.surface.tabId, owner.surface.leafId) + const persisted = { + sessionResolved: Boolean(session && sessionWorktreeId), + tabPresent: Boolean(persistedTab), + ptyId: + session?.terminalLayoutsByTabId[owner.surface.tabId]?.ptyIdsByLeafId?.[ + owner.surface.leafId + ] ?? null, + incarnationId: session?.terminalPtyIncarnationsByPaneKey?.[paneKey] ?? null + } + const evaluation = evaluateStructuredTuiRecoveryClaim( + { + expectedWorkspaceId: workspace.id, + claimMatches: scopedAgentSessionClaimsEqual(owner.claim, claim), + pty: { + connected: pty.connected, + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + worktreeId: pty.worktreeId + }, + owner: { + phase: owner.phase, + ptyId: owner.ptyId, + surface: owner.surface + }, + persisted + }, + runtimeWorktreeIdsEqual + ) + return { pty, owner, persisted, evaluation } + }) + ) + const recoveredCandidates = candidateEvaluations + .filter(({ evaluation }) => evaluation.matches) + .map(({ pty, owner }) => ({ pty, owner })) + const recovered = recoveredCandidates.length === 1 ? recoveredCandidates[0] : null + if (!recovered) { + console.warn('[structured-tui-recovery] claim mismatch', { + sessionId: record.sessionId, + expectedWorkspaceId: workspace.id, + persistedOwnerProcess: { + hostId: identity.hostId, + pid: identity.pid, + processStartTimeMs: identity.processStartTimeMs, + spawnTokenPresent: identity.spawnToken.length > 0 + }, + candidates: candidateEvaluations.map(({ pty, owner, persisted, evaluation }) => ({ + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + worktreeId: pty.worktreeId, + ownerSurface: owner.surface, + persisted, + mismatchedFields: evaluation.mismatchedFields + })) + }) + } + if ( + !recovered || + !(await this.proveRecoveredStructuredTuiPtyProcess(recovered.pty, identity, provider)) + ) { + throw new Error('The owning agent terminal could not be recovered.') + } + candidate = recovered.pty + candidate.tabId = recovered.owner.surface.tabId + candidate.paneKey = makePaneKey( + recovered.owner.surface.tabId, + recovered.owner.surface.leafId + ) + // Runtime handles rotate on packaged relaunch; claim, incarnation, and process proof are durable. + handle = this.issuePtyHandle(candidate) + const recoveredIncarnationId = candidate.incarnationId + if (handle && recoveredIncarnationId) { + durableOwner = { + binding: cloneAgentSessionOwnerBinding(recovered.owner), + incarnationId: recoveredIncarnationId + } + } + } + if (!candidate?.tabId || !candidate.paneKey || !handle) { + throw new Error('The owning agent terminal could not be recovered.') + } + agentSessionPtyWriteGate.bindPty(candidate.ptyId, record.sessionId) + const proof = + provider === 'codex' + ? durableOwner + ? await this.resolveRecoveredStructuredTuiTranscript({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + codexHome: record.accountHome.path, + durableOwner + }) + : await this.waitForStructuredTuiProof({ + handle, + paneKey: candidate.paneKey, + threadId: head.handle.threadId, + spawnToken: identity.spawnToken, + codexHome: record.accountHome.path, + sessionId: record.sessionId + }) + : await this.waitForStructuredClaudeTuiProof({ + handle, + paneKey: candidate.paneKey, + sessionId: head.handle.sessionId, + previousLeafUuid: head.handle.leafUuid, + projectsDir: join(record.accountHome.path, 'projects') + }) + return { + terminal: { + handle, + tabId: candidate.tabId, + paneKey: candidate.paneKey, + ptyId: candidate.ptyId + }, + process: identity, + link: + provider === 'codex' + ? codexProviderHandleLink({ + threadId: head.handle.threadId, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }) + : claudeProviderHandleLink({ + sessionId: head.handle.sessionId, + leafUuid: proof.leafUuid ?? head.handle.leafUuid, + resumed: true, + fence: record.lease.runtimeFence, + observedAt: Date.now() + }), + transcriptPath: proof.transcriptPath + } + }, + probeRecoveredOwner: async (record) => { + const identity = record.lease.ownerProcess + if (!identity) { + return 'dead' + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'identity-matched' && proof.matchedOn.length > 0) { + return 'live' + } + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return 'dead' + } + return 'unknown' + }, + stopRecoveredOwner: (record) => this.stopStructuredSessionProcess(record), + tuiStatus: (owner) => this.structuredTuiStatus(owner), + closeTuiOwner: (owner) => this.closeStructuredTuiOwner(owner), + revealNativeSession: ({ workspaceId, sessionId, agent = 'codex', adoptedTerminal }) => { + if (adoptedTerminal || agent !== 'codex') { + return + } + this.publishStructuredAgentSessionTab({ + workspaceId, + sessionId, + agent, + activate: false + }) + this.notifier?.focusEditorTab?.(structuredAgentSessionTabId(sessionId), workspaceId) + }, + stopFailedTuiLaunch: async (owner) => void (await this.closeStructuredTuiOwner(owner)) + } + } + + private async proveRecoveredStructuredTuiPtyProcess( + pty: RuntimePtyWorktreeRecord, + identity: NonNullable, + provider: 'codex' | 'claude' = 'codex' + ): Promise { + const listings = await this.ptyController?.listProcesses?.(pty.connectionId) + const listed = listings?.find( + (candidate) => candidate.id === pty.ptyId && candidate.incarnationId === pty.incarnationId + ) + if (!listed?.rootProcessId || identity.processStartTimeMs === null) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed?.rootProcessId ?? null, + mismatchedFields: [ + ...(!listed?.rootProcessId ? ['root-process-id'] : []), + ...(identity.processStartTimeMs === null ? ['persisted-process-start-time'] : []) + ] + }) + return false + } + try { + const observed = await readStructuredTuiProcessIdentity({ + hostId: identity.hostId, + rootPid: listed.rootProcessId, + spawnToken: identity.spawnToken, + agent: provider + }) + const matched = { + hostId: observed.hostId === identity.hostId, + pid: observed.pid === identity.pid, + processStartTime: + observed.processStartTimeMs !== null && + Math.abs(observed.processStartTimeMs - identity.processStartTimeMs) <= + PROCESS_START_TIME_TOLERANCE_MS + } + if (!Object.values(matched).every(Boolean)) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed.rootProcessId, + persisted: { + hostId: identity.hostId, + pid: identity.pid, + processStartTimeMs: identity.processStartTimeMs + }, + observed: { + hostId: observed.hostId, + pid: observed.pid, + processStartTimeMs: observed.processStartTimeMs + }, + mismatchedFields: Object.entries(matched) + .filter(([, matches]) => !matches) + .map(([field]) => field) + }) + } + return Object.values(matched).every(Boolean) + } catch (error) { + console.warn('[structured-tui-recovery] claimed PTY process mismatch', { + ptyId: pty.ptyId, + incarnationId: pty.incarnationId, + rootProcessId: listed.rootProcessId, + mismatchedFields: [`${provider}-child-proof`], + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + private async closeStructuredTuiOwner( + owner: StructuredTuiOwner + ): Promise<{ transcriptPath?: string }> { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + const current = this.refreshStructuredTuiOwnerBinding(owner) + try { + await this.closeTerminal(current.terminal.handle) + } catch (error) { + if (this.ptysById.get(owner.terminal.ptyId)?.connected) { + throw error + } + } + } + await this.waitForStructuredTuiOwnerExit(owner) + return owner.transcriptPath ? { transcriptPath: owner.transcriptPath } : {} + } + + // The new exact `codex resume ` child proves the resumed owner without + // a first turn; the pinned rollout then binds its durable transcript. + private async waitForAdoptedStructuredTuiProof(input: { + owner: StructuredTuiOwner + threadId: string + codexHome: string + }): Promise<{ transcriptPath: string; leafUuid?: never }> { + const assertPaneIdentity = (): void => { + const pty = this.ptysById.get(input.owner.terminal.ptyId) + if (!pty?.connected || pty.paneKey !== input.owner.terminal.paneKey) { + throw new Error('The adopted terminal lost its pane identity.') + } + } + assertPaneIdentity() + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + assertPaneIdentity() + const processProof = await probeAgentSessionProcessIdentity({ identity: input.owner.process }) + if (processProof.outcome !== 'identity-matched' || processProof.matchedOn.length === 0) { + throw new Error('The resumed Codex process could not be re-proved.') + } + return { transcriptPath } + } + + private refreshStructuredTuiOwnerBinding(owner: StructuredTuiOwner): StructuredTuiOwner { + const pty = this.ptysById.get(owner.terminal.ptyId) + if (!pty?.connected) { + throw new Error('The owning agent terminal lost its launch identity.') + } + const handle = this.issueStructuredTuiPtyHandle(pty) + if (handle === owner.terminal.handle) { + return owner + } + return { ...owner, terminal: { ...owner.terminal, handle } } + } + + private issueStructuredTuiPtyHandle(pty: RuntimePtyWorktreeRecord): string { + const existingHandle = this.findHandleForPtyRecord(pty.ptyId) + if (existingHandle) { + this.handleByPtyId.set(pty.ptyId, existingHandle) + return existingHandle + } + const handle = `term_${randomUUID()}` + const syntheticId = `pty:${pty.ptyId}` + this.syntheticTerminalHandles.add(handle) + this.handles.set(handle, { + handle, + runtimeId: this.runtimeId, + rendererGraphEpoch: this.rendererGraphEpoch, + worktreeId: pty.worktreeId, + tabId: syntheticId, + leafId: syntheticId, + ptyId: pty.ptyId, + ptyGeneration: 0 + }) + this.handleByPtyId.set(pty.ptyId, handle) + return handle + } + + private async waitForStructuredTuiPtyExit(ptyId: string): Promise { + const deadline = Date.now() + 5_000 + while (this.ptysById.get(ptyId)?.connected === true) { + if (Date.now() >= deadline) { + throw new Error('terminal_handle_stale') + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } + + private async waitForStructuredTuiOwnerExit(owner: StructuredTuiOwner): Promise { + await waitForStructuredTuiExitProof({ + identity: owner.process, + waitForExit: () => this.waitForStructuredTuiPtyExit(owner.terminal.ptyId) + }) + } + + private async waitForStructuredTuiIdleOrExit( + owner: StructuredTuiOwner, + signal: AbortSignal + ): Promise<'idle' | 'exited' | null> { + const deadline = Date.now() + 250 + while (!signal.aborted && Date.now() < deadline) { + if (!this.ptysById.get(owner.terminal.ptyId)?.connected) { + await this.waitForStructuredTuiOwnerExit(owner) + return 'exited' + } + if (this.structuredTuiStatus(owner) === 'idle') { + return 'idle' + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } + return null + } + + private async stopStructuredSessionProcess(record: AgentSessionRecord): Promise { + const identity = record.lease.ownerProcess + if (!identity) { + return + } + const proof = await probeAgentSessionProcessIdentity({ identity }) + if (proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch') { + return + } + if (proof.outcome !== 'identity-matched' || proof.matchedOn.length === 0) { + throw new Error('The recovered owner process could not be stopped safely.') + } + try { + process.kill(identity.pid, 'SIGTERM') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const deadline = Date.now() + 15_000 + while (Date.now() < deadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + // SIGTERM is only a request. Escalate once, then require an independent + // absence probe before allowing the lease transition to proceed. + try { + process.kill(identity.pid, 'SIGKILL') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') { + throw error + } + return + } + const forcedDeadline = Date.now() + 5_000 + while (Date.now() < forcedDeadline) { + const current = await probeAgentSessionProcessIdentity({ identity }) + if (current.outcome === 'pid-absent' || current.outcome === 'identity-mismatch') { + return + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + throw new Error('The recovered owner process did not exit after forced termination.') + } + + private structuredTuiStatus(owner: StructuredTuiOwner): 'idle' | 'busy' { + const pty = this.ptysById.get(owner.terminal.ptyId) + const paneKey = pty?.paneKey ?? owner.terminal.paneKey + const explicit = this.getFreshExplicitAgentStatusForHandle(owner.terminal.handle, paneKey) + if (explicit) { + return explicit.status === 'idle' ? 'idle' : 'busy' + } + if (pty?.connected) { + const text = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + const blocked = detectTerminalWaitBlockedReason(text) !== null + if (!blocked && isKnownReadyPromptPreview(text)) { + return 'idle' + } + return hasStructuredTuiIdleEvidence({ + blocked, + status: pty.lastAgentStatus, + statusObservedLive: pty.lastAgentStatusObservedLive + }) + ? 'idle' + : 'busy' + } + return 'busy' + } + + private async waitForStructuredTuiProof(input: { + handle: string + paneKey: string + threadId: string + spawnToken: string + codexHome: string + sessionId: string + }): Promise<{ transcriptPath?: string; leafUuid?: never }> { + const readBoundPty = (): RuntimePtyWorktreeRecord => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.launchAgent !== 'codex' || + pty.launchToken !== input.spawnToken + ) { + throw new Error('The resumed terminal lost its launch identity.') + } + return pty + } + const initialPty = readBoundPty() + const kittyKeyboardFlags = this.providerModeTrackersByPtyId.get(initialPty.ptyId)?.flags ?? 0 + return proveCodexTuiRollout({ + codexHome: input.codexHome, + threadId: input.threadId, + kittyKeyboardFlags, + readOutput: () => { + const pty = readBoundPty() + return { + text: buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview), + lastOutputAt: pty.lastOutputAt + } + }, + write: (data) => { + const pty = readBoundPty() + return ( + this.ptyController?.writeAgentSessionProof?.(pty.ptyId, data, { + sessionId: input.sessionId, + spawnToken: input.spawnToken + }) ?? false + ) + } + }) + } + + private async waitForStructuredClaudeTuiProof(input: { + handle: string + paneKey: string + sessionId: string + previousLeafUuid: string | null + projectsDir: string + /** Set when this call launched a new Claude process; a cached transcript marker is not enough. */ + spawnToken?: string + minimumProviderSessionReceivedAt?: number + }): Promise<{ transcriptPath: string; leafUuid: string }> { + const deadline = Date.now() + 15_000 + let incompleteTail: ClaudeTranscriptTailIncompleteError | null = null + while (Date.now() < deadline) { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if (!pty?.connected || pty.paneKey !== input.paneKey || pty.launchAgent !== 'claude') { + throw new Error('The resumed Claude terminal lost its launch identity.') + } + if (input.spawnToken) { + if (!this.hasProviderSessionObservationSource()) { + throw new Error('The Claude terminal could not prove its fresh provider session.') + } + const observedProviderRow = this.findAdoptedProviderSession( + input.paneKey, + 'claude', + input.sessionId + ) + if ( + !observedProviderRow || + observedProviderRow.launchToken !== input.spawnToken || + (input.minimumProviderSessionReceivedAt !== undefined && + observedProviderRow.receivedAt < input.minimumProviderSessionReceivedAt) + ) { + await new Promise((resolve) => setTimeout(resolve, 100)) + continue + } + } + const transcriptPath = await resolveSessionFilePath('claude', input.sessionId, { + claudeProjectsDir: input.projectsDir + }) + if (transcriptPath) { + if (!isPathWithinDirectory(input.projectsDir, transcriptPath)) { + throw new Error('The Claude terminal reported a transcript outside its account root.') + } + try { + const leafUuid = await readClaudeTranscriptLeafUuid( + transcriptPath, + input.sessionId, + input.previousLeafUuid + ) + return { transcriptPath, leafUuid } + } catch (error) { + if (!(error instanceof ClaudeTranscriptTailIncompleteError)) { + throw error + } + incompleteTail = error + } + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + if (incompleteTail) { + throw incompleteTail + } + throw new Error('The agent terminal did not prove the expected Claude session.') + } + + private async resolveRecoveredStructuredTuiTranscript(input: { + handle: string + paneKey: string + threadId: string + codexHome: string + durableOwner: { binding: AgentSessionOwnerBinding; incarnationId: string } + }): Promise<{ transcriptPath: string; leafUuid?: never }> { + const assertDurableOwner = (): void => { + const pty = this.getLivePtyForHandle(input.handle)?.pty + if ( + !pty?.connected || + pty.paneKey !== input.paneKey || + pty.incarnationId !== input.durableOwner.incarnationId || + !pty.agentSessionOwners.some((owner) => + agentSessionOwnerBindingsEqual(owner, input.durableOwner.binding) + ) + ) { + throw new Error('The resumed terminal lost its durable owner identity.') + } + } + assertDurableOwner() + const transcriptPath = await resolvePinnedCodexRolloutProof(input.codexHome, input.threadId) + assertDurableOwner() + if (!transcriptPath) { + throw new Error('The agent terminal did not prove the expected Codex rollout.') + } + return { transcriptPath } + } + + async getStructuredAgentSessionCreateSupport( + worktreeSelector: string, + agent: 'codex' + ): Promise<{ supported: boolean; reason?: 'agent' | 'remote' | 'wsl' }> { + const location = await this.resolveStructuredAgentSessionLocation(worktreeSelector) + await this.ensureStructuredAgentSessionHost() + if (getStructuredAgentSessionHost()?.supportsCreate(location, agent)) { + return { supported: true } + } + return { + supported: false, + reason: + location.executionHostId !== LOCAL_EXECUTION_HOST_ID + ? 'remote' + : location.wslDistro + ? 'wsl' + : 'agent' + } + } + + private hasProviderSessionObservationSource(): boolean { + return ( + this.getAgentProviderSessionRowsForPaneFn !== null || + this.getAgentProviderSessionSnapshotFn !== null + ) + } + + private findAdoptedProviderSession( + paneKey: string, + provider: 'claude' | 'codex', + providerSessionId: string + ): AgentStatusIpcPayload | undefined { + const rows = + this.getAgentProviderSessionRowsForPaneFn?.(paneKey) ?? + (this.getAgentProviderSessionSnapshotFn?.() ?? []).filter((row) => row.paneKey === paneKey) + return rows + .filter((row) => row.agentType === provider && row.providerSession?.id === providerSessionId) + .reduce( + (latest, row) => (!latest || row.receivedAt > latest.receivedAt ? row : latest), + undefined + ) + } + + private async resolveStructuredAgentSessionLocation(worktreeSelector: string) { + const target = await this.resolveRuntimeFileTarget(worktreeSelector) + const repo = this.store?.getRepo(target.worktree.repoId) + const wslDistro = + repo && !target.connectionId + ? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null) + : null + const folderWorkspace = this.store + ?.getFolderWorkspaces?.() + .some((workspace) => workspace.id === target.worktree.id) + return { + executionHostId: getRuntimeFileTargetExecutionHostId({ + worktree: target.worktree, + connectionId: target.connectionId + }), + wslDistro, + workspaceId: target.worktree.id, + workspaceKind: folderWorkspace ? ('folder' as const) : ('git-worktree' as const) + } + } + + async resolveStructuredAgentSessionCreateIntent(input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }): Promise { + return this.resolveStructuredAgentSessionIntent(input, async ({ workspacePath, launchEnv }) => { + // A create has no process yet, so the current selection is what it must follow. + const preparedHome = await this.prepareCodexStructuredLaunchFn?.({ workspacePath, launchEnv }) + const configuredHome = launchEnv.CODEX_HOME + return ( + preparedHome?.trim() || + (this.prepareCodexStructuredLaunchFn ? getSystemCodexHomePath() : configuredHome?.trim()) || + getSystemCodexHomePath() + ) + }) + } + + private async resolveStructuredAgentSessionIntent( + input: { + envelope: { sessionId: string; clientOperationId: string } + worktree: string + agent: 'codex' + }, + resolveAccountHomePath: (context: { + workspacePath: string + launchEnv: NodeJS.ProcessEnv + }) => string | Promise + ): Promise { + const support = await this.getStructuredAgentSessionCreateSupport(input.worktree, input.agent) + if (!support.supported) { + throw new Error('structured_agent_session_unsupported') + } + const settings = this.requireStore().getSettings() + const launchEnv = resolveTuiAgentLaunchEnv(input.agent, settings.agentDefaultEnv) + const location = await this.resolveStructuredAgentSessionLocation(input.worktree) + const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + return { + envelope: { + sessionId: input.envelope.sessionId, + clientOperationId: input.envelope.clientOperationId, + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + location, + provider: input.agent, + agent: input.agent, + accountHome: { + variable: 'CODEX_HOME', + path: await resolveAccountHomePath({ workspacePath, launchEnv }) + }, + runtimeKind: 'native' + } + } + + restoreStructuredAgentSessionTabs(): Promise { + this.structuredAgentSessionTabRestorePromise ??= + this.restoreStructuredAgentSessionTabsOnce().catch((error) => { + this.structuredAgentSessionTabRestorePromise = null + throw error + }) + return this.structuredAgentSessionTabRestorePromise + } + + prepareStructuredAgentSessionStartupRestoration(): Promise { + this.structuredAgentSessionStartupRestorePromise ??= + this.prepareStructuredAgentSessionStartupRestorationOnce().catch((error) => { + this.structuredAgentSessionStartupRestorePromise = null + throw error + }) + return this.structuredAgentSessionStartupRestorePromise + } + + private async prepareStructuredAgentSessionStartupRestorationOnce(): Promise { + if (!this.hasPersistedStructuredAgentSessionStore()) { + return + } + // Durable agent records must exist before daemon inventory can be reconciled against them. + await this.ensureStructuredAgentSessionHost() + await this.refreshMobileSessionPtyRecords() + await getStructuredAgentSessionHost()?.reconcileRestartLeases() + } + + private hasPersistedStructuredAgentSessionStore(): boolean { + return hasPersistedStructuredAgentSessionStoreOnDisk(getProfileUserDataPath()) + } + + private async restoreStructuredAgentSessionTabsOnce(): Promise { + await this.prepareStructuredAgentSessionStartupRestoration() + const host = getStructuredAgentSessionHost() + await host?.restoreReadableSessions( + collectSavedStructuredAgentSessionIds( + this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null + ) + ) + for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + } + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() + for (const session of host?.listSessionTabs() ?? []) { + if (session.agent !== 'codex') { + continue + } + let sessionId = session.sessionId + while (sessionId.startsWith('agent-session:')) { + sessionId = sessionId.slice('agent-session:'.length) + } + this.publishStructuredAgentSessionTab({ + ...session, + agent: 'codex', + sessionId, + activate: false, + notify: false + }) + } + } + + publishStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: 'codex' + activate: boolean + notify?: boolean + }): void { + const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) + const id = `agent-session:${input.sessionId}` + if (existing?.tabs.some((tab) => tab.id === id)) { + return + } + const tab: RuntimeMobileSessionAgentTab = { + type: 'agent-session', + id, + title: 'Codex Chat', + sessionId: input.sessionId, + agent: input.agent, + isActive: input.activate + } + const tabs = [...(existing?.tabs ?? [])].map((candidate) => ({ + ...candidate, + isActive: input.activate ? false : candidate.isActive + })) + tabs.push(tab) + const priorGroups = existing?.tabGroups ?? [ + { + id: this.getHeadlessMobileSessionGroupId(input.workspaceId), + activeTabId: existing?.activeTabId ?? null, + tabOrder: [] + } + ] + const groupId = priorGroups.some((group) => group.id === existing?.activeGroupId) + ? existing!.activeGroupId! + : priorGroups[0]!.id + const tabGroups = priorGroups.map((group) => + group.id === groupId + ? { + ...group, + activeTabId: input.activate ? id : group.activeTabId, + tabOrder: [...group.tabOrder, id] + } + : group + ) + const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: input.workspaceId, + publicationEpoch: existing?.publicationEpoch ?? `structured:${Date.now().toString(36)}`, + snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, + activeGroupId: input.activate ? groupId : (existing?.activeGroupId ?? groupId), + activeTabId: input.activate ? id : (existing?.activeTabId ?? null), + activeTabType: input.activate ? 'agent-session' : (existing?.activeTabType ?? null), + tabGroups, + ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), + tabs + } + this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot) + if (input.notify !== false) { + this.emitMobileSessionTabsSnapshot(snapshot) + } + } + private async resolveRuntimeGitTarget(worktreeSelector: string): Promise<{ worktree: ResolvedWorktree repo?: Repo @@ -11250,7 +12527,7 @@ export class OrcaRuntimeService { } onMobileSessionTabsChanged( - listener: (snapshot: RuntimeMobileSessionTabsResult) => void, + listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void, clientNavigationId?: string ): () => void { // Why: a notify coalesced before this subscriber existed is already folded @@ -13329,14 +14606,20 @@ export class OrcaRuntimeService { } try { await assertTerminalInputWithinLimitWithYield(data) - await this.writeTerminalInputChunks(ptyId, data, { - // Why: a phone can claim the floor while a paste yields between chunks. - beforeWrite: () => { - if (this.getDriver(ptyId).kind === 'mobile') { - throw new Error('terminal_mobile_driver_active') + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) + await this.writeTerminalInputChunks( + ptyId, + data, + { + // Why: a phone can claim the floor while a paste yields between chunks. + beforeWrite: () => { + if (this.getDriver(ptyId).kind === 'mobile') { + throw new Error('terminal_mobile_driver_active') + } } - } - }) + }, + admitted + ) return true } catch { return false @@ -16065,6 +17348,7 @@ export class OrcaRuntimeService { this.intentionalHandlelessPtyStops.has(ptyId) && (intentionalStopIncarnation === null || intentionalStopIncarnation === incarnationId) advertisedUrlWatcher.unbindPty(ptyId) + agentSessionPtyWriteGate.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) this.remoteTerminalViewSubscriberCounts.delete(ptyId) @@ -20048,13 +21332,16 @@ export class OrcaRuntimeService { return true } - private getFreshExplicitAgentStatusForHandle(handle: string): { + private getFreshExplicitAgentStatusForHandle( + handle: string, + paneKeyOverride?: string | null + ): { status: NonNullable updatedAt: number /** When this state was entered. Pinned across same-state pings, so it identifies the turn. */ stateStartedAt: number } | null { - const paneKey = this.getPaneKeyForTerminalHandle(handle) + const paneKey = paneKeyOverride ?? this.getPaneKeyForTerminalHandle(handle) const now = Date.now() let bestStatus: NonNullable | null = null let bestUpdatedAt = -1 @@ -20111,12 +21398,15 @@ export class OrcaRuntimeService { signal?: AbortSignal } = {} ): Promise { + // Why: the lease is checked before the mobile floor is reserved, so a refused send never takes + // a claim it will not use. + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) // Why: direct terminal.send can carry paste-sized text from RPC/mobile // clients; chunk text before PTY/ConPTY while preserving suffix separation. const text = typeof action.text === 'string' ? action.text : '' const hasSuffix = action.enter || action.interrupt if (text) { - await this.writeTerminalInputChunks(ptyId, text, options) + await this.writeTerminalInputChunks(ptyId, text, options, admitted) } if (hasSuffix) { const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '') @@ -20131,15 +21421,19 @@ export class OrcaRuntimeService { options.signal ) } + // Why: the 500ms text/suffix pause is long enough for a handoff to complete, so the submit + // is re-checked against the fence the text was admitted under. + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) try { await options.beforeWrite?.(ptyId) - options.reserveWrite?.(ptyId) } catch (error) { if (options.suffixFailureError) { throw new Error(options.suffixFailureError) } throw error } + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + options.reserveWrite?.(ptyId) const suffixWrote = this.ptyController?.write(ptyId, suffix) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') @@ -20152,6 +21446,7 @@ export class OrcaRuntimeService { } await options.beforeWrite?.(ptyId) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, payload) ?? false if (!wrote) { @@ -20167,12 +21462,21 @@ export class OrcaRuntimeService { beforeWrite?: (ptyId: string) => void | Promise reserveWrite?: (ptyId: string) => void afterWrite?: (ptyId: string) => void | Promise - } = {} + } = {}, + admitted: AgentSessionPtyWriteAdmittance ): Promise { const chunks = iterateTerminalInputChunks(text) let chunk = chunks.next() + let firstChunk = true while (!chunk.done) { + // Why: every inter-chunk yield is a window for a handoff to take the lease; the rest of a + // paste must not land in a session this runtime no longer owns. + if (!firstChunk) { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + firstChunk = false await options.beforeWrite?.(ptyId) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) options.reserveWrite?.(ptyId) const wrote = this.ptyController?.write(ptyId, chunk.value) ?? false if (!wrote) { @@ -20222,6 +21526,7 @@ export class OrcaRuntimeService { this.assertAgentPromptGeneration(ptyId, generation) const permissionBaseline = this.getAgentPromptActivity(handle, ptyId) this.assertAgentPromptPermissionSafe(permissionBaseline, permissionBaseline) + const admitted = agentSessionPtyWriteGate.assertAdmitted(ptyId) // Why: the floor for every wait below. Enter must never overtake bytes the execution // host is still feeding the child, and that cost is proportional to the payload. const writeHostPlatform = this.getPtyWriteHostPlatform(ptyId) @@ -20233,10 +21538,17 @@ export class OrcaRuntimeService { try { const chunks = iterateTerminalInputChunks(pastePayload) let chunk = chunks.next() + let firstChunk = true while (!chunk.done) { const nextChunk = chunks.next() assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) + // Why: the first chunk was just admitted above; re-checking the lease there would only + // re-read what `assertAdmitted` established. + if (!firstChunk) { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + firstChunk = false await options.beforeWrite?.(ptyId) assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) @@ -20244,6 +21556,7 @@ export class OrcaRuntimeService { permissionBaseline, this.getAgentPromptActivity(handle, ptyId) ) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) if (nextChunk.done) { renderGate?.arm() } @@ -20264,7 +21577,15 @@ export class OrcaRuntimeService { !completedPaste && this.getPtyLifecycleGeneration(ptyId) === generation ) { - this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + // Why: a lease that moved mid-paste also refuses this terminator, leaving the TUI in paste + // mode — the incoming owner re-establishes the mode, and feeding a session we no longer own + // is the worse outcome. + try { + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + this.ptyController?.write(ptyId, AGENT_PROMPT_BRACKETED_PASTE_END) + } catch { + // The original refusal is the actionable error. + } } renderGate?.dispose() throw error @@ -20284,6 +21605,7 @@ export class OrcaRuntimeService { } assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) try { await options.beforeWrite?.(ptyId) } catch (error) { @@ -20297,6 +21619,7 @@ export class OrcaRuntimeService { const waitTextCache: AgentPromptWaitTextCache = {} const baseline = this.getAgentPromptActivity(handle, ptyId, waitTextCache) this.assertAgentPromptPermissionSafe(permissionBaseline, baseline) + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) const suffixWrote = this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT) ?? false if (!suffixWrote) { throw new Error(options.suffixFailureError ?? 'terminal_not_writable') @@ -28713,7 +30036,8 @@ export class OrcaRuntimeService { async ensureAgentSession( request: RuntimeEnsureAgentSessionRequest, - _caller: RuntimeAgentSessionRpcCaller = {} + _caller: RuntimeAgentSessionRpcCaller = {}, + handoffAuthority?: { spawnToken: string; providerRoot: string; sessionId: string } ): Promise { if (request.kind === 'automatic') { // Legacy renderer sleep records are migration evidence, not host authority. @@ -28723,7 +30047,11 @@ export class OrcaRuntimeService { throw new Error('runtime_unavailable') } const workspace = await this.resolveTerminalWorkspaceLaunchScope(request.worktree) - const namespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const resolvedNamespace = this.getAgentSessionExecutionNamespace(workspace, request.agent) + const namespace = + resolvedNamespace && handoffAuthority + ? { ...resolvedNamespace, providerRoot: handoffAuthority.providerRoot } + : resolvedNamespace if ( !namespace || !(await this.executionOwnerSupportsAgentSessionOperation(workspace, 'resume', _caller.signal)) @@ -28757,9 +30085,17 @@ export class OrcaRuntimeService { request.agentArgs !== undefined ? request.agentArgs : resolveTuiAgentLaunchArgs(request.agent, settings.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + agentEnv: { + ...resolveTuiAgentLaunchEnv(request.agent, settings.agentDefaultEnv), + ...(handoffAuthority && request.agent === 'codex' + ? { CODEX_HOME: handoffAuthority.providerRoot } + : handoffAuthority && request.agent === 'claude' + ? { CLAUDE_CONFIG_DIR: handoffAuthority.providerRoot } + : {}) + }, ompResumeFilePath: request.ompResumeFilePath, sessionOptions: this.toAgentSessionOptions(request.launchPreferences), + sessionOptionsOverrideAgentArgs: Boolean(request.launchPreferences), platform, shell, isRemote @@ -28776,10 +30112,17 @@ export class OrcaRuntimeService { env: startup.env, launchConfig: startup.launchConfig, launchAgent: request.agent, + startupCommandDelivery: startup.startupCommandDelivery, presentation: request.presentation ?? 'background', tabId: request.placement?.tabId, leafId: request.placement?.leafId, agentSessionClaim: claim, + ...(handoffAuthority + ? { + launchToken: handoffAuthority.spawnToken, + structuredAgentSessionId: handoffAuthority.sessionId + } + : {}), signal: _caller.signal }) return { @@ -29268,6 +30611,9 @@ export class OrcaRuntimeService { leafId, ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}) }) + if (launchOpts.structuredAgentSessionId) { + agentSessionPtyWriteGate.bindPty(result.id, launchOpts.structuredAgentSessionId) + } const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { // Released again by releaseRuntimeSessionOwnershipForRendererRetiredTabs @@ -29346,6 +30692,7 @@ export class OrcaRuntimeService { title: pty?.title ?? launchOpts.title ?? null, ...this.getPtyExecutionHostMetadata(result.id), surface, + ...(result.pid ? { processId: result.pid } : {}), ...(result.agentSessionEnsure ? { agentSessionDisposition: result.agentSessionEnsure.disposition } : {}), @@ -30085,9 +31432,11 @@ export class OrcaRuntimeService { } this.mobileSessionTabsByWorktree.set(worktreeId, next) const result = this.toMobileSessionTabsResult(next) + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId) + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence ) } const created = result.tabs.find((candidate) => candidate.id === tab.id) @@ -33390,6 +34739,7 @@ export class OrcaRuntimeService { | 'isWsl' | 'wslDistro' | 'incarnationId' + | 'agentSessionOwners' > > = {} ): RuntimePtyWorktreeRecord { @@ -33420,6 +34770,7 @@ export class OrcaRuntimeService { launchToken: null, launchIncarnationId: null, launchAgent: null, + agentSessionOwners: (state.agentSessionOwners ?? []).map(cloneAgentSessionOwnerBinding), foregroundAgent: null, connected: state.connected ?? true, disconnectedAt: state.connected === false ? Date.now() : null, @@ -33465,12 +34816,22 @@ export class OrcaRuntimeService { } pty.worktreeId = worktreeId + if ( + state.incarnationId !== undefined && + pty.incarnationId !== null && + state.incarnationId !== pty.incarnationId + ) { + pty.agentSessionOwners = [] + } if (state.incarnationId !== undefined) { if (pty.incarnationId && state.incarnationId && pty.incarnationId !== state.incarnationId) { this.invalidatePtyIncarnationHandle(ptyId) } pty.incarnationId = state.incarnationId } + if (state.agentSessionOwners !== undefined) { + pty.agentSessionOwners = state.agentSessionOwners.map(cloneAgentSessionOwnerBinding) + } if (state.connectionId !== undefined) { pty.connectionId = state.connectionId if (state.connectionId !== null) { @@ -33757,6 +35118,7 @@ export class OrcaRuntimeService { const pty = this.recordPtyWorktree(session.id, worktreeId, { connected: true, ...(session.incarnationId ? { incarnationId: session.incarnationId } : {}), + agentSessionOwners: session.incarnationId ? (session.agentSessionOwners ?? []) : [], ...(session.wslDistro !== undefined ? { isWsl: Boolean(session.wslDistro), wslDistro: session.wslDistro } : {}), @@ -33811,6 +35173,7 @@ export class OrcaRuntimeService { } pty.connected = false pty.disconnectedAt ??= Date.now() + pty.agentSessionOwners = [] // Why: this list only enumerates registered providers, so a dropped relay // clears `connected` for every one of its PTYs at once. Only `false` here // is an observed absence; `null` means no provider could be asked. @@ -34296,7 +35659,7 @@ export class OrcaRuntimeService { this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. - this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) + this.cancelScheduledMobileSessionTabsChanged(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } @@ -34320,16 +35683,28 @@ export class OrcaRuntimeService { if (preservedTabs.length === 0) { return snapshot } + const preservedActiveTab = preservedTabs.find( + (tab) => tab.id === existing.activeTabId && tab.isActive + ) const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) const normalizedPreservedTabs = preservedTabs.map((tab) => - hasIncomingActiveTab ? { ...tab, isActive: false } : tab + hasIncomingActiveTab && !preservedActiveTab ? { ...tab, isActive: false } : tab + ) + // Why: an omitting renderer frame predates the runtime-owned structured + // publication, so it cannot revoke that publication's focus intent. + const normalizedIncomingTabs = preservedActiveTab + ? snapshot.tabs.map((tab) => (tab.isActive ? { ...tab, isActive: false } : tab)) + : snapshot.tabs + const tabs = this.mergeMobileSessionSnapshotTabs( + normalizedIncomingTabs, + normalizedPreservedTabs ) - const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs) if (tabs.length === snapshot.tabs.length) { return snapshot } const activeTab = - snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? + preservedActiveTab ?? + normalizedIncomingTabs.find((tab) => tab.id === snapshot.activeTabId) ?? tabs.find((tab) => tab.id === existing.activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0] ?? @@ -34337,6 +35712,12 @@ export class OrcaRuntimeService { const terminalTabs = tabs.filter( (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' ) + const tabGroups = this.mergeMobileSessionTabGroups( + snapshot.worktree, + snapshot.tabGroups ?? existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ) return { ...snapshot, publicationEpoch: this.getMergedMobileSessionPublicationEpoch( @@ -34347,16 +35728,40 @@ export class OrcaRuntimeService { activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, activeTabId: activeTab?.id ?? null, activeTabType: activeTab?.type ?? null, - tabGroups: this.mergeMobileSessionTabGroups( - snapshot.worktree, - snapshot.tabGroups ?? existing.tabGroups ?? [], - terminalTabs, - activeTab?.type === 'terminal' ? activeTab : null + tabGroups: this.mergeStructuredAgentSessionTabGroups( + tabGroups, + existing.tabGroups ?? [], + normalizedPreservedTabs, + activeTab?.id ?? null ), tabs } } + private mergeStructuredAgentSessionTabGroups( + groups: readonly RuntimeMobileSessionTabGroup[], + existingGroups: readonly RuntimeMobileSessionTabGroup[], + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[], + activeTabId: string | null + ): RuntimeMobileSessionTabGroup[] { + const structuredTabs = preservedTabs.filter((tab) => tab.type === 'agent-session') + if (structuredTabs.length === 0) { + return [...groups] + } + const next = groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + for (const tab of structuredTabs) { + const priorGroupId = existingGroups.find((group) => group.tabOrder.includes(tab.id))?.id + const target = next.find((group) => group.id === priorGroupId) ?? next[0] + if (target && !target.tabOrder.includes(tab.id)) { + target.tabOrder.push(tab.id) + } + if (target && tab.id === activeTabId) { + target.activeTabId = tab.id + } + } + return next + } + private buildPreservedHeadlessMobileSessionSnapshot( existing: RuntimeMobileSessionTabsSnapshot ): RuntimeMobileSessionTabsSnapshot | null { @@ -34427,6 +35832,9 @@ export class OrcaRuntimeService { snapshot: RuntimeMobileSessionTabsSnapshot, tab: RuntimeMobileSessionSnapshotTab ): boolean { + if (tab.type === 'agent-session') { + return true + } if (tab.type === 'browser') { const liveClientPage = typeof tab.browserPageId === 'string' @@ -34538,9 +35946,11 @@ export class OrcaRuntimeService { activeTabType: null, tabs: [] } + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { subscription.listener( - this.clientSessionTabSelections.project(removed, subscription.clientNavigationId) + this.clientSessionTabSelections.project(removed, subscription.clientNavigationId), + changeSequence ) } this.clientSessionTabSelections.forgetWorktree(worktreeId) @@ -34582,11 +35992,33 @@ export class OrcaRuntimeService { } } // Why: structural changes must propagate promptly; cancel any pending coalesced notify since this immediate emit supersedes it. - this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) - this.notifyMobileSessionTabsChangedNow(worktreeId) + this.cancelScheduledMobileSessionTabsChanged(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId, ++this.mobileSessionTabsChangeSequence) } - private notifyMobileSessionTabsChangedNow(worktreeId: string): void { + private scheduleMobileSessionTabsChanged(worktreeId: string): void { + this.pendingMobileSessionTabsChangeSequenceByWorktree.set( + worktreeId, + ++this.mobileSessionTabsChangeSequence + ) + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + } + + private cancelScheduledMobileSessionTabsChanged(worktreeId: string): void { + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) + this.pendingMobileSessionTabsChangeSequenceByWorktree.delete(worktreeId) + } + + private flushScheduledMobileSessionTabsChanged(worktreeId: string): void { + const changeSequence = this.pendingMobileSessionTabsChangeSequenceByWorktree.get(worktreeId) + if (changeSequence === undefined) { + return + } + this.pendingMobileSessionTabsChangeSequenceByWorktree.delete(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId, changeSequence) + } + + private notifyMobileSessionTabsChangedNow(worktreeId: string, changeSequence: number): void { if (this.mobileSessionTabListeners.size === 0) { return } @@ -34598,7 +36030,8 @@ export class OrcaRuntimeService { const result = this.toMobileSessionTabsResult(snapshot) for (const subscription of this.mobileSessionTabListeners) { subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId) + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence ) } } @@ -34609,9 +36042,11 @@ export class OrcaRuntimeService { } for (const snapshot of this.mobileSessionTabsByWorktree.values()) { const result = this.toMobileSessionTabsResult(snapshot) + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { subscription.listener( - this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId) + this.projectMobileSessionTabsForClient(result, subscription.clientNavigationId), + changeSequence ) } } @@ -34647,9 +36082,13 @@ export class OrcaRuntimeService { clientNavigationId: string, follow = false ): void { + const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { if (subscription.clientNavigationId === clientNavigationId) { - subscription.listener(follow ? { ...projected, navigationIntent: 'follow' } : projected) + subscription.listener( + follow ? { ...projected, navigationIntent: 'follow' } : projected, + changeSequence + ) } } } @@ -34801,7 +36240,7 @@ export class OrcaRuntimeService { }) continue } - if (tab.type === 'markdown' || tab.type === 'file') { + if (tab.type === 'markdown' || tab.type === 'file' || tab.type === 'agent-session') { tabs.push(tab) continue } @@ -35985,8 +37424,35 @@ export class OrcaRuntimeService { this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes) } + /** Admission snapshot taken when a mailbox pointer's text lands, asserted again + * before its Enter. The two writes straddle a 500ms pause, so a structured + * session that re-leases the pty in between must not receive the submit. */ + private readonly orchestrationPointerAdmissionByPtyId = new Map< + string, + AgentSessionPtyWriteAdmittance + >() + private writeOrchestrationPointerPty(ptyId: string, data: string): boolean | Promise { try { + if (data === '\r') { + const admitted = this.orchestrationPointerAdmissionByPtyId.get(ptyId) + this.orchestrationPointerAdmissionByPtyId.delete(ptyId) + if (admitted) { + // Throws when the lease moved under the in-flight pointer, withholding the submit. + agentSessionPtyWriteGate.assertReadmitted(ptyId, admitted) + } + } else { + const admission = agentSessionPtyWriteGate.admit(ptyId) + if (!admission.admitted) { + this.orchestrationPointerAdmissionByPtyId.delete(ptyId) + // Preserve the controller's own refusal reporting for internal deliveries. + return this.ptyController?.write(ptyId, data) ?? false + } + this.orchestrationPointerAdmissionByPtyId.set(ptyId, { + sessionId: admission.sessionId, + runtimeFence: admission.runtimeFence + }) + } if (this.ptyController?.writeWithSettlement) { return this.ptyController.writeWithSettlement(ptyId, data).catch(() => false) } diff --git a/src/main/runtime/orchestration-structured-chat-lease.test.ts b/src/main/runtime/orchestration-structured-chat-lease.test.ts new file mode 100644 index 00000000000..b3a78b08a5c --- /dev/null +++ b/src/main/runtime/orchestration-structured-chat-lease.test.ts @@ -0,0 +1,373 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { TERMINAL_METHODS } from './rpc/methods/terminal' + +const WORKTREE_ID = 'repo-structured-chat::/tmp/structured-chat' +const SESSION_ID = 'session-structured-chat' +const COORDINATOR = { + handle: 'term_structured_coord', + tabId: '11111111-1111-4111-8111-111111111111', + leafId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ptyId: 'pty-structured-coord' +} +const WORKER = { + handle: 'term_structured_worker', + tabId: '22222222-2222-4222-8222-222222222222', + leafId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + ptyId: 'pty-structured-worker' +} +const PLAIN = { + handle: 'term-plain', + tabId: '33333333-3333-4333-8333-333333333333', + leafId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + ptyId: 'pty-plain' +} +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKTREE_ID, + workspaceKind: 'git-worktree' +} + +type TestTerminal = typeof COORDINATOR + +function paneKey(terminal: TestTerminal): string { + return `${terminal.tabId}:${terminal.leafId}` +} + +function makeStore() { + const session = getDefaultWorkspaceSession() + const repo = { + id: 'repo-structured-chat', + path: '/tmp/structured-chat', + displayName: 'structured-chat', + badgeColor: '#000000', + addedAt: 0 + } + return { + getWorkspaceSession: vi.fn(() => session), + setWorkspaceSession: vi.fn(), + getRepos: vi.fn(() => [repo]), + getRepo: vi.fn(() => repo), + getAllWorktreeMeta: vi.fn(() => ({})), + getWorktreeMeta: vi.fn(() => undefined), + setWorktreeMeta: vi.fn(), + removeWorktreeMeta: vi.fn(), + getSettings: vi.fn(() => ({ workspaceDir: '/tmp/workspaces' })), + getProjects: vi.fn(() => []) + } +} + +describe('orchestration while Structured Chat owns an agent session', () => { + let directory: string + let recordStore: AgentSessionRecordStore + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let dispatcher: RpcDispatcher + let writes: Mock<(ptyId: string, data: string) => void> + let operationSequence: number + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-orchestration-structured-chat-')) + recordStore = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService(makeStore() as never) + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({ + id: WORKTREE_ID, + repoId: 'repo-structured-chat' + } as never) + writes = vi.fn<(ptyId: string, data: string) => void>() + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'unused' })), + write: (ptyId: string, data: string) => { + agentSessionPtyWriteGate.assertAdmitted(ptyId) + writes(ptyId, data) + return true + }, + kill: vi.fn(() => true), + getForegroundProcess: vi.fn(async () => 'codex'), + listProcesses: vi.fn(async () => []), + hasPty: vi.fn(() => true) + } as never) + for (const terminal of [COORDINATOR, WORKER, PLAIN]) { + runtime.registerPty(terminal.ptyId, WORKTREE_ID, null, { + tabId: terminal.tabId, + leafId: terminal.leafId, + incarnationId: `${terminal.ptyId}-incarnation`, + agentLaunchAuthority: { launchToken: `${terminal.ptyId}-launch`, launchAgent: 'codex' } + }) + runtime.registerPreAllocatedHandleForPty(terminal.ptyId, terminal.handle) + } + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [COORDINATOR, WORKER, PLAIN].map((terminal) => ({ + tabId: terminal.tabId, + worktreeId: WORKTREE_ID, + title: 'Codex', + activeLeafId: terminal.leafId, + layout: null + })), + leaves: [COORDINATOR, WORKER, PLAIN].map((terminal, index) => ({ + tabId: terminal.tabId, + worktreeId: WORKTREE_ID, + leafId: terminal.leafId, + paneRuntimeId: index + 1, + ptyId: terminal.ptyId, + paneTitle: null, + title: 'Codex' + })) + }) + await runtime.listTerminals() + for (const terminal of [COORDINATOR, WORKER, PLAIN]) { + runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex working\x07', 1) + runtime.onPtyData(terminal.ptyId, '\x1b]0;Codex done\x07', 2) + } + operationSequence = 0 + await establishOwner('native', 'spawn-native', null) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => recordStore.getRecord(sessionId)) + agentSessionPtyWriteGate.bindPty(WORKER.ptyId, SESSION_ID) + dispatcher = new RpcDispatcher({ + runtime, + methods: [...ORCHESTRATION_METHODS, ...TERMINAL_METHODS] + }) + }) + + afterEach(async () => { + vi.useRealTimers() + agentSessionPtyWriteGate.detachRecordLookup() + db.close() + await rm(directory, { recursive: true, force: true }) + }) + + function operation() { + operationSequence += 1 + return { + callerKey: 'structured-chat-test', + operationId: `1800000000000-${operationSequence.toString(16).padStart(32, '0')}`, + fingerprint: `structured-chat-${operationSequence}` + } + } + + async function establishOwner( + runtimeKind: 'native' | 'tui', + spawnToken: string, + expectedFence: number | null + ): Promise { + const now = 1_800_000_000_000 + operationSequence + const reserved = await recordStore.reserveOwner({ + sessionId: SESSION_ID, + location: LOCATION, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + runtimeKind, + expectedFence, + spawnToken, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: + expectedFence === null + ? { outcome: 'indeterminate', reason: 'new session' } + : { outcome: 'pid-absent' }, + operation: operation(), + now + }) + const fence = reserved.record.lease.runtimeFence + await recordStore.commitProcessIdentity({ + sessionId: SESSION_ID, + fence, + process: { hostId: 'local', pid: 4242 + fence, processStartTimeMs: now, spawnToken }, + now + }) + await recordStore.proveOwner({ + sessionId: SESSION_ID, + fence, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: 'thread-1' }, + origin: fence === 1 ? 'created' : 'resumed', + mintedAtFence: fence, + observedAt: now + }, + now + }) + } + + function createRun(coordinator = COORDINATOR) { + return db.createRun({ + objective: 'Structured Chat lease coverage', + coordinatorHandle: coordinator.handle, + coordinatorPaneKey: paneKey(coordinator) + }) + } + + function queueRunMessage(runId: string) { + return db.insertMessage({ + from: 'term_sender', + to: `run:${runId}`, + subject: 'Queued guidance', + type: 'status', + runId + }) + } + + async function rpc(method: string, params: Record, capability?: string) { + return dispatcher.dispatch({ + id: `request-${method}-${Math.random()}`, + authToken: 'test-token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `mutation-${method}-${Math.random()}`, + orchestrationCapability: capability + }) + } + + it('retains a Run mailbox pointer while Structured Chat refuses the PTY write', () => { + const run = createRun(WORKER) + const message = queueRunMessage(run.id) + + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + + expect(db.getMessageById(message.id)?.delivered_at).toBeNull() + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('redrives the same retained pointer after the lease returns to TUI', async () => { + vi.useFakeTimers() + const run = createRun(WORKER) + const message = queueRunMessage(run.id) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + expect(db.getMessageById(message.id)?.delivered_at).toBeNull() + + await establishOwner('tui', 'spawn-tui', 1) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + await vi.advanceTimersByTimeAsync(500) + + expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[0]?.[1]).toContain('orca orchestration check') + expect(writes.mock.calls[1]).toEqual([WORKER.ptyId, '\r']) + }) + + it('fails worker-start truthfully when its reused terminal is in Structured Chat', async () => { + const run = createRun() + const task = db.createTask({ spec: 'Run the queued work', runId: run.id }) + + const response = await rpc('orchestration.workerStart', { + task: task.id, + worktree: 'current', + terminal: WORKER.handle, + from: COORDINATOR.handle + }) + + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.ok).toBe(true) + expect(response.result).toMatchObject({ + state: 'failed', + failedStage: 'dispatch_input', + lastError: expect.stringMatching(/Structured Chat.*Switch it to Terminal/), + agentSessionRefusal: { code: 'agent_session_conflict', ownerRuntimeKind: 'native' } + }) + expect(db.getTask(task.id)?.status).toBe('failed') + expect(db.getDispatchContext(task.id)?.status).toBe('failed') + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('reports a real gate refusal with typed metadata and zero bytes written', async () => { + const response = await rpc('terminal.send', { + terminal: WORKER.handle, + text: 'new prompt', + enter: true, + agentPrompt: true, + client: { id: 'test-client', type: 'desktop' } + }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: WORKER.handle, + accepted: false, + bytesWritten: 0, + agentSessionRefusal: expect.objectContaining({ + code: 'agent_session_conflict', + ownerRuntimeKind: 'native' + }) + } + }) + expect(writes).not.toHaveBeenCalled() + }) + + it('settles worker_done while its pane remains in Structured Chat', async () => { + const run = createRun() + const task = db.createTask({ spec: 'Finish from Structured Chat', runId: run.id }) + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: WORKER.handle, + assigneePaneKey: paneKey(WORKER), + processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle) ?? undefined, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: paneKey(WORKER), + processIncarnation: runtime.getTerminalProcessIncarnation(WORKER.handle)! + }) + + const response = await rpc( + 'orchestration.send', + { + from: WORKER.handle, + subject: 'Done', + body: 'Implemented the task. Verified the result. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + }, + capability + ) + + expect(response.ok).toBe(true) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(writes.mock.calls.filter(([ptyId]) => ptyId === WORKER.ptyId)).toHaveLength(0) + }) + + it('delivers normally to a never-adopted terminal', async () => { + vi.useFakeTimers() + const run = createRun(PLAIN) + const message = queueRunMessage(run.id) + + expect(agentSessionPtyWriteGate.admit(PLAIN.ptyId)).toEqual({ + admitted: true, + sessionId: null, + runtimeFence: null + }) + runtime.deliverPendingMessagesForHandle(`run:${run.id}`) + await vi.advanceTimersByTimeAsync(500) + + expect(db.getMessageById(message.id)?.delivered_at).not.toBeNull() + expect(writes).toHaveBeenCalledTimes(2) + expect(writes.mock.calls[1]).toEqual([PLAIN.ptyId, '\r']) + }) +}) diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 2ef1c235766..7a19314ace4 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -29,6 +29,9 @@ import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming- export type DispatcherOptions = { runtime: OrcaRuntimeService; methods?: readonly RpcAnyMethod[] } +// oxfmt-ignore +type DispatchCallOptions = Pick + export class RpcDispatcher { private readonly runtime: OrcaRuntimeService private readonly registry: RpcRegistry @@ -42,10 +45,7 @@ export class RpcDispatcher { this.legacyOrchestration = new OrchestrationLegacyCompatibility(runtime) } - async dispatch( - request: RpcRequest, - options?: { signal?: AbortSignal; authenticatedCallerFingerprint?: string } - ): Promise { + async dispatch(request: RpcRequest, options?: DispatchCallOptions): Promise { const meta = this.meta() const method = this.registry.get(request.method) if (!method) { @@ -118,7 +118,11 @@ export class RpcDispatcher { return method.handler(effectiveParams, { runtime: this.runtime, signal: options?.signal, + connectionId: options?.connectionId, requestId: request.id, + clientId: options?.clientId, + clientKind: options?.clientKind, + clientCapabilities: options?.clientCapabilities, orchestrationCapability: request.orchestrationCapability, authenticatedCallerFingerprint: mutation?.identity.callerFingerprint ?? authenticatedCallerFingerprint, diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index c46c077b768..d943775ef3b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -71,6 +71,7 @@ function makeDispatcher(): RpcDispatcher { // which delegates to the shared cache module the IPC handler also uses. const runtime = { getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), listAiVaultSessions: (args?: Parameters[0]) => listAiVaultSessions(args), resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) => @@ -82,6 +83,7 @@ function makeDispatcher(): RpcDispatcher { function makeFailingDispatcher(error: Error): RpcDispatcher { const runtime = { getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), listAiVaultSessions: vi.fn().mockRejectedValue(error) } as unknown as OrcaRuntimeService return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) @@ -195,6 +197,7 @@ describe('aiVault.prepareSessionResume', () => { const prepareAiVaultSessionResume = vi.fn().mockResolvedValue({ useRealCodexHome: true }) const runtime = { getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn(async () => undefined), prepareAiVaultSessionResume } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index ce4f05b9f76..c689165924d 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -4,8 +4,14 @@ import { OptionalBoolean } from '../schemas' import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title' +import type { AiVaultPrepareSessionResumeArgs } from '../../../../shared/ai-vault-resume-preparation' import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + assertLegacyAiVaultResumeAllowed, + projectStructuredAiVaultSessions +} from '../../../ai-vault/structured-session-ownership' // Why: bound limit + scopePaths so a client cannot force an unbounded scan. // Each scopePath is a host-local match prefix (validated/capped, never used for @@ -56,6 +62,7 @@ export const AiVaultListSessionsParams = z export const AiVaultPrepareSessionResumeParams = z.object({ agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string().min(1).max(512).optional(), filePath: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH), codexHome: z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH).nullable(), executionHostId: z.string().optional() @@ -83,7 +90,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, clientKind, clientCapabilities }) => { + await runtime.ensureStructuredAgentSessionHost() let result try { result = await runtime.listAiVaultSessions({ @@ -101,22 +109,32 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ } // Why: web clients consume this response directly (no parent-side retag), // so sessions must come back stamped as the runtime host they addressed. - return params.executionHostId + const stamped = params.executionHostId ? restampAiVaultListResult(result, params.executionHostId) : result + return projectStructuredAiVaultSessions( + stamped, + clientKind === undefined || + (clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + ) } }), defineMethod({ name: 'aiVault.prepareSessionResume', params: AiVaultPrepareSessionResumeParams, - handler: (params, { runtime }) => - runtime.prepareAiVaultSessionResume({ + handler: async (params, { runtime }) => { + const args: AiVaultPrepareSessionResumeArgs = { agent: params.agent, + ...(params.sessionId ? { sessionId: params.sessionId } : {}), filePath: params.filePath, codexHome: params.codexHome, // Why: the RPC executes on the transcript-owning host; never let a // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID - }) + } + await runtime.ensureStructuredAgentSessionHost() + assertLegacyAiVaultResumeAllowed(args) + return runtime.prepareAiVaultSessionResume(args) + } }) ] diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index d919a62c289..1bdaf224397 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -42,6 +42,7 @@ import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' import { AGENT_HOOK_METHODS } from './agent-hooks' @@ -57,6 +58,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...REPO_METHODS, ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, ...BROWSER_CORE_METHODS, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts index cde2ea9a22f..6ff031ec4c1 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -5,6 +5,8 @@ import { type WorkerSetupReceipt } from './orchestration-worker-topology' import type { OrchestrationWorkerLaunchReceipt } from './orchestration-worker-launch-preferences' +import { isAgentSessionPtyWriteRefusedError } from '../../../../shared/agent-session-pty-write-admission' +import { structuredChatPtyWriteRefusalCopy } from '../../../../shared/agent-session-pty-write-refusal-copy' export function failWorkerStartWithReceipt(args: { db: OrchestrationDb @@ -16,7 +18,13 @@ export function failWorkerStartWithReceipt(args: { setup: WorkerSetupReceipt launch: OrchestrationWorkerLaunchReceipt }): unknown { - const reason = args.error instanceof Error ? args.error.message : String(args.error) + const agentSessionRefusal = isAgentSessionPtyWriteRefusedError(args.error) + ? args.error.refusal + : undefined + const reason = + (agentSessionRefusal && + structuredChatPtyWriteRefusalCopy(agentSessionRefusal, 'worker-start')) ?? + (args.error instanceof Error ? args.error.message : String(args.error)) const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) const worker = unknown ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) @@ -37,6 +45,7 @@ export function failWorkerStartWithReceipt(args: { launch: args.launch, effects: JSON.parse(worker.effects) as unknown[], residualResources: JSON.parse(worker.residual_resources) as unknown[], + ...(agentSessionRefusal ? { agentSessionRefusal } : {}), ...(unknown ? { nextCommands: [ diff --git a/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts new file mode 100644 index 00000000000..488ab69fd1e --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tab-agent-capability-mutations.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest' +import { + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' +import { SESSION_TAB_METHODS } from './session-tabs' + +const METHODS = [ + { + name: 'session.tabs.close', + runtimeMethod: 'closeMobileSessionTab', + params: (tabId: string) => ({ worktree: 'id:wt-1', tabId, reason: 'user' }) + }, + { + name: 'session.tabs.closeLifecycle', + runtimeMethod: 'closeMobileSessionTab', + params: (tabId: string) => ({ + worktree: 'id:wt-1', + tabId, + reason: 'cleanup', + publicationEpoch: 'epoch-1', + terminal: 'pty-1' + }) + }, + { + name: 'session.tabs.activate', + runtimeMethod: 'activateMobileSessionTab', + params: (tabId: string) => ({ worktree: 'id:wt-1', tabId, notifyClients: false }) + }, + { + name: 'session.tabs.move', + runtimeMethod: 'moveMobileSessionTab', + params: (tabId: string) => ({ + worktree: 'id:wt-1', + tabId, + targetGroupId: 'group-1', + kind: 'split', + splitDirection: 'right' + }) + }, + { + name: 'session.tabs.setTabProps', + runtimeMethod: 'setMobileSessionTabProps', + params: (tabId: string) => ({ worktree: 'id:wt-1', tabId, isPinned: true }) + } +] as const + +describe('session tab structured capability mutations', () => { + for (const method of METHODS) { + it(`rejects ${method.name} when the structured row is hidden`, async () => { + const fixture = createFixture([]) + const response = await fixture.dispatch(method.name, method.params('codex-session')) + + expect(response.ok).toBe(false) + expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() + }) + + it(`allows ${method.name} for a capable client`, async () => { + const fixture = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) + const response = await fixture.dispatch(method.name, method.params('codex-session')) + + expect(response.ok).toBe(true) + expect(fixture.calls[method.runtimeMethod]).toHaveBeenCalledOnce() + }) + + it(`rejects ${method.name} for a legacy Claude row`, async () => { + const fixture = createFixture([STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]) + const response = await fixture.dispatch(method.name, method.params('claude-session')) + + expect(response.ok).toBe(false) + expect(fixture.calls[method.runtimeMethod]).not.toHaveBeenCalled() + }) + } +}) + +function createFixture(capabilities: RuntimeCapability[]) { + const snapshot = agentSnapshot() + const calls = { + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }), + activateMobileSessionTab: vi.fn().mockResolvedValue(snapshot), + moveMobileSessionTab: vi.fn().mockResolvedValue({ moved: true }), + setMobileSessionTabProps: vi.fn().mockResolvedValue({ updated: true }) + } + const runtime = { + getRuntimeId: () => 'test-runtime', + listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot), + ...calls + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const context: RpcDispatchStreamingOptions = { + clientKind: 'runtime', + pairedDeviceId: 'paired-client', + clientCapabilities: capabilities + } + return { + calls, + dispatch: async (method: string, params: unknown) => { + const replies: string[] = [] + await dispatcher.dispatchStreaming( + { id: 'request-1', authToken: 'token', method, params }, + (response) => replies.push(response), + context + ) + return JSON.parse(replies[0]!) + } + } +} + +function agentSnapshot() { + const codexTab = { + type: 'agent-session' as const, + id: 'codex-session', + sessionId: 'codex-session', + title: 'Codex session', + agent: 'codex' as const, + isActive: true + } + const claudeTab = { + ...codexTab, + id: 'claude-session', + sessionId: 'claude-session', + title: 'Legacy Claude session', + agent: 'claude', + isActive: false + } + return { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'codex-session', + activeTabType: 'agent-session' as const, + tabGroups: [ + { + id: 'group-1', + activeTabId: 'codex-session', + tabOrder: ['codex-session', 'claude-session'] + } + ], + tabs: [codexTab, claudeTab] + } as unknown as RuntimeMobileSessionTabsResult +} diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts index 45f6613e354..e713f74f057 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' @@ -34,6 +37,104 @@ function makeSnapshot(sessionBoundary: boolean): RuntimeMobileSessionTabsSnapsho } describe('projectSessionTabAgentStatus', () => { + it('projects structured tabs and dangling group focus out of old clients', () => { + const snapshot: RuntimeMobileSessionTabsSnapshot = { + ...makeSnapshot(false), + activeGroupId: 'group-a', + activeTabId: 'agent-session:session-a', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'group-a', + activeTabId: 'agent-session:session-a', + tabOrder: ['tab-1::leaf-1', 'agent-session:session-a'], + recentTabIds: ['agent-session:session-a', 'tab-1::leaf-1'] + }, + { + id: 'group-b', + activeTabId: 'agent-session:session-b', + tabOrder: ['agent-session:session-b'] + } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-a' }, + second: { type: 'leaf', groupId: 'group-b' } + }, + tabs: [ + { ...makeSnapshot(false).tabs[0]!, isActive: false }, + { + type: 'agent-session', + id: 'agent-session:session-a', + title: 'Codex Chat', + sessionId: 'session-a', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-b', + title: 'Codex Chat', + sessionId: 'session-b', + agent: 'codex', + isActive: false + } + ] + } + const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', []) + expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal']) + expect(oldClient.activeTabId).toBe('tab-1::leaf-1') + expect(oldClient.activeTabType).toBe('terminal') + expect(oldClient.tabs[0]?.isActive).toBe(true) + expect(oldClient.tabGroups?.[0]?.tabOrder).toEqual(['tab-1::leaf-1']) + expect(oldClient.tabGroups).toHaveLength(1) + expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' }) + + expect( + projectSessionTabAgentStatus(snapshot, 'mobile', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + ).toEqual(oldClient) + + const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + expect(capable).toBe(snapshot) + }) + + it('withholds legacy Claude rows from paired structured clients', () => { + const snapshot = { + ...makeSnapshot(false), + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex', + title: 'Codex Chat', + sessionId: 'codex', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:claude', + title: 'Claude Chat', + sessionId: 'claude', + agent: 'claude', + isActive: false + } + ], + activeTabId: 'agent-session:codex', + activeTabType: 'agent-session' + } as unknown as RuntimeMobileSessionTabsSnapshot + + expect( + projectSessionTabAgentStatus(snapshot, 'runtime', [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]).tabs.map((tab) => tab.id) + ).toEqual(['agent-session:codex']) + }) + it('withholds session boundaries from legacy paired clients', () => { const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', []) diff --git a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts index 125e083db4f..ac8cc0b2164 100644 --- a/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts +++ b/src/main/runtime/rpc/methods/session-tab-agent-status-projection.ts @@ -1,9 +1,14 @@ -import type { RuntimeCapability } from '../../../../shared/protocol-version' -import { AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + type RuntimeCapability +} from '../../../../shared/protocol-version' import type { + RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsResult, RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-types' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' type SessionTabsPayload = RuntimeMobileSessionTabsResult | RuntimeMobileSessionTabsSnapshot @@ -12,16 +17,24 @@ export function projectSessionTabAgentStatus true) + if (structuredVisible && clientKind !== undefined) { + projected = projectAgentSessionTabsOut(projected, (tab) => tab.agent !== 'codex') + } // Why: only paired runtimes have legacy `done` completion side effects; mobile must keep its row without changing the exact v2 auth shape. if ( clientKind !== 'runtime' || clientCapabilities?.includes(AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY) ) { - return payload + return projected } let changed = false - const tabs = payload.tabs.map((tab) => { + const tabs = projected.tabs.map((tab) => { if (tab.type !== 'terminal' || !tab.agentStatus?.sessionBoundary) { return tab } @@ -29,5 +42,73 @@ export function projectSessionTabAgentStatus( + payload: TPayload, + shouldHide: (tab: RuntimeMobileSessionAgentTab) => boolean +): TPayload { + const hiddenIds = new Set( + payload.tabs + .filter( + (tab): tab is RuntimeMobileSessionAgentTab => + tab.type === 'agent-session' && shouldHide(tab) + ) + .map((tab) => tab.id) + ) + if (hiddenIds.size === 0) { + return payload + } + const tabs = payload.tabs.filter((tab) => !hiddenIds.has(tab.id)) + const groups = payload.tabGroups + ?.map((group) => { + const tabOrder = group.tabOrder.filter((id) => !hiddenIds.has(id)) + if (tabOrder.length === 0) { + return null + } + const recentTabIds = group.recentTabIds?.filter((id) => !hiddenIds.has(id)) + return { + ...group, + activeTabId: + group.activeTabId && tabOrder.includes(group.activeTabId) + ? group.activeTabId + : (recentTabIds?.find((id) => tabOrder.includes(id)) ?? tabOrder[0] ?? null), + tabOrder, + ...(recentTabIds ? { recentTabIds } : {}) + } + }) + .filter((group): group is NonNullable => group !== null) + const active = + tabs.find((tab) => tab.id === payload.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const validGroupIds = new Set(groups?.map((group) => group.id) ?? []) + return { + ...payload, + activeGroupId: + groups?.find((group) => group.tabOrder.includes(active?.id ?? ''))?.id ?? + groups?.[0]?.id ?? + null, + activeTabId: active?.id ?? null, + activeTabType: active?.type ?? null, + ...(groups ? { tabGroups: groups } : { tabGroups: undefined }), + ...(payload.tabGroupLayout !== undefined + ? { tabGroupLayout: pruneStructuredTabGroupLayout(payload.tabGroupLayout, validGroupIds) } + : {}), + tabs: tabs.map((tab) => ({ ...tab, isActive: tab.id === active?.id })) + } as TPayload +} + +function pruneStructuredTabGroupLayout( + layout: TabGroupLayoutNode | null, + validGroupIds: ReadonlySet +): TabGroupLayoutNode | null { + if (!layout || layout.type === 'leaf') { + return layout && validGroupIds.has(layout.groupId) ? layout : null + } + const first = pruneStructuredTabGroupLayout(layout.first, validGroupIds) + const second = pruneStructuredTabGroupLayout(layout.second, validGroupIds) + return first && second ? { ...layout, first, second } : (first ?? second) } diff --git a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts index 980b63d88f4..4067836d88e 100644 --- a/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts +++ b/src/main/runtime/rpc/methods/session-tab-browser-placement-mutations.test.ts @@ -85,7 +85,7 @@ describe('session tab browser placement mutations', () => { it('keeps capable mutation callers on the unprojected path', async () => { const runtime = { getRuntimeId: () => 'test-runtime', - listMobileSessionTabs: vi.fn(), + listMobileSessionTabs: vi.fn().mockResolvedValue(mixedPlacementSnapshot()), closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) @@ -102,7 +102,7 @@ describe('session tab browser placement mutations', () => { ) expect(response.ok).toBe(true) - expect(runtime.listMobileSessionTabs).not.toHaveBeenCalled() + expect(runtime.listMobileSessionTabs).toHaveBeenCalledOnce() expect(runtime.closeMobileSessionTab).toHaveBeenCalledOnce() }) }) diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 11cb5f067f9..50e56144f29 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -2,23 +2,18 @@ import { withSpan } from '../../../observability/tracer' import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' -import { - assertProjectedSessionTabVisible, - clientCanObserveClientHostedBrowserPages, - projectSessionTabBrowserPlacements -} from './session-tab-browser-placement-projection' +import { assertProjectedSessionTabVisible } from './session-tab-browser-placement-projection' +import { projectSessionTabsForClient } from './session-tabs-inventory' export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.close', params: CloseTab, handler: async (params, context) => { - if ( - context.clientKind && - !clientCanObserveClientHostedBrowserPages(context.clientCapabilities) - ) { - const visible = projectSessionTabBrowserPlacements( + if (context.clientKind) { + const visible = projectSessionTabsForClient( await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + context.clientKind, context.clientCapabilities ) assertProjectedSessionTabVisible(visible, params.tabId) @@ -80,8 +75,16 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.closeLifecycle', params: CloseLifecycleTab, - handler: async (params, context) => - withSpan( + handler: async (params, context) => { + if (context.clientKind) { + const visible = projectSessionTabsForClient( + await context.runtime.listMobileSessionTabs(params.worktree, context.pairedDeviceId), + context.clientKind, + context.clientCapabilities + ) + assertProjectedSessionTabVisible(visible, params.tabId) + } + return withSpan( 'runtime.session-tabs.close-lifecycle', async (span) => { const result = await context.runtime.closeMobileSessionTab( @@ -117,5 +120,6 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ } } ) + } }) ] diff --git a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts index cb3694489df..6b9e953e4e3 100644 --- a/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-mutation-methods.ts @@ -1,14 +1,11 @@ import { resolveRuntimeNavigationTarget } from '../../../../shared/runtime-navigation' -import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { OrcaRuntimeService } from '../../orca-runtime' import { defineMethod, type RpcAnyMethod } from '../core' -import { projectSessionTabAgentStatus } from './session-tab-agent-status-projection' import { assertProjectedSessionTabVisible, - clientCanObserveClientHostedBrowserPages, - projectSessionTabBrowserPlacements, translateProjectedSessionTabMove } from './session-tab-browser-placement-projection' +import { projectSessionTabsForClient } from './session-tabs-inventory' import { ActivateTab, MoveTab, SetTabProps, UpdatePaneLayout } from './session-tabs-schemas' export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ @@ -16,9 +13,10 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ name: 'session.tabs.activate', params: ActivateTab, handler: async (params, { runtime, clientKind, pairedDeviceId, clientCapabilities }) => { - if (clientKind && !clientCanObserveClientHostedBrowserPages(clientCapabilities)) { - const visible = projectSessionTabBrowserPlacements( + if (clientKind) { + const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), + clientKind, clientCapabilities ) assertProjectedSessionTabVisible(visible, params.tabId) @@ -46,9 +44,9 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ params: MoveTab, handler: async (params, { runtime, pairedDeviceId, clientCapabilities, clientKind }) => { let translated: Parameters[2] = params - if (clientKind && !clientCanObserveClientHostedBrowserPages(clientCapabilities)) { + if (clientKind) { const raw = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) - const projected = projectSessionTabBrowserPlacements(raw, clientCapabilities) + const projected = projectSessionTabsForClient(raw, clientKind, clientCapabilities) translated = translateProjectedSessionTabMove(raw, projected, params) } const base = { tabId: translated.tabId, targetGroupId: translated.targetGroupId } @@ -115,16 +113,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [ }) ] -function projectSessionTabsForMutationClient( - snapshot: RuntimeMobileSessionTabsResult, - clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] -): RuntimeMobileSessionTabsResult { - return projectSessionTabBrowserPlacements( - projectSessionTabAgentStatus(snapshot, clientKind, clientCapabilities), - clientCapabilities - ) -} +const projectSessionTabsForMutationClient = projectSessionTabsForClient async function assertVisibleMutationTab( runtime: OrcaRuntimeService, @@ -132,13 +121,14 @@ async function assertVisibleMutationTab( tabId: string, pairedDeviceId: string | undefined, clientKind: 'mobile' | 'runtime' | undefined, - clientCapabilities: Parameters[2] + clientCapabilities: Parameters[2] ): Promise { - if (!clientKind || clientCanObserveClientHostedBrowserPages(clientCapabilities)) { + if (!clientKind) { return } - const visible = projectSessionTabBrowserPlacements( + const visible = projectSessionTabsForClient( await runtime.listMobileSessionTabs(worktree, pairedDeviceId), + clientKind, clientCapabilities ) assertProjectedSessionTabVisible(visible, tabId) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts new file mode 100644 index 00000000000..e8166bdd056 --- /dev/null +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-census-race.test.ts @@ -0,0 +1,897 @@ +import { describe, expect, it, vi } from 'vitest' +import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../../shared/runtime-session-contracts' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { OrcaRuntimeService } from '../../orca-runtime' +import { subscribeSessionTabsInventory } from './session-tabs-inventory' + +const runningBaselineOracle = process.env.ORCA_TEST_BASELINE_SESSION_TABS_CENSUS_ORACLE === '1' + +type Inventory = { + snapshots: RuntimeMobileSessionTabsResult[] + authoritative: true + changeSequence: number +} + +function deferredInventory(): { + promise: Promise + resolve: (inventory: Inventory) => void +} { + let resolve!: (inventory: Inventory) => void + return { + promise: new Promise((settle) => { + resolve = settle + }), + resolve + } +} + +function snapshot( + snapshotVersion: number, + tabs: RuntimeMobileSessionTabsResult['tabs'], + options: { publicationEpoch?: string; removed?: true } = {} +): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-census-race', + publicationEpoch: options.publicationEpoch ?? 'epoch-current', + snapshotVersion, + ...(options.removed ? { removed: true as const } : {}), + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs + } +} + +function terminalTab(id: string): RuntimeMobileSessionTabsResult['tabs'][number] { + return { + id, + type: 'terminal', + title: id, + parentTabId: `parent-${id}`, + leafId: `leaf-${id}`, + ptyId: `pty-${id}`, + status: 'ready', + terminal: `term-${id}`, + isActive: false + } +} + +type RuntimeInventoryInternals = { + refreshMobileSessionPtyInventory: () => Promise + mobileSessionTabsNotifyCoalescer: { flushAll: () => void } + mobileSessionTabListeners: Set + mobileSessionTabsByWorktree: Map + emitMobileSessionTabsSnapshot: (snapshot: RuntimeMobileSessionTabsSnapshot) => void + emitMobileSessionTabsSnapshotToClient: ( + snapshot: RuntimeMobileSessionTabsResult, + clientNavigationId: string, + follow?: boolean + ) => void +} + +type PtyInventory = { + livePtyIds: Set + allLivePtyIds: Set + terminalIdentityByPtyId: Map + queriedHostIds: Set +} + +function completePtyInventory(): PtyInventory { + return { + livePtyIds: new Set(), + allLivePtyIds: new Set(), + terminalIdentityByPtyId: new Map(), + queriedHostIds: new Set(['local']) + } +} + +function runtimeSnapshot( + worktree: string, + snapshotVersion: number +): RuntimeMobileSessionTabsSnapshot { + return { + worktree, + publicationEpoch: 'epoch-current', + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } +} + +function deferredPtyInventory(): { + promise: Promise + resolve: (inventory: PtyInventory) => void +} { + let resolve!: (inventory: PtyInventory) => void + return { + promise: new Promise((settle) => { + resolve = settle + }), + resolve + } +} + +function createRuntimeHarness(initialSnapshots: RuntimeMobileSessionTabsSnapshot[] = []) { + const runtime = new OrcaRuntimeService() + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: initialSnapshots }) + const census = deferredPtyInventory() + const internals = runtime as unknown as RuntimeInventoryInternals + vi.spyOn(internals, 'refreshMobileSessionPtyInventory').mockReturnValue(census.promise) + const emit = vi.fn<(event: unknown) => void>() + const pending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-runtime-census-race', + requestId: 'req-runtime-census-race', + pairedDeviceId: 'paired-runtime-census-race', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + }, + emit + ) + return { + census, + emit, + internals, + pending, + publish: (snapshots: RuntimeMobileSessionTabsSnapshot[], flush = true): void => { + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: snapshots }) + if (flush) { + internals.mobileSessionTabsNotifyCoalescer.flushAll() + } + }, + runtime + } +} + +function createHarness() { + const census = deferredInventory() + const emit = vi.fn<(event: unknown) => void>() + const unsubscribe = vi.fn<() => void>() + const cleanup = vi.fn<() => void>() + let changeSequence = 0 + let listener: + | ((value: RuntimeMobileSessionTabsResult, changeSequence: number) => void) + | undefined + const runtime = { + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventory: vi.fn(() => census.promise), + listAllMobileSessionTabsInventoryWithChangeSequence: vi.fn(async () => { + return await census.promise + }), + onMobileSessionTabsChanged: vi.fn( + (nextListener: (value: RuntimeMobileSessionTabsResult, changeSequence: number) => void) => { + listener = nextListener + return unsubscribe + } + ), + registerSubscriptionCleanup: vi.fn((_id: string, nextCleanup: () => void) => + cleanup.mockImplementation(nextCleanup) + ), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + + return { + census, + cleanup, + context: { + runtime, + connectionId: 'conn-census-race', + requestId: 'req-census-race', + clientCapabilities: [SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY] + }, + emit, + deliver: (value: RuntimeMobileSessionTabsResult, sequence: number) => + listener?.(value, sequence), + publish: (value: RuntimeMobileSessionTabsResult) => listener?.(value, ++changeSequence), + unsubscribe + } +} + +describe.skipIf(runningBaselineOracle)('real runtime session tabs census boundary', () => { + it('subsumes a delivered change already captured by the census', async () => { + const harness = createRuntimeHarness() + const created = runtimeSnapshot('wt-census-race', 1) + + harness.publish([created]) + harness.census.resolve(completePtyInventory()) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ + type: 'snapshots', + authoritative: true, + snapshots: [expect.objectContaining({ worktree: created.worktree, snapshotVersion: 1 })] + }) + ]) + }) + + it('subsumes a removal that precedes the final census snapshot', async () => { + const existing = runtimeSnapshot('wt-census-race', 1) + const harness = createRuntimeHarness([existing]) + + harness.publish([]) + harness.census.resolve(completePtyInventory()) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true } + ]) + }) + + it('subsumes creation and removal at the census release edge', async () => { + const created = runtimeSnapshot('wt-created', 1) + const creation = createRuntimeHarness() + creation.census.resolve(completePtyInventory()) + creation.publish([created]) + + const existing = runtimeSnapshot('wt-removed', 1) + const removal = createRuntimeHarness([existing]) + removal.census.resolve(completePtyInventory()) + removal.publish([]) + + await Promise.all([creation.pending, removal.pending]) + + expect(creation.emit).toHaveBeenCalledOnce() + expect(creation.emit.mock.calls[0]?.[0]).toMatchObject({ + type: 'snapshots', + snapshots: [expect.objectContaining({ worktree: created.worktree })] + }) + expect(removal.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true } + ]) + }) + + it('does not replay a delayed pre-boundary change after initialization', async () => { + const harness = createRuntimeHarness() + const created = runtimeSnapshot('wt-census-race', 1) + + harness.publish([created], false) + harness.census.resolve(completePtyInventory()) + await harness.pending + harness.internals.mobileSessionTabsNotifyCoalescer.flushAll() + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ + type: 'snapshots', + authoritative: true, + snapshots: [expect.objectContaining({ worktree: created.worktree, snapshotVersion: 1 })] + }) + ]) + }) + + it('delivers a creation after the effective snapshot boundary', async () => { + const harness = createRuntimeHarness() + const created = runtimeSnapshot('wt-census-race', 1) + + harness.census.resolve(completePtyInventory()) + queueMicrotask(() => harness.publish([created])) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + expect.objectContaining({ + type: 'updated', + worktree: created.worktree, + snapshotVersion: created.snapshotVersion + }) + ]) + }) + + it('delivers a removal after the effective snapshot boundary', async () => { + const existing = runtimeSnapshot('wt-census-race', 1) + const harness = createRuntimeHarness([existing]) + + harness.census.resolve(completePtyInventory()) + queueMicrotask(() => harness.publish([])) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ + type: 'snapshots', + authoritative: true, + snapshots: [expect.objectContaining({ worktree: existing.worktree, snapshotVersion: 1 })] + }), + expect.objectContaining({ + type: 'updated', + worktree: existing.worktree, + removed: true, + tabs: [] + }) + ]) + }) + + it('coalesces ordinary same-structure churn and replays worktrees in sequence order', async () => { + const harness = createRuntimeHarness() + const worktreeB1 = runtimeSnapshot('wt-b', 1) + const worktreeA = runtimeSnapshot('wt-a', 1) + const worktreeB2 = runtimeSnapshot('wt-b', 2) + + harness.census.resolve(completePtyInventory()) + queueMicrotask(() => { + harness.publish([worktreeB1]) + harness.publish([worktreeB1, worktreeA]) + harness.publish([worktreeB2, worktreeA]) + }) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + expect.objectContaining({ type: 'updated', worktree: 'wt-a', snapshotVersion: 1 }), + expect.objectContaining({ type: 'updated', worktree: 'wt-b', snapshotVersion: 2 }) + ]) + }) + + it('replays create, remove, and recreate transitions without collapsing them', async () => { + const harness = createRuntimeHarness() + const created = runtimeSnapshot('wt-census-race', 1) + const recreated = runtimeSnapshot('wt-census-race', 2) + + harness.census.resolve(completePtyInventory()) + queueMicrotask(() => { + harness.publish([created]) + harness.publish([]) + harness.publish([recreated]) + }) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + expect.objectContaining({ type: 'updated', worktree: created.worktree, snapshotVersion: 1 }), + expect.objectContaining({ type: 'updated', worktree: created.worktree, removed: true }), + expect.objectContaining({ type: 'updated', worktree: recreated.worktree, snapshotVersion: 2 }) + ]) + }) + + it('cancels a stale coalesced callback before removal and later recreation', async () => { + const initial = runtimeSnapshot('wt-census-race', 1) + const harness = createRuntimeHarness([initial]) + const pending = runtimeSnapshot('wt-census-race', 2) + const recreated = runtimeSnapshot('wt-census-race', 3) + + harness.census.resolve(completePtyInventory()) + await harness.pending + harness.publish([pending], false) + harness.publish([]) + harness.internals.mobileSessionTabsNotifyCoalescer.flushAll() + harness.publish([recreated]) + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ + type: 'snapshots', + snapshots: [expect.objectContaining({ snapshotVersion: initial.snapshotVersion })] + }), + expect.objectContaining({ type: 'updated', worktree: initial.worktree, removed: true }), + expect.objectContaining({ + type: 'updated', + worktree: recreated.worktree, + snapshotVersion: recreated.snapshotVersion + }) + ]) + }) + + it('keeps a newer immediate change when an older coalesced callback arrives last', async () => { + const harness = createRuntimeHarness() + const scheduled = runtimeSnapshot('wt-census-race', 1) + const immediate = runtimeSnapshot('wt-census-race', 2) + + harness.publish([scheduled], false) + harness.census.resolve(completePtyInventory()) + queueMicrotask(() => { + harness.internals.mobileSessionTabsByWorktree.set(immediate.worktree, immediate) + harness.internals.emitMobileSessionTabsSnapshot(immediate) + harness.internals.mobileSessionTabsNotifyCoalescer.flushAll() + }) + await harness.pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + expect.objectContaining({ + type: 'snapshots', + snapshots: [expect.objectContaining({ snapshotVersion: scheduled.snapshotVersion })] + }), + expect.objectContaining({ + type: 'updated', + worktree: immediate.worktree, + snapshotVersion: immediate.snapshotVersion + }) + ]) + }) + + it('preserves caller-only follow intent when a later shared snapshot is buffered', async () => { + const runtime = new OrcaRuntimeService() + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const census = deferredInventory() + vi.spyOn(runtime, 'listAllMobileSessionTabsInventoryWithChangeSequence').mockImplementation( + () => census.promise + ) + const callerEmit = vi.fn<(event: unknown) => void>() + const bystanderEmit = vi.fn<(event: unknown) => void>() + const callerPending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-caller', + requestId: 'req-caller', + pairedDeviceId: 'device-caller' + }, + callerEmit + ) + const bystanderPending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-bystander', + requestId: 'req-bystander', + pairedDeviceId: 'device-bystander' + }, + bystanderEmit + ) + const followed = snapshot(1, [terminalTab('followed')]) + const latest = snapshot(2, [terminalTab('latest')]) + const internals = runtime as unknown as RuntimeInventoryInternals + + census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => { + internals.emitMobileSessionTabsSnapshotToClient(followed, 'device-caller', true) + const latestRuntimeSnapshot = latest as RuntimeMobileSessionTabsSnapshot + internals.mobileSessionTabsByWorktree.set(latest.worktree, latestRuntimeSnapshot) + internals.emitMobileSessionTabsSnapshot(latestRuntimeSnapshot) + }) + await Promise.all([callerPending, bystanderPending]) + + expect(callerEmit).toHaveBeenCalledTimes(3) + expect(callerEmit.mock.calls[1]?.[0]).toMatchObject({ + type: 'updated', + worktree: followed.worktree, + snapshotVersion: followed.snapshotVersion, + navigationIntent: 'follow', + tabs: [expect.objectContaining({ id: 'followed' })] + }) + expect(callerEmit.mock.calls[2]?.[0]).toMatchObject({ + type: 'updated', + worktree: latest.worktree, + snapshotVersion: latest.snapshotVersion, + tabs: [expect.objectContaining({ id: 'latest' })] + }) + expect(callerEmit.mock.calls[2]?.[0]).not.toHaveProperty('navigationIntent') + expect(bystanderEmit).toHaveBeenCalledTimes(2) + expect(bystanderEmit.mock.calls[1]?.[0]).toMatchObject({ + type: 'updated', + worktree: latest.worktree, + snapshotVersion: latest.snapshotVersion, + tabs: [expect.objectContaining({ id: 'latest' })] + }) + expect(bystanderEmit.mock.calls[1]?.[0]).not.toHaveProperty('navigationIntent') + }) + + it('subsumes pre-boundary follow intent into the census selection', async () => { + const runtime = new OrcaRuntimeService() + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const census = deferredInventory() + vi.spyOn(runtime, 'listAllMobileSessionTabsInventoryWithChangeSequence').mockImplementation( + () => census.promise + ) + const emit = vi.fn<(event: unknown) => void>() + const pending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-follow-before', + requestId: 'req-follow-before', + pairedDeviceId: 'device-follow-before' + }, + emit + ) + const selected = snapshot(1, [terminalTab('selected')]) + const internals = runtime as unknown as RuntimeInventoryInternals + + internals.emitMobileSessionTabsSnapshotToClient(selected, 'device-follow-before', true) + census.resolve({ snapshots: [selected], authoritative: true, changeSequence: 1 }) + await pending + + expect(emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [selected] } + ]) + }) + + it('uses one change sequence across subscribers without duplicate fanout', async () => { + const runtime = new OrcaRuntimeService() + const created = runtimeSnapshot('wt-census-race', 1) + const first: number[] = [] + const second: number[] = [] + const unsubscribeFirst = runtime.onMobileSessionTabsChanged((_snapshot, sequence) => + first.push(sequence) + ) + const unsubscribeSecond = runtime.onMobileSessionTabsChanged((_snapshot, sequence) => + second.push(sequence) + ) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [created] }) + ;(runtime as unknown as RuntimeInventoryInternals).mobileSessionTabsNotifyCoalescer.flushAll() + + expect(first).toEqual([expect.any(Number)]) + expect(second).toEqual(first) + unsubscribeFirst() + unsubscribeSecond() + }) + + it('aborts the census and removes the real runtime listener on disconnect', async () => { + const runtime = new OrcaRuntimeService() + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const census = deferredPtyInventory() + const internals = runtime as unknown as RuntimeInventoryInternals + vi.spyOn(internals, 'refreshMobileSessionPtyInventory').mockReturnValue(census.promise) + const controller = new AbortController() + const pending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-abort', + requestId: 'req-abort', + signal: controller.signal + }, + vi.fn() + ) + + controller.abort() + census.resolve(completePtyInventory()) + + await expect(pending).rejects.toThrow('client_disconnected') + expect(internals.mobileSessionTabListeners).toHaveLength(0) + }) +}) + +describe('session tabs inventory census boundary', () => { + it('subsumes a change that precedes the final census snapshot', async () => { + const harness = createHarness() + const prior = snapshot(1, [terminalTab('prior')], { publicationEpoch: 'epoch-prior' }) + const current = snapshot(1, [terminalTab('current')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.publish(prior) + harness.census.resolve({ snapshots: [current], authoritative: true, changeSequence: 1 }) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [current], authoritative: true } + ]) + }) + + it('subsumes a creation captured at the census boundary exactly once', async () => { + const harness = createHarness() + const created = snapshot(2, [terminalTab('created')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.publish(created) + harness.census.resolve({ snapshots: [created], authoritative: true, changeSequence: 1 }) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [created], authoritative: true } + ]) + }) + + it('delivers a creation after the census boundary as the next ordered update', async () => { + const harness = createHarness() + const created = snapshot(2, [terminalTab('created')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => harness.publish(created)) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + { type: 'updated', ...created } + ]) + }) + + it('delivers a removal after the census boundary as the next ordered update', async () => { + const harness = createHarness() + const existing = snapshot(1, [terminalTab('existing')]) + const removed = snapshot(0, [], { publicationEpoch: 'removed:epoch', removed: true }) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [existing], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => harness.publish(removed)) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [existing], authoritative: true }, + { type: 'updated', ...removed } + ]) + }) + + it('delivers ordinary changes after initialization without duplication', async () => { + const harness = createHarness() + const created = snapshot(2, [terminalTab('created')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + await pending + harness.publish(created) + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + { type: 'updated', ...created } + ]) + }) + + it('drops a delayed callback already covered by the census watermark', async () => { + const harness = createHarness() + const included = snapshot(2, [terminalTab('included')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [included], authoritative: true, changeSequence: 1 }) + await pending + harness.deliver(included, 1) + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [included], authoritative: true } + ]) + }) + + it('deduplicates a post-watermark notification already projected by the census', async () => { + const harness = createHarness() + const included = snapshot(2, [terminalTab('included')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [included], authoritative: true, changeSequence: 0 }) + await pending + harness.deliver(included, 1) + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [included], authoritative: true } + ]) + }) + + it('delivers a post-boundary projected-state change without a new snapshot revision', async () => { + const harness = createHarness() + const pendingTab = { + ...terminalTab('derived'), + status: 'pending-handle' as const, + terminal: null + } + const initial = snapshot(2, [pendingTab]) + const ready = snapshot(2, [terminalTab('derived')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [initial], authoritative: true, changeSequence: 0 }) + await pending + harness.deliver(ready, 1) + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [initial], authoritative: true }, + { type: 'updated', ...ready } + ]) + }) + + it('replays buffered follow intent before the later ordinary snapshot', async () => { + const harness = createHarness() + const followed = { + ...snapshot(1, [terminalTab('followed')]), + navigationIntent: 'follow' as const + } + const latest = snapshot(2, [terminalTab('latest')]) + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => { + harness.publish(followed) + harness.publish(latest) + }) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + { type: 'updated', ...followed }, + { type: 'updated', ...latest } + ]) + }) + + it('bounds non-structural initialization churn to the latest projected state', async () => { + const harness = createHarness() + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => { + for (let version = 1; version <= 1_000; version += 1) { + harness.publish(snapshot(version, [terminalTab('stable')])) + } + }) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + { type: 'updated', ...snapshot(1_000, [terminalTab('stable')]) } + ]) + }) + + it('bounds alternating selection churn to the latest projected state', async () => { + const harness = createHarness() + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => { + for (let version = 1; version <= 1_000; version += 1) { + const selected = version % 2 === 0 + harness.publish({ + ...snapshot(version, [{ ...terminalTab('stable'), isActive: selected }]), + activeTabId: selected ? 'stable' : null, + activeTabType: selected ? 'terminal' : null + }) + } + }) + await pending + + expect(harness.emit).toHaveBeenCalledTimes(2) + expect(harness.emit.mock.calls[1]?.[0]).toMatchObject({ + type: 'updated', + snapshotVersion: 1_000, + activeTabId: 'stable' + }) + }) + + it('bounds repeated follow intent to the latest caller request', async () => { + const harness = createHarness() + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + queueMicrotask(() => { + for (let version = 1; version <= 1_000; version += 1) { + harness.publish({ + ...snapshot(version, [terminalTab(`followed-${version}`)]), + navigationIntent: 'follow' + }) + } + }) + await pending + + expect(harness.emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [], authoritative: true }, + { + type: 'updated', + ...snapshot(1_000, [terminalTab('followed-1000')]), + navigationIntent: 'follow' + } + ]) + }) + + it('restarts the census when structural churn fills the bounded buffer', async () => { + const firstCensus = deferredInventory() + const secondCensus = deferredInventory() + const emit = vi.fn<(event: unknown) => void>() + let listener: + | ((value: RuntimeMobileSessionTabsResult, changeSequence: number) => void) + | undefined + const collect = vi + .fn() + .mockImplementationOnce(() => firstCensus.promise) + .mockImplementationOnce(() => secondCensus.promise) + const runtime = { + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventoryWithChangeSequence: collect, + onMobileSessionTabsChanged: vi.fn( + (nextListener: (value: RuntimeMobileSessionTabsResult, sequence: number) => void) => { + listener = nextListener + return vi.fn() + } + ), + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn() + } as unknown as OrcaRuntimeService + const pending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-structural-churn', + requestId: 'req-structural-churn' + }, + emit + ) + + await Promise.resolve() + for (let sequence = 1; sequence <= 300; sequence += 1) { + listener?.(snapshot(sequence, sequence % 2 === 0 ? [terminalTab('stable')] : []), sequence) + } + firstCensus.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + await vi.waitFor(() => expect(collect).toHaveBeenCalledTimes(2)) + const settled = snapshot(300, [terminalTab('stable')]) + secondCensus.resolve({ snapshots: [settled], authoritative: true, changeSequence: 300 }) + await pending + + expect(emit.mock.calls.map(([event]) => event)).toEqual([ + { type: 'snapshots', snapshots: [settled] } + ]) + }) + + it('fails before publication after repeated structural buffer overflow', async () => { + const censuses = [deferredInventory(), deferredInventory(), deferredInventory()] + const emit = vi.fn<(event: unknown) => void>() + const unsubscribe = vi.fn() + let cleanup = vi.fn() + let listener: + | ((value: RuntimeMobileSessionTabsResult, changeSequence: number) => void) + | undefined + const collect = vi.fn((..._args: unknown[]) => { + const census = censuses[collect.mock.calls.length - 1] + if (!census) { + throw new Error('unexpected extra census') + } + return census.promise + }) + const runtime = { + supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), + listAllMobileSessionTabsInventoryWithChangeSequence: collect, + onMobileSessionTabsChanged: vi.fn( + (nextListener: (value: RuntimeMobileSessionTabsResult, sequence: number) => void) => { + listener = nextListener + return unsubscribe + } + ), + registerSubscriptionCleanup: vi.fn((_id: string, nextCleanup: () => void) => { + cleanup = vi.fn(nextCleanup) + }), + cleanupSubscription: vi.fn(() => cleanup()) + } as unknown as OrcaRuntimeService + const pending = subscribeSessionTabsInventory( + { + runtime, + connectionId: 'conn-sustained-structural-churn', + requestId: 'req-sustained-structural-churn' + }, + emit + ) + let changeSequence = 0 + const overflowBuffer = (): void => { + for (let index = 0; index <= 256; index += 1) { + changeSequence += 1 + listener?.( + snapshot(changeSequence, index % 2 === 0 ? [terminalTab('alternating')] : []), + changeSequence + ) + } + } + + await Promise.resolve() + for (let censusIndex = 0; censusIndex < censuses.length; censusIndex += 1) { + overflowBuffer() + censuses[censusIndex]?.resolve({ + snapshots: [], + authoritative: true, + changeSequence + }) + if (censusIndex < censuses.length - 1) { + await vi.waitFor(() => expect(collect).toHaveBeenCalledTimes(censusIndex + 2)) + } + } + + await expect(pending).rejects.toThrow('session_tabs_inventory_unstable') + expect(collect).toHaveBeenCalledTimes(3) + expect(unsubscribe).toHaveBeenCalledOnce() + expect(emit).not.toHaveBeenCalled() + }) + + it('drops buffered work and removes the listener when disposed during census', async () => { + const harness = createHarness() + const pending = subscribeSessionTabsInventory(harness.context, harness.emit) + + await Promise.resolve() + harness.cleanup() + harness.publish(snapshot(2, [terminalTab('late')])) + harness.census.resolve({ snapshots: [], authoritative: true, changeSequence: 0 }) + await pending + + expect(harness.emit).not.toHaveBeenCalled() + expect(harness.unsubscribe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts index 2834c55b36d..6b53a3ef991 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory-rpc.test.ts @@ -194,22 +194,27 @@ describe('session tabs inventory RPC methods', () => { let resolveInventory!: (value: { snapshots: RuntimeMobileSessionTabsResult[] authoritative: true + changeSequence: number }) => void - const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + const listeners: (( + snapshot: RuntimeMobileSessionTabsResult, + changeSequence: number + ) => void)[] = [] const runtime = { getRuntimeId: () => 'test-runtime', supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), - listAllMobileSessionTabsInventory: vi.fn( + listAllMobileSessionTabsInventoryWithChangeSequence: vi.fn( () => new Promise<{ snapshots: RuntimeMobileSessionTabsResult[] authoritative: true + changeSequence: number }>((resolve) => { resolveInventory = resolve }) ), onMobileSessionTabsChanged: vi.fn( - (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + (listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void) => { listeners.push(listener) return vi.fn() } @@ -229,15 +234,18 @@ describe('session tabs inventory RPC methods', () => { } ) await Promise.resolve() - listeners[0]?.({ - worktree: 'wt-authoritative', - publicationEpoch: 'epoch-before-reload', - snapshotVersion: 2, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }) + listeners[0]?.( + { + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-before-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + 1 + ) expect(messages).toEqual([]) resolveInventory({ @@ -252,18 +260,22 @@ describe('session tabs inventory RPC methods', () => { tabs: [] } ], - authoritative: true + authoritative: true, + changeSequence: 1 }) await pending - listeners[0]?.({ - worktree: 'wt-authoritative', - publicationEpoch: 'epoch-after-reload', - snapshotVersion: 2, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }) + listeners[0]?.( + { + worktree: 'wt-authoritative', + publicationEpoch: 'epoch-after-reload', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + 2 + ) expect(messages.map((message) => JSON.parse(message).result)).toEqual([ { @@ -287,19 +299,26 @@ describe('session tabs inventory RPC methods', () => { }) it('lets authoritative empty inventory win over prior-epoch updates', async () => { - let resolveInventory!: (value: { snapshots: []; authoritative: true }) => void - const listeners: ((snapshot: RuntimeMobileSessionTabsResult) => void)[] = [] + let resolveInventory!: (value: { + snapshots: [] + authoritative: true + changeSequence: number + }) => void + const listeners: (( + snapshot: RuntimeMobileSessionTabsResult, + changeSequence: number + ) => void)[] = [] const runtime = { getRuntimeId: () => 'test-runtime', supportsAuthoritativeSessionTabsInventory: vi.fn(() => true), - listAllMobileSessionTabsInventory: vi.fn( + listAllMobileSessionTabsInventoryWithChangeSequence: vi.fn( () => - new Promise<{ snapshots: []; authoritative: true }>((resolve) => { + new Promise<{ snapshots: []; authoritative: true; changeSequence: number }>((resolve) => { resolveInventory = resolve }) ), onMobileSessionTabsChanged: vi.fn( - (listener: (snapshot: RuntimeMobileSessionTabsResult) => void) => { + (listener: (snapshot: RuntimeMobileSessionTabsResult, changeSequence: number) => void) => { listeners.push(listener) return vi.fn() } @@ -328,10 +347,10 @@ describe('session tabs inventory RPC methods', () => { } ) await Promise.resolve() - listeners[0]?.(snapshot(2)) - listeners[0]?.(snapshot(3)) + listeners[0]?.(snapshot(2), 1) + listeners[0]?.(snapshot(3), 2) - resolveInventory({ snapshots: [], authoritative: true }) + resolveInventory({ snapshots: [], authoritative: true, changeSequence: 2 }) await pending expect(messages.map((message) => JSON.parse(message).result)).toEqual([ diff --git a/src/main/runtime/rpc/methods/session-tabs-inventory.ts b/src/main/runtime/rpc/methods/session-tabs-inventory.ts index f47c2251f6e..fba9a460e86 100644 --- a/src/main/runtime/rpc/methods/session-tabs-inventory.ts +++ b/src/main/runtime/rpc/methods/session-tabs-inventory.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util' import { SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { RpcContext } from '../core' @@ -9,6 +10,11 @@ type SessionTabsInventory = { authoritative?: true } +type SessionTabsChange = RuntimeMobileSessionTabsResult & { removed?: true } + +const MAX_BUFFERED_CENSUS_CHANGES = 256 +const MAX_CENSUS_COLLECTION_ATTEMPTS = 3 + function clientUnderstandsAuthoritativeInventory(context: RpcContext): boolean { return ( context.clientCapabilities?.includes( @@ -42,14 +48,33 @@ function projectInventory( } } -export async function listSessionTabsInventory(context: RpcContext): Promise { +async function collectSessionTabsInventory( + context: RpcContext, + includeChangeSequence = false +): Promise<{ + inventory: SessionTabsInventory + changeSequence: number +}> { const { runtime, pairedDeviceId, signal } = context // Why: a failed census degrades inside the runtime to the same scan without // the authoritative label, so no client ever pays a second full collection. - const inventory = runtime.supportsAuthoritativeSessionTabsInventory() - ? await runtime.listAllMobileSessionTabsInventory(pairedDeviceId, signal) - : { snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) } - return projectInventory(inventory, context) + if (!includeChangeSequence) { + const inventory = runtime.supportsAuthoritativeSessionTabsInventory() + ? await runtime.listAllMobileSessionTabsInventory(pairedDeviceId, signal) + : { snapshots: await runtime.listAllMobileSessionTabs(pairedDeviceId) } + return { inventory: projectInventory(inventory, context), changeSequence: 0 } + } + const collected = runtime.supportsAuthoritativeSessionTabsInventory() + ? await runtime.listAllMobileSessionTabsInventoryWithChangeSequence(pairedDeviceId, signal) + : await runtime.listAllMobileSessionTabsWithChangeSequence(pairedDeviceId) + return { + inventory: projectInventory(collected, context), + changeSequence: collected.changeSequence + } +} + +export async function listSessionTabsInventory(context: RpcContext): Promise { + return (await collectSessionTabsInventory(context)).inventory } export async function subscribeSessionTabsInventory( @@ -67,15 +92,117 @@ export async function subscribeSessionTabsInventory( context.signal?.addEventListener('abort', abortInventory, { once: true }) let initialized = false let closed = false - const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { - if (!initialized) { - // Why: the final authoritative scan subsumes these; replaying a prior epoch afterward resurrects stale host state. + const bufferedChanges: { snapshot: SessionTabsChange; changeSequence: number }[] = [] + const bufferedOrdinaryChangeByWorktree = new Map< + string, + { index: number; membershipKey: string; changeSequence: number } + >() + const bufferedNavigationIntentByWorktree = new Map< + string, + { index: number; changeSequence: number } + >() + const publishedSnapshotsByWorktree = new Map() + const deliveredChangeSequenceByWorktree = new Map() + let censusChangeSequence: number | undefined + let censusInvalidated = false + const projectChange = (snapshot: SessionTabsChange): SessionTabsChange => + projectSessionTabsForClient( + snapshot, + context.clientKind, + context.clientCapabilities + ) as SessionTabsChange + const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => { + if (snapshot.navigationIntent === undefined) { + return snapshot + } + const { navigationIntent: _navigationIntent, ...state } = snapshot + return state as SessionTabsChange + } + const membershipKey = (snapshot: SessionTabsChange): string => + JSON.stringify({ + removed: snapshot.removed === true, + tabs: snapshot.tabs.map((tab) => ({ + type: tab.type, + id: tab.id + })) + }) + const clearBufferedChanges = (): void => { + bufferedChanges.length = 0 + bufferedOrdinaryChangeByWorktree.clear() + bufferedNavigationIntentByWorktree.clear() + } + const reserveBufferedChange = (): void => { + if (bufferedChanges.length < MAX_BUFFERED_CENSUS_CHANGES) { + return + } + clearBufferedChanges() + censusInvalidated = true + } + const bufferChange = (snapshot: SessionTabsChange, changeSequence: number): void => { + if (snapshot.navigationIntent !== undefined) { + const previous = bufferedNavigationIntentByWorktree.get(snapshot.worktree) + if (previous) { + if (changeSequence > previous.changeSequence) { + bufferedChanges[previous.index] = { snapshot, changeSequence } + previous.changeSequence = changeSequence + } + return + } + reserveBufferedChange() + const index = bufferedChanges.push({ snapshot, changeSequence }) - 1 + bufferedNavigationIntentByWorktree.set(snapshot.worktree, { index, changeSequence }) + return + } + const nextMembershipKey = membershipKey(snapshot) + const previous = bufferedOrdinaryChangeByWorktree.get(snapshot.worktree) + if (previous?.membershipKey === nextMembershipKey) { + if (changeSequence > previous.changeSequence) { + bufferedChanges[previous.index] = { snapshot, changeSequence } + previous.changeSequence = changeSequence + } + return + } + reserveBufferedChange() + const index = bufferedChanges.push({ snapshot, changeSequence }) - 1 + bufferedOrdinaryChangeByWorktree.set(snapshot.worktree, { + index, + membershipKey: nextMembershipKey, + changeSequence + }) + } + const publishChange = (snapshot: SessionTabsChange, changeSequence: number): void => { + const projected = projectChange(snapshot) + const state = withoutNavigationIntent(projected) + const published = publishedSnapshotsByWorktree.get(snapshot.worktree) + if (projected.navigationIntent === undefined && isDeepStrictEqual(published, state)) { + deliveredChangeSequenceByWorktree.set(snapshot.worktree, changeSequence) return } emit({ type: 'updated', - ...projectSessionTabsForClient(snapshot, context.clientKind, context.clientCapabilities) + ...projected }) + if (projected.removed === true) { + publishedSnapshotsByWorktree.delete(snapshot.worktree) + deliveredChangeSequenceByWorktree.delete(snapshot.worktree) + } else { + publishedSnapshotsByWorktree.set(snapshot.worktree, state) + deliveredChangeSequenceByWorktree.set(snapshot.worktree, changeSequence) + } + } + const unsubscribe = runtime.onMobileSessionTabsChanged((runtimeSnapshot, changeSequence) => { + const snapshot = runtimeSnapshot as SessionTabsChange + if (!initialized) { + bufferChange(snapshot, changeSequence) + return + } + if ( + changeSequence <= (censusChangeSequence ?? 0) || + changeSequence <= (deliveredChangeSequenceByWorktree.get(snapshot.worktree) ?? 0) + ) { + return + } + publishChange(snapshot, changeSequence) }, pairedDeviceId) runtime.registerSubscriptionCleanup( subscriptionId, @@ -83,6 +210,9 @@ export async function subscribeSessionTabsInventory( closed = true inventoryController.abort() unsubscribe() + clearBufferedChanges() + publishedSnapshotsByWorktree.clear() + deliveredChangeSequenceByWorktree.clear() if (initialized) { emit({ type: 'end' }) } @@ -93,18 +223,53 @@ export async function subscribeSessionTabsInventory( context.signal?.removeEventListener('abort', abortInventory) return } - const inventory = await listSessionTabsInventory({ - ...context, - signal: inventoryController.signal - }) - .catch((error) => { - runtime.cleanupSubscription(subscriptionId) - throw error - }) - .finally(() => context.signal?.removeEventListener('abort', abortInventory)) + let collected: Awaited> | undefined + try { + for (let attempt = 1; !collected; attempt += 1) { + censusInvalidated = false + const candidate = await collectSessionTabsInventory( + { ...context, signal: inventoryController.signal }, + true + ) + if (closed) { + return + } + if (censusInvalidated) { + clearBufferedChanges() + if (attempt === MAX_CENSUS_COLLECTION_ATTEMPTS) { + throw new Error('session_tabs_inventory_unstable') + } + } else { + collected = candidate + } + } + } catch (error) { + runtime.cleanupSubscription(subscriptionId) + throw error + } finally { + context.signal?.removeEventListener('abort', abortInventory) + } if (closed) { return } + const { inventory, changeSequence } = collected + censusChangeSequence = changeSequence emit({ type: 'snapshots', ...inventory }) + for (const snapshot of inventory.snapshots) { + publishedSnapshotsByWorktree.set(snapshot.worktree, withoutNavigationIntent(snapshot)) + } + bufferedChanges.sort((left, right) => left.changeSequence - right.changeSequence) + for (const buffered of bufferedChanges) { + if (closed) { + break + } + if (buffered.changeSequence > changeSequence) { + publishChange(buffered.snapshot, buffered.changeSequence) + } + } + clearBufferedChanges() + if (closed) { + return + } initialized = true } diff --git a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts index f593e91e40d..384844141ed 100644 --- a/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs-move-validation.test.ts @@ -13,6 +13,17 @@ function setMobileSessionSnapshot( ).mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) } +function getMobileSessionSnapshot( + runtime: OrcaRuntimeService, + worktree: string +): RuntimeMobileSessionTabsSnapshot | undefined { + return ( + runtime as unknown as { + mobileSessionTabsByWorktree: Map + } + ).mobileSessionTabsByWorktree.get(worktree) +} + function terminalTab() { return { type: 'terminal' as const, @@ -50,6 +61,154 @@ function browserTab({ } describe('session tab move validation', () => { + it('preserves a structured tab across renderer-authored snapshot sync', () => { + const runtime = new OrcaRuntimeService() + const structured = { + type: 'agent-session' as const, + id: 'agent-session:session-a', + title: 'Codex Chat', + sessionId: 'session-a', + agent: 'codex' as const, + isActive: false + } + setMobileSessionSnapshot(runtime, { + worktree: 'wt-1', + publicationEpoch: 'structured-epoch', + snapshotVersion: 2, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'terminal-tab', + tabOrder: ['terminal-tab', structured.id] + } + ], + tabs: [terminalTab(), structured] + }) + const incoming: RuntimeMobileSessionTabsSnapshot = { + worktree: 'wt-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }], + tabs: [terminalTab()] + } + + ;( + runtime as unknown as { + syncMobileSessionTabs(snapshots: RuntimeMobileSessionTabsSnapshot[]): Set + } + ).syncMobileSessionTabs([incoming]) + + const snapshot = getMobileSessionSnapshot(runtime, 'wt-1') + expect(snapshot?.tabs).toContainEqual(structured) + expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab', 'agent-session:session-a']) + }) + + it('publishes a structured tab into the active group instead of the first group', () => { + const runtime = new OrcaRuntimeService() + setMobileSessionSnapshot(runtime, { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-2', + activeTabId: 'file-tab', + activeTabType: 'file', + tabGroups: [ + { id: 'group-1', activeTabId: 'terminal-tab', tabOrder: ['terminal-tab'] }, + { id: 'group-2', activeTabId: 'file-tab', tabOrder: ['file-tab'] } + ], + tabs: [ + terminalTab(), + { + type: 'file', + id: 'file-tab', + title: 'README.md', + filePath: 'README.md', + relativePath: 'README.md', + language: 'markdown', + isDirty: false, + isActive: true + } + ] + }) + + runtime.publishStructuredAgentSessionTab({ + workspaceId: 'wt-1', + sessionId: 'session-a', + agent: 'codex', + activate: true + }) + + const snapshot = getMobileSessionSnapshot(runtime, 'wt-1') + expect(snapshot?.activeGroupId).toBe('group-2') + expect(snapshot?.tabGroups?.[0]?.tabOrder).toEqual(['terminal-tab']) + expect(snapshot?.tabGroups?.[1]).toMatchObject({ + activeTabId: 'agent-session:session-a', + tabOrder: ['file-tab', 'agent-session:session-a'] + }) + }) + + it('preserves a capability-hidden structured tab during an old-client reorder', async () => { + const runtime = new OrcaRuntimeService() + const moveSessionTab = vi.fn() + runtime.setNotifier({ moveSessionTab } as never) + setMobileSessionSnapshot(runtime, { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'terminal-tab::leaf-1', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'group-1', + activeTabId: 'terminal-tab', + tabOrder: ['terminal-tab', 'agent-session:session-a', 'file-tab'] + } + ], + tabs: [ + terminalTab(), + { + type: 'agent-session', + id: 'agent-session:session-a', + title: 'Codex Chat', + sessionId: 'session-a', + agent: 'codex', + isActive: false + }, + { + type: 'file', + id: 'file-tab', + title: 'README.md', + filePath: 'README.md', + relativePath: 'README.md', + language: 'markdown', + isDirty: false, + isActive: false + } + ] + }) + + await runtime.moveMobileSessionTab('id:wt-1', { + kind: 'reorder', + tabId: 'file-tab', + targetGroupId: 'group-1', + tabOrder: ['file-tab', 'terminal-tab'] + }) + + expect(moveSessionTab).toHaveBeenCalledWith('wt-1', { + kind: 'reorder', + tabId: 'file-tab', + targetGroupId: 'group-1', + tabOrder: ['file-tab', 'agent-session:session-a', 'terminal-tab'] + }) + }) + it('validates reorder moves against sanitized visible tab groups', async () => { const runtime = new OrcaRuntimeService() const moveSessionTab = vi.fn() diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 18f130bd869..be61fc55edf 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -628,34 +628,41 @@ describe('session tab RPC methods', () => { it('streams all known session tab snapshots and later updates', async () => { const unsubscribe = vi.fn() - const listeners: ((snapshot: unknown) => void)[] = [] + const listeners: ((snapshot: unknown, changeSequence: number) => void)[] = [] + const snapshots = [ + { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + { + worktree: 'wt-2', + publicationEpoch: 'epoch-2', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] const runtime = { getRuntimeId: () => 'test-runtime', - listAllMobileSessionTabs: vi.fn(() => [ - { - worktree: 'wt-1', - publicationEpoch: 'epoch-1', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }, - { - worktree: 'wt-2', - publicationEpoch: 'epoch-2', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - ]), + listAllMobileSessionTabs: vi.fn(() => snapshots), + listAllMobileSessionTabsWithChangeSequence: vi.fn(() => ({ + snapshots, + changeSequence: 0 + })), supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), - onMobileSessionTabsChanged: vi.fn((listener: (snapshot: unknown) => void) => { - listeners.push(listener) - return unsubscribe - }), + onMobileSessionTabsChanged: vi.fn( + (listener: (snapshot: unknown, changeSequence: number) => void) => { + listeners.push(listener) + return unsubscribe + } + ), registerSubscriptionCleanup: vi.fn() } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) @@ -666,15 +673,18 @@ describe('session tab RPC methods', () => { (message) => messages.push(message), { connectionId: 'conn-1' } ) - listeners[0]?.({ - worktree: 'wt-1', - publicationEpoch: 'epoch-3', - snapshotVersion: 2, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }) + listeners[0]?.( + { + worktree: 'wt-1', + publicationEpoch: 'epoch-3', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }, + 1 + ) expect(runtime.registerSubscriptionCleanup).toHaveBeenCalledWith( 'session.tabs:conn-1:*:req-1', @@ -698,6 +708,10 @@ describe('session tab RPC methods', () => { const runtime = { getRuntimeId: () => 'test-runtime', listAllMobileSessionTabs: vi.fn(() => []), + listAllMobileSessionTabsWithChangeSequence: vi.fn(() => ({ + snapshots: [], + changeSequence: 0 + })), supportsAuthoritativeSessionTabsInventory: vi.fn(() => false), onMobileSessionTabsChanged: vi.fn(() => vi.fn()), registerSubscriptionCleanup: vi.fn() diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 5b9958aeee8..3a322e33ed7 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -14,30 +14,42 @@ import { } from './session-tabs-inventory' import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods' import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods' +import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore' +import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership' export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.list', params: WorktreeTabSelector, - handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => - projectSessionTabsForClient( + handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => { + await restoreStructuredTabsIfSupported(runtime, clientCapabilities) + return projectSessionTabsForClient( await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId), clientKind, clientCapabilities ) + } }), defineMethod({ name: 'session.tabs.listAll', params: null, - handler: async (_params, context) => listSessionTabsInventory(context) + handler: async (_params, context) => { + await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + return listSessionTabsInventory(context) + } }), ...SESSION_TAB_MUTATION_METHODS, ...SESSION_TAB_CLOSE_METHODS, defineMethod({ name: 'session.tabs.createTerminal', params: CreateTerminalTab, - handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => - runtime.createMobileSessionTerminal(params.worktree, { + handler: async (params, { runtime, signal, clientKind, pairedDeviceId }) => { + if (params.command) { + await assertLegacyAiVaultResumeCommandAllowed(params.command, () => + runtime.ensureStructuredAgentSessionHost() + ) + } + return runtime.createMobileSessionTerminal(params.worktree, { afterTabId: params.afterTabId, targetGroupId: params.targetGroupId, command: params.command, @@ -63,6 +75,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ // of running down the timeout and rolling back a live tab (#7718). signal }) + } }), defineStreamingMethod({ name: 'session.tabs.subscribe', @@ -76,6 +89,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ let unsubscribe = (): void => {} let closed = false let initialized = false + await restoreStructuredTabsIfSupported(runtime, clientCapabilities) const initial = await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId) if (closed) { return @@ -142,7 +156,10 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ defineStreamingMethod({ name: 'session.tabs.subscribeAll', params: null, - handler: async (_params, context, emit) => subscribeSessionTabsInventory(context, emit) + handler: async (_params, context, emit) => { + await restoreStructuredTabsIfSupported(context.runtime, context.clientCapabilities) + return subscribeSessionTabsInventory(context, emit) + } }), defineMethod({ name: 'session.tabs.unsubscribeAll', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts new file mode 100644 index 00000000000..2dd317e08b0 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -0,0 +1,58 @@ +// Who may see `agentSession.*` at all. +// +// Shared by every structured method file so one gate governs the whole surface: a client that does +// not advertise `agent-session.structured.v1` is told the surface does not exist rather than being +// handed a session it cannot render or drive — and, just as importantly, cannot make the host EXIST +// by calling into it, which is an observable side effect. + +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import type { RpcContext } from '../core' + +/** + * In-process callers are the same build as the host, so they carry no negotiated + * capability list; every remote client must say it can read structured sessions. + */ +export function supportsStructuredSessions(ctx: RpcContext): boolean { + return ( + ctx.clientKind === undefined || + (ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + ) +} + +export function requireStructuredCapability(ctx: RpcContext): void { + if (!supportsStructuredSessions(ctx)) { + throw new Error('structured_agent_session_unsupported') + } +} + +export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { + requireStructuredCapability(ctx) + const host = getStructuredAgentSessionHost() + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + return host +} + +/** Attach is the only way a session comes into being, so it is the only call + * that builds the host. Every other method addresses a session that must + * already be attached, and correctly reports absent when none is. */ +export async function ensureStructuredHostInstalled(ctx: RpcContext): Promise { + // Gated first: a client that cannot read structured sessions must not be able + // to make the host exist, which is an observable side effect of the surface. + if (!supportsStructuredSessions(ctx) || getStructuredAgentSessionHost()) { + return + } + await ctx.runtime.ensureStructuredAgentSessionHost() +} + +/** Mirrors the existing agent-session host-authority derivation so one client + * gets one operation namespace across both surfaces. */ +export function structuredCallerFor(ctx: RpcContext): StructuredAgentSessionCaller { + return { + callerKey: ctx.clientId?.trim() || `trusted-local:${ctx.clientKind ?? 'runtime'}` + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts new file mode 100644 index 00000000000..61bb3849bc7 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -0,0 +1,235 @@ +// The wire half of a session's lifetime: who takes a hold, and what happens when they vanish. +// +// Run against the REAL subscription registry rather than a stub, because the backstop being tested +// IS that registry's connection sweep — a stubbed `registerSubscriptionCleanup` would prove that +// the handler called a function, which is not the claim. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + resetHostTestOperationIds +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const CONNECTION = 'connection-1' +const GRACE_MS = 5 +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + connectionId: CONNECTION +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let runtime: OrcaRuntimeService +let dispatcher: RpcDispatcher +let closeSession: Mock> +let requests = 0 + +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + requests += 1 + await dispatcher.dispatchStreaming( + { id: `request-${requests}`, authToken: 'token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + CLIENT + ) + return replies[0] as RpcResponse +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-')) + resetHostTestOperationIds() + requests = 0 + closeSession = vi.fn(async () => true) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + closeSession, + dispatch: async () => ({ state: 'rejected', reason: 'unused' }), + cancelTurn: async () => ({ cancelled: false }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + releaseGraceMs: GRACE_MS, + now: () => NOW + }) + setStructuredAgentSessionHost(host) + runtime = new OrcaRuntimeService() + dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) + expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) +}) + +afterEach(async () => { + setStructuredAgentSessionHost(null) + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +describe('a client that holds a session', () => { + it('keeps the provider child while the hold stands', async () => { + expect( + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true }) + + await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) + + it('releases it when the client says so', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + + expect( + await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' }) + ).toMatchObject({ ok: true }) + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + + it('does not report success when no provider child can be acquired', async () => { + const response = await call('agentSession.hold', { + sessionId: 'session-missing', + holderId: 'chat-missing' + }) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'agent_session_identity_required' } + }) + expect(host.isHeld('session-missing')).toBe(false) + }) +}) + +describe('a client that disappears without cleanup', () => { + it('still releases the session when its transport closes', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + + runtime.cleanupSubscriptionsForConnection(CONNECTION) + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + + it('does not release a hold another connection is still holding', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + await dispatcher.dispatchStreaming( + { + id: 'request-other', + authToken: 'token', + method: 'agentSession.hold', + params: { sessionId: SESSION, holderId: 'chat-1' } + }, + () => {}, + { ...CLIENT, clientId: 'device-2', connectionId: 'connection-2' } + ) + + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + }) + + it('does not let an old connection sweep release its same-document replacement', async () => { + await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' }) + await dispatcher.dispatchStreaming( + { + id: 'request-replacement', + authToken: 'token', + method: 'agentSession.hold', + params: { sessionId: SESSION, holderId: 'chat-1' } + }, + () => {}, + { ...CLIENT, connectionId: 'connection-2' } + ) + + runtime.cleanupSubscriptionsForConnection(CONNECTION) + await new Promise((resolve) => setTimeout(resolve, GRACE_MS * 20)) + + expect(closeSession).not.toHaveBeenCalled() + expect(host.hasSession(SESSION)).toBe(true) + + runtime.cleanupSubscriptionsForConnection('connection-2') + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + }) + + it('releases a desktop subscription when its renderer transport dies', async () => { + const transport = new AbortController() + await dispatcher.dispatchStreaming( + { + id: 'desktop-subscription', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + () => {}, + { + signal: transport.signal, + clientId: 'desktop-renderer', + clientKind: 'runtime', + clientCapabilities: CLIENT.clientCapabilities + } + ) + expect(host.isHeld(SESSION)).toBe(true) + + transport.abort() + + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(closeSession).toHaveBeenCalledWith(SESSION) + }) + + it('does not let a stream alone resume a released session', async () => { + await host.close(SESSION) + expect(host.hasSession(SESSION)).toBe(false) + await host.restoreReadableSessions() + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + + await dispatcher.dispatchStreaming( + { + id: 'request-subscribe', + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION } + }, + () => {}, + CLIENT + ) + + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts new file mode 100644 index 00000000000..346082bd576 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.ts @@ -0,0 +1,64 @@ +// `agentSession.hold` / `agentSession.release` — a surface saying it is bound to a session. +// +// The holder identity is scoped to the CONNECTION, not taken from the client verbatim: two clients +// are free to name their surfaces the same thing, and a hold that collides is one that a stranger +// can release. +// +// The registered cleanup is the backstop, and the ONLY thing that covers a client which vanishes — +// a paired client that disconnects mid-turn never gets to send its release. Registering it before taking +// the hold is deliberate: re-registering an id runs the previous cleanup synchronously, so the +// stale release lands before this hold rather than after it. + +import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + ensureStructuredHostInstalled, + requireStructuredHost +} from './structured-agent-session-gate' +import { HoldParams } from './structured-agent-session-schemas' + +const HOLD_CLEANUP_PREFIX = 'agentSession.hold' + +function holderKeyFor(ctx: RpcContext, holderId: string): string { + const client = ctx.clientId?.trim() || (ctx.clientKind ?? 'runtime') + return `${ctx.connectionId ?? 'local'}:${client}:${holderId}` +} + +function holdCleanupIdFor(sessionId: string, holderKey: string): string { + return `${HOLD_CLEANUP_PREFIX}:${holderKey}:${sessionId}` +} + +export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.hold', + params: HoldParams, + handler: async (params, ctx) => { + await ensureStructuredHostInstalled(ctx) + const host = requireStructuredHost(ctx) + const holderKey = holderKeyFor(ctx, params.holderId) + ctx.runtime.registerSubscriptionCleanup( + holdCleanupIdFor(params.sessionId, holderKey), + () => host.release(params.sessionId, holderKey), + ctx.connectionId + ) + try { + await host.hold(params.sessionId, holderKey) + } catch (error) { + ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + throw error + } + return { held: true as const } + } + }), + defineMethod({ + name: 'agentSession.release', + params: HoldParams, + handler: async (params, ctx) => { + const host = requireStructuredHost(ctx) + const holderKey = holderKeyFor(ctx, params.holderId) + host.release(params.sessionId, holderKey) + // Retires the backstop too; its release is a no-op against a holder already gone. + ctx.runtime.cleanupSubscription(holdCleanupIdFor(params.sessionId, holderKey)) + return { released: true as const } + } + }) +] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts new file mode 100644 index 00000000000..6a9372ed2c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -0,0 +1,203 @@ +// Wire validation for `agentSession.*`. +// +// Strict objects throughout: zod drops unknown keys, and a silently dropped key +// is how a newer client's field becomes a different effect on an older host. + +import { z } from 'zod' +import { isAgentSessionId } from '../../../../shared/agent-session-record' +import { + AGENT_SESSION_HISTORY_DIRECTIONS, + AGENT_SESSION_HISTORY_MAX_LIMIT +} from '../../../../shared/agent-session-wire' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' + +const MAX_ID_LENGTH = 512 +const MAX_PROMPT_BYTES = 256 * 1024 +const MAX_BLOCKS = 64 +const MAX_OPTION_LABEL = 512 + +export const SessionId = z + .string() + .max(MAX_ID_LENGTH) + .refine(isAgentSessionId, 'Invalid agent session id') + +const Identifier = (message: string) => + z + .string() + .min(1, message) + .max(MAX_ID_LENGTH, message) + .refine((value) => value === value.trim(), message) + +export const JournalCursor = z + .object({ + epoch: Identifier('Invalid journal epoch'), + sequence: z.number().int().nonnegative() + }) + .strict() + +export const MutationEnvelope = z + .object({ + sessionId: SessionId, + clientOperationId: Identifier('Invalid client operation id'), + /** Null is the "must not exist yet" case; every other call fences. */ + expectedRuntimeFence: z.number().int().positive().nullable(), + payloadFingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') + }) + .strict() + +const ProviderHandle = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), + z + .object({ + kind: z.literal('claude'), + sessionId: Identifier('Invalid provider session id'), + leafUuid: Identifier('Invalid leaf uuid').nullable() + }) + .strict() +]) + +const ExecutionHostId = z + .string() + .max(MAX_ID_LENGTH) + .transform((value) => normalizeExecutionHostId(value)) + .refine((value): value is NonNullable => value !== null, { + message: 'Invalid execution host id' + }) + +const ExecutionLocation = z + .object({ + executionHostId: ExecutionHostId, + wslDistro: Identifier('Invalid WSL distro').nullable(), + workspaceId: Identifier('Invalid workspace id'), + workspaceKind: z.enum(['git-worktree', 'folder']) + }) + .strict() + +const AccountHome = z + .object({ + variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), + path: z.string().min(1).max(4096) + }) + .strict() + +export const AttachParams = z + .object({ + envelope: MutationEnvelope, + location: ExecutionLocation, + provider: z.enum(['codex', 'claude']), + agent: Identifier('Invalid agent'), + accountHome: AccountHome, + runtimeKind: z.enum(['native', 'tui']), + providerHandle: ProviderHandle + }) + .strict() + +export const CreateIntentParams = z + .object({ + envelope: MutationEnvelope, + worktree: Identifier('Invalid worktree selector'), + agent: z.literal('codex') + }) + .strict() + +export const CreateParams = z.union([AttachParams, CreateIntentParams]) + +export const CreateSupportParams = z + .object({ + worktree: Identifier('Invalid worktree selector'), + agent: z.literal('codex') + }) + .strict() + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendParams = z + .object({ + envelope: MutationEnvelope, + retryUnknown: z.literal(true).optional(), + body: z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + .refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' + ) + }) + .strict() + +export const CancelParams = z + .object({ envelope: MutationEnvelope, turnId: Identifier('Invalid turn id') }) + .strict() + +export const RespondParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + /** Compare-and-set: the revision the client had on screen. */ + expectedRevision: z.number().int().positive(), + optionId: Identifier('Invalid option id') + }) + .strict() + +export const SetOptionParams = z + .object({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) + }) + .strict() + +export const OptionsParams = z.object({ sessionId: SessionId }).strict() + +/** One surface's claim on one session. The id names the surface, not the client: two chat views + * looking at the same session are two holders, and either leaving must not release + * the other's. */ +export const HoldParams = z + .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) + .strict() + +export const HistoryParams = z + .object({ + sessionId: SessionId, + direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), + cursor: JournalCursor.optional(), + limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() + }) + .strict() + +export const SubscribeParams = z + .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) + .strict() + +export const UnsubscribeParams = z + .object({ + sessionId: SessionId, + subscriptionId: Identifier('Invalid subscription id').optional() + }) + .strict() + +/** Read-only owner classification retained for restart safety; mutation handoff is separate. */ +export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts new file mode 100644 index 00000000000..4da598ff876 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -0,0 +1,428 @@ +// The wire boundary: who may see `agentSession.*` at all, and what shapes it +// accepts once they can. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ALL_RPC_METHODS } from './index' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' + +const SESSION = 'session-alpha' +const FINGERPRINT = 'f'.repeat(64) +const OPERATION = '1800000000000-00000000000000000000000000000001' + +function envelope(overrides: Record = {}) { + return { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: 1, + payloadFingerprint: FINGERPRINT, + ...overrides + } +} + +function sendParams(overrides: Record = {}) { + return { + envelope: envelope(), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...overrides + } +} + +function attachParams(overrides: Record = {}) { + return { + envelope: envelope({ expectedRuntimeFence: null }), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + ...overrides + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'token', method, params } +} + +let hostCalls: Record> +let runtimeCalls: Record> + +function hostStub(): StructuredAgentSessionHost { + hostCalls = { + attach: vi.fn(async () => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-a', sequence: 0 }, + value: { + sessionId: SESSION, + fence: 1, + page: { + sessionId: SESSION, + epoch: 'epoch-a', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-a', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), + readOptions: vi.fn(async () => ({ + models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }], + current: { model: 'gpt-live' } + })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + return hostCalls as unknown as StructuredAgentSessionHost +} + +function dispatcher(): RpcDispatcher { + runtimeCalls = { + getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })), + resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, + runtimeKind: 'native' + })), + publishStructuredAgentSessionTab: vi.fn() + } + const runtime = { + getRuntimeId: () => 'runtime-1', + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn(), + ...runtimeCalls + } + return new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +} + +/** The reply path is the only one that carries a client's negotiated identity, + * which is exactly what the capability gate reads. */ +async function call( + method: string, + params: unknown, + client?: { + clientId?: string + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: string[] + } +): Promise { + const replies: RpcResponse[] = [] + await dispatcher().dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +const STRUCTURED_CLIENT = { + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +beforeEach(() => { + setStructuredAgentSessionHost(hostStub()) +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('capability gating', () => { + it('advertises the capability without bumping the protocol version', () => { + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY) + // Additive methods do not break an old client; bumping would strand every + // paired device that has not updated. + expect(RUNTIME_PROTOCOL_VERSION).toBe(3) + }) + + it('registers every structured method on the runtime manifest', () => { + const names = new Set(ALL_RPC_METHODS.map((method) => method.name)) + for (const method of STRUCTURED_AGENT_SESSION_METHODS) { + expect(names).toContain(method.name) + } + // Bump deliberately: the whole agentSession.* surface is behind the structured capability, + // so an additive method is invisible to old clients and needs no protocol bump. + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(16) + }) + + it('hides the surface from a declared client that did not advertise it', async () => { + const response = await call('agentSession.send', sendParams(), { + clientKind: 'runtime', + clientCapabilities: ['terminal.stream.v1'] + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.send).not.toHaveBeenCalled() + }) + + it('rejects create intent before resolving host-owned fields for an old client', async () => { + const worktree = 'id:workspace-1' + const response = await call( + 'agentSession.create', + { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + }, + { clientKind: 'runtime', clientCapabilities: [] } + ) + + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).not.toHaveBeenCalled() + }) + + it('serves a client that advertised it', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.send).toHaveBeenCalledTimes(1) + }) + + it('serves an in-process caller, which negotiates no capabilities at all', async () => { + const response = await call('agentSession.send', sendParams()) + expect(response).toMatchObject({ ok: true }) + }) + + it('reports the surface as absent when no host is installed', async () => { + setStructuredAgentSessionHost(null) + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false }) + }) +}) + +describe('method routing', () => { + it('creates from a client intent while the host resolves paths and provider identity', async () => { + const worktree = 'id:workspace-1' + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + const created = await call('agentSession.create', params, STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true, result: { ok: true } }) + expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' } + }) + ) + expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: SESSION, activate: true }) + ) + }) + + it('separates create from ensure by the fence the client may declare', async () => { + const created = await call('agentSession.create', attachParams()) + expect(created).toMatchObject({ ok: true }) + + const fenced = await call('agentSession.create', attachParams({ envelope: envelope() })) + expect(fenced).toMatchObject({ ok: false }) + + const ensured = await call('agentSession.ensure', attachParams({ envelope: envelope() })) + expect(ensured).toMatchObject({ ok: true }) + }) + + it('tags the prompt kind from the method name, not from the client', async () => { + const params = { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'allow' + } + await call('agentSession.respondToApproval', params, STRUCTURED_CLIENT) + await call('agentSession.respondToQuestion', params, STRUCTURED_CLIENT) + expect(hostCalls.respondToPrompt.mock.calls.map((invocation) => invocation[1].kind)).toEqual([ + 'approval', + 'question' + ]) + }) + + it('does not register the structured handoff mutation', async () => { + const response = await call('agentSession.requestHandoff', { + envelope: envelope(), + direction: 'to-tui', + mode: 'now', + action: 'start' + }) + + expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } }) + }) +}) + +describe('parameter validation', () => { + const rejects = async (method: string, params: unknown): Promise => { + const response = await call(method, params, STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + + it('rejects an unknown key rather than dropping it', async () => { + await rejects('agentSession.send', { ...sendParams(), replyToItemId: 'item-1' }) + await rejects('agentSession.send', { + ...sendParams(), + envelope: { ...envelope(), priority: 'high' } + }) + }) + + it('refuses to let a client author anything but a user turn', async () => { + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] } + }) + ) + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'Bash' }] } + }) + ) + }) + + it('rejects a journal-only opaque provider handle', async () => { + await rejects( + 'agentSession.create', + attachParams({ providerHandle: { kind: 'opaque', agent: 'codex', value: 'thread-1' } }) + ) + }) + + it('rejects Claude structured create shapes', async () => { + await rejects('agentSession.createSupport', { + worktree: 'id:workspace-1', + agent: 'claude' + }) + const fields = { worktree: 'id:workspace-1', agent: 'claude' } + await rejects('agentSession.create', { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields + }) + }), + ...fields + }) + }) + + it('requires a sha256 fingerprint and a positive fence', async () => { + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'f' }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'F'.repeat(64) }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ expectedRuntimeFence: 0 }) }) + ) + }) + + it('requires the item revision on a prompt answer', async () => { + await rejects('agentSession.respondToApproval', { + envelope: envelope(), + itemId: 'item-1', + optionId: 'allow' + }) + }) + + it('bounds a history page and validates its cursor', async () => { + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 100_000 + }) + await rejects('agentSession.history', { sessionId: SESSION, direction: 'sideways' }) + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: -1 } + }) + }) + + it('accepts a well-formed history request', async () => { + const response = await call( + 'agentSession.history', + { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: 4 }, + limit: 40 + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts new file mode 100644 index 00000000000..0f007006109 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -0,0 +1,246 @@ +// `agentSession.*` — the structured session RPC surface. +// +// Every method here is gated on the client advertising +// `agent-session.structured.v1`. A client that does not is told the surface does +// not exist rather than being handed a session it cannot render or drive; that +// is the whole visibility rule, because nothing else on the runtime publishes a +// structured session. + +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../../shared/agent-session-mutation-envelope' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + ensureStructuredHostInstalled as ensureHostInstalled, + requireStructuredCapability, + requireStructuredHost as requireHost, + structuredCallerFor as callerFor, + supportsStructuredSessions +} from './structured-agent-session-gate' +import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' +import { + AttachParams, + CancelParams, + CreateParams, + CreateSupportParams, + HistoryParams, + HandoffStatusParams, + OptionsParams, + RespondParams, + SendParams, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from './structured-agent-session-schemas' + +const SUBSCRIPTION_PREFIX = 'agentSession' + +function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { + const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` + // Shared control multiplexes several streams over one socket; the frame id + // keeps one subscriber from evicting another on the same session. + return ctx.requestId ? `${base}:${ctx.requestId}` : base +} + +export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.createSupport', + params: CreateSupportParams, + handler: async (params, ctx) => { + if (!supportsStructuredSessions(ctx)) { + throw new Error('structured_agent_session_unsupported') + } + return ctx.runtime.getStructuredAgentSessionCreateSupport(params.worktree, params.agent) + } + }), + defineMethod({ + name: 'agentSession.create', + params: CreateParams, + handler: async (params, ctx) => { + requireStructuredCapability(ctx) + if (params.envelope.expectedRuntimeFence !== null) { + throw new Error('agent_session_operation_invalid') + } + if ('worktree' in params) { + const intentFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { worktree: params.worktree, agent: params.agent } + }) + const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint) + if (conflict) { + return { ok: false, refusal: conflict } + } + const resolved = await ctx.runtime.resolveStructuredAgentSessionCreateIntent(params) + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: { + location: resolved.location, + provider: resolved.provider, + agent: resolved.agent, + accountHome: resolved.accountHome, + runtimeKind: resolved.runtimeKind, + expectedRuntimeFence: null + } + }) + await ensureHostInstalled(ctx) + const result = await requireHost(ctx).attach(callerFor(ctx), { + ...resolved, + envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } + }) + if (result.ok && resolved.agent === 'codex') { + ctx.runtime.publishStructuredAgentSessionTab({ + workspaceId: resolved.location.workspaceId, + sessionId: result.value.sessionId, + agent: 'codex', + activate: true + }) + } + return result + } + await ensureHostInstalled(ctx) + return requireHost(ctx).attach(callerFor(ctx), params) + } + }), + defineMethod({ + name: 'agentSession.ensure', + params: AttachParams, + handler: async (params, ctx) => { + await ensureHostInstalled(ctx) + return requireHost(ctx).attach(callerFor(ctx), params) + } + }), + defineMethod({ + name: 'agentSession.send', + params: SendParams, + handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.cancel', + params: CancelParams, + handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) + }), + defineMethod({ + // Releasing a chat view, not ending a conversation: the record and journal stay on disk so the + // same session can be attached again. Only the provider child and the in-memory entry go. + name: 'agentSession.close', + params: OptionsParams, + handler: async (params, ctx) => { + await requireHost(ctx).close(params.sessionId) + return { ok: true as const } + } + }), + defineMethod({ + name: 'agentSession.respondToApproval', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' }) + }), + defineMethod({ + name: 'agentSession.respondToQuestion', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) + }), + defineMethod({ + name: 'agentSession.setOption', + params: SetOptionParams, + handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.handoffStatus', + params: HandoffStatusParams, + handler: async (params, ctx) => requireHost(ctx).handoffStatus(params.sessionId) + }), + defineMethod({ + name: 'agentSession.options', + params: OptionsParams, + handler: async (params, ctx) => requireHost(ctx).readOptions(params.sessionId) + }), + defineMethod({ + name: 'agentSession.history', + params: HistoryParams, + handler: async (params, ctx) => requireHost(ctx).history(params) + }), + defineStreamingMethod({ + name: 'agentSession.subscribe', + params: SubscribeParams, + handler: async (params, ctx, emit) => { + const host = requireHost(ctx) + const subscriptionId = subscriptionIdFor(ctx, params.sessionId) + // A live stream is a surface too: it keeps a session from being evicted while it is read and + // releases that retention when the transport dies without a word. + // + // Retain-only: reading history must never be what starts a provider process. Current clients + // explicitly hold every open surface before subscribing. + const streamHolder = `subscription:${subscriptionId}` + let closed = false + let dispose = (): void => {} + let releaseTransportSubscription = (): void => {} + const onTransportAbort = (): void => releaseTransportSubscription() + const cleanup = () => { + closed = true + ctx.signal?.removeEventListener('abort', onTransportAbort) + dispose() + host.release(params.sessionId, streamHolder) + } + let registration: { releaseIfCurrent: () => void } + if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') { + registration = ctx.runtime.registerOwnedSubscriptionCleanup( + subscriptionId, + cleanup, + ctx.connectionId + ) + } else { + ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId) + registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) } + } + releaseTransportSubscription = registration.releaseIfCurrent + ctx.signal?.addEventListener('abort', onTransportAbort, { once: true }) + if (ctx.signal?.aborted) { + onTransportAbort() + } + if (closed) { + return + } + // The host emits the opening snapshot (or the missed batch) synchronously + // inside open(), so nothing between here and there can interleave. + dispose = host.subscribe({ + id: subscriptionId, + sessionId: params.sessionId, + emit, + ...(params.cursor ? { cursor: params.cursor } : {}) + }) + if (closed) { + dispose() + } else { + // Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a + // readable session, which is exactly what the client sees anyway. + void host + .hold(params.sessionId, streamHolder, { resume: false }) + .catch((error: unknown) => + console.warn('[agent-session] stream hold failed', params.sessionId, error) + ) + } + } + }), + defineMethod({ + name: 'agentSession.unsubscribe', + params: UnsubscribeParams, + handler: async (params, ctx) => { + requireHost(ctx) + const connection = ctx.connectionId ?? 'local' + const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}` + if (params.subscriptionId) { + ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) + return { unsubscribed: true } + } + ctx.runtime.cleanupSubscription(base) + ctx.runtime.cleanupSubscriptionsByPrefix(`${base}:`) + return { unsubscribed: true } + } + }), + ...STRUCTURED_AGENT_SESSION_HOLD_METHODS +] diff --git a/src/main/runtime/rpc/methods/structured-session-tab-restore.ts b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts new file mode 100644 index 00000000000..c1f265cc4c8 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-session-tab-restore.ts @@ -0,0 +1,11 @@ +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import type { RpcContext } from '../core' + +export async function restoreStructuredTabsIfSupported( + runtime: RpcContext['runtime'], + capabilities: readonly string[] | undefined +): Promise { + if (capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)) { + await runtime.restoreStructuredAgentSessionTabs() + } +} diff --git a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts index 7313eb82b94..8c234ce2ba4 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-input-delivery.ts @@ -1,3 +1,4 @@ +import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' import { InvalidArgumentError } from '../../core' import type { DriverState, @@ -91,6 +92,11 @@ export function watchSubscriptionLifetime( } export function isTerminalStreamInputRejection(error: unknown): boolean { + // Why: a lease refusal is a deliberate rejection, not a transport failure, so the stream reports + // it through the WriteUnavailable frame old clients already decode rather than a new opcode. + if (isAgentSessionPtyWriteRefusedError(error)) { + return true + } const message = error instanceof Error ? error.message : String(error) return message.includes('terminal_not_writable') || message.includes('terminal_handle_stale') } diff --git a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts index 9baa4827a55..6bafeb93966 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-send-method.ts @@ -1,3 +1,5 @@ +import { isAgentSessionPtyWriteRefusedError } from '../../../../../shared/agent-session-pty-write-admission' +import { assertLegacyAiVaultResumeCommandAllowed } from '../../../../ai-vault/structured-session-ownership' import { InvalidArgumentError, defineMethod, type RpcAnyMethod } from '../../core' import { isTerminalQueryReply } from '../../../../../shared/terminal-query-reply' import { assertTerminalAgentSendable } from '../../terminal-agent-send-guard' @@ -21,6 +23,16 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ handler: async (params, { runtime, clientId, signal }) => { await assertTerminalSendTextWithinLimit(params.text) await assertTerminalSendTextWithinLimit(params.resolvedLaunchDraft?.text) + if (params.text) { + await assertLegacyAiVaultResumeCommandAllowed(params.text, () => + runtime.ensureStructuredAgentSessionHost() + ) + } + if (params.resolvedLaunchDraft?.text) { + await assertLegacyAiVaultResumeCommandAllowed(params.resolvedLaunchDraft.text, () => + runtime.ensureStructuredAgentSessionHost() + ) + } const queryReplyClientId = clientId ?? params.client?.id if ( params.inputKind === 'query-reply' && @@ -188,6 +200,18 @@ export const TERMINAL_SEND_METHODS: RpcAnyMethod[] = [ ) } catch (error) { mobileFloorClaim.current?.rollback() + if (isAgentSessionPtyWriteRefusedError(error)) { + // Why: name the owner and the stage instead of a bare not-writable, so a client can say + // who holds the session rather than retrying into a lease it will never win. + return { + send: { + handle: params.terminal, + accepted: false, + bytesWritten: 0, + agentSessionRefusal: error.refusal + } + } + } const refusedReason = getTerminalSendGuardRefusedReason(error) if (refusedReason) { return { diff --git a/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts b/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts new file mode 100644 index 00000000000..94c6731f384 --- /dev/null +++ b/src/main/runtime/rpc/terminal-send-agent-session-lease.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import { TERMINAL_METHODS } from './methods/terminal' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { + AgentSessionPtyWriteRefusedError, + type AgentSessionPtyWriteRefusal +} from '../../../shared/agent-session-pty-write-admission' + +// terminal.send is the one write path a paired client can reach, so a lease refusal has to arrive +// as a result it can render — not as a transport error and not as a silent `accepted: false`. + +const REFUSAL: AgentSessionPtyWriteRefusal = { + code: 'agent_session_conflict', + sessionId: 'session-alpha-1', + ownerRuntimeKind: 'native', + handoffStage: 'preparing', + ownerPid: 4242, + runtimeFence: 7 +} + +const rollback = vi.fn() + +function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + beginMobileInputFloor: vi.fn(() => ({ commit: async () => {}, rollback })), + ...overrides + } as OrcaRuntimeService +} + +function makeRequest(params: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method: 'terminal.send', params } +} + +async function send(runtime: OrcaRuntimeService, client: { id: string; type: string }) { + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + return await dispatcher.dispatch(makeRequest({ terminal: 'terminal-1', text: 'hello', client })) +} + +describe('terminal.send under a refusing lease', () => { + it('returns the typed refusal instead of failing the call', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockRejectedValue(new AgentSessionPtyWriteRefusedError(REFUSAL)) + }) + + const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ + send: { + handle: 'terminal-1', + accepted: false, + bytesWritten: 0, + agentSessionRefusal: REFUSAL + } + }) + }) + + it('keeps the refusal additive so an old client still reads accepted: false', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockRejectedValue(new AgentSessionPtyWriteRefusedError(REFUSAL)) + }) + + const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = response.result as { send: { accepted: boolean; refusedReason?: string } } + expect(result.send.accepted).toBe(false) + // A new `refusedReason` value would reach old clients as an unknown enum member. + expect(result.send.refusedReason).toBeUndefined() + }) + + it('releases the mobile input floor a refused send never used', async () => { + rollback.mockClear() + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + options?.reserveWrite?.('pty-1') + throw new AgentSessionPtyWriteRefusedError(REFUSAL) + }) + }) + + await send(runtime, { id: 'mobile-1', type: 'mobile' }) + + expect(rollback).toHaveBeenCalled() + }) + + it('still surfaces unrelated send failures as errors', async () => { + const runtime = stubRuntime({ + sendTerminal: vi.fn().mockRejectedValue(new Error('terminal_not_writable')) + }) + + const response = await send(runtime, { id: 'desktop-1', type: 'desktop' }) + + expect(response.ok).toBe(false) + }) +}) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8ca08e042b3..b508f734c04 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -13,6 +13,7 @@ import { type RuntimeMetadataOwnershipWatch } from './runtime-metadata-ownership-watch' import { RpcDispatcher } from './rpc/dispatcher' +import { ALL_RPC_METHODS } from './rpc/methods' import type { RpcAnyMethod, RpcRequest, RpcResponse } from './rpc/core' import { errorResponse } from './rpc/errors' import { fingerprintAuthenticatedPairingCredential } from './rpc/orchestration-mutation-executor' @@ -597,7 +598,7 @@ export class OrcaRuntimeRpcServer { methods }: OrcaRuntimeRpcServerOptions) { this.runtime = runtime - this.dispatcher = new RpcDispatcher({ runtime, methods }) + this.dispatcher = new RpcDispatcher({ runtime, methods: methods ?? ALL_RPC_METHODS }) this.userDataPath = userDataPath this.pid = pid this.platform = platform diff --git a/src/main/runtime/saved-structured-agent-session-restoration.test.ts b/src/main/runtime/saved-structured-agent-session-restoration.test.ts new file mode 100644 index 00000000000..1386e5a97f0 --- /dev/null +++ b/src/main/runtime/saved-structured-agent-session-restoration.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { Tab } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' + +function tab(input: Partial & Pick): Tab { + const { id, ...overrides } = input + return { + id, + entityId: input.entityId ?? id, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...overrides + } +} + +function session(tabs: Tab[], activeTabId: string | null): WorkspaceSessionState { + return { + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { 'workspace-1': tabs }, + activeTabIdByWorktree: { 'workspace-1': activeTabId } + } +} + +describe('saved structured session restoration targets', () => { + it('prioritizes the visible chat and excludes closed history', () => { + const saved = session( + [ + tab({ id: 'tab-background', structuredSessionId: 'session-background' }), + tab({ id: 'tab-visible', structuredSessionId: 'session-visible' }) + ], + 'tab-visible' + ) + + expect(collectSavedStructuredAgentSessionIds(saved)).toEqual([ + 'session-visible', + 'session-background' + ]) + expect(collectSavedStructuredAgentSessionIds(session([], null))).toEqual([]) + }) + + it('keeps restoration on the local execution host and deduplicates adopted tabs', () => { + const saved = session( + [ + tab({ id: 'remote', executionHostId: 'ssh:build', structuredSessionId: 'session-remote' }), + tab({ id: 'local-a', structuredSessionId: 'session-local' }), + tab({ + id: 'local-b', + contentType: 'terminal', + structuredSessionId: 'session-local' + }) + ], + 'remote' + ) + + expect(collectSavedStructuredAgentSessionIds(saved)).toEqual(['session-local']) + }) + + it('skips explicitly Claude-owned structured tabs', () => { + const saved = session( + [ + tab({ + id: 'claude-tab', + agentSessionAgent: 'claude', + structuredSessionId: 'session-claude' + }), + tab({ + id: 'codex-tab', + agentSessionAgent: 'codex', + structuredSessionId: 'session-codex' + }) + ], + 'claude-tab' + ) + + expect(collectSavedStructuredAgentSessionIds(saved)).toEqual(['session-codex']) + }) +}) diff --git a/src/main/runtime/saved-structured-agent-session-restoration.ts b/src/main/runtime/saved-structured-agent-session-restoration.ts new file mode 100644 index 00000000000..819e38df67e --- /dev/null +++ b/src/main/runtime/saved-structured-agent-session-restoration.ts @@ -0,0 +1,43 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import type { Tab } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +function savedSessionId(tab: Tab): string | null { + if (tab.executionHostId && tab.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return null + } + if (tab.agentSessionAgent === 'claude') { + return null + } + return tab.structuredSessionId ?? (tab.contentType === 'agent-session' ? tab.entityId : null) +} + +/** Visible chats restore first; closed historical journals stay lazy. */ +export function collectSavedStructuredAgentSessionIds( + session: WorkspaceSessionState | null +): string[] { + const tabs = Object.values(session?.unifiedTabs ?? {}).flat() + const activeTabIds = new Set( + Object.values(session?.activeTabIdByWorktree ?? {}).filter( + (tabId): tabId is string => typeof tabId === 'string' + ) + ) + const selected: string[] = [] + const seen = new Set() + const add = (tab: Tab): void => { + const sessionId = savedSessionId(tab) + if (sessionId && !seen.has(sessionId)) { + seen.add(sessionId) + selected.push(sessionId) + } + } + for (const tab of tabs) { + if (activeTabIds.has(tab.id)) { + add(tab) + } + } + for (const tab of tabs) { + add(tab) + } + return selected +} diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts new file mode 100644 index 00000000000..c00fdf2cbee --- /dev/null +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -0,0 +1,891 @@ +// One structured Codex session driven end to end over `agentSession.*`. +// +// Nothing here is stubbed except the Codex child itself: the RPC dispatcher, the +// zod schemas, the capability gate, the durable record store, the journal, the +// lease, the Codex adapter, and the event-to-journal translation are all the ones +// that ship. The fake app-server answers the same JSON-RPC calls the real one +// does and pushes the same notifications and blocking requests back. + +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryResult, + AgentSessionSubscribeEvent +} from '../../shared/agent-session-wire' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' +import { readJournalBlob } from '../native-chat/agent-session-journal/journal-blob-store' +import { appendLegacyTranscriptMessages } from '../native-chat/agent-session-journal/journal-legacy-import' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../native-chat/agent-session-journal/journal-store' +import type { OrcaRuntimeService } from './orca-runtime' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { STRUCTURED_AGENT_SESSION_METHODS } from './rpc/methods/structured-agent-session' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const SESSION = 'session-integration-1' +const THREAD = 'thread-integration' +const TURN = 'turn-1' +const WORKSPACE = 'workspace-1' +const CLIENT = { + clientId: 'device-a', + clientKind: 'runtime' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +// ─── the fake `codex app-server` ──────────────────────────────────────────── + +type CodexScript = { + connections: FakeConnection[] + openConnection: typeof openCodexAppServerConnection + live: () => FakeConnection + notify: (method: string, params: unknown) => void + ask: (id: number, method: string, params: unknown) => void +} + +// `closed` is readonly on the real connection; the fake flips it so the test can +// see the takeover reap the previous child. +type FakeConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + calls: { method: string; params?: Record }[] + replies: { id: number | string; result?: unknown; code?: number }[] + resumedThreadId: string | null + launch: Parameters[0] +} + +function fakeCodex(): CodexScript { + const connections: FakeConnection[] = [] + const openConnection = (async (launch, handlers = {}) => { + const connection: FakeConnection = { + launch, + handlers, + calls: [], + replies: [], + resumedThreadId: null, + pid: 4321, + closed: false, + request: async (method, params) => { + connection.calls.push({ method, params }) + if (method === 'thread/start') { + return { thread: { id: THREAD, path: '/rollouts/integration.jsonl' } } + } + if (method === 'thread/resume') { + connection.resumedThreadId = (params as { threadId: string }).threadId + return { thread: { id: connection.resumedThreadId } } + } + if (method === 'turn/start') { + return { turn: { id: TURN } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'Balanced' }, + { reasoningEffort: 'high', description: 'Deep reasoning' } + ], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: (id, result) => connection.replies.push({ id, result }), + respondWithError: (id, code) => connection.replies.push({ id, code }), + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + const live = (): FakeConnection => { + const connection = connections.at(-1) + if (!connection) { + throw new Error('no codex app-server has been opened') + } + return connection + } + return { + connections, + openConnection, + live, + notify: (method, params) => live().handlers.onNotification?.(method, params), + ask: (id, method, params) => live().handlers.onServerRequest?.({ id, method, params }) + } +} + +// ─── the RPC client ───────────────────────────────────────────────────────── + +let operations = 0 + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. Real + * time, not a frozen constant: the runtime under test stamps the ledger with + * its own clock and refuses a future-dated id. */ +function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(method: string, fields: Record, fence: number | null) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +function attachParams(fence: number | null) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const envelope = { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +function createIntentParams() { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope('agentSession.create', fields, null), ...fields } +} + +let codex: CodexScript +let root: string +let dispatcher: RpcDispatcher +let bootEnvironmentReads: number +let codexOverrideReads: number +let configuredCodexProfile: string + +/** Runs a one-shot method and returns its decoded reply. */ +async function call(method: string, params: unknown): Promise { + const replies: RpcResponse[] = [] + const request: RpcRequest = { id: `req-${operations}`, authToken: 'token', method, params } + await dispatcher.dispatchStreaming(request, (raw) => replies.push(JSON.parse(raw)), CLIENT) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +/** Asserts success and unwraps the host's `{ok:true, value}` mutation result. */ +async function ok(method: string, params: unknown): Promise { + const response = await call(method, params) + expect(response, `${method} failed: ${JSON.stringify(response)}`).toMatchObject({ ok: true }) + const result = (response as { result: { ok: boolean; value?: T; refusal?: unknown } }).result + expect(result, `${method} refused: ${JSON.stringify(result.refusal)}`).toMatchObject({ ok: true }) + return result.value as T +} + +/** Opens a live subscription and keeps collecting frames after the call settles. */ +async function subscribe( + requestId: string, + cursor?: { epoch: string; sequence: number } +): Promise { + const frames: AgentSessionSubscribeEvent[] = [] + await dispatcher.dispatchStreaming( + { + id: requestId, + authToken: 'token', + method: 'agentSession.subscribe', + params: { sessionId: SESSION, ...(cursor ? { cursor } : {}) } + }, + (raw) => { + const response = JSON.parse(raw) as { ok: boolean; result?: AgentSessionSubscribeEvent } + if (response.ok && response.result) { + frames.push(response.result) + } + }, + CLIENT + ) + return frames +} + +/** Settles everything the provider streamed into the journal. Real clients see + * these rows arrive on the subscription; a test has to wait for them. */ +function drainStreamedEvents(): Promise { + return getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() +} + +function textOf(item: AgentJournalRenderItem): string { + const body = item.body + return body?.kind === 'message' + ? body.blocks.map((block) => (block.type === 'text' ? block.text : '')).join('') + : '' +} + +async function historyPage( + direction: 'tail' | 'before' | 'after', + extra: Record = {} +): Promise { + const response = await call('agentSession.history', { + sessionId: SESSION, + direction, + ...extra + }) + return (response as { result: AgentSessionHistoryResult }).result +} + +beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-structured-integration-')) + codex = fakeCodex() + bootEnvironmentReads = 0 + codexOverrideReads = 0 + configuredCodexProfile = 'configured' + const runtime = { + getRuntimeId: () => 'runtime-1', + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + ensureStructuredAgentSessionHost: () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveEnvironment: async () => { + bootEnvironmentReads += 1 + return { + PATH: '/shell/bin:/usr/bin', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/shell/home' + } + }, + resolveCodexOverrides: () => { + codexOverrideReads += 1 + return { CODEX_PROFILE: configuredCodexProfile } + }, + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }).then(() => undefined), + registerOwnedSubscriptionCleanup: vi.fn((_id: string, dispose: () => void) => { + return { + releaseIfCurrent: dispose + } + }) + } + dispatcher = new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe('a structured codex session over agentSession.*', () => { + it('hydrates provider options after activating a legacy-imported journal', async () => { + const identity = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const journal = await openAgentSessionJournal({ + identity, + journalDir: journalDirectoryFor(root, identity) + }) + await appendLegacyTranscriptMessages({ + journal, + agent: 'codex', + sessionId: THREAD, + fence: 0, + messages: [ + { + id: 'legacy-user-1', + role: 'user', + source: 'transcript', + timestamp: 1_800_000_000_000, + blocks: [{ type: 'text', text: 'legacy question' }] + } + ] + }) + + const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.page.items.map(textOf)).toContain('legacy question') + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + }) + + it('dispatches and streams a plain first send from a fresh session', async () => { + const created = await ok<{ fence: number; page: { items: unknown[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.page.items).toEqual([]) + expect(codex.live().launch.env).toMatchObject({ + CODEX_PROFILE: 'configured', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + }) + const store = await readFile(join(root, 'agent-sessions', 'agent-sessions.json'), 'utf-8') + expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') + expect(store).not.toContain('"launchEnv"') + const stream = await subscribe('sub-first-send') + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + + const sent = await ok<{ + clientMessageId: string + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/start', + params: { threadId: THREAD, clientUserMessageId: sent.clientMessageId } + }) + + codex.notify('turn/started', { turn: { id: TURN } }) + codex.notify('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'hi' }] } + }) + codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Hello.' }) + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'Hello.' } + }) + await drainStreamedEvents() + + expect(itemsOf(stream).map(textOf).filter(Boolean)).toEqual(['hi', 'Hello.']) + }) + + it('runs create → send → stream → approval → cancel → reconnect → page history', async () => { + // ── create ────────────────────────────────────────────────────────────── + // No host exists yet; `create` is the call that builds one. + expect(getStructuredAgentSessionHost()).toBeNull() + const created = await ok<{ fence: number; page: { items: unknown[] } }>( + 'agentSession.create', + createIntentParams() + ) + expect(created.page.items).toEqual([]) + expect(codex.live().calls[0]).toMatchObject({ + method: 'thread/start', + params: { cwd: `/repos/${WORKSPACE}` } + }) + const fence = created.fence + + const stream = await subscribe('sub-1') + expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) + + // ── options ───────────────────────────────────────────────────────────── + const options = await call('agentSession.options', { sessionId: SESSION }) + expect(options).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + await ok('agentSession.setOption', { + envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), + key: 'model', + value: 'gpt-live' + }) + await ok('agentSession.setOption', { + envelope: envelope('agentSession.setOption', { key: 'effort', value: 'high' }, fence), + key: 'effort', + value: 'high' + }) + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { current: { model: 'gpt-live', effort: 'high' } } + }) + expect(itemsOf(stream)).toEqual([]) + + // ── send ──────────────────────────────────────────────────────────────── + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'list files' }] } + const sent = await ok<{ + clientMessageId: string + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, fence), + body + }) + // Codex named the turn, so the submission is accepted rather than + // "delivery unconfirmed", and adopts the provider's own item identity. + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `codex:${THREAD}:${TURN}:0` + }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/start', + params: { + threadId: THREAD, + clientUserMessageId: sent.clientMessageId, + model: 'gpt-live', + effort: 'high' + } + }) + + // ── stream ────────────────────────────────────────────────────────────── + codex.notify('turn/started', { turn: { id: TURN } }) + // Codex echoes the user message back as ordinal 0 of the turn. That is the + // key the submission adopted, so the echo has to reconcile into the bubble + // the client already has rather than append a second copy of it. + codex.notify('item/completed', { + item: { type: 'userMessage', id: 'item-0', content: [{ type: 'text', text: 'list files' }] } + }) + await drainStreamedEvents() + expect(itemsOf(stream).filter((item) => textOf(item) === 'list files')).toHaveLength(1) + + codex.notify('item/started', { item: { type: 'agentMessage', id: 'item-1', text: '' } }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'Two ' }) + codex.notify('item/agentMessage/delta', { itemId: 'item-1', delta: 'files.' }) + await drainStreamedEvents() + // The 60ms window has not elapsed, so no half-written row reached the + // journal — the coalescer is holding both deltas. + expect(itemsOf(stream).filter((item) => textOf(item).startsWith('Two'))).toEqual([]) + + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-1', text: 'Two files.' } + }) + await drainStreamedEvents() + const answer = itemsOf(stream).find((item) => textOf(item) === 'Two files.') + // Keyed by (threadId, turnId, ordinal), so a resumed thread reuses this row. + expect(answer?.itemId).toBe(`codex:${THREAD}:${TURN}:1`) + + // ── approval ──────────────────────────────────────────────────────────── + codex.notify('item/started', { + item: { type: 'commandExecution', id: 'item-2', command: 'ls -la', status: 'inProgress' } + }) + codex.ask(7, 'item/commandExecution/requestApproval', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-2', + availableDecisions: ['accept', 'decline'] + }) + await drainStreamedEvents() + const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ title: 'Run a command?', detail: 'ls -la' }) + + const answered = await ok<{ resolution: { state: string; selectedOptionId: string } }>( + 'agentSession.respondToApproval', + { + envelope: envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + }, + fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + } + ) + expect(answered.resolution).toMatchObject({ state: 'resolved', selectedOptionId: 'accept' }) + // The durable journal item id round-tripped back to the live Codex request. + expect(codex.live().replies).toEqual([{ id: 7, result: { decision: 'accept' } }]) + + // ── cancel ────────────────────────────────────────────────────────────── + const cancelled = await ok<{ turnId: string; cancelled: boolean }>('agentSession.cancel', { + envelope: envelope('agentSession.cancel', { turnId: TURN }, fence), + turnId: TURN + }) + expect(cancelled).toEqual({ turnId: TURN, cancelled: true }) + expect(codex.live().calls.at(-1)).toMatchObject({ + method: 'turn/interrupt', + params: { threadId: THREAD, turnId: TURN } + }) + + // ── reconnect ─────────────────────────────────────────────────────────── + // The client drops. Its subscription is reaped; the session and its child + // are not. + await call('agentSession.unsubscribe', { sessionId: SESSION }) + const lastCursor = cursorOf(stream) + codex.notify('item/completed', { + item: { type: 'agentMessage', id: 'item-3', text: 'Stopped.' } + }) + await drainStreamedEvents() + + // Resubscribing from the cursor it held replays only what it missed. + const missed = await subscribe('sub-2', lastCursor) + expect(missed[0]?.type).toBe('batch') + expect(itemsOf(missed).map(textOf)).toEqual(['Stopped.']) + + // A runtime taking the session over is the other half of reconnect: the + // fence advances, the old child is reaped, and its replacement resumes the + // thread this session proved rather than forking a new one. + const reaped = codex.live() + const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( + 'agentSession.ensure', + attachParams(fence) + ) + expect(resumed.fence).toBe(fence + 1) + expect(reaped.closed).toBe(true) + expect(codex.live().resumedThreadId).toBe(THREAD) + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live', effort: 'high' } + } + }) + // The journal belongs to the session, not to the process that just died. + expect(resumed.page.items.map(textOf)).toContain('Two files.') + + // ── page history ──────────────────────────────────────────────────────── + const tail = await historyPage('tail', { limit: 2 }) + expect(tail.ok).toBe(true) + if (!tail.ok) { + throw new Error('history reset') + } + expect(tail.page.hasOlder).toBe(true) + const older = await historyPage('before', { + cursor: tail.page.window.nextCursor, + limit: 10 + }) + if (!older.ok) { + throw new Error('history reset') + } + expect(older.page.hasOlder).toBe(false) + // Every step of the conversation, in order, from the durable journal alone — + // no page overlaps another, and nothing the live stream showed is missing. + expect([...older.page.items, ...tail.page.items].map((item) => item.body?.kind)).toEqual([ + 'message', + 'message', + 'tool-call', + 'approval', + 'status', + 'message' + ]) + expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([ + 'list files', + 'Two files.', + '', + '', + '', + 'Stopped.' + ]) + }) + + it('caches shell exports but re-reads configured overrides for a resume', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ + bootEnvironmentReads: 1, + codexOverrideReads: 1 + }) + + configuredCodexProfile = 'updated' + const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) + + expect(resumed.fence).toBe(created.fence + 1) + expect(codex.live().resumedThreadId).toBe(THREAD) + expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated' }) + expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ + bootEnvironmentReads: 1, + codexOverrideReads: 2 + }) + }) + + it('refuses to build a host for a client that never advertised the capability', async () => { + const replies: RpcResponse[] = [] + await dispatcher.dispatchStreaming( + { + id: 'req-gate', + authToken: 'token', + method: 'agentSession.create', + params: attachParams(null) + }, + (raw) => replies.push(JSON.parse(raw)), + { clientKind: 'runtime', clientCapabilities: ['terminal.stream.v1'] } + ) + + expect(replies[0]).toMatchObject({ ok: false }) + // Building the host is itself observable — it opens a store and spawns a + // child — so the gate has to run before it, not after. + expect(getStructuredAgentSessionHost()).toBeNull() + expect(codex.connections).toEqual([]) + }) + + it('joins final deferred writes before runtime teardown completes', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + codex.notify('item/started', { + threadId: THREAD, + turnId: TURN, + item: { type: 'agentMessage', id: 'item-final', text: '' } + }) + await drainStreamedEvents() + + codex.notify('item/agentMessage/delta', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-final', + delta: 'Final text before shutdown.' + }) + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const appendEntered = Promise.withResolvers() + const appendGate = Promise.withResolvers() + const originalAppend = journal.appendItem.bind(journal) + vi.spyOn(journal, 'appendItem').mockImplementationOnce(async (...args) => { + appendEntered.resolve() + await appendGate.promise + return originalAppend(...args) + }) + + let stopped = false + const stopping = stopStructuredAgentSessionRuntime().then(() => { + stopped = true + }) + await appendEntered.promise + await new Promise((resolve) => setImmediate(resolve)) + const waitedForFinalAppend = !stopped + appendGate.resolve() + await stopping + + expect(waitedForFinalAppend).toBe(true) + const identity = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const reopened = await openAgentSessionJournal({ + identity, + journalDir: journalDirectoryFor(root, identity) + }) + expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') + expect( + reopened + .snapshot() + .items.some( + (item) => item.body?.kind === 'status' && item.body.turnLifecycle?.state === 'running' + ) + ).toBe(false) + }) + + it('persists truncated command output before publishing its journal row', async () => { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const output = 'large command output\n'.repeat(2_000) + + codex.notify('item/completed', { + threadId: THREAD, + turnId: TURN, + item: { + type: 'commandExecution', + id: 'item-large-output', + command: 'print-many-lines', + status: 'completed', + exitCode: 0, + aggregatedOutput: output + } + }) + await drainStreamedEvents() + + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') + const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined + expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) + expect(await readJournalBlob(journal.directory, bounded?.digest ?? '')).toBe(output) + }) + + it('replays a durable image send without dispatching it twice', async () => { + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const path = '/tmp/orca-paste-image.png' + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'image-ref' as const, path }] + } + const params = { + envelope: envelope('agentSession.send', { body }, created.fence), + body + } + + await ok('agentSession.send', params) + const replay = await call('agentSession.send', params) + + expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true } }) + expect(codex.live().calls.filter((entry) => entry.method === 'turn/start')).toHaveLength(1) + }) + + it('joins an acquired attach through journal bind before draining final rows', async () => { + const host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps + .adapter + const historyEntered = Promise.withResolvers() + const historyGate = Promise.withResolvers() + const originalHistoryFilePath = adapter.historyFilePath.bind(adapter) + vi.spyOn(adapter, 'historyFilePath').mockImplementation(async (input) => { + historyEntered.resolve() + await historyGate.promise + return originalHistoryFilePath(input) + }) + + const creating = ok<{ fence: number }>('agentSession.create', createIntentParams()) + await historyEntered.promise + codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + codex.notify('item/started', { + threadId: THREAD, + turnId: TURN, + item: { type: 'agentMessage', id: 'item-bind-window', text: '' } + }) + codex.notify('item/agentMessage/delta', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-bind-window', + delta: 'Buffered while the journal opens.' + }) + + let stopped = false + const stopping = stopStructuredAgentSessionRuntime().then(() => { + stopped = true + }) + await new Promise((resolve) => setImmediate(resolve)) + const waitedForJournalBind = !stopped + historyGate.resolve() + await creating + await stopping + expect(waitedForJournalBind).toBe(true) + + const identity = { + sessionId: SESSION, + workspaceId: WORKSPACE, + hostId: 'local', + agent: 'codex' as const, + providerHandle: { kind: 'codex' as const, threadId: THREAD } + } + const reopened = await openAgentSessionJournal({ + identity, + journalDir: journalDirectoryFor(root, identity) + }) + expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') + expect( + reopened + .snapshot() + .items.some( + (item) => item.body?.kind === 'status' && item.body.turnLifecycle?.state === 'running' + ) + ).toBe(false) + }) +}) + +/** Every item the subscription has published, latest revision per id. */ +function itemsOf(frames: AgentSessionSubscribeEvent[]): AgentJournalRenderItem[] { + const items = new Map() + for (const frame of frames) { + const published = + frame.type === 'snapshot' || frame.type === 'reset' + ? frame.page.items + : frame.type === 'batch' + ? frame.batch.items + : [] + for (const item of published) { + items.set(item.itemId, item) + } + } + return [...items.values()] +} + +function cursorOf(frames: AgentSessionSubscribeEvent[]): { epoch: string; sequence: number } { + for (let index = frames.length - 1; index >= 0; index -= 1) { + const frame = frames[index] as AgentSessionSubscribeEvent + if (frame.type === 'batch') { + return frame.batch.cursor + } + if (frame.type === 'snapshot' || frame.type === 'reset') { + return frame.page.liveCursor ?? frame.page.window.nextCursor + } + } + throw new Error('subscription published no cursor') +} diff --git a/src/main/runtime/structured-agent-session-pty-binding.test.ts b/src/main/runtime/structured-agent-session-pty-binding.test.ts new file mode 100644 index 00000000000..e6656c025a8 --- /dev/null +++ b/src/main/runtime/structured-agent-session-pty-binding.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { OrcaRuntimeService } from './orca-runtime' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const SESSION_ID = 'structured-session-1' + +afterEach(() => agentSessionPtyWriteGate.detachRecordLookup()) + +describe('structured handoff PTY binding', () => { + it('binds before runtime writes and unbinds on process exit', async () => { + const runtime = new OrcaRuntimeService() + const internal = runtime as unknown as { + resolveTerminalWorkspaceLaunchScope: (selector: string) => Promise<{ + id: string + path: string + connectionId: null + repo: null + folderWorkspace: null + }> + } + vi.spyOn(internal, 'resolveTerminalWorkspaceLaunchScope').mockResolvedValue({ + id: 'worktree-1', + path: '/tmp/worktree-1', + connectionId: null, + repo: null, + folderWorkspace: null + }) + const write = vi.fn(() => true) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: 'pty-structured', pid: 4200 })), + write, + kill: () => true, + getForegroundProcess: async () => null + }) + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION_ID, + runtimeKind: 'native', + handoffStage: 'new-owner-proving' + }) + ) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => + sessionId === SESSION_ID ? record : null + ) + + await runtime.createTerminal('id:worktree-1', { + command: 'codex resume thread-1', + structuredAgentSessionId: SESSION_ID + }) + + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBe(SESSION_ID) + await expect(runtime.writeTerminalPreviewInput('pty-structured', 'unsafe')).resolves.toBe(false) + expect(write).not.toHaveBeenCalled() + + runtime.onPtyExit('pty-structured', 0) + expect(agentSessionPtyWriteGate.boundSessionId('pty-structured')).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts new file mode 100644 index 00000000000..6adf5d368fd --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -0,0 +1,265 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionClaimStatus, + AgentSessionProcessIdentity, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { + createStructuredAgentSessionOwnerProbe, + createStructuredAgentSessionOwnerProbes, + ensureStructuredAgentSessionHost, + hasPersistedStructuredAgentSessionStore, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const HOST_ID = 'local' + +function record( + ownerProcess: AgentSessionProcessIdentity | null, + lease: { + processlessAt?: number | null + reservedSpawnToken?: string | null + claimStatus?: AgentSessionClaimStatus + runtimeFence?: number + } = {} +): AgentSessionRecord { + return { + sessionId: 'session-1', + providerHandleChain: [], + lease: { + ownerProcess, + reservedSpawnToken: null, + claimStatus: 'released', + runtimeFence: 3, + ...lease + } + } as unknown as AgentSessionRecord +} + +const OWNER: AgentSessionProcessIdentity = { + hostId: HOST_ID, + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'token-1' +} + +const deadProbe = () => vi.fn(async () => ({ outcome: 'pid-absent' }) as const) + +describe('structured agent-session store presence', () => { + it('stops after finding the durable primary store', () => { + const fileExists = vi.fn(() => true) + + expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) + expect(fileExists).toHaveBeenCalledOnce() + expect(fileExists).toHaveBeenCalledWith( + join('/profile', 'agent-sessions', 'agent-sessions.json') + ) + }) + + it('checks the durable backup when the primary store is absent', () => { + const fileExists = vi.fn((path: string) => path.endsWith('.bak')) + + expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) + expect(fileExists).toHaveBeenNthCalledWith( + 1, + join('/profile', 'agent-sessions', 'agent-sessions.json') + ) + expect(fileExists).toHaveBeenNthCalledWith( + 2, + join('/profile', 'agent-sessions', 'agent-sessions.json.bak') + ) + }) + + it('reports a fresh profile absent after two bounded presence checks', () => { + const fileExists = vi.fn(() => false) + + expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) + expect(fileExists).toHaveBeenCalledTimes(2) + }) +}) + +describe('structured agent-session owner probe', () => { + it('probes an owner this host spawned', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe(HOST_ID, probe)(record(OWNER)) + + expect(probe).toHaveBeenCalledWith({ + identity: OWNER, + deps: { readEchoedSpawnToken: expect.any(Function) } + }) + expect(result).toEqual({ outcome: 'pid-absent' }) + }) + + it('reads the process table once for many local owners', async () => { + const secondOwner = { ...OWNER, pid: 5252, spawnToken: 'token-2' } + const probeMany = vi.fn(async () => [ + { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, + { outcome: 'pid-absent' as const } + ]) + const probeOne = vi.fn(async () => ({ outcome: 'indeterminate' as const, reason: 'unused' })) + const records = [ + record(OWNER), + { ...record(secondOwner), sessionId: 'session-2' } + ] as AgentSessionRecord[] + + const results = await createStructuredAgentSessionOwnerProbes( + HOST_ID, + probeMany, + probeOne + )(records) + + expect(probeMany).toHaveBeenCalledOnce() + expect(probeMany).toHaveBeenCalledWith({ + identities: [OWNER, secondOwner], + deps: { readEchoedSpawnToken: expect.any(Function) } + }) + expect(probeOne).not.toHaveBeenCalled() + expect(results.get('session-1')?.outcome).toBe('identity-matched') + expect(results.get('session-2')).toEqual({ outcome: 'pid-absent' }) + }) + + it('refuses to probe an owner on another host, whose pid means nothing here', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + probe + )(record({ ...OWNER, hostId: 'ssh:build-box' })) + + expect(probe).not.toHaveBeenCalled() + expect(result.outcome).toBe('indeterminate') + }) + + it('leaves a reservation whose spawn token is still live on this host latched', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe(HOST_ID, probe, async () => [9001])( + record(null, { claimStatus: 'reserved', reservedSpawnToken: 'token-1' }) + ) + + // Evicting here would put a second writer on a live Codex thread. + expect(result.outcome).toBe('indeterminate') + }) + + it('leaves a reservation latched on a host that cannot enumerate spawn tokens', async () => { + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + deadProbe(), + async () => null + )(record(null, { claimStatus: 'reserved', reservedSpawnToken: 'token-1' })) + + expect(result.outcome).toBe('indeterminate') + }) + + it('frees a reservation once the host proves no process carries its token', async () => { + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + deadProbe(), + async () => [] + )(record(null, { claimStatus: 'reserved', reservedSpawnToken: 'token-1' })) + + expect(result).toEqual({ outcome: 'reservation-unused' }) + }) + + it('frees a lease that names neither an owner nor a spawn token', async () => { + const probe = deadProbe() + const scan = vi.fn(async () => [] as number[]) + // Nothing was ever minted that a child could be carrying, so no scan is even needed; + // answering `indeterminate` here is what latches every released record into recovery. + const result = await createStructuredAgentSessionOwnerProbe(HOST_ID, probe, scan)(record(null)) + + expect(probe).not.toHaveBeenCalled() + expect(scan).not.toHaveBeenCalled() + expect(result).toEqual({ outcome: 'reservation-unused' }) + }) + + it('still refuses a reservation that recorded no token to scan for', async () => { + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + deadProbe(), + async () => [] + )(record(null, { claimStatus: 'reserved' })) + + expect(result.outcome).toBe('indeterminate') + }) + + it('releases only a reservation carrying durable pre-spawn proof', async () => { + const probe = deadProbe() + const result = await createStructuredAgentSessionOwnerProbe( + HOST_ID, + probe + )(record(null, { processlessAt: 1_800_000_000_000, claimStatus: 'reserved' })) + + expect(probe).not.toHaveBeenCalled() + expect(result).toEqual({ outcome: 'reservation-unused' }) + }) +}) + +describe('structured agent-session runtime install', () => { + let stateDirectory: string | null = null + + afterEach(async () => { + await stopStructuredAgentSessionRuntime() + if (stateDirectory) { + await rm(stateDirectory, { recursive: true, force: true }) + stateDirectory = null + } + vi.restoreAllMocks() + }) + + it('starts orphan reaping and reports failures without failing installation', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) + const failure = new Error('scan failed') + const reapOrphanChildren = vi.fn(async () => { + throw failure + }) + const onError = vi.fn() + + await expect( + ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveEnvironment: async () => ({}), + reapOrphanChildren, + onError + }) + ).resolves.toBeDefined() + + await vi.waitFor(() => + expect(onError).toHaveBeenCalledWith({ + scope: 'agent-session-orphan-child-reaper', + error: failure + }) + ) + expect(reapOrphanChildren).toHaveBeenCalledWith({ store: expect.anything() }) + }) + + it('logs an orphan-reaper failure when no reporter is configured', async () => { + stateDirectory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) + const failure = new Error('scan failed') + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await expect( + ensureStructuredAgentSessionHost({ + stateDirectory, + hostId: HOST_ID, + claimKeyId: 'key-1', + resolveWorkspacePath: async () => stateDirectory!, + resolveEnvironment: async () => ({}), + reapOrphanChildren: async () => { + throw failure + } + }) + ).resolves.toBeDefined() + + await vi.waitFor(() => + expect(consoleError).toHaveBeenCalledWith( + '[structured-agent-session] orphan reaper failed', + failure + ) + ) + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts new file mode 100644 index 00000000000..2226fd35b6e --- /dev/null +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -0,0 +1,278 @@ +// Where the structured agent-session wire becomes a live host on this runtime. +// +// Built on the first `agentSession.*` call rather than at startup: the record +// store and the journals live under the profile's user-data path, which is not +// final until Electron is ready, and a runtime that never serves a structured +// session should not pay for a store it will never read. The slot the RPC layer +// reads is module-level for the same reason the registry is — the runtime +// service is already far past its size budget. + +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { createCodexStructuredLaunchResolver } from '../codex/codex-structured-launch-resolution' +import { + CodexStructuredSessionAdapter, + type CodexStructuredSessionAdapterDeps +} from '../codex/codex-structured-session-adapter' +import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types' +import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' +import { + probeAgentSessionProcessIdentities, + probeAgentSessionProcessIdentity, + probeAgentSessionReservation +} from './agent-session-process-identity-probe' +import { findAgentSessionSpawnTokenProcesses } from './agent-session-spawn-token-process-scan' +import { readEchoedAgentSessionSpawnToken } from './agent-session-spawn-token-readback' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { resolveLoginShellEnvironment } from '../startup/login-shell-environment' +import { recordAgentSessionProviderHandle } from './agent-session-provider-handle-transition' + +/** Sibling of the journal tree rather than inside it: one file adjudicates every + * session's lease, while a journal is per session. */ +const RECORD_STORE_DIR_NAME = 'agent-sessions' + +export function hasPersistedStructuredAgentSessionStore( + stateDirectory: string, + fileExists: (path: string) => boolean = existsSync +): boolean { + const filePath = agentSessionStorePath(join(stateDirectory, RECORD_STORE_DIR_NAME)) + return fileExists(filePath) || fileExists(`${filePath}.bak`) +} + +export type StructuredAgentSessionRuntimeDeps = { + /** Host state root. The record store and the journal tree both hang off it. */ + stateDirectory: string + /** Execution host this runtime *is*. A record pinned elsewhere is not ours to + * probe and not ours to spawn for. */ + hostId: string + /** Key id this host's claims are minted under. */ + claimKeyId: string + resolveWorkspacePath: (workspaceId: string) => Promise + resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string + /** Provider transports are overridden only to drive the runtime against scripted children. */ + openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection'] + /** Scripted app-servers carry fake pids the real start-time read cannot answer for. */ + readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime'] + resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise | string[] + resolveLaunchEnv?: () => Promise + resolveLaunchEnvOverlay?: () => Promise> | Record + resolveEnvironment?: () => Promise + resolveCodexOverrides?: () => NodeJS.ProcessEnv + onError?: (input: { scope: string; error: unknown }) => void + handoffTransport?: StructuredAgentSessionHandoffTransport + reapOrphanChildren?: typeof stopOrphanAgentSessionChildren +} + +type InstalledRuntime = { + host: StructuredAgentSessionHost + adapter: CodexStructuredSessionAdapter +} + +let installing: Promise | null = null + +export function ensureStructuredAgentSessionHost( + deps: StructuredAgentSessionRuntimeDeps +): Promise { + // A failed open must not poison the slot forever — the next call retries. + installing ??= install(deps).catch((error) => { + installing = null + throw error + }) + return installing.then((installed) => installed.host) +} + +/** Drops the host and reaps every Codex child under it. Runtime teardown and + * test isolation take the same path, so neither can leave a live app-server. */ +export async function stopStructuredAgentSessionRuntime(): Promise { + const pending = installing + installing = null + setStructuredAgentSessionHost(null) + agentSessionPtyWriteGate.detachRecordLookup() + if (!pending) { + return + } + const installed = await pending.catch(() => null) + if (!installed) { + return + } + try { + await installed.adapter.closeAll() + } finally { + await installed.host.flushAllStreamedEvents() + } +} + +async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const bootEnvironment = (deps.resolveEnvironment ?? resolveLoginShellEnvironment)() + const resolveEnvironment = async (): Promise => ({ + ...(await bootEnvironment), + ...(await deps.resolveLaunchEnv?.()), + ...(await deps.resolveLaunchEnvOverlay?.()), + ...deps.resolveCodexOverrides?.() + }) + const store = await AgentSessionRecordStore.open({ + directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), + hostId: deps.hostId + }) + agentSessionPtyWriteGate.attachRecordLookup((sessionId) => store.getRecord(sessionId)) + // Why: only the durable store can identify a provider child lost before record publication. + void (deps.reapOrphanChildren ?? stopOrphanAgentSessionChildren)({ store }).catch((error) => { + try { + if (deps.onError) { + deps.onError({ scope: 'agent-session-orphan-child-reaper', error }) + } else { + console.error('[structured-agent-session] orphan reaper failed', error) + } + } catch (reportingError) { + console.error( + '[structured-agent-session] orphan reaper error reporting failed', + reportingError + ) + } + }) + try { + const codex = new CodexStructuredSessionAdapter({ + resolveLaunch: createCodexStructuredLaunchResolver({ + store, + resolveWorkspacePath: deps.resolveWorkspacePath, + resolveEnvironment, + ...(deps.resolveCodexCommand ? { resolveCommand: deps.resolveCodexCommand } : {}) + }), + ...(deps.openCodexConnection ? { openConnection: deps.openCodexConnection } : {}), + ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}) + }) + const adapter = codex + const host = new StructuredAgentSessionHost({ + store, + adapter, + journalRoot: deps.stateDirectory, + claimKeyId: deps.claimKeyId, + probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), + probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), + ...(deps.resolveLaunchArgs + ? { + resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => + await deps.resolveLaunchArgs!(provider) + } + : {}), + onEventSinkError: ({ sessionId, error }) => + deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + persistTuiProviderHandle: async ({ sessionId, link, now }) => { + await store.transitionHandoff(sessionId, (record) => + recordAgentSessionProviderHandle({ record, fence: record.lease.runtimeFence, link, now }) + ) + }, + ...(deps.handoffTransport ? { handoffTransport: deps.handoffTransport } : {}) + }) + setStructuredAgentSessionHost(host) + return { host, adapter } + } catch (error) { + agentSessionPtyWriteGate.detachRecordLookup() + throw error + } +} + +/** + * The lease's only source of truth about a previous owner. Everything it cannot + * answer PID-reuse-safely reports `indeterminate`. An exact owner stays fenced in `recovering`; + * an ownerless, unattributable reservation enters `manual-recovery`. + */ +export function createStructuredAgentSessionOwnerProbe( + hostId: string, + probe = probeAgentSessionProcessIdentity, + findSpawnTokenProcesses = findAgentSessionSpawnTokenProcesses +): (record: AgentSessionRecord) => Promise { + return async (record) => { + const owner = record.lease.ownerProcess + if (!owner) { + if (record.lease.processlessAt !== undefined && record.lease.processlessAt !== null) { + return { outcome: 'reservation-unused' } + } + const spawnToken = record.lease.reservedSpawnToken + if (spawnToken === null) { + if (record.lease.claimStatus === 'reserved') { + return { + outcome: 'indeterminate', + reason: 'reservation recorded no spawn token to scan for' + } + } + // The token is minted before the child and is the only thing a child could be carrying. + // No owner and no token means nothing on any host can be holding this lease — answering + // `indeterminate` here is what latches an already-free record into recovery forever. + return { outcome: 'reservation-unused' } + } + // Freeing a reservation needs positive proof that nothing spawned under its token. The scan + // answers null where the platform cannot read another process's environment. + return probeAgentSessionReservation({ + spawnToken, + findProcessesWithSpawnToken: (token) => findSpawnTokenProcesses(token), + hasProviderActivitySinceReservation: async () => + agentSessionReservationTouchedProvider(record) + }) + } + if (owner.hostId !== hostId) { + // Checking a remote host's pid against this machine's process table is + // exactly how a live owner gets declared dead. + return { + outcome: 'indeterminate', + reason: `owner runs on ${owner.hostId}, which this host cannot probe` + } + } + // The env read-back answers on hosts that expose it and null elsewhere, giving the + // probe a PID-reuse-safe element even when no start time was recorded. + return probe({ + identity: owner, + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + } +} + +export function createStructuredAgentSessionOwnerProbes( + hostId: string, + probeMany: typeof probeAgentSessionProcessIdentities = probeAgentSessionProcessIdentities, + probeOne = createStructuredAgentSessionOwnerProbe(hostId) +): (records: readonly AgentSessionRecord[]) => Promise> { + return async (records) => { + const results = new Map() + const localOwners: { + record: AgentSessionRecord + owner: NonNullable + }[] = [] + for (const record of records) { + const owner = record.lease.ownerProcess + if (owner?.hostId === hostId) { + localOwners.push({ record, owner }) + } else { + results.set(record.sessionId, await probeOne(record)) + } + } + const probes = await probeMany({ + identities: localOwners.map(({ owner }) => owner), + deps: { readEchoedSpawnToken: readEchoedAgentSessionSpawnToken } + }) + for (const [index, { record }] of localOwners.entries()) { + results.set( + record.sessionId, + probes[index] ?? { outcome: 'indeterminate', reason: 'owner probe returned no result' } + ) + } + return results + } +} + +/** + * The only provider-side trace a reservation can leave in its own record: a handle link minted at + * this fence. `proveAgentSessionOwner` refuses to append one before an identity is committed, so a + * link at the reservation's fence means a child got far enough to resume the provider thread. It + * cannot see activity the child produced without proving a handle, which is why it is paired with + * the token scan rather than trusted alone. + */ +function agentSessionReservationTouchedProvider(record: AgentSessionRecord): boolean { + return record.providerHandleChain.at(-1)?.mintedAtFence === record.lease.runtimeFence +} diff --git a/src/main/runtime/structured-tui-exit-proof.test.ts b/src/main/runtime/structured-tui-exit-proof.test.ts new file mode 100644 index 00000000000..3652aae1880 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { waitForStructuredTuiExitProof } from './structured-tui-exit-proof' + +const identity: AgentSessionProcessIdentity = { + hostId: 'local', + pid: 123, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-token' +} + +describe('structured TUI exit proof', () => { + it('accepts the exact terminal exit event without probing', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ identity, waitForExit: async () => {}, probe }) + ).resolves.toBeUndefined() + expect(probe).not.toHaveBeenCalled() + }) + + it.each([ + { outcome: 'pid-absent' as const }, + { outcome: 'identity-mismatch' as const, field: 'process-start-time' as const } + ])('accepts a retired handle only when the recorded process is gone: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof + }) + ).resolves.toBeUndefined() + }) + + it.each([ + { outcome: 'identity-matched' as const, matchedOn: ['process-start-time' as const] }, + { outcome: 'indeterminate' as const, reason: 'probe unavailable' } + ])('fails closed while the recorded process may still own: $outcome', async (proof) => { + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe: async () => proof, + staleHandleProbeAttempts: 1 + }) + ).rejects.toThrow('terminal_handle_stale') + }) + + it('retries the persisted process identity when handle retirement wins the exit race', async () => { + const probe = vi + .fn() + .mockResolvedValueOnce({ + outcome: 'identity-matched' as const, + matchedOn: ['process-start-time' as const] + }) + .mockResolvedValueOnce({ outcome: 'pid-absent' as const }) + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('terminal_handle_stale') + }, + probe, + staleHandleProbeAttempts: 2, + staleHandleProbeIntervalMs: 0 + }) + ).resolves.toBeUndefined() + expect(probe).toHaveBeenNthCalledWith(1, identity) + expect(probe).toHaveBeenNthCalledWith(2, identity) + }) + + it('preserves unrelated terminal wait failures', async () => { + const probe = vi.fn() + + await expect( + waitForStructuredTuiExitProof({ + identity, + waitForExit: async () => { + throw new Error('timeout') + }, + probe + }) + ).rejects.toThrow('timeout') + expect(probe).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/structured-tui-exit-proof.ts b/src/main/runtime/structured-tui-exit-proof.ts new file mode 100644 index 00000000000..c9c733f4ef2 --- /dev/null +++ b/src/main/runtime/structured-tui-exit-proof.ts @@ -0,0 +1,44 @@ +import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe' + +type ExitProofInput = { + identity: AgentSessionProcessIdentity + waitForExit: () => Promise + probe?: (identity: AgentSessionProcessIdentity) => Promise + staleHandleProbeAttempts?: number + staleHandleProbeIntervalMs?: number +} + +const DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS = 50 +const DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS = 100 + +function provesRecordedProcessExited(proof: AgentSessionOwnerProbe): boolean { + return proof.outcome === 'pid-absent' || proof.outcome === 'identity-mismatch' +} + +async function waitForRecordedProcessExit(input: ExitProofInput, staleError: Error): Promise { + const probe = input.probe ?? ((identity) => probeAgentSessionProcessIdentity({ identity })) + const attempts = input.staleHandleProbeAttempts ?? DEFAULT_STALE_HANDLE_PROBE_ATTEMPTS + const intervalMs = input.staleHandleProbeIntervalMs ?? DEFAULT_STALE_HANDLE_PROBE_INTERVAL_MS + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (provesRecordedProcessExited(await probe(input.identity))) { + return + } + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, intervalMs)) + } + } + throw staleError +} + +export async function waitForStructuredTuiExitProof(input: ExitProofInput): Promise { + try { + await input.waitForExit() + } catch (error) { + if (!(error instanceof Error) || error.message !== 'terminal_handle_stale') { + throw error + } + await waitForRecordedProcessExit(input, error) + } +} diff --git a/src/main/runtime/structured-tui-idle-evidence.test.ts b/src/main/runtime/structured-tui-idle-evidence.test.ts new file mode 100644 index 00000000000..7177cb9bfc3 --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { hasStructuredTuiIdleEvidence } from './structured-tui-idle-evidence' + +describe('structured TUI idle evidence', () => { + it('does not treat a ready prompt preview as proof that a turn is idle', () => { + const readyPreview = ' >_ OpenAI Codex\n model: gpt-5.5\n directory: /workspace' + expect(readyPreview).toContain('OpenAI Codex') + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: null, statusObservedLive: false }) + ).toBe(false) + }) + + it('requires an explicit idle state and still rejects blocked prompts', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: true }) + ).toBe(true) + expect( + hasStructuredTuiIdleEvidence({ blocked: true, status: 'idle', statusObservedLive: true }) + ).toBe(false) + }) + + it('does not authorize a restored idle status before live observation', () => { + expect( + hasStructuredTuiIdleEvidence({ blocked: false, status: 'idle', statusObservedLive: false }) + ).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-tui-idle-evidence.ts b/src/main/runtime/structured-tui-idle-evidence.ts new file mode 100644 index 00000000000..d91518cdd4c --- /dev/null +++ b/src/main/runtime/structured-tui-idle-evidence.ts @@ -0,0 +1,9 @@ +import type { AgentStatus } from '../../shared/agent-title-core' + +export function hasStructuredTuiIdleEvidence(input: { + blocked: boolean + status: AgentStatus | null + statusObservedLive: boolean +}): boolean { + return !input.blocked && input.status === 'idle' && input.statusObservedLive +} diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts new file mode 100644 index 00000000000..d1a7510041f --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest' +import { readStructuredTuiProcessIdentity } from './structured-tui-process-identity' + +describe('structured TUI process identity', () => { + it('binds the direct Codex child instead of the PTY shell pid', async () => { + const readStartTime = vi.fn(async () => 1_700_000_000_000) + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' }, + { pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' } + ], + readStartTime + }) + ).resolves.toEqual({ + hostId: 'local', + pid: 101, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-1' + }) + expect(readStartTime).toHaveBeenCalledWith(101, 'darwin') + }) + + it('fails closed when sibling Codex children make the owner ambiguous', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'win32', + readWindowsRows: async () => [ + { pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' }, + { pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' }, + { pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' } + ], + timeoutMs: 0 + }) + ).rejects.toThrow('one exact Codex child process') + }) + + it('waits for a shell-delivered Codex child before binding ownership', async () => { + let snapshots = 0 + const delays: number[] = [] + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-2', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => { + snapshots += 1 + return [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + ...(snapshots >= 3 + ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'codex resume session-1' }] + : []) + ] + }, + readStartTime: async () => 1_700_000_000_000, + timeoutMs: 1_000, + pollIntervalMs: 25, + now: () => delays.length * 25, + sleep: async (delayMs) => { + delays.push(delayMs) + } + }) + ).resolves.toEqual({ + hostId: 'local', + pid: 101, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-2' + }) + expect(delays).toEqual([25, 25]) + }) + + it('fails closed when the process snapshot omitted the PTY root', async () => { + await expect( + readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-1', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => [ + { pid: 101, ppid: 100, stat: 'S+', command: 'codex resume abc' } + ] + }) + ).rejects.toThrow('root process was not present') + }) +}) diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts new file mode 100644 index 00000000000..0ce78275f04 --- /dev/null +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -0,0 +1,194 @@ +import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' +import { + getFreshProcessTableSnapshot, + type ProcessTableRow +} from '../../shared/process-table-snapshot' +import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' +import { queryWindowsProcessRowsFresh } from '../providers/windows-foreground-process-rows' +import { + PROCESS_START_TIME_TOLERANCE_MS, + readProcessStartTimeMs +} from './agent-session-process-identity-probe' + +type ProcessRow = { pid: number; ppid: number; command: string; foreground: boolean } + +const STRUCTURED_TUI_PROCESS_WAIT_MS = 5_000 +const STRUCTURED_TUI_PROCESS_POLL_MS = 50 + +function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { + const children = new Map() + for (const row of rows) { + children.set(row.ppid, [...(children.get(row.ppid) ?? []), row]) + } + const found: (ProcessRow & { depth: number })[] = [] + const pending = [{ pid: rootPid, depth: 0 }] + const seen = new Set() + while (pending.length > 0) { + const current = pending.pop()! + if (seen.has(current.pid)) { + continue + } + seen.add(current.pid) + const row = rows.find((candidate) => candidate.pid === current.pid) + if (row) { + found.push({ ...row, depth: current.depth }) + } + for (const child of children.get(current.pid) ?? []) { + pending.push({ pid: child.pid, depth: current.depth + 1 }) + } + } + return found +} + +function excludedProcessTreePids( + rows: ProcessRow[], + rootPids: ReadonlySet | undefined +): ReadonlySet { + if (!rootPids || rootPids.size === 0) { + return new Set() + } + const excluded = new Set(rootPids) + const children = new Map() + for (const row of rows) { + children.set(row.ppid, [...(children.get(row.ppid) ?? []), row.pid]) + } + const pending = [...rootPids] + while (pending.length > 0) { + for (const childPid of children.get(pending.pop()!) ?? []) { + if (!excluded.has(childPid)) { + excluded.add(childPid) + pending.push(childPid) + } + } + } + return excluded +} + +async function resolveExcludedProcessTreePids( + rows: ProcessRow[], + identities: readonly { pid: number; processStartTimeMs: number | null }[] | undefined, + platform: NodeJS.Platform, + readStartTime: (pid: number, platform?: NodeJS.Platform) => Promise +): Promise> { + if (!identities || identities.length === 0) { + return new Set() + } + const roots = new Set() + for (const identity of identities) { + if (!rows.some((row) => row.pid === identity.pid)) { + continue + } + // Unavailable start time cannot prove PID reuse, so retain the conservative exclusion. + if (identity.processStartTimeMs === null) { + roots.add(identity.pid) + continue + } + const observed = await readStartTime(identity.pid, platform) + if ( + observed === null || + Math.abs(observed - identity.processStartTimeMs) <= PROCESS_START_TIME_TOLERANCE_MS + ) { + roots.add(identity.pid) + } + } + return excludedProcessTreePids(rows, roots) +} + +export function resolveStructuredTuiChildPid( + rows: ProcessRow[], + rootPid: number, + agent: AgentSessionHandleProvider, + processCommandMatches?: (command: string) => boolean, + excludedPids?: ReadonlySet +): number | null { + const candidates = descendants(rows, rootPid).filter( + (row) => + !excludedPids?.has(row.pid) && + recognizeAgentProcessFromCommandLine(row.command)?.agent === agent && + (processCommandMatches?.(row.command) ?? true) + ) + const foreground = candidates.filter((row) => row.foreground) + const eligible = foreground.length > 0 ? foreground : candidates + eligible.sort((left, right) => left.depth - right.depth || left.pid - right.pid) + if (eligible.length === 0 || eligible[1]?.depth === eligible[0]?.depth) { + return null + } + return eligible[0]!.pid +} + +function posixRows(rows: ProcessTableRow[]): ProcessRow[] { + return rows.map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: row.stat.includes('+') + })) +} + +export async function readStructuredTuiProcessIdentity(input: { + hostId: string + rootPid: number + spawnToken: string + agent: AgentSessionHandleProvider + platform?: NodeJS.Platform + readPosixRows?: () => Promise + readWindowsRows?: typeof queryWindowsProcessRowsFresh + readStartTime?: (pid: number, platform?: NodeJS.Platform) => Promise + timeoutMs?: number + pollIntervalMs?: number + now?: () => number + sleep?: (delayMs: number) => Promise + processCommandMatches?: (command: string) => boolean + excludedProcessTreeRootIdentities?: readonly { + pid: number + processStartTimeMs: number | null + }[] +}): Promise { + const platform = input.platform ?? process.platform + const now = input.now ?? Date.now + const sleep = input.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) + const deadline = now() + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) + + while (true) { + const rows: ProcessRow[] = + platform === 'win32' + ? (await (input.readWindowsRows ?? queryWindowsProcessRowsFresh)()).map((row) => ({ + pid: row.pid, + ppid: row.ppid, + command: row.command, + foreground: false + })) + : posixRows(await (input.readPosixRows ?? getFreshProcessTableSnapshot)()) + if (!rows.some((row) => row.pid === input.rootPid)) { + throw new Error('The terminal root process was not present in the process snapshot.') + } + const excludedPids = await resolveExcludedProcessTreePids( + rows, + input.excludedProcessTreeRootIdentities, + platform, + input.readStartTime ?? readProcessStartTimeMs + ) + const pid = resolveStructuredTuiChildPid( + rows, + input.rootPid, + input.agent, + input.processCommandMatches, + excludedPids + ) + if (pid !== null) { + return { + hostId: input.hostId, + pid, + processStartTimeMs: await (input.readStartTime ?? readProcessStartTimeMs)(pid, platform), + spawnToken: input.spawnToken + } + } + const remainingMs = deadline - now() + if (remainingMs <= 0) { + const label = input.agent === 'codex' ? 'Codex' : 'Claude' + throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) + } + await sleep(Math.min(input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS, remainingMs)) + } +} diff --git a/src/main/runtime/structured-tui-recovery-claim-match.test.ts b/src/main/runtime/structured-tui-recovery-claim-match.test.ts new file mode 100644 index 00000000000..39495872b93 --- /dev/null +++ b/src/main/runtime/structured-tui-recovery-claim-match.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + evaluateStructuredTuiRecoveryClaim, + type StructuredTuiRecoveryClaimCandidate +} from './structured-tui-recovery-claim-match' + +// Modeled after a packaged restart whose first recovery claim failed. +const PACKAGED_CANDIDATE: StructuredTuiRecoveryClaimCandidate = { + expectedWorkspaceId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', + claimMatches: true, + pty: { + connected: true, + ptyId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', + incarnationId: '4cf23679-8987-487d-a24c-dba3bed1b442', + worktreeId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture' + }, + owner: { + phase: 'live', + ptyId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', + surface: { + worktreeId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture', + tabId: 'ced3bd39-262b-41f3-a446-92ceab4f938c', + leafId: 'd4e9d94d-8ec3-4d0d-8ca3-52730ba61c24' + } + }, + persisted: { + sessionResolved: true, + tabPresent: true, + ptyId: + '4d68c30a-b7eb-4078-a1ca-e44e9fa75024::/Users/alice/orca/workspaces/orca/recovery-fixture@@fda34510', + incarnationId: null + } +} + +type CandidatePatch = Partial< + Omit +> & { + owner?: Partial> & { + surface?: Partial + } + persisted?: Partial + pty?: Partial +} + +const MISMATCH_CASES: [string, CandidatePatch, string[]][] = [ + ['connection', { pty: { connected: false } }, ['connected']], + ['owner phase', { owner: { phase: 'retiring' } }, ['owner-phase']], + ['owner PTY', { owner: { ptyId: 'different-pty' } }, ['owner-pty-id', 'persisted-pty-id']], + ['presented incarnation', { pty: { incarnationId: null } }, ['presented-incarnation']], + ['PTY workspace', { pty: { worktreeId: 'different-workspace' } }, ['pty-workspace']], + [ + 'surface workspace', + { owner: { surface: { worktreeId: 'different-workspace' } } }, + ['surface-workspace'] + ], + ['claim', { claimMatches: false }, ['claim']], + ['persisted session', { persisted: { sessionResolved: false } }, ['persisted-session']], + ['persisted tab', { persisted: { tabPresent: false } }, ['persisted-tab']], + ['persisted PTY', { persisted: { ptyId: 'different-pty' } }, ['persisted-pty-id']], + [ + 'persisted incarnation', + { persisted: { incarnationId: 'different-incarnation' } }, + ['persisted-incarnation'] + ] +] + +describe('structured TUI packaged recovery claim matching', () => { + it('accepts the real first-claim surface while persisted incarnation hydration is pending', () => { + expect(evaluateStructuredTuiRecoveryClaim(PACKAGED_CANDIDATE)).toEqual({ + matches: true, + mismatchedFields: [] + }) + }) + + it('accepts the same surface once the persisted incarnation arrives', () => { + expect( + evaluateStructuredTuiRecoveryClaim({ + ...PACKAGED_CANDIDATE, + persisted: { + ...PACKAGED_CANDIDATE.persisted, + incarnationId: PACKAGED_CANDIDATE.pty.incarnationId + } + }) + ).toMatchObject({ matches: true }) + }) + + it.each(MISMATCH_CASES)('rejects a %s mismatch', (_label, patch, mismatchedFields) => { + const candidate = { + ...PACKAGED_CANDIDATE, + ...patch, + pty: { ...PACKAGED_CANDIDATE.pty, ...patch.pty }, + owner: { + ...PACKAGED_CANDIDATE.owner, + ...patch.owner, + surface: { ...PACKAGED_CANDIDATE.owner.surface, ...patch.owner?.surface } + }, + persisted: { ...PACKAGED_CANDIDATE.persisted, ...patch.persisted } + } + + expect(evaluateStructuredTuiRecoveryClaim(candidate)).toEqual({ + matches: false, + mismatchedFields + }) + }) +}) diff --git a/src/main/runtime/structured-tui-recovery-claim-match.ts b/src/main/runtime/structured-tui-recovery-claim-match.ts new file mode 100644 index 00000000000..658ae15247c --- /dev/null +++ b/src/main/runtime/structured-tui-recovery-claim-match.ts @@ -0,0 +1,89 @@ +export type StructuredTuiRecoveryClaimMismatch = + | 'claim' + | 'connected' + | 'owner-phase' + | 'owner-pty-id' + | 'persisted-incarnation' + | 'persisted-pty-id' + | 'persisted-session' + | 'persisted-tab' + | 'presented-incarnation' + | 'pty-workspace' + | 'surface-workspace' + +export type StructuredTuiRecoveryClaimCandidate = { + expectedWorkspaceId: string + claimMatches: boolean + pty: { + connected: boolean + ptyId: string + incarnationId: string | null + worktreeId: string + } + owner: { + phase: string + ptyId: string + surface: { + worktreeId: string + tabId: string + leafId: string + } + } + persisted: { + sessionResolved: boolean + tabPresent: boolean + ptyId: string | null + incarnationId: string | null + } +} + +export type StructuredTuiRecoveryClaimEvaluation = { + matches: boolean + mismatchedFields: StructuredTuiRecoveryClaimMismatch[] +} + +export function evaluateStructuredTuiRecoveryClaim( + candidate: StructuredTuiRecoveryClaimCandidate, + worktreeIdsEqual: (left: string, right: string) => boolean = (left, right) => left === right +): StructuredTuiRecoveryClaimEvaluation { + const mismatchedFields: StructuredTuiRecoveryClaimMismatch[] = [] + if (!candidate.pty.connected) { + mismatchedFields.push('connected') + } + if (candidate.owner.phase !== 'live') { + mismatchedFields.push('owner-phase') + } + if (candidate.owner.ptyId !== candidate.pty.ptyId) { + mismatchedFields.push('owner-pty-id') + } + if (!candidate.pty.incarnationId) { + mismatchedFields.push('presented-incarnation') + } + if (!worktreeIdsEqual(candidate.pty.worktreeId, candidate.expectedWorkspaceId)) { + mismatchedFields.push('pty-workspace') + } + if (!worktreeIdsEqual(candidate.owner.surface.worktreeId, candidate.expectedWorkspaceId)) { + mismatchedFields.push('surface-workspace') + } + if (!candidate.claimMatches) { + mismatchedFields.push('claim') + } + if (!candidate.persisted.sessionResolved) { + mismatchedFields.push('persisted-session') + } else { + if (!candidate.persisted.tabPresent) { + mismatchedFields.push('persisted-tab') + } + if (candidate.persisted.ptyId !== candidate.owner.ptyId) { + mismatchedFields.push('persisted-pty-id') + } + // Packaged hydration can omit this binding briefly; daemon incarnation and child proof stay mandatory. + if ( + candidate.persisted.incarnationId !== null && + candidate.persisted.incarnationId !== candidate.pty.incarnationId + ) { + mismatchedFields.push('persisted-incarnation') + } + } + return { matches: mismatchedFields.length === 0, mismatchedFields } +} diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 330052b59e1..9019a10e9b5 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -4,8 +4,9 @@ import { randomUUID } from 'node:crypto' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence' import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' -import { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' +import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { HostCliUnavailableError, @@ -101,7 +102,7 @@ async function runLegacyRemoteOrcaCli( json: boolean, passthroughFailure: HostCliUnavailableError ): Promise { - const dispatcher = new RpcDispatcher({ runtime }) + const dispatcher = new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }) const help = getRemoteLinearHelp(parsed) if (help) { return { stdout: `${help}\n`, stderr: '', exitCode: 0 } diff --git a/src/main/ssh/ssh-remote-orchestration-post-output.ts b/src/main/ssh/ssh-remote-orchestration-post-output.ts index 5d833651350..3778ea2524d 100644 --- a/src/main/ssh/ssh-remote-orchestration-post-output.ts +++ b/src/main/ssh/ssh-remote-orchestration-post-output.ts @@ -4,6 +4,7 @@ import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { RpcResponse } from '../runtime/rpc/core' import { RpcDispatcher } from '../runtime/rpc/dispatcher' +import { ALL_RPC_METHODS } from '../runtime/rpc/methods' import type { RemoteOrcaCliPostOutput, RemoteOrcaCliRequest @@ -39,7 +40,7 @@ export async function acknowledgeRemoteOrcaCliPostOutput( answerMessageId: args.postOutput.answerMessageId }) } - const response = await new RpcDispatcher({ runtime }).dispatch({ + const response = await new RpcDispatcher({ runtime, methods: ALL_RPC_METHODS }).dispatch({ id: `remote-cli-post-output-${randomUUID()}`, authToken: 'remote-cli', method: 'orchestration.check', diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index af4c7d609ca..9c26b12292d 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -270,6 +270,20 @@ describe('startup ordering', () => { expect(disposeIndex).toBeGreaterThan(commitIndex) }) + it('joins structured agent sessions to the committed quit barrier', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + const willQuitStart = source.indexOf("app.on('will-quit'") + const willQuitEnd = source.indexOf("app.on('window-all-closed'", willQuitStart) + const willQuit = source.slice(willQuitStart, willQuitEnd) + + expect(willQuit).toContain( + 'const structuredAgentSessionShutdown = stopStructuredAgentSessionRuntime()' + ) + expect(willQuit).toContain( + "{ name: 'structured-agent-session', promise: structuredAgentSessionShutdown }" + ) + }) + it('joins agent-browser cleanup before the committed quit exits', () => { const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') const willQuitStart = source.indexOf("app.on('will-quit'") diff --git a/src/main/startup/hydrate-shell-path.ts b/src/main/startup/hydrate-shell-path.ts index 64409dbbb3e..766fda268c6 100644 --- a/src/main/startup/hydrate-shell-path.ts +++ b/src/main/startup/hydrate-shell-path.ts @@ -80,7 +80,7 @@ export function _resetHydrateShellPathCache(): void { windowsPathOwnership.reset() } -function pickShell(): string | null { +export function resolveProfileLoadingShell(): string | null { if (process.platform === 'win32') { const family = resolveWindowsShellStartupFamily(configuredWindowsShell) if (family === 'cmd') { @@ -93,12 +93,12 @@ function pickShell(): string | null { return basename === 'powershell.exe' || basename === 'pwsh.exe' ? configuredWindowsShell : null } const shell = process.env.SHELL - if (shell && shell.length > 0) { - return shell - } - return process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' + return shell?.length ? shell : process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash' } +export const resolveProfileLoadingFallbackShell = (): string | null => + configuredWindowsFallbackShell + function parseCapturedPath(stdout: string, pathDelimiter: string = delimiter): string[] { const cleaned = stdout.replace(ANSI_RE, '') const first = cleaned.indexOf(DELIMITER) @@ -289,7 +289,8 @@ export function hydrateShellPath(options: HydrateOptions = {}): Promise { - it('hydrates after bounded barriers while provider startup remains pending', async () => { + it('waits for daemon adoption before renderer recovery can continue', async () => { let resolveFirstWindow!: () => void let resolveWslBarrier!: () => void let resolveProvider!: () => void @@ -29,14 +34,15 @@ describe('legacy worker renderer recovery', () => { expect(reconcile).not.toHaveBeenCalled() resolveWslBarrier() - await startup - expect(reconcile).toHaveBeenCalledTimes(1) + await Promise.resolve() + expect(reconcile).not.toHaveBeenCalled() resolveProvider() - await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + await startup + expect(reconcile).toHaveBeenCalledOnce() }) - it('retries after initial recovery when the provider is already ready', async () => { + it('recovers once when the provider is already ready', async () => { const reconcile = vi.fn().mockResolvedValue(undefined) await recoverLegacyWorkerTerminalsForRendererStartup({ @@ -47,7 +53,7 @@ describe('legacy worker renderer recovery', () => { onDeferredRecoveryError: vi.fn() }) - await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + expect(reconcile).toHaveBeenCalledOnce() }) it('contains provider startup rejection after initial recovery', async () => { @@ -67,12 +73,12 @@ describe('legacy worker renderer recovery', () => { }) await expect(reportedError).resolves.toBe(providerError) - expect(reconcile).toHaveBeenCalledTimes(1) + expect(reconcile).not.toHaveBeenCalled() }) - it('contains deferred recovery rejection', async () => { + it('contains recovery rejection', async () => { const recoveryError = new Error('recovery failed') - const reconcile = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(recoveryError) + const reconcile = vi.fn().mockRejectedValueOnce(recoveryError) let reportError!: (error: unknown) => void const reportedError = new Promise((resolve) => { reportError = resolve @@ -87,31 +93,42 @@ describe('legacy worker renderer recovery', () => { }) await expect(reportedError).resolves.toBe(recoveryError) - expect(reconcile).toHaveBeenCalledTimes(2) + expect(reconcile).toHaveBeenCalledOnce() }) - it('contains initial recovery rejection and still retries when the provider becomes ready', async () => { - const initialError = new Error('initial recovery failed') - let resolveProvider!: () => void - const providerReady = new Promise((resolve) => { - resolveProvider = resolve - }) - const reconcile = vi.fn().mockRejectedValueOnce(initialError).mockResolvedValueOnce(undefined) - const onDeferredRecoveryError = vi.fn() - - await expect( - recoverLegacyWorkerTerminalsForRendererStartup({ - firstWindowStartupServicesReady: Promise.resolve(), + it('fails open at the hard cap without allowing a premature recovery', async () => { + vi.useFakeTimers() + let daemonSignal: AbortSignal | undefined + try { + const services = startFirstWindowStartupServices({ + startDaemonPtyProvider: (signal) => { + daemonSignal = signal + return new Promise(() => {}) + }, + startAgentHookServer: () => Promise.resolve(), + onDaemonError: vi.fn(), + onAgentHookServerError: vi.fn() + }) + const reconcile = vi.fn().mockResolvedValue(undefined) + const startup = recoverLegacyWorkerTerminalsForRendererStartup({ + firstWindowStartupServicesReady: services.firstWindowReady, managedWslCliStartupBarrierReady: Promise.resolve(), - localPtyProviderStartupReady: providerReady, + localPtyProviderStartupReady: services.localPtyProviderReady, reconcile, - onDeferredRecoveryError + onDeferredRecoveryError: vi.fn() }) - ).resolves.toBeUndefined() - expect(onDeferredRecoveryError).toHaveBeenCalledWith(initialError) - expect(reconcile).toHaveBeenCalledTimes(1) - resolveProvider() - await vi.waitFor(() => expect(reconcile).toHaveBeenCalledTimes(2)) + await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + expect(reconcile).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync( + LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS - FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS + ) + await startup + expect(reconcile).toHaveBeenCalledOnce() + expect(daemonSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/main/startup/legacy-worker-renderer-recovery.ts b/src/main/startup/legacy-worker-renderer-recovery.ts index fa149c189bd..4a24f107135 100644 --- a/src/main/startup/legacy-worker-renderer-recovery.ts +++ b/src/main/startup/legacy-worker-renderer-recovery.ts @@ -13,18 +13,15 @@ export async function recoverLegacyWorkerTerminalsForRendererStartup( () => ({ ok: true as const }), (error: unknown) => ({ ok: false as const, error }) ) - await Promise.all([ + const [providerResult] = await Promise.all([ + providerStartupResult, options.firstWindowStartupServicesReady, options.managedWslCliStartupBarrierReady ]) - void providerStartupResult - .then(async (result) => { - if (!result.ok) { - throw result.error - } - await options.reconcile() - }) - .catch(options.onDeferredRecoveryError) + if (!providerResult.ok) { + options.onDeferredRecoveryError(providerResult.error) + return + } try { await options.reconcile() } catch (error) { diff --git a/src/main/startup/login-shell-environment.test.ts b/src/main/startup/login-shell-environment.test.ts new file mode 100644 index 00000000000..33d7e2a7ddb --- /dev/null +++ b/src/main/startup/login-shell-environment.test.ts @@ -0,0 +1,86 @@ +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + resetLoginShellEnvironmentCacheForTests, + resolveLoginShellEnvironment +} from './login-shell-environment' + +const originalHome = process.env.HOME +const originalZdotdir = process.env.ZDOTDIR +const SHELL_ONLY_VARIABLE = 'ORCA_TEST_LOGIN_SHELL_ONLY' +const originalShellOnlyValue = process.env[SHELL_ONLY_VARIABLE] +let testHome: string | null = null + +// Why: this case spawns a REAL login shell, so it can only run against one the +// machine actually has. Hardcoding /bin/zsh made it fail on Linux CI, where zsh +// is not installed — the resolver simply returned the parent env and the +// assertion read `undefined`. Each entry pairs a shell with the profile file an +// interactive login shell of that family sources (bash reads .bash_profile when +// it is a login shell, never .bashrc). +const REAL_SHELL_CANDIDATES = [ + { path: '/bin/zsh', profileFile: '.zshenv' }, + { path: '/bin/bash', profileFile: '.bash_profile' } +] as const + +const realShell = REAL_SHELL_CANDIDATES.find((candidate) => existsSync(candidate.path)) ?? null + +afterEach(async () => { + resetLoginShellEnvironmentCacheForTests() + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = originalZdotdir + } + if (originalShellOnlyValue === undefined) { + delete process.env[SHELL_ONLY_VARIABLE] + } else { + process.env[SHELL_ONLY_VARIABLE] = originalShellOnlyValue + } + if (testHome) { + await rm(testHome, { recursive: true, force: true }) + testHome = null + } +}) + +describe('resolveLoginShellEnvironment', () => { + it('returns variables exported by the profile-loading shell', async () => { + const spawner = vi.fn(async () => ({ + ...process.env, + EXAMPLE_GATEWAY_TOKEN: 'shell-exported' + })) + + await expect( + resolveLoginShellEnvironment({ shellOverride: '/bin/zsh', spawner }) + ).resolves.toMatchObject({ EXAMPLE_GATEWAY_TOKEN: 'shell-exported' }) + }) + + it.runIf(realShell !== null)( + 'captures a profile export missing from the parent process', + async () => { + const shell = realShell! + testHome = await mkdtemp(join(tmpdir(), 'orca-login-shell-env-')) + await writeFile( + join(testHome, shell.profileFile), + `export ${SHELL_ONLY_VARIABLE}=shell-only\n` + ) + process.env.HOME = testHome + // zsh reads .zshenv from ZDOTDIR when set; harmless for the bash variant. + process.env.ZDOTDIR = testHome + delete process.env[SHELL_ONLY_VARIABLE] + + const environment = await resolveLoginShellEnvironment({ + shellOverride: shell.path, + force: true + }) + expect(environment[SHELL_ONLY_VARIABLE]).toBe('shell-only') + } + ) +}) diff --git a/src/main/startup/login-shell-environment.ts b/src/main/startup/login-shell-environment.ts new file mode 100644 index 00000000000..d456dff64c9 --- /dev/null +++ b/src/main/startup/login-shell-environment.ts @@ -0,0 +1,160 @@ +import { win32 as pathWin32 } from 'node:path' +import { spawnProcess } from '../../shared/child-process/run-process' +import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell' +import { + resolveProfileLoadingFallbackShell, + resolveProfileLoadingShell +} from './hydrate-shell-path' + +const START_MARKER = '__ORCA_LOGIN_SHELL_ENV_START__' +const END_MARKER = '__ORCA_LOGIN_SHELL_ENV_END__' +const SPAWN_TIMEOUT_MS = 5000 + +let cached: Promise | null = null +let cachedShellKey: string | null = null + +function processEnvironment(): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + ) +} + +function shellProbe(shell: string): string[] | null { + if (process.platform !== 'win32' || resolveWindowsShellStartupFamily(shell) === 'posix') { + const command = + `printf '\\0${START_MARKER}\\0'; /usr/bin/env -0; ` + `printf '\\0${END_MARKER}\\0'` + return ['-ilc', command] + } + const basename = pathWin32.basename(shell).toLowerCase() + if (basename !== 'powershell.exe' && basename !== 'pwsh.exe') { + return null + } + const command = + `$values = @{}; [Environment]::GetEnvironmentVariables().GetEnumerator() | ` + + `ForEach-Object { $values[[string]$_.Key] = [string]$_.Value }; ` + + `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); ` + + `[Console]::Write('${START_MARKER}'); ` + + `[Console]::Write(($values | ConvertTo-Json -Compress)); ` + + `[Console]::Write('${END_MARKER}')` + return ['-NoLogo', '-Command', command] +} + +function parsePosixEnvironment(output: Buffer): NodeJS.ProcessEnv | null { + const start = output.indexOf(Buffer.from(`\0${START_MARKER}\0`)) + const end = output.indexOf(Buffer.from(`\0${END_MARKER}\0`), start + START_MARKER.length + 2) + if (start === -1 || end === -1) { + return null + } + const bodyStart = start + START_MARKER.length + 2 + const entries = output.subarray(bodyStart, end).toString('utf8').split('\0') + const environment: NodeJS.ProcessEnv = {} + for (const entry of entries) { + const separator = entry.indexOf('=') + if (separator > 0) { + environment[entry.slice(0, separator)] = entry.slice(separator + 1) + } + } + return Object.keys(environment).length > 0 ? environment : null +} + +function parsePowerShellEnvironment(output: Buffer): NodeJS.ProcessEnv | null { + const text = output.toString('utf8') + const start = text.indexOf(START_MARKER) + const end = text.indexOf(END_MARKER, start + START_MARKER.length) + if (start === -1 || end === -1) { + return null + } + try { + const parsed = JSON.parse(text.slice(start + START_MARKER.length, end)) as unknown + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const entries = Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + return entries.length > 0 ? Object.fromEntries(entries) : null + } catch { + return null + } +} + +function spawnShellAndReadEnvironment(shell: string): Promise { + const args = shellProbe(shell) + if (!args) { + return Promise.resolve(null) + } + return new Promise((resolve) => { + let settled = false + const chunks: Buffer[] = [] + const child = spawnProcess({ program: shell, args, env: process.env }) + const finish = (value: NodeJS.ProcessEnv | null): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + resolve(value) + } + const timer = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // Best-effort timeout cleanup. + } + finish(null) + }, SPAWN_TIMEOUT_MS) + child.stdin.on('error', () => {}) + child.stdout.on('error', () => finish(null)) + child.stderr.on('error', () => {}) + child.stdin.end() + child.stderr.resume() + child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) + child.on('error', () => finish(null)) + child.on('close', () => { + const output = Buffer.concat(chunks) + finish( + process.platform === 'win32' && resolveWindowsShellStartupFamily(shell) !== 'posix' + ? parsePowerShellEnvironment(output) + : parsePosixEnvironment(output) + ) + }) + }) +} + +export type ResolveLoginShellEnvironmentOptions = { + force?: boolean + shellOverride?: string | null + spawner?: (shell: string) => Promise +} + +/** Resolves the environment seen by commands launched from Orca's profile-loading terminal shell. */ +export function resolveLoginShellEnvironment( + options: ResolveLoginShellEnvironmentOptions = {} +): Promise { + const shell = + options.shellOverride !== undefined ? options.shellOverride : resolveProfileLoadingShell() + const fallback = options.shellOverride === undefined ? resolveProfileLoadingFallbackShell() : null + const shellKey = `${shell ?? ''}\0${fallback ?? ''}` + if (cached && cachedShellKey === shellKey && !options.force) { + return cached + } + if (!shell) { + return Promise.resolve(processEnvironment()) + } + const spawner = options.spawner ?? spawnShellAndReadEnvironment + cachedShellKey = shellKey + cached = spawner(shell) + .then(async (environment) => { + if (environment) { + return environment + } + return fallback ? ((await spawner(fallback)) ?? processEnvironment()) : processEnvironment() + }) + .catch(() => processEnvironment()) + return cached +} + +export function resetLoginShellEnvironmentCacheForTests(): void { + cached = null + cachedShellKey = null +} diff --git a/src/main/startup/secret-protection-report-deferral-wiring.test.ts b/src/main/startup/secret-protection-report-deferral-wiring.test.ts new file mode 100644 index 00000000000..eda9721667f --- /dev/null +++ b/src/main/startup/secret-protection-report-deferral-wiring.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guards the one line that decides whether the OS keyring gates the first window. + * + * The report is diagnostics nothing on the startup path reads, but `describeProtectionGap()` + * is a blocking D-Bus round trip on Linux, and a present-but-locked keyring never answers — + * 76s to first window on Ubuntu 24.04 (STA-5765). Both directions of the one flag here are + * silent: `true` gives headless serve a deferral it must never have (serve opens no window, + * so only the fallback fires, after clients may have paired, and a frozen main thread reads + * as a dead host); `false` puts the blocking probe back in front of the window. Deleting the + * call entirely restores the original regression. + * + * Source-level because that is the property: this runs once inside `app.whenReady()` during + * startup, so there is no seam to assert against at runtime. + */ +describe('secret protection report deferral wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + const SCHEDULE = 'scheduleSecretProtectionGapReport({' + + it('arms the deferred report exactly once and never calls the blocking one directly', () => { + expect(source.split(SCHEDULE).length - 1, `${SCHEDULE} should appear exactly once`).toBe(1) + expect(source).toContain( + "import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'" + ) + // Why also assert the absence: re-importing the blocking entry point reinstates the + // pre-window probe without touching the call site the next test pins. Note the scheduling + // name is `...GapReport(`, so it does not match this substring. + expect(source.split('reportSecretProtectionGap(').length - 1).toBe(0) + }) + + it('defers on desktop and reports inline in headless serve', () => { + const start = source.indexOf(SCHEDULE) + // Why bound every anchor: an unresolved one is -1, and the slice below would then run to + // EOF and pass against unrelated code. + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('\n })', start) + expect(end).toBeGreaterThan(start) + // Why bound the length too: `end` is the next call-shaped close at this indent, not + // necessarily this call's. Nest the call one level deeper and that anchor overshoots into + // unrelated code, so the assertions below pass against a call site that never runs. + expect(end - start).toBeLessThan(500) + const call = source.slice(start, end) + + // Why anchor the indent: `SCHEDULE` matches anywhere, including as the body of an added + // `if (...) schedule(...)` guard, which leaves every assertion here true while the call + // stops running unconditionally. Pinning it as a statement at whenReady's own indent is + // what makes "this runs on every desktop startup" the thing under test. + expect(source).toContain(`\n ${SCHEDULE}`) + + expect(call).toContain('deferUntilFirstWindow: !isServeMode') + // Why assert the constants are absent too: `!isServeMode` being present does not stop a + // later property in the same literal from overriding it. + expect(call).not.toContain('deferUntilFirstWindow: true') + expect(call).not.toContain('deferUntilFirstWindow: false') + }) + + it('arms the report after the profile exists and inside app readiness', () => { + // Why: the report remembers what it last said beside the profile data file, so arming it + // before the profile is resolved would key the state off a path that does not exist yet. + // Anchored on code, never a comment — a reworded comment silently becomes -1. + const ready = source.indexOf('app.whenReady().then(') + const profile = source.indexOf('const activeOrcaProfile = ensureActiveOrcaProfile()') + const schedule = source.indexOf(SCHEDULE) + + expect(ready).toBeGreaterThanOrEqual(0) + expect(profile).toBeGreaterThan(ready) + expect(schedule).toBeGreaterThan(profile) + }) +}) diff --git a/src/main/text-generation/source-control-agent-launch.ts b/src/main/text-generation/source-control-agent-launch.ts index 7468b2215ad..8587497d318 100644 --- a/src/main/text-generation/source-control-agent-launch.ts +++ b/src/main/text-generation/source-control-agent-launch.ts @@ -3,7 +3,10 @@ import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' import { resolveCliCommand } from '../codex-cli/command' import { wslAwareSpawn } from '../git/runner' import { getSpawnArgsForWindows } from '../win32-utils' -import type { SpawnSourceControlAgent } from './source-control-text-generation-types' +import type { + SpawnedSourceControlAgentProcess, + SpawnSourceControlAgent +} from './source-control-text-generation-types' const WSL_LAUNCHER_ENV_KEYS = [ 'ComSpec', @@ -36,6 +39,7 @@ function buildWslLauncherEnv(explicitEnv: NodeJS.ProcessEnv | undefined): NodeJS export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { const spawnEnv = input.env ?? process.env if (process.platform === 'win32' && input.wslDistro) { + // Same contract as spawnProcess: stdout/stderr are piped; stdin matches stdinMode. return wslAwareSpawn(input.binary, input.args, { cwd: input.cwd, env: buildWslLauncherEnv(input.env), @@ -43,7 +47,7 @@ export const spawnSourceControlAgent: SpawnSourceControlAgent = (input) => { windowsHide: true, wslDistro: input.wslDistro, useWslLoginShell: true - }) + }) as SpawnedSourceControlAgentProcess } const resolvedBinary = process.platform === 'win32' diff --git a/src/main/windows-pty-root-identity.test.ts b/src/main/windows-pty-root-identity.test.ts index 8ce8e76b63c..3614f639471 100644 --- a/src/main/windows-pty-root-identity.test.ts +++ b/src/main/windows-pty-root-identity.test.ts @@ -175,7 +175,7 @@ describe('verifyWindowsTreeKillTarget scan volume', () => { cb(withSelf(NATIVE_ROWS)) }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: getAllProcessesMock })) }) diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 8bbd0ed7ccb..609de009820 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -4,6 +4,7 @@ import { __setWindowsProcessTreeLoaderForTests, __setWindowsProcessTreeRequireForTests, isWindowsProcessTableAvailable, + isWindowsProcessStartTimeAvailable, readWindowsProcessTable, readWindowsProcessTableFresh, resetWindowsProcessTableForTests @@ -17,7 +18,14 @@ const getAllProcesses = vi.fn() const SELF = { pid: process.pid, ppid: 0, name: 'vitest.exe' } const NATIVE = [ SELF, - { pid: 100, ppid: 4, name: 'orca.exe', commandLine: '"C:/a b/orca.exe" --x', memory: 4096 } + { + pid: 100, + ppid: 4, + name: 'orca.exe', + commandLine: '"C:/a b/orca.exe" --x', + memory: 4096, + creationTimeMs: 1_700_000_000_000 + } ] describe('windows process table', () => { @@ -29,7 +37,7 @@ describe('windows process table', () => { platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) }) @@ -50,14 +58,24 @@ describe('windows process table', () => { ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096 + memoryBytes: 4096, + creationTimeMs: 1_700_000_000_000 } ]) }) it('requests memory and command line together', async () => { await readWindowsProcessTableFresh() - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(3) + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7) + }) + + it('only advertises PID-safe ownership when the native creation-time field exists', () => { + expect(isWindowsProcessStartTimeAvailable()).toBe(true) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + getAllProcesses + })) + expect(isWindowsProcessStartTimeAvailable()).toBe(false) }) it('serves repeat reads from the shared snapshot', async () => { @@ -208,7 +226,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -230,7 +248,7 @@ describe('sticky wedge', () => { vi.useFakeTimers() const getAllProcesses = vi.fn(() => {}) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -316,7 +334,7 @@ describe('sticky wedge', () => { throw new Error('addon exploded') }) __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses })) @@ -325,7 +343,7 @@ describe('sticky wedge', () => { // The recovered reader must answer, not report a wedge left by a dead timer. __setWindowsProcessTreeLoaderForTests(() => ({ - ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }, + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, getAllProcesses: (cb: (rows: typeof NATIVE | undefined) => void) => cb(NATIVE) })) await expect(readWindowsProcessTableFresh()).resolves.toHaveLength(NATIVE.length) @@ -388,7 +406,8 @@ describe('resolving the native reader', () => { ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096 + memoryBytes: 4096, + creationTimeMs: 1_700_000_000_000 } ]) expect(isWindowsProcessTableAvailable()).toBe(true) diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 35dab09d100..9308c64a9f0 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -33,6 +33,8 @@ export type WindowsProcessRow = { command: string /** Working set in bytes, or undefined when not requested/queryable. */ memoryBytes?: number + /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ + creationTimeMs?: number } type NativeProcessInfo = { @@ -41,10 +43,16 @@ type NativeProcessInfo = { name: string memory?: number commandLine?: string + creationTimeMs?: number } type WindowsProcessTreeModule = { - ProcessDataFlag: { None: number; Memory: number; CommandLine: number } + ProcessDataFlag: { + None: number + Memory: number + CommandLine: number + CreationTime?: number + } getAllProcesses: ( callback: (processes: NativeProcessInfo[] | undefined) => void, flags?: number @@ -188,7 +196,10 @@ function readNativeRows(): Promise { // one snapshot so a 32-wide teardown collapses into a single scan, and that // snapshot has to satisfy every caller. Splitting the cache per field set // would restore exactly the fan-out it exists to prevent. - const flags = native.ProcessDataFlag.Memory | native.ProcessDataFlag.CommandLine + const flags = + native.ProcessDataFlag.Memory | + native.ProcessDataFlag.CommandLine | + (native.ProcessDataFlag.CreationTime ?? 0) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -230,7 +241,10 @@ function readNativeRows(): Promise { ppid: row.ppid, name: row.name, command: row.commandLine ?? '', - memoryBytes: row.memory + memoryBytes: row.memory, + ...(typeof row.creationTimeMs === 'number' + ? { creationTimeMs: row.creationTimeMs } + : {}) })) ) }, flags) @@ -284,6 +298,17 @@ export function isWindowsProcessTableAvailable(): boolean { return moduleLoader() !== null } +/** + * PID-reuse-safe ownership needs the native creation-time field, not merely a + * process list. Older addon builds expose the table without that field; keep + * structured ownership unavailable on those hosts instead of fabricating proof + * from a PID. + */ +export function isWindowsProcessStartTimeAvailable(): boolean { + const native = moduleLoader() + return native !== null && typeof native.ProcessDataFlag.CreationTime === 'number' +} + /** * Test-only: substitute the native module. * diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 1e5e9f013fb..88cb86dc32b 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -38,6 +38,8 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ + prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ recoverLegacyWorkerTerminalsForRendererStartup: () => Promise /** Emits a startup benchmark marker when ORCA_STARTUP_DIAGNOSTICS is enabled. */ diff --git a/src/preload/api/gitlab-api.ts b/src/preload/api/gitlab-api.ts index dd3f0fdc689..3f034573deb 100644 --- a/src/preload/api/gitlab-api.ts +++ b/src/preload/api/gitlab-api.ts @@ -27,6 +27,8 @@ export type GitLabRepoSelectorArgs = { repoPath: string repoId?: string | null sourceContext?: TaskSourceContext | null + /** Desktop IPC-only owner guard; web adapters remove it before runtime RPC. */ + repoOwnerExecutionHostId?: string } // ── GitLab — parallel to gh, MR/issue surface only in v1 ──────── diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index 25907848e3c..ae8db3cedf5 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -26,6 +26,10 @@ export type RuntimeApi = { ) => Promise getStatus: () => Promise call: (args: { method: string; params?: unknown }) => Promise> + subscribe: ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ) => Promise getTerminalFitOverrides: () => Promise< { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 88a5246fe26..c3eefb02115 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -35,6 +35,10 @@ import type { export type UiCommandEventApi = { get: () => Promise set: (args: Partial) => Promise + /** Like set, but REJECTS when the update did not reach the host (the web preload's set + * swallows transport failures for offline use). The diff writer needs the distinction: + * folding an unacked patch into its baseline would silently stop retrying it (STA-5781). */ + setWithAck?: (args: Partial) => Promise recordFeatureInteraction: (id: FeatureInteractionId) => Promise onStateChanged: (callback: (ui: PersistedUIState) => void) => () => void onOpenSettings: (callback: () => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 46a392d4e34..56b34e6fd45 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -31,6 +31,7 @@ import type { TerminalPreviewConnectResult, TerminalPreviewDataPayload } from '../shared/terminal-preview' +import type { AgentSessionPtyWriteRefusal } from '../shared/agent-session-pty-write-admission' import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { CodexConfigSyncStatus } from '../shared/codex-config-sync-types' @@ -591,6 +592,8 @@ const api = { }, awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + prepareTerminalStartupRestoration: (): Promise => + ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => ipcRenderer.invoke('app:recoverLegacyWorkerTerminalsForRendererStartup'), startupDiagnostic: (event: string, details?: Record): Promise => @@ -1085,9 +1088,17 @@ const api = { }, writeAccepted: (id: string, data: string): Promise => ipcRenderer.invoke('pty:writeAccepted', { id, data }), - onWriteUnavailable: (callback: (payload: { id: string }) => void): (() => void) => { - const handler = (_event: Electron.IpcRendererEvent, payload: { id: string }): void => - callback(payload) + onWriteUnavailable: ( + callback: (payload: { + id: string + /** Set only when a durable agent-session lease refused the write; absent otherwise. */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal + }) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + payload: { id: string; agentSessionRefusal?: AgentSessionPtyWriteRefusal } + ): void => callback(payload) ipcRenderer.on('pty:writeUnavailable', handler) return () => ipcRenderer.removeListener('pty:writeUnavailable', handler) }, @@ -3798,6 +3809,8 @@ const api = { ui: { get: () => ipcRenderer.invoke('ui:get'), set: (args) => ipcRenderer.invoke('ui:set', args), + // Same channel: the local invoke already rejects when main fails to apply. + setWithAck: (args) => ipcRenderer.invoke('ui:set', args), recordFeatureInteraction: (id) => ipcRenderer.invoke('ui:recordFeatureInteraction', id), onStateChanged: (callback: (ui: PersistedUIState) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, ui: PersistedUIState): void => @@ -4618,6 +4631,31 @@ const api = { getStatus: (): Promise => ipcRenderer.invoke('runtime:getStatus'), call: (args: { method: string; params?: unknown }): Promise> => ipcRenderer.invoke('runtime:call', args), + subscribe: async ( + args: { method: string; params?: unknown }, + callback: (response: RuntimeRpcResponse) => void + ): Promise => { + const subscriptionId = `desktop-${crypto.randomUUID()}` + const channel = `runtime:subscription:${subscriptionId}` + const listener = (_event: Electron.IpcRendererEvent, response: RuntimeRpcResponse) => + callback(response) + ipcRenderer.on(channel, listener) + try { + await ipcRenderer.invoke('runtime:subscribe', { subscriptionId, ...args }) + } catch (error) { + ipcRenderer.removeListener(channel, listener) + throw error + } + return { + unsubscribe: () => { + ipcRenderer.removeListener(channel, listener) + ipcRenderer.send('runtime:unsubscribe', { subscriptionId }) + }, + sendBinary: () => { + throw new Error('Local runtime subscriptions do not accept binary input') + } + } + }, getTerminalFitOverrides: (): Promise< { ptyId: string; mode: 'mobile-fit' | 'remote-desktop-fit'; cols: number; rows: number }[] > => ipcRenderer.invoke('runtime:getTerminalFitOverrides'), diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index 4740481e34f..014b3f69bca 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -6,6 +6,7 @@ import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGa import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner' import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' import { useAppStore } from '../store' +import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge' const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) @@ -33,6 +34,7 @@ export function AppBackgroundServices(): React.JSX.Element { ) : null} + ) } diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index 68db2874593..e969609c268 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -15,6 +15,7 @@ import { useRadixBodyPointerEventsRecovery } from '../hooks/useRadixBodyPointerE import { useGitStatusPolling } from '../components/right-sidebar/useGitStatusPolling' import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/osc52-clipboard-default-on-notice' import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' +import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' /** @@ -31,6 +32,7 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useRadixBodyPointerEventsRecovery() useWebSessionTabsSync() + useLocalStructuredSessionTabsSync() // Subscribe to IPC push events useIpcEvents() useRemoteRuntimeRecoveryTriggers() diff --git a/src/renderer/src/app-shell/use-app-startup-actions.ts b/src/renderer/src/app-shell/use-app-startup-actions.ts new file mode 100644 index 00000000000..df7cec71ab1 --- /dev/null +++ b/src/renderer/src/app-shell/use-app-startup-actions.ts @@ -0,0 +1,39 @@ +// The renderer boot chain's store subscription, kept apart from the chain itself +// so one useShallow equality check covers every startup action. + +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../store' + +export function useStartupActions() { + // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. + return useAppStore( + useShallow((s) => ({ + fetchReposForAllHosts: s.fetchReposForAllHosts, + awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, + fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, + fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, + fetchAllWorktrees: s.fetchAllWorktrees, + fetchWorktrees: s.fetchWorktrees, + fetchWorktreeLineage: s.fetchWorktreeLineage, + fetchOrcaProfiles: s.fetchOrcaProfiles, + fetchSettings: s.fetchSettings, + awaitOwnerWorktreeVisibilityDefaultsHydration: + s.awaitOwnerWorktreeVisibilityDefaultsHydration, + fetchKeybindings: s.fetchKeybindings, + initGitHubCache: s.initGitHubCache, + hydrateWorkspaceSession: s.hydrateWorkspaceSession, + hydrateTabsSession: s.hydrateTabsSession, + hydrateEditorSession: s.hydrateEditorSession, + hydrateBrowserSession: s.hydrateBrowserSession, + fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, + reconnectPersistedTerminals: s.reconnectPersistedTerminals, + setTerminalStartupRestorationReady: s.setTerminalStartupRestorationReady, + setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, + setSshConnectionState: s.setSshConnectionState, + hydratePersistedUI: s.hydratePersistedUI, + setHydrationSucceeded: s.setHydrationSucceeded, + pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, + seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing + })) + ) +} diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 515f084708c..091039fb793 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -1,8 +1,8 @@ import { useEffect, useRef } from 'react' -import { useShallow } from 'zustand/react/shallow' import { syncZoomCSSVar } from '@/lib/ui-zoom' import { installCodexDetachedPaneRestartExecutor } from '@/components/terminal-pane/codex-detached-pane-restart-scheduler' import { useAppStore } from '../store' +import { useStartupActions } from './use-app-startup-actions' import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees' import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep' import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence' @@ -33,6 +33,7 @@ import { } from '../../../shared/execution-host' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { OnboardingState } from '../../../shared/onboarding-state-types' +import { restoreLocalStructuredSessionTabsOnce } from '../runtime/local-structured-session-tabs-sync' async function listRuntimeSessionHostIdsForStartup(): Promise { try { @@ -45,39 +46,6 @@ async function listRuntimeSessionHostIdsForStartup(): Promise } } -function useStartupActions() { - // Why: consolidate action refs into one useShallow subscription so React runs one equality check per store mutation instead of one per action. - return useAppStore( - useShallow((s) => ({ - fetchReposForAllHosts: s.fetchReposForAllHosts, - awaitLocalRepoCatalogSettlement: s.awaitLocalRepoCatalogSettlement, - fetchProjectGroupsForAllHosts: s.fetchProjectGroupsForAllHosts, - fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts, - fetchAllWorktrees: s.fetchAllWorktrees, - fetchWorktrees: s.fetchWorktrees, - fetchWorktreeLineage: s.fetchWorktreeLineage, - fetchOrcaProfiles: s.fetchOrcaProfiles, - fetchSettings: s.fetchSettings, - awaitOwnerWorktreeVisibilityDefaultsHydration: - s.awaitOwnerWorktreeVisibilityDefaultsHydration, - fetchKeybindings: s.fetchKeybindings, - initGitHubCache: s.initGitHubCache, - hydrateWorkspaceSession: s.hydrateWorkspaceSession, - hydrateTabsSession: s.hydrateTabsSession, - hydrateEditorSession: s.hydrateEditorSession, - hydrateBrowserSession: s.hydrateBrowserSession, - fetchBrowserSessionProfiles: s.fetchBrowserSessionProfiles, - reconnectPersistedTerminals: s.reconnectPersistedTerminals, - setDeferredSshReconnectTargets: s.setDeferredSshReconnectTargets, - setSshConnectionState: s.setSshConnectionState, - hydratePersistedUI: s.hydratePersistedUI, - setHydrationSucceeded: s.setHydrationSucceeded, - pruneLastVisitedTimestamps: s.pruneLastVisitedTimestamps, - seedActiveWorktreeLastVisitedIfMissing: s.seedActiveWorktreeLastVisitedIfMissing - })) - ) -} - /** * Runs the renderer's one-shot boot chain: settings, persisted UI, the local repo catalog, * the workspace session, SSH reconnect, and terminal restoration — then unlocks the session @@ -226,6 +194,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) }) + await timeRendererStartupStep('prepare-terminal-startup-restoration', () => + window.api.app.prepareTerminalStartupRestoration() + ) + if (cancelled) { + return + } // Why: prune visit timestamps AFTER hydration (earlier, worktreesByRepo may be empty and prune would drop entries for worktrees about to appear); seed the active worktree if missing. // See docs/cmd-j-empty-query-ordering.md. timeRendererStartupSyncStep('visit-timestamp-prune', () => { @@ -275,12 +249,19 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta await timeRendererStartupStep('recover-legacy-worker-terminals-post-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) + await timeRendererStartupStep('project-structured-session-tabs', () => + restoreLocalStructuredSessionTabsOnce() + ) + if (cancelled) { + return + } // Why here: reconnect just published restored PTY ids; sweeping them now // re-offers stale Codex panes whose tabs never mount this session. sweepRestoredCodexPanesForStaleAccounts(useAppStore.getState()) syncZoomCSSVar() // Why (issue #1158): unlock the session writer only after hydration and all dependent steps succeeded, so a mid-startup throw can't serialize partially-mutated state to disk. actions.setHydrationSucceeded(true) + actions.setTerminalStartupRestorationReady(true) logRendererStartupDiagnostic('startup-hydration-done', { durationMs: Math.round(performance.now() - startupStartedAt) }) diff --git a/src/renderer/src/app-shell/use-persisted-ui-writer.ts b/src/renderer/src/app-shell/use-persisted-ui-writer.ts index 30b82da76b3..f16346b25fb 100644 --- a/src/renderer/src/app-shell/use-persisted-ui-writer.ts +++ b/src/renderer/src/app-shell/use-persisted-ui-writer.ts @@ -1,10 +1,94 @@ import { useEffect } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../store' +import { + capturePersistedUIWriteBaseline, + diffPersistedUIWriteFields, + persistedUIWriteFieldsToWireUpdate, + type PersistedUIWriteBaseline +} from '../store/slices/persisted-ui-write-baseline' + +/** + * Send one field patch and settle it against the baseline. Fields are marked + * in flight so a hydration during the round-trip can't revert a newer local + * flip-back; on ack the patch folds into the baseline (generation-guarded: a + * hydration during the round trip wins instead) and a debounced trailing + * flush re-diffs the mirror — an edit made while the write was in flight, + * which diffed empty against the pre-fold baseline, is re-sent then. A + * rejected write folds nothing, leaving its fields dirty to re-flush on the + * next change (no automatic retry loop) — which is why this sends through + * setWithAck: the web preload's plain set swallows transport failures. + */ +function sendPersistedUIWrite(changed: Partial): void { + const fields = Object.keys(changed) as (keyof PersistedUIWriteBaseline)[] + const state = useAppStore.getState() + const sentAtGeneration = state.persistedUIWriteBaselineGeneration + state.notePersistedUIWriteStarted(fields) + let request: Promise + try { + // setWithAck rejects when the host did not apply the patch (web's plain set + // swallows transport failures); older preloads without it fall back to set. + const send = window.api.ui.setWithAck ?? window.api.ui.set + request = send(persistedUIWriteFieldsToWireUpdate(changed)) + } catch { + // A synchronous throw (e.g. a non-cloneable value) must still settle the + // in-flight marker, or the field stays pinned against hydration forever. + useAppStore.getState().notePersistedUIWriteSettled(fields, null) + scheduleTrailingPersistedUIFlush() + return + } + // Two-arg then: the rejection handler must not catch throws from the ack + // handler, which would double-settle these fields and leak the trailing ones. + request.then( + () => { + useAppStore.getState().notePersistedUIWriteSettled(fields, changed, { sentAtGeneration }) + scheduleTrailingPersistedUIFlush() + }, + () => { + useAppStore.getState().notePersistedUIWriteSettled(fields, null) + // A trailing pass skipped because THIS write was in flight must still + // happen — a rejection schedules nothing on its own, and a pending + // flip-back would otherwise be stranded until the next edit. + scheduleTrailingPersistedUIFlush() + } + ) +} + +/** + * Debounced (never inline at ack rate — one in-flight write must not turn a + * drag into one ui.set per IPC round trip) re-diff of the mirror against the + * settled baseline. Skips while any write is still in flight: that write's + * own ack schedules the next pass, so overlapping timers can't double-send. + * Termination invariant: each acked write folds exactly the values it diffed + * (or a hydration advances the baseline), so the trailing diff shrinks to + * empty; without the fold this would loop. + */ +function scheduleTrailingPersistedUIFlush(): void { + window.setTimeout(() => { + const state = useAppStore.getState() + if (Object.keys(state.persistedUIWriteInFlightCounts).length > 0) { + return + } + const baseline = state.persistedUIWriteBaseline + if (!baseline) { + return + } + const trailing = diffPersistedUIWriteFields(capturePersistedUIWriteBaseline(state), baseline) + if (Object.keys(trailing).length > 0) { + sendPersistedUIWrite(trailing) + } + }, 150) +} /** * Mirrors the sidebar/right-sidebar/filter preferences into the durable UI file. * + * Why field-level diffs (STA-5781): the durable UI state is shared with mobile/web + * clients, which edit it concurrently. Writing the whole snapshot let this client's + * stale mirror overwrite fields another client had just changed. The writer now + * diffs the mirror against the last state hydrated from main and persists only the + * fields this client changed itself; main merges partial updates field-by-field. + * * Why (#9002): activeView is deliberately kept off this debounced writer. It used to ride the * same 150ms save (#8265), so every top-level view switch scheduled a full durable-state write. */ @@ -12,48 +96,63 @@ export function usePersistedUIWriter(): void { const persistedUIReady = useAppStore((s) => s.persistedUIReady) const activeView = useAppStore((s) => s.activeView) const ui = useAppStore( - useShallow((s) => ({ - sidebarWidth: s.sidebarWidth, - rightSidebarOpen: s.rightSidebarOpen, - rightSidebarTab: s.rightSidebarTab, - rightSidebarExplorerView: s.rightSidebarExplorerView, - rightSidebarWidth: s.rightSidebarWidth, - markdownTocPanelWidth: s.markdownTocPanelWidth, - combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth, - groupBy: s.groupBy, - sortBy: s.sortBy, - projectOrderBy: s.projectOrderBy, - showSleepingWorkspaces: s.showSleepingWorkspaces, - hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace, - hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces, - hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces, - hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces, - hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices, - alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace, - showDotfilesByWorktree: s.showDotfilesByWorktree, - filterRepoIds: s.filterRepoIds, - // Why: rides the same debounced save so dashboard auto-acks (which fire on focus/ - // visibility) and the in-memory ack cleanup paths in agent-status.ts (close/dismiss) - // both flow to disk through map identity changes. Without persisting, agent rows that - // survive restart come back bold even when the user had already visited them. - acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey - })) + useShallow( + (s): PersistedUIWriteBaseline => ({ + sidebarWidth: s.sidebarWidth, + rightSidebarOpen: s.rightSidebarOpen, + rightSidebarTab: s.rightSidebarTab, + rightSidebarExplorerView: s.rightSidebarExplorerView, + rightSidebarWidth: s.rightSidebarWidth, + markdownTocPanelWidth: s.markdownTocPanelWidth, + combinedDiffFileTreeWidth: s.combinedDiffFileTreeWidth, + groupBy: s.groupBy, + sortBy: s.sortBy, + projectOrderBy: s.projectOrderBy, + showSleepingWorkspaces: s.showSleepingWorkspaces, + hideDefaultBranchWorkspace: s.hideDefaultBranchWorkspace, + hideAutomationGeneratedWorkspaces: s.hideAutomationGeneratedWorkspaces, + hideCliCreatedWorkspaces: s.hideCliCreatedWorkspaces, + hideDetachedHeadWorkspaces: s.hideDetachedHeadWorkspaces, + hideWorkspacesFromOtherDevices: s.hideWorkspacesFromOtherDevices, + alwaysShowDefaultBranchWorkspace: s.alwaysShowDefaultBranchWorkspace, + showDotfilesByWorktree: s.showDotfilesByWorktree, + filterRepoIds: s.filterRepoIds, + // Why: dashboard auto-acks (fire on focus/visibility) and the in-memory ack cleanup + // paths in agent-status.ts (close/dismiss) flow to disk through map identity changes. + // Without persisting, agent rows that survive restart come back bold even when the + // user had already visited them. + acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey + }) + ) ) - useEffect(() => { - if (!persistedUIReady) { + // The baseline holds the values this client last saw persisted (newest + // hydration from main, overlaid with this client's flushed writes); fields + // equal to it are never written, so remote changes are never echoed back. + // Read via getState, not a selector: baseline identity changes on every + // broadcast, and subscribing would re-render and re-arm the debounce on + // remote traffic that changed nothing this writer owns. + const armBaseline = useAppStore.getState().persistedUIWriteBaseline + if (!persistedUIReady || !armBaseline) { + return + } + if (Object.keys(diffPersistedUIWriteFields(ui, armBaseline)).length === 0) { return } - const timer = window.setTimeout(() => { - void window.api.ui.set({ - ...ui, - showActiveOnly: false, - hideSleepingWorkspaces: !ui.showSleepingWorkspaces, - // Why: the store keeps this readonly for identity stability, but PersistedUI crosses to - // main, which owns a mutable array — copy at the boundary rather than widening the wire type. - filterRepoIds: [...ui.filterRepoIds] - }) + // Re-diff against the store at fire time: a broadcast landing inside the + // debounce window may have refreshed the baseline (its identity is + // deliberately NOT an effect dep, so remote traffic can't starve the timer). + const state = useAppStore.getState() + const baseline = state.persistedUIWriteBaseline + if (!baseline) { + return + } + const changed = diffPersistedUIWriteFields(ui, baseline) + if (Object.keys(changed).length === 0) { + return + } + sendPersistedUIWrite(changed) }, 150) return () => window.clearTimeout(timer) diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx new file mode 100644 index 00000000000..839b74f73d4 --- /dev/null +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -0,0 +1,633 @@ +// @vitest-environment happy-dom +// STA-5781: workspace sidebar filter settings intermittently reset across clients. +// +// Topology under test (all shipping code except where pinned): +// desktop renderer = real usePersistedUIWriter + real UI slice (createUIStore) +// authority (main) = real updatePersistedUI/getPersistedUI merge +// broadcast = controlled queue modeling the async ui:stateChanged IPC send +// mobile client = mobile/src/worktree/workspace-view-settings mapping; the ui.set +// payload uses the shipping buildWorkspaceViewSettingsUpdate when +// exported, else the legacy whole-snapshot shape — the source-pin +// test asserts index.tsx matches whichever path is active, so the +// model stays tethered to shipping code on baseline and candidate. +// +// Invariant: when two independently identified clients change DISJOINT workspace-view +// fields concurrently or across stale-mirror windows, both changes survive and all +// mirrors converge; no client may restore a stale sibling field. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { StrictMode, act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StoreApi } from 'zustand/vanilla' +import { getDefaultUIState } from '../../../shared/constants' +import { omitPairingLocalUiFields } from '../../../shared/pairing-local-ui-fields' +import type { PersistedUIState } from '../../../shared/persisted-ui-state-types' +import type { PersistedState } from '../../../shared/persisted-state-types' +import { getPersistedUI } from '../../../main/persistence/applying-settings/ui-state-read' +import { + updatePersistedUI, + type UIUpdateOperations +} from '../../../main/persistence/applying-settings/ui-state-update' +import type { AppState } from '../store/types' +import { createUIStore } from '../store/slices/ui-slice-test-harness' +import { usePersistedUIWriter } from './use-persisted-ui-writer' + +const storeRef = vi.hoisted(() => ({ + current: null as unknown as StoreApi +})) + +vi.mock('../store', async () => { + const { useStore } = await import('zustand') + const useAppStore = (selector: (s: unknown) => unknown) => useStore(storeRef.current, selector) + useAppStore.getState = () => storeRef.current.getState() + useAppStore.setState = (partial: never) => storeRef.current.setState(partial) + useAppStore.subscribe = (listener: never) => storeRef.current.subscribe(listener) + return { useAppStore } +}) + +/** The main-process authority: the real ui.set merge over a real PersistedState. */ +function createAuthority() { + const state = { ui: { ...getDefaultUIState() } } as PersistedState + let activeView: PersistedState['ui']['activeView'] = 'terminal' + const listeners = new Set<(ui: PersistedUIState) => void>() + const operations: UIUpdateOperations = { + state, + removeRetainedBlob: () => {}, + setActiveView: (next) => { + if (next === undefined || next === activeView) { + return false + } + activeView = next + return true + }, + getUI: () => getPersistedUI(state, activeView), + scheduleSave: () => {}, + notifyUIChanged: () => { + const ui = getPersistedUI(state, activeView) + for (const listener of listeners) { + listener(ui) + } + } + } + return { + set: (updates: Partial) => updatePersistedUI(operations, updates), + get: () => getPersistedUI(state, activeView), + onChanged: (listener: (ui: PersistedUIState) => void) => listeners.add(listener) + } +} + +type Authority = ReturnType + +// Local model of mobile/src/worktree/workspace-view-settings.ts (the desktop test +// runner cannot transform Expo-configured sources). The source-pin test at the +// bottom fails if the shipping module stops matching this model. +type MobileViewState = { + groupMode: 'none' | 'workspaceStatus' | 'repo' | 'prStatus' + sortMode: 'smart' | 'name' | 'recent' | 'repo' | 'manual' + hideSleeping: boolean + hideDefaultBranch: boolean + alwaysShowDefaultBranch: boolean + filterRepoIds: string[] + collapsedGroups: string[] +} + +function mobileHasPatchOnlyBuilder(): boolean { + return readMobileViewSettingsSource().includes('export function buildWorkspaceViewSettingsUpdate') +} + +/** Mirrors buildWorkspaceViewSettingsUpdate (candidate) — only touched fields. */ +function patchOnlyUpdate( + patch: Partial, + next: MobileViewState +): Partial { + const update: Partial = {} + if ('groupMode' in patch) { + update.groupBy = next.groupMode === 'workspaceStatus' ? 'workspace-status' : 'repo' + } + if ('sortMode' in patch) { + update.sortBy = next.sortMode + } + if ('hideSleeping' in patch) { + update.hideSleepingWorkspaces = next.hideSleeping + } + if ('hideDefaultBranch' in patch) { + update.hideDefaultBranchWorkspace = next.hideDefaultBranch + } + if ('filterRepoIds' in patch) { + update.filterRepoIds = next.filterRepoIds + } + if ('collapsedGroups' in patch) { + update.collapsedGroups = next.collapsedGroups + } + return update +} + +/** Mirrors the pre-fix persistViewSettings payload — the full snapshot on every tap. */ +function legacyWholeSnapshotUpdate(next: MobileViewState): Partial { + return { + groupBy: next.groupMode === 'workspaceStatus' ? 'workspace-status' : 'repo', + sortBy: next.sortMode, + hideSleepingWorkspaces: next.hideSleeping, + hideDefaultBranchWorkspace: next.hideDefaultBranch, + filterRepoIds: next.filterRepoIds, + collapsedGroups: next.collapsedGroups + } +} + +/** Model of the mobile host screen's view-settings client (persistViewSettings et al.). */ +function createMobileClient(authority: Authority) { + let view: MobileViewState = { + groupMode: 'repo', + sortMode: 'recent', + hideSleeping: false, + hideDefaultBranch: false, + alwaysShowDefaultBranch: true, + filterRepoIds: [], + collapsedGroups: [] + } + return { + get view() { + return view + }, + /** ui.get on connect/focus: merge the shared state onto the local mirror + * (mirrors applyDesktopViewSettings' ??-per-field semantics). */ + sync() { + const ui = omitPairingLocalUiFields(authority.get()) + view = { + ...view, + hideSleeping: ui.hideSleepingWorkspaces ?? view.hideSleeping, + hideDefaultBranch: ui.hideDefaultBranchWorkspace ?? view.hideDefaultBranch, + alwaysShowDefaultBranch: + ui.alwaysShowDefaultBranchWorkspace ?? view.alwaysShowDefaultBranch, + filterRepoIds: ui.filterRepoIds ?? view.filterRepoIds, + collapsedGroups: ui.collapsedGroups ?? view.collapsedGroups + } + }, + /** A user tap: apply locally, then push through the shipping payload shape. */ + tap(patch: Partial) { + view = { ...view, ...patch } + const payload = mobileHasPatchOnlyBuilder() + ? patchOnlyUpdate(patch, view) + : legacyWholeSnapshotUpdate(view) + authority.set(omitPairingLocalUiFields(payload) as Partial) + } + } +} + +function readMobileHostScreenSource(): string { + return readFileSync(join(__dirname, '../../../../mobile/app/h/[hostId]/index.tsx'), 'utf-8') +} + +function readMobileViewSettingsSource(): string { + return readFileSync( + join(__dirname, '../../../../mobile/src/worktree/workspace-view-settings.ts'), + 'utf-8' + ) +} + +describe('workspace view preferences: cross-client persistence (STA-5781)', () => { + let authority: Authority + let store: StoreApi + let root: Root + let container: HTMLDivElement + let pendingBroadcasts: PersistedUIState[] + let holdAcks: boolean + let rejectSets: boolean + let setCallCount: number + let pendingAcks: (() => void)[] + + async function resolveAcks() { + await act(async () => { + for (const resolve of pendingAcks.splice(0)) { + resolve() + } + await Promise.resolve() + }) + } + + function deliverBroadcasts() { + // Models the async ui:stateChanged IPC delivery to the desktop renderer. + const queued = pendingBroadcasts.splice(0) + for (const ui of queued) { + act(() => { + store.getState().hydratePersistedUI(ui, 'sync') + }) + } + } + + function mountDesktopWriter() { + function Probe() { + usePersistedUIWriter() + return null + } + // StrictMode, like the real renderer (main.tsx): the writer effect must + // stay correct under double-invoked mount/cleanup cycles. + act(() => { + root.render(createElement(StrictMode, null, createElement(Probe))) + }) + } + + async function flushDesktopDebounce() { + // Async act: the writer folds its baseline in a .then after the ui.set + // resolves, so the microtask queue must drain for the flush to register. + await act(async () => { + vi.advanceTimersByTime(200) + await Promise.resolve() + }) + } + + beforeEach(() => { + vi.useFakeTimers() + authority = createAuthority() + pendingBroadcasts = [] + authority.onChanged((ui) => pendingBroadcasts.push(ui)) + store = createUIStore() + storeRef.current = store as unknown as typeof storeRef.current + holdAcks = false + rejectSets = false + setCallCount = 0 + pendingAcks = [] + ;(window as unknown as { api: unknown }).api = { + ui: { + set: (updates: Partial) => { + setCallCount += 1 + // rejectSets models transport failure: nothing reaches the host. + if (rejectSets) { + return Promise.reject(new Error('transport failure')) + } + // Like the real IPC: main applies the update before the renderer's + // promise resolves; holdAcks models the in-flight round-trip window. + authority.set(updates) + if (!holdAcks) { + return Promise.resolve() + } + return new Promise((resolve) => pendingAcks.push(resolve)) + } + } + } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + // Startup: desktop hydrates from the authority before the writer arms. + act(() => { + store.getState().hydratePersistedUI(authority.get(), 'startup') + }) + mountDesktopWriter() + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.useRealTimers() + }) + + it('desktop restart alone does not rewrite the authority (control)', async () => { + const before = authority.get() + await flushDesktopDebounce() + const after = authority.get() + expect(after.hideSleepingWorkspaces).toBe(before.hideSleepingWorkspaces) + expect(after.hideDefaultBranchWorkspace).toBe(before.hideDefaultBranchWorkspace) + expect(after.hideCliCreatedWorkspaces).toBe(before.hideCliCreatedWorkspaces) + }) + + it('a mobile tap must not revert a desktop change the mobile mirror has not seen', async () => { + const mobile = createMobileClient(authority) + mobile.sync() + + // Desktop turns on "hide default branch"; the write lands and broadcasts. + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(true) + deliverBroadcasts() + + // Mobile (mirror stale since its last ui.get) toggles the DISJOINT + // "hide sleeping" filter. + mobile.tap({ hideSleeping: true }) + + // Both changes must survive. + expect(authority.get().hideSleepingWorkspaces).toBe(true) + expect(authority.get().hideDefaultBranchWorkspace).toBe(true) + + // And the desktop mirror must not be reverted by the echoed broadcast. + deliverBroadcasts() + expect(store.getState().hideDefaultBranchWorkspace).toBe(true) + }) + + it('persists the desktop sleeping-workspaces toggle in the durable hide form', async () => { + // Pins the mirror->wire inversion end-to-end: showSleepingWorkspaces false + // must land as hideSleepingWorkspaces true at the authority. + act(() => { + store.getState().setShowSleepingWorkspaces(false) + }) + await flushDesktopDebounce() + expect(authority.get().hideSleepingWorkspaces).toBe(true) + + act(() => { + store.getState().setShowSleepingWorkspaces(true) + }) + await flushDesktopDebounce() + expect(authority.get().hideSleepingWorkspaces).toBe(false) + }) + + it('the desktop debounced writer must not revert a concurrent mobile change', async () => { + const mobile = createMobileClient(authority) + mobile.sync() + + // t=0: desktop toggles a desktop-only filter (disjoint from mobile fields). + act(() => { + store.getState().setHideCliCreatedWorkspaces(true) + }) + + // t<150ms: mobile turns on "hide sleeping". The authority applies it, but the + // ui:stateChanged broadcast is still in flight to the desktop renderer. + act(() => { + vi.advanceTimersByTime(100) + }) + mobile.tap({ hideSleeping: true }) + expect(authority.get().hideSleepingWorkspaces).toBe(true) + + // t=150ms: the desktop debounce fires from its (not yet re-hydrated) mirror. + await flushDesktopDebounce() + + // Both disjoint changes must survive. + expect(authority.get().hideCliCreatedWorkspaces).toBe(true) + expect(authority.get().hideSleepingWorkspaces).toBe(true) + + // After full delivery and a mobile re-sync, every mirror converges on both. + deliverBroadcasts() + mobile.sync() + expect(store.getState().showSleepingWorkspaces).toBe(false) + expect(store.getState().hideCliCreatedWorkspaces).toBe(true) + expect(mobile.view.hideSleeping).toBe(true) + }) + + it('a broadcast landing inside the debounce window must not revert the pending desktop toggle', async () => { + const mobile = createMobileClient(authority) + mobile.sync() + + // t=0: desktop toggles a filter; its write is pending in the 150ms debounce. + act(() => { + store.getState().setHideCliCreatedWorkspaces(true) + }) + + // t<150ms: mobile changes a disjoint field AND its broadcast is delivered + // before the desktop debounce fires. The broadcast still carries the OLD + // value of the desktop's pending toggle. + mobile.tap({ hideSleeping: true }) + deliverBroadcasts() + + // The hydration must not wipe the user's pending toggle from the mirror. + expect(store.getState().hideCliCreatedWorkspaces).toBe(true) + // The remote change must land in the mirror. + expect(store.getState().showSleepingWorkspaces).toBe(false) + + await flushDesktopDebounce() + + // Both disjoint changes survive at the authority. + expect(authority.get().hideCliCreatedWorkspaces).toBe(true) + expect(authority.get().hideSleepingWorkspaces).toBe(true) + }) + + it('preserves a flip-back made while the first write is still in flight', async () => { + // CodeRabbit PR#17057 finding: toggle -> debounce fires (write in flight) + // -> toggle back. The flip-back equals the pre-fold baseline, so without + // in-flight tracking it diffs empty, the ack folds the obsolete value in, + // and the echo broadcast visually reverts the user's second toggle. + holdAcks = true + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(true) + + // Flip back while the first write's ack is still in flight. + act(() => { + store.getState().setHideDefaultBranchWorkspace(false) + }) + // The echo of the FIRST write arrives before the ack. + deliverBroadcasts() + expect(store.getState().hideDefaultBranchWorkspace).toBe(false) + + // Ack lands; the writer must notice the mirror moved on and re-flush. + await resolveAcks() + await flushDesktopDebounce() + await resolveAcks() + expect(authority.get().hideDefaultBranchWorkspace).toBe(false) + deliverBroadcasts() + expect(store.getState().hideDefaultBranchWorkspace).toBe(false) + }) + + it('edits made during the ack window flush at debounce rate, not ack rate', async () => { + // Round-3 review: the trailing re-diff must not bypass the 150ms debounce, + // or one in-flight write turns a drag into one ui.set per IPC round trip. + holdAcks = true + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + const sendsAfterFirstFlush = setCallCount + + // Rapid edits while the first write's ack is in flight. + act(() => { + store.getState().setHideCliCreatedWorkspaces(true) + }) + act(() => { + store.getState().setHideDetachedHeadWorkspaces(true) + }) + act(() => { + store.getState().setHideAutomationGeneratedWorkspaces(true) + }) + + // The ack lands: nothing may be sent inline — only a debounced flush later. + await resolveAcks() + expect(setCallCount).toBe(sendsAfterFirstFlush) + + // One debounce window later, the three edits coalesce into a single write. + await flushDesktopDebounce() + expect(setCallCount).toBe(sendsAfterFirstFlush + 1) + await resolveAcks() + await flushDesktopDebounce() + await resolveAcks() + expect(authority.get().hideDefaultBranchWorkspace).toBe(true) + expect(authority.get().hideCliCreatedWorkspaces).toBe(true) + expect(authority.get().hideDetachedHeadWorkspaces).toBe(true) + expect(authority.get().hideAutomationGeneratedWorkspaces).toBe(true) + }) + + it('converges when a remote client writes the same field during the ack window', async () => { + // Round-3 review: the ack must not fold the sent value over a baseline a + // hydration advanced past, or the mirror and authority diverge with no + // further traffic to reconcile them (the reset then reappears later). + holdAcks = true + const mobile = createMobileClient(authority) + mobile.sync() + + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + + // Mobile writes the SAME field at the authority after us; both broadcasts + // (our echo, then mobile's) land before our ack does. + mobile.tap({ hideDefaultBranch: false }) + deliverBroadcasts() + await resolveAcks() + + await flushDesktopDebounce() + await resolveAcks() + deliverBroadcasts() + await flushDesktopDebounce() + await resolveAcks() + deliverBroadcasts() + + // Either side may win a same-field conflict, but mirror and authority + // must agree once traffic settles. + expect(store.getState().hideDefaultBranchWorkspace).toBe( + authority.get().hideDefaultBranchWorkspace + ) + }) + + it('a rejected write folds nothing and re-flushes with the next change', async () => { + rejectSets = true + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(false) + // The rejection must settle the in-flight marker, not leak it. + expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) + + // Transport recovers; the next edit re-flushes the dirty field too. + rejectSets = false + act(() => { + store.getState().setHideCliCreatedWorkspaces(true) + }) + await flushDesktopDebounce() + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(true) + expect(authority.get().hideCliCreatedWorkspaces).toBe(true) + }) + + it('a synchronously throwing ui.set still settles the marker and reschedules', async () => { + const api = ( + window as unknown as { api: { ui: { set: (u: Partial) => Promise } } } + ).api.ui + const workingSet = api.set + api.set = () => { + setCallCount += 1 + throw new Error('non-cloneable argument') + } + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + // A leaked marker would pin the field against hydration for the renderer's life. + expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) + + // The throw must also reschedule the trailing pass: once the transport + // recovers, the dirty field flushes without waiting for another edit. + api.set = workingSet + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(true) + }) + + it('a rejection re-schedules the trailing pass it caused to be skipped', async () => { + // Round-3 verification: a trailing pass that bails because a write is in + // flight relies on that write's settle to reschedule — including rejection, + // or a pending flip-back is stranded until the next unrelated edit. + holdAcks = true + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + // Flip back while write #1 is in flight: only a trailing flush carries it. + act(() => { + store.getState().setHideDefaultBranchWorkspace(false) + }) + await resolveAcks() + + // Before the trailing pass fires, a different field's write goes out and + // is REJECTED while in flight when the trailing pass checks. + rejectSets = true + act(() => { + store.getState().setHideCliCreatedWorkspaces(true) + }) + await flushDesktopDebounce() + + rejectSets = false + holdAcks = false + await flushDesktopDebounce() + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(false) + expect(store.getState().hideDefaultBranchWorkspace).toBe(false) + }) + + it('overlapping in-flight writes on one field decrement, not clear, the marker', () => { + // Unit-pins the count semantics: ack #1 of two overlapping writes must not + // un-pin the field while write #2 is still out. + const s = store.getState() + s.notePersistedUIWriteStarted(['hideDefaultBranchWorkspace']) + s.notePersistedUIWriteStarted(['hideDefaultBranchWorkspace']) + store.getState().notePersistedUIWriteSettled(['hideDefaultBranchWorkspace'], null) + expect(store.getState().persistedUIWriteInFlightCounts).toEqual({ + hideDefaultBranchWorkspace: 1 + }) + store.getState().notePersistedUIWriteSettled(['hideDefaultBranchWorkspace'], null) + expect(store.getState().persistedUIWriteInFlightCounts).toEqual({}) + }) + + it('desktop and mobile changing the same field converges on the newest write', async () => { + const mobile = createMobileClient(authority) + mobile.sync() + + act(() => { + store.getState().setHideDefaultBranchWorkspace(true) + }) + await flushDesktopDebounce() + deliverBroadcasts() + + // Mobile flips the SAME field afterwards; last writer wins everywhere. + mobile.tap({ hideDefaultBranch: false }) + deliverBroadcasts() + expect(authority.get().hideDefaultBranchWorkspace).toBe(false) + await flushDesktopDebounce() + expect(authority.get().hideDefaultBranchWorkspace).toBe(false) + expect(store.getState().hideDefaultBranchWorkspace).toBe(false) + }) + + it('pins the modeled mobile ui.set payload to the shipping source', async () => { + const source = readMobileHostScreenSource() + if (mobileHasPatchOnlyBuilder()) { + // Candidate: index.tsx must push through the patch-only builder this model uses. + expect(source).toContain('buildWorkspaceViewSettingsUpdate(patch, next)') + const builderSource = readMobileViewSettingsSource() + for (const guard of [ + "if ('groupMode' in patch)", + "if ('sortMode' in patch)", + "if ('hideSleeping' in patch)", + "if ('hideDefaultBranch' in patch)", + "if ('filterRepoIds' in patch)", + "if ('collapsedGroups' in patch)" + ]) { + expect(builderSource).toContain(guard) + } + } else { + // Baseline: persistViewSettings pushes exactly this whole-snapshot payload. + for (const key of [ + 'groupBy: groupModeToDesktop(next.groupMode)', + 'sortBy: next.sortMode', + 'hideSleepingWorkspaces: next.hideSleeping', + 'hideDefaultBranchWorkspace: next.hideDefaultBranch', + 'filterRepoIds: next.filterRepoIds', + 'collapsedGroups: next.collapsedGroups' + ]) { + expect(source).toContain(key) + } + } + }) +}) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index e7b7cfe1fef..94034900e5e 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -19,6 +19,20 @@ const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-per const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts' describe('renderer startup runtime routing', () => { + it('routes packaged terminal restore through the daemon adoption gate', () => { + const source = readFileSync( + join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + 'utf8' + ) + const gateStart = source.indexOf('const startupActivationGateWorktreeIdsRef') + const gateEnd = source.indexOf('const startupResumeWorktreeIdsRef', gateStart) + const gateEffect = source.slice(gateStart, gateEnd) + + expect(gateStart).toBeGreaterThanOrEqual(0) + expect(gateEffect).toContain('void gateWorktreeAgentActivation(activeWorktreeId)') + expect(gateEffect).not.toContain('resumeSleepingAgentSessionsForWorktree') + }) + it('hydrates persisted UI before local catalog and worktree hydration', () => { const source = readSource(STARTUP_HYDRATION_PATH) const startupBlockStart = source.indexOf('void (async () => {') @@ -325,6 +339,46 @@ describe('renderer startup runtime routing', () => { expect(reconnectIndex).toBeGreaterThan(capabilityIndex) }) + it('orders packaged restoration before adoption, projection, and default creation', () => { + // Why this file: the startup sequence moved out of App.tsx into the hydration hook; + // the ordering it asserts is unchanged, only the module that now spells it out. + const appSource = readFileSync( + join(process.cwd(), 'src/renderer/src/app-shell/use-app-startup-hydration.ts'), + 'utf8' + ) + const terminalSource = readFileSync( + join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'), + 'utf8' + ) + const hydrateIndex = appSource.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'") + const prepareIndex = appSource.indexOf( + "timeRendererStartupStep('prepare-terminal-startup-restoration'" + ) + const reconnectIndex = appSource.indexOf("timeRendererStartupStep('reconnect-terminals'") + const projectIndex = appSource.indexOf( + "timeRendererStartupStep('project-structured-session-tabs'" + ) + const readyIndex = appSource.indexOf('actions.setTerminalStartupRestorationReady(true)') + const gateStart = terminalSource.indexOf('const startupActivationGateWorktreeIdsRef') + const gateEnd = terminalSource.indexOf('const startupResumeWorktreeIdsRef', gateStart) + const gateBlock = terminalSource.slice(gateStart, gateEnd) + const gateIndex = gateBlock.indexOf('gateWorktreeAgentActivation(activeWorktreeId)') + const createIndex = gateBlock.indexOf( + 'createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true })' + ) + + expect(hydrateIndex).toBeGreaterThanOrEqual(0) + expect(hydrateIndex).toBeLessThan(prepareIndex) + expect(prepareIndex).toBeLessThan(reconnectIndex) + expect(reconnectIndex).toBeLessThan(projectIndex) + expect(projectIndex).toBeLessThan(readyIndex) + expect(gateBlock).toContain('terminalStartupRestorationReady') + expect(gateBlock).not.toContain('hydrationSucceeded') + expect(gateIndex).toBeGreaterThanOrEqual(0) + expect(gateIndex).toBeLessThan(createIndex) + expect(gateBlock.slice(gateIndex, createIndex)).toContain("outcome !== 'empty'") + }) + it('does not load the terminal workbench on the no-workspace landing path', () => { const shellSource = readSource(WORKSPACE_SHELL_PATH) const layoutSource = readSource(CHROME_LAYOUT_PATH) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index cd556d6f486..1b5f40ccdb4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -389,6 +389,12 @@ [data-sonner-toaster] { font-family: var(--font-sans); + z-index: 40 !important; +} + +/* Keep interruption controls above unrelated updater/onboarding chrome. */ +.native-chat-pane-shell:has([data-native-chat-working='true']) { + z-index: 50; } [data-sonner-toaster] [data-sonner-toast][data-styled='true'] { diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 6304b1cfd68..ab84e4562d8 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -2,8 +2,24 @@ import React from 'react' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' -import { AgentStateDot, type AgentDotState } from './AgentStateDot' +import { describe, expect, it, vi } from 'vitest' +import { AgentStateDot, agentStateLabel, type AgentDotState } from './AgentStateDot' + +vi.mock('@/components/StateIndicatorTooltip', async () => { + const { createElement } = await import('react') + return { + StateIndicatorTooltip: ({ + label, + children + }: { + label: string | null + children: React.ReactElement + }) => + label === null + ? children + : createElement('span', { 'data-state-indicator-tooltip': label }, children) + } +}) function renderMarkup(state: AgentDotState): string { return renderToStaticMarkup(React.createElement(AgentStateDot, { state })) @@ -42,11 +58,11 @@ describe('AgentStateDot', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) - it('renders monitoring as a static yellow radio glyph', () => { + it('renders monitoring as a static yellow heartbeat glyph', () => { const markup = renderMarkup('monitoring') expect(markup).toContain('aria-label="Monitoring background tasks"') - expect(markup).toContain('lucide-radio') + expect(markup).toContain('lucide-activity') expect(markup).toContain('text-yellow-500') expect(markup).not.toContain('data-agent-spinner') }) @@ -86,4 +102,51 @@ describe('AgentStateDot', () => { expect(classNames).not.toContain('bg-amber-500') } ) + + const ALL_STATES = [ + 'working', + 'monitoring', + 'blocked', + 'waiting', + 'interrupted', + 'failed', + 'done', + 'idle', + 'permission' + ] satisfies AgentDotState[] + + it.each(ALL_STATES)('labels %s with the shared hover tooltip', (state) => { + const markup = renderMarkup(state) + + expect(markup).toContain(`data-state-indicator-tooltip="${agentStateLabel(state)}"`) + expect(markup).not.toContain(' title=') + }) + + // Typecheck-time guard: a new AgentDotState member that ALL_STATES omits + // fails `pnpm tc`, so the tooltip case above can never silently skip a state. + type UncoveredState = Exclude + const _allStatesAreCovered: UncoveredState extends never ? true : never = true + void _allStatesAreCovered + + it('lets a caller override the tooltip', () => { + const markup = renderToStaticMarkup( + React.createElement(AgentStateDot, { state: 'done', title: 'Finished 2m ago' }) + ) + + expect(markup).toContain('data-state-indicator-tooltip="Finished 2m ago"') + expect(markup).not.toContain(' title=') + expect(markup).toContain('aria-label="Done"') + }) + + it('lets a caller with an existing tooltip suppress the shared tooltip', () => { + const markup = renderToStaticMarkup( + React.createElement(AgentStateDot, { state: 'interrupted', title: null }) + ) + + expect(markup).not.toContain('data-state-indicator-tooltip') + expect(markup).toContain('aria-label="Interrupted"') + expect(renderMarkup('interrupted')).toContain( + `data-state-indicator-tooltip="${agentStateLabel('interrupted')}"` + ) + }) }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 4f361d01f87..bcb44a8e726 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,8 +1,12 @@ import React from 'react' -import { CircleCheck, Radio } from 'lucide-react' +import { Activity, CircleCheck } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' +import { + StateIndicatorTooltip, + type StateIndicatorTooltipSide +} from '@/components/StateIndicatorTooltip' // Why: shared state-indicator primitive so the dashboard and the sidebar's // agent hover share a single state vocabulary. Most states render as a dot; @@ -61,20 +65,28 @@ type Props = { state: AgentDotState size?: 'sm' | 'md' className?: string + /** Overrides the hover tooltip; null suppresses it for an existing tooltip. */ + title?: string | null + tooltipSide?: StateIndicatorTooltipSide } /** Render the compact state glyph used by agent rows and terminal tabs. */ export const AgentStateDot = React.memo(function AgentStateDot({ state, size = 'sm', - className + className, + title, + tooltipSide }: Props): React.JSX.Element { const box = size === 'md' ? 'h-3 w-3' : 'h-2.5 w-2.5' const inner = size === 'md' ? 'size-2' : 'size-1.5' const icon = size === 'md' ? 'size-3' : 'size-2.5' + const tooltipLabel = title === null ? null : (title ?? agentStateLabel(state)) + + let indicator: React.JSX.Element if (state === 'working') { - return ( + indicator = ( ) - } - - if (state === 'monitoring') { - return ( + } else if (state === 'monitoring') { + indicator = ( - ) - } - - if (state === 'done') { + } else if (state === 'done') { // Why: the dashboard lists many agents, so a check glyph scans well for // agent-reported completion and keeps 'done' visually distinct from // 'idle' and other dot states at a glance. The sidebar's StatusIndicator // intentionally diverges (emerald dot + tooltip) — see file header. - return ( + indicator = ( ) - } - - if (state === 'permission' || state === 'waiting') { - return ( + } else if (state === 'permission' || state === 'waiting') { + indicator = ( ) + } else { + indicator = ( + + + + ) } return ( - - - + + {indicator} + ) }) diff --git a/src/renderer/src/components/StateIndicatorTooltip.test.tsx b/src/renderer/src/components/StateIndicatorTooltip.test.tsx new file mode 100644 index 00000000000..70bb8272425 --- /dev/null +++ b/src/renderer/src/components/StateIndicatorTooltip.test.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { STATE_INDICATOR_TOOLTIP_DELAY_MS, StateIndicatorTooltip } from './StateIndicatorTooltip' + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ delayDuration, children }: { delayDuration: number; children: ReactNode }) => ( + {children} + ), + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children, side }: { children: ReactNode; side: string }) => ( + + {children} + + ) +})) + +describe('StateIndicatorTooltip', () => { + it('uses Orca tooltip chrome with an explicit 200ms delay', () => { + const markup = renderToStaticMarkup( + + + + ) + + expect(STATE_INDICATOR_TOOLTIP_DELAY_MS).toBe(200) + expect(markup).toContain('data-delay-duration="200"') + expect(markup).toContain('data-tooltip-content=""') + expect(markup).toContain('data-side="top"') + expect(markup).toContain('Monitoring background tasks') + }) + + it('supports surface-aware placement without changing the delay', () => { + const markup = renderToStaticMarkup( + + + + ) + + expect(markup).toContain('data-delay-duration="200"') + expect(markup).toContain('data-side="right"') + }) + + it('renders only the indicator when a caller already owns the tooltip', () => { + const markup = renderToStaticMarkup( + + + + ) + + expect(markup).toBe('') + }) +}) diff --git a/src/renderer/src/components/StateIndicatorTooltip.tsx b/src/renderer/src/components/StateIndicatorTooltip.tsx new file mode 100644 index 00000000000..d4deaf2406e --- /dev/null +++ b/src/renderer/src/components/StateIndicatorTooltip.tsx @@ -0,0 +1,28 @@ +import type { ComponentProps, ReactElement } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +export const STATE_INDICATOR_TOOLTIP_DELAY_MS = 200 +export type StateIndicatorTooltipSide = ComponentProps['side'] + +export function StateIndicatorTooltip({ + label, + side = 'top', + children +}: { + label: string | null + side?: StateIndicatorTooltipSide + children: ReactElement +}): React.JSX.Element { + if (label === null) { + return children + } + + return ( + + {children} + + {label} + + + ) +} diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 1889c1b7bbd..f5857a33e13 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -44,6 +44,7 @@ import { hasFeatureInteraction } from '../../../shared/feature-interactions' import BrowserPane from './browser-pane/BrowserPane' import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' +import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' import { useClientHostedBrowserRows } from '@/lib/pane-manager/client-hosted-browser-row-state' import { onBrowserGuestPaintRetentionChange, @@ -157,6 +158,7 @@ import { } from '@/runtime/web-runtime-session' import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' import { listBoundAgentTabActions, resolveDefaultAgentForNewTab } from '@/lib/agent-tab-shortcuts' import { terminalProviderHasAuthoritativeSnapshot } from './terminal/terminal-provider-snapshot-capability' @@ -374,6 +376,7 @@ function Terminal(): React.JSX.Element | null { const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) + const terminalStartupRestorationReady = useAppStore((s) => s.terminalStartupRestorationReady) const hydrationSucceeded = useAppStore((s) => s.hydrationSucceeded) const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted) const openFiles = useAppStore((s) => s.openFiles) @@ -1511,7 +1514,9 @@ function Terminal(): React.JSX.Element | null { ]) // Why: on host unmount no reconciliation effect runs again, so dispose every remaining parked watcher. useEffect(() => () => disposeAllParkedTerminalWatchers(), []) - // Auto-create first tab when worktree activates + const startupActivationGateWorktreeIdsRef = useRef(new Set()) + // Why (main): a missing row means never initialized, an explicit empty row means the user + // closed the last terminal — so the gate must not re-seed one in the second case. const activeWorktreeHasTerminalState = activeWorktreeId ? Object.hasOwn(tabsByWorktree, activeWorktreeId) : false @@ -1525,10 +1530,7 @@ function Terminal(): React.JSX.Element | null { ) const activeWorktreeHostAuthority = useAppStore(hostAuthoritySelector) useEffect(() => { - if (!workspaceSessionReady) { - return - } - if (!activeWorktreeId) { + if (!workspaceSessionReady || !terminalStartupRestorationReady || !activeWorktreeId) { return } // Why: the execution host owns terminal creation, and a host that has not answered is not a host @@ -1536,21 +1538,37 @@ function Terminal(): React.JSX.Element | null { if (activeWorktreeHostAuthority !== 'none') { return } - - // Why: give a newly activated worktree a focusable surface when nothing renders, without recreating one after the user closes the last visible tab. - const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) - if (!shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) { + if (startupActivationGateWorktreeIdsRef.current.has(activeWorktreeId)) { return } - // Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps). - createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) + startupActivationGateWorktreeIdsRef.current.add(activeWorktreeId) + let cancelled = false + void gateWorktreeAgentActivation(activeWorktreeId).then((outcome) => { + if ( + cancelled || + outcome !== 'empty' || + useAppStore.getState().activeWorktreeId !== activeWorktreeId + ) { + return + } + // Why: the activation gate reconciles durable/live agent state first; only an actually empty, never-visited workspace receives a default shell. + const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId) + if (shouldAutoCreateInitialTerminal(renderableTabCount, activeWorktreeHasTerminalState)) { + // Why: tag this never-visited-worktree tab so its PTY spawn doesn't count as activity and reshuffle the sidebar (explicit New Tab still bumps). + createTab(activeWorktreeId, undefined, undefined, { pendingActivationSpawn: true }) + } + }) + return () => { + cancelled = true + } }, [ - workspaceSessionReady, activeWorktreeId, activeWorktreeHasTerminalState, activeWorktreeHostAuthority, createTab, - reconcileWorktreeTabModel + reconcileWorktreeTabModel, + terminalStartupRestorationReady, + workspaceSessionReady ]) const startupResumeWorktreeIdsRef = useRef(new Set()) @@ -2926,6 +2944,10 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ {isVisible || backgroundMountTabIds === null ? ( ) : null} + ) diff --git a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx index 43a77e30556..2975beddc0f 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.recent-tabs.test.tsx @@ -827,11 +827,10 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { }) await flushEffects() - // Why: the row stays where the frozen order put it, and its badge must keep resolving — row - // data covers every open tab, so inclusion dropping it can't blank the pip mid-open. + // Why: a frozen row must retain its live badge while staying in its original slot. expect(getTabRowIds()).toContain('tab-alpha') expect(testContainer.textContent).toContain('Alpha chat') - expect(testContainer.querySelector('[title="Working"]')).not.toBeNull() + expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working') }) it('keeps a frozen current row listed when its agent finishes mid-open', async () => { @@ -859,10 +858,9 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { }) await flushEffects() - // Why: `done` gates entry, not rendering — a row already in the frozen order keeps its slot and - // flips to the completed check rather than blanking under the cursor. + // Why: completion changes the frozen row's badge without removing its reserved slot. expect(getTabRowIds()).toContain('tab-alpha') - expect(testContainer.querySelector('[title="Done"]')).not.toBeNull() + expect(document.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Done') }) it('activates the row a digit chord addresses while open', async () => { @@ -920,8 +918,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { }) ) - // Why not optional-call: a skipped setter would leave the empty-query Recent section standing - // and the assertions below would pass without the query path ever running. + // Why: require the setter so this cannot silently exercise the empty-query section. const applyQuery = setCommandQuery if (!applyQuery) { throw new Error('CommandInput never installed a query setter') @@ -937,7 +934,7 @@ describe('WorktreeJumpPalette recent chats & terminals', () => { const alphaRow = testContainer.querySelector( '[data-command-item="workspace-tab:tab-alpha"]' ) - expect(alphaRow?.querySelector('[title="Working"]')).not.toBeNull() + expect(alphaRow?.querySelector('[data-slot=tooltip-trigger]')?.textContent).toContain('Working') }) it('keeps create-worktree below the matches it would otherwise outrank', async () => { diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 2cbe63a9576..d729bc3cbef 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -226,6 +226,7 @@ import type { SettingsNavTarget } from '@/lib/settings-navigation-types' import { getHostDisplayLabelOverrides } from '../../../shared/host-setting-overrides' import type { GitHubWorkItem } from '../../../shared/github/work-item-types' import type { LinearIssue } from '../../../shared/linear/issue-types' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { Worktree } from '../../../shared/worktree/types' import { isGitRepoKind } from '../../../shared/repo-kind' @@ -879,9 +880,7 @@ function WorktreeJumpPaletteContent({ const [dialogElement, setDialogElement] = useState(null) const previousWorktreeIdRef = useRef(null) - const previousActiveTabTypeRef = useRef<'browser' | 'editor' | 'terminal' | 'simulator'>( - 'terminal' - ) + const previousActiveTabTypeRef = useRef('terminal') const previousBrowserPageIdRef = useRef(null) const previousBrowserFocusTargetRef = useRef<'webview' | 'address-bar'>('webview') // Why: the exact element focused before Cmd+J opened, so Escape restores it precisely (not a background worktree's hidden terminal). diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts index e7cfcd94e57..72a1b6d3355 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts +++ b/src/renderer/src/components/activity/ActivityPrototypePage.thread-grouping.test.ts @@ -1,11 +1,15 @@ +import { createElement } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { formatAgentTypeLabel } from '@/lib/agent-status' +import { TooltipProvider } from '@/components/ui/tooltip' import { buildActivityThreadGroups, buildActivityEvents, buildAgentPaneThreads, - getActivityThreadGroup + getActivityThreadGroup, + ThreadAgentStateIndicator } from './ActivityPrototypePage' import { makeActivityResult, @@ -23,6 +27,28 @@ import { UNKNOWN_PANE_KEY } from './ActivityPrototypePage-test-fixtures' +describe('ThreadAgentStateIndicator', () => { + it('labels the state through its Radix tooltip only', () => { + const threads = makeThreads( + makeActivityResult({ entries: { [PANE_KEY]: makeWorkingEntryWithoutHistory() } }) + ) + + const markup = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + createElement(ThreadAgentStateIndicator, { thread: threads[0]! }) + ) + ) + const titles = [...markup.matchAll(/\stitle="([^"]*)"/g)].map((match) => match[1]) + + expect(markup).toContain('data-slot="tooltip-trigger"') + expect(markup).toContain('aria-label="Working"') + // A native title here would fire alongside the Radix tooltip as a double label. + expect(titles).toEqual([]) + }) +}) + describe('activity thread grouping', () => { it('status grouping separates interrupted done from normal done and keeps Interrupted label', () => { const repo = makeRepo() diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 623e41d4c89..dcc068d88f8 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -1180,14 +1180,18 @@ export function handleActivityFilterFocusShortcut({ return true } -function ThreadAgentStateIndicator({ thread }: { thread: AgentPaneThread }): React.JSX.Element { +export function ThreadAgentStateIndicator({ + thread +}: { + thread: AgentPaneThread +}): React.JSX.Element { const state = threadAgentState(thread) const label = threadAgentStateLabel(thread) return ( - + diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index d0a9ea6b619..a2656104d87 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -4,12 +4,13 @@ import React, { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' import type { AppState } from '@/store/types' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { - PaletteLiveStatusProvider, + PaletteLiveStatusProvider as ProductionPaletteLiveStatusProvider, PaletteRecentTabStatusDot, PaletteWorktreeStatusDot } from './palette-live-status' @@ -21,6 +22,16 @@ vi.mock('@/components/AgentWorkingSpinner', () => ({ const initialAppState = useAppStore.getInitialState() const LEAF = '11111111-2222-4333-8444-555555555555' +function PaletteLiveStatusProvider( + props: React.ComponentProps +): React.JSX.Element { + return ( + + + + ) +} + let testRoot: Root let testContainer: HTMLDivElement @@ -79,6 +90,13 @@ function dotLabels(): string[] { ) } +function expectStyledStatusTooltip(label: string): void { + const trigger = testContainer.querySelector('[data-slot="tooltip-trigger"]') + expect(trigger).not.toBeNull() + expect(trigger?.getAttribute('title')).toBeNull() + expect(trigger?.textContent).toContain(label) +} + describe('palette live status', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -118,6 +136,7 @@ describe('palette live status', () => { setAgentState('working') await render() expect(dotLabels()).toEqual(['Working']) + expect(testContainer.querySelector('[data-slot="tooltip-trigger"]')).not.toBeNull() await act(async () => { setAgentState('blocked') @@ -136,7 +155,7 @@ describe('palette live status', () => { await render() expect(testContainer.querySelector('[data-spinner]')).toBeNull() - expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(testContainer.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500') expect(dotLabels()).toEqual(['Monitoring background tasks']) }) @@ -244,8 +263,7 @@ describe('palette live status', () => { expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() expect(testContainer.querySelector('[data-spinner]')).not.toBeNull() expect(dotLabels()).toEqual(['Working']) - // Hover tooltip on the outer hit target (badge is pointer-events-none). - expect(testContainer.querySelector('[title="Working"]')).not.toBeNull() + expectStyledStatusTooltip('Working') await act(async () => { setAgentState('blocked') @@ -253,7 +271,7 @@ describe('palette live status', () => { expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() expect(testContainer.querySelector('[data-spinner]')).toBeNull() expect(dotLabels()).toEqual(['Needs permission']) - expect(testContainer.querySelector('[title="Needs permission"]')).not.toBeNull() + expectStyledStatusTooltip('Needs permission') }) it('shows monitoring with a static radio instead of the working spinner', async () => { @@ -276,9 +294,9 @@ describe('palette live status', () => { }) expect(testContainer.querySelector('[data-spinner]')).toBeNull() - expect(testContainer.querySelector('.lucide-radio')?.classList).toContain('text-yellow-500') + expect(testContainer.querySelector('.lucide-activity')?.classList).toContain('text-yellow-500') expect(dotLabels()).toEqual(['Monitoring background tasks']) - expect(testContainer.querySelector('[title="Monitoring background tasks"]')).not.toBeNull() + expectStyledStatusTooltip('Monitoring background tasks') }) it('shows only the content icon when a terminal-backed row is inactive', async () => { @@ -331,7 +349,7 @@ describe('palette live status', () => { expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() expect(testContainer.querySelector('[data-spinner]')).toBeNull() expect(dotLabels()).toEqual(['Unread agent completion']) - expect(testContainer.querySelector('[title="Unread agent completion"]')).not.toBeNull() + expectStyledStatusTooltip('Unread agent completion') }) it('prefers working over unread on the same row', async () => { @@ -379,7 +397,7 @@ describe('palette live status', () => { }) expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() expect(dotLabels()).toEqual(['Done']) - expect(testContainer.querySelector('[title="Done"]')).not.toBeNull() + expectStyledStatusTooltip('Done') // lucide CircleCheck class marker expect(testContainer.innerHTML).toContain('lucide-circle-check') }) diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index 377b1527343..e19780a3602 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { AgentStateDot } from '@/components/AgentStateDot' +import { StateIndicatorTooltip } from '@/components/StateIndicatorTooltip' import StatusIndicator from '@/components/sidebar/StatusIndicator' import { FilledBellIcon } from '@/components/sidebar/WorktreeCardHelpers' import { @@ -244,30 +245,28 @@ export function PaletteRecentTabStatusDot({ 'Unread agent completion' ) : getWorktreeStatusLabel(badge) - // Why: title on the outer hit target (not the pointer-events-none pip) so hover still reveals - // status — matches StatusIndicator's tooltip placement. + // Why: the outer hit target owns the tooltip because the overlaid pip ignores pointer events. return ( - - {fallback} - + ) } @@ -281,5 +280,5 @@ function RecentTabAttentionBadgeGlyph({ return } // Why: AgentStateDot owns working/permission/done glyphs app-wide (spinner / ? / check). - return + return } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx index e428d43f5f6..e6b84a0a20f 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -7,6 +7,7 @@ import type { DashboardSpawnAgentArgs } from '../../../../shared/dashboard-snapshot' import type { TuiAgent } from '../../../../shared/tui-agent' +import { TooltipProvider } from '@/components/ui/tooltip' import { AgentMap } from './AgentMap' import type { AgentMapState } from './agent-map-filter' @@ -76,7 +77,8 @@ export function renderMap( launchableAgentsByWorktreeId={launchableAgentsByWorktreeId} onSpawnAgent={onSpawnAgent} onSleepWorkspace={onSleepWorkspace} - /> + />, + { wrapper: TooltipProvider } ) } diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx index 9a95a6293e8..a32056df297 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.test.tsx @@ -320,6 +320,7 @@ describe('DashboardAgentRow', () => { // on the response line so it does not compete with the user's prompt. expect(markup).toContain('data-slot="tooltip-trigger"') expect(markup).toContain('aria-label="Interrupted by user"') + expect(markup).not.toContain('title="Interrupted"') expect(markup).toContain('bg-red-500') expect(markup).not.toContain('data-slot="badge"') expect(interruptedIndex).toBeGreaterThan(promptIndex) @@ -362,7 +363,7 @@ describe('DashboardAgentRow', () => { ) expect(markup).toContain('Monitoring background tasks') - expect(markup).toContain('lucide-radio') + expect(markup).toContain('lucide-activity') expect(classTokens(markup)).toContain('text-yellow-500') expect(markup).not.toContain('data-agent-spinner') expect(markup).not.toContain('data-agent-row-tool-slot') diff --git a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx index 8bc76da2709..6063aa32e4d 100644 --- a/src/renderer/src/components/dashboard/DashboardAgentRow.tsx +++ b/src/renderer/src/components/dashboard/DashboardAgentRow.tsx @@ -253,7 +253,7 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({ className="inline-flex shrink-0 items-center justify-center" aria-label={dotTooltipLabel} > - + diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 1ae74985923..5f900b4cfd0 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -506,9 +506,11 @@ describe('ReviewNotesSendMenuContent', () => { const tree = render() const item = findByType(tree, 'DropdownMenuItem') + const stateDot = findByType(item, 'AgentStateDot') expect(item.props.disabled).toBe(true) expect(item.props.title).toBe('Agent needs permission') + expect(stateDot.props.title).toBeNull() ;(item.props.onSelect as () => void)() expect(harness.sendNotesToActiveAgentSession).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx index adde4669ef4..0800be5b224 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.tsx @@ -247,6 +247,7 @@ function AgentTargetMenuItem({ const tabTitle = target.tabTitle.trim() const state = asDotState(agent?.state ?? 'idle', agent?.entry.workingMode) const timeAgo = agent ? formatAgentRelativeTime(agent, now) : null + const disabledReason = target.status === 'disabled' ? target.disabledReason : undefined const secondaryParts = [ agentStateLabel(state), ...(timeAgo ? [timeAgo] : []), @@ -259,10 +260,16 @@ function AgentTargetMenuItem({ // Why: surface the ineligibility reason (permission/stale/no-terminal) as a // hover tooltip rather than inline text, matching DashboardAgentRow's // title-attribute treatment of the same disabledReason. - title={target.status === 'disabled' ? target.disabledReason : undefined} + title={disabledReason} className="min-w-[240px] gap-2 rounded-[7px] px-2 py-1.5 text-[12px] leading-5 font-medium" > - + {/* Why: the ancestor's actionable disabled reason must win on every hit area. */} + diff --git a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx index 0fcea197023..b113f0cfba9 100644 --- a/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx +++ b/src/renderer/src/components/feature-wall/FeatureWallSetupChecklist.tsx @@ -27,6 +27,8 @@ import type { TuiAgent } from '../../../../shared/tui-agent' import { getProviderRuntimeContextKey } from '@/lib/provider-runtime-context' import { translate } from '@/i18n/i18n' +import { getLocalizedFeatureWallSetupChecklistCopy } from './feature-wall-setup-checklist-localized-copy' + type FeatureWallSetupChecklistLayout = 'modal' | 'embedded' type FeatureWallSetupChecklistProps = { @@ -49,6 +51,7 @@ function SetupStepRow(props: { }): React.JSX.Element { const { step, done, active, ordinal, onSelect, layout } = props const isEmbedded = layout === 'embedded' + const localizedStepCopy = getLocalizedFeatureWallSetupChecklistCopy(step) return ( @@ -325,7 +328,7 @@ export function FeatureWallSetupChecklist(
- {activeStep.name} + {getLocalizedFeatureWallSetupChecklistCopy(activeStep).name}
- {activeStep.description} + {getLocalizedFeatureWallSetupChecklistCopy(activeStep).description}

{/* Action lives under the caption, not after the grid, so it sits just below the copy instead of being pushed down by the taller visual. */} diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts new file mode 100644 index 00000000000..b4206933cdf --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { FEATURE_WALL_SETUP_STEPS } from '../../../../shared/feature-wall-setup-steps' +import { getLocalizedFeatureWallSetupChecklistCopy } from './feature-wall-setup-checklist-localized-copy' +import ko from '../../i18n/locales/ko.json' +import en from '../../i18n/locales/en.json' + +describe('feature-wall-setup-checklist-localized-copy', () => { + it('returns non-empty localized name and description for all setup checklist steps', () => { + for (const step of FEATURE_WALL_SETUP_STEPS) { + const localized = getLocalizedFeatureWallSetupChecklistCopy(step) + expect(localized.name).toBeTruthy() + expect(localized.description).toBeTruthy() + } + }) + + it('has valid Korean and English catalog entries for all setup checklist steps', () => { + const enKeys = en.auto.components.feature.wall.feature.wall.setup.checklist.localized.copy + const koKeys = ko.auto.components.feature.wall.feature.wall.setup.checklist.localized.copy + expect(Object.keys(enKeys).length).toBe(16) + expect(Object.keys(koKeys).length).toBe(16) + for (const [hash, enVal] of Object.entries(enKeys)) { + expect(typeof enVal).toBe('string') + expect((koKeys as Record)[hash]).toBeTruthy() + } + }) +}) diff --git a/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts new file mode 100644 index 00000000000..316011b4a0f --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-wall-setup-checklist-localized-copy.ts @@ -0,0 +1,99 @@ +import type { + FeatureWallSetupStep, + FeatureWallSetupStepId +} from '../../../../shared/feature-wall-setup-steps' +import { translate } from '@/i18n/i18n' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' + +type LocalizedFeatureWallSetupChecklistCopy = Pick + +const getLocalizedFeatureWallSetupChecklistCopyById = createLocalizedCatalog( + (): Record => ({ + 'two-worktrees': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.ec0a363633', + 'Multi-task' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.62bac8f43c', + 'Work in 2 different worktrees at once. Each one is isolated (even in the same project). Perfect for working on 2 features at once.' + ) + }, + browser: { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.908898c3ee', + "Use Orca's browser" + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.43781563c3', + 'Browse your web app without leaving Orca. Grab any element and send its exact source and styles to an agent with one click.' + ) + }, + notifications: { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.29aa2c2077', + 'Turn on notifications' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.71bd9a8c95', + 'Know the moment an agent finishes, needs attention, or gets blocked.' + ) + }, + 'default-agent': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.46db810da8', + 'Choose your default agent' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.b8e5bae17f', + 'Start new work faster with your preferred agent already selected.' + ) + }, + 'agent-capabilities': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.fee5557b02', + 'Enable Orca CLI' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.7bcb4097fa', + 'Register the Orca shell command and install agent skills for browser, computer, and orchestration workflows.' + ) + }, + 'task-sources': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.ad342dd4c6', + 'Connect integrations' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.06fe30fdb0', + 'Start an agent from a task in one click and keep PR status in view.' + ) + }, + 'setup-script': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.eddc532e58', + 'Automate workspace setup' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.56049b74c2', + 'Run install and setup commands automatically so every new worktree is ready for agents.' + ) + }, + 'add-two-repos': { + name: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.2cf795433b', + 'Start work in multiple repos' + ), + description: translate( + 'auto.components.feature.wall.feature.wall.setup.checklist.localized.copy.42525ba8a4', + 'Bring your key repos into Orca so you can start agent work without hunting for folders.' + ) + } + }) +) + +export function getLocalizedFeatureWallSetupChecklistCopy( + step: FeatureWallSetupStep +): LocalizedFeatureWallSetupChecklistCopy { + return getLocalizedFeatureWallSetupChecklistCopyById()[step.id] +} diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx index 74476af1fb2..d5dac1efab5 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -89,6 +89,24 @@ describe('NativeChatPickerMenu', () => { expect(screen.getAllByText('Loading skills...')).toHaveLength(2) }) + it('renders a retryable error instead of the loading spinner when discovery fails', () => { + const onRetry = vi.fn() + render( + + ) + + expect(screen.getAllByText('Could not load skills from this host')).toHaveLength(2) + expect(screen.queryByText('Loading skills...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(onRetry).toHaveBeenCalledOnce() + }) + it('uses command-only empty copy for a picker without skill support', () => { render( ({ onCompositionEnd?: (event: { currentTarget: HTMLTextAreaElement }) => void sessionOptionsSurface?: SessionOptionsSurface | null sessionOptionsSnapshot?: SessionOptionDescriptor[] + attachDisabled?: boolean } | null, modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown', confirmationObserver: null as { @@ -31,6 +32,7 @@ const mocks = vi.hoisted(() => ({ createClaudeModelSwitchConfirmationObserver: vi.fn(), discoverCommitMessageModels: vi.fn(), draft: 'hello', + imageAttachments: [] as { id: string; path: string }[], getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), @@ -109,7 +111,7 @@ vi.mock('./use-native-chat-skills', () => ({ })) vi.mock('./use-native-chat-composer-attachments', () => ({ useNativeChatComposerAttachments: () => ({ - imageAttachments: [], + imageAttachments: mocks.imageAttachments, attachResolvedPaths: vi.fn(), clearImageAttachments: vi.fn(), removeImageAttachment: vi.fn() @@ -150,6 +152,7 @@ describe('NativeChatComposer', () => { mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' mocks.draft = 'hello' + mocks.imageAttachments = [] mocks.draftScopeKeys.length = 0 mocks.confirmationObserver = null mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { @@ -244,6 +247,85 @@ describe('NativeChatComposer', () => { expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + it('routes structured sends and hydrated options through the existing composer', async () => { + const send = vi.fn(() => true) + const dispatchCommand = vi.fn(async () => ({ + handled: false, + accepted: false, + error: null + })) + const optionsSurface = { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + } satisfies SessionOptionsSurface + const optionSnapshot = [{ id: 'model' }] as SessionOptionDescriptor[] + render( + + ) + + expect(mocks.fieldProps?.sessionOptionsSurface).toBe(optionsSurface) + expect(mocks.fieldProps?.sessionOptionsSnapshot).toBe(optionSnapshot) + expect(mocks.fieldProps?.attachDisabled).toBe(false) + await act(async () => mocks.fieldProps?.onSend?.()) + + expect(dispatchCommand).toHaveBeenCalledWith('hello') + expect(send).toHaveBeenCalledWith('hello', []) + expect(mocks.sendNativeChatMessage).not.toHaveBeenCalled() + expect(mocks.setDraft).toHaveBeenCalledWith('') + }) + + it('sends structured image attachments through the durable transport', async () => { + mocks.draft = '' + mocks.imageAttachments = [{ id: 'image-1', path: '/tmp/image.png' }] + const send = vi.fn(() => true) + render( + ({ + handled: false, + accepted: false, + error: null + })), + optionsSurface: { + getSnapshot: () => [], + setOption: vi.fn(), + invokeAction: vi.fn(), + subscribe: () => () => {} + }, + optionSnapshot: [], + worktreeId: 'wt-1', + onError: vi.fn(), + runtime: 'local' + }} + /> + ) + + await act(async () => mocks.fieldProps?.onSend?.()) + + expect(send).toHaveBeenCalledWith('', mocks.imageAttachments) + expect(mocks.setDraft).toHaveBeenCalledWith('') + }) + it('types Codex slash composer sends instead of pasting them', () => { mocks.draft = '/status' render( diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index a0fec999d8a..256e7878f7a 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -2,16 +2,11 @@ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState import { useAppStore } from '../../store' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' -import { - sendNativeChatMessage, - sendNativeChatTypedCommand, - submitNativeChatPrompt -} from './native-chat-runtime-send' -import type { NativeChatSendHandle } from './native-chat-runtime-send' -import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' -import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' -import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' +import { + isStructuredAgentSessionComposerCommand, + STRUCTURED_AGENT_SESSION_SLASH_COMMANDS +} from '../../../../shared/structured-agent-session-composer' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { applyMentionSuggestion, @@ -23,10 +18,7 @@ import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatDraft } from './use-native-chat-draft' import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft-adoption' import { NativeChatComposerField } from './NativeChatComposerField' -import { - nativeChatComposerTargetIsRemote, - type NativeChatResolvedTarget -} from './native-chat-composer-target' +import type { NativeChatResolvedTarget } from './native-chat-composer-target' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' @@ -43,6 +35,8 @@ import type { NativeChatComposerHandle, NativeChatComposerProps } from './native-chat-composer-types' +import { dispatchNativeChatStructuredComposerText } from './native-chat-structured-composer-dispatch' +import { useNativeChatPtyComposerSend } from './use-native-chat-pty-composer-send' export type { NativeChatComposerHandle, @@ -79,7 +73,8 @@ export const NativeChatComposer = forwardRef getVerifiedNativeChatCommands(agent), [agent]) + const agentCommands = useMemo( + () => + structuredTransport + ? STRUCTURED_AGENT_SESSION_SLASH_COMMANDS + : getVerifiedNativeChatCommands(agent), + [agent, structuredTransport] + ) const picker = useNativeChatPickerState({ agent, terminalTabId, @@ -160,7 +161,9 @@ export const NativeChatComposer = forwardRef { setCaret(el.selectionStart ?? el.value.length) @@ -169,6 +172,7 @@ export const NativeChatComposer = forwardRef { - const text = draft - const imagePaths = imageAttachments.map((attachment) => attachment.path) - if ((text.trim() === '' && imagePaths.length === 0) || disabled) { - return - } - // Why: block a normal send while a session-option command (e.g. /model) is - // still writing its body+delayed-Enter to the same pty, so the two write - // sequences can't interleave on one input line. - if (isDispatchingSessionOption) { - return - } - const target = resolveTarget() - if (!target) { - return - } - const classification = classifySend(text) - // A parked launch draft must be cleared line-by-line before the body. - const { sendOptions } = resolveNativeChatLaunchDraftSend({ - launchDraft, - launchDraftResolved, + const sendStructured = useCallback( + (text: string, attachments = imageAttachments): void => { + if (!structuredTransport) { + return + } + if (attachments.length > 0 && isStructuredAgentSessionComposerCommand(text, agent)) { + structuredTransport.onError('Remove attachments before using a chat-session command.') + return + } + void dispatchNativeChatStructuredComposerText(structuredTransport, text, attachments) + .then(({ accepted, error }) => { + structuredTransport.onError(error) + if (!accepted) { + return + } + emitNativeChatMessageSent({ agent, runtime: structuredTransport.runtime }) + setHistory((previous) => pushHistory(previous, text)) + setDraft('') + setCaret(0) + clearSkillOrigin() + clearImageAttachments() + }) + .catch((error) => + structuredTransport.onError(error instanceof Error ? error.message : String(error)) + ) + }, + [ agent, - readScreen: () => readTerminalScreen?.() - }) - let pendingHandle: NativeChatSendHandle | null = null - // Why: image attachments take the attachment send path even for a - // command/unknown send, otherwise `clearImageAttachments()` below drops - // them silently when the text starts with the agent's slash/skill prefix. - if (classification !== 'chat' && imagePaths.length === 0) { - pendingHandle = - agent === 'codex' && isSlashCommandDraft(text) - ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) - : sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) - } else if (imagePaths.length > 0) { - pendingHandle = sendNativeChatMessageWithImageAttachments( - target.settings, - target.ptyId, - text, - imagePaths, - sendOptions - ) - } else if (text.trim().length > 0) { - pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) - } else { - submitNativeChatPrompt(target.settings, target.ptyId) - } - if (classification !== 'chat') { - if (pendingHandle) { - trackPendingSend(pendingHandle) - } - // Why: only verified catalog commands can truthfully claim they ran or - // mutate session-option state; unknown slash-like text has no such proof. - if (classification === 'command') { - onSlashCommand?.(text.trim()) - sessionOptionsSurface?.recordOutgoingCommand(text.trim()) - } - } else { - const pendingId = onOptimisticSend?.(text, imagePaths) - if (pendingHandle) { - trackPendingSend(pendingHandle, pendingId) - } - } - // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The - // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. - emitNativeChatMessageSent({ - agent, - runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' - }) - setHistory((prev) => pushHistory(prev, text)) - setDraft('') - setCaret(0) - clearSkillOrigin() - clearImageAttachments() - setNotice(null) - // The send cleared the TUI input line before its body, so retire the seed. - useAppStore.getState().clearNativeChatLaunchDraft(terminalTabId) - }, [ + clearImageAttachments, + clearSkillOrigin, + imageAttachments, + setDraft, + structuredTransport + ] + ) + + const sendPty = useNativeChatPtyComposerSend({ agent, - classifySend, - clearSkillOrigin, - clearImageAttachments, draft, imageAttachments, disabled, @@ -325,13 +289,26 @@ export const NativeChatComposer = forwardRef { + if (!structuredTransport) { + sendPty() + } else if ((draft.trim() !== '' || imageAttachments.length > 0) && !disabled) { + sendStructured(draft, imageAttachments) + } + }, [disabled, draft, imageAttachments, sendPty, sendStructured, structuredTransport]) const interrupt = useCallback(() => { cancelPendingSends() @@ -346,13 +323,13 @@ export const NativeChatComposer = forwardRef[0]) => { + if (structuredTransport) { + sendStructured(`/${command.name}`) + return + } + dispatchPtyPickerCommand(command) + }, + [dispatchPtyPickerCommand, sendStructured, structuredTransport] + ) const handleKeyDown = useNativeChatComposerKeyDown({ autocomplete, @@ -448,6 +435,7 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx index 2e1b02ac249..99db859aab5 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -59,4 +59,59 @@ describe('NativeChatComposerActions', () => { const dictation = screen.getByRole('button', { name: 'Start dictation' }) expect(pickers.nextElementSibling).toBe(dictation) }) + + it('marks the streaming Stop control as the critical hit target', () => { + render( + + ) + + expect( + screen + .getByRole('button', { name: 'Stop the agent' }) + .getAttribute('data-native-chat-critical-action') + ).toBe('stop') + }) + + it('ignores the second click of a double-click after send becomes Stop', () => { + const onSend = vi.fn() + const onStop = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Stop the agent' }), { detail: 2 }) + + expect(onSend).not.toHaveBeenCalled() + expect(onStop).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index aee0f79c3c0..3ad7f6ab2be 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -7,6 +7,7 @@ import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' +import type { NativeChatOptionPickerRequest } from './native-chat-composer-types' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -23,6 +24,7 @@ export type NativeChatComposerActionsProps = { onStop?: () => void sessionOptionsSurface: SessionOptionsSurface | null sessionOptionsSnapshot: SessionOptionDescriptor[] + sessionOptionsPickerRequest?: NativeChatOptionPickerRequest | null } export function NativeChatComposerActions({ @@ -39,8 +41,21 @@ export function NativeChatComposerActions({ onSend, onStop, sessionOptionsSurface, - sessionOptionsSnapshot + sessionOptionsSnapshot, + sessionOptionsPickerRequest }: NativeChatComposerActionsProps): React.JSX.Element { + const handleCriticalAction = (event: React.MouseEvent): void => { + // A double-click commonly lands after the first send has started and the button has + // changed to Stop; ignore the second click instead of cancelling the new turn. + if (event.detail > 1) { + return + } + if (isWorking) { + onStop?.() + } else { + onSend() + } + } const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') @@ -73,6 +88,7 @@ export function NativeChatComposerActions({ surface={sessionOptionsSurface} snapshot={sessionOptionsSnapshot} isWorking={isWorking} + pickerRequest={sessionOptionsPickerRequest} /> @@ -120,13 +136,14 @@ export function NativeChatComposerActions({
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx new file mode 100644 index 00000000000..248b2bae40b --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.provider-frame.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { ProviderFrameRow } from './NativeChatMessageList' + +afterEach(cleanup) + +describe('ProviderFrameRow', () => { + it('renders a compact provider-kind summary with expandable bounded detail', () => { + const { container } = render( + + ) + + expect(container.querySelector('details')).toBeInTheDocument() + expect(screen.getByText('codex')).toBeInTheDocument() + expect(screen.getByText('notification:new/event')).toBeInTheDocument() + expect(screen.getByText('{"future":true}')).toBeInTheDocument() + }) + + it('leads with the provider sentence instead of the raw frame kind', () => { + render( + + ) + + expect(screen.getByText('Your plan limit resets in 2 hours.')).toBeInTheDocument() + expect(screen.queryByText('notification:warning')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index f3393a188b1..4a261af1d8f 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -19,7 +19,8 @@ import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './nat import { isNativeChatPastedImagePath } from './native-chat-image-paste' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatCopyButton } from './NativeChatCopyButton' -import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' +import { nativeChatProviderFrameSummary } from '../../../../shared/native-chat-provider-frame-summary' function geometryOf(el: HTMLElement): ScrollGeometry { return { scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight } @@ -119,6 +120,34 @@ function TypingIndicatorRow(): React.JSX.Element { ) } +export function ProviderFrameRow({ block }: { block: NativeChatBlock }): React.JSX.Element | null { + if (block.type !== 'text' || !block.providerFrame) { + return null + } + const frame = block.providerFrame + return ( +
+ + › + {frame.provider} + {nativeChatProviderFrameSummary(block)} + {frame.payload.truncated ? ( + + ·{' '} + {translate('components.native-chat.providerFrame.byteLength', '{{value0}} bytes', { + value0: frame.payload.byteLength + })} + + ) : null} + +
+        {frame.payload.head}
+        {frame.payload.truncated ? '\n…' : ''}
+      
+
+ ) +} + /** One message: its prose first, then a collapsible run folding all of the * turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a * lifted card, assistant prose as body copy, reasoning de-emphasized. */ @@ -145,6 +174,7 @@ function MessageRow({ const isUser = message.role === 'user' const isReasoning = message.role === 'reasoning' const isSystem = message.role === 'system' + const providerFrame = message.blocks.find((block) => block.type === 'text' && block.providerFrame) const scrollToTop = useCallback(() => { if (rowRef.current) { @@ -159,6 +189,14 @@ function MessageRow({ return null } + if (providerFrame) { + return ( +
+ +
+ ) + } + if (isUser) { // Why: an optimistic echo is rendered identically to a real user turn (no // muting, no "Queued" label) so that when the real transcript turn lands and @@ -270,8 +308,7 @@ export function NativeChatMessageList({ () => stripNoiseMessages(foldToolMessages(orderNativeChatMessages(session.messages))), [session.messages] ) - const showTypingIndicator = - isWorking && !messages.some((message) => message.id === NATIVE_CHAT_STREAMING_ID) + const showTypingIndicator = shouldShowNativeChatTypingIndicator({ messages, isWorking }) // When an older page prepends, the scroll content grows above the viewport. // Capture the pre-render scroll height so the layout effect can restore the diff --git a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx new file mode 100644 index 00000000000..a13f672feb4 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx @@ -0,0 +1,33 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' + +describe('NativeChatOrchestrationPausedNotice', () => { + afterEach(cleanup) + + it('stays hidden while dispatch state is loading or settled', () => { + const { rerender } = render() + + expect(screen.queryByRole('status')).toBeNull() + + rerender() + expect(screen.queryByRole('status')).toBeNull() + }) + + it.each(['pending', 'dispatched'] as const)( + 'persists recovery guidance for an active %s Dispatch', + (dispatchStatus) => { + render() + + const notice = screen.getByRole('status') + expect(notice.textContent).toContain('Orchestration paused') + expect(notice.textContent).toContain('Structured Chat blocks terminal prompts and sends') + expect(notice.textContent).toContain('Orchestration messages remain queued') + expect(notice.textContent).toContain( + 'switch to Terminal, then check the Orca inbox with orca orchestration check' + ) + } + ) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx new file mode 100644 index 00000000000..da3bfec5aaa --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx @@ -0,0 +1,42 @@ +import { PauseCircle } from 'lucide-react' +import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' +import { Badge } from '@/components/ui/badge' +import { translate } from '@/i18n/i18n' + +export function NativeChatOrchestrationPausedNotice({ + dispatchStatus +}: { + dispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] +}): React.JSX.Element | null { + if (dispatchStatus !== 'pending' && dispatchStatus !== 'dispatched') { + return null + } + + return ( +
+
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx index 26805ed2f06..b6748fe7e82 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.test.tsx @@ -25,9 +25,20 @@ afterEach(() => { container.remove() }) -function render(prompt: AskPrompt, onAnswer: (s: AskAnswerSelection[]) => void): void { +function render( + prompt: AskPrompt, + onAnswer: (s: AskAnswerSelection[]) => void, + allowOther = true +): void { act(() => { - root.render( {}} />) + root.render( + {}} + allowOther={allowOther} + /> + ) }) } @@ -137,4 +148,11 @@ describe('NativeChatQuestionCard', () => { expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }]) }) + + it('hides free text when the provider requires a listed option', () => { + render(tabsOrSpaces, vi.fn(), false) + + expect(container.querySelector('input')).toBeNull() + expect(container.textContent).not.toContain('Type your answer') + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx index adabecf67b8..4bc881ee3e1 100644 --- a/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatQuestionCard.tsx @@ -10,6 +10,7 @@ export type NativeChatQuestionCardProps = { isSubmitting?: boolean /** Deliver the chosen answer (per-question option indices + free text). */ onAnswer: (selections: AskAnswerSelection[]) => void + allowOther?: boolean /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void /** Exposes the free-text row so pane-level Paste can target it while the @@ -20,7 +21,7 @@ export type NativeChatQuestionCardProps = { /** * Native renderer for an agent's AskUserQuestion prompt: a numbered pick-list * (mobile/Claude-Code parity) with a header + close, a hover-highlighted row per - * option, and an always-present free-text row for a custom answer. Single-select + * option, and an optional free-text row for a custom answer. Single-select * commits on click; multi-select toggles and confirms via the trailing action. * Multi-question prompts step through tabs across the top. Neutral shadcn tokens. */ @@ -28,6 +29,7 @@ export function NativeChatQuestionCard({ prompt, isSubmitting = false, onAnswer, + allowOther = true, onCancel, answerInputRef }: NativeChatQuestionCardProps): React.JSX.Element { @@ -184,26 +186,32 @@ export function NativeChatQuestionCard({ /> ))}
- - - - setOther(index, e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault() - confirm(true) - } - }} - placeholder={translate( - 'components.native-chat.question.otherPlaceholder', - 'Type your answer' - )} - className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-default disabled:opacity-50" - /> + {allowOther ? ( + <> + + + + setOther(index, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirm(true) + } + }} + placeholder={translate( + 'components.native-chat.question.otherPlaceholder', + 'Type your answer' + )} + className="min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/60 disabled:cursor-default disabled:opacity-50" + /> + + ) : ( + + )} +
+ ) : null} + {controller.error || composerError ? ( +

+ {controller.error ?? composerError} +

+ ) : null} + {prompt ? null : ( + { + if (controller.turnId) { + void controller.cancel(controller.turnId) + } + }} + structuredTransport={structuredTransport} + /> + )} + + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index fa5358a3fad..a092b8f00bb 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -4,7 +4,9 @@ import '@testing-library/jest-dom/vitest' import { cleanup, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' import type { NativeChatBlock } from '../../../../shared/native-chat-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' import { NativeChatToolRun } from './NativeChatToolRun' afterEach(cleanup) @@ -24,4 +26,67 @@ describe('NativeChatToolRun', () => { expect(screen.getByTitle('src/index.ts')).toHaveTextContent('src/index.ts') expect(screen.queryByTitle('{"file_path":"src/index.ts","offset":10}')).toBeNull() }) + + it('renders structured apply_patch changes as a reviewable diff instead of JSON', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'apply_patch', + input: { + changes: [ + { + path: '/repo/src/app.ts', + kind: { type: 'update', move_path: null }, + diff: '@@ -1 +1 @@\n-before\n+after' + } + ] + } + } + ] + + const { container } = render() + + expect(screen.getByText('+after')).toBeInTheDocument() + expect(screen.getByText('-before')).toBeInTheDocument() + expect(container.querySelector('pre')).toBeNull() + }) + + it('renders evidence-shaped projected patches as colored diffs without changes JSON', () => { + const item: AgentJournalRenderItem = { + itemId: 'apply-patch', + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'tool-call', + name: 'apply_patch', + input: { + changes: [ + { + path: 'src/app.ts', + diff: '@@ -1 +1 @@\n-before\n+after' + } + ] + }, + state: 'completed' + } + } + const projected = projectStructuredItemToNativeChat(item) + + expect(projected).not.toBeNull() + const { container } = render( + + ) + + expect(screen.getByText('+after')).toHaveClass( + 'bg-emerald-500/10', + 'text-[var(--git-decoration-added)]' + ) + expect(screen.getByText('-before')).toHaveClass( + 'bg-rose-500/10', + 'text-[var(--git-decoration-deleted)]' + ) + expect(container).not.toHaveTextContent('"changes"') + expect(container.querySelector('pre')).toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index ed798188fcc..f4172a1d7a6 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption' import { useNativeChatRetainedSession } from './use-native-chat-retained-session' @@ -13,7 +12,6 @@ import { NativeChatInteractiveCard } from './NativeChatInteractiveCard' import { NativeChatEmptyState } from './NativeChatEmptyState' import { NativeChatSessionGate } from './NativeChatSessionGate' import { useNativeChatInteractiveSend } from './use-native-chat-interactive-send' -import { findTabAgentEntry } from './native-chat-tab-agent-entry' import { shouldClearNativeChatWorkingSuppression, shouldShowNativeChatWorking @@ -49,16 +47,26 @@ import { emptyNativeChatContextMenuActions, useNativeChatContextMenu } from './use-native-chat-context-menu' -import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatResolvedViewProps, NativeChatViewProps } from './native-chat-view-types' +import { NativeChatStructuredSession } from './NativeChatStructuredSession' +import { useNativeChatStatusEntry } from './use-native-chat-status-entry' +import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' export type { NativeChatViewProps } from './native-chat-view-types' /** Resolves an agent terminal into its native conversation and composer UI. */ -export default function NativeChatView({ +export default function NativeChatView(props: NativeChatViewProps): React.JSX.Element { + if (props.mode === 'structured') { + return + } + return +} + +function NativeChatBridgeView({ terminalTabId, isVisible, paneKey: preferredPaneKey, @@ -67,21 +75,13 @@ export default function NativeChatView({ resolvedAgent, onSwitchToTerminal, readTerminalScreen, - contextMenuActions -}: NativeChatViewProps): React.JSX.Element { - // Select only this tab's status entry (shallow-compared) so an unrelated - // pane's status tick doesn't re-render this view or re-run the resolution. - const agentStatusEntry = useAppStore( - useShallow((s) => - preferredPaneKey - ? s.agentStatusByPaneKey[preferredPaneKey] - : findTabAgentEntry(s.agentStatusByPaneKey, terminalTabId) - ) + contextMenuActions, + orchestrationDispatchStatus +}: Exclude): React.JSX.Element { + const { entry: agentStatusEntry, paneKey } = useNativeChatStatusEntry( + terminalTabId, + preferredPaneKey ) - - // paneKey: prefer the live entry's key; fall back to the tab id so the hook - // still has a stable key to select live status by before any pane reports. - const paneKey = preferredPaneKey ?? agentStatusEntry?.paneKey ?? `${terminalTabId}:` return ( )} @@ -118,7 +119,8 @@ function NativeChatResolvedView({ terminalTabId, onSwitchToTerminal, readTerminalScreen, - contextMenuActions + contextMenuActions, + orchestrationDispatchStatus }: NativeChatResolvedViewProps): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to // the remote runtime host for a runtime-owned pane; null keeps the local path. @@ -171,9 +173,7 @@ function NativeChatResolvedView({ // The question card's free-text row; keeps Paste working while the card // replaces the composer. const questionAnswerInputRef = useRef(null) - const fileLinkContext = useAppStore( - useShallow((s) => resolveNativeChatFileLinkContext(s, terminalTabId)) - ) + const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) const pasteClipboardIntoComposer = useNativeChatPasteBridge({ rootRef, composerRef, @@ -181,7 +181,6 @@ function NativeChatResolvedView({ }) const contextMenu = useNativeChatContextMenu({ rootRef, - onSwitchToTerminal, actions: { onPaste: pasteClipboardIntoComposer, ...(contextMenuActions ?? emptyNativeChatContextMenuActions) @@ -367,6 +366,7 @@ function NativeChatResolvedView({
{ if (event.button === 2) { @@ -400,6 +400,7 @@ function NativeChatResolvedView({ onContextMenuCapture={contextMenu.onContextMenuCapture} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > +
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx new file mode 100644 index 00000000000..25991eac3ff --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup } from '../../../../shared/tab-types' + +type MockAppState = { + unifiedTabsByWorktree: Record + groupsByWorktree: Record + runtimeEnvironmentId: string | null + executionHostId: string + focusGroup: (worktreeId: string, groupId: string) => void +} + +const mocks = vi.hoisted(() => ({ + store: null as null | { setState: (state: Partial) => void }, + focusGroup: vi.fn(), + mountsByTabId: new Map(), + unmountsByTabId: new Map() +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + runtimeEnvironmentId: null, + executionHostId: 'local', + focusGroup: mocks.focusGroup + })) + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: MockAppState) => state.runtimeEnvironmentId, + getExecutionHostIdForWorktree: (state: MockAppState) => state.executionHostId +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string | null + }) => + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' } +})) + +vi.mock('./NativeChatView', async () => { + const { useEffect } = await import('react') + return { + default: function MockNativeChatView({ + tabId, + isVisible + }: { + tabId: string + isVisible: boolean + }) { + useEffect(() => { + mocks.mountsByTabId.set(tabId, (mocks.mountsByTabId.get(tabId) ?? 0) + 1) + return () => { + mocks.unmountsByTabId.set(tabId, (mocks.unmountsByTabId.get(tabId) ?? 0) + 1) + } + }, [tabId]) + return ( + + ) + } + } +}) + +import StructuredAgentSessionPaneOverlayLayer from './StructuredAgentSessionPaneOverlayLayer' + +const WORKTREE_ID = 'wt-1' +const GROUP_ID = 'group-1' +const FIRST_TAB_ID = 'structured-agent-session-session-1' +const SECOND_TAB_ID = 'structured-agent-session-session-2' + +describe('StructuredAgentSessionPaneOverlayLayer', () => { + beforeEach(() => { + mocks.focusGroup.mockClear() + mocks.mountsByTabId.clear() + mocks.unmountsByTabId.clear() + mocks.store?.setState(createState(FIRST_TAB_ID)) + }) + + afterEach(cleanup) + + it('keeps materialized chat surfaces mounted while activation only swaps visibility', () => { + const view = render( + + ) + const firstBefore = chatSurface(view.container, FIRST_TAB_ID) + const secondBefore = chatSurface(view.container, SECOND_TAB_ID) + + expect(firstBefore.dataset.chatVisible).toBe('true') + expect(secondBefore.dataset.chatVisible).toBe('false') + expect(mocks.mountsByTabId).toEqual( + new Map([ + [FIRST_TAB_ID, 1], + [SECOND_TAB_ID, 1] + ]) + ) + + act(() => { + mocks.store?.setState({ + groupsByWorktree: { + [WORKTREE_ID]: [createGroup(SECOND_TAB_ID)] + } + }) + }) + + const firstAfter = chatSurface(view.container, FIRST_TAB_ID) + const secondAfter = chatSurface(view.container, SECOND_TAB_ID) + expect(firstAfter).toBe(firstBefore) + expect(secondAfter).toBe(secondBefore) + expect(firstAfter.dataset.chatVisible).toBe('false') + expect(secondAfter.dataset.chatVisible).toBe('true') + expect(mocks.mountsByTabId.get(FIRST_TAB_ID)).toBe(1) + expect(mocks.mountsByTabId.get(SECOND_TAB_ID)).toBe(1) + expect(mocks.unmountsByTabId.size).toBe(0) + }) + + it('routes overlay interaction back to the owning split group', () => { + const view = render( + + ) + const slot = view.container.querySelector( + `[data-structured-agent-session-overlay-tab-id="${FIRST_TAB_ID}"]` + ) + + expect(slot).not.toBeNull() + fireEvent.pointerDown(slot!) + expect(mocks.focusGroup).toHaveBeenCalledWith(WORKTREE_ID, GROUP_ID) + }) + + it('keeps the base z-layer overridable by the working-chat stylesheet rule', () => { + const view = render( + + ) + const slot = view.container.querySelector( + `[data-structured-agent-session-overlay-tab-id="${FIRST_TAB_ID}"]` + ) + + expect(slot).not.toBeNull() + expect(slot?.classList.contains('native-chat-pane-shell')).toBe(true) + expect(slot?.classList.contains('z-10')).toBe(true) + expect(slot?.style.zIndex).toBe('') + expect(slot?.querySelector('[data-native-chat-working="true"]')).not.toBeNull() + }) +}) + +function createState(activeTabId: string): MockAppState { + return { + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + structuredTab(FIRST_TAB_ID, 'session-1', 0), + structuredTab(SECOND_TAB_ID, 'session-2', 1) + ] + }, + groupsByWorktree: { [WORKTREE_ID]: [createGroup(activeTabId)] }, + runtimeEnvironmentId: null, + executionHostId: 'local', + focusGroup: mocks.focusGroup + } +} + +function createGroup(activeTabId: string): TabGroup { + return { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId, + tabOrder: [FIRST_TAB_ID, SECOND_TAB_ID] + } +} + +function structuredTab(id: string, sessionId: string, sortOrder: number): Tab { + return { + id, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +function chatSurface(container: HTMLElement, tabId: string): HTMLElement { + const surface = container.querySelector(`[data-chat-tab-id="${tabId}"]`) + if (!surface) { + throw new Error(`missing structured chat surface ${tabId}`) + } + return surface +} diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx new file mode 100644 index 00000000000..555d23b9ec4 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionPaneOverlayLayer.tsx @@ -0,0 +1,139 @@ +import { memo, useCallback, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { Tab, TabGroup } from '../../../../shared/tab-types' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { useAppStore } from '@/store' +import { + getExecutionHostIdForWorktree, + getRuntimeEnvironmentIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { getActiveRuntimeTarget, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { tabGroupBodyAnchorName } from '../tab-group/tab-group-body-anchor' +import NativeChatView from './NativeChatView' + +type StructuredAgentSessionTab = Tab & { + contentType: 'agent-session' + agentSessionAgent: NonNullable +} + +const EMPTY_UNIFIED_TABS: readonly Tab[] = [] +const EMPTY_GROUPS: readonly TabGroup[] = [] + +const StructuredAgentSessionOverlaySlot = memo(function StructuredAgentSessionOverlaySlot({ + tab, + groupId, + isActive, + target, + allowFileUriLinks, + onFocusOwningGroup +}: { + tab: StructuredAgentSessionTab + groupId: string | undefined + isActive: boolean + target: RuntimeClientTarget + allowFileUriLinks: boolean + onFocusOwningGroup: ((groupId: string) => void) | undefined +}): React.JSX.Element { + const anchorName = groupId !== undefined ? tabGroupBodyAnchorName(groupId) : undefined + const style = useMemo( + () => + anchorName + ? { + position: 'absolute', + positionAnchor: anchorName, + top: `anchor(${anchorName} top)`, + left: `anchor(${anchorName} left)`, + width: `anchor-size(${anchorName} width)`, + height: `anchor-size(${anchorName} height)`, + display: isActive ? 'flex' : 'none', + pointerEvents: isActive ? 'auto' : 'none' + } + : { display: 'none' }, + [anchorName, isActive] + ) + const focusOwningGroup = useCallback(() => { + if (groupId !== undefined && onFocusOwningGroup) { + onFocusOwningGroup(groupId) + } + }, [groupId, onFocusOwningGroup]) + + return ( +
+ +
+ ) +}) + +const StructuredAgentSessionPaneOverlayLayer = memo( + function StructuredAgentSessionPaneOverlayLayer({ + worktreeId, + isWorktreeActive + }: { + worktreeId: string + isWorktreeActive: boolean + }): React.JSX.Element { + const { unifiedTabs, groups, runtimeEnvironmentId, allowFileUriLinks } = useAppStore( + useShallow((state) => ({ + unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_UNIFIED_TABS, + groups: state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId), + allowFileUriLinks: getExecutionHostIdForWorktree(state, worktreeId) === 'local' + })) + ) + const focusGroup = useAppStore((state) => state.focusGroup) + const target = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }), + [runtimeEnvironmentId] + ) + const focusOwningGroup = useCallback( + (groupId: string) => focusGroup(worktreeId, groupId), + [focusGroup, worktreeId] + ) + const groupActiveTabById = useMemo( + () => new Map(groups.map((group) => [group.id, group.activeTabId] as const)), + [groups] + ) + const structuredTabs = useMemo( + () => + unifiedTabs.filter( + (tab): tab is StructuredAgentSessionTab => + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ), + [unifiedTabs] + ) + + return ( + <> + {structuredTabs.map((tab) => ( + + ))} + + ) + } +) + +export default StructuredAgentSessionPaneOverlayLayer diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx new file mode 100644 index 00000000000..aca391e238f --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -0,0 +1,238 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + removeAgentStatus: vi.fn(), + setAgentStatus: vi.fn(), + store: null as null | { + getState: () => Record + setState: (state: Record) => void + }, + subscribe: vi.fn(), + unsubscribe: vi.fn() +})) + +vi.mock('@/store', async () => { + const { create } = await import('zustand') + const useAppStore = create<{ + agentStatusByPaneKey: Record> + removeAgentStatus: (paneKey: string) => void + setAgentStatus: (...args: unknown[]) => void + testRuntimeOwner: string | null + unifiedTabsByWorktree: Record + }>((set, get) => ({ + agentStatusByPaneKey: {}, + removeAgentStatus: (paneKey) => { + mocks.removeAgentStatus(paneKey) + if (!get().agentStatusByPaneKey[paneKey]) { + return + } + const next = { ...get().agentStatusByPaneKey } + delete next[paneKey] + set({ agentStatusByPaneKey: next }) + }, + setAgentStatus: (...args) => { + mocks.setAgentStatus(...args) + const [paneKey, payload, terminalTitle, , routing, metadata] = args as [ + string, + Record, + string, + unknown, + Record, + Record + ] + set((state) => ({ + agentStatusByPaneKey: { + ...state.agentStatusByPaneKey, + [paneKey]: { + ...payload, + ...routing, + ...metadata, + paneKey, + terminalTitle, + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + })) + }, + testRuntimeOwner: null, + unifiedTabsByWorktree: {} + })) + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: (state: { testRuntimeOwner?: string | null }) => + state.testRuntimeOwner ?? null +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' + +const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' +} satisfies Tab + +const userItem = { + itemId: 'item-1', + revision: 1, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } +} as const + +const historyResult = { + ok: true, + providerSession: { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' }, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + fence: 1, + direction: 'tail', + items: [userItem], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-1', sequence: 1 }, + newest: { epoch: 'epoch-1', sequence: 1 }, + nextCursor: { epoch: 'epoch-1', sequence: 1 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 1 }, + hasOlder: false, + hasNewer: false + } +} + +function ActiveSessionRead(): null { + useStructuredAgentSessionRead({ + sessionId: structuredTab.entityId, + target: { kind: 'local' }, + isVisible: true + }) + return null +} + +function ActiveComposition(): React.JSX.Element { + return ( + <> + + + + ) +} + +describe('StructuredAgentSessionStatusBridge', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() + mocks.call.mockResolvedValue(historyResult) + mocks.subscribe.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.store?.setState({ + agentStatusByPaneKey: {}, + testRuntimeOwner: null, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionReadOwnersForTests() + }) + + it('keeps restored inactive tabs transport-neutral', async () => { + render() + await act(() => Promise.resolve()) + + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.setAgentStatus).not.toHaveBeenCalled() + }) + + it('shares the visible pane subscriber with status projection', async () => { + render() + + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) + expect(mocks.call).toHaveBeenCalledOnce() + expect(mocks.subscribe).toHaveBeenCalledOnce() + expect(mocks.setAgentStatus.mock.calls[0]?.[5]).toEqual({ + providerSession: historyResult.providerSession, + terminalResumeEligible: false + }) + }) + + it('keeps the status map reference stable for coalesced assistant deltas', async () => { + render() + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalledOnce()) + const before = mocks.store?.getState().agentStatusByPaneKey + const onEvent = mocks.subscribe.mock.calls[0]?.[2] as (event: unknown) => void + + act(() => { + for (let sequence = 2; sequence <= 12; sequence += 1) { + onEvent({ + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'epoch-1', sequence }, + items: [ + { + itemId: 'assistant-1', + revision: sequence, + sequence, + observedAt: sequence, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `delta-${sequence}` }] + } + } + ], + removedItemIds: [], + submissions: [] + } + }) + } + }) + await act(async () => new Promise((resolve) => setTimeout(resolve, 60))) + + expect(mocks.setAgentStatus).toHaveBeenCalledOnce() + expect(mocks.store?.getState().agentStatusByPaneKey).toBe(before) + }) + + it('does not project an unknown provider as Codex', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { + 'wt-1': [{ ...structuredTab, agentSessionAgent: 'gemini' }] + } + }) + render() + await act(() => Promise.resolve()) + + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.subscribe).not.toHaveBeenCalled() + expect(mocks.setAgentStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx new file mode 100644 index 00000000000..cdd33499c7e --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -0,0 +1,119 @@ +import { useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import { + hasPersistedStructuredAgentSessionTurn, + projectStructuredAgentSessionStatus, + structuredAgentSessionPaneKey +} from '../../../../shared/structured-agent-session-projection' +import type { StructuredAgentSessionState } from '../../../../shared/structured-agent-session-reducer' +import type { Tab } from '../../../../shared/tab-types' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { useAppStore } from '@/store' +import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { useStructuredAgentSessionReadObservation } from './use-structured-agent-session-read' + +type StructuredTab = Tab & { contentType: 'agent-session' } + +function latestPrompt(state: StructuredAgentSessionState): string { + for (let index = state.items.length - 1; index >= 0; index -= 1) { + const body = state.items[index]?.body + if (body?.kind === 'message' && body.role === 'user') { + return body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])).join('\n') + } + } + return '' +} + +function projectStatus( + tab: StructuredTab, + state: StructuredAgentSessionState, + providerSession: AgentProviderSessionMetadata | undefined +): void { + const paneKey = structuredAgentSessionPaneKey(tab.id, tab.entityId) + const store = useAppStore.getState() + if (!hasPersistedStructuredAgentSessionTurn(state.items)) { + if (store.agentStatusByPaneKey?.[paneKey]) { + store.removeAgentStatus(paneKey) + } + return + } + const projection = projectStructuredAgentSessionStatus(state.items) + const desired = { + state: projection === 'working' ? 'working' : projection === 'attention' ? 'blocked' : 'done', + prompt: latestPrompt(state), + agentType: tab.agentSessionAgent, + sessionBoundary: projection === 'idle' + } as const + const current = store.agentStatusByPaneKey?.[paneKey] + if ( + current?.state === desired.state && + current.prompt === desired.prompt && + current.agentType === desired.agentType && + current.sessionBoundary === desired.sessionBoundary && + current.terminalTitle === tab.label && + current.tabId === tab.id && + current.worktreeId === tab.worktreeId && + current.terminalResumeEligible === false && + agentProviderSessionsEqual(tab.agentSessionAgent, current.providerSession, providerSession) + ) { + return + } + store.setAgentStatus( + paneKey, + desired, + tab.label, + undefined, + { tabId: tab.id, worktreeId: tab.worktreeId }, + { + ...(providerSession ? { providerSession } : {}), + terminalResumeEligible: false + } + ) +} + +function StructuredAgentSessionStatusProjection({ tab }: { tab: StructuredTab }): null { + const environmentId = useAppStore((state) => + getRuntimeEnvironmentIdForWorktree(state, tab.worktreeId) + ) + const target = useMemo( + () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + [environmentId] + ) + const { providerSession, state } = useStructuredAgentSessionReadObservation({ + sessionId: tab.entityId, + target + }) + useEffect(() => { + projectStatus(tab, state, providerSession) + }, [providerSession, state, tab]) + useEffect( + () => () => + useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), + [tab.entityId, tab.id] + ) + return null +} + +export function StructuredAgentSessionStatusBridge(): React.JSX.Element { + const tabs = useAppStore( + useShallow((state) => + Object.values(state.unifiedTabsByWorktree) + .flat() + .filter( + (tab): tab is StructuredTab => + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ) + ) + ) + return ( + <> + {tabs.map((tab) => ( + + ))} + + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts index 3dde6373391..e90b7edcb44 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.test.ts @@ -25,6 +25,7 @@ vi.mock('@/i18n/i18n', () => ({ import { resolveNativeChatAttachmentOwner, + resolveNativeChatAttachmentOwnerForWorktree, uploadNativeChatAttachmentPaths } from './native-chat-attachment-upload' @@ -66,6 +67,28 @@ describe('resolveNativeChatAttachmentOwner', () => { expect(resolveNativeChatAttachmentOwner(state(), 'tab-1')).toEqual({ kind: 'local' }) }) + it('resolves a structured tab owner directly from its worktree', () => { + expect(resolveNativeChatAttachmentOwnerForWorktree(state(), 'wt-1')).toEqual({ + kind: 'local' + }) + }) + + it('resolves a structured SSH owner directly from its worktree', () => { + expect( + resolveNativeChatAttachmentOwnerForWorktree( + state({ + repos: [{ id: 'repo', connectionId: 'conn-1' }] as never, + sshConnectionStates: new Map([['conn-1', { connectionGeneration: 4 } as never]]) + }), + 'wt-1' + ) + ).toMatchObject({ + kind: 'ssh', + connectionId: 'conn-1', + worktreePath: '/repo/worktree' + }) + }) + it('resolves an SSH repo worktree to ssh with the worktree path', () => { expect( resolveNativeChatAttachmentOwner( diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index 76b3b6471c8..8157a5190ff 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -58,6 +58,14 @@ export function resolveNativeChatAttachmentOwner( if (!worktreeId) { return { kind: 'not-ready' } } + return resolveNativeChatAttachmentOwnerForWorktree(state, worktreeId, terminalTabId) +} + +export function resolveNativeChatAttachmentOwnerForWorktree( + state: NativeChatAttachmentOwnerState, + worktreeId: string, + terminalTabId?: string +): NativeChatAttachmentOwner { if (getRuntimeEnvironmentIdForWorktree(state, worktreeId)) { return { kind: 'runtime' } } @@ -68,7 +76,9 @@ export function resolveNativeChatAttachmentOwner( if (connectionId === null) { return { kind: 'local' } } - const worktreePath = resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + const worktreePath = terminalTabId + ? resolveNativeChatFileLinkContext(state, terminalTabId)?.worktreePath + : state.getKnownWorktreeById(worktreeId)?.path if (!worktreePath) { return { kind: 'not-ready' } } @@ -87,6 +97,13 @@ export function nativeChatWorktreeNotReadyNotice(): string { ) } +export function nativeChatLocalAttachmentUnsupportedNotice(): string { + return translate( + 'components.native-chat.composer.localAttachmentUnsupported', + 'Local attachments are not available for remote sessions.' + ) +} + /** * Upload client-local paths into `${worktreePath}/.orca/drops` on the SSH * remote and return the remote paths the agent can read (input order diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 58e97ccd209..45ebde1b375 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -1,5 +1,27 @@ import type { AgentType } from '../../../../shared/agent-status-types' +import type { StructuredAgentSessionCommandOutcome } from '../../../../shared/structured-agent-session-composer' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import type { NativeChatComposerImageAttachment } from './NativeChatComposerField' + +export type NativeChatOptionPickerRequest = { + id: string + sequence: number +} + +export type NativeChatStructuredComposerTransport = { + send: (text: string, attachments: readonly NativeChatComposerImageAttachment[]) => boolean + dispatchCommand: (text: string) => Promise + optionsSurface: SessionOptionsSurface + optionSnapshot: SessionOptionDescriptor[] + optionPickerRequest?: NativeChatOptionPickerRequest | null + worktreeId?: string + onError: (message: string | null) => void + runtime: 'local' | 'remote' +} export type NativeChatComposerProps = { /** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */ @@ -29,6 +51,8 @@ export type NativeChatComposerProps = { launchDraft?: NativeChatLaunchDraft | null /** True once the transcript shows the TUI-side draft was submitted or cleared. */ launchDraftResolved?: boolean + /** Structured journal transport; absent keeps the existing PTY path unchanged. */ + structuredTransport?: NativeChatStructuredComposerTransport } export type NativeChatComposerHandle = { diff --git a/src/renderer/src/components/native-chat/native-chat-diff.test.ts b/src/renderer/src/components/native-chat/native-chat-diff.test.ts index 229edc1e4bd..7f8fa7af575 100644 --- a/src/renderer/src/components/native-chat/native-chat-diff.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-diff.test.ts @@ -30,6 +30,39 @@ describe('diffFromToolCall', () => { ]) }) + it('reads structured apply_patch file changes as a diff', () => { + const diff = diffFromToolCall('apply_patch', { + changes: [ + { + path: '/repo/src/app.ts', + kind: { type: 'update', move_path: null }, + diff: '@@ -1,2 +1,2 @@\n const value = 1\n-old()\n+newValue()' + } + ] + }) + + expect(diff).toEqual([ + { kind: 'meta', text: '--- /repo/src/app.ts' }, + { kind: 'meta', text: '+++ /repo/src/app.ts' }, + { kind: 'meta', text: '@@ -1,2 +1,2 @@' }, + { kind: 'context', text: ' const value = 1' }, + { kind: 'del', text: 'old()' }, + { kind: 'add', text: 'newValue()' } + ]) + }) + + it('reads direct apply_patch text as a diff', () => { + expect( + diffFromToolCall('apply_patch', { + patch: '@@ -1 +1 @@\n-before\n+after' + }) + ).toEqual([ + { kind: 'meta', text: '@@ -1 +1 @@' }, + { kind: 'del', text: 'before' }, + { kind: 'add', text: 'after' } + ]) + }) + it('returns null when there is no old/new payload', () => { expect(diffFromToolCall('Edit', { file_path: '/x' })).toBeNull() }) diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index 17dae96d98d..ab5d273ce54 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store/types' import { @@ -32,6 +33,7 @@ function state(overrides: Partial = {}): AppState { tabsByWorktree: { 'wt-1': [terminalTab()] }, + unifiedTabsByWorktree: {}, worktreesByRepo: { repo: [{ id: 'wt-1', repoId: 'repo', path: '/repo/worktree' } as never] }, @@ -65,6 +67,33 @@ describe('resolveNativeChatFileLinkContext', () => { expect(resolveNativeChatFileLinkContext(state({ tabsByWorktree: {} }), 'tab-1')).toBeNull() }) + it('resolves the worktree context for a structured session tab', () => { + const structuredTab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + + expect( + resolveNativeChatFileLinkContext( + state({ + tabsByWorktree: {}, + unifiedTabsByWorktree: { 'wt-1': [structuredTab] } + }), + structuredTab.id + ) + ).toEqual(context) + }) + it('falls back to repo-scoped worktrees when a known worktree has no path', () => { expect( resolveNativeChatFileLinkContext( diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index 44c1e83b6ab..c2d77d066c5 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -28,7 +28,9 @@ type NativeChatFileLinkState = Pick< | 'settings' | 'tabsByWorktree' | 'worktreesByRepo' -> +> & { + unifiedTabsByWorktree?: AppState['unifiedTabsByWorktree'] +} export function findTerminalTabWorktreeId( tabsByWorktree: NativeChatFileLinkState['tabsByWorktree'], @@ -44,6 +46,18 @@ export function findTerminalTabWorktreeId( return null } +function findStructuredTabWorktreeId( + unifiedTabsByWorktree: NativeChatFileLinkState['unifiedTabsByWorktree'], + tabId: string +): string | null { + for (const [worktreeId, tabs] of Object.entries(unifiedTabsByWorktree ?? {})) { + if (tabs.some((tab) => tab.id === tabId && tab.contentType === 'agent-session')) { + return worktreeId + } + } + return null +} + function findWorktreeFallback( worktreesByRepo: NativeChatFileLinkState['worktreesByRepo'], worktreeId: string @@ -61,7 +75,9 @@ export function resolveNativeChatFileLinkContext( state: NativeChatFileLinkState, terminalTabId: string ): NativeChatFileLinkContext | null { - const worktreeId = findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) + const worktreeId = + findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) ?? + findStructuredTabWorktreeId(state.unifiedTabsByWorktree, terminalTabId) if (!worktreeId) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index 3aead4c8dda..dc1ba79be3a 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -160,6 +160,33 @@ describe('resolveNativeChatLeafRoute', () => { ).toEqual({ chatLeafId: null, exitChat: true }) }) + it('keeps structured chat open when its ownership transfer stops the TUI', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'adopted-agent', + activeLeafId: 'adopted-agent', + chatLeafStillMounted: false, + activeLeafIsEligible: false, + chatLeafHasConfirmedAgentExit: true, + structuredSessionId: 'codex_thread-1' + }) + ).toEqual({ chatLeafId: 'adopted-agent', exitChat: false }) + }) + + it('binds tab-bar structured adoption to the active leaf after the TUI exits', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: null, + activeLeafId: 'adopted-agent', + chatLeafStillMounted: true, + activeLeafIsEligible: false, + structuredSessionId: 'codex_thread-1' + }) + ).toEqual({ chatLeafId: 'adopted-agent', exitChat: false }) + }) + it('moves chat to an eligible sibling after the owning agent exits', () => { expect( resolveNativeChatLeafRoute({ diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index 2dfc25e77f8..59c660930c5 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -73,11 +73,19 @@ export function resolveNativeChatLeafRoute(args: { chatLeafStillMounted: boolean activeLeafIsEligible: boolean chatLeafHasConfirmedAgentExit?: boolean + structuredSessionId?: string | null }): NativeChatLeafRoute { + const confirmedAgentExit = args.chatLeafHasConfirmedAgentExit && !args.structuredSessionId if (!args.isChatViewMode) { return { chatLeafId: null, exitChat: false } } - if (args.chatLeafId && args.chatLeafStillMounted && !args.chatLeafHasConfirmedAgentExit) { + if (args.structuredSessionId) { + return { + chatLeafId: args.chatLeafId ?? args.activeLeafId, + exitChat: false + } + } + if (args.chatLeafId && args.chatLeafStillMounted && !confirmedAgentExit) { // Why: agent/title evidence can disappear while local, SSH, or runtime // transports reconnect. A mounted owning pane is not a terminal lifecycle // event, so keep its chat surface until the pane itself is removed. @@ -85,13 +93,10 @@ export function resolveNativeChatLeafRoute(args: { } // Manager hydration can briefly have no active pane; preserve the requested // mode until a concrete leaf exists instead of toggling it off during mount. - if (!args.activeLeafId && !args.chatLeafHasConfirmedAgentExit) { + if (!args.activeLeafId && !confirmedAgentExit) { return { chatLeafId: args.chatLeafId, exitChat: false } } - if ( - args.activeLeafIsEligible && - (!args.chatLeafHasConfirmedAgentExit || args.activeLeafId !== args.chatLeafId) - ) { + if (args.activeLeafIsEligible && (!confirmedAgentExit || args.activeLeafId !== args.chatLeafId)) { return { chatLeafId: args.activeLeafId, exitChat: false } } // Why: removing the owning leaf or confirming its agent exited must not leave diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index 17e05af6a5c..b0f59a8a8a1 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -20,6 +20,7 @@ export type NativeChatSkillStateInputs = Pick< | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' | 'tabsByWorktree' + | 'unifiedTabsByWorktree' | 'worktreesByRepo' > @@ -27,6 +28,7 @@ type NativeChatSkillTab = { id: string; startupCwd?: string } type NativeChatSkillWorktreeState = { tabsByWorktree: Record + unifiedTabsByWorktree?: Record worktreesByRepo: Record } @@ -49,6 +51,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, tabsByWorktree: state.tabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktreesByRepo: state.worktreesByRepo } } @@ -57,7 +60,7 @@ export function resolveNativeChatSkillDiscoveryCwd( state: NativeChatSkillWorktreeState, terminalTabId: string ): string | null { - const found = findTerminalTab(state.tabsByWorktree, terminalTabId) + const found = findNativeChatTab(state, terminalTabId) if (!found) { return null } @@ -80,7 +83,7 @@ export function resolveNativeChatSkillDiscoveryContext( state: NativeChatSkillStateInputs, terminalTabId: string ): NativeChatSkillDiscoveryContext | null { - const worktreeId = findTerminalTab(state.tabsByWorktree, terminalTabId)?.worktreeId ?? null + const worktreeId = findNativeChatTab(state, terminalTabId)?.worktreeId ?? null if (!worktreeId) { return null } @@ -142,6 +145,16 @@ export function resolveNativeChatSkillDiscoveryContext( } } +function findNativeChatTab( + state: Pick, + tabId: string +): { worktreeId: string; tab: NativeChatSkillTab } | null { + return ( + findTerminalTab(state.tabsByWorktree, tabId) ?? + findTerminalTab(state.unifiedTabsByWorktree ?? {}, tabId) + ) +} + function findTerminalTab( tabsByWorktree: Record, terminalTabId: string diff --git a/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts new file mode 100644 index 00000000000..4b18921f221 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-stop-layering.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +function source(path: string): string { + return readFileSync(join(process.cwd(), path), 'utf8') +} + +describe('native chat Stop layering', () => { + it('keeps a working chat pane above bottom-right product chrome', () => { + const css = source('src/renderer/src/assets/main.css') + const terminalPane = source('src/renderer/src/components/terminal-pane/TerminalPane.tsx') + + expect(terminalPane).toContain('native-chat-pane-shell absolute inset-0 z-10') + expect(css).toMatch(/\[data-sonner-toaster\][^{]*\{[^}]*z-index:\s*40\s*!important;/s) + expect(css).toMatch( + /\.native-chat-pane-shell:has\(\[data-native-chat-working='true'\]\)[^{]*\{[^}]*z-index:\s*50;/s + ) + }) + + it('publishes working state from both structured and bridge chat roots', () => { + for (const path of [ + 'src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx', + 'src/renderer/src/components/native-chat/NativeChatView.tsx' + ]) { + expect(source(path)).toContain('data-native-chat-working=') + } + }) + + it('owns structured session panes at the retained worktree overlay layer', () => { + const terminal = source('src/renderer/src/components/Terminal.tsx') + const tabGroup = source('src/renderer/src/components/tab-group/TabGroupPanel.tsx') + + expect(terminal).toContain(' { + const command = await transport.dispatchCommand(text) + if (command.handled) { + return { accepted: command.accepted, error: command.error } + } + return { accepted: transport.send(text, attachments), error: null } +} diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts new file mode 100644 index 00000000000..db572e57364 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' + +function message(id: string, role: NativeChatMessage['role'], text = id): NativeChatMessage { + return { id, role, blocks: [{ type: 'text', text }], timestamp: null, source: 'transcript' } +} + +describe('shouldShowNativeChatTypingIndicator', () => { + it('stays hidden when the session is idle', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user')], + isWorking: false + }) + ).toBe(false) + }) + + it('shows once a send lands and no assistant row exists yet', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('a0', 'assistant'), message('u1', 'user')], + isWorking: true + }) + ).toBe(true) + }) + + it('hides as soon as the structured reply row arrives, before working clears', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user'), message('orca-item', 'assistant')], + isWorking: true + }) + ).toBe(false) + }) + + it('hides behind the PTY streaming bubble', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user'), message(NATIVE_CHAT_STREAMING_ID, 'assistant')], + isWorking: true + }) + ).toBe(false) + }) + + it('does not flicker back on when a system row interleaves mid-turn', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [ + message('u1', 'user'), + message('a1', 'assistant'), + message('s1', 'system', 'Ran /status') + ], + isWorking: true + }) + ).toBe(false) + }) + + it('shows again for the next send even though an earlier turn replied', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [message('u1', 'user'), message('a1', 'assistant'), message('u2', 'user')], + isWorking: true + }) + ).toBe(true) + }) + + it('shows after a slash-command marker even though an earlier turn replied', () => { + expect( + shouldShowNativeChatTypingIndicator({ + messages: [ + message('a1', 'assistant'), + message('command:compact', 'system', 'Ran /compact') + ], + isWorking: true + }) + ).toBe(true) + }) + + it('shows on a session whose transcript is still empty', () => { + expect(shouldShowNativeChatTypingIndicator({ messages: [], isWorking: true })).toBe(true) + }) +}) + +// These build rows through the REAL structured projection instead of hand-made +// `command:` marker ids. The hand-made ids only exist on the PTY transport, so +// tests using them were blind to how the shipping transport actually looks. +describe('with rows projected from the structured journal', () => { + function toolCallItem(sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:thread-1:turn-1:${sequence}`, + revision: 1, + sequence, + observedAt: 1_800_000_000_000, + body: { + kind: 'tool-call', + name: 'shell', + state: 'running', + input: { command: 'sed -n 1,240p README.md' } + } + } as AgentJournalRenderItem + } + + function assistantTextItem(sequence: number): AgentJournalRenderItem { + return { + itemId: `codex:thread-1:turn-1:${sequence}`, + revision: 1, + sequence, + observedAt: 1_800_000_000_000, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: "I'm checking PR 14696's metadata." }] + } + } as AgentJournalRenderItem + } + + it('keeps showing while a running command is the newest row', () => { + // The screenshot case: prose landed, then codex started running shell commands + // and the chat body went still for the length of the command. + const messages = projectStructuredItemsToNativeChat([assistantTextItem(1), toolCallItem(2)]) + expect(messages.at(-1)?.role).toBe('assistant') + expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(true) + }) + + it('still hides once prose is the newest row', () => { + const messages = projectStructuredItemsToNativeChat([toolCallItem(1), assistantTextItem(2)]) + expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: true })).toBe(false) + }) + + it('stays hidden when the turn is not working, command row or not', () => { + const messages = projectStructuredItemsToNativeChat([toolCallItem(1)]) + expect(shouldShowNativeChatTypingIndicator({ messages, isWorking: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts new file mode 100644 index 00000000000..a3574e1842c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-typing-indicator.ts @@ -0,0 +1,54 @@ +// When the trailing "…" row is allowed to render. +// +// The rule suppresses the dots once the turn's own assistant ANSWER is on screen, +// because a placeholder below streamed text reflows the list when it disappears. +// It must not suppress on a row that only reports tool work: a shell command can +// run for a minute with nothing else arriving, and that is precisely when the +// user needs to see that the turn is still alive. +// +// Both transports have to agree, and matching on `role` alone does not get there: +// the PTY path emits synthetic `command:` marker rows, while the structured path +// projects a journal tool-call item as `role: 'assistant'` with tool blocks. Same +// meaning, different shape — so the predicate is about the row's CONTENT. + +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' +import { isCommandMarkerId } from './native-chat-command-marker' + +/** A row carrying only tool activity — no prose. It is progress, not an answer. */ +function isToolActivityOnlyRow(message: NativeChatMessage): boolean { + const blocks = message.blocks + if (!blocks || blocks.length === 0) { + return false + } + return blocks.every((block) => block.type === 'tool-call' || block.type === 'tool-result') +} + +export function shouldShowNativeChatTypingIndicator(args: { + messages: readonly NativeChatMessage[] + isWorking: boolean +}): boolean { + if (!args.isWorking) { + return false + } + const { messages } = args + // Scan back only to the turn boundary: an assistant row from an EARLIER turn + // must not suppress the indicator for the send the user just made. + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index] + if (!message || message.role === 'user' || isCommandMarkerId(message.id)) { + return true + } + // Tool work is the strongest reason to KEEP the dots, so it decides here + // rather than falling through to the assistant-role check below. + if (isToolActivityOnlyRow(message)) { + return true + } + // Status/system rows interleave mid-turn; they neither suppress nor unsuppress, + // otherwise the dots would flicker back on between assistant chunks. + if (message.role === 'assistant' || message.id === NATIVE_CHAT_STREAMING_ID) { + return false + } + } + return true +} diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index de095f9b232..4dd9a7bac03 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -1,8 +1,18 @@ +import type { + AgentStatusOrchestrationContext, + AgentType +} from '../../../../shared/agent-status-types' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { NativeChatSession } from '../../../../shared/native-chat-types' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -export type NativeChatViewProps = { +type NativeChatOrchestrationProps = { + orchestrationDispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] +} + +export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & { + mode?: 'bridge' /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ terminalTabId: string /** Whether the hosted terminal surface is currently visible. */ @@ -22,7 +32,17 @@ export type NativeChatViewProps = { contextMenuActions?: Omit } -export type NativeChatResolvedViewProps = { +export type NativeChatStructuredViewProps = NativeChatOrchestrationProps & { + mode: 'structured' + tabId: string + sessionId: string + target: RuntimeClientTarget + agent: AgentType + isVisible: boolean + allowFileUriLinks: boolean +} + +export type NativeChatResolvedViewProps = NativeChatOrchestrationProps & { paneKey: string agent: NativeChatSession['agent'] sessionId: string | null @@ -34,3 +54,5 @@ export type NativeChatResolvedViewProps = { readTerminalScreen?: () => string | null contextMenuActions?: Omit } + +export type NativeChatViewProps = NativeChatBridgeViewProps | NativeChatStructuredViewProps diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts new file mode 100644 index 00000000000..745aa5b4314 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import { createStructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +function submission(index: number): AgentJournalSubmission { + return { + clientMessageId: `client-${index}`, + fence: 1, + payloadFingerprint: `fingerprint-${index}`, + dispatchState: 'accepted', + providerItemId: `provider-${index}`, + reason: null, + submittedAt: index, + resolvedAt: index + } +} + +function item(index: number): AgentJournalRenderItem { + return { + itemId: `journal-${index}`, + revision: 1, + sequence: index, + observedAt: index, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: `send ${index}` }] } + } +} + +describe('structured agent session message projection', () => { + it.each([5, 10])('renders %i rapid accepted desktop sends exactly once', (sendCount) => { + const outbox = Array.from({ length: sendCount }, (_, index) => + createStructuredAgentSessionOutboxEntry({ + clientMessageId: `client-${index}`, + sessionId: 'session-1', + text: `send ${index}`, + attachments: [], + queuedAt: index + }) + ) + const messages = projectStructuredAgentSessionMessages( + Array.from({ length: sendCount }, (_, index) => item(index)), + outbox, + Array.from({ length: sendCount }, (_, index) => submission(sendCount - index - 1)) + ) + + expect(messages.filter((message) => message.role === 'user')).toHaveLength(sendCount) + expect(messages.map((message) => message.id)).toEqual( + Array.from({ length: sendCount }, (_, index) => `journal-${index}`) + ) + }) + + it('renders one bubble while the submission is still dispatching', () => { + const outbox = [ + createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-pending', + sessionId: 'session-1', + text: 'Ok thanks', + attachments: [], + queuedAt: 1 + }) + ] + // The host's WAL row is on screen while the provider round trip is in flight. + const walItem: AgentJournalRenderItem = { + itemId: agentJournalSubmissionKey('client-pending'), + revision: 0, + sequence: 1, + observedAt: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Ok thanks' }] } + } + const pending: AgentJournalSubmission = { + ...submission(0), + clientMessageId: 'client-pending', + dispatchState: 'pending', + providerItemId: null, + resolvedAt: null + } + + const messages = projectStructuredAgentSessionMessages([walItem], outbox, [pending]) + const optimistic = projectStructuredAgentSessionMessages([], outbox, []) + + expect(messages.filter((message) => message.role === 'user')).toHaveLength(1) + expect(messages.map((message) => message.id)).toEqual([walItem.itemId]) + expect(optimistic[0]?.id).toBe(messages[0]?.id) + }) + + it('keeps an optimistic send until its acceptance arrives', () => { + const outbox = [ + createStructuredAgentSessionOutboxEntry({ + clientMessageId: 'client-pending', + sessionId: 'session-1', + text: 'pending', + attachments: [], + queuedAt: 1 + }) + ] + + expect(projectStructuredAgentSessionMessages([], outbox, [])).toMatchObject([ + { id: agentJournalSubmissionKey('client-pending'), role: 'user' } + ]) + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts new file mode 100644 index 00000000000..9da10c6581a --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-message-projection.ts @@ -0,0 +1,38 @@ +import type { + AgentJournalRenderItem, + AgentJournalSubmission +} from '../../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { + reconcileStructuredAgentSessionOutbox, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' + +export function projectStructuredAgentSessionMessages( + items: readonly AgentJournalRenderItem[], + outbox: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): NativeChatMessage[] { + const optimistic = reconcileStructuredAgentSessionOutbox(outbox, submissions) + // Why: the host renders its own bubble off the submission WAL row, which lands + // while the dispatch is still `pending`. Reconciliation only retires the echo on + // `accepted`, so keying visibility on that alone double-rendered the bubble for + // the whole provider round trip. The entry itself stays for retry/unconfirmed. + const journalled = new Set(items.map((item) => item.itemId)) + return [ + ...projectStructuredItemsToNativeChat(items), + ...optimistic + .filter((entry) => !journalled.has(agentJournalSubmissionKey(entry.clientMessageId))) + .map( + (entry): NativeChatMessage => ({ + id: agentJournalSubmissionKey(entry.clientMessageId), + role: 'user', + source: 'transcript', + timestamp: entry.queuedAt, + blocks: entry.body.blocks + }) + ) + ] +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts new file mode 100644 index 00000000000..4c43326acb1 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -0,0 +1,272 @@ +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { agentProviderSessionsEqual } from '../../../../shared/agent-session-resume' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + oldestStructuredAgentSessionCursor, + reduceStructuredAgentSession, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { NATIVE_CHAT_INITIAL_LIMIT } from './native-chat-pagination' +import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' + +export type StructuredAgentSessionReadSnapshot = { + state: StructuredAgentSessionState + loadingOlder: boolean + providerSession?: AgentProviderSessionMetadata +} + +export type StructuredAgentSessionReadOwner = { + activate: () => () => void + dispose: () => void + getSnapshot: () => StructuredAgentSessionReadSnapshot + loadOlder: () => Promise + refresh: () => void + subscribe: (listener: () => void) => () => void +} + +const owners = new Map() + +function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean { + return item.body.kind !== 'status' || !item.body.providerFrame +} + +function ownerKey(sessionId: string, target: RuntimeClientTarget): string { + const targetKey = target.kind === 'local' ? 'local' : `environment:${target.environmentId}` + return `${targetKey}:${sessionId}` +} + +function createReadOwner( + key: string, + sessionId: string, + target: RuntimeClientTarget +): StructuredAgentSessionReadOwner { + let snapshot: StructuredAgentSessionReadSnapshot = { + state: EMPTY_STRUCTURED_AGENT_SESSION, + loadingOlder: false + } + let stopActiveRun: (() => void) | null = null + let refreshActiveRun = (): void => {} + const retiredHistoryRead = (): boolean => true + let captureActiveHistoryReadGuard = (): (() => boolean) => retiredHistoryRead + const activations = new Set() + const listeners = new Set<() => void>() + + const emit = (): void => { + for (const listener of listeners) { + listener() + } + } + const setSnapshot = (next: StructuredAgentSessionReadSnapshot): void => { + if (next === snapshot) { + return + } + snapshot = next + emit() + } + const apply = (action: StructuredAgentSessionAction): void => { + const state = reduceStructuredAgentSession(snapshot.state, action) + if (state !== snapshot.state) { + setSnapshot({ ...snapshot, state }) + } + } + const setProviderSession = (providerSession: AgentProviderSessionMetadata | undefined): void => { + if (!agentProviderSessionsEqual(undefined, snapshot.providerSession, providerSession)) { + setSnapshot({ ...snapshot, providerSession }) + } + } + const clearLoadingOlder = (): void => { + if (snapshot.loadingOlder) { + setSnapshot({ ...snapshot, loadingOlder: false }) + } + } + const refreshTail = async (shouldStop: () => boolean): Promise => { + const result = await callStructuredAgentSession( + target, + 'agentSession.history', + { sessionId, direction: 'tail', limit: AGENT_SESSION_HISTORY_MAX_LIMIT } + ) + if (shouldStop()) { + return + } + setProviderSession(result.providerSession) + if (!result.ok) { + if (shouldStop()) { + return + } + apply({ + type: 'event', + event: { + type: 'reset', + sessionId, + reset: result.reset, + page: result.page, + fence: result.fence ?? 0 + } + }) + return + } + if (shouldStop()) { + return + } + apply({ type: 'tail-page', page: result.page }) + if (shouldStop()) { + return + } + let restored = snapshot.state.items.filter(countsTowardInitialHistory).length + while (snapshot.state.hasOlder && restored < NATIVE_CHAT_INITIAL_LIMIT) { + const oldest = oldestStructuredAgentSessionCursor(snapshot.state) + if (!oldest || shouldStop()) { + break + } + const missing = NATIVE_CHAT_INITIAL_LIMIT - restored + const older = await callStructuredAgentSession( + target, + 'agentSession.history', + { + sessionId, + direction: 'before', + cursor: oldest, + limit: Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, missing) + } + ) + if (shouldStop()) { + return + } + if (!older.ok || older.page.window.oldest?.sequence === oldest.sequence) { + break + } + if (shouldStop()) { + return + } + apply({ type: 'older-page', requestedEpoch: oldest.epoch, page: older.page }) + if (shouldStop()) { + return + } + restored = snapshot.state.items.filter(countsTowardInitialHistory).length + } + } + + const start = (): void => { + if (snapshot.state.epoch === null) { + apply({ type: 'loading' }) + } + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: (event) => apply({ type: 'event', event }), + applyError: (message) => apply({ type: 'error', message }), + getCursor: () => snapshot.state.cursor, + onHistoryReadInvalidated: clearLoadingOlder, + refreshTail, + sessionId, + target + }) + captureActiveHistoryReadGuard = transport.captureHistoryReadGuard + refreshActiveRun = transport.refresh + stopActiveRun = () => { + captureActiveHistoryReadGuard = () => retiredHistoryRead + refreshActiveRun = (): void => {} + transport.dispose() + stopActiveRun = null + } + } + + let owner: StructuredAgentSessionReadOwner + const deleteIfUnused = (): void => { + if (activations.size === 0 && listeners.size === 0 && owners.get(key) === owner) { + owners.delete(key) + } + } + owner = { + activate: () => { + const token = Symbol(sessionId) + activations.add(token) + if (activations.size === 1) { + start() + } + return () => { + activations.delete(token) + if (activations.size === 0) { + stopActiveRun?.() + deleteIfUnused() + } + } + }, + dispose: () => { + activations.clear() + listeners.clear() + stopActiveRun?.() + }, + getSnapshot: () => snapshot, + loadOlder: async () => { + const shouldStop = captureActiveHistoryReadGuard() + if (shouldStop()) { + return + } + const cursor = oldestStructuredAgentSessionCursor(snapshot.state) + if (!cursor || !snapshot.state.hasOlder || snapshot.loadingOlder) { + return + } + if (shouldStop()) { + return + } + setSnapshot({ ...snapshot, loadingOlder: true }) + try { + const result = await callStructuredAgentSession( + target, + 'agentSession.history', + { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } + ) + if (shouldStop()) { + return + } + if (result.ok && !shouldStop()) { + apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) + } + } catch (error) { + if (!shouldStop()) { + apply({ type: 'error', message: String(error) }) + } + } finally { + if (!shouldStop()) { + clearLoadingOlder() + } + } + }, + refresh: () => refreshActiveRun(), + subscribe: (listener) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + deleteIfUnused() + } + } + } + return owner +} + +export function getStructuredAgentSessionReadOwner( + sessionId: string, + target: RuntimeClientTarget +): StructuredAgentSessionReadOwner { + const key = ownerKey(sessionId, target) + let owner = owners.get(key) + if (!owner) { + owner = createReadOwner(key, sessionId, target) + owners.set(key, owner) + } + return owner +} + +export function resetStructuredAgentSessionReadOwnersForTests(): void { + for (const owner of owners.values()) { + owner.dispose() + } + owners.clear() +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts new file mode 100644 index 00000000000..b420cc5b529 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' + +type SubscribeAttempt = { + closed: PromiseWithResolvers<{ unsubscribe: () => void }> + onClose: () => void + onError: (error: unknown) => void + onEvent: (event: AgentSessionSubscribeEvent) => void + unsubscribe: ReturnType void>> +} + +const target = { kind: 'local' } as const + +function snapshot(sequence: number): AgentSessionSubscribeEvent { + const cursor: AgentJournalCursor = { epoch: 'epoch-a', sequence } + const page: AgentSessionHistoryPage = { + sessionId: 'session-a', + epoch: cursor.epoch, + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { oldest: null, newest: null, nextCursor: cursor }, + liveCursor: cursor, + hasOlder: false, + hasNewer: false + } + return { type: 'snapshot', sessionId: 'session-a', page, fence: sequence } +} + +async function flushPromises(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('structured agent-session read transport generations', () => { + const attempts: SubscribeAttempt[] = [] + + beforeEach(() => { + attempts.length = 0 + vi.clearAllMocks() + mocks.subscribe.mockImplementation((_target, _params, onEvent, onError, onClose) => { + const attempt: SubscribeAttempt = { + closed: Promise.withResolvers<{ unsubscribe: () => void }>(), + onClose, + onError, + onEvent, + unsubscribe: vi.fn<() => void>() + } + attempts.push(attempt) + return attempt.closed.promise + }) + }) + + function start(applyEvent: (event: AgentSessionSubscribeEvent) => void, applyError = vi.fn()) { + return startStructuredAgentSessionReadTransport({ + applyEvent, + applyError, + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + refreshTail: async () => undefined, + sessionId: 'session-a', + target + }) + } + + it('ignores opening frames after disposal and a replacement transport starts', async () => { + const applyEvent = vi.fn() + const applyError = vi.fn() + const retired = start(applyEvent, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) + + retired.dispose() + const replacement = start(applyEvent, applyError) + await flushPromises() + expect(attempts).toHaveLength(2) + + attempts[0].onEvent(snapshot(1)) + attempts[0].onError(new Error('retired error')) + attempts[0].onClose() + expect(applyEvent).not.toHaveBeenCalled() + expect(applyError).not.toHaveBeenCalled() + + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(attempts[0].unsubscribe).toHaveBeenCalledOnce() + + attempts[1].onEvent(snapshot(2)) + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2)) + replacement.dispose() + }) + + it('ignores callbacks from a subscription superseded by reconnect', async () => { + vi.useFakeTimers() + try { + const applyEvent = vi.fn() + const applyError = vi.fn() + const transport = start(applyEvent, applyError) + await flushPromises() + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + + attempts[0].onClose() + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) + + attempts[0].onEvent(snapshot(1)) + attempts[0].onError(new Error('stale error')) + expect(applyEvent).not.toHaveBeenCalled() + expect(applyError).not.toHaveBeenCalled() + + attempts[1].onEvent(snapshot(2)) + expect(applyEvent).toHaveBeenCalledExactlyOnceWith(snapshot(2)) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + transport.dispose() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts new file mode 100644 index 00000000000..79232a4ead7 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -0,0 +1,210 @@ +import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' +import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' +import { shouldAdvanceStructuredResumeCursor } from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +function createReconnectScheduler(args: { shouldStop: () => boolean; reconnect: () => void }) { + let timer: ReturnType | null = null + return { + schedule(delay = 750): void { + if (args.shouldStop() || timer) { + return + } + timer = setTimeout(() => { + timer = null + if (!args.shouldStop()) { + args.reconnect() + } + }, delay) + }, + dispose(): void { + if (timer) { + clearTimeout(timer) + timer = null + } + } + } +} + +export function startStructuredAgentSessionReadTransport(args: { + applyEvent: (event: AgentSessionSubscribeEvent) => void + applyError: (message: string) => void + getCursor: () => AgentJournalCursor | null + onHistoryReadInvalidated: () => void + refreshTail: (shouldStop: () => boolean) => Promise + sessionId: string + target: RuntimeClientTarget +}): { + captureHistoryReadGuard: () => () => boolean + dispose: () => void + refresh: () => void +} { + let stopped = false + let connected = false + let opening = false + let openGeneration = 0 + let stateGeneration = 0 + let unsubscribe = (): void => {} + let resumeCursor = args.getCursor() + let shouldStopCoalescedEvent = (): boolean => true + const coalescer = createStructuredAgentSessionEventCoalescer((event) => { + if (!shouldStopCoalescedEvent()) { + args.applyEvent(event) + } + }) + const reconnectScheduler = createReconnectScheduler({ + shouldStop: () => stopped || connected, + reconnect: () => void open() + }) + const isCurrentOpenGeneration = (candidate: number): boolean => + !stopped && candidate === openGeneration + const captureHistoryReadGuard = (): (() => boolean) => { + const readOpenGeneration = openGeneration + const readStateGeneration = stateGeneration + return () => + !isCurrentOpenGeneration(readOpenGeneration) || readStateGeneration !== stateGeneration + } + const handleEvent = (event: AgentSessionSubscribeEvent, eventOpenGeneration: number): void => { + if (!isCurrentOpenGeneration(eventOpenGeneration)) { + return + } + if (event.type === 'snapshot' || event.type === 'reset') { + coalescer.flush() + if (!isCurrentOpenGeneration(eventOpenGeneration)) { + return + } + stateGeneration += 1 + args.onHistoryReadInvalidated() + if (!isCurrentOpenGeneration(eventOpenGeneration)) { + return + } + resumeCursor = event.page.liveCursor ?? event.page.window.nextCursor + } else if ( + event.type === 'batch' && + shouldAdvanceStructuredResumeCursor(resumeCursor, event.batch.cursor) + ) { + resumeCursor = event.batch.cursor + } else if (event.type === 'end') { + connected = false + reconnectScheduler.schedule() + } + shouldStopCoalescedEvent = captureHistoryReadGuard() + coalescer.push(event) + } + async function open(): Promise { + if (stopped || connected) { + return + } + if (opening) { + reconnectScheduler.schedule() + return + } + opening = true + coalescer.flush() + if (stopped) { + opening = false + return + } + const currentOpenGeneration = ++openGeneration + args.onHistoryReadInvalidated() + unsubscribe() + unsubscribe = (): void => {} + try { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + let closedDuringOpen = false + const handle = await subscribeStructuredAgentSession( + args.target, + { sessionId: args.sessionId, ...(resumeCursor ? { cursor: resumeCursor } : {}) }, + (event) => handleEvent(event, currentOpenGeneration), + (error) => { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + closedDuringOpen = true + connected = false + args.applyError(String(error)) + reconnectScheduler.schedule() + }, + () => { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + closedDuringOpen = true + connected = false + reconnectScheduler.schedule() + } + ) + if (!isCurrentOpenGeneration(currentOpenGeneration) || closedDuringOpen) { + handle.unsubscribe() + if (isCurrentOpenGeneration(currentOpenGeneration)) { + reconnectScheduler.schedule() + } + } else { + connected = true + unsubscribe = handle.unsubscribe + } + } catch (error) { + if (!isCurrentOpenGeneration(currentOpenGeneration)) { + return + } + connected = false + args.applyError(String(error)) + reconnectScheduler.schedule() + } finally { + if (currentOpenGeneration === openGeneration) { + opening = false + } + } + } + const refresh = (): void => { + const shouldStop = captureHistoryReadGuard() + void args + .refreshTail(shouldStop) + .then(() => { + if (shouldStop()) { + return + } + resumeCursor = args.getCursor() + if (!connected) { + reconnectScheduler.schedule(0) + } + }) + .catch((error) => { + if (!shouldStop()) { + args.applyError(String(error)) + } + }) + } + const shouldStopInitialRead = captureHistoryReadGuard() + void args + .refreshTail(shouldStopInitialRead) + .then(() => { + if (shouldStopInitialRead()) { + return + } + resumeCursor = args.getCursor() + return open() + }) + .catch((error) => { + if (!shouldStopInitialRead()) { + args.applyError(String(error)) + reconnectScheduler.schedule() + } + }) + return { + captureHistoryReadGuard, + dispose: () => { + stopped = true + openGeneration += 1 + args.onHistoryReadInvalidated() + reconnectScheduler.dispose() + coalescer.dispose() + unsubscribe() + }, + refresh + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx index 82d788e94f6..06b61d9610b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.test.tsx @@ -25,9 +25,11 @@ const target: NativeChatResolvedTarget = { function Probe({ scopeKey, + structured = false, onReady }: { scopeKey: string + structured?: boolean onReady: (api: ProbeApi) => void }): React.JSX.Element { const [caret, setCaret] = useState(0) @@ -36,8 +38,9 @@ function Probe({ const textareaRef = useRef(null) const api = useNativeChatComposerAttachments({ attachmentScopeKey: scopeKey, + allowWithoutTarget: structured, caret, - resolveTarget: () => target, + resolveTarget: () => (structured ? null : target), textareaRef, setCaret, setDraft: (updater) => setDraftValue((previous) => updater(previous)), @@ -48,7 +51,8 @@ function Probe({ } async function renderProbe( - scopeKey: string + scopeKey: string, + structured = false ): Promise<{ root: Root; latest: () => ProbeApi; rerender: (scopeKey: string) => Promise }> { const container = document.createElement('div') document.body.append(container) @@ -60,7 +64,7 @@ async function renderProbe( api = next } await act(async () => { - root.render(createElement(Probe, { scopeKey, onReady })) + root.render(createElement(Probe, { scopeKey, structured, onReady })) }) if (!api) { throw new Error('Probe did not render') @@ -75,7 +79,7 @@ async function renderProbe( }, rerender: async (nextScopeKey: string) => { await act(async () => { - root.render(createElement(Probe, { scopeKey: nextScopeKey, onReady })) + root.render(createElement(Probe, { scopeKey: nextScopeKey, structured, onReady })) }) } } @@ -112,6 +116,17 @@ describe('useNativeChatComposerAttachments', () => { act(() => second.root.unmount()) }) + it('accepts host-readable image paths without a PTY for structured transport', async () => { + const probe = await renderProbe('structured-session-1', true) + + await act(async () => { + probe.latest().attachResolvedPaths(['/tmp/structured-image.png']) + }) + + expect(probe.latest().imageAttachments).toMatchObject([{ path: '/tmp/structured-image.png' }]) + act(() => probe.root.unmount()) + }) + it('removes an attached image chip cleanly', async () => { const probe = await renderProbe('pty-1') await act(async () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts index 60ecf44ff5a..b9d94d8e253 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-attachments.ts @@ -11,6 +11,7 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' export type UseNativeChatComposerAttachmentsArgs = { attachmentScopeKey: string + allowWithoutTarget?: boolean caret: number resolveTarget: () => NativeChatResolvedTarget | null textareaRef: RefObject @@ -21,6 +22,7 @@ export type UseNativeChatComposerAttachmentsArgs = { export function useNativeChatComposerAttachments({ attachmentScopeKey, + allowWithoutTarget = false, caret, resolveTarget, textareaRef, @@ -107,7 +109,10 @@ export function useNativeChatComposerAttachments({ const attachResolvedPaths = useCallback( (paths: string[]) => { const target = resolveTarget() - if (!target || nativeChatComposerTargetIsRemote(target.ptyId)) { + if ( + (!target && !allowWithoutTarget) || + (target && nativeChatComposerTargetIsRemote(target.ptyId)) + ) { setNotice( translate( 'components.native-chat.composer.localAttachmentUnsupported', @@ -128,7 +133,14 @@ export function useNativeChatComposerAttachments({ requestAnimationFrame(() => textareaRef.current?.focus()) } }, - [appendImageAttachments, insertFileReferences, resolveTarget, setNotice, textareaRef] + [ + allowWithoutTarget, + appendImageAttachments, + insertFileReferences, + resolveTarget, + setNotice, + textareaRef + ] ) return { diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx index 158ab0bfa13..7f5d0e2f1b9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.test.tsx @@ -18,6 +18,8 @@ vi.mock('./native-chat-composer-target', () => ({ })) vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' })) @@ -133,6 +135,24 @@ afterEach(() => { }) describe('useNativeChatComposerPaste', () => { + it('does not save a clipboard image locally for a remote runtime', async () => { + const setNotice = vi.fn() + const attachResolvedPaths = vi.fn() + const probe = await renderProbe({ + resolveAttachmentOwner: () => ({ kind: 'runtime' }), + attachResolvedPaths, + setNotice + }) + + await act(async () => probe.latest().pasteFromClipboard()) + + expect(setNotice).toHaveBeenCalledWith( + 'Local attachments are not available for remote sessions.' + ) + expect(mocks.saveClipboardImageAsTempFile).not.toHaveBeenCalled() + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('surfaces a failed SSH image save through the composer notice', async () => { mocks.saveClipboardImageAsTempFile.mockRejectedValue( new Error('Remote connection dropped. Click Reconnect on the SSH target before retrying.') diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts index dbb6a06ed03..c92c071209e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-paste.ts @@ -5,6 +5,7 @@ import type { AgentType } from '../../../../shared/agent-status-types' import { resolveImagePaste } from './native-chat-image-paste' import { NATIVE_CHAT_CONTEXT_PASTE_MAX_BYTES } from './native-chat-composer-target' import { + nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' @@ -73,6 +74,10 @@ export function useNativeChatComposerPaste({ async ( owner: NativeChatAttachmentOwner ): Promise<{ status: 'saved'; tempPath: string } | { status: 'empty' | 'failed' }> => { + if (owner.kind === 'runtime') { + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return { status: 'failed' } + } try { // SSH panes save the image on the remote host (SFTP) so the attached // path is readable by the remote agent, matching terminal image paste. diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 61d2b321fb8..9aed41a0cdc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useMemo, useRef, useState, type MouseEventHandler, @@ -18,7 +17,6 @@ import { PanelsTopLeft, PanelRightClose, Pencil, - SquareTerminal, X } from 'lucide-react' import { @@ -30,7 +28,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { translate } from '@/i18n/i18n' -import { isMacPlatform, nativeChatToggleShortcutLabel } from './native-chat-shortcut' +import { isMacPlatform } from './native-chat-shortcut' type NativeChatContextMenuState = { open: boolean @@ -40,7 +38,6 @@ type NativeChatContextMenuState = { type UseNativeChatContextMenuArgs = { rootRef: RefObject - onSwitchToTerminal?: () => void actions: NativeChatContextMenuActions } @@ -82,11 +79,7 @@ export const emptyNativeChatContextMenuActions: Omit {} } -export function useNativeChatContextMenu({ - rootRef, - onSwitchToTerminal, - actions -}: UseNativeChatContextMenuArgs): { +export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatContextMenuArgs): { onContextMenuCapture: MouseEventHandler onSelectionCapture: () => void menu: React.JSX.Element @@ -98,7 +91,6 @@ export function useNativeChatContextMenu({ point: { x: 0, y: 0 }, selectedText: '' }) - const shortcutLabel = useMemo(() => nativeChatToggleShortcutLabel(isMacPlatform()), []) const rememberCurrentSelection = useCallback(() => { const selectedText = getNativeChatSelectedText(rootRef.current) @@ -165,16 +157,6 @@ export function useNativeChatContextMenu({ {translate('auto.components.terminal.pane.TerminalContextMenu.0a917b591a', 'Paste')} - {onSwitchToTerminal ? ( - - - {translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - )} - {shortcutLabel} - - ) : null} {actions.canContinueAgentSessionInNewSession ? ( diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx index cc2dc4c9264..3d9017a0c8a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.test.tsx @@ -13,6 +13,8 @@ vi.mock('@/store', () => ({ })) vi.mock('./native-chat-attachment-upload', () => ({ + nativeChatLocalAttachmentUnsupportedNotice: () => + 'Local attachments are not available for remote sessions.', resolveNativeChatAttachmentOwner: mocks.resolveNativeChatAttachmentOwner, uploadNativeChatAttachmentPaths: mocks.uploadNativeChatAttachmentPaths, nativeChatWorktreeNotReadyNotice: () => 'Worktree not ready — try again in a moment.' @@ -137,6 +139,20 @@ describe('useNativeChatExternalAttachments', () => { expect(attachResolvedPaths).not.toHaveBeenCalled() }) + it('does not attach client-local paths to a remote runtime', async () => { + mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'runtime' }) + const attachResolvedPaths = vi.fn() + const setNotice = vi.fn() + const probe = await renderProbe({ attachResolvedPaths, setNotice }) + await act(async () => { + probe.latest().attachExternalPaths(['/local/a.txt']) + }) + expect(setNotice).toHaveBeenCalledWith( + 'Local attachments are not available for remote sessions.' + ) + expect(attachResolvedPaths).not.toHaveBeenCalled() + }) + it('drops an upload that resolves after the composer became disabled', async () => { mocks.resolveNativeChatAttachmentOwner.mockReturnValue({ kind: 'ssh', diff --git a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts index d2e19375eec..25794e4c128 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-external-attachments.ts @@ -1,14 +1,17 @@ import { useCallback, useRef } from 'react' import { useAppStore } from '@/store' import { + nativeChatLocalAttachmentUnsupportedNotice, nativeChatWorktreeNotReadyNotice, resolveNativeChatAttachmentOwner, + resolveNativeChatAttachmentOwnerForWorktree, uploadNativeChatAttachmentPaths, type NativeChatAttachmentOwner } from './native-chat-attachment-upload' export type UseNativeChatExternalAttachmentsArgs = { terminalTabId: string + structuredWorktreeId?: string /** Live composer-disabled state; read at await-resume via a ref so a flip * mid-upload doesn't attach into a guarded composer. */ disabled: boolean @@ -23,6 +26,7 @@ export type UseNativeChatExternalAttachmentsArgs = { */ export function useNativeChatExternalAttachments({ terminalTabId, + structuredWorktreeId, disabled, attachResolvedPaths, setNotice @@ -34,8 +38,11 @@ export function useNativeChatExternalAttachments({ disabledRef.current = disabled const resolveAttachmentOwner = useCallback( - () => resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), - [terminalTabId] + () => + structuredWorktreeId + ? resolveNativeChatAttachmentOwnerForWorktree(useAppStore.getState(), structuredWorktreeId) + : resolveNativeChatAttachmentOwner(useAppStore.getState(), terminalTabId), + [structuredWorktreeId, terminalTabId] ) const attachExternalPaths = useCallback( @@ -48,9 +55,11 @@ export function useNativeChatExternalAttachments({ setNotice(nativeChatWorktreeNotReadyNotice()) return } + if (owner.kind === 'runtime') { + setNotice(nativeChatLocalAttachmentUnsupportedNotice()) + return + } if (owner.kind !== 'ssh') { - // 'runtime' proceeds so attachResolvedPaths' existing remote-session - // gate reports the unsupported state. attachResolvedPaths(paths) return } diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts new file mode 100644 index 00000000000..d3073115b5a --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-context.ts @@ -0,0 +1,7 @@ +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../../store' +import { resolveNativeChatFileLinkContext } from './native-chat-file-link' + +export function useNativeChatFileLinkContext(terminalTabId: string) { + return useAppStore(useShallow((state) => resolveNativeChatFileLinkContext(state, terminalTabId))) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts new file mode 100644 index 00000000000..333109748fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-pty-composer-send.ts @@ -0,0 +1,112 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { NativeChatLaunchDraft } from '@/lib/native-chat-launch-prompt' +import { useAppStore } from '../../store' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + sendNativeChatMessage, + sendNativeChatTypedCommand, + submitNativeChatPrompt +} from './native-chat-runtime-send' +import type { NativeChatSendHandle } from './native-chat-runtime-send' +import { sendNativeChatMessageWithImageAttachments } from './native-chat-runtime-image-send' +import { resolveNativeChatLaunchDraftSend } from './native-chat-launch-draft-send' +import { nativeChatComposerTargetIsRemote } from './native-chat-composer-target' +import type { NativeChatResolvedTarget } from './native-chat-composer-target' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { isSlashCommandDraft } from '../../../../shared/native-chat-slash-commands' +import type { NativeChatPickerState } from './use-native-chat-picker-state' +import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' + +export function useNativeChatPtyComposerSend(args: { + agent: AgentType + draft: string + imageAttachments: readonly { path: string }[] + disabled: boolean + isDispatchingSessionOption: boolean + launchDraft?: NativeChatLaunchDraft | null + launchDraftResolved: boolean + readTerminalScreen?: () => string | null + resolveTarget: () => NativeChatResolvedTarget | null + classifySend: NativeChatPickerState['classifySend'] + onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + onSlashCommand?: (command: string) => void + sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null + terminalTabId: string + trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] + setHistory: Dispatch> + setDraft: (value: string) => void + setCaret: Dispatch> + clearSkillOrigin: () => void + clearImageAttachments: () => void + setNotice: Dispatch> +}): () => void { + return useCallback(() => { + const text = args.draft + const imagePaths = args.imageAttachments.map((attachment) => attachment.path) + if ((text.trim() === '' && imagePaths.length === 0) || args.disabled) { + return + } + // Why: keep option-command and prompt writes from interleaving on the PTY input line. + if (args.isDispatchingSessionOption) { + return + } + const target = args.resolveTarget() + if (!target) { + return + } + const classification = args.classifySend(text) + const { sendOptions } = resolveNativeChatLaunchDraftSend({ + launchDraft: args.launchDraft, + launchDraftResolved: args.launchDraftResolved, + agent: args.agent, + readScreen: () => args.readTerminalScreen?.() + }) + let pendingHandle: NativeChatSendHandle | null = null + // Why: slash-like text must not silently drop its attached images. + if (classification !== 'chat' && imagePaths.length === 0) { + pendingHandle = + args.agent === 'codex' && isSlashCommandDraft(text) + ? sendNativeChatTypedCommand(target.settings, target.ptyId, text) + : sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) + } else if (imagePaths.length > 0) { + pendingHandle = sendNativeChatMessageWithImageAttachments( + target.settings, + target.ptyId, + text, + imagePaths, + sendOptions + ) + } else if (text.trim().length > 0) { + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text, sendOptions) + } else { + submitNativeChatPrompt(target.settings, target.ptyId) + } + if (classification !== 'chat') { + if (pendingHandle) { + args.trackPendingSend(pendingHandle) + } + if (classification === 'command') { + args.onSlashCommand?.(text.trim()) + args.sessionOptionsSurface?.recordOutgoingCommand(text.trim()) + } + } else { + const pendingId = args.onOptimisticSend?.(text, imagePaths) + if (pendingHandle) { + args.trackPendingSend(pendingHandle, pendingId) + } + } + emitNativeChatMessageSent({ + agent: args.agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + args.setHistory((previous) => pushHistory(previous, text)) + args.setDraft('') + args.setCaret(0) + args.clearSkillOrigin() + args.clearImageAttachments() + args.setNotice(null) + useAppStore.getState().clearNativeChatLaunchDraft(args.terminalTabId) + }, [args]) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index a436ba3c7ad..f360394cf37 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -44,6 +44,7 @@ function stateForHost(hostId: string) { restoredRuntimeHostIdByWorkspaceSessionKey: {}, settings: { activeRuntimeEnvironmentId: null }, tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + unifiedTabsByWorktree: {}, worktreesByRepo: { 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId }] } @@ -111,6 +112,46 @@ describe('useNativeChatSkills', () => { ) }) + it('resolves the catalog for a structured session tab', async () => { + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + unifiedTabsByWorktree: { + 'worktree-1': [{ id: 'tab-1', contentType: 'agent-session', entityId: 'session-1' }] + } + } + render() + + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) + + it('surfaces discovery failure instead of remaining loading', async () => { + mocks.callRuntimeRpc.mockRejectedValueOnce(new Error('scan failed')) + render() + + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('error')) + expect(mocks.snapshots.at(-1)?.error?.message).toBe('scan failed') + }) + + it('surfaces missing tab ownership instead of remaining loading', () => { + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + unifiedTabsByWorktree: {} + } + render() + + expect(mocks.snapshots.at(-1)?.status).toBe('error') + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + it('shares one in-flight request between sibling panes', async () => { render( <> diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 6e5daaf43db..9baf747673a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -132,6 +132,23 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { ).toBe('/repo/worktree') }) + it('returns the owning worktree path for a structured session tab', () => { + expect( + resolveNativeChatSkillDiscoveryCwd( + { + tabsByWorktree: {}, + unifiedTabsByWorktree: { + 'repo-1::/repo/worktree': [{ id: 'structured-tab-1' }] + }, + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/worktree', path: '/repo/worktree' }] + } + }, + 'structured-tab-1' + ) + ).toBe('/repo/worktree') + }) + it('returns null when the tab has no known worktree owner', () => { expect( resolveNativeChatSkillDiscoveryCwd({ tabsByWorktree: {}, worktreesByRepo: {} }, 'tab-1') diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index f8a7e943935..42adc911656 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -43,6 +43,13 @@ const IDLE_STATE: StoredDiscoveryState = { error: null, contextKey: null } +const MISSING_CONTEXT_STATE: StoredDiscoveryState = { + status: 'error', + skills: [], + error: new Error('Skill discovery context is unavailable.'), + errorKind: 'unknown', + contextKey: null +} const inFlightDiscovery = new Map>() export function isNativeChatSkillForAgent( @@ -175,11 +182,13 @@ export function useNativeChatSkills( const effectiveState = useMemo( () => - !profile || !enabled || !context + !profile || !enabled ? IDLE_STATE - : state.contextKey === context.key - ? state - : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, + : !context + ? MISSING_CONTEXT_STATE + : state.contextKey === context.key + ? state + : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, [context, enabled, profile, state] ) const visibleSkills = useMemo(() => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-status-entry.ts b/src/renderer/src/components/native-chat/use-native-chat-status-entry.ts new file mode 100644 index 00000000000..6f3778dcc8c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-status-entry.ts @@ -0,0 +1,20 @@ +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../../store' +import { findTabAgentEntry } from './native-chat-tab-agent-entry' + +export function useNativeChatStatusEntry( + terminalTabId: string, + preferredPaneKey: string | undefined +) { + const entry = useAppStore( + useShallow((state) => + preferredPaneKey + ? state.agentStatusByPaneKey[preferredPaneKey] + : findTabAgentEntry(state.agentStatusByPaneKey, terminalTabId) + ) + ) + return { + entry, + paneKey: preferredPaneKey ?? entry?.paneKey ?? `${terminalTabId}:` + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx new file mode 100644 index 00000000000..d8c9981ad04 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +// A structured chat is a view on a terminal tab, so closing the tab is an unmount and nothing else. +// If that unmount does not reach main, the codex app-server behind the chat has no other way to +// learn the chat is gone. + +import { renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: vi.fn() +})) + +import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' + +const LOCAL_TARGET = { kind: 'local' } as const + +function callsTo(method: string): unknown[] { + return mocks.call.mock.calls.filter((call) => call[1] === method).map((call) => call[2]) +} + +beforeEach(() => { + mocks.call.mockReset() + mocks.call.mockResolvedValue(undefined) +}) + +describe('a mounted structured chat', () => { + it('holds the session while it is on screen and releases it on unmount', async () => { + const { unmount } = renderHook(() => + useStructuredAgentSessionHold({ + sessionId: 'session-alpha', + target: LOCAL_TARGET, + surface: 'desktop-chat' + }) + ) + + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + const held = callsTo('agentSession.hold')[0] as { sessionId: string; holderId: string } + expect(held.sessionId).toBe('session-alpha') + expect(callsTo('agentSession.release')).toHaveLength(0) + + unmount() + + await waitFor(() => + expect(callsTo('agentSession.release')).toEqual([ + { sessionId: 'session-alpha', holderId: held.holderId } + ]) + ) + }) + + it('does not release a hold that has not landed yet', async () => { + let settleHold = (): void => {} + mocks.call.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.hold' + ? new Promise((resolve) => { + settleHold = resolve + }) + : Promise.resolve() + ) + const { unmount } = renderHook(() => + useStructuredAgentSessionHold({ + sessionId: 'session-alpha', + target: LOCAL_TARGET, + surface: 'desktop-chat' + }) + ) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + + unmount() + // The hold is still in flight; releasing now would leave the late hold with nothing to undo it. + expect(callsTo('agentSession.release')).toHaveLength(0) + + settleHold() + + await waitFor(() => expect(callsTo('agentSession.release')).toHaveLength(1)) + }) + + it('keeps one hold across re-renders that rebuild the target object', async () => { + const { rerender, unmount } = renderHook( + (props: { sessionId: string }) => + useStructuredAgentSessionHold({ + sessionId: props.sessionId, + target: { kind: 'local' }, + surface: 'desktop-chat' + }), + { initialProps: { sessionId: 'session-alpha' } } + ) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + + rerender({ sessionId: 'session-alpha' }) + rerender({ sessionId: 'session-alpha' }) + + expect(callsTo('agentSession.hold')).toHaveLength(1) + expect(callsTo('agentSession.release')).toHaveLength(0) + unmount() + }) + + it('holds only while a retained pane is visible', async () => { + const view = renderHook( + ({ visible }: { visible: boolean }) => + useStructuredAgentSessionHold({ + sessionId: 'session-restored', + target: LOCAL_TARGET, + surface: 'desktop-chat', + enabled: visible + }), + { initialProps: { visible: false } } + ) + + expect(callsTo('agentSession.hold')).toHaveLength(0) + view.rerender({ visible: true }) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(1)) + + view.rerender({ visible: false }) + await waitFor(() => expect(callsTo('agentSession.release')).toHaveLength(1)) + + view.rerender({ visible: true }) + await waitFor(() => expect(callsTo('agentSession.hold')).toHaveLength(2)) + expect(callsTo('agentSession.release')).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts new file mode 100644 index 00000000000..b7288d4a2a3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-hold.ts @@ -0,0 +1,59 @@ +// The desktop chat telling main that this session is on screen. +// +// A structured chat is an in-place view on a terminal tab, so closing the tab unmounts this and +// nothing else in the close path knows a provider process is involved: `closeUnifiedTab` retires +// the PTY and drops the tab, main hears nothing, and a codex app-server outlives the chat for the +// rest of the app's life. Surface activity is the honest signal — it covers closing the tab, +// closing the window, and visibility changes for retained panes, none of which share a code path. +// +// The release CHAINS off the hold rather than racing it: an unmount during the hold's round trip +// would otherwise release a hold that has not landed yet, and the late hold would never be undone. + +import { useEffect, useRef } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +let holderOrdinal = 0 + +export function structuredAgentSessionHolderId(surface: string): string { + holderOrdinal += 1 + return `${surface}:${holderOrdinal}` +} + +export function useStructuredAgentSessionHold(args: { + sessionId: string + target: RuntimeClientTarget + surface: string + enabled?: boolean +}): void { + const { enabled = true, sessionId, surface, target } = args + // Keyed by VALUE, not identity: callers build the target inline, so an identity dependency would + // release and re-take the hold on every render of the pane. + const targetKey = target.kind === 'local' ? 'local' : `environment:${target.environmentId}` + const targetRef = useRef(target) + // Synced in an effect declared first (so it lands before the hold below) rather than in render: + // a render React discards must not leak its target into the next commit. + useEffect(() => { + targetRef.current = target + }, [target]) + useEffect(() => { + if (!enabled) { + return + } + const runtimeTarget = targetRef.current + const holderId = structuredAgentSessionHolderId(surface) + const held = callStructuredAgentSession(runtimeTarget, 'agentSession.hold', { + sessionId, + holderId + // An older host has no such method; the session still reads, it just is not held. + }).catch(() => undefined) + return () => { + void held.then(() => + callStructuredAgentSession(runtimeTarget, 'agentSession.release', { + sessionId, + holderId + }).catch(() => undefined) + ) + } + }, [enabled, sessionId, surface, targetKey]) +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx new file mode 100644 index 00000000000..403769144fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.test.tsx @@ -0,0 +1,454 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { useLayoutEffect } from 'react' +import { createRoot } from 'react-dom/client' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { AgentSessionWireRefusalCode } from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ + call: vi.fn() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' + +const LOCAL_TARGET = { kind: 'local' } as const + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((next) => { + resolve = next + }) + return { promise, resolve } +} + +function acceptedResult(fence: number) { + return { + ok: true, + replayed: false, + fence, + cursor: { epoch: 'epoch-1', sequence: fence }, + value: { + clientMessageId: 'client-1', + submission: { + clientMessageId: 'client-1', + fence, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: 'provider-1', + reason: null, + submittedAt: fence, + resolvedAt: fence + } + } + } +} + +function acceptedResultFor(clientMessageId: string, fence: number) { + return { + ok: true, + replayed: false, + fence, + cursor: { epoch: 'epoch-1', sequence: fence }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence, + payloadFingerprint: 'fingerprint', + dispatchState: 'accepted', + providerItemId: `provider-${clientMessageId}`, + reason: null, + submittedAt: fence, + resolvedAt: fence + } + } + } +} + +function unknownResultFor(clientMessageId: string, submittedAt: number) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: submittedAt }, + value: { + clientMessageId, + submission: { + clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown' as const, + providerItemId: null, + reason: 'socket closed', + submittedAt, + resolvedAt: submittedAt + } + } + } +} + +function refusedResult(code: AgentSessionWireRefusalCode) { + return { ok: false, refusal: { code, message: code } } +} + +describe('useStructuredAgentSessionOutbox', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue( + '11111111-1111-4111-8111-111111111111' + ) + }) + + it('requeues across a fence change and ignores the stale settlement', async () => { + const first = deferred>() + const second = deferred>() + mocks.call.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + const { result, rerender } = renderHook( + ({ fence }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence, + submissions: [] + }), + { initialProps: { fence: 1 } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + + rerender({ fence: 2 }) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ + envelope: { expectedRuntimeFence: 2 } + }) + + await act(async () => first.resolve(acceptedResult(1))) + expect(result.current.outbox).toHaveLength(1) + + await act(async () => second.resolve(acceptedResult(2))) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + }) + + it.each(['agent_session_operation_conflict', 'agent_session_operation_expired'] as const)( + 'rotates a send operation after %s', + async (code) => { + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockReturnValueOnce('22222222-2222-4222-8222-222222222222') + mocks.call.mockResolvedValueOnce(refusedResult(code)).mockResolvedValueOnce(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('queued')) + const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } }) + .envelope.clientOperationId + const retryId = result.current.outbox[0]!.clientMessageId + expect(retryId).not.toBe(firstId) + + act(() => result.current.retry(retryId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect( + (mocks.call.mock.calls[1]![2] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + ).toBe(retryId) + } + ) + + it('retains a send operation after a pending-admission refusal', async () => { + mocks.call + .mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale')) + .mockResolvedValueOnce(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('queued')) + const firstId = (mocks.call.mock.calls[0]![2] as { envelope: { clientOperationId: string } }) + .envelope.clientOperationId + expect(result.current.outbox[0]?.clientMessageId).toBe(firstId) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect( + (mocks.call.mock.calls[1]![2] as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + ).toBe(firstId) + }) + + it('persists and dispatches an attachment-only structured send', async () => { + mocks.call.mockResolvedValue(acceptedResult(1)) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + ) + + act(() => + expect( + result.current.send('', [{ path: '/tmp/image.png', previewUri: 'file:///tmp/image.png' }]) + ).toBe(true) + ) + await waitFor(() => expect(mocks.call).toHaveBeenCalledOnce()) + + expect(mocks.call.mock.calls[0]?.[2]).toMatchObject({ + body: { + kind: 'message', + role: 'user', + blocks: [{ type: 'image-ref', path: '/tmp/image.png' }] + } + }) + }) + + it('retries an unknown head and advances a queued tail', async () => { + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') + mocks.call + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return unknownResultFor(clientMessageId, 10) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 11) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 12) + }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + ) + + act(() => { + expect(result.current.send('first')).toBe(true) + }) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + const firstId = result.current.outbox[0]!.clientMessageId + rerender({ + submissions: [ + { + clientMessageId: firstId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'unknown', + providerItemId: null, + reason: 'socket closed', + submittedAt: 10, + resolvedAt: 10 + } + ] + }) + act(() => { + expect(result.current.send('second')).toBe(true) + }) + expect(result.current.outbox).toHaveLength(2) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + const retryParams = mocks.call.mock.calls[1]?.[2] as { retryUnknown?: true } | undefined + expect(retryParams?.retryUnknown).toBe(true) + }) + + it('rotates a history-rejected unknown head so the queued tail can advance', async () => { + vi.mocked(globalThis.crypto.randomUUID) + .mockReturnValueOnce('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') + .mockReturnValueOnce('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb') + .mockReturnValueOnce('cccccccc-cccc-4ccc-8ccc-cccccccccccc') + mocks.call + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return unknownResultFor(clientMessageId, 10) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 11) + }) + .mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return acceptedResultFor(clientMessageId, 12) + }) + const { result, rerender } = renderHook( + ({ submissions }: { submissions: readonly AgentJournalSubmission[] }) => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: LOCAL_TARGET, + fence: 1, + submissions + }), + { initialProps: { submissions: [] as readonly AgentJournalSubmission[] } } + ) + + act(() => expect(result.current.send('first')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + const firstId = result.current.outbox[0]!.clientMessageId + act(() => expect(result.current.send('second')).toBe(true)) + rerender({ + submissions: [ + { + clientMessageId: firstId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: 'not_delivered', + submittedAt: 10, + resolvedAt: 10 + } + ] + }) + + act(() => result.current.retry(firstId)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(3)) + await waitFor(() => expect(result.current.outbox).toHaveLength(0)) + const retryParams = mocks.call.mock.calls[1]?.[2] as + | { envelope: { clientOperationId: string } } + | undefined + expect(retryParams?.envelope.clientOperationId).not.toBe(firstId) + }) + + it('loads the new session outbox when a pane switches sessions', async () => { + mocks.call.mockImplementationOnce(async (_target, _method, params) => { + const clientMessageId = (params as { envelope: { clientOperationId: string } }).envelope + .clientOperationId + return unknownResultFor(clientMessageId, 10) + }) + + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }), + { initialProps: { sessionId: 'session-1' } } + ) + + act(() => expect(result.current.send('first session')).toBe(true)) + await waitFor(() => expect(result.current.outbox[0]?.state).toBe('unconfirmed')) + + rerender({ sessionId: 'session-2' }) + expect(result.current.outbox).toHaveLength(0) + + act(() => expect(result.current.send('second session')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(mocks.call.mock.calls[1]?.[2]).toMatchObject({ + envelope: { sessionId: 'session-2' }, + body: { + blocks: [{ type: 'text', text: 'second session' }] + } + }) + }) + + it('drops a session error on switch and does not resurrect it on return', async () => { + const redispatch = deferred>() + mocks.call + .mockResolvedValueOnce(refusedResult('agent_session_checkpoint_stale')) + .mockReturnValueOnce(redispatch.promise) + const { result, rerender } = renderHook( + ({ sessionId }: { sessionId: string }) => + useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }), + { initialProps: { sessionId: 'session-1' } } + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + await waitFor(() => expect(result.current.error).toBe('agent_session_checkpoint_stale')) + + rerender({ sessionId: 'session-2' }) + expect(result.current.error).toBeNull() + + rerender({ sessionId: 'session-1' }) + expect(result.current.error).toBeNull() + }) + + it('invalidates an old dispatch before it settles during a session switch', async () => { + const oldDispatch = deferred>() + const sessionTwoCommitted = deferred() + mocks.call.mockReturnValueOnce(oldDispatch.promise) + const controllerRef: { + current: ReturnType | null + } = { current: null } + function Probe({ sessionId }: { sessionId: string }): null { + controllerRef.current = useStructuredAgentSessionOutbox({ + sessionId, + target: LOCAL_TARGET, + fence: 1, + submissions: [] + }) + useLayoutEffect(() => { + if (sessionId === 'session-2') { + oldDispatch.resolve(refusedResult('agent_session_checkpoint_stale')) + sessionTwoCommitted.resolve() + } + }, [sessionId]) + return null + } + + const container = document.createElement('div') + const root = createRoot(container) + const actEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT + try { + await act(async () => root.render()) + act(() => expect(controllerRef.current?.send('hello')).toBe(true)) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + const oldSettlementProcessed = oldDispatch.promise.then(() => undefined) + + globalThis.IS_REACT_ACT_ENVIRONMENT = false + root.render() + await sessionTwoCommitted.promise + globalThis.IS_REACT_ACT_ENVIRONMENT = actEnvironment + await act(async () => oldSettlementProcessed) + + expect(controllerRef.current?.error).toBeNull() + await act(async () => root.render()) + expect(controllerRef.current?.error).toBeNull() + } finally { + globalThis.IS_REACT_ACT_ENVIRONMENT = actEnvironment + await act(async () => root.unmount()) + } + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts new file mode 100644 index 00000000000..56ccefe3f76 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox.ts @@ -0,0 +1,317 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import type { + AgentSessionMutationResult, + AgentSessionSendResult +} from '../../../../shared/agent-session-wire' +import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' +import { + classifyStructuredAgentSessionSendFailure, + createStructuredAgentSessionOutboxEntry, + parseStructuredAgentSessionOutboxEntry, + reconcileStructuredAgentSessionOutbox, + requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionSendRequest, + type StructuredAgentSessionOutboxEntry +} from '../../../../shared/structured-agent-session-outbox' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' + +export function structuredSessionOperationId(): string { + return createStructuredAgentSessionOperationId(() => crypto.randomUUID()) +} + +function storageKey(sessionId: string): string { + return `${OUTBOX_PREFIX}${encodeURIComponent(sessionId)}` +} + +function readOutbox(sessionId: string): StructuredAgentSessionOutboxEntry[] { + try { + const value = JSON.parse(localStorage.getItem(storageKey(sessionId)) ?? '[]') + return Array.isArray(value) + ? value + .map((entry) => parseStructuredAgentSessionOutboxEntry(entry, sessionId)) + .filter((entry): entry is StructuredAgentSessionOutboxEntry => entry !== null) + .map((entry) => + entry.state === 'dispatching' ? { ...entry, state: 'unconfirmed' as const } : entry + ) + .sort((left, right) => left.queuedAt - right.queuedAt) + : [] + } catch { + return [] + } +} + +function writeOutbox( + sessionId: string, + entries: readonly StructuredAgentSessionOutboxEntry[] +): boolean { + try { + if (entries.length === 0) { + localStorage.removeItem(storageKey(sessionId)) + } else { + localStorage.setItem(storageKey(sessionId), JSON.stringify(entries)) + } + return true + } catch { + return false + } +} + +function isDesktopDeliveryUnknown(error: unknown): boolean { + const text = error instanceof Error ? `${error.name}:${error.message}` : String(error) + return /timeout|disconnect|connection|closed|unavailable|cutover/i.test(text) +} + +export function useStructuredAgentSessionOutbox(args: { + sessionId: string + target: RuntimeClientTarget + fence: number | null + submissions: readonly AgentJournalSubmission[] +}) { + const { fence, sessionId, submissions, target } = args + const [outbox, setOutbox] = useState(() => + readOutbox(sessionId) + ) + const outboxRef = useRef(outbox) + const outboxSessionRef = useRef(sessionId) + const dispatchingRef = useRef(false) + const dispatchGenerationRef = useRef(0) + const blockedIdRef = useRef(null) + const [error, setError] = useState(null) + const [errorSession, setErrorSession] = useState(sessionId) + // Render-time reset (react.dev: adjusting state when a prop changes), so the + // old session's banner neither flashes for a frame nor resurrects on return. + if (errorSession !== sessionId) { + setErrorSession(sessionId) + setError(null) + } + + useEffect(() => { + outboxRef.current = outbox + }, [outbox]) + + useLayoutEffect(() => { + dispatchGenerationRef.current += 1 + dispatchingRef.current = false + blockedIdRef.current = null + }, [fence, sessionId, target]) + + useEffect(() => { + const sessionChanged = outboxSessionRef.current !== sessionId + outboxSessionRef.current = sessionId + const current = sessionChanged ? readOutbox(sessionId) : outboxRef.current + const next = current.map((entry) => + entry.state === 'dispatching' ? { ...entry, state: 'queued' as const } : entry + ) + if ( + sessionChanged || + next.some((entry, index) => entry !== current[index]) || + next.length !== current.length + ) { + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + } + }, [fence, sessionId, target]) + + useEffect(() => { + const next = reconcileStructuredAgentSessionOutbox(outboxRef.current, submissions) + if ( + next.some((entry, index) => entry !== outboxRef.current[index]) || + next.length !== outboxRef.current.length + ) { + outboxRef.current = next + setOutbox(next) + writeOutbox(sessionId, next) + } + }, [sessionId, submissions]) + + useEffect(() => { + const next = outbox[0] + if ( + !next || + next.sessionId !== sessionId || + next.state !== 'queued' || + fence === null || + dispatchingRef.current || + blockedIdRef.current === next.clientMessageId + ) { + return + } + dispatchingRef.current = true + const dispatchGeneration = dispatchGenerationRef.current + const staged = [ + { ...next, state: 'dispatching' as const, lastAttemptAt: Date.now() }, + ...outbox.slice(1) + ] + if (!writeOutbox(sessionId, staged)) { + dispatchingRef.current = false + blockedIdRef.current = next.clientMessageId + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = staged + setOutbox(staged) + void callStructuredAgentSession>( + target, + 'agentSession.send', + structuredAgentSessionSendRequest(next, fence) + ) + .then((result) => { + if (dispatchGenerationRef.current !== dispatchGeneration) { + return + } + if (!result.ok) { + setError(result.refusal.message) + const updated = outboxRef.current.map((entry) => + entry.clientMessageId === next.clientMessageId + ? requeueStructuredAgentSessionSendRefusal( + entry, + result.refusal.code, + structuredSessionOperationId + ) + : entry + ) + blockedIdRef.current = updated[0]?.clientMessageId ?? null + outboxRef.current = updated + setOutbox(updated) + writeOutbox(sessionId, updated) + return + } + const submission = result.value.submission + if (submission.dispatchState === 'rejected') { + blockedIdRef.current = next.clientMessageId + setError(submission.reason ?? 'Message was not accepted') + } else { + setError(null) + } + const updated = + submission.dispatchState === 'accepted' + ? outboxRef.current.filter((entry) => entry.clientMessageId !== next.clientMessageId) + : outboxRef.current.map((entry) => + entry.clientMessageId === next.clientMessageId + ? { + ...entry, + state: + submission.dispatchState === 'unknown' + ? ('unconfirmed' as const) + : ('queued' as const) + } + : entry + ) + outboxRef.current = updated + setOutbox(updated) + writeOutbox(sessionId, updated) + }) + .catch((caught) => { + if (dispatchGenerationRef.current !== dispatchGeneration) { + return + } + const failure = classifyStructuredAgentSessionSendFailure(caught, isDesktopDeliveryUnknown) + if (failure === 'failed') { + blockedIdRef.current = next.clientMessageId + } + const updated = outboxRef.current.map((entry) => + entry.clientMessageId === next.clientMessageId + ? { + ...entry, + state: + failure === 'delivery-unknown' ? ('unconfirmed' as const) : ('queued' as const) + } + : entry + ) + setError( + failure === 'delivery-unknown' ? 'Message delivery is unconfirmed' : String(caught) + ) + outboxRef.current = updated + setOutbox(updated) + writeOutbox(sessionId, updated) + }) + .finally(() => { + if (dispatchGenerationRef.current === dispatchGeneration) { + dispatchingRef.current = false + } + }) + }, [fence, outbox, sessionId, target]) + + const send = useCallback( + (text: string, attachments: readonly { path: string; previewUri: string }[] = []): boolean => { + if (!text.trim() && attachments.length === 0) { + return false + } + const entry = createStructuredAgentSessionOutboxEntry({ + clientMessageId: structuredSessionOperationId(), + sessionId, + text, + attachments, + queuedAt: Date.now() + }) + const next = [...outboxRef.current, entry] + if (!writeOutbox(sessionId, next)) { + setError('Message could not be saved to the outbox') + return false + } + outboxRef.current = next + setOutbox(next) + setError(null) + return true + }, + [sessionId] + ) + + const retry = (clientMessageId: string): void => { + blockedIdRef.current = null + setError(null) + const submission = submissions.find( + (candidate) => candidate.clientMessageId === clientMessageId + ) + const current = outboxRef.current.find((entry) => entry.clientMessageId === clientMessageId) + // A provider-history reconciliation can settle an earlier unknown as + // rejected before the user presses Retry. Reusing that operation id only + // replays the settled rejection forever, so rotate the id for a safe resend. + if (current && submission?.dispatchState === 'rejected') { + const rotated = outboxRef.current.map((entry) => + entry.clientMessageId === clientMessageId + ? { + ...entry, + clientMessageId: structuredSessionOperationId(), + state: 'queued' as const, + retryAfterUnknownSubmittedAt: null + } + : entry + ) + if (!writeOutbox(sessionId, rotated)) { + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = rotated + setOutbox(rotated) + return + } + const retryAfterUnknownSubmittedAt = + submission?.dispatchState === 'unknown' + ? submission.submittedAt + : current?.state === 'unconfirmed' + ? -1 + : null + const next = outboxRef.current.map((entry) => + entry.clientMessageId === clientMessageId + ? { + ...entry, + state: 'queued' as const, + retryAfterUnknownSubmittedAt + } + : entry + ) + if (!writeOutbox(sessionId, next)) { + setError('Message could not be saved to the outbox') + return + } + outboxRef.current = next + setOutbox(next) + } + return { outbox, error, blockedClientMessageId: blockedIdRef.current, send, retry } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx new file mode 100644 index 00000000000..2d320694c9c --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.test.tsx @@ -0,0 +1,430 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalCursor, + AgentJournalRenderItem +} from '../../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryPage, + type AgentSessionSubscribeEvent +} from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) + +import { + useStructuredAgentSessionRead, + useStructuredAgentSessionReadObservation +} from './use-structured-agent-session-read' +import { resetStructuredAgentSessionReadOwnersForTests } from './structured-agent-session-read-owner' + +const LOCAL_TARGET = { kind: 'local' } as const + +function message(id: string, sequence: number, role: 'user' | 'assistant'): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role, blocks: [{ type: 'text', text: id }] } + } +} + +function providerFrame(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { + kind: 'status', + text: id, + providerFrame: { + provider: 'codex', + kind: 'notification:item/commandExecution/outputDelta', + payload: { head: id, byteLength: id.length, digest: id, truncated: false } + } + } + } +} + +function page( + direction: 'tail' | 'before', + items: AgentJournalRenderItem[], + hasOlder: boolean, + epoch = 'epoch-a' +): AgentSessionHistoryPage { + const cursor = (sequence: number): AgentJournalCursor => ({ epoch, sequence }) + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? oldest + return { + sessionId: 'session-a', + epoch, + direction, + items, + removedItemIds: [], + submissions: [], + window: { + oldest: items.length > 0 ? cursor(oldest) : null, + newest: items.length > 0 ? cursor(newest) : null, + nextCursor: cursor(oldest) + }, + liveCursor: cursor(500), + hasOlder, + hasNewer: direction === 'before' + } +} + +describe('useStructuredAgentSessionRead history window', () => { + afterEach(cleanup) + + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionReadOwnersForTests() + mocks.subscribe.mockResolvedValue({ unsubscribe: vi.fn() }) + }) + + it('restores a realistic 21-turn window across the wire-safe bridge-sized read', async () => { + const items = Array.from({ length: 21 }, (_, turn) => [ + message(`user-${turn}`, turn * 2 + 1, 'user'), + message(`assistant-${turn}`, turn * 2 + 2, 'assistant') + ]).flat() + const olderItems = items.slice(0, 12) + const tailItems = [ + ...Array.from({ length: 170 }, (_, index) => providerFrame(`delta-${index}`, 43 + index)), + ...items.slice(12).map((item, index) => ({ ...item, sequence: 213 + index })) + ] + mocks.call + .mockResolvedValueOnce({ ok: true, page: page('tail', tailItems, true) }) + .mockResolvedValueOnce({ ok: true, page: page('before', olderItems, false) }) + + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + await waitFor(() => + expect( + result.current.state.items.filter((item) => item.body.kind === 'message') + ).toHaveLength(items.length) + ) + expect(mocks.call).toHaveBeenNthCalledWith(1, LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'tail', + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + expect(mocks.call).toHaveBeenNthCalledWith(2, LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'before', + cursor: { epoch: 'epoch-a', sequence: tailItems[0].sequence }, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + }) + + it('loads each earlier page at the wire maximum', async () => { + const tailItems = Array.from({ length: 200 }, (_, index) => + message(`tail-${index}`, 301 + index, 'assistant') + ) + const initialOlderItems = Array.from({ length: 100 }, (_, index) => + message(`middle-${index}`, 201 + index, 'assistant') + ) + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', tailItems, true) + }) + .mockResolvedValueOnce({ + ok: true, + page: page('before', initialOlderItems, true) + }) + .mockResolvedValueOnce({ + ok: true, + page: page('before', [message('oldest', 1, 'user')], false) + }) + + const { result } = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + await waitFor(() => expect(result.current.state.hasOlder).toBe(true)) + + await act(async () => result.current.loadOlder()) + + expect(mocks.call).toHaveBeenLastCalledWith(LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-a', + direction: 'before', + cursor: { epoch: 'epoch-a', sequence: 201 }, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + expect(result.current.state.items).toHaveLength(301) + expect(result.current.state.items[0]?.itemId).toBe('oldest') + }) + + it('refreshes only visible structured sessions when the app regains focus', async () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + mocks.call.mockResolvedValue({ ok: true, page: page('tail', [], false) }) + const visible = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-visible', + target: LOCAL_TARGET, + isVisible: true + }) + ) + const hidden = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-hidden', + target: LOCAL_TARGET, + isVisible: false + }) + ) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(1)) + expect(mocks.subscribe).toHaveBeenCalledTimes(1) + + act(() => window.dispatchEvent(new Event('focus'))) + + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + expect(mocks.call).toHaveBeenLastCalledWith(LOCAL_TARGET, 'agentSession.history', { + sessionId: 'session-visible', + direction: 'tail', + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + }) + visible.unmount() + hidden.unmount() + hasFocus.mockRestore() + }) + + it('drops a delayed refresh after reconnect without mutating state or provider session', async () => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + const delayedRefresh = Promise.withResolvers<{ + ok: true + page: AgentSessionHistoryPage + providerSession: { key: 'session_id'; id: string } + }>() + const closes: (() => void)[] = [] + const initialProviderSession = { key: 'session_id', id: 'provider-initial' } as const + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', [message('initial', 1, 'assistant')], false), + providerSession: initialProviderSession + }) + .mockReturnValueOnce(delayedRefresh.promise) + mocks.subscribe.mockImplementation((_target, _params, _onEvent, _onError, onClose) => { + closes.push(onClose) + return Promise.resolve({ unsubscribe: vi.fn() }) + }) + + const view = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + try { + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledOnce()) + expect(view.result.current.state.items[0]?.itemId).toBe('initial') + expect(view.result.current.providerSession).toBe(initialProviderSession) + const stateBeforeRefresh = view.result.current.state + + act(() => window.dispatchEvent(new Event('focus'))) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + + vi.useFakeTimers() + act(() => closes[0]?.()) + await act(async () => vi.advanceTimersByTimeAsync(750)) + expect(mocks.subscribe).toHaveBeenCalledTimes(2) + + await act(async () => { + delayedRefresh.resolve({ + ok: true, + page: page('tail', [message('stale', 2, 'assistant')], false), + providerSession: { key: 'session_id', id: 'provider-stale' } + }) + await delayedRefresh.promise + await Promise.resolve() + }) + + expect(view.result.current.state).toBe(stateBeforeRefresh) + expect(view.result.current.state.items[0]?.itemId).toBe('initial') + expect(view.result.current.providerSession).toBe(initialProviderSession) + } finally { + vi.useRealTimers() + view.unmount() + hasFocus.mockRestore() + } + }) + + it.each(['snapshot', 'reset'] as const)( + 'drops a delayed refresh after a same-stream %s advances the epoch', + async (eventType) => { + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true) + const delayedRefresh = Promise.withResolvers<{ + ok: true + page: AgentSessionHistoryPage + providerSession: { key: 'session_id'; id: string } + }>() + const onEvents: ((event: AgentSessionSubscribeEvent) => void)[] = [] + const initialProviderSession = { key: 'session_id', id: 'provider-initial' } as const + mocks.call + .mockResolvedValueOnce({ + ok: true, + page: page('tail', [message('initial', 1, 'assistant')], false), + providerSession: initialProviderSession + }) + .mockReturnValueOnce(delayedRefresh.promise) + mocks.subscribe.mockImplementation((_target, _params, onEvent) => { + onEvents.push(onEvent) + return Promise.resolve({ unsubscribe: vi.fn() }) + }) + + const view = renderHook(() => + useStructuredAgentSessionRead({ sessionId: 'session-a', target: LOCAL_TARGET }) + ) + + try { + await waitFor(() => expect(onEvents).toHaveLength(1)) + act(() => window.dispatchEvent(new Event('focus'))) + await waitFor(() => expect(mocks.call).toHaveBeenCalledTimes(2)) + + const replacementPage = page( + 'tail', + [message('new-epoch', 2, 'assistant')], + false, + 'epoch-b' + ) + const replacementEvent: AgentSessionSubscribeEvent = + eventType === 'reset' + ? { + type: 'reset', + sessionId: 'session-a', + reset: 'epoch_changed', + page: replacementPage, + fence: 2 + } + : { type: 'snapshot', sessionId: 'session-a', page: replacementPage, fence: 2 } + act(() => onEvents[0]?.(replacementEvent)) + + expect(view.result.current.state.epoch).toBe('epoch-b') + expect(view.result.current.state.items[0]?.itemId).toBe('new-epoch') + expect(view.result.current.providerSession).toBe(initialProviderSession) + const stateAfterReplacement = view.result.current.state + + await act(async () => { + delayedRefresh.resolve({ + ok: true, + page: page('tail', [message('stale-refresh', 3, 'assistant')], false), + providerSession: { key: 'session_id', id: 'provider-stale' } + }) + await delayedRefresh.promise + await Promise.resolve() + }) + + expect(view.result.current.state).toBe(stateAfterReplacement) + expect(view.result.current.state.epoch).toBe('epoch-b') + expect(view.result.current.state.items[0]?.itemId).toBe('new-epoch') + expect(view.result.current.providerSession).toBe(initialProviderSession) + } finally { + view.unmount() + hasFocus.mockRestore() + } + } + ) + + it('does no host work for retained inactive sessions', async () => { + const first = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-inactive-a', + target: LOCAL_TARGET, + isVisible: false + }) + ) + const second = renderHook(() => + useStructuredAgentSessionRead({ + sessionId: 'session-inactive-b', + target: LOCAL_TARGET, + isVisible: false + }) + ) + + await act(() => Promise.resolve()) + + expect(mocks.call).not.toHaveBeenCalled() + expect(mocks.subscribe).not.toHaveBeenCalled() + first.unmount() + second.unmount() + }) + + it('shares one subscriber when pane and projection observe the same visible session', async () => { + const unsubscribe = vi.fn() + mocks.call.mockResolvedValue({ ok: true, page: page('tail', [], false) }) + mocks.subscribe.mockResolvedValue({ unsubscribe }) + + const view = renderHook(() => { + const pane = useStructuredAgentSessionRead({ + sessionId: 'session-shared', + target: LOCAL_TARGET, + isVisible: true + }) + const projection = useStructuredAgentSessionReadObservation({ + sessionId: 'session-shared', + target: LOCAL_TARGET + }) + return { pane, projection } + }) + + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledOnce()) + expect(mocks.call).toHaveBeenCalledOnce() + expect(view.result.current.pane.state).toBe(view.result.current.projection.state) + + view.unmount() + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('preserves cached state while switching away and refreshes once on re-entry', async () => { + const unsubscribe = vi.fn() + mocks.call.mockImplementation((_target, _method, params) => { + const sessionId = (params as { sessionId: string }).sessionId + return Promise.resolve({ + ok: true, + page: { + ...page('tail', [message(`${sessionId}-message`, 1, 'user')], false), + sessionId + } + }) + }) + mocks.subscribe.mockResolvedValue({ unsubscribe }) + const view = renderHook( + ({ active }: { active: 'first' | 'second' | null }) => ({ + first: useStructuredAgentSessionRead({ + sessionId: 'session-switch-a', + target: LOCAL_TARGET, + isVisible: active === 'first' + }), + second: useStructuredAgentSessionRead({ + sessionId: 'session-switch-b', + target: LOCAL_TARGET, + isVisible: active === 'second' + }) + }), + { initialProps: { active: null as 'first' | 'second' | null } } + ) + expect(mocks.call).not.toHaveBeenCalled() + + view.rerender({ active: 'first' }) + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(1)) + expect(view.result.current.first.state.items[0]?.itemId).toBe('session-switch-a-message') + + view.rerender({ active: 'second' }) + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2)) + expect(unsubscribe).toHaveBeenCalledTimes(1) + + view.rerender({ active: 'first' }) + expect(view.result.current.first.state.items[0]?.itemId).toBe('session-switch-a-message') + await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(3)) + expect(mocks.call).toHaveBeenCalledTimes(3) + expect(unsubscribe).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts new file mode 100644 index 00000000000..894730f5e65 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-read.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + getStructuredAgentSessionReadOwner, + type StructuredAgentSessionReadSnapshot +} from './structured-agent-session-read-owner' + +function useReadOwnerSnapshot( + sessionId: string, + target: RuntimeClientTarget +): { + owner: ReturnType + snapshot: StructuredAgentSessionReadSnapshot +} { + const owner = useMemo( + () => getStructuredAgentSessionReadOwner(sessionId, target), + [sessionId, target] + ) + const snapshot = useSyncExternalStore(owner.subscribe, owner.getSnapshot, owner.getSnapshot) + return { owner, snapshot } +} + +export function useStructuredAgentSessionReadObservation(args: { + sessionId: string + target: RuntimeClientTarget +}): StructuredAgentSessionReadSnapshot { + return useReadOwnerSnapshot(args.sessionId, args.target).snapshot +} + +export function useStructuredAgentSessionRead(args: { + sessionId: string + target: RuntimeClientTarget + isVisible?: boolean +}) { + const { sessionId, target, isVisible = true } = args + const { owner, snapshot } = useReadOwnerSnapshot(sessionId, target) + + useEffect(() => (isVisible ? owner.activate() : undefined), [isVisible, owner]) + + useEffect(() => { + if (!isVisible) { + return + } + const refresh = (): void => { + if (document.hasFocus()) { + owner.refresh() + } + } + window.addEventListener('focus', refresh) + return () => window.removeEventListener('focus', refresh) + }, [isVisible, owner]) + + return { + state: snapshot.state, + loadingOlder: snapshot.loadingOlder, + loadOlder: owner.loadOlder, + providerSession: snapshot.providerSession + } +} diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx new file mode 100644 index 00000000000..2e611e4c726 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment happy-dom + +import { act, renderHook, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ call: vi.fn(), operationId: vi.fn() })) +let fence = 3 + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +vi.mock('./use-structured-agent-session-read', () => ({ + useStructuredAgentSessionRead: () => ({ + state: { + fence, + items: [], + submissions: [], + status: 'ready', + error: null, + hasOlder: false, + handoff: null + }, + loadingOlder: false, + loadOlder: vi.fn() + }) +})) + +vi.mock('./use-structured-agent-session-outbox', () => ({ + structuredSessionOperationId: mocks.operationId, + useStructuredAgentSessionOutbox: () => ({ + outbox: [], + blockedClientMessageId: null, + error: null, + send: vi.fn(), + retry: vi.fn() + }) +})) + +import { useStructuredAgentSession } from './use-structured-agent-session' + +const LOCAL_TARGET = { kind: 'local' } as const + +const OPTIONS = { + models: [ + { + id: 'gpt-live', + label: 'GPT Live', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + }, + { + id: 'gpt-fast', + label: 'GPT Fast', + isDefault: false, + defaultEffort: 'low', + efforts: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' } + ] + } + ], + current: { model: 'gpt-live', effort: 'medium' } +} + +describe('useStructuredAgentSession options', () => { + beforeEach(() => { + vi.clearAllMocks() + fence = 3 + mocks.operationId + .mockReset() + .mockReturnValueOnce('operation-1') + .mockReturnValueOnce('operation-2') + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' ? Promise.resolve(OPTIONS) : Promise.resolve(null) + ) + }) + + it('applies provider-reconciled values after a model change', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: true, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')?.kind).toMatchObject( + { + currentValue: 'gpt-fast' + } + ) + expect( + result.current.optionSnapshot.find((entry) => entry.id === 'effort')?.kind + ).toMatchObject({ + currentValue: 'low' + }) + }) + + it('surfaces a rejected option transport call and clears pending state', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.reject(new Error('provider rejected option')) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + }) + + expect(result.current.error).toBe('provider rejected option') + expect(result.current.optionSnapshot.find((entry) => entry.id === 'model')).toMatchObject({ + settable: true + }) + }) + + it('mints a fresh operation when the same option is retried after a typed refusal', async () => { + let attempts = 0 + mocks.call.mockImplementation((_target, method) => { + if (method !== 'agentSession.setOption') { + // The hook also holds the session while it is mounted; only option writes are attempts. + return Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + } + attempts += 1 + return Promise.resolve( + attempts === 1 + ? { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: 'model list unavailable' + } + } + : { + ok: true, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + } + ) + }) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + const mutations = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.setOption' + ) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + ).toEqual(['operation-1', 'operation-2']) + }) + + it('reuses an option operation after a pending admission refusal', async () => { + let attempts = 0 + mocks.call.mockImplementation((_target, method) => { + if (method !== 'agentSession.setOption') { + // The hook also holds the session while it is mounted; only option writes are attempts. + return Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + } + attempts += 1 + return Promise.resolve( + attempts === 1 + ? { + ok: false, + refusal: { + code: 'agent_session_checkpoint_stale', + message: 'runtime fence advanced', + currentFence: 4 + } + } + : { + ok: true, + replayed: false, + value: { + key: 'model', + value: 'gpt-fast', + options: { model: 'gpt-fast', effort: 'low' } + } + } + ) + }) + const { result, rerender } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(false) + }) + fence = 4 + rerender() + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + await act(async () => { + expect(await result.current.setStructuredOption('model', 'gpt-fast')).toBe(true) + }) + + const mutations = mocks.call.mock.calls.filter( + ([, method]) => method === 'agentSession.setOption' + ) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { clientOperationId: string } }).envelope.clientOperationId + ) + ).toEqual(['operation-1', 'operation-1']) + expect( + mutations.map( + ([, , params]) => + (params as { envelope: { expectedRuntimeFence: number } }).envelope.expectedRuntimeFence + ) + ).toEqual([3, 4]) + expect(mocks.operationId).toHaveBeenCalledTimes(1) + }) + + it('ignores an option failure from a superseded fence', async () => { + let reject!: (error: Error) => void + const pending = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise + }) + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' ? Promise.resolve(OPTIONS) : pending + ) + const { result, rerender } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'codex', + isVisible: true + }) + ) + await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2)) + let setting!: Promise + act(() => { + setting = result.current.setStructuredOption('model', 'gpt-fast') + }) + fence = 4 + rerender() + + await act(async () => { + reject(new Error('stale provider failure')) + await setting + }) + + expect(result.current.error).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.ts b/src/renderer/src/components/native-chat/use-structured-agent-session.ts new file mode 100644 index 00000000000..5bea1af8c50 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + AgentSessionMutationResult, + AgentSessionOptionResult, + AgentSessionOptionsResult, + AgentSessionPromptResult +} from '../../../../shared/agent-session-wire' +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import { agentSessionRefusalOperationState } from '../../../../shared/agent-session-refusal-retry' +import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' +import { + applyStructuredAgentSessionOptions, + canSetStructuredAgentSessionOption, + commitStructuredAgentSessionOptionValues, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from '../../../../shared/structured-agent-session-options' +import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + structuredSessionOperationId, + useStructuredAgentSessionOutbox +} from './use-structured-agent-session-outbox' +import { useStructuredAgentSessionHold } from './use-structured-agent-session-hold' +import { useStructuredAgentSessionRead } from './use-structured-agent-session-read' +import { projectStructuredAgentSessionMessages } from './structured-agent-session-message-projection' + +export type StructuredPromptItem = AgentJournalRenderItem & { + body: Extract +} + +export function useStructuredAgentSession(args: { + sessionId: string + target: RuntimeClientTarget + agent: AgentType + isVisible: boolean +}) { + const { agent, isVisible, sessionId, target } = args + // Declared first: the hold is what gives a restored session its provider child back, and the + // read below is useless for sending until it lands. + useStructuredAgentSessionHold({ + sessionId, + target, + surface: 'desktop-chat', + enabled: isVisible + }) + const { state, loadingOlder, loadOlder } = useStructuredAgentSessionRead({ + sessionId, + target, + isVisible + }) + const stateRef = useRef(state) + const [writeError, setWriteError] = useState(null) + const operationIds = useRef(new Map()) + const [optionState, setOptionState] = useState(() => + createStructuredAgentSessionOptionState(agent) + ) + const activeOptionRecordRef = useRef(optionState.record) + const optionCatalog = useMemo(() => getAgentSessionOptionCatalog(agent), [agent]) + const outboxController = useStructuredAgentSessionOutbox({ + sessionId, + target, + fence: state.fence, + submissions: state.submissions + }) + + useEffect(() => { + stateRef.current = state + }, [state]) + + useEffect(() => { + const next = createStructuredAgentSessionOptionState(agent) + activeOptionRecordRef.current = next.record + setOptionState(next) + }, [agent, sessionId, state.fence]) + + const mutate = useCallback( + async ( + method: string, + fingerprintMethod: string, + fields: Record, + operationIdOverride?: string | null + ): Promise => { + if (stateRef.current.fence === null) { + return null + } + const targetFence = stateRef.current.fence + const key = `${fingerprintMethod}:${JSON.stringify(fields)}` + const clientOperationId = + operationIdOverride ?? operationIds.current.get(key) ?? structuredSessionOperationId() + operationIds.current.set(key, clientOperationId) + let result: AgentSessionMutationResult + try { + result = await callStructuredAgentSession>(target, method, { + envelope: { + sessionId, + clientOperationId, + expectedRuntimeFence: targetFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: fingerprintMethod, + sessionId, + fields + }) + }, + ...fields + }) + } catch (error) { + if (stateRef.current.fence === targetFence) { + setWriteError(error instanceof Error ? error.message : 'Request was not sent') + } + return null + } + if (!result.ok) { + if ( + agentSessionRefusalOperationState(fingerprintMethod, result.refusal.code) === + 'settled-rejected' + ) { + operationIds.current.delete(key) + } + if (stateRef.current.fence === targetFence) { + setWriteError(result.refusal.message) + } + return null + } + if (stateRef.current.fence !== targetFence) { + return null + } + operationIds.current.delete(key) + setWriteError(null) + return result.value + }, + [sessionId, target] + ) + + useEffect(() => { + if (!isVisible || !optionCatalog) { + return + } + let stale = false + void callStructuredAgentSession(target, 'agentSession.options', { + sessionId + }) + .then((result) => { + if (!stale) { + setOptionState((current) => + current.record === activeOptionRecordRef.current + ? applyStructuredAgentSessionOptions(current, optionCatalog, result) + : current + ) + } + }) + .catch(() => {}) + return () => { + stale = true + } + }, [isVisible, optionCatalog, sessionId, state.fence, target]) + + const optionSnapshot = useMemo( + () => structuredAgentSessionOptionSnapshot(optionState), + [optionState] + ) + const setStructuredOption = useCallback( + async (id: string, value: string | boolean): Promise => { + if ( + !canSetStructuredAgentSessionOption(optionState, id, value) || + typeof value !== 'string' + ) { + return false + } + const targetRecord = optionState.record + setOptionState((current) => ({ ...current, pendingId: id })) + try { + const result = await mutate( + 'agentSession.setOption', + 'agentSession.setOption', + { key: id, value } + ) + if (result && activeOptionRecordRef.current === targetRecord) { + setOptionState((current) => + current.record === targetRecord + ? commitStructuredAgentSessionOptionValues(current, result.options ?? { [id]: value }) + : current + ) + } + return Boolean(result) + } finally { + setOptionState((current) => + current.record === targetRecord && current.pendingId === id + ? { ...current, pendingId: null } + : current + ) + } + }, + [mutate, optionState] + ) + const setOption = useCallback( + async (id: string, value: string | boolean) => { + await setStructuredOption(id, value) + return { snapshot: optionSnapshot } + }, + [optionSnapshot, setStructuredOption] + ) + const optionSurface = useMemo( + () => ({ + getSnapshot: () => optionSnapshot, + setOption, + invokeAction: async () => ({ snapshot: optionSnapshot }), + subscribe: () => () => {} + }), + [optionSnapshot, setOption] + ) + + const prompts = state.items.filter( + (item): item is StructuredPromptItem => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + const turnId = activeStructuredAgentSessionTurnId(state.items) + return { + messages: projectStructuredAgentSessionMessages( + state.items, + outboxController.outbox, + state.submissions + ), + status: state.status, + error: state.error ?? writeError ?? outboxController.error, + hasOlder: state.hasOlder, + loadingOlder, + loadOlder, + prompts, + outbox: outboxController.outbox, + blockedClientMessageId: outboxController.blockedClientMessageId, + send: outboxController.send, + retry: outboxController.retry, + isWorking: turnId !== null, + turnId, + cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }), + respond: (item: StructuredPromptItem, optionId: string) => + mutate( + item.body.kind === 'approval' + ? 'agentSession.respondToApproval' + : 'agentSession.respondToQuestion', + `agentSession.respondTo:${item.body.kind}`, + { itemId: item.itemId, expectedRevision: item.revision, optionId } + ), + optionSnapshot, + optionSurface, + setStructuredOption + } +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index 8af06720ce0..389d60000f8 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -96,6 +96,7 @@ export function VaultSessionRow({ writeAiVaultSessionDragData(event.dataTransfer, { agent: session.agent, sessionId: session.sessionId, + ...(session.structuredSession ? { structuredSession: session.structuredSession } : {}), title: session.title, command: resumeStartup.command, sessionFilePath: session.filePath, diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx index 1aa6ac3a5bb..24bc826e973 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionSubagents.test.tsx @@ -1,12 +1,24 @@ // @vitest-environment happy-dom +import type { ComponentProps, JSX } from 'react' import { act, render } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' import type { AiVaultSession, AiVaultSubagentListResult } from '../../../../shared/ai-vault-types' -import { SessionSubagentsSection } from './AiVaultSessionSubagents' +import { SessionSubagentsSection as ProductionSessionSubagentsSection } from './AiVaultSessionSubagents' const listSubagentSessions = vi.fn<(args: unknown) => Promise>() +function SessionSubagentsSection( + props: ComponentProps +): JSX.Element { + return ( + + + + ) +} + beforeEach(() => { listSubagentSessions.mockReset() // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only window.api shim @@ -86,6 +98,22 @@ describe('SessionSubagentsSection', () => { expect(queryByText('First pass')).toBeNull() }) + it('labels the subagent run state exactly once, on the dot itself', async () => { + listSubagentSessions.mockResolvedValueOnce({ + sessions: [makeSubagent('Running task')], + issues: [] + }) + const { container } = render() + await act(async () => {}) + + const titles = [...container.querySelectorAll('[title]')].map((element) => + element.getAttribute('title') + ) + + expect(titles).toEqual(['Running task', 'View Log']) + expect(container.querySelector('[data-slot="tooltip-trigger"]')).not.toBeNull() + }) + it('does not fetch for remote sessions even when the scan counted transcripts', async () => { const { container } = render( + ) : null} diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx index c9d535b2a05..72a73046288 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.review-header.test.tsx @@ -29,11 +29,11 @@ afterEach(() => { }) function renderHeader({ - canUnlinkPullRequest = true, + canUnlinkReview = true, provider = 'github', modifierHintDestination = 'system-browser' }: { - canUnlinkPullRequest?: boolean + canUnlinkReview?: boolean provider?: 'github' | 'gitlab' modifierHintDestination?: ChecksPanelHostedReviewModifierDestination } = {}): string { @@ -53,12 +53,12 @@ function renderHeader({ mergeable: 'UNKNOWN' }} isRefreshing={false} - canUnlinkPullRequest={canUnlinkPullRequest} + canUnlinkReview={canUnlinkReview} modifierHintDestination={modifierHintDestination} onRefresh={vi.fn()} onOpenReview={vi.fn()} - onUnlinkPullRequest={vi.fn()} - onLinkAnotherPullRequest={vi.fn()} + onUnlinkReview={vi.fn()} + onLinkAnotherReview={vi.fn()} /> ) } @@ -109,19 +109,19 @@ describe('ChecksPanelReviewHeader', () => { }) it('disables unlinking when the displayed PR is not manually linked', () => { - const markup = renderHeader({ canUnlinkPullRequest: false }) + const markup = renderHeader({ canUnlinkReview: false }) expect(markup).toContain('data-disabled="true"') expect(markup).toContain('unlink PR') }) - it('shows GitLab MR identity without GitHub-only link management actions', () => { + it('shows GitLab MR identity with provider-appropriate link management actions', () => { const markup = renderHeader({ provider: 'gitlab' }) expect(markup).toContain('Open on GitLab') expect(markup).toContain('!31') - expect(markup).not.toContain('More PR actions') - expect(markup).not.toContain('unlink PR') - expect(markup).not.toContain('Link another PR') + expect(markup).toContain('More MR actions') + expect(markup).toContain('Unlink MR') + expect(markup).toContain('Link another MR') }) }) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 78a3f9f5628..b475e2a894f 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -41,28 +41,31 @@ import { ChecksPanelActiveContent } from './checks-panel/active-content' type ChecksPanelReviewHeaderProps = { review: ChecksPanelReview isRefreshing: boolean - canUnlinkPullRequest: boolean + canUnlinkReview: boolean modifierHintDestination: ChecksPanelHostedReviewModifierDestination onRefresh: () => void onOpenReview: (event: React.MouseEvent) => void - onUnlinkPullRequest: () => void - onLinkAnotherPullRequest: () => void + onUnlinkReview: () => void + onLinkAnotherReview: () => void } export function ChecksPanelReviewHeader({ review, isRefreshing, - canUnlinkPullRequest, + canUnlinkReview, modifierHintDestination, onRefresh, onOpenReview, - onUnlinkPullRequest, - onLinkAnotherPullRequest + onUnlinkReview, + onLinkAnotherReview }: ChecksPanelReviewHeaderProps): React.JSX.Element { const reviewNumberLabel = review.provider === 'gitlab' ? `!${review.number}` : `#${review.number}` const ReviewIcon = review.provider === 'gitlab' ? GitMerge : PullRequestIcon const reviewHostLabel = review.provider === 'gitlab' ? 'GitLab' : 'GitHub' - const showPullRequestMenu = review.provider === 'github' + const moreActionsLabel = + review.provider === 'gitlab' + ? translate('auto.components.right.sidebar.ChecksPanel.gitlabMoreActions', 'More MR actions') + : translate('auto.components.right.sidebar.ChecksPanel.653c105ecc', 'More PR actions') const openTitle = translate( 'auto.components.right.sidebar.ChecksPanel.5c88c6db07', 'Open on {{value0}}', @@ -104,38 +107,40 @@ export function ChecksPanelReviewHeader({ > - {showPullRequestMenu && ( - - - - - - - - {translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')} - - - - {translate('auto.components.right.sidebar.ChecksPanel.07871c0589', 'Link another PR')} - - - - )} + + + + + + + + {review.provider === 'gitlab' + ? translate('auto.components.right.sidebar.ChecksPanel.gitlabUnlink', 'Unlink MR') + : translate('auto.components.right.sidebar.ChecksPanel.7202f4a40a', 'unlink PR')} + + + + {review.provider === 'gitlab' + ? translate( + 'auto.components.right.sidebar.ChecksPanel.gitlabLinkAnother', + 'Link another MR' + ) + : translate( + 'auto.components.right.sidebar.ChecksPanel.07871c0589', + 'Link another PR' + )} + + +
) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts index 4ede3cafe71..fbc14e6a19f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-launch-actions.ts @@ -2,8 +2,7 @@ import { useCallback, useState } from 'react' import { toast } from 'sonner' import { buildAiVaultResumeCopyCommandForWorktree, - buildAiVaultResumeStartupForWorktree, - type AiVaultResumeStartup + buildAiVaultResumeStartupForWorktree } from '@/lib/ai-vault-resume-command' import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' import { @@ -29,6 +28,7 @@ import { import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { findWorktreeById } from '@/store/slices/worktree-helpers' +import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' export function useAiVaultSessionLaunchActions({ activeWorktree, @@ -40,14 +40,7 @@ export function useAiVaultSessionLaunchActions({ activeWorktreeId: string | null targetState: AiVaultSessionResumeTargetState agentCmdOverrides?: Partial> -}): { - buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup - copyResumeCommand: (session: AiVaultSession, worktreeId?: string | null) => Promise - handleResume: (session: AiVaultSession, targetWorktreeId?: string) => void - handleContinueInNewSession: (session: AiVaultSession, targetWorktreeId: string) => void - continuationRequest: AgentSessionContinuationRequest | null - handleContinuationDialogOpenChange: (open: boolean) => void -} { +}) { const [continuationRequest, setContinuationRequest] = useState(null) @@ -93,6 +86,10 @@ export function useAiVaultSessionLaunchActions({ const handleResume = useCallback( (session: AiVaultSession, targetWorktreeId?: string): void => { + if (session.structuredSession) { + void activateAiVaultStructuredSession(session) + return + } const targetId = resolveAiVaultSessionLaunchTargetOrNotify({ sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts index 3952accd4ac..b1695c8ca8a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts @@ -46,6 +46,7 @@ export type ChecksPanelActiveContentModel = Pick< | 'activeConflictReview' | 'activeGitLabReview' | 'activeReview' + | 'linkedGitLabMR' | 'linkedPR' | 'pr' | 'prRefreshState' @@ -85,10 +86,10 @@ export type ChecksPanelActiveContentModel = Pick< Pick< ChecksPanelCheckAndReviewActionsState, | 'handleFixChecksWithAI' - | 'handleLinkAnotherPullRequest' + | 'handleLinkAnotherReview' | 'handleOpenPR' | 'handleOpenStackPR' - | 'handleUnlinkPullRequest' + | 'handleUnlinkReview' > & Pick & Pick & diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index 3b26c0130d5..237e76b239b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -23,12 +23,12 @@ import type { ChecksPanelActiveContentModel } from './active-content-props' type ReviewHeaderComponentProps = { review: ChecksPanelReview isRefreshing: boolean - canUnlinkPullRequest: boolean + canUnlinkReview: boolean modifierHintDestination: ChecksPanelHostedReviewModifierDestination onRefresh: () => void onOpenReview: (event: React.MouseEvent) => void - onUnlinkPullRequest: () => void - onLinkAnotherPullRequest: () => void + onUnlinkReview: () => void + onLinkAnotherReview: () => void } export function ChecksPanelActiveContent({ @@ -69,7 +69,7 @@ export function ChecksPanelActiveContent({ handleFixChecksWithAI, handleLaunchAborted, handleLaunchAccepted, - handleLinkAnotherPullRequest, + handleLinkAnotherReview, handleLoadCheckDetails, handleOpenPR, handleOpenStackPR, @@ -82,10 +82,11 @@ export function ChecksPanelActiveContent({ handleSetReaction, handleStartEdit, handleTitleKeyDown, - handleUnlinkPullRequest, + handleUnlinkReview, isFixingChecksWithAI, isRefreshing, isResolvingConflictsWithAI, + linkedGitLabMR, linkedPR, pendingCommentResolutionRef, pr, @@ -131,12 +132,14 @@ export function ChecksPanelActiveContent({ void handleRefresh()} onOpenReview={handleOpenPR} - onUnlinkPullRequest={handleUnlinkPullRequest} - onLinkAnotherPullRequest={handleLinkAnotherPullRequest} + onUnlinkReview={handleUnlinkReview} + onLinkAnotherReview={handleLinkAnotherReview} /> {detachedHeadDisplay && } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts b/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts index 522823f28b8..b0a6e91bbe3 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/check-and-review-action-dependencies.ts @@ -28,10 +28,13 @@ export type ChecksPanelCheckAndReviewActionsInput = Pick< | 'fetchPRForBranch' | 'gitLabProjectRefRef' | 'isFixingChecksWithAI' + | 'localExecutionScope' | 'openModal' | 'panelContextKey' | 'panelContextKeyRef' | 'repo' + | 'repoConnectionId' + | 'runtimeEnvironmentId' | 'settings' | 'setChecks' | 'setChecksLoading' diff --git a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts index 58b74e10fa8..6c98eff79d6 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/gitlab-review-client.ts @@ -27,6 +27,7 @@ export async function fetchGitLabMRDetailsForChecks(args: { repoId?: string settings: Parameters[0] iid: number + repoOwnerExecutionHostId?: string }): Promise { const target = getActiveRuntimeTarget(args.settings) if (target.kind === 'environment') { @@ -44,6 +45,7 @@ export async function fetchGitLabMRDetailsForChecks(args: { return (await window.api.gl.workItemDetails({ repoPath: args.repoPath, repoId: args.repoId, + repoOwnerExecutionHostId: args.repoOwnerExecutionHostId, iid: args.iid, type: 'mr' })) as GitLabWorkItemDetails | null diff --git a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx index 63d494edfba..67d601120d3 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx @@ -78,7 +78,7 @@ describe('checks panel concrete content', () => { handleFixChecksWithAI: vi.fn(), handleLaunchAborted: vi.fn(), handleLaunchAccepted: vi.fn(), - handleLinkAnotherPullRequest: vi.fn(), + handleLinkAnotherReview: vi.fn(), handleLoadCheckDetails: vi.fn(), handleOpenPR: vi.fn(), handleRefresh: vi.fn(), @@ -91,11 +91,12 @@ describe('checks panel concrete content', () => { handleSetReaction: vi.fn(), handleStartEdit: vi.fn(), handleTitleKeyDown: vi.fn(), - handleUnlinkPullRequest: vi.fn(), + handleUnlinkReview: vi.fn(), isFixingChecksWithAI: false, isRefreshing: false, isResolvingConflictsWithAI: false, linkedPR: null, + linkedGitLabMR: null, pendingCommentResolutionRef: { current: null }, pr: null, prRefreshState: undefined, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx new file mode 100644 index 00000000000..3990d47ea3a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useChecksPanelCheckAndReviewActions } from './use-checks-panel-check-and-review-actions' + +type Input = Parameters[0] + +afterEach(cleanup) + +function makeInput(overrides: Partial = {}): Input { + const worktree = { + id: 'repo-1::/workspace/repo', + repoId: 'repo-1', + path: '/workspace/repo', + branch: 'refs/heads/feature/mr', + hostId: 'ssh:ssh-1', + displayName: 'MR workspace', + linkedGitLabMR: 42, + linkedPR: null, + comment: '' + } + return { + activeReview: { + provider: 'gitlab', + number: 42, + title: 'GitLab review', + state: 'open', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/42', + status: 'success', + updatedAt: null, + mergeable: 'UNKNOWN' + }, + activeWorktree: worktree as Input['activeWorktree'], + activeWorktreeId: worktree.id, + asyncResultKeyRef: { current: '' }, + branch: 'feature/mr', + checks: [], + fetchHostedReviewForBranch: vi.fn(), + fetchPRCheckDetails: vi.fn(), + fetchPRChecks: vi.fn(), + fetchPRComments: vi.fn(), + fetchPRForBranch: vi.fn(), + gitLabProjectRefRef: { current: null }, + isCurrentAsyncResult: vi.fn(() => true), + isFixingChecksWithAI: false, + linkedAzureDevOpsPR: null, + linkedBitbucketPR: null, + linkedGiteaPR: null, + linkedGitLabMR: 42, + linkedPR: null, + localExecutionScope: null, + openModal: vi.fn(), + panelContextKey: 'context', + panelContextKeyRef: { current: 'context' }, + pr: null, + prCacheKey: 'pr-cache', + repo: { + id: 'repo-1', + path: '/workspace/repo', + connectionId: 'ssh-1' + } as NonNullable, + repoConnectionId: 'ssh-1', + runtimeEnvironmentId: null, + settings: null, + setChecks: vi.fn(), + setChecksLoading: vi.fn(), + setComments: vi.fn(), + setCommentsLoading: vi.fn(), + setIsFixingChecksWithAI: vi.fn(), + sourceControlAiActionsVisible: false, + stateRequestKey: 'state', + updateWorktreeMeta: vi.fn(), + ...overrides + } as Input +} + +describe('useChecksPanelCheckAndReviewActions GitLab links', () => { + it('unlinks the displayed MR through its provider slot', () => { + const input = makeInput() + const { result } = renderHook(() => useChecksPanelCheckAndReviewActions(input)) + + act(() => result.current.handleUnlinkReview()) + + expect(input.updateWorktreeMeta).toHaveBeenCalledWith( + input.activeWorktreeId, + { linkedGitLabMR: null }, + { executionHostId: 'ssh:ssh-1' } + ) + }) + + it('refreshes a replacement MR on the captured owner and leaves details to the poller', async () => { + const fetchHostedReviewForBranch = vi.fn().mockResolvedValue({ + provider: 'gitlab', + number: 43, + title: 'Replacement', + state: 'open', + url: 'https://gitlab.example.com/group/repo/-/merge_requests/43', + status: 'success', + updatedAt: null, + mergeable: 'UNKNOWN', + headSha: 'abc123' + }) + const openModal = vi.fn() + const input = makeInput({ fetchHostedReviewForBranch, openModal }) + const hook = renderHook(({ model }) => useChecksPanelCheckAndReviewActions(model), { + initialProps: { model: input } + }) + + act(() => hook.result.current.handleLinkAnotherReview()) + const modal = openModal.mock.calls[0]?.[1] + expect(modal.suppressHostedReviewRefresh).toBe(true) + hook.rerender({ + model: { + ...input, + activeWorktree: { ...input.activeWorktree, linkedGitLabMR: 43 }, + linkedGitLabMR: 43, + panelContextKey: 'context::gitlab::43' + } as Input + }) + await act(async () => modal.afterSave({ updates: { linkedGitLabMR: 43 } })) + + expect(fetchHostedReviewForBranch).toHaveBeenCalledWith( + '/workspace/repo', + 'feature/mr', + expect.objectContaining({ + linkedGitLabMR: 43, + repoOwnerExecutionHostId: 'ssh:ssh-1' + }) + ) + expect(fetchHostedReviewForBranch).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index 1a7a34fa0fc..5838e9c8781 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -16,6 +16,7 @@ import { translate } from '@/i18n/i18n' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../../shared/github/check-types' import type { GitHubPRStackMapNavigationModifiers } from '../GitHubPRStackMap' import type { ChecksPanelCheckAndReviewActionsInput } from './check-and-review-action-dependencies' +import { useChecksPanelReviewLinkActions } from './use-checks-panel-review-link-actions' function hasGitHubCheckHandle(check: PRCheckDetail): boolean { return Boolean(check.checkRunId || check.workflowRunId || check.url) @@ -24,7 +25,6 @@ function hasGitHubCheckHandle(check: PRCheckDetail): boolean { export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndReviewActionsInput) { const { activeReview, - activeWorktree, activeWorktreeId, asyncResultKeyRef, branch, @@ -41,8 +41,6 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe linkedBitbucketPR, linkedGiteaPR, linkedGitLabMR, - linkedPR, - openModal, panelContextKey, panelContextKeyRef, pr, @@ -55,8 +53,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe setCommentsLoading, setIsFixingChecksWithAI, sourceControlAiActionsVisible, - stateRequestKey, - updateWorktreeMeta + stateRequestKey } = model const handleFixChecksWithAI = useCallback(async (): Promise => { if ( @@ -348,52 +345,17 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe [activeWorktreeId] ) - const handleUnlinkPullRequest = useCallback(() => { - if ( - !activeWorktreeId || - !activeWorktree || - activeReview?.provider !== 'github' || - linkedPR === null - ) { - return - } - void updateWorktreeMeta( - activeWorktreeId, - { linkedPR: null }, - { executionHostId: activeWorktree.hostId } - ) - }, [activeReview?.provider, activeWorktree, activeWorktreeId, linkedPR, updateWorktreeMeta]) - - const handleLinkAnotherPullRequest = useCallback(() => { - if (!activeWorktreeId || !activeWorktree || activeReview?.provider !== 'github') { - return - } - openModal('edit-meta', { - worktreeId: activeWorktreeId, - // Why: the same workspace ID can exist under two hosts. Naming the owner - // keeps the dialog on this workspace instead of the ambiguous lookup. - repoId: activeWorktree.repoId, - executionHostId: activeWorktree.hostId, - currentDisplayName: activeWorktree.displayName, - currentIssue: activeWorktree.linkedIssue, - currentPR: activeWorktree.linkedPR ?? activeReview.number, - currentComment: activeWorktree.comment, - focus: 'pr', - afterSave: ({ updates }: { updates?: { linkedPR?: unknown } }) => { - const nextLinkedPR = updates?.linkedPR - if (typeof nextLinkedPR === 'number') { - void refreshLinkedGitHubPullRequest(nextLinkedPR) - } - } - }) - }, [activeReview, activeWorktree, activeWorktreeId, openModal, refreshLinkedGitHubPullRequest]) + const { handleUnlinkReview, handleLinkAnotherReview } = useChecksPanelReviewLinkActions( + model, + refreshLinkedGitHubPullRequest + ) return { handleFixChecksWithAI, refreshLinkedGitHubPullRequest, handleOpenPR, handleOpenStackPR, - handleUnlinkPullRequest, - handleLinkAnotherPullRequest + handleUnlinkReview, + handleLinkAnotherReview } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx index 5182241b42f..9d177efa92b 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { PRCheckDetail } from '../../../../../shared/github/check-types' import type * as GitLabReviewClient from './gitlab-review-client' @@ -36,6 +37,7 @@ function createModel(overrides: Partial = {}): PollingInput { const fetchPRChecks = vi.fn<() => Promise>().mockResolvedValue([]) return { activeGitLabReview: null, + activeWorktree: null, asyncResultKeyRef: { current: 'cache::main::42' }, branch: 'main', fetchPRChecks, @@ -125,4 +127,118 @@ describe('useChecksPanelPolling live behavior', () => { expect(gitlab.fetchDetails).toHaveBeenCalledOnce() expect(model.fetchPRChecks).not.toHaveBeenCalled() }) + + it('uses an explicit owner and missing head override for a replacement MR', async () => { + const ownerSettings = { + activeRuntimeEnvironmentId: 'owner-runtime' + } as PollingInput['settings'] + const model = createModel({ + activeGitLabReview: { + provider: 'gitlab', + number: 17, + headSha: 'old-head' + } as NonNullable, + activeWorktree: makeWorktree({ + id: 'worktree-1', + repoId: 'repo-1', + hostId: 'runtime:owner-runtime' + }), + settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as PollingInput['settings'] + }) + const { result } = renderHook(() => useChecksPanelPolling(model)) + + await act(async () => + result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + headShaOverride: null, + commitAsCurrent: true, + settingsOverride: ownerSettings + }) + ) + + expect(gitlab.fetchDetails).toHaveBeenCalledWith( + expect.objectContaining({ + iid: 18, + settings: ownerSettings, + repoOwnerExecutionHostId: 'runtime:owner-runtime' + }) + ) + expect(model.asyncResultKeyRef.current).toContain('::18::none') + expect(model.asyncResultKeyRef.current).not.toContain('old-head') + }) + + it('drops replacement MR details when the relink scope changes in flight', async () => { + let resolveDetails!: (value: { + item: { projectRef: null } + pipelineJobs: PRCheckDetail[] + comments: [] + }) => void + gitlab.fetchDetails.mockReturnValueOnce( + new Promise((resolve) => { + resolveDetails = resolve + }) + ) + let requestCurrent = true + const model = createModel() + const { result } = renderHook(() => useChecksPanelPolling(model)) + + const request = result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + commitAsCurrent: true, + isRequestCurrent: () => requestCurrent + }) + await act(() => Promise.resolve()) + requestCurrent = false + resolveDetails({ + item: { projectRef: null }, + pipelineJobs: [], + comments: [] + }) + await act(async () => request) + + expect(model.setChecks).not.toHaveBeenCalled() + expect(model.setComments).not.toHaveBeenCalled() + expect(model.setChecksLoading).toHaveBeenLastCalledWith(false) + expect(model.setCommentsLoading).toHaveBeenLastCalledWith(false) + }) + + it('keeps loading owned by the newest replacement MR details request', async () => { + const detailsResolvers: ((value: { + item: { projectRef: null } + pipelineJobs: [] + comments: [] + }) => void)[] = [] + gitlab.fetchDetails.mockImplementation( + () => + new Promise((resolve) => { + detailsResolvers.push(resolve) + }) + ) + let firstRequestCurrent = true + const model = createModel() + const { result } = renderHook(() => useChecksPanelPolling(model)) + + const firstRequest = result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + commitAsCurrent: true, + isRequestCurrent: () => firstRequestCurrent + }) + await act(() => Promise.resolve()) + firstRequestCurrent = false + const secondRequest = result.current.fetchGitLabDetails({ + mrNumberOverride: 18, + commitAsCurrent: true + }) + await act(() => Promise.resolve()) + + detailsResolvers[0]?.({ item: { projectRef: null }, pipelineJobs: [], comments: [] }) + await act(async () => firstRequest) + expect(model.setChecksLoading).not.toHaveBeenCalledWith(false) + expect(model.setCommentsLoading).not.toHaveBeenCalledWith(false) + + detailsResolvers[1]?.({ item: { projectRef: null }, pipelineJobs: [], comments: [] }) + await act(async () => secondRequest) + expect(model.setChecksLoading).toHaveBeenLastCalledWith(false) + expect(model.setCommentsLoading).toHaveBeenLastCalledWith(false) + }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx index 25ac79fc5e0..510a60cc025 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-polling.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller' import { gitLabPipelineJobsToPRChecks } from '../../../../../shared/gitlab-pipeline-checks' import { @@ -17,6 +17,7 @@ type ChecksPanelPollingInput = Pick< Pick< ChecksPanelControllerState, | 'asyncResultKeyRef' + | 'activeWorktree' | 'branch' | 'fetchPRChecks' | 'isPanelVisible' @@ -34,6 +35,7 @@ type ChecksPanelPollingInput = Pick< export function useChecksPanelPolling(model: ChecksPanelPollingInput) { const { + activeWorktree, activeGitLabReview, asyncResultKeyRef, branch, @@ -54,6 +56,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { setCommentsLoading, gitLabProjectRefRef } = model + const gitLabDetailsLoadingGenerationRef = useRef(0) // Fetch checks via cached store method const fetchChecks = useCallback( async ({ @@ -136,14 +139,19 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { async ({ mrNumberOverride, headShaOverride, - commitAsCurrent = false + commitAsCurrent = false, + settingsOverride, + isRequestCurrent }: { mrNumberOverride?: number | null headShaOverride?: string | null commitAsCurrent?: boolean + settingsOverride?: ChecksPanelControllerState['settings'] + isRequestCurrent?: () => boolean } = {}) => { const targetMRNumber = mrNumberOverride ?? activeGitLabReview?.number ?? null - const targetHeadSha = headShaOverride ?? activeGitLabReview?.headSha ?? null + const targetHeadSha = + headShaOverride === undefined ? (activeGitLabReview?.headSha ?? null) : headShaOverride if (!repo || !targetMRNumber) { return } @@ -154,19 +162,25 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { targetMRNumber, targetHeadSha ) + if (isRequestCurrent?.() === false) { + return + } if (commitAsCurrent) { asyncResultKeyRef.current = requestKey } + const loadingGeneration = gitLabDetailsLoadingGenerationRef.current + 1 + gitLabDetailsLoadingGenerationRef.current = loadingGeneration setChecksLoading(true) setCommentsLoading(true) try { const details = await fetchGitLabMRDetailsForChecks({ repoPath: repo.path, repoId: repo.id, - settings, - iid: targetMRNumber + settings: settingsOverride ?? settings, + iid: targetMRNumber, + repoOwnerExecutionHostId: activeWorktree?.hostId }) - if (!isCurrentAsyncResult(requestKey)) { + if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) { return } gitLabProjectRefRef.current = details?.item.projectRef ?? null @@ -180,14 +194,17 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { : 30_000 prevChecksRef.current = signature } catch (err) { - if (!isCurrentAsyncResult(requestKey)) { + if (isRequestCurrent?.() === false || !isCurrentAsyncResult(requestKey)) { return } console.warn('Failed to fetch GitLab MR checks:', err) setChecks([]) setComments([]) } finally { - if (isCurrentAsyncResult(requestKey)) { + if ( + gitLabDetailsLoadingGenerationRef.current === loadingGeneration && + isCurrentAsyncResult(requestKey) + ) { setChecksLoading(false) setCommentsLoading(false) } @@ -196,6 +213,7 @@ export function useChecksPanelPolling(model: ChecksPanelPollingInput) { [ activeGitLabReview?.headSha, activeGitLabReview?.number, + activeWorktree?.hostId, branch, hostedReviewCacheKey, isCurrentAsyncResult, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx new file mode 100644 index 00000000000..6a7645667ec --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-link-actions.tsx @@ -0,0 +1,130 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type { ChecksPanelCheckAndReviewActionsInput } from './check-and-review-action-dependencies' + +type RefreshLinkedGitHubPullRequest = (linkedPRNumber: number) => Promise + +export function useChecksPanelReviewLinkActions( + model: ChecksPanelCheckAndReviewActionsInput, + refreshLinkedGitHubPullRequest: RefreshLinkedGitHubPullRequest +) { + const { + activeReview, + activeWorktree, + activeWorktreeId, + branch, + fetchHostedReviewForBranch, + linkedGitLabMR, + linkedPR, + localExecutionScope, + openModal, + repo, + repoConnectionId, + runtimeEnvironmentId, + updateWorktreeMeta + } = model + const reviewLinkScopeKey = JSON.stringify([ + repo?.id ?? null, + repo?.path ?? null, + activeWorktree?.id ?? null, + activeWorktree?.path ?? null, + branch, + activeWorktree?.hostId ?? null, + repo?.executionHostId ?? null, + repoConnectionId, + runtimeEnvironmentId, + localExecutionScope + ]) + const reviewLinkScopeKeyRef = useRef(reviewLinkScopeKey) + const reviewLinkActionGenerationRef = useRef(0) + useLayoutEffect(() => { + reviewLinkScopeKeyRef.current = reviewLinkScopeKey + }, [reviewLinkScopeKey]) + + const handleUnlinkReview = useCallback(() => { + if (!activeWorktreeId || !activeWorktree || !activeReview) { + return + } + const updates = + activeReview.provider === 'gitlab' + ? linkedGitLabMR === null + ? null + : { linkedGitLabMR: null } + : linkedPR === null + ? null + : { linkedPR: null } + if (!updates) { + return + } + reviewLinkActionGenerationRef.current += 1 + void updateWorktreeMeta(activeWorktreeId, updates, { executionHostId: activeWorktree.hostId }) + }, [activeReview, activeWorktree, activeWorktreeId, linkedGitLabMR, linkedPR, updateWorktreeMeta]) + + const handleLinkAnotherReview = useCallback(() => { + if (!activeWorktreeId || !activeWorktree || !activeReview || !repo || !branch) { + return + } + const provider = activeReview.provider + const openedScopeKey = reviewLinkScopeKey + openModal('edit-meta', { + worktreeId: activeWorktreeId, + // Why: the same workspace ID can exist under two hosts, so pin the dialog to its owner. + repoId: activeWorktree.repoId, + executionHostId: activeWorktree.hostId, + currentDisplayName: activeWorktree.displayName, + currentIssue: activeWorktree.linkedIssue, + reviewProvider: provider, + currentReview: + provider === 'gitlab' + ? (activeWorktree.linkedGitLabMR ?? activeReview.number) + : (activeWorktree.linkedPR ?? activeReview.number), + currentComment: activeWorktree.comment, + focus: 'pr', + suppressHostedReviewRefresh: true, + afterSave: async ({ + updates + }: { + updates?: { linkedPR?: unknown; linkedGitLabMR?: unknown } + }) => { + const actionGeneration = reviewLinkActionGenerationRef.current + 1 + reviewLinkActionGenerationRef.current = actionGeneration + const isActionCurrent = (): boolean => + reviewLinkScopeKeyRef.current === openedScopeKey && + reviewLinkActionGenerationRef.current === actionGeneration + if (!isActionCurrent()) { + return + } + if (provider === 'github') { + if (typeof updates?.linkedPR === 'number') { + await refreshLinkedGitHubPullRequest(updates.linkedPR) + } + return + } + const nextMR = updates?.linkedGitLabMR + if (typeof nextMR !== 'number') { + return + } + await fetchHostedReviewForBranch(repo.path, branch, { + repoId: repo.id, + repoOwnerExecutionHostId: activeWorktree.hostId, + linkedGitHubPR: null, + linkedGitLabMR: nextMR, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null + }) + } + }) + }, [ + activeReview, + activeWorktree, + activeWorktreeId, + branch, + fetchHostedReviewForBranch, + openModal, + refreshLinkedGitHubPullRequest, + repo, + reviewLinkScopeKey + ]) + + return { handleUnlinkReview, handleLinkAnotherReview } +} diff --git a/src/renderer/src/components/settings/ExperimentalPane.test.tsx b/src/renderer/src/components/settings/ExperimentalPane.test.tsx index 54264e1a4f6..b421b77bfc2 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.test.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.test.tsx @@ -222,6 +222,94 @@ describe('ExperimentalPane', () => { expect(markup).toContain('aria-checked="true"') }) + it('shows the structured-native-chat child setting only when Chat UI is the default view', async () => { + const updateSettings = vi.fn() + const disabledSettings = getDefaultSettings('/tmp') + const disabledMarkup = renderToStaticMarkup( + + ) + expect(disabledMarkup).toContain('Chat UI') + expect(disabledMarkup).not.toContain('Use updated structured native chat') + expect(disabledMarkup).not.toContain('Default view') + + const terminalDefault = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: false + } + const terminalRender = await renderExperimentalPane({ + updateSettings, + settings: terminalDefault + }) + + // The default-view control is a sibling of the Chat UI toggle, never replaced by the opt-in. + expect(terminalRender.container.textContent).toContain('Default view') + expect( + terminalRender.container.querySelector('[data-slot="native-chat-default-view-select"]') + ).not.toBeNull() + // Structured chat has no entry path under Terminal chat, so its opt-in is not offered. + expect(terminalRender.container.textContent).not.toContain('Use updated structured native chat') + terminalRender.root.unmount() + + const { root, container } = await renderExperimentalPane({ + updateSettings, + settings: { ...terminalDefault, openAgentTabsInChatByDefault: true } + }) + + expect(container.textContent).toContain('Use updated structured native chat') + expect(container.textContent).toContain( + 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + ) + expect(container.textContent).toContain('Default view') + root.unmount() + }) + + it('hides a stale structured opt-in under Terminal chat without clearing it', async () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + expect(container.textContent).toContain('Use updated structured native chat') + + const terminalChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + // Switching the default view must not clobber the persisted opt-in — only hide its control. + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + expect(updateSettings).toHaveBeenCalledTimes(1) + root.unmount() + + const hidden = await renderExperimentalPane({ + updateSettings, + settings: { ...settings, openAgentTabsInChatByDefault: false } + }) + + expect(hidden.container.textContent).not.toContain('Use updated structured native chat') + hidden.root.unmount() + + // Returning to Chat UI restores the control still switched on. + const restored = await renderExperimentalPane({ updateSettings, settings }) + const structuredSwitch = restored.container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' + ) + expect(structuredSwitch?.getAttribute('aria-checked')).toBe('true') + restored.root.unmount() + }) + it('shows Chat UI default-mode as a child setting only when Chat UI is enabled', async () => { const updateSettings = vi.fn() const disabledSettings = getDefaultSettings('/tmp') @@ -293,6 +381,46 @@ describe('ExperimentalPane', () => { secondRender.root.unmount() }) + // The two controls are nested, but each still writes only its own key. + it('never writes one Chat UI child setting while changing the other', async () => { + const updateSettings = vi.fn() + const settings = { + ...getDefaultSettings('/tmp'), + experimentalNativeChat: true, + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: true + } + const { root, container } = await renderExperimentalPane({ updateSettings, settings }) + + const structuredSwitch = container.querySelector( + '#experimental-native-chat button[role="switch"][aria-label="Toggle updated structured native chat"]' + ) + if (!structuredSwitch) { + throw new Error('Structured native chat switch was not rendered') + } + + await act(async () => { + structuredSwitch.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ experimentalStructuredNativeChat: true }) + + const terminalChatOption = Array.from( + container.querySelectorAll('[data-slot="select-item"]') + ).find((button) => button.getAttribute('data-value') === 'terminal-chat') + if (!terminalChatOption) { + throw new Error('Terminal chat default-view option was not rendered') + } + + await act(async () => { + terminalChatOption.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(updateSettings).toHaveBeenCalledWith({ openAgentTabsInChatByDefault: false }) + expect(updateSettings).toHaveBeenCalledTimes(2) + root.unmount() + }) + it('renders the agent sleep idle duration as configurable minutes', async () => { const updateSettings = vi.fn() const settings = { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 6dac8022c8d..93c4b1c899d 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -19,8 +19,9 @@ export function NativeChatExperimentalSetting({ updateSettings }: NativeChatExperimentalSettingProps): React.JSX.Element { const nativeChatEnabled = settings.experimentalNativeChat === true - const openByDefault = settings.openAgentTabsInChatByDefault === true - const defaultView: NativeChatDefaultView = openByDefault ? 'native-chat' : 'terminal-chat' + const structuredNativeChatEnabled = settings.experimentalStructuredNativeChat === true + const defaultView: NativeChatDefaultView = + settings.openAgentTabsInChatByDefault === true ? 'native-chat' : 'terminal-chat' return ( {translate( 'auto.components.settings.ExperimentalPane.nativeChat.copy', - 'Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' + 'Enables the experimental Chat UI for newly created supported local sessions. Existing terminal sessions keep the terminal chat path while we tune transcript fidelity, streaming, and parity.' )}

@@ -60,7 +61,7 @@ export function NativeChatExperimentalSetting({ />
{nativeChatEnabled ? ( -
+
+ + {/* Structured chat rides the Chat UI default view; it has no entry path under Terminal + chat. Hidden only — the opt-in keeps its persisted value for when Chat UI returns. */} + {defaultView === 'native-chat' ? ( +
+
+ +

+ {translate( + 'auto.components.settings.ExperimentalPane.nativeChat.structuredCopy', + 'Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.' + )} +

+

+ {translate( + 'auto.components.settings.ExperimentalPane.nativeChat.structuredScope', + 'Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.' + )} +

+
+ + updateSettings({ + experimentalStructuredNativeChat: !structuredNativeChatEnabled + }) + } + /> +
+ ) : null}
) : null} diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index c7ebd3a59ec..1c238615628 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -1,8 +1,24 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import StatusIndicator, { type Status } from './StatusIndicator' +vi.mock('@/components/StateIndicatorTooltip', async () => { + const { createElement } = await import('react') + return { + StateIndicatorTooltip: ({ + label, + children + }: { + label: string | null + children: React.ReactElement + }) => + label === null + ? children + : createElement('span', { 'data-state-indicator-tooltip': label }, children) + } +}) + function renderMarkup(status: Status): string { return renderToStaticMarkup(React.createElement(StatusIndicator, { status })) } @@ -31,11 +47,12 @@ describe('StatusIndicator', () => { expect(markup).toContain('motion-reduce:border-t-yellow-500') }) - it('renders monitoring as a static radio glyph', () => { + it('renders monitoring as a static heartbeat glyph', () => { const markup = renderMarkup('monitoring') - expect(markup).toContain('title="Monitoring background tasks"') - expect(markup).toContain('lucide-radio') + expect(markup).toContain('data-state-indicator-tooltip="Monitoring background tasks"') + expect(markup).not.toContain(' title=') + expect(markup).toContain('lucide-activity') expect(markup).toContain('text-yellow-500') expect(markup).not.toContain('data-agent-spinner') }) @@ -67,4 +84,36 @@ describe('StatusIndicator', () => { expect(classNames).toContain('bg-red-500') expect(classNames).not.toContain('bg-emerald-500') }) + + it.each([ + ['working', 'Working'], + ['monitoring', 'Monitoring background tasks'], + ['permission', 'Needs permission'], + ['interrupted', 'Interrupted'], + ['done', 'Done'] + ] as const)('labels the agent-derived %s workspace state', (status, label) => { + const markup = renderMarkup(status) + + expect(markup).toContain(`data-state-indicator-tooltip="${label}"`) + expect(markup).not.toContain(' title=') + }) + + it.each(['active', 'inactive'] as const)( + 'does not label the passive %s workspace state', + (status) => { + const markup = renderMarkup(status) + + expect(markup).not.toContain('data-state-indicator-tooltip') + expect(markup).not.toContain(' title=') + } + ) + + it('lets an enclosing action own the tooltip', () => { + const markup = renderToStaticMarkup( + React.createElement(StatusIndicator, { status: 'working', showTooltip: false }) + ) + + expect(markup).not.toContain('data-state-indicator-tooltip') + expect(markup).toContain('data-agent-spinner') + }) }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 26493bb7d40..fdce0b62f83 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -1,8 +1,12 @@ import React from 'react' -import { Radio } from 'lucide-react' +import { Activity } from 'lucide-react' import { cn } from '@/lib/utils' import { AgentQuestionIcon } from '@/components/AgentQuestionIcon' import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' +import { + StateIndicatorTooltip, + type StateIndicatorTooltipSide +} from '@/components/StateIndicatorTooltip' import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-status' // Why: re-export WorktreeStatus under the existing `Status` alias so the @@ -11,90 +15,92 @@ import { getWorktreeStatusLabel, type WorktreeStatus } from '@/lib/worktree-stat // (e.g., 'error') and the other didn't. export type Status = WorktreeStatus -type StatusIndicatorProps = React.ComponentProps<'span'> & { +type StatusIndicatorProps = Omit, 'title'> & { status: Status + showTooltip?: boolean + tooltipSide?: StateIndicatorTooltipSide } +const AGENT_STATUS_TOOLTIP_STATUSES = new Set([ + 'working', + 'monitoring', + 'permission', + 'interrupted', + 'done' +]) + const StatusIndicator = React.memo(function StatusIndicator({ status, className, - title, + showTooltip = true, + tooltipSide, ...rest }: StatusIndicatorProps) { - // Why: surface the status label as a native tooltip so hovering the dot - // reveals the state — matters especially for 'active' vs 'done', which - // share the same emerald dot. Callers pass aria-hidden="true" alongside - // an sr-only label, so the `title` attribute is ignored by AT and only - // serves sighted users on hover. Callers can override by passing their - // own `title`. - const resolvedTitle = title ?? getWorktreeStatusLabel(status) + const tooltipLabel = + showTooltip && AGENT_STATUS_TOOLTIP_STATUSES.has(status) ? getWorktreeStatusLabel(status) : null + let indicator: React.JSX.Element if (status === 'working') { - return ( + indicator = ( ) - } - - if (status === 'monitoring') { - return ( + } else if (status === 'monitoring') { + indicator = ( - ) - } - - if (status === 'interrupted') { - return ( + } else if (status === 'interrupted') { + indicator = ( ) - } - - if (status === 'permission') { - return ( + } else if (status === 'permission') { + indicator = ( ) + } else { + indicator = ( + + + + ) } return ( - - - + + {indicator} + ) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index a3097460ea7..3fa1c7b053c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -168,7 +168,7 @@ function makeHostedReview(overrides: Partial = {}): HostedRevi } } -function expectParentBodyIsHoverTrigger(markup: string): void { +function expectIdentityBodyIsHoverTrigger(markup: string): void { const surfaceTag = markup.match(/]*data-worktree-card-surface="true"[^>]*>/)?.[0] const triggerTag = markup.match(/]*data-worktree-card-hover-trigger=""[^>]*>/)?.[0] @@ -236,7 +236,7 @@ describe('WorktreeCard compact hover details', () => { ) expect(markup).toContain('data-worktree-title-inline-rename=""') - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup).toContain('data-hover-open-delay="100"') expect(markup).toContain('PR #456') expect(markup).toContain('Fix stale GH PR') @@ -288,7 +288,7 @@ describe('WorktreeCard compact hover details', () => { ) expect(markup).toContain('data-hover-open-delay="100"') - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup).toContain('Issue #123') expect(markup).toContain('Linear ENG-123') expect(markup).toContain('Reviewer handoff note') @@ -397,7 +397,7 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Human title') }) - it('uses one whole-card hover even when detailed metadata icons are visible when new card style is on', async () => { + it('uses one identity hover even when detailed metadata icons are visible when new card style is on', async () => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status', 'issue', 'linear-issue', 'comment', 'ports'] const { default: WorktreeCard } = await import('./WorktreeCard') @@ -417,12 +417,12 @@ describe('WorktreeCard compact hover details', () => { expect(markup).toContain('Workspace metadata') expect(markup).not.toContain('data-worktree-card-meta-row=""') - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1) expect(markup).toContain('Reviewer handoff note') }) - it('keeps long workspace and branch identity in whole-card hover details when the branch row is hidden', async () => { + it('keeps long workspace and branch identity in hover details when the branch row is hidden', async () => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status', 'comment'] const { default: WorktreeCard } = await import('./WorktreeCard') @@ -440,13 +440,13 @@ describe('WorktreeCard compact hover details', () => { ) expect(markup).not.toContain('data-worktree-card-meta-row=""') - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup).toContain('[Bug]: Hold-to-talk speech-to-text option no longer works') expect(markup).toContain('bug-hold-to-talk-speech-to-text-option-no-longer-works') expect(markup).toContain('Reviewer handoff note') }) - it('repeats a long workspace title inside the whole-card hover when branch is already visible', async () => { + it('repeats a long workspace title inside the identity hover when branch is already visible', async () => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status', 'branch', 'comment'] const longTitle = @@ -461,13 +461,13 @@ describe('WorktreeCard compact hover details', () => { /> ) - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup.match(new RegExp(longTitle, 'g'))).toHaveLength(2) expect(markup).toContain('feature/local-branch') expect(markup).toContain('Reviewer handoff note') }) - it('uses whole-card hover for identity-only new card worktrees with branch row visible', async () => { + it('uses identity hover for identity-only new card worktrees with branch row visible', async () => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status', 'branch'] const { default: WorktreeCard } = await import('./WorktreeCard') @@ -481,7 +481,7 @@ describe('WorktreeCard compact hover details', () => { ) expect(markup).toContain('data-worktree-card-meta-row=""') - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup.match(/data-hover-open-delay="100"/g)).toHaveLength(1) expect(markup.match(/Readable identity only/g)).toHaveLength(2) expect(markup).toContain('feature/local-branch') @@ -502,7 +502,7 @@ describe('WorktreeCard compact hover details', () => { /> ) - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup.match(/feature\/local-branch/g)).toHaveLength(3) }) @@ -605,6 +605,28 @@ describe('WorktreeCard compact hover details', () => { ) }) + it('keeps status and agent tooltip targets outside the worktree details hover trigger', async () => { + settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } + worktreeCardProperties = ['status', 'inline-agents'] + agentActivityDisplayMode = 'compact' + mockInlineAgentRows = [{} as DashboardAgentRowData] + const { default: WorktreeCard } = await import('./WorktreeCard') + + const markup = renderToStaticMarkup( + + ) + const statusIndex = markup.indexOf('data-worktree-card-status-slot=""') + const triggerIndex = markup.indexOf('data-worktree-card-hover-trigger=""') + const hoverContentIndex = markup.indexOf('data-hover-card-content=""') + const agentsIndex = markup.indexOf('data-worktree-agents=""') + + expectIdentityBodyIsHoverTrigger(markup) + expect(statusIndex).toBeGreaterThanOrEqual(0) + expect(statusIndex).toBeLessThan(triggerIndex) + expect(hoverContentIndex).toBeGreaterThan(triggerIndex) + expect(agentsIndex).toBeGreaterThan(hoverContentIndex) + }) + it('preserves the aggregate cache timer when compact inline agents are enabled but absent', async () => { settings = { compactWorktreeCards: false, experimentalNewWorktreeCardStyle: true } worktreeCardProperties = ['status', 'inline-agents'] @@ -643,7 +665,7 @@ describe('WorktreeCard compact hover details', () => { const hoverContentIndex = markup.indexOf('data-hover-card-content=""') const childIndex = markup.indexOf('data-lineage-child-card=""') - expectParentBodyIsHoverTrigger(markup) + expectIdentityBodyIsHoverTrigger(markup) expect(markup).toContain('data-worktree-lineage-children=""') expect(markup).toContain('group/worktree-card') expect(markup).not.toContain('group relative flex cursor-pointer') diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx index 90ac7cdf38c..a940dd5f25b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.activation.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' +import { TooltipProvider } from '@/components/ui/tooltip' import type * as ActivateTabAndFocusPaneModule from '@/lib/activate-tab-and-focus-pane' import { makePaneKey } from '../../../../shared/stable-pane-id' @@ -50,6 +51,7 @@ function mockAgent({ let mockAgents: DashboardAgentRowData[] = [] let mockAgentActivityDisplayMode: 'compact' | 'full' | undefined let mockTabsByWorktree: Record = {} +let mockStructuredTabIds = new Set() let mockAgentStatusByPaneKey: Record = {} let mockActiveTabId: string | null = null let mockActiveTabType: string = 'editor' @@ -100,6 +102,10 @@ const staleAgentRowMocks = vi.hoisted(() => ({ dismissStaleAgentRowByKey: vi.fn() })) +const structuredActivationMocks = vi.hoisted(() => ({ + activateStructuredAgentSessionTab: vi.fn() +})) + vi.mock('@/store', () => ({ useAppStore: Object.assign( (selector: (state: unknown) => unknown) => selector(buildMockStoreState()), @@ -121,6 +127,10 @@ vi.mock('../terminal-pane/stale-agent-row', () => ({ dismissStaleAgentRowByKey: staleAgentRowMocks.dismissStaleAgentRowByKey })) +vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ + activateStructuredAgentSessionTab: structuredActivationMocks.activateStructuredAgentSessionTab +})) + vi.mock('./useWorktreeAgentRows', () => ({ useWorktreeAgentRows: vi.fn(() => mockAgents) })) @@ -154,10 +164,43 @@ describe('WorktreeCardAgents activation', () => { mockAgents = [] mockAgentActivityDisplayMode = undefined mockTabsByWorktree = {} + mockStructuredTabIds = new Set() mockAgentStatusByPaneKey = {} mockActiveTabId = null mockActiveTabType = 'editor' capturedRowActivations = [] + structuredActivationMocks.activateStructuredAgentSessionTab.mockImplementation( + ({ tabId }: { tabId: string }) => mockStructuredTabIds.has(tabId) + ) + }) + + it('activates a projected structured session row through the unified tab path', async () => { + mockAgentActivityDisplayMode = 'full' + const tabId = 'structured-tab' + const paneKey = makePaneKey(tabId, LEAF_A) + mockAgents = [ + mockAgent({ + paneKey, + tabId, + agentType: 'codex', + prompt: 'Structured session', + worktreeId: 'wt-1' + }) + ] + mockAgentStatusByPaneKey = { [paneKey]: { worktreeId: 'wt-1' } } + mockStructuredTabIds.add(tabId) + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + renderToStaticMarkup() + capturedRowActivations[0].onActivate(tabId, paneKey) + + expect(activationMocks.activateAndRevealWorktree).toHaveBeenCalledWith('wt-1') + expect(structuredActivationMocks.activateStructuredAgentSessionTab).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + tabId + }) + expect(activationMocks.activateTabAndFocusPane).not.toHaveBeenCalled() + expect(staleAgentRowMocks.dismissStaleAgentRowByKey).not.toHaveBeenCalled() }) it('reveals the worktree and focuses an automation worker row hydrated during reveal', async () => { @@ -395,7 +438,11 @@ describe('WorktreeCardAgents activation', () => { const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') await act(async () => { - root.render() + root.render( + + + + ) }) const row = host.querySelector('.compact-agent-row') expect(row).toBeInstanceOf(HTMLElement) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d5591b9e9d5..4bf3a8dcd17 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -771,7 +771,9 @@ describe('WorktreeCardAgents', () => { const markup = renderToStaticMarkup() const iconTitles = [...markup.matchAll(/title="([^"]+)"/g)].map((match) => match[1]) + // Variety icons stay identity-free; the state label belongs to the shared tooltip. expect(iconTitles).toEqual([]) + expect(markup).toContain('>Working<') expect(markup).not.toContain('>5 working<') expect(markup).toContain('>+2<') }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 87ab769d138..3fe9a01398e 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -26,6 +26,7 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant import { revealElementInScrollContainer } from './worktree-sidebar-reveal' import { useWorktreeAgentExpansionState } from './worktree-card-agents-expansion-state' import { translate } from '@/i18n/i18n' +import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' export const SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT = 'orca-suppress-worktree-list-scroll-adjustment' @@ -173,7 +174,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true }) - } else { + } else if (!activateStructuredAgentSessionTab({ worktreeId, tabId })) { const liveEntry = useAppStore.getState().agentStatusByPaneKey[paneKey] if (liveEntry?.worktreeId === worktreeId) { // Why: orchestration worker status can be worktree-attributed before the renderer knows its tab; keep the live row instead of dismissing as stale. diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx index b9a32ddeb8f..9baed34e995 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.test.tsx @@ -9,8 +9,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: ReactNode }) => {children}, + TooltipContent: ({ children }: { children: ReactNode }) => ( + {children} + ), TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} })) @@ -171,6 +173,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Active · Mark as unread') expect(markup).toContain('bg-emerald-500') + expect(markup.match(/data-tooltip-root/g)).toHaveLength(1) }) it('keeps the quiet active dot ahead of PR status by default', () => { @@ -212,6 +215,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('size-[13px] translate-x-px') expect(markup).toContain('text-rose-500/85') expect(markup).not.toContain('bg-emerald-500') + expect(markup).not.toContain('data-tooltip-root') }) it('uses the unified compact review glyph for GitLab MR status', () => { @@ -277,7 +281,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('bg-neutral-500/40') }) - it('uses a branch icon with branch-only tooltip copy by default', () => { + it('uses a branch icon with branch-only accessible copy by default', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('size-[13px] translate-x-px text-muted-foreground/70') expect(markup).toContain('text-muted-foreground/70') expect(markup).not.toContain('bg-emerald-500') + expect(markup).not.toContain('data-tooltip-root') }) - it('uses context-aware branch or folder path tooltip copy', () => { + it('uses context-aware branch or folder path accessible copy', () => { const markup = renderToStaticMarkup( { expect(markup).toContain('Branch or folder path') expect(markup).toContain('lucide-git-branch') + expect(markup).not.toContain('data-tooltip-root') }) it('keeps the quiet dot when the row has no branch identity', () => { @@ -338,6 +344,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Active') expect(markup).toContain('bg-emerald-500') expect(markup).not.toContain('lucide-git-branch') + expect(markup).not.toContain('data-tooltip-root') }) it('keeps working activity ahead of PR status in new card style', () => { @@ -359,6 +366,8 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Working') expect(markup).toContain('inline-flex size-5 items-center justify-center') expect(markup).toContain('border-yellow-500') + expect(markup).toContain('data-tooltip-root') + expect(markup).toContain('data-tooltip-content="">Working') expect(markup).not.toContain('PR checks: Failed') }) @@ -381,6 +390,8 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).toContain('Needs permission') expect(markup).toContain('lucide-message-circle-question-mark') expect(markup).toContain('text-agent-question') + expect(markup).toContain('data-tooltip-root') + expect(markup).toContain('data-tooltip-content="">Needs permission') expect(markup).not.toContain('PR checks: Failed') }) @@ -433,6 +444,7 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('lucide-bell') expect(markup).not.toContain('text-amber-500') expect(markup).not.toContain('bg-emerald-500') + expect(markup).not.toContain('data-tooltip-root') }) it('overlays an unread badge on the branch icon in new card style', () => { @@ -461,5 +473,6 @@ describe('WorktreeCardStatusSlot', () => { expect(markup).not.toContain('lucide-bell') expect(markup).not.toContain('text-amber-500') expect(markup).not.toContain('bg-emerald-500') + expect(markup).not.toContain('data-tooltip-root') }) }) diff --git a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx index 9f8dc175652..72b04b22b1f 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardStatusSlot.tsx @@ -63,7 +63,7 @@ function overlayNewCardUnreadStatus( ) } -function getReviewStatusTooltip(review: WorktreeCardPrDisplay): string { +function getReviewStatusLabel(review: WorktreeCardPrDisplay): string { const label = getReviewLabel(review) if (review.state === 'merged') { return `${label}: Merged` @@ -115,57 +115,44 @@ export function WorktreeCardStatusSlot({ QUIET_REVIEW_REPLACEABLE_STATUSES.has(status) const passiveStatusLabel = canShowReviewStatus && prDisplay - ? getReviewStatusTooltip(prDisplay) + ? getReviewStatusLabel(prDisplay) : canShowBranchStatus ? (branchIdentityLabel ?? getDefaultBranchIdentityLabel()) : statusLabel - const passiveStatusTooltip = + const passiveStatusAnnouncement = newCardStyle && isUnread ? `${passiveStatusLabel} · Unread` : passiveStatusLabel // Why: working and permission already own the new-card status lane, but - // unread state should still surface in tooltip/sr-only copy and reappear afterward. + // unread state should still surface to assistive technology and reappear afterward. const showNewCardUnreadAlert = newCardStyle && isUnread && showStatus && status !== 'working' && status !== 'permission' const reviewStatusIconClassName = compactReviewAndBranchStatusIconClassName const branchStatusIcon = ) : showStatus ? ( -
diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index a9c91c78131..a045a7b2d32 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -4,8 +4,6 @@ import { LoaderCircle } from 'lucide-react' import { cn } from '@/lib/utils' import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' import WorktreeContextMenu from './WorktreeContextMenu' -import { WorktreeCardDetailsHover } from './WorktreeCardMeta' -import { WorktreeCardPortsDetails } from './WorktreeCardPorts' import { WorktreeCardParentContent } from './worktree-card-parent-content' import { buildWorktreeCardPresentation } from './worktree-card-presentation' import type { WorktreeCardController } from './use-worktree-card-controller' @@ -38,83 +36,13 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): handleDragStart, handleDragEnd, handleContextMenuSelect, - hoverIssue, - hoverLinearIssue, - hoverJiraIssue, - hoverReview, - hoverComment, - metaAutomationProvenance, - metaCliProvenance, - workspacePorts, - detailsHoverControl, - handleRenameTitle, - handleEditIssue, - handleEditComment, - handleOpenGitHubIssueInOrca, - linearIssue, - handleOpenLinearIssueInOrca, - handleOpenReviewInOrca, - handleOpenAutomation, - handleOpenAutomationRun, - hasExplicitLinkedReview, - handleUnlinkReview, showRenameErrorDialog, setShowRenameErrorDialog } = card - const { titleOnlyCard, hasHoverDetails, hoverBranchName, hoverWorkspaceTitle, cardStyle } = - presentation + const { titleOnlyCard, cardStyle } = presentation const parentCardContent = - const parentHoverTriggerBody = ( -
- {parentCardContent} -
- ) - - const parentCardBodyWithHoverDetails = - hasHoverDetails && !titleRenaming ? ( - 0 ? : null - } - openDelay={100} - hoverControl={detailsHoverControl} - onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} - onEditIssue={affiliateListMode ? undefined : handleEditIssue} - onEditComment={affiliateListMode ? undefined : handleEditComment} - onOpenGitHubIssueInOrca={ - hoverIssue && 'url' in hoverIssue && hoverIssue.url - ? handleOpenGitHubIssueInOrca - : undefined - } - onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} - onOpenReviewInOrca={ - hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined - } - onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} - onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} - // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. - onUnlinkReview={ - !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined - } - > - {parentHoverTriggerBody} - - ) : ( - parentHoverTriggerBody - ) - const cardBody = (
)} - {parentCardBodyWithHoverDetails} + {parentCardContent} {newCardStyle && lineageChildren ? (
{ expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBeNull() }) + + it.each(['status', 'agent'] as const)( + 'keeps the %s region in the lineage nesting hit zone', + (targetRole) => { + const { container, target } = makeTarget({ + worktreeId: 'parent', + top: 100, + bottom: 200, + targetRole + }) + + expect(getWorktreeLineageDropTargetId({ container, target, pointerY: 150 })).toBe('parent') + } + ) }) describe('getReorderedWorktreeIdsToUnnest', () => { @@ -168,24 +184,30 @@ function makeTarget(args: { top: number bottom: number contained?: boolean + targetRole?: 'identity' | 'status' | 'agent' }): { container: HTMLElement target: Element } { - const row = { - getAttribute: (name: string) => (name === 'data-worktree-drag-id' ? args.worktreeId : null) - } as HTMLElement - const content = { - getBoundingClientRect: () => ({ top: args.top, bottom: args.bottom }), - closest: (selector: string) => (selector === '[data-worktree-drag-id]' ? row : null) - } as HTMLElement - const target = { - closest: (selector: string) => - selector === '[data-worktree-card-hover-trigger]' ? content : null - } as Element + const container = document.createElement('div') + const row = document.createElement('div') + row.setAttribute('data-worktree-drag-id', args.worktreeId) + const content = document.createElement('div') + content.setAttribute('data-worktree-card-parent-content', '') + content.getBoundingClientRect = () => ({ top: args.top, bottom: args.bottom }) as DOMRect + const status = document.createElement('div') + const identity = document.createElement('div') + identity.setAttribute('data-worktree-card-hover-trigger', '') + const agent = document.createElement('div') + content.append(status, identity, agent) + row.append(content) + container.append(row) + + const targetByRole = { status, identity, agent } + const target = targetByRole[args.targetRole ?? 'identity'] const contained = args.contained ?? true - const container = { - contains: (element: Element) => contained && (element === content || element === row) - } as HTMLElement + if (!contained) { + container.removeChild(row) + } return { container, target } } diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index 5f63768aac3..a67c7ab3e43 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -2,7 +2,7 @@ import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' import type { Worktree } from '../../../../shared/worktree/types' import { getLineageRenderInfo } from './worktree-lineage-projection' -const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' +const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-parent-content]' const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' const LINEAGE_DROP_ZONE_RATIO = 0.4 diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts index 38985cf7330..f6fe251d0cc 100644 --- a/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import { buildWorktreeMetaUpdates, + parseGitLabMergeRequestNumberForMetaField, type WorktreeMetaDraft, type WorktreeMetaLiveLinks, - type WorktreeMetaSnapshot + type WorktreeMetaSnapshot, + type WorktreeReviewProvider } from './worktree-meta-updates' function makeDraft(overrides: Partial = {}): WorktreeMetaDraft { @@ -12,7 +14,7 @@ function makeDraft(overrides: Partial = {}): WorktreeMetaDraf displayNameInput: 'Workspace', issueInput: '', issueProvider: 'github', - prInput: '', + reviewInput: '', commentInput: '', ...overrides } @@ -33,9 +35,15 @@ function makeSnapshot(overrides: Partial = {}): WorktreeMe function buildUpdates( draft: Partial, snapshot: Partial = {}, - live: WorktreeMetaLiveLinks = {} + live: WorktreeMetaLiveLinks = {}, + reviewProvider: WorktreeReviewProvider = 'github' ): Partial { - const updates = buildWorktreeMetaUpdates(makeDraft(draft), makeSnapshot(snapshot), live) + const updates = buildWorktreeMetaUpdates( + makeDraft(draft), + makeSnapshot(snapshot), + live, + reviewProvider + ) const undefinedKeys = Object.keys(updates).filter( (key) => updates[key as keyof WorktreeMeta] === undefined ) @@ -50,6 +58,33 @@ const LINEAR_LINK_KEYS = [ ] as const describe('buildWorktreeMetaUpdates', () => { + it('writes only the GitLab MR slot in GitLab mode', () => { + expect(buildUpdates({ reviewInput: '!42' }, {}, {}, 'gitlab')).toEqual({ + linkedGitLabMR: 42 + }) + expect(buildUpdates({ reviewInput: '' }, {}, {}, 'gitlab')).toEqual({ linkedGitLabMR: null }) + }) + + it('accepts only positive MR references for the GitLab review row', () => { + expect(parseGitLabMergeRequestNumberForMetaField('42')).toBe(42) + expect(parseGitLabMergeRequestNumberForMetaField('!42')).toBe(42) + expect( + parseGitLabMergeRequestNumberForMetaField( + 'https://gitlab.example.com/group/project/-/merge_requests/42' + ) + ).toBe(42) + for (const invalid of [ + '#42', + '0', + '!0', + '9007199254740992', + 'https://gitlab.example.com/group/project/-/issues/42', + 'https://gitlab.example.com/group/project/-/work_items/42', + 'ftp://gitlab.example.com/group/project/-/merge_requests/42' + ]) { + expect(parseGitLabMergeRequestNumberForMetaField(invalid)).toBeNull() + } + }) // The dialog opens focused on Comment, so this is the common save path; a // regression here silently destroys the user's existing link. it('emits no link keys when the issue field is untouched', () => { @@ -317,11 +352,13 @@ describe('buildWorktreeMetaUpdates', () => { }) it('rejects issue URLs in the PR input', () => { - expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual({}) + expect(buildUpdates({ reviewInput: 'https://github.com/stablyai/orca/issues/6933' })).toEqual( + {} + ) }) it('accepts PR URLs in the PR input', () => { - expect(buildUpdates({ prInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({ + expect(buildUpdates({ reviewInput: 'https://github.com/stablyai/orca/pull/6934' })).toEqual({ linkedPR: 6934 }) }) diff --git a/src/renderer/src/components/sidebar/worktree-meta-updates.ts b/src/renderer/src/components/sidebar/worktree-meta-updates.ts index b8f06c43d8c..8e6be6af1e8 100644 --- a/src/renderer/src/components/sidebar/worktree-meta-updates.ts +++ b/src/renderer/src/components/sidebar/worktree-meta-updates.ts @@ -7,6 +7,9 @@ import { parseIssueLinkInput, type IssueLinkProvider } from '../../../../shared/ import type { WorkspaceSourceProvider } from '../../../../shared/new-workspace/workspace-source' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' import type { WorkspaceLinkedItem } from '../../../../shared/worktree/types' +import { parseGitLabIssueOrMRLink } from '../../../../shared/new-workspace/gitlab-links' + +export type WorktreeReviewProvider = 'github' | 'gitlab' export type WorktreeMetaSavedPayload = { worktreeId: string @@ -18,7 +21,7 @@ export type WorktreeMetaDraft = { displayNameInput: string issueInput: string issueProvider: IssueLinkProvider - prInput: string + reviewInput: string commentInput: string } @@ -73,6 +76,28 @@ export function parseGitHubWorkItemNumberForMetaField( return parseGitHubIssueOrPRNumber(input) } +export function parseGitLabMergeRequestNumberForMetaField(input: string): number | null { + const trimmed = input.trim() + const direct = trimmed.startsWith('!') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(direct)) { + const number = Number(direct) + return Number.isSafeInteger(number) && number > 0 ? number : null + } + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null + } + const link = parseGitLabIssueOrMRLink(trimmed) + return link?.type === 'mr' && Number.isSafeInteger(link.number) && link.number > 0 + ? link.number + : null +} + // Why: blanking the field means "fall back to the branch/folder name", and the // empty string is how that intent is persisted. Emitting `undefined` instead // put a present-but-undefined key into the store spread, wiping the live name @@ -235,16 +260,25 @@ function buildIssueLinkUpdates( return linearUpdates ? { linkedIssue: null, ...linearUpdates, ...displacedWorkItem } : {} } -// Requires the dialog to seed `prInput` from the persisted `linkedPR`: the blank +// Requires the dialog to seed `reviewInput` from the selected provider slot: the blank // input is written through as a clear, so an unseeded field drops the link on an // untouched save. -function buildPrLinkUpdate(draft: WorktreeMetaDraft): Partial { - const trimmed = draft.prInput.trim() +function buildReviewLinkUpdate( + draft: WorktreeMetaDraft, + provider: WorktreeReviewProvider +): Partial { + const trimmed = draft.reviewInput.trim() if (trimmed === '') { - return { linkedPR: null } + return provider === 'gitlab' ? { linkedGitLabMR: null } : { linkedPR: null } } - const number = parseGitHubWorkItemNumberForMetaField(trimmed, 'pr') - return number === null ? {} : { linkedPR: number } + const number = + provider === 'gitlab' + ? parseGitLabMergeRequestNumberForMetaField(trimmed) + : parseGitHubWorkItemNumberForMetaField(trimmed, 'pr') + if (number === null) { + return {} + } + return provider === 'gitlab' ? { linkedGitLabMR: number } : { linkedPR: number } } /** Pure save-payload builder for the worktree meta dialog: empty inputs clear @@ -254,12 +288,13 @@ function buildPrLinkUpdate(draft: WorktreeMetaDraft): Partial { export function buildWorktreeMetaUpdates( draft: WorktreeMetaDraft, current: WorktreeMetaSnapshot, - live: WorktreeMetaLiveLinks + live: WorktreeMetaLiveLinks, + reviewProvider: WorktreeReviewProvider = 'github' ): Partial { return { ...buildCommentUpdate(draft, current), ...buildDisplayNameUpdate(draft, current), ...buildIssueLinkUpdates(draft, current, live), - ...buildPrLinkUpdate(draft) + ...buildReviewLinkUpdate(draft, reviewProvider) } } diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index b1417b57c13..70935abb29a 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -51,12 +51,6 @@ type SortableTabProps = { dragData: TabDragItemData dropIndicator?: DropIndicator includeTopTabBorder?: boolean - /** True when this agent terminal can switch to native chat view; surfaces the "Switch view" context-menu item. */ - canToggleViewMode?: boolean - /** True when the tab is currently showing the native chat view. */ - isChatView?: boolean - /** Toggle the tab between terminal and native chat view. */ - onToggleViewMode?: () => void } export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -82,10 +76,7 @@ export default function SortableTab({ onToggleExpand, dragData, dropIndicator, - includeTopTabBorder = true, - canToggleViewMode = false, - isChatView = false, - onToggleViewMode + includeTopTabBorder = true }: SortableTabProps): React.JSX.Element { // Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render. const hasUnreadActivity = useAppStore((s) => @@ -435,9 +426,6 @@ export default function SortableTab({ onRenameOpen={handleRenameOpen} onSetTabColor={onSetTabColor} onTogglePin={onTogglePin} - canToggleViewMode={canToggleViewMode} - isChatView={isChatView} - onToggleViewMode={onToggleViewMode} /> ) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index cd7c43096b1..76d5d8d145a 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -207,6 +207,13 @@ describe('requestActiveTerminalPaneSplit', () => { }) describe('SortableTabContextMenu', () => { + it('does not expose a native/terminal view switch', () => { + const { container } = renderMenu() + + expect(container.textContent).not.toContain('Switch to terminal view') + expect(container.textContent).not.toContain('Switch to chat view') + }) + it('dispatches split requests and activates inactive terminal tabs first', () => { const dispatchSpy = vi.spyOn(window, 'dispatchEvent') const { container, onActivate } = renderMenu({ isActive: false }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index c10118dc45d..53b31012d39 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,14 +1,4 @@ -import { - MessageSquare, - PanelLeftClose, - PanelRightClose, - Pin, - PinOff, - Pencil, - SquareTerminal, - X, - ListX -} from 'lucide-react' +import { PanelLeftClose, PanelRightClose, Pin, PinOff, Pencil, X, ListX } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -107,14 +97,6 @@ type SortableTabContextMenuProps = { onRenameOpen: () => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void - /** True when this tab is an agent terminal that can switch to the native chat - * view; gates the "Switch view" menu item. */ - canToggleViewMode?: boolean - /** True when the tab is currently showing the native chat view (drives the - * item's label/icon between "chat" and "terminal"). */ - isChatView?: boolean - /** Toggle the tab between terminal and native chat view. */ - onToggleViewMode?: () => void } export function SortableTabContextMenu({ @@ -136,10 +118,7 @@ export function SortableTabContextMenu({ onCloseToLeft, onRenameOpen, onSetTabColor, - onTogglePin, - canToggleViewMode = false, - isChatView = false, - onToggleViewMode + onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) @@ -168,27 +147,6 @@ export function SortableTabContextMenu({ splitRightShortcut={splitRightShortcut} splitDownShortcut={splitDownShortcut} /> - {canToggleViewMode && onToggleViewMode ? ( - <> - - - {isChatView ? ( - - ) : ( - - )} - {isChatView - ? translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to chat view' - )} - - - ) : null} {isPinned ? ( diff --git a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx index 57095344384..ca755e18271 100644 --- a/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx +++ b/src/renderer/src/components/tab-bar/TerminalTabLeadingIcon.test.tsx @@ -1,18 +1,21 @@ import { renderToStaticMarkup } from 'react-dom/server' import { describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' import type { TerminalTabActivityStatus } from './terminal-tab-activity-status' /** Render one activity status through the production leading-icon component. */ function renderStatus(status: TerminalTabActivityStatus): string { return renderToStaticMarkup( - + + + ) } diff --git a/src/renderer/src/components/tab-bar/group-tab-order.test.ts b/src/renderer/src/components/tab-bar/group-tab-order.test.ts index 6258c659700..ca693893893 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.test.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.test.ts @@ -64,7 +64,42 @@ function simulatorTab(id: string, groupId: string, sortOrder: number): Tab { } } +function agentSessionTab(id: string, groupId: string, sessionId: string, sortOrder: number): Tab { + return { + id, + entityId: sessionId, + groupId, + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + } +} + describe('getGroupVisibleTabOrder', () => { + it('includes structured sessions without a terminal backing entity', () => { + const group: TabGroup = { + id: 'g1', + worktreeId: 'wt', + activeTabId: 'tab-a1', + tabOrder: ['tab-t1', 'tab-a1'] + } + const tabs: Tab[] = [ + terminalTab('tab-t1', 'g1', 'term-1', 0), + agentSessionTab('tab-a1', 'g1', 'session-1', 1) + ] + expect(getGroupVisibleTabOrder(group, tabs, new Set(['term-1']), new Set(), new Set())).toEqual( + [ + { type: 'terminal', id: 'term-1', tabId: 'tab-t1' }, + { type: 'agent-session', id: 'session-1', tabId: 'tab-a1' } + ] + ) + }) + it('returns active-group refs with backing ids plus unified tab ids', () => { const group: TabGroup = { id: 'g1', @@ -398,9 +433,11 @@ describe('group order matches the rendered tab strip', () => { editorFileIds: [], browserTabIds: [], simulatorTabIds: [], + agentSessionTabIds: [], terminalMap: terminalMap as never, editorMap: new Map(), browserMap: new Map(), + agentSessionMap: new Map(), unifiedTabByVisibleId: new Map() }).map((item) => item.id) } diff --git a/src/renderer/src/components/tab-bar/group-tab-order.ts b/src/renderer/src/components/tab-bar/group-tab-order.ts index d24568f236a..c0755565ec2 100644 --- a/src/renderer/src/components/tab-bar/group-tab-order.ts +++ b/src/renderer/src/components/tab-bar/group-tab-order.ts @@ -3,7 +3,7 @@ import type { AppState } from '../../store/types' import { reconcileTabOrder } from './reconcile-order' export type VisibleTabRef = { - type: 'terminal' | 'editor' | 'browser' | 'simulator' + type: 'terminal' | 'editor' | 'agent-session' | 'browser' | 'simulator' id: string tabId?: string } @@ -13,6 +13,7 @@ export type ActiveTabNavOrderIds = { editorIds?: string[] browserIds?: string[] simulatorIds?: string[] + agentSessionIds?: string[] } /** @@ -47,6 +48,10 @@ export function getGroupVisibleTabOrder( if (tab.contentType === 'simulator') { return simulatorTabIds.has(tab.id) ? { type: 'simulator', id: tab.id, tabId: tab.id } : null } + if (tab.contentType === 'agent-session') { + // Structured chat tabs are self-backed: the unified tab is the entity, so none can be stale. + return { type: 'agent-session', id: tab.entityId, tabId: tab.id } + } return editorEntityIds.has(tab.entityId) ? { type: 'editor', id: tab.entityId, tabId: tab.id } : null @@ -54,14 +59,19 @@ export function getGroupVisibleTabOrder( // Why: the strip keys terminals/browsers by entity id and editors/simulators by unified tab id // (see useTabGroupItemProjections) — reconcileTabOrder must see that same id domain. const visibleIdOf = (tab: Tab): string => - tab.contentType === 'terminal' || tab.contentType === 'browser' ? tab.entityId : tab.id + tab.contentType === 'terminal' || + tab.contentType === 'browser' || + tab.contentType === 'agent-session' + ? tab.entityId + : tab.id if (preserveTypeCollisions) { const seenByType = { terminal: new Set(), editor: new Set(), browser: new Set(), - simulator: new Set() + simulator: new Set(), + 'agent-session': new Set() } const result: VisibleTabRef[] = [] for (const unifiedId of group.tabOrder) { @@ -90,11 +100,13 @@ export function getGroupVisibleTabOrder( const editorIds: string[] = [] const browserIds: string[] = [] const simulatorIds: string[] = [] + const agentSessionIds: string[] = [] const idsByType = { terminal: terminalIds, editor: editorIds, browser: browserIds, - simulator: simulatorIds + simulator: simulatorIds, + 'agent-session': agentSessionIds } for (const tab of [...declaredTabs, ...groupTabs]) { const visibleId = visibleIdOf(tab) @@ -106,7 +118,13 @@ export function getGroupVisibleTabOrder( if (existing) { // Keep the same type precedence as buildOrderedTabItems, whose terminal map wins over // editor/browser/simulator maps when visible ids collide across content types. - const priority = { terminal: 0, editor: 1, browser: 2, simulator: 3 } as const + const priority = { + terminal: 0, + editor: 1, + browser: 2, + simulator: 3, + 'agent-session': 4 + } as const if (priority[ref.type] > priority[existing.type]) { continue } @@ -122,7 +140,8 @@ export function getGroupVisibleTabOrder( terminalIds, editorIds, browserIds, - simulatorIds + simulatorIds, + agentSessionIds ).flatMap((visibleId) => { const ref = refByVisibleId.get(visibleId) return ref ? [ref] : [] @@ -167,6 +186,11 @@ export function getActiveTabNavOrder( (state.unifiedTabsByWorktree[worktreeId] ?? []) .filter((tab) => tab.contentType === 'simulator') .map((tab) => tab.id) + const agentSessionIds = + ids.agentSessionIds ?? + (state.unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.contentType === 'agent-session') + .map((tab) => tab.id) const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = activeGroupId @@ -199,12 +223,14 @@ export function getActiveTabNavOrder( terminalIds, editorIds, browserIds, - simulatorIds + simulatorIds, + agentSessionIds ) const terminalIdSet = new Set(terminalIds) const editorIdSet = new Set(editorIds) const browserIdSet = new Set(browserIds) const simulatorIdSet = new Set(simulatorIds) + const agentSessionIdSet = new Set(agentSessionIds) const result: VisibleTabRef[] = [] for (const id of visibleIds) { if (terminalIdSet.has(id)) { @@ -215,6 +241,13 @@ export function getActiveTabNavOrder( result.push({ type: 'browser', id }) } else if (simulatorIdSet.has(id)) { result.push({ type: 'simulator', id }) + } else if (agentSessionIdSet.has(id)) { + const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (candidate) => candidate.id === id && candidate.contentType === 'agent-session' + ) + if (tab) { + result.push({ type: 'agent-session', id: tab.entityId, tabId: tab.id }) + } } } return result diff --git a/src/renderer/src/components/tab-bar/reconcile-order.ts b/src/renderer/src/components/tab-bar/reconcile-order.ts index f613e2c0f4d..117585546f9 100644 --- a/src/renderer/src/components/tab-bar/reconcile-order.ts +++ b/src/renderer/src/components/tab-bar/reconcile-order.ts @@ -8,9 +8,16 @@ export function reconcileTabOrder( terminalIds: string[], editorIds: string[], browserIds: string[] = [], - simulatorIds: string[] = [] + simulatorIds: string[] = [], + agentSessionIds: string[] = [] ): string[] { - const validIds = new Set([...terminalIds, ...editorIds, ...browserIds, ...simulatorIds]) + const validIds = new Set([ + ...terminalIds, + ...editorIds, + ...browserIds, + ...simulatorIds, + ...agentSessionIds + ]) // Why: storedOrder is persisted group tab order and is mutated by many // codepaths (drop/move/reorder/hydrate). A stale or racey write can leave // the same tab id twice in the list, which surfaces as React's "two @@ -25,7 +32,13 @@ export function reconcileTabOrder( inResult.add(id) } } - for (const id of [...terminalIds, ...editorIds, ...browserIds, ...simulatorIds]) { + for (const id of [ + ...terminalIds, + ...editorIds, + ...browserIds, + ...simulatorIds, + ...agentSessionIds + ]) { if (!inResult.has(id)) { result.push(id) inResult.add(id) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts index 0eee66d7b68..d5b00ada161 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.ts @@ -40,6 +40,13 @@ export type TabBarItem = isPinned: boolean data: Tab } + | { + type: 'agent-session' + id: string + unifiedTabId: string + isPinned: boolean + data: Tab & { contentType: 'agent-session' } + } export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolean): string { if (item.type === 'terminal') { @@ -48,7 +55,7 @@ export function getTabDragLabel(item: TabBarItem, generatedTitlesEnabled: boolea if (item.type === 'browser') { return getBrowserTabLabel(item.data) } - if (item.type === 'simulator') { + if (item.type === 'simulator' || item.type === 'agent-session') { return item.data.label || 'Mobile Emulator' } return getEditorDisplayLabel(item.data) @@ -99,9 +106,11 @@ export function buildOrderedTabItems({ editorFileIds, browserTabIds, simulatorTabIds, + agentSessionTabIds, terminalMap, editorMap, browserMap, + agentSessionMap, unifiedTabByVisibleId }: { tabBarOrder?: string[] @@ -109,9 +118,11 @@ export function buildOrderedTabItems({ editorFileIds: string[] browserTabIds: string[] simulatorTabIds: string[] + agentSessionTabIds: string[] terminalMap: Map editorMap: Map browserMap: Map + agentSessionMap: Map unifiedTabByVisibleId: Map }): TabBarItem[] { const ids = reconcileTabOrder( @@ -119,7 +130,8 @@ export function buildOrderedTabItems({ terminalIds, editorFileIds, browserTabIds, - simulatorTabIds + simulatorTabIds, + agentSessionTabIds ) const items: TabBarItem[] = [] for (const id of ids) { @@ -168,6 +180,17 @@ export function buildOrderedTabItems({ isPinned: simulatorTab.isPinned === true, data: simulatorTab }) + continue + } + const agentSession = agentSessionMap.get(id) + if (agentSession) { + items.push({ + type: 'agent-session', + id, + unifiedTabId: agentSession.id, + isPinned: agentSession.isPinned === true, + data: agentSession + }) } } return items @@ -210,6 +233,9 @@ export function findActiveVisibleTabId( if (item.type === 'simulator') { return active.activeTabType === 'simulator' && item.id === active.activeSimulatorTabId } + if (item.type === 'agent-session') { + return active.activeTabType === 'agent-session' && item.id === active.activeTabId + } return ( (active.activeTabType === 'editor' || active.activeTabType === 'simulator') && active.activeFileId === item.id diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index 0011531a7cd..c0af60cf6fc 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -1,8 +1,9 @@ import React from 'react' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { OpenFile } from '../../store/slices/editor' -import { canToggleNativeChat } from '../native-chat/native-chat-availability' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' import BrowserTab from './BrowserTab' @@ -49,22 +50,13 @@ export function renderTabBarItems({ onActivateFile, onCloseFile, onActivateBrowserTab, + onActivateAgentSession, onCloseBrowserTab, onDuplicateBrowserTab, onCloseAllFiles, onMakePreviewFilePermanent } = props - const { - resolvedGroupId, - generatedTabTitlesEnabled, - unifiedTabByVisibleId, - nativeChatEnabled, - tabAgentTypesByTabId, - nativeChatTabWideFallbackUnsafeTabsById, - nativeChatTranscriptIsLocalReadable, - toggleTabViewMode, - statusByRelativePath - } = runtime + const { resolvedGroupId, generatedTabTitlesEnabled, statusByRelativePath } = runtime // A selected client-hosted row covers the pane, so the tab it covers must stop looking active — // the group's own activeTabId never moves for it, and two underlines would show at once. @@ -97,25 +89,6 @@ export function renderTabBarItems({ ...item.data, title: resolveTerminalTabTitle(item.data, generatedTabTitlesEnabled, item.data.title) } - const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) - // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. - const resolvedAgent = - resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? - resolveCommittedTitleAgentType(terminalTab.title) - // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. - const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null - const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true - const canToggleViewMode = - unifiedTabForItem !== undefined && - canToggleNativeChat({ - experimentalNativeChatEnabled: nativeChatEnabled, - contentType: 'terminal', - launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, - detectedAgent, - resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, - nativeChatTranscriptIsLocalReadable, - isChatViewMode: unifiedTabForItem.viewMode === 'chat' - }) return ( toggleTabViewMode(unifiedTabForItem.id) : undefined - } hasTabsToRight={index < items.length - 1} hasTabsToLeft={index > 0} isActive={ @@ -224,6 +192,51 @@ export function renderTabBarItems({ /> ) } + if (item.type === 'agent-session') { + const structuredTab: TerminalTab = { + id: item.id, + ptyId: null, + worktreeId, + title: item.data.label, + customTitle: item.data.customLabel, + color: item.data.color, + sortOrder: item.data.sortOrder, + createdAt: item.data.createdAt, + ...(isAgentSessionHandleProvider(item.data.agentSessionAgent) + ? { launchAgent: item.data.agentSessionAgent as TuiAgent } + : {}) + } + return ( + 0} + isActive={ + !clientHostedRowOwnsActiveState && + activeTabType === 'agent-session' && + item.id === activeTabId + } + isPinned={item.isPinned} + isExpanded={false} + onActivate={() => activateRealTab(onActivateAgentSession)(item.id)} + onClose={() => onClose(item.id)} + onCloseOthers={() => onCloseOthers(item.id)} + onCloseToRight={() => onCloseToRight(item.id)} + onCloseToLeft={() => onCloseToLeft(item.id)} + onSetCustomTitle={onSetCustomTitle} + onSetTabColor={onSetTabColor} + onTogglePin={() => togglePinned(item)} + onToggleExpand={() => {}} + dragData={dragData} + dropIndicator={dropIndicatorByVisibleId.get(item.id) ?? null} + includeTopTabBorder={includeTopTabBorder} + /> + ) + } return ( void onCloseFile?: (fileId: string) => void onActivateBrowserTab?: (tabId: string) => void + onActivateAgentSession?: (tabId: string) => void onCloseBrowserTab?: (tabId: string) => void onDuplicateBrowserTab?: (tabId: string) => void onCloseAllFiles?: () => void diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts index d12b275b556..c154a284b8a 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-create-menu-controller.ts @@ -3,7 +3,10 @@ import { toast } from 'sonner' import type { TuiAgent } from '../../../../shared/tui-agent' import { translate } from '@/i18n/i18n' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { + launchAgentInNewTab, + shouldQueueTerminalFocusAfterMenuClose +} from '@/lib/launch-agent-in-new-tab' import type { WindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { useAppStore } from '../../store' import type { TabAgentLaunchOption } from './tab-agent-launch-options' @@ -237,7 +240,9 @@ export function useTabBarCreateMenuController({ queueTerminalTabFocusAfterNewTabMenuClose(result.tabId) return } - queueNewActiveTerminalFocusAfterNewTabMenuClose() + if (shouldQueueTerminalFocusAfterMenuClose(result)) { + queueNewActiveTerminalFocusAfterNewTabMenuClose() + } } const runPendingNewTabMenuFocusAfterClose = (): void => { const pendingFocus = pendingNewTabMenuFocusRef.current diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts index 3da80876b98..f48c3ab14c3 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-item-projection.ts @@ -38,6 +38,7 @@ export function useTabBarItemProjection({ tabs, editorFiles, browserTabs, + agentSessionTabs, tabBarOrder, hoveredTabInsertion, activeTabId, @@ -56,6 +57,10 @@ export function useTabBarItemProjection({ () => new Map((browserTabs ?? []).map((tab) => [tab.id, tab])), [browserTabs] ) + const agentSessionMap = useMemo( + () => new Map((agentSessionTabs ?? []).map((tab) => [tab.id, tab])), + [agentSessionTabs] + ) const terminalIds = useMemo(() => tabs.map((tab) => tab.id), [tabs]) const editorFileIds = useMemo( () => editorFiles?.map((file) => file.tabId ?? file.id) ?? [], @@ -69,6 +74,10 @@ export function useTabBarItemProjection({ .map((tab) => tab.id), [unifiedTabs, resolvedGroupId] ) + const agentSessionTabIds = useMemo( + () => agentSessionTabs?.map((tab) => tab.id) ?? [], + [agentSessionTabs] + ) const orderedItems = useMemo( () => buildOrderedTabItems({ @@ -77,9 +86,11 @@ export function useTabBarItemProjection({ editorFileIds, browserTabIds, simulatorTabIds, + agentSessionTabIds, terminalMap, editorMap, browserMap, + agentSessionMap, unifiedTabByVisibleId }), [ @@ -88,9 +99,11 @@ export function useTabBarItemProjection({ editorFileIds, browserTabIds, simulatorTabIds, + agentSessionTabIds, terminalMap, editorMap, browserMap, + agentSessionMap, unifiedTabByVisibleId ] ) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx index a55aed7e886..55be0effcf7 100644 --- a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -23,6 +23,7 @@ import { resolveDropZone } from './tab-drop-zone' import type { TabDropZone } from './useTabDragSplit' import { translate } from '@/i18n/i18n' import type { AiVaultPrepareSessionResumeResult } from '../../../../shared/ai-vault-resume-preparation' +import { activateStructuredAgentSessionById } from '@/lib/structured-agent-session-tab-activation' type PaneDropTarget = { groupId: string @@ -175,6 +176,18 @@ export default function AiVaultSessionDropLayer({ ) return true } + if (payload.structuredSession) { + const { sessionId, workspaceId } = payload.structuredSession + if (!activateStructuredAgentSessionById({ worktreeId: workspaceId, sessionId })) { + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) + } + return true + } const state = useAppStore.getState() const targetStatus = getAiVaultResumeWorkspaceTargetStatus(state, worktreeId) @@ -224,6 +237,7 @@ export default function AiVaultSessionDropLayer({ }) ? window.api.aiVault.prepareSessionResume({ agent: payload.agent, + sessionId: payload.sessionId, filePath: payload.sessionFilePath, executionHostId: payload.sessionExecutionHostId, codexHome: payload.codexHome diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 06c31c79e83..6e48ddd0ec0 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -63,9 +63,16 @@ export default function TabGroupPanel({ }): React.JSX.Element { const rightSidebarOpen = useAppStore((state) => state.rightSidebarOpen) const sidebarOpen = useAppStore((state) => state.sidebarOpen) - const model = useTabGroupWorkspaceModel({ groupId, worktreeId }) - const { activeTab, browserItems, commands, editorItems, tabBarOrder, terminalTabs } = model + const { + activeTab, + agentSessionItems, + browserItems, + commands, + editorItems, + tabBarOrder, + terminalTabs + } = model // Why: one strip owns the worktree's client-hosted rows, or every split repeats them. const ownsClientHostedRows = useAppStore( (state) => @@ -97,14 +104,20 @@ export default function TabGroupPanel({ const tabBar = ( { const item = resolveGroupTabFromVisibleId(model.groupTabs, terminalId) - if (item?.contentType === 'terminal') { + if (item?.contentType === 'terminal' || item?.contentType === 'agent-session') { commands.closeItem(item.id) return } @@ -143,8 +156,10 @@ export default function TabGroupPanel({ browserTabs={browserItems} clientHostedBrowserRows={clientHostedRows} groupActiveTabId={activeTab?.id ?? null} + agentSessionTabs={agentSessionItems} activeFileId={ activeTab?.contentType === 'terminal' || + activeTab?.contentType === 'agent-session' || activeTab?.contentType === 'browser' || activeTab?.contentType === 'simulator' ? null @@ -155,15 +170,18 @@ export default function TabGroupPanel({ activeTabType={ activeTab?.contentType === 'terminal' ? 'terminal' - : activeTab?.contentType === 'browser' - ? 'browser' - : activeTab?.contentType === 'simulator' - ? 'simulator' - : 'editor' + : activeTab?.contentType === 'agent-session' + ? 'agent-session' + : activeTab?.contentType === 'browser' + ? 'browser' + : activeTab?.contentType === 'simulator' + ? 'simulator' + : 'editor' } onActivateFile={commands.activateEditor} onCloseFile={commands.closeItem} onActivateBrowserTab={commands.activateBrowser} + onActivateAgentSession={commands.activateAgentSession} onCloseBrowserTab={(browserTabId) => { const item = model.groupTabs.find( (candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser' @@ -331,6 +349,7 @@ export default function TabGroupPanel({ ) : null} {activeTab && activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'agent-session' && activeTab.contentType !== 'browser' && activeTab.contentType !== 'simulator' && (
@@ -355,7 +374,7 @@ export default function TabGroupPanel({
)} - {/* Why: terminal/browser/simulator panes render at the worktree level (overlay layers); per-group rendering remounted xterm/webview/simulator on split moves. */} + {/* Why: terminal/browser/simulator/structured-chat panes render at the worktree level; tab activation only changes overlay visibility and never remounts a live surface. */}
) diff --git a/src/renderer/src/components/tab-group/tab-drag-data.ts b/src/renderer/src/components/tab-group/tab-drag-data.ts index 572385a0d14..eb582af01fc 100644 --- a/src/renderer/src/components/tab-group/tab-drag-data.ts +++ b/src/renderer/src/components/tab-group/tab-drag-data.ts @@ -10,7 +10,7 @@ export type TabDragItemData = { groupId: string unifiedTabId: string visibleTabId: string - tabType: 'terminal' | 'editor' | 'browser' | 'simulator' + tabType: 'terminal' | 'editor' | 'agent-session' | 'browser' | 'simulator' label: string iconPath?: string color?: string | null diff --git a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts index 0f28e730eab..7263472cb08 100644 --- a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts @@ -9,6 +9,7 @@ import { } from '../../runtime/web-runtime-session' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership' +import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import type { TabGroupWorktreeSnapshot } from './useTabGroupItemProjections' export function useTabGroupActivationCommands({ @@ -138,5 +139,18 @@ export function useTabGroupActivationCommands({ [activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId] ) - return { activateTerminal, toggleTerminalPaneExpand, activateEditor, activateBrowser } + const activateAgentSession = useCallback( + (tabId: string) => { + activateStructuredAgentSessionTab({ worktreeId, tabId }) + }, + [worktreeId] + ) + + return { + activateTerminal, + toggleTerminalPaneExpand, + activateEditor, + activateBrowser, + activateAgentSession + } } diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts index dd31114853b..921176604b0 100644 --- a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts @@ -22,6 +22,7 @@ export type TabGroupWorktreeSnapshot = { export type GroupEditorItem = OpenFile & { tabId: string } export type GroupBrowserItem = BrowserTabState & { tabId: string } +export type GroupAgentSessionItem = Tab & { contentType: 'agent-session' } type TerminalTabItem = TerminalTab & { unifiedTabId: string } @@ -118,6 +119,14 @@ export function useTabGroupItemProjections({ [groupTabs, worktreeState.browserTabs] ) + const agentSessionItems = useMemo( + () => + groupTabs.filter( + (item): item is GroupAgentSessionItem => item.contentType === 'agent-session' + ), + [groupTabs] + ) + const tabBarOrder = useMemo( () => (group?.tabOrder ?? []).map((itemId) => { @@ -132,5 +141,14 @@ export function useTabGroupItemProjections({ [group, groupTabs] ) - return { group, groupTabs, activeTab, terminalTabs, editorItems, browserItems, tabBarOrder } + return { + group, + groupTabs, + activeTab, + terminalTabs, + editorItems, + browserItems, + agentSessionItems, + tabBarOrder + } } diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts new file mode 100644 index 00000000000..66f2d7681ee --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.structured-session.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ReactModule from 'react' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + closeBrowserTab: vi.fn(), + closeFile: vi.fn(), + closeStructuredAgentSession: vi.fn(), + closeTerminalTab: vi.fn(), + closeUnifiedTab: vi.fn(), + setActiveWorktree: vi.fn(), + toastError: vi.fn() +})) + +const store = vi.hoisted(() => ({ + activeWorktreeId: 'wt-1', + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + closeBrowserTab: mocks.closeBrowserTab, + closeFile: mocks.closeFile, + closeUnifiedTab: mocks.closeUnifiedTab, + openFiles: [], + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })), + setActiveWorktree: mocks.setActiveWorktree, + tabsByWorktree: {}, + unifiedTabsByWorktree: {} +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { ...actual, useCallback: (callback: T) => callback } +}) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign((selector: (state: typeof store) => unknown) => selector(store), { + getState: () => store + }) +})) + +vi.mock('../../store/slices/browser-webview-cleanup', () => ({ + destroyWorkspaceWebviews: vi.fn() +})) + +vi.mock('../editor/editor-autosave', () => ({ + requestEditorFileClose: vi.fn() +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ + closeTerminalTab: mocks.closeTerminalTab +})) + +vi.mock('../../runtime/web-runtime-session', () => ({ + closeWebRuntimeSessionTab: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false) +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => null +})) + +vi.mock('@/runtime/remote-browser-tab-ownership', () => ({ + browserWorkspaceHasRemoteOwner: () => false +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: () => ({ kind: 'local' }) +})) + +vi.mock('@/runtime/structured-agent-session-close', () => ({ + closeStructuredAgentSession: mocks.closeStructuredAgentSession +})) + +vi.mock('@/runtime/runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.toastError } +})) + +import { useTabGroupTabCloseCommands } from './useTabGroupTabCloseCommands' + +const AGENT_TAB = { + id: 'agent-tab-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session' as const, + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.closeStructuredAgentSession.mockResolvedValue('closed') + mocks.callRuntimeRpc.mockResolvedValue({ ok: true }) +}) + +describe('structured agent-session close ordering', () => { + it('disposes the owner before asking the host to remove the canonical tab', async () => { + const order: string[] = [] + mocks.closeStructuredAgentSession.mockImplementation(async () => { + order.push('agent-close') + return 'closed' + }) + mocks.callRuntimeRpc.mockImplementation(async () => { + order.push('tab-close') + return { ok: true } + }) + mocks.closeUnifiedTab.mockImplementation(() => order.push('local-remove')) + + const { closeItem } = useTabGroupTabCloseCommands({ + worktreeId: 'wt-1', + groupTabs: [AGENT_TAB] + }) + closeItem(AGENT_TAB.id) + + await vi.waitFor(() => expect(order).toEqual(['agent-close', 'tab-close', 'local-remove'])) + }) + + it('keeps the tab available when owner disposal fails, so close can be retried', async () => { + mocks.closeStructuredAgentSession.mockRejectedValueOnce(new Error('owner unavailable')) + + const { closeItem } = useTabGroupTabCloseCommands({ + worktreeId: 'wt-1', + groupTabs: [AGENT_TAB] + }) + closeItem(AGENT_TAB.id) + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalled()) + + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.closeUnifiedTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts index eed462aa59a..5fe23b67927 100644 --- a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts @@ -1,4 +1,5 @@ import { useCallback } from 'react' +import { toast } from 'sonner' import type { Tab } from '../../../../shared/tab-types' import { useAppStore } from '../../store' import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup' @@ -7,6 +8,20 @@ import { isWebRuntimeSessionActive } from '../../runtime/web-runtime-session' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { translate } from '@/i18n/i18n' + +function reportStructuredSessionCloseError(error: unknown): void { + toast.error( + translate( + 'components.native-chat.structuredSessionCloseFailed', + 'Could not close this Codex chat' + ), + { description: error instanceof Error ? error.message : String(error) } + ) +} export function useTabGroupTabCloseCommands({ worktreeId, @@ -16,7 +31,6 @@ export function useTabGroupTabCloseCommands({ groupTabs: Tab[] }) { const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab) - const closeTab = useAppStore((state) => state.closeTab) const closeFile = useAppStore((state) => state.closeFile) const closeBrowserTab = useAppStore((state) => state.closeBrowserTab) const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) @@ -105,6 +119,29 @@ export function useTabGroupTabCloseCommands({ useAppStore.getState(), worktreeId ) + if (item.contentType === 'agent-session') { + // Why: the structured session lives on the host, so the local tab close must also + // retire the host's canonical row or it reappears on the next sync. + const target = getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: runtimeEnvironmentId + }) + void closeStructuredAgentSession(target, item.entityId) + .then(() => + callRuntimeRpc(target, 'session.tabs.close', { + worktree: toRuntimeWorktreeSelector(worktreeId), + tabId: `agent-session:${item.entityId}`, + reason: 'user' + }) + ) + .then(() => { + closeUnifiedTab(item.id) + if (!opts?.skipEmptyCheck) { + leaveWorktreeIfEmpty() + } + }) + .catch(reportStructuredSessionCloseError) + return + } if (item.contentType === 'terminal') { // Why: closeTerminalTab can defer behind a pin / running-process dialog, so the // empty check has to run on the actual close — never on cancel. @@ -155,6 +192,22 @@ export function useTabGroupTabCloseCommands({ useAppStore.getState(), worktreeId ) + if (item.contentType === 'agent-session') { + const target = getActiveRuntimeTarget({ + activeRuntimeEnvironmentId: runtimeEnvironmentId + }) + void closeStructuredAgentSession(target, item.entityId) + .then(() => + callRuntimeRpc(target, 'session.tabs.close', { + worktree: toRuntimeWorktreeSelector(worktreeId), + tabId: `agent-session:${item.entityId}`, + reason: 'user' + }) + ) + .then(() => closeUnifiedTab(item.id)) + .catch(reportStructuredSessionCloseError) + continue + } if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) { // Why: revoke local resume + hook authority before the host removes its canonical tab. // No running-process prompt: a bulk close of N busy tabs would be a modal storm. @@ -164,7 +217,7 @@ export function useTabGroupTabCloseCommands({ if (item.contentType === 'browser') { closeBrowserItem(item, runtimeEnvironmentId) } else if (item.contentType === 'terminal') { - closeTab(item.entityId) + closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true }) } else if (item.contentType === 'simulator') { closeUnifiedTab(item.id) } else { @@ -175,7 +228,7 @@ export function useTabGroupTabCloseCommands({ } } }, - [closeBrowserItem, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs, worktreeId] + [closeBrowserItem, closeEditorIfUnreferenced, closeUnifiedTab, groupTabs, worktreeId] ) return { closeItem, closeMany, leaveWorktreeIfEmpty } diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts index 87fb9e1f555..30bc640fd25 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.focus.test.ts @@ -10,6 +10,8 @@ const mocks = vi.hoisted(() => ({ closeTab: vi.fn(), closeUnifiedTab: vi.fn(), closeWebRuntimeSessionTab: vi.fn(), + callRuntimeRpc: vi.fn(), + runtimeEnvironmentSupportsCapability: vi.fn(), createBrowserTab: vi.fn(), createEmptySplitGroup: vi.fn(), createTab: vi.fn(), @@ -77,6 +79,19 @@ vi.mock('../../runtime/web-runtime-session', () => ({ toHostSessionTabId: (tabId: string) => tabId })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId?: string | null + }) => + activeRuntimeEnvironmentId + ? { kind: 'environment', environmentId: activeRuntimeEnvironmentId } + : { kind: 'local' }, + runtimeEnvironmentSupportsCapability: mocks.runtimeEnvironmentSupportsCapability +})) + vi.mock('../../store/slices/browser-webview-cleanup', () => ({ destroyWorkspaceWebviews: mocks.destroyWorkspaceWebviews })) @@ -170,6 +185,8 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { status: 'failed', message: 'The workspace is not connected to a remote Orca host.' }) + mocks.callRuntimeRpc.mockResolvedValue({ ok: true }) + mocks.runtimeEnvironmentSupportsCapability.mockResolvedValue(true) resetStore() vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0) @@ -197,6 +214,55 @@ describe('useTabGroupWorkspaceModel terminal activation focus', () => { expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1', null) }) + it('closes the durable native owner from the real structured tab close action', async () => { + const agentTab = { + id: 'structured-agent-session-codex-session-1', + entityId: 'codex-session-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + storeBox.state = { + ...storeBox.state, + tabsByWorktree: { 'wt-1': [] }, + unifiedTabsByWorktree: { 'wt-1': [agentTab] }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: agentTab.id, + tabOrder: [agentTab.id] + } + ] + } + } + const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') + const model = useTabGroupWorkspaceModel({ groupId: 'group-1', worktreeId: 'wt-1' }) + + model.commands.closeItem(agentTab.id) + + await vi.waitFor(() => expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(agentTab.id)) + expect(mocks.callRuntimeRpc.mock.calls).toEqual([ + [{ kind: 'local' }, 'agentSession.close', { sessionId: 'codex-session-1' }], + [ + { kind: 'local' }, + 'session.tabs.close', + { + worktree: 'id:wt-1', + tabId: 'agent-session:codex-session-1', + reason: 'user' + } + ] + ]) + }) + it('falls back to a local shell when the typed remote-create outcome is unavailable', async () => { mocks.createTab.mockReturnValue({ id: 'terminal-new' }) const { useTabGroupWorkspaceModel } = await import('./useTabGroupWorkspaceModel') diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 34a75641430..bc8c676c2e5 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -45,8 +45,16 @@ export function useTabGroupWorkspaceModel({ const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle) const setTabColor = useAppStore((state) => state.setTabColor) - const { group, groupTabs, activeTab, terminalTabs, editorItems, browserItems, tabBarOrder } = - useTabGroupItemProjections({ groupId, worktreeId, worktreeState }) + const { + group, + groupTabs, + activeTab, + terminalTabs, + editorItems, + browserItems, + agentSessionItems, + tabBarOrder + } = useTabGroupItemProjections({ groupId, worktreeId, worktreeState }) const { closeItem, closeMany, leaveWorktreeIfEmpty } = useTabGroupTabCloseCommands({ worktreeId, @@ -64,8 +72,13 @@ export function useTabGroupWorkspaceModel({ leaveWorktreeIfEmpty }) - const { activateTerminal, toggleTerminalPaneExpand, activateEditor, activateBrowser } = - useTabGroupActivationCommands({ groupId, worktreeId, groupTabs, worktreeState }) + const { + activateTerminal, + toggleTerminalPaneExpand, + activateEditor, + activateBrowser, + activateAgentSession + } = useTabGroupActivationCommands({ groupId, worktreeId, groupTabs, worktreeState }) const creationCommands = useTabGroupCreationCommands({ groupId, worktreeId, worktreeState }) @@ -74,6 +87,7 @@ export function useTabGroupWorkspaceModel({ activeTab, browserItems, editorItems, + agentSessionItems, terminalTabs, tabBarOrder, groupTabs, @@ -82,6 +96,7 @@ export function useTabGroupWorkspaceModel({ focusGroup: () => { focusGroup(worktreeId, groupId) }, + activateAgentSession, activateBrowser, activateEditor, activateTerminal, diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 6deef0b5864..bd2ca330f5c 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -77,9 +77,6 @@ function renderMenu(overrides: Record = {}): string { canContinueAgentSessionInNewSession: false, onContinueAgentSessionInNewSession: vi.fn(), onForkAgentSession: vi.fn(), - canToggleNativeChat: false, - isNativeChatView: false, - onToggleNativeChat: vi.fn(), onCopyAgentSessionContext: vi.fn(), quickCommandHosts: [ { hostId: 'local' as const, label: 'Local Linux', repoCommands: [], globalCommands: [] } @@ -143,6 +140,12 @@ describe('TerminalContextMenu', () => { expect(onContinueAgentSessionInNewSession).toHaveBeenCalledTimes(1) }) + it('does not expose a native/terminal view switch in the terminal menu', () => { + renderMenu() + + expect(items.list.some((item) => childrenText(item.children).includes('Switch to'))).toBe(false) + }) + it('shows one shortcut per terminal menu action on Windows', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 73a9d508f4c..178ab0a3247 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -6,13 +6,11 @@ import { Eraser, GitFork, Maximize2, - MessageSquare, Minimize2, PanelBottomClose, PanelsTopLeft, PanelRightClose, Pencil, - SquareTerminal, TextSelect, X } from 'lucide-react' @@ -30,7 +28,6 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import type { KeybindingOverrides } from '../../../../shared/keybindings' import { translate } from '@/i18n/i18n' -import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut' import { AgentSessionContinuationMenuItem } from './AgentSessionContinuationMenuItem' import type { TerminalQuickCommandMenuHost } from '@/hooks/use-terminal-quick-command-hosts' import { TerminalQuickCommandsSubmenu } from './TerminalQuickCommandsSubmenu' @@ -56,9 +53,6 @@ type TerminalContextMenuProps = { canContinueAgentSessionInNewSession: boolean onContinueAgentSessionInNewSession: () => void onForkAgentSession: () => void - canToggleNativeChat: boolean - isNativeChatView: boolean - onToggleNativeChat: () => void onCopyAgentSessionContext: () => void quickCommandHosts: TerminalQuickCommandMenuHost[] quickCommandHostLoadFailed: boolean @@ -95,9 +89,6 @@ export default function TerminalContextMenu({ canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onForkAgentSession, - canToggleNativeChat, - isNativeChatView, - onToggleNativeChat, onCopyAgentSessionContext, quickCommandHosts, quickCommandHostLoadFailed, @@ -124,8 +115,7 @@ export default function TerminalContextMenu({ expand: formatPrimaryShortcutLabel('terminal.expandPane', keybindings), setTitle: formatPrimaryShortcutLabel('terminal.setTitle', keybindings), clearPaneTitle: formatPrimaryShortcutLabel('terminal.clearPaneTitle', keybindings), - close: formatPrimaryShortcutLabel('terminal.closePane', keybindings), - nativeChat: nativeChatToggleShortcutLabel(isMacPlatform()) + close: formatPrimaryShortcutLabel('terminal.closePane', keybindings) }), [keybindings] ) @@ -215,21 +205,6 @@ export default function TerminalContextMenu({ 'Copy Context' )} - {canToggleNativeChat ? ( - - {isNativeChatView ? : } - {isNativeChatView - ? translate( - 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', - 'Switch to terminal view' - ) - : translate( - 'components.tab.bar.SortableTabContextMenu.switchToChatView', - 'Switch to chat view' - )} - {shortcuts.nativeChat} - - ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 2b58732f0ea..6157af7d36d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -12,6 +12,7 @@ import { import { useShallow } from 'zustand/react/shallow' import { createPortal } from 'react-dom' import type { CSSProperties } from 'react' +import type { TuiAgent } from '../../../../shared/tui-agent' import type { IDisposable } from '@xterm/xterm' import { useAppStore } from '../../store' import { useLinkRoutingPreferenceDialog } from '@/components/link-routing-preference-dialog' @@ -522,13 +523,28 @@ function TerminalPane( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.id ) + const structuredSessionAgent = useAppStore( + (store) => + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) + ?.agentSessionAgent + ) const isChatViewMode = useAppStore( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) ?.viewMode === 'chat' ) + const structuredSessionId = useAppStore( + (store) => + getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId) + ?.structuredSessionId ?? null + ) const nativeChatEnabled = useAppStore((store) => store.settings?.experimentalNativeChat === true) const effectiveChatViewMode = nativeChatEnabled && isChatViewMode + const chatPaneDispatchStatus = useAppStore((store) => + chatLeafId + ? store.agentStatusByPaneKey[makePaneKey(tabId, chatLeafId)]?.orchestration?.dispatchStatus + : undefined + ) const unifiedTabLabel = useAppStore( (store) => getCachedUnifiedTerminalTabForWorktree(store.unifiedTabsByWorktree, worktreeId, tabId)?.label @@ -538,9 +554,12 @@ function TerminalPane( ) // Carry each leaf's agent identity, not just "an agent exists", so the gate can reject unsupported agents; scoped to this tab's panes. const tabAgentTypeByLeaf = useAppStore((store) => - selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId) + selectTerminalTabAgentTypesByLeaf( + store.agentStatusByPaneKey, + tabId, + store.paneForegroundAgentByPaneKey + ) ) - const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode) const setTabViewMode = useAppStore((store) => store.setTabViewMode) const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT) const terminalTab = useAppStore((store) => @@ -616,13 +635,18 @@ function TerminalPane( contentType: 'terminal', launchAgent: detectedAgent ? null : launchAgent, detectedAgent, - resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), + // A structured handoff keeps the durable provider identity even when the + // foreground hook has not republished agent status after returning to TUI. + resolvedAgent: detectedAgent + ? null + : ((structuredSessionAgent as TuiAgent | null) ?? resolveTitleAgentForLeaf(leafId)), nativeChatTranscriptIsLocalReadable }) }, [ tabAgentTypeByLeaf, nativeChatEnabled, + structuredSessionAgent, nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, getNativeChatLeafIds, @@ -656,55 +680,31 @@ function TerminalPane( activeLeafId, chatLeafStillMounted: panes.some((pane) => pane.leafId === chatLeafId), activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), - chatLeafHasConfirmedAgentExit: true + chatLeafHasConfirmedAgentExit: true, + structuredSessionId }) ) }, - [applyNativeChatLeafRoute, chatLeafId, isChatEligibleForLeaf, isChatViewMode] + [ + applyNativeChatLeafRoute, + chatLeafId, + isChatEligibleForLeaf, + isChatViewMode, + structuredSessionId + ] ) useEffect(() => { // Why: transport callbacks must observe only committed chat ownership; render work can be replayed/discarded under concurrent React. onAgentExitedRef.current = handleConfirmedAgentExit }, [handleConfirmedAgentExit]) - const canToggleChatForLeaf = useCallback( - (leafId: string | null): boolean => { - // Scope the "always allow toggling back" rule to the leaf showing chat; must not make an unsupported sibling look eligible. - const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId - return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) - }, - [chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled] - ) - const toggleNativeChatForLeaf = useCallback( - (leafId: string) => { - if (!unifiedTabId) { - return - } - if (effectiveChatViewMode && chatLeafId === leafId) { - setChatLeafId(null) - toggleTabViewMode(unifiedTabId) - return - } - setChatLeafId(leafId) - if (!effectiveChatViewMode) { - toggleTabViewMode(unifiedTabId) - } - }, - [unifiedTabId, effectiveChatViewMode, chatLeafId, toggleTabViewMode] - ) - const handleToggleNativeChat = useCallback(() => { - const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null - if (!activeLeafId) { - return - } - toggleNativeChatForLeaf(activeLeafId) - }, [toggleNativeChatForLeaf]) // Stable identity: this reaches the session-option surface's useMemo deps, so an // inline arrow would rebuild the surface on every TerminalPane render. const switchNativeChatToTerminal = useCallback(() => { - if (chatLeafId) { - toggleNativeChatForLeaf(chatLeafId) + if (chatLeafId && unifiedTabId) { + setChatLeafId(null) + setTabViewMode(unifiedTabId, 'terminal') } - }, [chatLeafId, toggleNativeChatForLeaf]) + }, [chatLeafId, setChatLeafId, setTabViewMode, unifiedTabId]) const readNativeChatTerminalScreen = useCallback((): string | null => { if (!chatLeafId) { return null @@ -743,12 +743,19 @@ function TerminalPane( const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState< Map >(() => new Map()) + const consumeTabStartupCommand = useAppStore((store) => store.consumeTabStartupCommand) const [setupSplit] = useState(() => useAppStore.getState().pendingSetupSplitByTabId[tabId]) const consumeTabSetupSplit = useAppStore((store) => store.consumeTabSetupSplit) const [issueCommandSplit] = useState( () => useAppStore.getState().pendingIssueCommandSplitByTabId[tabId] ) const consumeTabIssueCommandSplit = useAppStore((store) => store.consumeTabIssueCommandSplit) + const settleTabStartupCommand = useCallback(() => { + if (startup) { + consumeTabStartupCommand(tabId, startup) + } + }, [consumeTabStartupCommand, startup, tabId]) + useLayoutEffect(() => { if (isVisible && shouldMeasureHiddenStartup) { // Why: hidden startup measurement is first-launch only; keeping it past first visibility would let inactive tabs refit and SIGWINCH. @@ -1400,6 +1407,7 @@ function TerminalPane( setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound: settleTabStartupCommand, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, @@ -2649,14 +2657,6 @@ function TerminalPane( return manager.getActivePane()?.leafId ?? null }, [contextMenu.menuPaneId]) const contextMenuLeafId = getContextMenuLeafId() - const contextMenuIsChatView = effectiveChatViewMode && contextMenuLeafId === chatLeafId - const handleContextMenuToggleNativeChat = useCallback(() => { - const leafId = getContextMenuLeafId() - if (!leafId) { - return - } - toggleNativeChatForLeaf(leafId) - }, [getContextMenuLeafId, toggleNativeChatForLeaf]) const getMobileOwnedTerminalPtyIds = useCallback((): string[] => { const ptyIds = new Set(getMobileFitOverridePtyIds()) @@ -2954,7 +2954,8 @@ function TerminalPane( chatLeafId, activeLeafId, chatLeafStillMounted, - activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), + structuredSessionId }) applyNativeChatLeafRoute(route) }, [ @@ -2963,7 +2964,8 @@ function TerminalPane( activePane?.leafId, chatLeafStillMounted, applyNativeChatLeafRoute, - isChatEligibleForLeaf + isChatEligibleForLeaf, + structuredSessionId ]) const chatPane = isChatViewMode && chatLeafId @@ -2979,9 +2981,8 @@ function TerminalPane( leafId: chatPane?.leafId ?? null, leafIds: getNativeChatLeafIds() }) - const activePaneIsChatLeaf = Boolean( - isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId - ) + const structuredChatAgent = structuredSessionAgent ?? chatPaneResolvedAgent ?? chatPaneLaunchAgent + const structuredChatTarget = useMemo(() => ({ kind: 'local' as const }), []) // A split can host different agents, so continuation resolves the specific leaf before using tab-wide hints. const resolveAgentForLeaf = (leafId: string | null): string | null => { const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null @@ -3003,9 +3004,6 @@ function TerminalPane( const contextMenuCanContinueInNewSession = canContinueAgentSessionInNewSession( resolveAgentForLeaf(contextMenuLeafId) ) - // Each toggle gates on its own leaf (header=active, menu=opened-over), so mixed splits show it only where chat can render. - const activePaneCanToggleChat = canToggleChatForLeaf(activePane?.leafId ?? null) - const contextMenuCanToggleChat = canToggleChatForLeaf(contextMenuLeafId) return ( <>
{effectiveChatViewMode && chatPane?.container ? createPortal( -
- contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), - onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), - canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, - onEqualizePaneSizes: () => - contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), - canExpandPane: managedPanes.length > 1, - isPaneExpanded: expandedPaneId === chatPane.id, - onToggleExpand: () => - contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), - canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( - resolveAgentForLeaf(chatPane.leafId) - ), - onContinueAgentSessionInNewSession: () => - contextMenu.runForPane( - chatPane.id, - contextMenu.onContinueAgentSessionInNewSession +
+ {structuredSessionId && structuredChatAgent ? ( + + ) : ( + + contextMenu.runForPane(chatPane.id, contextMenu.onSplitRight), + onSplitDown: () => contextMenu.runForPane(chatPane.id, contextMenu.onSplitDown), + canEqualizePaneSizes: managedPanes.length > 1 && expandedPaneId === null, + onEqualizePaneSizes: () => + contextMenu.runForPane(chatPane.id, contextMenu.onEqualizePaneSizes), + canExpandPane: managedPanes.length > 1, + isPaneExpanded: expandedPaneId === chatPane.id, + onToggleExpand: () => + contextMenu.runForPane(chatPane.id, contextMenu.onToggleExpand), + canContinueAgentSessionInNewSession: canContinueAgentSessionInNewSession( + resolveAgentForLeaf(chatPane.leafId) ), - onForkAgentSession: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), - onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), - onCopyTerminalId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), - onCopyPaneId: () => - void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), - canClosePane: managedPanes.length > 1, - onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) - }} - /> + onContinueAgentSessionInNewSession: () => + contextMenu.runForPane( + chatPane.id, + contextMenu.onContinueAgentSessionInNewSession + ), + onForkAgentSession: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onForkAgentSession), + onSetTitle: () => contextMenu.runForPane(chatPane.id, contextMenu.onSetTitle), + onCopyTerminalId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), + onCopyPaneId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), + canClosePane: managedPanes.length > 1, + onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) + }} + orchestrationDispatchStatus={chatPaneDispatchStatus} + /> + )}
, chatPane.container, `native-chat-${tabId}-${chatPane.leafId}` @@ -3204,9 +3217,6 @@ function TerminalPane( canContinueAgentSessionInNewSession={contextMenuCanContinueInNewSession} onContinueAgentSessionInNewSession={contextMenu.onContinueAgentSessionInNewSession} onForkAgentSession={() => void contextMenu.onForkAgentSession()} - canToggleNativeChat={contextMenuCanToggleChat} - isNativeChatView={contextMenuIsChatView} - onToggleNativeChat={handleContextMenuToggleNativeChat} onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()} quickCommandHosts={visibleQuickCommandHosts} quickCommandHostLoadFailed={quickCommandHostLoadFailed} @@ -3278,9 +3288,6 @@ function TerminalPane( hiddenStartupStyle={hiddenStartupStyle} managerRef={managerRef} paneTransportsRef={paneTransportsRef} - canToggleNativeChat={activePaneCanToggleChat} - isChatViewMode={activePaneIsChatLeaf} - onToggleNativeChat={handleToggleNativeChat} canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession} onContinueAgentSessionInNewSession={(pane) => contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession) diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index a9ed8249179..6b246701c43 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -1,11 +1,5 @@ import type { CSSProperties, RefObject } from 'react' -import { - MessageSquare, - MessageSquarePlus, - SquareSplitVertical, - SquareTerminal, - X -} from 'lucide-react' +import { MessageSquarePlus, SquareSplitVertical, X } from 'lucide-react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -42,14 +36,6 @@ type TerminalPaneHeaderOverlayProps = { hiddenStartupStyle: CSSProperties managerRef: RefObject paneTransportsRef: RefObject> - /** When true, this pane can toggle the native chat view; renders a chat/terminal - * toggle as the first button in the pane header actions row (beside split/close). - * The caller gates it to the active pane to avoid duplicating it across splits. */ - canToggleNativeChat?: boolean - /** True when the active pane is currently showing the native chat view. */ - isChatViewMode?: boolean - /** Flip the active pane between the terminal and the native chat view. */ - onToggleNativeChat?: () => void canContinueAgentSessionInNewSession?: boolean onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void @@ -85,9 +71,6 @@ export default function TerminalPaneHeaderOverlay({ hiddenStartupStyle, managerRef, paneTransportsRef, - canToggleNativeChat, - isChatViewMode, - onToggleNativeChat, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onSplitPane, @@ -272,47 +255,6 @@ export default function TerminalPaneHeaderOverlay({ ) : null} - {canToggleNativeChat && isActivePane ? ( - - - - - - {isChatViewMode - ? translate('components.native-chat.toggle.showTerminal', 'Show terminal') - : translate('components.native-chat.toggle.showChat', 'Show chat view')} - - - ) : null} {showAlwaysOnHeaders && showSplitButton ? ( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 2cb20783f53..79d0f42a14e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -8,7 +8,6 @@ import { type ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' -import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { TerminalOverlaySlot } from './TerminalOverlaySlot' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' @@ -60,8 +59,6 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel) - useNativeChatToggleShortcut(worktreeId, isWorktreeActive) - const leaveWorktreeIfEmpty = useCallback(() => { const state = useAppStore.getState() if (state.activeWorktreeId !== worktreeId) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts index 0f36829835b..acbced67212 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts @@ -454,4 +454,32 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, 'echo hi\r') expect(transport.sendInput).toHaveBeenCalledWith('echo hi\r') }) + + it('settles a queued startup only after the pane binds its spawned PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-resume') + transportFactoryQueue.push(transport) + const onStartupBound = vi.fn() + const startup = { + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: { key: 'session_id', id: 'codex-session-1' } as const + } + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup, onStartupBound }) as never + ) + + expect(onStartupBound).not.toHaveBeenCalled() + expect(createdTransportOptions[0]).toMatchObject(startup) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + onPtySpawn?.('pty-resume') + onPtySpawn?.('pty-resume') + + expect(onStartupBound).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 1fc9411cdfe..54030f10d77 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -117,6 +117,8 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Settles the captured one-shot startup only after this pane owns a concrete PTY. */ + onStartupBound?: () => void deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void /** Records a DECSET 2031 subscription seen through main's '2031-subscribe' * fact (paneMode2031 + the mode at subscribe time) so later theme flips push diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts index fe449dadb47..8fbb4c613be 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts @@ -74,6 +74,11 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo session.deps.updateTabPtyId(session.deps.tabId, ptyId) } } + if (session.paneStartup && !session.startupPtyBound) { + // Settles the captured one-shot startup only after this pane owns a concrete PTY. + session.startupPtyBound = true + session.deps.onStartupBound?.() + } if (options.seedInitialAgentStatus) { session.applyInitialAgentStatus() } diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts index da14fb4b9b6..636f126133d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.test.ts @@ -9,6 +9,7 @@ import { isTerminalInputQuarantined } from './terminal-input-quarantine' const mocks = vi.hoisted(() => ({ remountTerminalTabForRecovery: vi.fn<(tabId: string) => boolean>(() => true), + getTab: vi.fn<() => { viewMode?: 'terminal' | 'chat' } | null>(() => null), recordRendererCrashBreadcrumb: vi.fn(), hasPty: vi.fn<(id: string) => Promise>(async () => true) })) @@ -16,7 +17,8 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ - remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery + remountTerminalTabForRecovery: mocks.remountTerminalTabForRecovery, + getTab: mocks.getTab }) } })) @@ -29,6 +31,8 @@ beforeEach(() => { _resetTerminalPaneRecoveryForTests() mocks.remountTerminalTabForRecovery.mockClear() mocks.remountTerminalTabForRecovery.mockReturnValue(true) + mocks.getTab.mockClear() + mocks.getTab.mockReturnValue(null) mocks.recordRendererCrashBreadcrumb.mockClear() mocks.hasPty.mockClear() mocks.hasPty.mockResolvedValue(true) @@ -45,6 +49,20 @@ afterEach(() => { }) describe('requestTerminalPaneRecovery', () => { + it('does not remount a terminal surface hidden behind native chat', async () => { + mocks.getTab.mockReturnValue({ viewMode: 'chat' }) + + await expect( + requestTerminalPaneRecovery({ + tabId: 'tab-1', + ptyId: 'pty-1', + reason: 'input-undeliverable' + }) + ).resolves.toBe(false) + expect(mocks.remountTerminalTabForRecovery).not.toHaveBeenCalled() + expect(mocks.hasPty).not.toHaveBeenCalled() + }) + it('remounts the tab and records a breadcrumb for a certified-dead pipeline', async () => { const result = await requestTerminalPaneRecovery({ tabId: 'tab-1', diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts index 3db158d805a..4c81e6f3c66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-recovery.ts @@ -210,6 +210,12 @@ export async function requestTerminalPaneRecovery(request: RecoveryRequest): Pro if (!isCurrentTerminalRecoveryRequest(request)) { return false } + // A terminal-backed tab is intentionally hidden while native chat owns the + // provider. Late xterm callbacks from that hidden surface must not remount + // the tab and race the handoff's owner transition. + if (useAppStore.getState().getTab?.(request.tabId)?.viewMode === 'chat') { + return false + } const budget = recoveryBudget(request.tabId, Date.now()) if (!budget.allowed) { if (shouldScheduleRecoveryRetry(request, budget)) { diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts index bf000ffc697..d1714b19ed8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts @@ -71,4 +71,30 @@ describe('createTerminalTabAgentTypeSelector', () => { expect(select(state, 'tab-1')).toEqual({}) expect(select(state, 'malformed')).toEqual({}) }) + + it('uses a live foreground process until hook identity arrives', () => { + const select = createTerminalTabAgentTypeSelector() + const foreground = { + 'tab-1:leaf-a': { + agent: 'codex' as const, + shellForeground: false, + routingTrusted: true + } + } + + expect(select({}, 'tab-1', foreground)).toEqual({ 'leaf-a': 'codex' }) + expect(select({ 'tab-1:leaf-a': entry('claude') }, 'tab-1', foreground)).toEqual({ + 'leaf-a': 'claude' + }) + expect( + select({}, 'tab-1', { + 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], shellForeground: true } + }) + ).toEqual({}) + expect( + select({}, 'tab-1', { + 'tab-1:leaf-a': { ...foreground['tab-1:leaf-a'], routingRevoked: true } + }) + ).toEqual({}) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts index 1b7693b45ab..f14e96763e7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -1,4 +1,5 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' export type TerminalTabAgentTypeState = Record export type TerminalTabAgentTypesByLeaf = Readonly> @@ -8,6 +9,9 @@ type SelectorDependencies = { } const EMPTY_AGENT_TYPES_BY_LEAF: TerminalTabAgentTypesByLeaf = Object.freeze({}) +const EMPTY_FOREGROUND_AGENT_BY_PANE_KEY: Record = Object.freeze( + {} +) function reuseRecordIfEqual( previous: TerminalTabAgentTypesByLeaf | undefined, @@ -25,14 +29,19 @@ function reuseRecordIfEqual( export function createTerminalTabAgentTypeSelector( dependencies: SelectorDependencies = {} -): (state: TerminalTabAgentTypeState, tabId: string) => TerminalTabAgentTypesByLeaf { +): ( + state: TerminalTabAgentTypeState, + tabId: string, + foreground?: Record +) => TerminalTabAgentTypesByLeaf { let cachedState: TerminalTabAgentTypeState | null = null + let cachedForeground: Record | null = null let cachedByTabId = new Map() - return (state, tabId) => { + return (state, tabId, foreground = EMPTY_FOREGROUND_AGENT_BY_PANE_KEY) => { // Why: production writes replace this map. Its identity lets unrelated // Zustand notifications skip the global scan entirely. - if (state !== cachedState) { + if (state !== cachedState || foreground !== cachedForeground) { const previousByTabId = cachedByTabId const nextByTabId = new Map>() for (const [paneKey, entry] of Object.entries(state)) { @@ -53,6 +62,23 @@ export function createTerminalTabAgentTypeSelector( nextByTabId.set(entryTabId, { [leafId]: entry.agentType }) } } + for (const [paneKey, entry] of Object.entries(foreground)) { + if (!entry.agent || entry.shellForeground || entry.routingRevoked) { + continue + } + const separator = paneKey.indexOf(':') + if (separator <= 0) { + continue + } + const entryTabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + const byLeaf = nextByTabId.get(entryTabId) + if (byLeaf) { + byLeaf[leafId] ??= entry.agent + } else { + nextByTabId.set(entryTabId, { [leafId]: entry.agent }) + } + } const stabilizedByTabId = new Map() for (const [entryTabId, byLeaf] of nextByTabId) { @@ -63,6 +89,7 @@ export function createTerminalTabAgentTypeSelector( } cachedByTabId = stabilizedByTabId cachedState = state + cachedForeground = foreground } return cachedByTabId.get(tabId) ?? EMPTY_AGENT_TYPES_BY_LEAF } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 0f033c1c3c8..473d8551346 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -329,6 +329,8 @@ type UseTerminalPaneLifecycleDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Settles the captured one-shot startup only after a pane owns a concrete PTY. */ + onStartupBound?: () => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void setTabCanExpandPane: (tabId: string, canExpand: boolean) => void setExpandedPane: (paneId: number | null) => void @@ -722,6 +724,7 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound, setTabPaneExpanded, setTabCanExpandPane, setExpandedPane, @@ -965,6 +968,7 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + onStartupBound, deferPtyInput: (paneId, data, forward) => { const suppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression if (!suppression) { diff --git a/src/renderer/src/components/terminal/structured-terminal-session-disposal.ts b/src/renderer/src/components/terminal/structured-terminal-session-disposal.ts new file mode 100644 index 00000000000..12edc1e6ecf --- /dev/null +++ b/src/renderer/src/components/terminal/structured-terminal-session-disposal.ts @@ -0,0 +1,64 @@ +import type { Tab } from '../../../../shared/tab-types' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' + +const STRUCTURED_SESSION_CLOSE_RETRY_DELAYS_MS = [0, 250, 1_000, 3_000] as const + +export function structuredTerminalSessionId( + unifiedTabs: readonly Tab[] | undefined, + terminalTabId: string +): string | null { + return ( + unifiedTabs?.find( + (tab) => + tab.contentType === 'terminal' && tab.entityId === terminalTabId && tab.viewMode === 'chat' + )?.structuredSessionId ?? null + ) +} + +export async function closeStructuredTerminalSessionWithRetry( + target: RuntimeClientTarget, + sessionId: string +): Promise { + for (const [attempt, delayMs] of STRUCTURED_SESSION_CLOSE_RETRY_DELAYS_MS.entries()) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + try { + await closeStructuredAgentSession(target, sessionId) + return true + } catch (error) { + if (attempt === STRUCTURED_SESSION_CLOSE_RETRY_DELAYS_MS.length - 1) { + console.warn('[structured-agent-session] terminal close disposal failed', { + sessionId, + error + }) + } + } + } + return false +} + +export function disposeStructuredTerminalSession({ + unifiedTabs, + terminalTabId, + target, + reason +}: { + unifiedTabs: readonly Tab[] | undefined + terminalTabId: string + target: RuntimeClientTarget + reason: TerminalTabCloseReason +}): void { + if (reason === 'pty-exit') { + return + } + const structuredSessionId = structuredTerminalSessionId(unifiedTabs, terminalTabId) + if (!structuredSessionId) { + return + } + // Closing is idempotent; a short retry window covers a dropped renderer/host request after the + // terminal surface has already been removed. + void closeStructuredTerminalSessionWithRetry(target, structuredSessionId) +} diff --git a/src/renderer/src/components/terminal/tab-type-cycle.ts b/src/renderer/src/components/terminal/tab-type-cycle.ts index 990d00cd87a..051c2533518 100644 --- a/src/renderer/src/components/terminal/tab-type-cycle.ts +++ b/src/renderer/src/components/terminal/tab-type-cycle.ts @@ -1,4 +1,6 @@ -export type TabCycleType = 'terminal' | 'editor' | 'browser' | 'simulator' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' + +export type TabCycleType = WorkspaceVisibleTabType export type TypeCyclableTab = { type: TabCycleType diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts index ea7a3868908..91adf10c4ae 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts @@ -37,6 +37,7 @@ function baseState(overrides: Record = {}): Record ({ renderableTabCount: 0 })), closeTab: vi.fn(), closeUnifiedTab: vi.fn(), setActiveFile: vi.fn(), diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts new file mode 100644 index 00000000000..caf15791365 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-actions-structured-session.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + closeStructuredAgentSession: vi.fn(), + closeTab: vi.fn(), + getState: vi.fn(), + isWebRuntimeSessionActive: vi.fn(), + resolveHostSessionTabIdForWebSessionTab: vi.fn(() => null), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + closeWebRuntimeSessionTab: vi.fn(), + isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive, + toHostSessionTabId: mocks.toHostSessionTabId +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab +})) + +vi.mock('@/runtime/structured-agent-session-close', () => ({ + closeStructuredAgentSession: mocks.closeStructuredAgentSession +})) + +import { closeTerminalTab } from './terminal-tab-actions' + +beforeEach(() => { + vi.clearAllMocks() + mocks.closeStructuredAgentSession.mockResolvedValue('closed') + mocks.isWebRuntimeSessionActive.mockReturnValue(false) +}) + +describe('structured session disposal from terminal close', () => { + it('disposes the native owner when an adopted TUI tab closes from chat view', async () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'terminal-1' }, { id: 'terminal-2' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'terminal-1', + contentType: 'terminal', + structuredSessionId: 'codex-adopted-1', + viewMode: 'chat' + } + ] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'terminal-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: mocks.closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('terminal-1') + + await vi.waitFor(() => + expect(mocks.closeStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'codex-adopted-1' + ) + ) + }) + + it('keeps natural adopted-TUI exits on the ownership reconciliation path', () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'terminal-1' }, { id: 'terminal-2' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'terminal-1', + contentType: 'terminal', + structuredSessionId: 'codex-adopted-1', + viewMode: 'chat' + } + ] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'terminal-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: mocks.closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('terminal-1', { reason: 'pty-exit' }) + + expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled() + }) + + it('retries a transient structured-owner close after the tab is removed', async () => { + vi.useFakeTimers() + try { + mocks.closeStructuredAgentSession + .mockRejectedValueOnce(new Error('host unavailable')) + .mockResolvedValueOnce('closed') + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'terminal-1' }, { id: 'terminal-2' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'terminal-1', + contentType: 'terminal', + structuredSessionId: 'codex-adopted-1', + viewMode: 'chat' + } + ] + }, + activeWorktreeId: 'wt-1', + activeTabId: 'terminal-2', + openFiles: [], + browserTabsByWorktree: {}, + closeTab: mocks.closeTab, + setActiveTab: vi.fn() + }) + + closeTerminalTab('terminal-1') + await vi.advanceTimersByTimeAsync(250) + expect(mocks.closeStructuredAgentSession).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts new file mode 100644 index 00000000000..bc6dc7b2604 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-actions-unified-close.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' + +// Why: drives the real closeTerminalTab orchestrator against the real store so the +// unified close contract (MRU/neighbor successor, renderable-count deactivation gate) +// is exercised end to end. Slice-level tests pass without the orchestrator fix and +// would be vacuous for these regressions. + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => null), + resolveHostSessionTabIdForWebSessionTab: vi.fn(() => null) +})) + +vi.mock('@/runtime/structured-agent-session-close', () => ({ + closeStructuredAgentSession: vi.fn(() => Promise.resolve()) +})) + +const { createTabsSliceMockApi } = await import('@/store/slices/tabs-slice-test-harness') +createTabsSliceMockApi() + +const { createTestStore, makeTab, makeTabGroup, makeUnifiedTab, makeWorktree, seedStore } = + await import('@/store/slices/store-test-helpers') +const store = createTestStore() + +vi.mock('@/store', () => ({ useAppStore: store })) + +const { closeTerminalTab } = await import('./terminal-tab-actions') + +const GIT_WT = 'repo1::/tmp/wt1' +const FOLDER_WT = 'folder:folder-1' +const GROUP = 'group-1' + +function seedWorktreeWithTabs( + worktreeId: string, + args: { + terminalIds: string[] + /** Unified group order; entries are unified tab ids ("u-" + terminal id, or the chat id). */ + groupOrder: string[] + /** MRU stack, most recent last. */ + recentTabIds: string[] + activeUnifiedTabId: string + activeTerminalId: string + includeChatTab?: boolean + } +): void { + const chatTab = makeUnifiedTab({ + id: 'chat-1', + entityId: 'codex-session-1', + groupId: GROUP, + worktreeId, + contentType: 'agent-session', + label: 'Codex Chat' + }) + const unifiedByTabId = new Map( + args.terminalIds.map((terminalId) => [ + `u-${terminalId}`, + makeUnifiedTab({ + id: `u-${terminalId}`, + entityId: terminalId, + groupId: GROUP, + worktreeId, + contentType: 'terminal' + }) + ]) + ) + if (args.includeChatTab !== false) { + unifiedByTabId.set(chatTab.id, chatTab) + } + // Why: keep the unified array in group order so insertion-order assertions are real. + const unifiedTabs = args.groupOrder.flatMap((tabId) => { + const tab = unifiedByTabId.get(tabId) + return tab ? [tab] : [] + }) + seedStore(store, { + activeWorktreeId: worktreeId, + worktreesByRepo: { + repo1: [makeWorktree({ id: GIT_WT, repoId: 'repo1', path: '/tmp/wt1' })] + }, + tabsByWorktree: { + [worktreeId]: args.terminalIds.map((terminalId) => makeTab({ id: terminalId, worktreeId })) + }, + unifiedTabsByWorktree: { + [worktreeId]: unifiedTabs + }, + groupsByWorktree: { + [worktreeId]: [ + makeTabGroup({ + id: GROUP, + worktreeId, + activeTabId: args.activeUnifiedTabId, + tabOrder: args.groupOrder, + recentTabIds: args.recentTabIds + }) + ] + }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId: GROUP } }, + activeGroupIdByWorktree: { [worktreeId]: GROUP }, + activeTabId: args.activeTerminalId, + activeTabIdByWorktree: { [worktreeId]: args.activeTerminalId }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [worktreeId]: 'terminal' }, + openFiles: [], + browserTabsByWorktree: {} + } as Partial) +} + +function group(worktreeId: string) { + return store.getState().groupsByWorktree[worktreeId]?.find((entry) => entry.id === GROUP) +} + +beforeEach(() => { + store.setState({ + activeWorktreeId: null, + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + browserTabsByWorktree: {}, + openFiles: [] + } as Partial) +}) + +describe('closeTerminalTab unified close contract', () => { + it('keeps the git worktree active and focuses the chat tab when the last terminal closes', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1', 'chat-1'], + recentTabIds: ['chat-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1' + }) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(GIT_WT) + expect(state.activeTabType).toBe('agent-session') + expect(group(GIT_WT)?.activeTabId).toBe('chat-1') + expect(state.unifiedTabsByWorktree[GIT_WT]?.map((tab) => tab.id)).toEqual(['chat-1']) + }) + + it('keeps the folder workspace active and focuses the chat tab when the last terminal closes', () => { + seedWorktreeWithTabs(FOLDER_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1', 'chat-1'], + recentTabIds: ['chat-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1' + }) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(FOLDER_WT) + expect(state.activeTabType).toBe('agent-session') + expect(group(FOLDER_WT)?.activeTabId).toBe('chat-1') + }) + + it('falls back to the most recent chat tab, not the next terminal, when closing among two terminals', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1', 'term-2'], + groupOrder: ['u-term-1', 'chat-1', 'u-term-2'], + recentTabIds: ['u-term-2', 'chat-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1' + }) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(GIT_WT) + expect(group(GIT_WT)?.activeTabId).toBe('chat-1') + expect(state.activeTabType).toBe('agent-session') + // Why: insertion order must survive the close — only the closed tab drops out. + expect(state.unifiedTabsByWorktree[GIT_WT]?.map((tab) => tab.id)).toEqual([ + 'chat-1', + 'u-term-2' + ]) + expect(group(GIT_WT)?.tabOrder).toEqual(['chat-1', 'u-term-2']) + }) + + it('still deactivates the worktree when the last renderable tab closes', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1'], + recentTabIds: ['u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1', + includeChatTab: false + }) + + closeTerminalTab('term-1') + + expect(store.getState().activeWorktreeId).toBeNull() + }) + + it('lands on an open editor tab instead of deactivating when the last terminal closes', () => { + seedWorktreeWithTabs(GIT_WT, { + terminalIds: ['term-1'], + groupOrder: ['u-term-1', 'editor-1'], + recentTabIds: ['editor-1', 'u-term-1'], + activeUnifiedTabId: 'u-term-1', + activeTerminalId: 'term-1', + includeChatTab: false + }) + store.setState((state) => ({ + openFiles: [ + { + id: 'file-1', + worktreeId: GIT_WT, + filePath: 'file-1', + relativePath: 'file.ts', + language: 'typescript', + isDirty: false, + mode: 'edit' as const + } + ], + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [GIT_WT]: [ + ...(state.unifiedTabsByWorktree[GIT_WT] ?? []), + makeUnifiedTab({ + id: 'editor-1', + entityId: 'file-1', + groupId: GROUP, + worktreeId: GIT_WT, + contentType: 'editor' + }) + ] + } + })) + + closeTerminalTab('term-1') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(GIT_WT) + expect(state.activeTabType).toBe('editor') + expect(state.activeFileId).toBe('file-1') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index 4b7557ba057..91e59845722 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -441,6 +441,7 @@ describe('closeTerminalTab', () => { activeTabId: 'terminal-entity-1', openFiles: [], browserTabsByWorktree: {}, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), closeTab, closeUnifiedTab, setActiveTab: vi.fn(), @@ -453,7 +454,7 @@ describe('closeTerminalTab', () => { expect(closeUnifiedTab).not.toHaveBeenCalled() }) - it('activates the next unified terminal tab when closing the active unified-only tab', () => { + it('defers successor selection for unified terminal tabs to the unified close contract', () => { const closeTab = vi.fn() const closeUnifiedTab = vi.fn() const setActiveTab = vi.fn() @@ -504,7 +505,10 @@ describe('closeTerminalTab', () => { closeTerminalTab('terminal-entity-1') - expect(setActiveTab).toHaveBeenCalledWith('terminal-entity-2') + // Why: a unified terminal must not pre-pick a terminal-only successor — the + // store's closeUnifiedTab owns the MRU/neighbor repair (which may land on an + // agent-session tab); terminal-tab-actions-unified-close.test.ts covers it. + expect(setActiveTab).not.toHaveBeenCalled() expect(closeTab).toHaveBeenCalledWith('terminal-entity-1', { reason: undefined }) expect(closeUnifiedTab).not.toHaveBeenCalled() }) @@ -573,6 +577,7 @@ describe('closeTerminalTab', () => { activeTabId: 'pinned-entity-1', openFiles: [], browserTabsByWorktree: {}, + reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })), closeTab: vi.fn(), closeUnifiedTab: vi.fn(), setActiveTab: vi.fn(), diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 8216696d217..49bc3d11fef 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -9,12 +9,19 @@ import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' +import { translate } from '@/i18n/i18n' import { guardPinnedTabClose, isUnifiedTabPinned, resolvePinnedTabLabel, shouldConfirmPinnedTabClose } from '@/store/pinned-tab-close-guard' +import { + closeStructuredTerminalSessionWithRetry, + disposeStructuredTerminalSession, + structuredTerminalSessionId +} from './structured-terminal-session-disposal' +import { toast } from 'sonner' import type { TerminalTabCloseReason, TerminalTabRetirementPlan @@ -51,6 +58,8 @@ export function closeTerminalTab( skipRunningProcessConfirm?: boolean captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean + /** Internal re-entry after the structured provider close is proven. */ + structuredSessionCloseConfirmed?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan precomputedCloseState?: PrecomputedTerminalCloseState onClosed?: () => void @@ -131,6 +140,59 @@ export function closeTerminalTab( } const runtimeEnvironmentId = worktreeRoute.runtimeEnvironmentId + const structuredSessionId = structuredTerminalSessionId( + state.unifiedTabsByWorktree?.[owningWorktreeId], + terminalTabId + ) + if ( + structuredSessionId && + options?.reason !== 'pty-exit' && + options?.structuredSessionCloseConfirmed !== true + ) { + const target = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) + void closeStructuredTerminalSessionWithRetry(target, structuredSessionId).then((closed) => { + if (!closed) { + toast.error( + translate( + 'components.native-chat.structuredSessionCloseFailed', + 'Could not close this Codex chat' + ), + { + description: translate( + 'components.native-chat.structuredSessionCloseFailedDescription', + 'The terminal stayed open so the provider remains recoverable.' + ) + } + ) + options?.onCancel?.() + return + } + closeTerminalTab(tabId, { + ...options, + force: true, + skipRunningProcessConfirm: true, + structuredSessionCloseConfirmed: true + }) + }) + return + } + const retireStructuredSession = (): void => { + const closeReason = options?.reason ?? options?.hostCloseReason ?? 'user' + const target = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) + if (options?.structuredSessionCloseConfirmed === true) { + return + } + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[owningWorktreeId], + terminalTabId, + target, + reason: closeReason + }) + } if (runtimeEnvironmentId && isWebRuntimeSessionActive(runtimeEnvironmentId)) { if (options?.reason === 'pty-exit') { // Why: stream exit is not host-tab closure; the HUB snapshot decides whether reconnect restores or removes this tab. @@ -189,6 +251,7 @@ export function closeTerminalTab( } : {}) }) + retireStructuredSession() options?.onClosed?.() return } @@ -198,42 +261,19 @@ export function closeTerminalTab( : getWorktreeTerminalTabIds(state, owningWorktreeId) const terminalCountBeforeClose = precomputedCloseState?.terminalCountBeforeClose ?? currentTerminalTabIds!.length - if (terminalCountBeforeClose <= 1) { - closeLocalTerminalTabState(terminalTabId, { - reason: options?.reason, - ...(options?.captureRecentlyClosed !== undefined - ? { captureRecentlyClosed: options.captureRecentlyClosed } - : {}), - ...(options?.localPtyTeardownOwnedExternally - ? { localPtyTeardownOwnedExternally: true } - : {}), - ...(options?.precomputedRetirementPlan - ? { precomputedRetirementPlan: options.precomputedRetirementPlan } - : {}) - }) - if (state.activeWorktreeId === owningWorktreeId) { - // Why: only deactivate the worktree when no tabs of any kind remain. - // Editor files are a separate tab type; closing the last terminal tab - // should switch to the editor view instead of tearing down the workspace. - const worktreeFile = state.openFiles.find((f) => f.worktreeId === owningWorktreeId) - if (worktreeFile) { - state.setActiveFile(worktreeFile.id) - state.setActiveTabType('editor') - } else { - const browserTab = (state.browserTabsByWorktree?.[owningWorktreeId] ?? [])[0] - if (browserTab) { - state.setActiveBrowserTab(browserTab.id) - state.setActiveTabType('browser') - } else { - state.setActiveWorktree(null) - } - } - } - options?.onClosed?.() - return - } - - if (state.activeWorktreeId === owningWorktreeId && terminalTabId === state.activeTabId) { + // Why: a terminal with a unified row must leave successor choice to closeUnifiedTab's + // MRU/neighbor repair — a terminal-only pre-pick skips agent-session/simulator neighbors + // and re-stamps the group active before the canonical repair can run. + const hasUnifiedRow = (state.unifiedTabsByWorktree?.[owningWorktreeId] ?? []).some( + (tab) => + tab.contentType === 'terminal' && (tab.entityId === terminalTabId || tab.id === terminalTabId) + ) + if ( + !hasUnifiedRow && + terminalCountBeforeClose > 1 && + state.activeWorktreeId === owningWorktreeId && + terminalTabId === state.activeTabId + ) { const currentIndex = currentTerminalTabIds?.indexOf(terminalTabId) ?? -1 const nextTabId = precomputedCloseState ? precomputedCloseState.nextTerminalTabId @@ -253,5 +293,32 @@ export function closeTerminalTab( ? { precomputedRetirementPlan: options.precomputedRetirementPlan } : {}) }) + if (terminalCountBeforeClose <= 1 && state.activeWorktreeId === owningWorktreeId) { + // Why: re-read after the close — closeUnifiedTab may have already deactivated or + // repaired the surface, and the pre-close snapshot must not clobber that outcome. + const current = useAppStore.getState() + if (current.activeWorktreeId === owningWorktreeId) { + // Why: agent-session and simulator tabs render without a terminal/editor/browser + // entity, so only the unified renderable count can prove the worktree is empty + // (mirrors leaveWorktreeIfEmpty in useTabGroupTabCloseCommands). + const { renderableTabCount } = current.reconcileWorktreeTabModel(owningWorktreeId) + if (renderableTabCount === 0) { + const worktreeFile = current.openFiles.find((f) => f.worktreeId === owningWorktreeId) + if (worktreeFile) { + current.setActiveFile(worktreeFile.id) + current.setActiveTabType('editor') + } else { + const browserTab = (current.browserTabsByWorktree?.[owningWorktreeId] ?? [])[0] + if (browserTab) { + current.setActiveBrowserTab(browserTab.id) + current.setActiveTabType('browser') + } else { + current.setActiveWorktree(null) + } + } + } + } + } + retireStructuredSession() options?.onClosed?.() } diff --git a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts new file mode 100644 index 00000000000..cb6a65146ce --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.test.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + closeTab: vi.fn(), + closeFile: vi.fn(), + closeLocalTerminalTabState: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + closeStructuredTerminalSessionWithRetry: vi.fn(), + disposeStructuredTerminalSession: vi.fn(), + getState: vi.fn(), + isWebRuntimeSessionActive: vi.fn(), + reconcileTabOrder: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: mocks.getState } +})) + +vi.mock('@/lib/terminal-worktree-route', () => ({ + hasUnroutableTerminalWorktreeOwner: () => false, + resolveTerminalWorktreeRoute: () => ({ runtimeEnvironmentId: null }) +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + closeWebRuntimeSessionTab: mocks.closeWebRuntimeSessionTab, + isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive +})) + +vi.mock('../tab-bar/reconcile-order', () => ({ + reconcileTabOrder: mocks.reconcileTabOrder +})) + +vi.mock('./close-local-terminal-tab-state', () => ({ + closeLocalTerminalTabState: mocks.closeLocalTerminalTabState +})) + +vi.mock('./structured-terminal-session-disposal', () => ({ + closeStructuredTerminalSessionWithRetry: mocks.closeStructuredTerminalSessionWithRetry, + disposeStructuredTerminalSession: mocks.disposeStructuredTerminalSession, + structuredTerminalSessionId: ( + tabs: { entityId: string; structuredSessionId?: string }[], + id: string + ) => tabs.find((tab) => tab.entityId === id)?.structuredSessionId ?? null +})) + +import { closeOtherTerminalTabs, closeTerminalTabsToRight } from './terminal-tab-bulk-actions' + +beforeEach(() => { + vi.clearAllMocks() + mocks.isWebRuntimeSessionActive.mockReturnValue(false) + mocks.reconcileTabOrder.mockReturnValue(['keep', 'close-a', 'close-b']) + mocks.closeStructuredTerminalSessionWithRetry.mockResolvedValue(true) +}) + +describe('adopted native-chat disposal in legacy terminal bulk actions', () => { + it('proves adopted-owner close before retiring other local terminals', async () => { + const state = { + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'keep' }, { id: 'close-a' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'close-a', + contentType: 'terminal', + viewMode: 'chat', + structuredSessionId: 'codex-adopted-1' + } + ] + }, + setActiveTab: vi.fn(), + closeTab: mocks.closeTab + } + mocks.getState.mockReturnValue(state) + + await closeOtherTerminalTabs('keep', 'wt-1') + + expect(mocks.closeTab).toHaveBeenCalledWith('close-a') + expect(mocks.closeStructuredTerminalSessionWithRetry).toHaveBeenCalledWith( + { kind: 'local' }, + 'codex-adopted-1' + ) + expect(mocks.disposeStructuredTerminalSession).not.toHaveBeenCalled() + }) + + it('proves adopted-owner close before retiring local terminals to the right', async () => { + const state = { + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'wt-1': [{ id: 'keep' }, { id: 'close-a' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'close-a', + contentType: 'terminal', + viewMode: 'chat', + structuredSessionId: 'codex-adopted-2' + } + ] + }, + openFiles: [], + tabBarOrderByWorktree: { 'wt-1': ['keep', 'close-a'] }, + closeTab: mocks.closeTab, + closeFile: mocks.closeFile + } + mocks.reconcileTabOrder.mockReturnValue(['keep', 'close-a']) + mocks.getState.mockReturnValue(state) + + await closeTerminalTabsToRight('keep', 'wt-1') + + expect(mocks.closeTab).toHaveBeenCalledWith('close-a') + expect(mocks.closeStructuredTerminalSessionWithRetry).toHaveBeenCalledWith( + { kind: 'local' }, + 'codex-adopted-2' + ) + expect(mocks.disposeStructuredTerminalSession).not.toHaveBeenCalled() + }) + + it('keeps a structured terminal visible when provider close is unproven', async () => { + mocks.closeStructuredTerminalSessionWithRetry.mockResolvedValue(false) + mocks.getState.mockReturnValue({ + tabsByWorktree: { 'wt-1': [{ id: 'keep' }, { id: 'close-a' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + entityId: 'close-a', + contentType: 'terminal', + viewMode: 'chat', + structuredSessionId: 'codex-live-1' + } + ] + }, + setActiveTab: vi.fn(), + closeTab: mocks.closeTab + }) + + await closeOtherTerminalTabs('keep', 'wt-1') + + expect(mocks.closeTab).not.toHaveBeenCalledWith('close-a') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts index 3bca59bd476..e4fc2254fcc 100644 --- a/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-bulk-actions.ts @@ -7,6 +7,11 @@ import { closeWebRuntimeSessionTab, isWebRuntimeSessionActive } from '@/runtime/ import { useAppStore } from '@/store' import { reconcileTabOrder } from '../tab-bar/reconcile-order' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' +import { + closeStructuredTerminalSessionWithRetry, + disposeStructuredTerminalSession, + structuredTerminalSessionId +} from './structured-terminal-session-disposal' const EDITOR_TAB_CONTENT_TYPES = new Set([ 'editor', @@ -29,7 +34,10 @@ function isPinnedVisibleTab( ) } -export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | null): void { +export async function closeOtherTerminalTabs( + tabId: string, + activeWorktreeId: string | null +): Promise { if (!activeWorktreeId) { return } @@ -44,10 +52,23 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | activeWorktreeId )?.runtimeEnvironmentId const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) + const runtimeTarget = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) for (const tab of currentTabs) { if (tab.id === tabId || isPinnedVisibleTab(state, activeWorktreeId, tab.id)) { continue } + const structuredSessionId = structuredTerminalSessionId( + state.unifiedTabsByWorktree?.[activeWorktreeId], + tab.id + ) + if ( + structuredSessionId && + !(await closeStructuredTerminalSessionWithRetry(runtimeTarget, structuredSessionId)) + ) { + continue + } if (closeHostTerminalTabs) { // Why: prune the mirror immediately, then close on its authoritative host so snapshots converge. closeLocalTerminalTabState(tab.id, { remoteCloseOwnedByHost: true }) @@ -57,13 +78,32 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string | environmentId: runtimeEnvironmentId, reason: 'user' }) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: tab.id, + target: runtimeTarget, + reason: 'user' + }) + } } else { state.closeTab(tab.id) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: tab.id, + target: runtimeTarget, + reason: 'user' + }) + } } } } -export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string | null): void { +export async function closeTerminalTabsToRight( + tabId: string, + activeWorktreeId: string | null +): Promise { if (!activeWorktreeId) { return } @@ -79,6 +119,9 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string activeWorktreeId )?.runtimeEnvironmentId const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId) + const runtimeTarget = runtimeEnvironmentId + ? ({ kind: 'environment', environmentId: runtimeEnvironmentId } as const) + : ({ kind: 'local' } as const) const terminalIds = currentTerminalTabs.map((tab) => tab.id) const terminalIdSet = new Set(terminalIds) const orderedIds = reconcileTabOrder( @@ -96,6 +139,16 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string continue } if (terminalIdSet.has(id)) { + const structuredSessionId = structuredTerminalSessionId( + state.unifiedTabsByWorktree?.[activeWorktreeId], + id + ) + if ( + structuredSessionId && + !(await closeStructuredTerminalSessionWithRetry(runtimeTarget, structuredSessionId)) + ) { + continue + } if (closeHostTerminalTabs) { // Why: prune the mirror immediately, then close on its authoritative host so snapshots converge. closeLocalTerminalTabState(id, { remoteCloseOwnedByHost: true }) @@ -105,8 +158,24 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string environmentId: runtimeEnvironmentId, reason: 'user' }) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: id, + target: runtimeTarget, + reason: 'user' + }) + } } else { state.closeTab(id) + if (!structuredSessionId) { + disposeStructuredTerminalSession({ + unifiedTabs: state.unifiedTabsByWorktree?.[activeWorktreeId], + terminalTabId: id, + target: runtimeTarget, + reason: 'user' + }) + } } continue } diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 480fca37fb8..ae9efcc798e 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -46,6 +46,7 @@ export type IpcEventsHarness = { useIpcEvents: () => void createTerminal: (request: CreateTerminalRequest) => void requestTerminalCreate: (request: RequestTerminalCreateRequest) => void + focusEditorTab: (request: { tabId: string; worktreeId: string }) => void replyTerminalCreate: ReturnType /** Fire a main-process digit chord (zero-based index). */ jumpToWorktreeIndex: (index: number) => void @@ -71,8 +72,7 @@ export type IpcEventsHarnessOptions = { } /** - * Loads useIpcEvents against a stubbed preload API and returns a driver for the - * create-terminal IPC, so reveal/adoption behavior is asserted through the hook. + * Loads useIpcEvents against a stubbed preload API so IPC behavior is asserted through the hook. */ export async function loadIpcEventsHarness( storeState: HarnessStoreState, @@ -82,6 +82,8 @@ export async function loadIpcEventsHarness( const activateAndRevealWorkspace = vi.fn() let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null + let focusEditorTabListener: ((request: { tabId: string; worktreeId: string }) => void) | null = + null let navigationUpdateListener: | ((event: { browserPageId: string; url: string; title: string }) => void) | null = null @@ -155,6 +157,12 @@ export async function loadIpcEventsHarness( requestTerminalCreateListener = listener return () => {} }, + onFocusEditorTab: ( + listener: (request: { tabId: string; worktreeId: string }) => void + ) => { + focusEditorTabListener = listener + return () => {} + }, onJumpToWorktreeIndex: (listener: (index: number) => void) => { indexJumpListeners.set('worktree', listener) return () => {} @@ -244,6 +252,12 @@ export async function loadIpcEventsHarness( } requestTerminalCreateListener(request) }, + focusEditorTab: (request) => { + if (typeof focusEditorTabListener !== 'function') { + throw new Error('Expected the focus-editor-tab listener to be registered') + } + focusEditorTabListener(request) + }, replyTerminalCreate, jumpToWorktreeIndex: (index) => fireIndexJump(indexJumpListeners, 'worktree', index), jumpToTabIndex: (index) => fireIndexJump(indexJumpListeners, 'tab', index), 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 4a7269fe2e7..4bd590d1d9a 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 @@ -64,6 +64,11 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void const store = useAppStore.getState() const tab = (store.unifiedTabsByWorktree[worktreeId] ?? []).find((item) => item.id === tabId) const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId) + // Why: chat-completion focus is a courtesy reveal, not navigation — never yank the user + // back into a workspace they deliberately left. + if (tab?.contentType === 'agent-session' && store.activeWorktreeId !== worktreeId) { + return + } if (!tab) { if (browserTarget) { // Why: older/mobile fallback snapshots identify browser tabs by workspace id when no unified tab wrapper exists. @@ -81,7 +86,9 @@ export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void store.setActiveView('terminal') store.focusGroup(worktreeId, tab.groupId) store.activateTab(tab.id) - if (browserTarget) { + if (tab.contentType === 'agent-session') { + store.setActiveTabType('agent-session') + } else if (browserTarget) { // Why: browser tabs need their own active-page state, not the editor file activation path. store.setActiveBrowserTab(browserTarget.workspaceId) store.setActiveTabType('browser') diff --git a/src/renderer/src/hooks/ipc-tab-switch.ts b/src/renderer/src/hooks/ipc-tab-switch.ts index 8237998e62c..9a2a760dad5 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.ts @@ -79,6 +79,11 @@ export function activateCyclableTab(store: AppStoreState, next: TypeCyclableTab) store.activateTab?.(next.tabId) } store.setActiveTabType('simulator') + } else if (next.type === 'agent-session') { + if (next.tabId) { + store.activateTab?.(next.tabId) + } + store.setActiveTabType('agent-session') } else { // Why: `setActiveFile` targets the file entity (its implicit activateTab // picks the first matching tab in the active group); `activateTab(tabId)` diff --git a/src/renderer/src/hooks/modal-return-focus-action.ts b/src/renderer/src/hooks/modal-return-focus-action.ts index 4e516001bfc..5a4eef905ee 100644 --- a/src/renderer/src/hooks/modal-return-focus-action.ts +++ b/src/renderer/src/hooks/modal-return-focus-action.ts @@ -1,10 +1,11 @@ import type { BrowserFocusTarget } from '../components/browser-pane/host-guest/browser-focus' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' // The surface that held focus before a modal (QuickOpen, Cmd+J, ...) opened. // Captured at open time because Radix steals document focus once the dialog // mounts, so the raw activeElement is gone by close time. export type ModalReturnFocusSurface = { - tabType: 'browser' | 'editor' | 'terminal' | 'simulator' + tabType: WorkspaceVisibleTabType worktreeId: string | null browserPageId: string | null browserTarget: BrowserFocusTarget diff --git a/src/renderer/src/hooks/resolve-zoom-target.ts b/src/renderer/src/hooks/resolve-zoom-target.ts index cc727f4c8a7..5aeb0148c70 100644 --- a/src/renderer/src/hooks/resolve-zoom-target.ts +++ b/src/renderer/src/hooks/resolve-zoom-target.ts @@ -4,7 +4,7 @@ */ export function resolveZoomTarget(args: { activeView: TopLevelView - activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' + activeTabType: WorkspaceVisibleTabType activeElement: unknown }): 'terminal' | 'editor' | 'simulator' | 'ui' { const { activeView, activeTabType, activeElement } = args @@ -55,3 +55,4 @@ export function resolveZoomTarget(args: { return 'ui' } import type { TopLevelView } from '../../../shared/ui-chrome-types' +import type { WorkspaceVisibleTabType } from '../../../shared/tab-types' diff --git a/src/renderer/src/hooks/structured-session-completion-focus.test.ts b/src/renderer/src/hooks/structured-session-completion-focus.test.ts new file mode 100644 index 00000000000..dfadee2c874 --- /dev/null +++ b/src/renderer/src/hooks/structured-session-completion-focus.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createHarnessStoreState, + loadIpcEventsHarness, + type HarnessStoreState +} from './ipc-events-test-harness' + +const SESSION_WORKSPACE_ID = 'repo-1::/session-workspace' +const OTHER_WORKSPACE_ID = 'repo-1::/other-workspace' +const TAB_ID = 'structured-agent-session-session-1' + +function createStoreState(activeWorktreeId: string): HarnessStoreState { + return createHarnessStoreState({ + tabsByWorktree: {}, + activeWorktreeId, + unifiedTabsByWorktree: { + [SESSION_WORKSPACE_ID]: [ + { + id: TAB_ID, + entityId: 'session-1', + groupId: 'group-1', + worktreeId: SESSION_WORKSPACE_ID, + contentType: 'agent-session' + } + ] + }, + focusGroup: vi.fn(), + activateTab: vi.fn() + }) +} + +describe('structured session completion focus', () => { + it('focuses the chat tab when its workspace is active', async () => { + const store = createStoreState(SESSION_WORKSPACE_ID) + const harness = await loadIpcEventsHarness(store) + harness.useIpcEvents() + + harness.focusEditorTab({ tabId: TAB_ID, worktreeId: SESSION_WORKSPACE_ID }) + + expect(store.focusGroup).toHaveBeenCalledWith(SESSION_WORKSPACE_ID, 'group-1') + expect(store.activateTab).toHaveBeenCalledWith(TAB_ID) + expect(store.setActiveTabType).toHaveBeenCalledWith('agent-session') + }) + + it('does not apply focus after the user moves to another workspace', async () => { + const store = createStoreState(OTHER_WORKSPACE_ID) + const harness = await loadIpcEventsHarness(store) + harness.useIpcEvents() + + harness.focusEditorTab({ tabId: TAB_ID, worktreeId: SESSION_WORKSPACE_ID }) + + expect(store.setActiveWorktree).not.toHaveBeenCalled() + expect(store.markWorktreeVisited).not.toHaveBeenCalled() + expect(store.setActiveView).not.toHaveBeenCalled() + expect(store.focusGroup).not.toHaveBeenCalled() + expect(store.activateTab).not.toHaveBeenCalled() + expect(store.setActiveTabType).not.toHaveBeenCalled() + expect(store.revealWorktreeInSidebar).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/i18n/locale-english-regression.test.ts b/src/renderer/src/i18n/locale-english-regression.test.ts index dc20755de6e..8f120f1d23c 100644 --- a/src/renderer/src/i18n/locale-english-regression.test.ts +++ b/src/renderer/src/i18n/locale-english-regression.test.ts @@ -22,6 +22,23 @@ import zh from './locales/zh.json' const catalogs = { es, ja, ko, zh } +const WORKSPACE_CLEANUP_BROWSE_TRANSLATIONS = { + zh: { + selectionWithheldOne: '当前筛选条件隐藏了 1 个已选工作区,已取消选择。', + selectionWithheld: '当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。', + selectAllCountOne: '选择 1 个通过安全检查的工作区', + selectAllCount: '选择全部 {{value0}} 个通过安全检查的工作区' + }, + ko: { + selectionWithheldOne: + '선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.', + selectionWithheld: + '선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.', + selectAllCountOne: '안전 검사를 통과한 워크스페이스 1개 선택', + selectAllCount: '안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택' + } +} as const + function lookup(catalog: unknown, key: string): string | undefined { const value = key .split('.') @@ -67,4 +84,14 @@ describe('locale catalogs reverted by a stale branch base (#10770)', () => { 'Recipes from orca.yaml and enabled plugins show up here, ready to launch a workspace on.' ) }) + + it.each(Object.entries(WORKSPACE_CLEANUP_BROWSE_TRANSLATIONS))( + '%s retains the merged workspace cleanup browse translations', + (code, translations) => { + const catalog = code === 'zh' ? zh : ko + for (const [key, expected] of Object.entries(translations)) { + expect(lookup(catalog, `components.workspace.cleanup.browse.${key}`)).toBe(expected) + } + } + ) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b3bdbfbcc65..23f03820b50 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -932,6 +932,9 @@ } } }, + "activateAiVaultStructuredSession": { + "unavailable": "The structured agent session is not available yet. Retry in a moment." + }, "ephemeralVmWorktreeCreation": { "sparseCheckoutUnsupported": "Provisioned-root recipes do not support sparse checkout." }, @@ -5565,7 +5568,11 @@ "382fd11a3e": "Edit Worktree Details", "2174f17011": "Save", "61d6f612cf": "Saving...", - "a0d191b7a7": "Edit issue links, pull request links, and notes for this workspace." + "a0d191b7a7": "Edit issue links, pull request links, and notes for this workspace.", + "gitlabDescription": "Edit issue links, merge request links, and notes for this workspace.", + "gitlabMR": "GitLab MR", + "gitlabPlaceholder": "MR ! or GitLab URL", + "gitlabHelp": "Paste a merge request URL, or enter a number. Leave blank to remove the link." }, "WorktreeOpenInMenu": { "localOnly": "Local only", @@ -6884,13 +6891,17 @@ "nativeChat": { "title": "Chat UI", "description": "Preview the desktop chat surface for supported agent terminal sessions.", - "copy": "Adds a Chat UI view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.", + "copy": "Enables the experimental Chat UI for newly created supported local sessions. Existing terminal sessions keep the terminal chat path while we tune transcript fidelity, streaming, and parity.", "toggleLabel": "Toggle Chat UI", "defaultTitle": "Default view", "defaultCopy": "Choose how new supported agent terminal tabs open.", "defaultViewLabel": "Default Chat UI view", "defaultViewTerminal": "Terminal chat", - "defaultViewNative": "Chat UI" + "defaultViewNative": "Chat UI", + "structuredTitle": "Use updated structured native chat", + "structuredCopy": "Opt in to the host-owned structured Codex runtime. Off keeps the existing terminal-backed chat path.", + "structuredScope": "Local macOS and Linux sessions only for now. Windows, WSL, and remote execution hosts (including SSH) continue to use terminal chat.", + "structuredToggleLabel": "Toggle updated structured native chat" }, "agentDashboard": { "title": "Agent Dashboard", @@ -11554,7 +11565,10 @@ }, "7e4b2a19c0": "Could not resolve the GitHub PR to reply on.", "430f1a62d4": "Could not resolve the selected thread on the host.", - "updateReactionFailed": "Failed to update reaction." + "updateReactionFailed": "Failed to update reaction.", + "gitlabMoreActions": "More MR actions", + "gitlabUnlink": "Unlink MR", + "gitlabLinkAnother": "Link another MR" }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -13831,6 +13845,30 @@ }, "feature": { "wall": { + "setup": { + "checklist": { + "localized": { + "copy": { + "ec0a363633": "Multi-task", + "62bac8f43c": "Work in 2 different worktrees at once. Each one is isolated (even in the same project). Perfect for working on 2 features at once.", + "908898c3ee": "Use Orca's browser", + "43781563c3": "Browse your web app without leaving Orca. Grab any element and send its exact source and styles to an agent with one click.", + "29aa2c2077": "Turn on notifications", + "71bd9a8c95": "Know the moment an agent finishes, needs attention, or gets blocked.", + "46db810da8": "Choose your default agent", + "b8e5bae17f": "Start new work faster with your preferred agent already selected.", + "fee5557b02": "Enable Orca CLI", + "7bcb4097fa": "Register the Orca shell command and install agent skills for browser, computer, and orchestration workflows.", + "ad342dd4c6": "Connect integrations", + "06fe30fdb0": "Start an agent from a task in one click and keep PR status in view.", + "eddc532e58": "Automate workspace setup", + "56049b74c2": "Run install and setup commands automatically so every new worktree is ready for agents.", + "2cf795433b": "Start work in multiple repos", + "42525ba8a4": "Bring your key repos into Orca so you can start agent work without hunting for folders." + } + } + } + }, "usage": { "tracking": { "b94ec70eda": "Tracking not set up", @@ -16366,6 +16404,15 @@ "clientNoteLocalStorage": "Imports read this device’s browsers. Cookies are stored locally.", "remoteNoteRemoteStorage": "Imports read browsers on {{value0}}. Cookies are stored on that machine, and a permission prompt may appear on its screen." }, + "native": { + "chat": { + "NativeChatStructuredSession": { + "1f772bb5d0": "Message delivery is unconfirmed.", + "93ef441197": "Message was not sent.", + "a5e7f14068": "Retry" + } + } + }, "ComposerParentWorktreePicker": { "label": "Parent worktree", "noParent": "No parent", @@ -16512,6 +16559,9 @@ "countOne": "1 tool call", "countN": "{{value0}} tool calls" }, + "providerFrame": { + "byteLength": "{{value0}} bytes" + }, "status": { "responding": "Agent is responding" }, @@ -16562,7 +16612,15 @@ "allow": "Allow", "deny": "Deny" }, - "launchPromptNotDelivered": "Not delivered — check the terminal" + "launchPromptNotDelivered": "Not delivered — check the terminal", + "orchestrationPaused": { + "label": "Orchestration paused", + "message": "Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with", + "command": "orca orchestration check" + }, + "structuredSessionCloseFailed": "Could not close this Codex chat", + "structuredSessionLaunchFailed": "Could not open Codex chat", + "structuredSessionCloseFailedDescription": "The terminal stayed open so the provider remains recoverable." }, "tab": { "bar": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 3f75e38e999..2e5fd1fd95f 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12411,6 +12411,30 @@ }, "feature": { "wall": { + "setup": { + "checklist": { + "localized": { + "copy": { + "ec0a363633": "동시 작업", + "62bac8f43c": "동시에 2개의 서로 다른 워크트리에서 작업하세요. 같은 프로젝트 내에서도 각각 완벽히 격리되어 있어 2개의 기능을 동시에 작업하기에 적합합니다.", + "908898c3ee": "Orca 브라우저 사용", + "43781563c3": "Orca를 벗어나지 않고 웹 앱을 탐색하세요. 원하는 요소를 선택하고 클릭 한 번으로 정확한 소스와 스타일을 에이전트에 전달할 수 있습니다.", + "29aa2c2077": "알림 켜기", + "71bd9a8c95": "에이전트가 작업을 완료했거나, 확인이 필요하거나, 차단된 순간을 즉시 알 수 있습니다.", + "46db810da8": "기본 에이전트 선택", + "b8e5bae17f": "선호하는 에이전트를 미리 선택하여 새로운 작업을 더 빠르게 시작하세요.", + "fee5557b02": "Orca CLI 활성화", + "7bcb4097fa": "Orca 셸 명령을 등록하고 브라우저, 컴퓨터 및 오케스트레이션 워크플로를 위한 에이전트 스킬을 설치하세요.", + "ad342dd4c6": "서비스 연동", + "06fe30fdb0": "클릭 한 번으로 작업에서 에이전트를 시작하고 PR 상태를 한눈에 확인하세요.", + "eddc532e58": "워크스페이스 설정 자동화", + "56049b74c2": "설치 및 설정 명령을 자동으로 실행하여 모든 새 워크트리가 에이전트 작업을 바로 수행할 수 있도록 준비합니다.", + "2cf795433b": "여러 저장소에서 작업 시작", + "42525ba8a4": "주요 저장소를 Orca로 가져와 폴더를 일일이 찾을 필요 없이 에이전트 작업을 시작하세요." + } + } + } + }, "usage": { "tracking": { "b94ec70eda": "추적이 설정되지 않았습니다.", @@ -14678,29 +14702,16 @@ }, "workspace": { "cleanup": { - "browse": { - "selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", - "selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", - "selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택", - "selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택" - }, - "scan": { - "readyOne": "워크스페이스 1개를 찾았습니다.", - "readyMany": "워크스페이스 {{value0}}개를 찾았습니다." - }, - "presentationFixtures": { - "reviewAlphaCleanup": "알파 정리 검토" - }, - "presentation": { - "gitlabMergeRequestNumber": "MR #{{value0}}", - "githubPullRequestNumber": "PR #{{value0}}" - }, "browse": { "gitStatusCheckFailed": "Git 상태 확인 실패", "gitStatusUnverified": "Git 상태를 확인할 수 없음", "deleteAnyway": "그래도 삭제", "forceDeleteProjectionOne": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다", "forceDeleteProjectionMany": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다", + "selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", + "selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.", + "selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택", + "selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택", "appliedFilters": "적용된 필터", "removeFilter": "{{value0}} 필터 제거", "chip": { @@ -14747,6 +14758,17 @@ "retainedAgents": "완료된 에이전트" } } + }, + "scan": { + "readyOne": "워크스페이스 1개를 찾았습니다.", + "readyMany": "워크스페이스 {{value0}}개를 찾았습니다." + }, + "presentationFixtures": { + "reviewAlphaCleanup": "알파 정리 검토" + }, + "presentation": { + "gitlabMergeRequestNumber": "MR #{{value0}}", + "githubPullRequestNumber": "PR #{{value0}}" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 8d4304abf23..3f2a1e2d1ed 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14702,29 +14702,16 @@ }, "workspace": { "cleanup": { - "browse": { - "selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。", - "selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。", - "selectAllCountOne": "选择 1 个通过安全检查的工作区", - "selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区" - }, - "scan": { - "readyOne": "找到 1 个工作区。", - "readyMany": "找到 {{value0}} 个工作区。" - }, - "presentationFixtures": { - "reviewAlphaCleanup": "评审 Alpha 清理" - }, - "presentation": { - "gitlabMergeRequestNumber": "MR #{{value0}}", - "githubPullRequestNumber": "PR #{{value0}}" - }, "browse": { "gitStatusCheckFailed": "Git 状态检查失败", "gitStatusUnverified": "无法验证 Git 状态", "deleteAnyway": "仍然删除", "forceDeleteProjectionOne": "当前有 {{count}} 个工作区显示风险,可能需要强制删除", "forceDeleteProjectionMany": "当前有 {{count}} 个工作区显示风险,可能需要强制删除", + "selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。", + "selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。", + "selectAllCountOne": "选择 1 个通过安全检查的工作区", + "selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区", "appliedFilters": "已应用的筛选", "removeFilter": "移除筛选 {{value0}}", "chip": { @@ -14771,6 +14758,17 @@ "retainedAgents": "已完成的代理" } } + }, + "scan": { + "readyOne": "找到 1 个工作区。", + "readyMany": "找到 {{value0}} 个工作区。" + }, + "presentationFixtures": { + "reviewAlphaCleanup": "评审 Alpha 清理" + }, + "presentation": { + "gitlabMergeRequestNumber": "MR #{{value0}}", + "githubPullRequestNumber": "PR #{{value0}}" } } }, diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts new file mode 100644 index 00000000000..1e6cd961d98 --- /dev/null +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { activateAiVaultStructuredSession } from './activate-ai-vault-structured-session' + +const structuredSession = { + structuredSession: { sessionId: 'session-1', workspaceId: 'workspace-1' } +} as AiVaultSession + +describe('activateAiVaultStructuredSession', () => { + it('refreshes an unpublished structured tab before activating it', async () => { + const activate = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true) + const refresh = vi.fn(async () => undefined) + const unavailable = vi.fn() + + await expect( + activateAiVaultStructuredSession(structuredSession, { activate, refresh, unavailable }) + ).resolves.toBe(true) + + expect(refresh).toHaveBeenCalledWith('workspace-1') + expect(activate).toHaveBeenCalledTimes(2) + expect(unavailable).not.toHaveBeenCalled() + }) + + it('surfaces a retryable state when the structured tab remains unavailable', async () => { + const activate = vi.fn(() => false) + const refresh = vi.fn(async () => undefined) + const unavailable = vi.fn() + + await expect( + activateAiVaultStructuredSession(structuredSession, { activate, refresh, unavailable }) + ).resolves.toBe(true) + + expect(activate).toHaveBeenCalledTimes(2) + expect(unavailable).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/activate-ai-vault-structured-session.ts b/src/renderer/src/lib/activate-ai-vault-structured-session.ts new file mode 100644 index 00000000000..5fc0684458b --- /dev/null +++ b/src/renderer/src/lib/activate-ai-vault-structured-session.ts @@ -0,0 +1,95 @@ +import { toast } from 'sonner' +import type { AiVaultSession } from '../../../shared/ai-vault-types' +import { translate } from '@/i18n/i18n' +import { activateAndRevealWorktree } from './worktree-activation' +import { activateStructuredAgentSessionById } from './structured-agent-session-tab-activation' +import { useAppStore } from '@/store' +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync' + +const STRUCTURED_SESSION_RESTORE_TIMEOUT_MS = 5_000 + +type StructuredSessionActivationDeps = { + activate: typeof activateStructuredAgentSessionById + refresh: (worktreeId: string) => Promise + unavailable: () => void +} + +const defaultDeps: StructuredSessionActivationDeps = { + activate: activateStructuredAgentSessionById, + refresh: refreshStructuredSessionTabs, + unavailable: () => { + toast.error( + translate( + 'auto.lib.activateAiVaultStructuredSession.unavailable', + 'The structured agent session is not available yet. Retry in a moment.' + ) + ) + } +} + +export async function activateAiVaultStructuredSession( + session: AiVaultSession, + deps: StructuredSessionActivationDeps = defaultDeps +): Promise { + const structured = session.structuredSession + if (!structured) { + return false + } + const target = { worktreeId: structured.workspaceId, sessionId: structured.sessionId } + if (!deps.activate(target)) { + try { + await deps.refresh(structured.workspaceId) + } catch { + deps.unavailable() + return true + } + if (!deps.activate(target)) { + deps.unavailable() + return true + } + } + if (useAppStore.getState().activeWorktreeId !== structured.workspaceId) { + activateAndRevealWorktree(structured.workspaceId) + } + return true +} + +async function refreshStructuredSessionTabs(worktreeId: string): Promise { + const state = useAppStore.getState() + const environmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId) + const snapshot = await withStructuredSessionRestoreTimeout( + callRuntimeRpc( + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + 'session.tabs.list', + { worktree: toRuntimeWorktreeSelector(worktreeId) }, + { timeoutMs: STRUCTURED_SESSION_RESTORE_TIMEOUT_MS } + ) + ) + applyStructuredSessionTabSnapshots( + [snapshot], + environmentId ? `structured-session:${environmentId}` : undefined + ) +} + +async function withStructuredSessionRestoreTimeout(promise: Promise): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('structured_session_restore_timeout')), + STRUCTURED_SESSION_RESTORE_TIMEOUT_MS + ) + }) + ]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 4d93a29dbe3..6968a21ae1c 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -218,6 +218,23 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') }) + it('marks a seeded launch prompt failed when paste delivery rejects', async () => { + const error = new Error('prompt transport rejected') + mocks.pasteDraftWhenAgentReady.mockRejectedValue(error) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'codex', + content: 'Large generated prompt', + submit: true, + forcePaste: true + }) + ).rejects.toBe(error) + + expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + }) + it('treats native-prefill delivery as success without flagging the seeded prompt', async () => { // claude delivers via `--prefill` at launch, so paste no-ops (returns false) // when forcePaste is false — that is a native delivery, not a failure. diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index 87680462cce..864c25f3e32 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -64,10 +64,18 @@ export function deliverLaunchPromptToAgentTab(args: { forcePaste, timeoutMs, onTimeout - }).then((delivered) => { - if (shouldSeed && !delivered && !deliversViaNativePrefill) { - useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + }).then( + (delivered) => { + if (shouldSeed && !delivered && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + return delivered || deliversViaNativePrefill + }, + (error) => { + if (shouldSeed && !deliversViaNativePrefill) { + useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) + } + throw error } - return delivered || deliversViaNativePrefill - }) + ) } diff --git a/src/renderer/src/lib/ai-vault-session-drag.ts b/src/renderer/src/lib/ai-vault-session-drag.ts index 69c17c5caa6..536616fdf81 100644 --- a/src/renderer/src/lib/ai-vault-session-drag.ts +++ b/src/renderer/src/lib/ai-vault-session-drag.ts @@ -11,6 +11,7 @@ export const AI_VAULT_SESSION_DRAG_PAYLOAD_MAX_BYTES = 16 * 1024 export type AiVaultSessionDragPayload = { agent: AiVaultAgent sessionId: string + structuredSession?: { sessionId: string; workspaceId: string } title: string command: string // Why: drop targets must know where the session file lives (host vs local @@ -49,6 +50,16 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0 } +function isStructuredSession( + value: unknown +): value is NonNullable { + if (!value || typeof value !== 'object') { + return false + } + const structured = value as Record + return isNonEmptyString(structured.sessionId) && isNonEmptyString(structured.workspaceId) +} + function isStringRecord(value: unknown): value is Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -87,6 +98,7 @@ function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionD payload.version === 1 && isAiVaultAgent(payload.agent) && isNonEmptyString(payload.sessionId) && + (payload.structuredSession === undefined || isStructuredSession(payload.structuredSession)) && isNonEmptyString(payload.title) && isNonEmptyString(payload.command) && (payload.sessionFilePath === undefined || isNonEmptyString(payload.sessionFilePath)) && @@ -165,6 +177,7 @@ export function readAiVaultSessionDragData( const { agent, sessionId, + structuredSession, title, command, sessionFilePath, @@ -179,6 +192,7 @@ export function readAiVaultSessionDragData( return { agent, sessionId, + ...(structuredSession ? { structuredSession } : {}), title, command, ...(sessionFilePath ? { sessionFilePath } : {}), diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts index 2ff2d5be6ab..1a26b8b80b7 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.test.ts @@ -19,6 +19,7 @@ describe('prepareAiVaultSessionForResume', () => { expect(prepared.codexHome).toBeNull() expect(prepareSessionResume).toHaveBeenCalledWith({ agent: 'codex', + sessionId: legacy.sessionId, filePath: legacy.filePath, codexHome: legacy.codexHome, executionHostId: 'local' @@ -55,6 +56,7 @@ describe('prepareAiVaultSessionForResume', () => { expect(prepared.codexHome).toBe('/tmp/orca/codex-accounts/account-2/home') expect(prepareSessionResume).toHaveBeenCalledWith({ agent: 'codex', + sessionId: current.sessionId, filePath: current.filePath, codexHome: current.codexHome, executionHostId: 'local' diff --git a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts index fc78dbed0c1..9c7fa8740ff 100644 --- a/src/renderer/src/lib/ai-vault-session-resume-preparation.ts +++ b/src/renderer/src/lib/ai-vault-session-resume-preparation.ts @@ -8,11 +8,12 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' export async function prepareAiVaultSessionForResume( session: AiVaultSession ): Promise { - if (!aiVaultSessionNeedsResumePreparation(session)) { + if (!session.structuredSession && !aiVaultSessionNeedsResumePreparation(session)) { return session } const result = await window.api.aiVault.prepareSessionResume({ agent: session.agent, + sessionId: session.sessionId, filePath: session.filePath, codexHome: session.codexHome, executionHostId: session.executionHostId diff --git a/src/renderer/src/lib/browser-palette-page-entries.ts b/src/renderer/src/lib/browser-palette-page-entries.ts index a52c743e7b8..93b5d71f442 100644 --- a/src/renderer/src/lib/browser-palette-page-entries.ts +++ b/src/renderer/src/lib/browser-palette-page-entries.ts @@ -1,6 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' -import type { Tab } from '../../../shared/tab-types' +import type { Tab, WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import type { ExecutionHostId } from '../../../shared/execution-host' import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' @@ -14,7 +14,7 @@ import { isUnifiedTabOwnedByWorktree } from './unified-tab-host-ownership' -type BrowserPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' +type BrowserPaletteActiveTabType = WorkspaceVisibleTabType export type BuildSearchableBrowserPagesOptions = { worktrees: readonly Worktree[] diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 5f9ce256885..979823f7f4b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -5,6 +5,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' const mockCreateTab = vi.fn() const mockQueueTabStartupCommand = vi.fn() const mockSetActiveTabType = vi.fn() +const mockSetTabViewMode = vi.fn() const mockSetTabBarOrder = vi.fn() const mockSetAgentStatus = vi.fn() const mockPasteDraftWhenAgentReady = vi.fn() @@ -13,6 +14,7 @@ const mockSeedNativeChatLaunchDraft = vi.fn() const mockMarkNativeChatLaunchPromptFailed = vi.fn() const mockTrack = vi.fn() const mockToastMessage = vi.fn() +const mockWaitForAgentReady = vi.fn() const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -31,6 +33,7 @@ const store = { activeRuntimeEnvironmentId: string | null terminalWindowsShell?: string experimentalNativeChat?: boolean + experimentalStructuredNativeChat?: boolean openAgentTabsInChatByDefault?: boolean nativeChatSessionOptions?: Record< string, @@ -79,6 +82,7 @@ const store = { closeTab: vi.fn(), queueTabStartupCommand: mockQueueTabStartupCommand, setActiveTabType: mockSetActiveTabType, + setTabViewMode: mockSetTabViewMode, setTabBarOrder: mockSetTabBarOrder, setAgentStatus: mockSetAgentStatus, seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, @@ -112,6 +116,10 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) +vi.mock('@/lib/agent-ready-wait', () => ({ + waitForAgentReady: mockWaitForAgentReady +})) + vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent @@ -171,6 +179,7 @@ describe('launchAgentInNewTab', () => { store.ptyIdsByTabId = {} mockCreateTab.mockReturnValue({ id: 'tab-1' }) mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) }) it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { @@ -185,7 +194,6 @@ describe('launchAgentInNewTab', () => { launchAgent: 'codex' }) }) - it('keeps Floating Workspace authority on native Windows beside an active WSL project', async () => { store.projects = [ { @@ -212,13 +220,14 @@ describe('launchAgentInNewTab', () => { ) }) - it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { + it('keeps prompted Codex launches on the ordinary terminal path', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -246,6 +255,7 @@ describe('launchAgentInNewTab', () => { text: 'large generated prompt', createdAt: expect.any(Number) }) + expect(mockSetTabViewMode).not.toHaveBeenCalled() }) it('opens local Grok submit-after-ready launches in native chat', async () => { @@ -255,6 +265,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -286,6 +297,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }] @@ -306,6 +318,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -317,8 +330,7 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'draft' }) - // Claude takes the draft on --prefill, so no paste runs and - // deliverLaunchPromptToAgentTab never fires — this is the only seed. + // Claude's --prefill launch seeds the draft without a paste callback. expect(result?.pasteDraftAfterLaunch).toBe(false) expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ @@ -327,12 +339,7 @@ describe('launchAgentInNewTab', () => { text: 'https://github.com/o/r/issues/12' }) ) - expect(mockCreateTab).toHaveBeenCalledWith( - 'wt-1', - undefined, - undefined, - expect.objectContaining({ viewMode: 'chat' }) - ) + expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') }) it('mirrors a multi-line draft into chat and opens the tab there', async () => { @@ -342,6 +349,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -357,12 +365,7 @@ describe('launchAgentInNewTab', () => { expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt }) ) - expect(mockCreateTab).toHaveBeenCalledWith( - 'wt-1', - undefined, - undefined, - expect.objectContaining({ viewMode: 'chat' }) - ) + expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') }) it('passes quick command labels only to locally-created agent tabs', async () => { @@ -387,6 +390,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: false, nativeChatSessionOptions: { codex: { @@ -419,6 +423,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { codex: { @@ -447,6 +452,7 @@ describe('launchAgentInNewTab', () => { undefined, expect.objectContaining({ viewMode: 'chat' }) ) + expect(mockSetTabViewMode).not.toHaveBeenCalled() }) it('preserves paired-host draft delivery and supported launch preferences', async () => { @@ -457,6 +463,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true, nativeChatSessionOptions: { claude: { @@ -476,8 +483,6 @@ describe('launchAgentInNewTab', () => { }) expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) - // The draft rides in on the launch command, so this host-class launch also - // carries the text that seeds the mirrored tab's chat composer. expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ launchAgent: 'claude', @@ -501,6 +506,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -526,6 +532,7 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: 'web-runtime', experimentalNativeChat: true, + experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: false } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -599,8 +606,6 @@ describe('launchAgentInNewTab', () => { prompt: 'fix the spinner', launchSource: 'onboarding' }) - - expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) it('does not track prompt-sent for draft launches', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d46cb530bd6..b6cbbb736d8 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -29,6 +29,8 @@ import type { LaunchSource } from '../../../shared/telemetry-events' import { getConnectionIdFromState } from '@/lib/connection-context' import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat/native-chat-launch-session-options' import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' +import { canUseStructuredNativeChat } from '@/lib/structured-native-chat-availability' +import { startStructuredCodexLaunch } from '@/lib/structured-agent-session-launch' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -56,9 +58,17 @@ export type LaunchAgentInNewTabResult = { tabId: string | null startupPlan: AgentStartupPlan pasteDraftAfterLaunch: boolean + /** The host will publish and focus a structured tab asynchronously. */ + focusAfterMenuClose?: 'structured-session' promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> } | null +export function shouldQueueTerminalFocusAfterMenuClose( + result: NonNullable +): boolean { + return result.tabId === null && result.focusAfterMenuClose !== 'structured-session' +} + /** * Create a new terminal tab and queue the agent's launch command, optionally * with an initial prompt. @@ -173,6 +183,21 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } } + const launchDirectStructuredChat = + agent === 'codex' && + !hasPrompt && + store.settings?.experimentalNativeChat === true && + canUseStructuredNativeChat(store, worktreeId) + if (launchDirectStructuredChat) { + startStructuredCodexLaunch(worktreeId) + return { + tabId: null, + startupPlan, + pasteDraftAfterLaunch: false, + focusAfterMenuClose: 'structured-session' + } + } + // Why: queue startup BEFORE TerminalPane mounts — it snapshots pendingStartupByTabId in useState on first render. // Why: followup path pastes an unsubmitted draft, so gate the initial chat view like a draft launch, not auto-submit. const tab = store.createTab(worktreeId, groupId, undefined, { diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts new file mode 100644 index 00000000000..c9d63359267 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -0,0 +1,321 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockCreateTab = vi.fn() +const mockSetTabViewMode = vi.fn() +const mockWaitForAgentReady = vi.fn() +const mockPasteDraftWhenAgentReady = vi.fn() +const mockMarkNativeChatLaunchPromptFailed = vi.fn() +const mockCreateStructuredCodexSessionLaunchIntent = vi.fn() +const mockLaunchStructuredCodexSession = vi.fn() +const mockRefreshLocalStructuredSessionTabs = vi.fn() +const mockToastError = vi.fn() + +function structuredLaunchIntent(worktreeId: string, sessionId = 'codex-session-1') { + return { + sessionId, + worktreeId, + params: { + envelope: { + sessionId, + clientOperationId: `operation-${sessionId}`, + expectedRuntimeFence: null, + payloadFingerprint: 'f'.repeat(64) + }, + worktree: `id:${worktreeId}`, + agent: 'codex' as const + } + } +} + +const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: true + }, + projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } }], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map(), + transientClearedAgentStatusConnectionIds: {}, + worktreesByRepo: { + 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', projectId: 'repo-1', path: '/repo/worktree' }] + }, + detectedWorktreesByRepo: {}, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), + tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + createTab: mockCreateTab, + closeTab: vi.fn(), + queueTabStartupCommand: vi.fn(), + setActiveTabType: vi.fn(), + setTabViewMode: mockSetTabViewMode, + setTabBarOrder: vi.fn(), + setAgentStatus: vi.fn(), + seedNativeChatLaunchPrompt: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed +} + +vi.mock('@/store', () => ({ useAppStore: { getState: () => store } })) +vi.mock('sonner', () => ({ toast: { message: vi.fn(), error: mockToastError } })) +vi.mock('@/components/tab-bar/reconcile-order', () => ({ reconcileTabOrder: vi.fn(() => []) })) +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) +vi.mock('@/lib/agent-ready-wait', () => ({ waitForAgentReady: mockWaitForAgentReady })) +vi.mock('@/lib/telemetry', () => ({ + track: vi.fn(), + tuiAgentToAgentKind: (agent: string) => agent +})) +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: vi.fn(), + createWebRuntimeAgentSessionTerminalWithLaunchDraft: vi.fn(), + isWebRuntimeSessionActive: vi.fn(() => false), + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) +vi.mock('@/lib/launch-structured-codex-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredCodexSessionLaunchIntent: mockCreateStructuredCodexSessionLaunchIntent, + launchStructuredCodexSession: mockLaunchStructuredCodexSession, + StructuredAgentSessionCreateRefusalError + } +}) +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: mockRefreshLocalStructuredSessionTabs, + LOCAL_STRUCTURED_SESSION_OWNER: 'local-structured-session' +})) + +/** Structured adoption creates the tab in terminal mode and flips it to chat once + * Codex is ready; the bridge stamps `viewMode: 'chat'` on the tab up front. That + * difference is the only observable signal that the availability guard ran. */ +describe('structured chat adoption guard on the launch path', () => { + beforeEach(() => { + vi.clearAllMocks() + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + mockPasteDraftWhenAgentReady.mockResolvedValue(true) + mockCreateStructuredCodexSessionLaunchIntent.mockImplementation((worktreeId: string) => + structuredLaunchIntent(worktreeId) + ) + mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + mockRefreshLocalStructuredSessionTabs.mockResolvedValue([ + { + worktree: 'wt-1', + tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] + } + ]) + mockToastError.mockReset() + store.settings.openAgentTabsInChatByDefault = true + }) + + it('takes the structured path when the chat-default view is selected', async () => { + const { launchAgentInNewTab, shouldQueueTerminalFocusAfterMenuClose } = + await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result).toMatchObject({ + tabId: null, + pasteDraftAfterLaunch: false, + focusAfterMenuClose: 'structured-session' + }) + expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false) + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1') + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'wt-1' }) + ) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) + + /** The toggle is hidden under Terminal chat but its persisted value survives, so the launch + * path must re-check the default view rather than trust a stale opt-in. */ + it('ignores a stale structured opt-in while the default view is Terminal chat', async () => { + store.settings.openAgentTabsInChatByDefault = false + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result?.tabId).toBe('tab-1') + expect(mockLaunchStructuredCodexSession).not.toHaveBeenCalled() + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + }) + + it('surfaces a direct structured launch failure instead of silently doing nothing', async () => { + const { StructuredAgentSessionCreateRefusalError } = + await import('./launch-structured-codex-session') + mockLaunchStructuredCodexSession.mockRejectedValueOnce( + new StructuredAgentSessionCreateRefusalError('provider unavailable') + ) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(result).toMatchObject({ tabId: null, pasteDraftAfterLaunch: false }) + await vi.waitFor(() => + expect(mockToastError).toHaveBeenCalledWith( + 'Could not open Codex chat', + expect.objectContaining({ description: 'provider unavailable' }) + ) + ) + expect(mockCreateTab).not.toHaveBeenCalled() + }) + + it('coalesces repeated structured launches for one worktree while the host is starting', async () => { + let resolveLaunch!: (sessionId: string) => void + mockLaunchStructuredCodexSession.mockImplementationOnce( + () => new Promise((resolve) => (resolveLaunch = resolve)) + ) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const first = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + const second = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(first).toMatchObject({ focusAfterMenuClose: 'structured-session' }) + expect(second).toMatchObject({ focusAfterMenuClose: 'structured-session' }) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + resolveLaunch('codex-session-1') + }) + + it('keeps the single-flight reservation until the published tab inventory is refreshed', async () => { + let resolveRefresh!: (snapshots: unknown[]) => void + mockRefreshLocalStructuredSessionTabs.mockImplementationOnce( + () => new Promise((resolve) => (resolveRefresh = resolve)) + ) + mockLaunchStructuredCodexSession.mockResolvedValue('codex-session-1') + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(1)) + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(1) + resolveRefresh([ + { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] } + ]) + await vi.waitFor(() => expect(mockToastError).not.toHaveBeenCalled()) + }) + + it('does not create a sibling when post-create visibility proof is unknown', async () => { + const firstIntent = structuredLaunchIntent('wt-1', 'codex-session-1') + const secondIntent = structuredLaunchIntent('wt-1', 'codex-session-2') + mockCreateStructuredCodexSessionLaunchIntent + .mockReturnValueOnce(firstIntent) + .mockReturnValueOnce(secondIntent) + mockLaunchStructuredCodexSession + .mockResolvedValueOnce(firstIntent.sessionId) + .mockRejectedValueOnce(new Error('response lost')) + .mockResolvedValueOnce(secondIntent.sessionId) + mockRefreshLocalStructuredSessionTabs + .mockRejectedValueOnce(new Error('inventory unavailable')) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-1' }] } + ]) + .mockResolvedValueOnce([ + { worktree: 'wt-1', tabs: [{ type: 'agent-session', sessionId: 'codex-session-2' }] } + ]) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockToastError).toHaveBeenCalledTimes(1)) + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(3)) + + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(1) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(2) + expect(mockLaunchStructuredCodexSession.mock.calls[0]?.[0]).toBe(firstIntent) + expect(mockLaunchStructuredCodexSession.mock.calls[1]?.[0]).toBe(firstIntent) + await new Promise((resolve) => setTimeout(resolve, 0)) + + // A successful retry must release the reservation so a later launch can start normally. + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + await vi.waitFor(() => expect(mockRefreshLocalStructuredSessionTabs).toHaveBeenCalledTimes(4)) + expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledTimes(2) + expect(mockLaunchStructuredCodexSession).toHaveBeenCalledTimes(3) + expect(mockLaunchStructuredCodexSession.mock.calls[2]?.[0]).toBe(secondIntent) + }) + + it('keeps prompted Codex on the ordinary terminal launch path', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'start this task' + }) + + expect(result?.tabId).toBe('tab-1') + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ launchAgent: 'codex' }) + ) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + expect(mockSetTabViewMode).not.toHaveBeenCalled() + }) + + it('shows rejected prompt delivery in chat after Codex becomes ready', async () => { + const error = new Error('prompt transport rejected') + mockPasteDraftWhenAgentReady.mockRejectedValue(error) + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'large generated prompt', + promptDelivery: 'submit-after-ready' + }) + + await expect(result?.promptDeliveryResult).rejects.toBe(error) + expect(mockMarkNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') + expect(mockSetTabViewMode).not.toHaveBeenCalled() + }) + + it('keeps an SSH Codex tab on the bridge', async () => { + store.repos = [{ id: 'repo-1', connectionId: 'ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) + + it('keeps a runtime-paired Codex tab on the bridge', async () => { + store.repos = [{ id: 'repo-1', connectionId: 'runtime-ssh-a', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'codex', + viewMode: 'chat' + }) + expect(mockWaitForAgentReady).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.test.ts b/src/renderer/src/lib/launch-structured-codex-session.test.ts new file mode 100644 index 00000000000..94bc0b04b0e --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + createStructuredCodexSessionLaunchIntent, + launchStructuredCodexSession +} from './launch-structured-codex-session' + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn() +})) + +describe('structured Codex launch', () => { + beforeEach(() => { + vi.mocked(callStructuredAgentSession).mockReset() + }) + + it('creates a native session with a host-verifiable launch intent', async () => { + vi.mocked(callStructuredAgentSession).mockImplementation(async (_target, _method, params) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 0 }, + value: { + sessionId: (params as { envelope: { sessionId: string } }).envelope.sessionId, + fence: 1, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [], + window: { + oldest: null, + newest: null, + nextCursor: { epoch: 'epoch-1', sequence: 0 } + }, + liveCursor: { epoch: 'epoch-1', sequence: 0 }, + hasOlder: false, + hasNewer: false + }, + unconfirmedClientMessageIds: [] + } + })) + + const intent = createStructuredCodexSessionLaunchIntent('workspace-1') + const sessionId = await launchStructuredCodexSession(intent) + const params = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] as { + envelope: { sessionId: string; payloadFingerprint: string } + worktree: string + agent: 'codex' + } + + expect(sessionId).toMatch(/^codex_[A-Za-z0-9_]{36}$/) + expect(callStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.create', + expect.objectContaining({ worktree: 'id:workspace-1', agent: 'codex' }) + ) + expect(params.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: params.envelope.sessionId, + fields: { worktree: 'id:workspace-1', agent: 'codex' } + }) + ) + expect(params).toBe(intent.params) + }) + + it('replays the exact create envelope when an unknown outcome is retried', async () => { + const intent = createStructuredCodexSessionLaunchIntent('workspace-retry') + vi.mocked(callStructuredAgentSession).mockRejectedValue(new Error('response lost')) + + await expect(launchStructuredCodexSession(intent)).rejects.toThrow('response lost') + await expect(launchStructuredCodexSession(intent)).rejects.toThrow('response lost') + + const first = vi.mocked(callStructuredAgentSession).mock.calls[0]?.[2] + const second = vi.mocked(callStructuredAgentSession).mock.calls[1]?.[2] + expect(first).toBe(intent.params) + expect(second).toBe(first) + expect(intent.params.envelope.clientOperationId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + }) +}) diff --git a/src/renderer/src/lib/launch-structured-codex-session.ts b/src/renderer/src/lib/launch-structured-codex-session.ts new file mode 100644 index 00000000000..b4deb731c28 --- /dev/null +++ b/src/renderer/src/lib/launch-structured-codex-session.ts @@ -0,0 +1,87 @@ +import type { + AgentSessionAttachResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + createStructuredAgentSessionOperationId, + structuredAgentSessionPayloadFingerprint +} from '../../../shared/structured-agent-session-mutation' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { useAppStore } from '@/store' +import { + clearWebSessionFocusIntentIfMatches, + recordWebSessionFocusIntent, + resolveWebSessionVisibleTabId +} from '@/runtime/web-session-focus-intent' +import { LOCAL_STRUCTURED_SESSION_OWNER } from '@/runtime/local-structured-session-tabs-sync' + +type StructuredAgentSessionCreateParams = { + envelope: AgentSessionMutationEnvelope + worktree: string + agent: 'codex' +} + +export type StructuredAgentSessionLaunchIntent = { + sessionId: string + worktreeId: string + params: StructuredAgentSessionCreateParams +} + +export class StructuredAgentSessionCreateRefusalError extends Error {} + +export function createStructuredCodexSessionLaunchIntent( + worktreeId: string +): StructuredAgentSessionLaunchIntent { + const sessionId = `codex_${crypto.randomUUID().replaceAll('-', '_')}` + const fields = { worktree: toRuntimeWorktreeSelector(worktreeId), agent: 'codex' as const } + const state = useAppStore.getState() + recordWebSessionFocusIntent( + { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + worktreeId, + `agent-session:${sessionId}`, + undefined, + resolveWebSessionVisibleTabId(state, worktreeId) + ) + return { + sessionId, + worktreeId, + params: { + envelope: { + sessionId, + clientOperationId: createStructuredAgentSessionOperationId(() => crypto.randomUUID()), + expectedRuntimeFence: null, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId, + fields + }) + }, + ...fields + } + } +} + +export function abandonStructuredAgentSessionLaunchIntent( + intent: StructuredAgentSessionLaunchIntent +): void { + clearWebSessionFocusIntentIfMatches( + { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, + intent.worktreeId, + `agent-session:${intent.sessionId}` + ) +} + +export async function launchStructuredCodexSession( + intent: StructuredAgentSessionLaunchIntent +): Promise { + const result = await callStructuredAgentSession< + AgentSessionMutationResult + >({ kind: 'local' }, 'agentSession.create', intent.params) + if (!result.ok) { + abandonStructuredAgentSessionLaunchIntent(intent) + throw new StructuredAgentSessionCreateRefusalError(result.refusal.message) + } + return result.value.sessionId +} diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts index 195fa721812..9b8cb76519c 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session-replay.test.ts @@ -34,6 +34,48 @@ function makeTerminalTab(id: string): Record { } describe('resumeSleepingAgentSessionsForWorktree replay protection', () => { + it('publishes the resume command and ownership claim with the first visible tab state', () => { + const record = makeRecord() + useAppStore.setState({ + tabsByWorktree: { 'wt-1': [] }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + let firstVisible: + | { + command: string | undefined + claim: unknown + layout: ReturnType['terminalLayoutsByTabId'][string] + } + | undefined + const unsubscribe = useAppStore.subscribe((state) => { + const tab = state.tabsByWorktree['wt-1']?.[0] + if (tab && !firstVisible) { + firstVisible = { + command: state.pendingStartupByTabId[tab.id]?.command, + claim: state.automaticAgentResumeClaimsByTabId[tab.id], + layout: state.terminalLayoutsByTabId[tab.id] + } + } + }) + + expect(resumeSleepingAgentSessionsForWorktree('wt-1')).toBe(1) + unsubscribe() + + expect(firstVisible?.command).toContain('resume') + expect(firstVisible?.command).toContain(record.providerSession.id) + expect(firstVisible?.claim).toEqual({ + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }) + expect(firstVisible?.layout).toMatchObject({ + root: { type: 'leaf', leafId: expect.any(String) }, + activeLeafId: expect.any(String) + }) + const root = firstVisible?.layout?.root + expect(firstVisible?.layout?.activeLeafId).toBe(root?.type === 'leaf' ? root.leafId : undefined) + }) + it('stores provider-session metadata in the queued startup and runtime claim', () => { const record = makeRecord() useAppStore.setState({ diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 1865dc747af..94dc52bc7c6 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -13,6 +13,7 @@ import { launchSleepingAgentSession, type ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch' +import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' import { findUnhydratedHostMirrorForPane } from './host-mirrored-pane-liveness' import { resolveWorkspaceTerminalHostAuthority } from './workspace-terminal-host-authority' import { parkUntilHostSessionMirrorHydrates } from '@/runtime/host-session-mirror-hydration' @@ -136,6 +137,9 @@ function activeOrQueuedResumeClaimsProviderSession( // Why: an interrupted turn is still resumable — `claude --resume` reopens the transcript at the // prompt — so discarding those records only stranded the session across wake and restart. function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { + if (isStructuredAgentSyntheticSleepingRecord(record)) { + return true + } if (!record.origin && record.state === 'done') { return true } diff --git a/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts b/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts new file mode 100644 index 00000000000..db16786d27a --- /dev/null +++ b/src/renderer/src/lib/resume-stale-structured-agent-session.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() +const WORKTREE_ID = 'stale-structured-worktree' +const SESSION_ID = 'structured-session-stale' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +describe('stale structured sleeping session', () => { + it('clears the synthetic terminal projection without spawning', () => { + const tabId = structuredAgentSessionTabId(SESSION_ID) + const paneKey = makePaneKey(tabId, LEAF_ID) + const record: SleepingAgentSessionRecord = { + paneKey, + tabId, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: SESSION_ID }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(useAppStore.getState().pendingStartupByTabId).toEqual({}) + }) +}) diff --git a/src/renderer/src/lib/simulator-palette-search.ts b/src/renderer/src/lib/simulator-palette-search.ts index 83d1b376f74..ea0e0aa2c2f 100644 --- a/src/renderer/src/lib/simulator-palette-search.ts +++ b/src/renderer/src/lib/simulator-palette-search.ts @@ -1,6 +1,6 @@ import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' import type { ExecutionHostId } from '../../../shared/execution-host' -import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' import { isPaletteCurrentWorktree, resolvePaletteRepoForWorktree } from './palette-repo-resolution' import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text' @@ -62,7 +62,7 @@ export type SimulatorPaletteSearchResult = { lastActiveAt?: number | null } -type SimulatorPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' +type SimulatorPaletteActiveTabType = WorkspaceVisibleTabType export const SIMULATOR_PALETTE_QUERY_MAX_BYTES = 2 * 1024 diff --git a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts index 9fe908050a0..59d54fbafb9 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch-windows-quoting.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' const mockCreateTab = vi.fn() -const mockQueueTabStartupCommand = vi.fn() const store = { settings: { @@ -47,7 +46,6 @@ const store = { browserTabsByWorktree: {} as Record, tabBarOrderByWorktree: {} as Record, createTab: mockCreateTab, - queueTabStartupCommand: mockQueueTabStartupCommand, claimAutomaticAgentResume: vi.fn(), clearSleepingAgentSession: vi.fn(), setActiveTabType: vi.fn(), @@ -83,10 +81,10 @@ const record: SleepingAgentSessionRecord = { async function launch(): Promise { const { launchSleepingAgentSession } = await import('./sleeping-agent-session-launch') launchSleepingAgentSession(record) - const queued = mockQueueTabStartupCommand.mock.calls.at(-1)?.[1] as - | { command: string } + const options = mockCreateTab.mock.calls.at(-1)?.[3] as + | { pendingStartup?: { command: string } } | undefined - return queued?.command + return options?.pendingStartup?.command } describe('launchSleepingAgentSession Windows shell quoting', () => { diff --git a/src/renderer/src/lib/sleeping-agent-session-launch.ts b/src/renderer/src/lib/sleeping-agent-session-launch.ts index 6d5f0d5ded2..43d7bdb3b30 100644 --- a/src/renderer/src/lib/sleeping-agent-session-launch.ts +++ b/src/renderer/src/lib/sleeping-agent-session-launch.ts @@ -100,30 +100,30 @@ export function launchSleepingAgentSession( const tab = state.createTab(record.worktreeId, undefined, undefined, { launchAgent: record.agent, + pendingStartup: { + command: startupPlan.launchCommand, + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + resumeProviderSession: record.providerSession, + launchAgent: record.agent, + ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), + ...(startupPlan.startupCommandDelivery + ? { startupCommandDelivery: startupPlan.startupCommandDelivery } + : {}), + showSessionRestoredBanner: true, + telemetry: { + agent_kind: tuiAgentToAgentKind(record.agent), + launch_source: 'sidebar', + request_kind: 'resume' + } + }, + automaticResumeClaim: { + worktreeId: record.worktreeId, + launchAgent: record.agent, + providerSession: record.providerSession + }, ...(options?.suppressNavigation ? { activate: false, recordInteraction: false } : {}) }) - state.queueTabStartupCommand(tab.id, { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - resumeProviderSession: record.providerSession, - launchAgent: record.agent, - ...(launchConfig ? { agentArgsOverride: launchConfig.agentArgs } : {}), - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - showSessionRestoredBanner: true, - telemetry: { - agent_kind: tuiAgentToAgentKind(record.agent), - launch_source: 'sidebar', - request_kind: 'resume' - } - }) - state.claimAutomaticAgentResume(tab.id, { - worktreeId: record.worktreeId, - launchAgent: record.agent, - providerSession: record.providerSession - }) state.clearSleepingAgentSession(record.paneKey) if (!options?.suppressNavigation) { state.setActiveTabType('terminal') diff --git a/src/renderer/src/lib/structured-agent-session-launch.test.ts b/src/renderer/src/lib/structured-agent-session-launch.test.ts new file mode 100644 index 00000000000..d345171cf0d --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' + +const mocks = vi.hoisted(() => ({ + createIntent: vi.fn(), + launch: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + message: vi.fn() + } +})) + +vi.mock('@/lib/launch-structured-codex-session', () => { + class StructuredAgentSessionCreateRefusalError extends Error {} + return { + createStructuredCodexSessionLaunchIntent: mocks.createIntent, + launchStructuredCodexSession: mocks.launch, + StructuredAgentSessionCreateRefusalError + } +}) + +vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({ + refreshLocalStructuredSessionTabs: vi.fn() +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +import { + StructuredAgentSessionCreateRefusalError, + type StructuredAgentSessionLaunchIntent +} from '@/lib/launch-structured-codex-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { startStructuredCodexLaunch } from './structured-agent-session-launch' + +function launchIntent( + worktreeId: string, + sessionId = `session-${worktreeId}` +): StructuredAgentSessionLaunchIntent { + return { + worktreeId, + sessionId, + params: { + envelope: { + sessionId, + clientOperationId: `operation-${sessionId}`, + expectedRuntimeFence: null, + payloadFingerprint: `fingerprint-${sessionId}` + }, + worktree: `id:${worktreeId}`, + agent: 'codex' + } + } +} + +function publishedSnapshot(worktreeId: string, sessionId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'tab-1', + title: 'Codex', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} + +async function flushLaunchSettlement(): Promise { + for (let i = 0; i < 20; i += 1) { + await Promise.resolve() + } +} + +describe('startStructuredCodexLaunch', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.createIntent.mockImplementation((worktreeId: string) => launchIntent(worktreeId)) + }) + + it('opens the chat without an informational progress toast', async () => { + const worktreeId = 'wt-open-quiet' + const intent = launchIntent(worktreeId, 'session-1') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue(intent.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.launch).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledWith(intent) + expect(toast.message).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('coalesces a duplicate click silently while the launch is in flight', async () => { + const worktreeId = 'wt-duplicate-click' + const intent = launchIntent(worktreeId) + let resolveLaunch: (sessionId: string) => void = () => {} + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockImplementation( + () => new Promise((resolve) => (resolveLaunch = resolve)) + ) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + startStructuredCodexLaunch(worktreeId) + + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + resolveLaunch(intent.sessionId) + await flushLaunchSettlement() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reconciles a host commit when the create reply is lost', async () => { + const worktreeId = 'wt-response-loss' + const intent = launchIntent(worktreeId) + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockRejectedValueOnce(new Error('response lost')) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('retries an absent unknown outcome with the exact same intent', async () => { + const worktreeId = 'wt-same-envelope-retry' + const intent = launchIntent(worktreeId) + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch + .mockRejectedValueOnce(new Error('response lost')) + .mockResolvedValueOnce(intent.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([publishedSnapshot(worktreeId, intent.sessionId)]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.launch).toHaveBeenCalledTimes(2) + expect(mocks.launch.mock.calls[0]?.[0]).toBe(intent) + expect(mocks.launch.mock.calls[1]?.[0]).toBe(intent) + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(toast.error).not.toHaveBeenCalled() + }) + + it('keeps an unresolved identity reserved until inventory reconciles it', async () => { + const worktreeId = 'wt-still-unknown' + const intent = launchIntent(worktreeId) + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockRejectedValue(new Error('offline')) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + expect(toast.error).toHaveBeenCalledOnce() + + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, intent.sessionId) + ]) + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.createIntent).toHaveBeenCalledOnce() + expect(mocks.launch).toHaveBeenCalledTimes(2) + expect(toast.error).toHaveBeenCalledOnce() + }) + + it('releases a definitively refused intent so a new click can create a new identity', async () => { + const worktreeId = 'wt-refused' + const first = launchIntent(worktreeId, 'session-first') + const second = launchIntent(worktreeId, 'session-second') + mocks.createIntent.mockReturnValueOnce(first).mockReturnValueOnce(second) + mocks.launch + .mockRejectedValueOnce(new StructuredAgentSessionCreateRefusalError('unsupported')) + .mockResolvedValueOnce(second.sessionId) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot(worktreeId, second.sessionId) + ]) + + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + startStructuredCodexLaunch(worktreeId) + await flushLaunchSettlement() + + expect(mocks.createIntent).toHaveBeenCalledTimes(2) + expect(mocks.launch.mock.calls[0]?.[0]).toBe(first) + expect(mocks.launch.mock.calls[1]?.[0]).toBe(second) + expect(toast.error).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts new file mode 100644 index 00000000000..f826ab58cee --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -0,0 +1,136 @@ +import { toast } from 'sonner' +import { + createStructuredCodexSessionLaunchIntent, + launchStructuredCodexSession, + StructuredAgentSessionCreateRefusalError, + type StructuredAgentSessionLaunchIntent +} from '@/lib/launch-structured-codex-session' +import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync' +import { translate } from '@/i18n/i18n' + +type StructuredLaunchState = { + intent: StructuredAgentSessionLaunchIntent + promise: Promise + visibilityUnknown: boolean +} + +const pendingStructuredLaunchesByWorktree = new Map() + +function trackLaunchSettlement( + worktreeId: string, + state: StructuredLaunchState, + promise: Promise +): void { + void promise.then( + () => { + if ( + state.promise === promise && + pendingStructuredLaunchesByWorktree.get(worktreeId) === state + ) { + pendingStructuredLaunchesByWorktree.delete(worktreeId) + } + }, + () => { + if ( + state.promise === promise && + !state.visibilityUnknown && + pendingStructuredLaunchesByWorktree.get(worktreeId) === state + ) { + pendingStructuredLaunchesByWorktree.delete(worktreeId) + } + } + ) +} + +async function verifyPublishedSession(intent: StructuredAgentSessionLaunchIntent): Promise { + const snapshots = await refreshLocalStructuredSessionTabs() + const published = snapshots.some( + (snapshot) => + snapshot.worktree === intent.worktreeId && + snapshot.tabs.some( + (tab) => tab.type === 'agent-session' && tab.sessionId === intent.sessionId + ) + ) + if (!published) { + throw new Error('structured session tab publication unavailable') + } + return intent.sessionId +} + +async function retrySameIntent(state: StructuredLaunchState, priorError: unknown): Promise { + try { + await launchStructuredCodexSession(state.intent) + return await verifyPublishedSession(state.intent) + } catch (error) { + if (error instanceof StructuredAgentSessionCreateRefusalError) { + throw error + } + try { + return await verifyPublishedSession(state.intent) + } catch { + state.visibilityUnknown = true + throw error ?? priorError + } + } +} + +async function launchAndReconcile(state: StructuredLaunchState): Promise { + try { + await launchStructuredCodexSession(state.intent) + } catch (error) { + if (error instanceof StructuredAgentSessionCreateRefusalError) { + throw error + } + try { + return await verifyPublishedSession(state.intent) + } catch { + return retrySameIntent(state, error) + } + } + try { + return await verifyPublishedSession(state.intent) + } catch (error) { + return retrySameIntent(state, error) + } +} + +async function reconcileUnknownLaunch(state: StructuredLaunchState): Promise { + state.visibilityUnknown = false + try { + return await verifyPublishedSession(state.intent) + } catch (error) { + return retrySameIntent(state, error) + } +} + +function launchStructuredCodexSessionOnce(worktreeId: string): Promise { + const existing = pendingStructuredLaunchesByWorktree.get(worktreeId) + if (existing) { + if (existing.visibilityUnknown) { + existing.promise = reconcileUnknownLaunch(existing) + trackLaunchSettlement(worktreeId, existing, existing.promise) + } + return existing.promise + } + const state: StructuredLaunchState = { + intent: createStructuredCodexSessionLaunchIntent(worktreeId), + promise: Promise.resolve(''), + visibilityUnknown: false + } + state.promise = launchAndReconcile(state) + pendingStructuredLaunchesByWorktree.set(worktreeId, state) + trackLaunchSettlement(worktreeId, state, state.promise) + return state.promise +} + +export function startStructuredCodexLaunch(worktreeId: string): void { + void launchStructuredCodexSessionOnce(worktreeId).catch((error) => { + toast.error( + translate( + 'components.native-chat.structuredSessionLaunchFailed', + 'Could not open Codex chat' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts new file mode 100644 index 00000000000..94261ccdf5e --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab } from '../../../shared/tab-types' + +const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), + callRuntimeRpc: vi.fn(async () => ({ ok: true })), + focusGroup: vi.fn(), + setActiveTabType: vi.fn(), + state: { unifiedTabsByWorktree: {} } as Record +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mocks.state } +})) + +vi.mock('./worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => 'env-1' +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mocks.callRuntimeRpc, + getActiveRuntimeTarget: ({ + activeRuntimeEnvironmentId + }: { + activeRuntimeEnvironmentId: string + }) => ({ kind: 'environment', environmentId: activeRuntimeEnvironmentId }) +})) + +vi.mock('@/runtime/runtime-worktree-selector', () => ({ + toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` +})) + +import { + activateStructuredAgentSessionById, + activateStructuredAgentSessionTab +} from './structured-agent-session-tab-activation' + +describe('activateStructuredAgentSessionTab', () => { + beforeEach(() => { + vi.clearAllMocks() + const tab = { + id: 'structured-tab-1', + worktreeId: 'wt-1', + groupId: 'group-1', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + mocks.state = { + unifiedTabsByWorktree: { 'wt-1': [tab] }, + focusGroup: mocks.focusGroup, + activateTab: mocks.activateTab, + setActiveTabType: mocks.setActiveTabType + } + }) + + it('selects the unified tab and synchronizes host focus', () => { + expect( + activateStructuredAgentSessionTab({ worktreeId: 'wt-1', tabId: 'structured-tab-1' }) + ).toBe(true) + + expect(mocks.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1') + expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) + expect(mocks.setActiveTabType).toHaveBeenCalledWith('agent-session') + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'session.tabs.activate', + { worktree: 'id:wt-1', tabId: 'agent-session:session-1' } + ) + }) + + it('routes a provider-owned vault row through its structured session id', () => { + expect(activateStructuredAgentSessionById({ worktreeId: 'wt-1', sessionId: 'session-1' })).toBe( + true + ) + expect(mocks.activateTab).toHaveBeenCalledWith('structured-tab-1', { worktreeId: 'wt-1' }) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'session.tabs.activate', + { worktree: 'id:wt-1', tabId: 'agent-session:session-1' } + ) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-tab-activation.ts b/src/renderer/src/lib/structured-agent-session-tab-activation.ts new file mode 100644 index 00000000000..e4d6eed92bc --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-activation.ts @@ -0,0 +1,43 @@ +import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' + +export function activateStructuredAgentSessionTab(args: { + worktreeId: string + tabId: string +}): boolean { + const state = useAppStore.getState() + const tab = (state.unifiedTabsByWorktree[args.worktreeId] ?? []).find( + (candidate) => candidate.id === args.tabId && candidate.contentType === 'agent-session' + ) + if (!tab) { + return false + } + state.focusGroup(args.worktreeId, tab.groupId) + state.activateTab(tab.id, { worktreeId: args.worktreeId }) + state.setActiveTabType('agent-session') + const environmentId = getRuntimeEnvironmentIdForWorktree(state, args.worktreeId) + void callRuntimeRpc( + getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), + 'session.tabs.activate', + { + worktree: toRuntimeWorktreeSelector(args.worktreeId), + tabId: `agent-session:${tab.entityId}` + } + ) + return true +} + +export function activateStructuredAgentSessionById(args: { + worktreeId: string + sessionId: string +}): boolean { + const tab = (useAppStore.getState().unifiedTabsByWorktree[args.worktreeId] ?? []).find( + (candidate) => + candidate.contentType === 'agent-session' && candidate.entityId === args.sessionId + ) + return tab + ? activateStructuredAgentSessionTab({ worktreeId: args.worktreeId, tabId: tab.id }) + : false +} diff --git a/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts b/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts new file mode 100644 index 00000000000..32fe82fe343 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts @@ -0,0 +1,15 @@ +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +/** Old structured projections persisted their desktop id as if a terminal could resume it. */ +export function isStructuredAgentSyntheticSleepingRecord( + record: SleepingAgentSessionRecord +): boolean { + const pane = parsePaneKey(record.paneKey) + return ( + pane !== null && + record.providerSession.key === 'session_id' && + structuredAgentSessionTabId(record.providerSession.id) === pane.tabId + ) +} diff --git a/src/renderer/src/lib/structured-native-chat-availability.test.ts b/src/renderer/src/lib/structured-native-chat-availability.test.ts new file mode 100644 index 00000000000..770413208cc --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type * as localPreflightContext from '@/lib/local-preflight-context' +import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' +import { canUseStructuredNativeChat } from './structured-native-chat-availability' + +const { mockGetRendererAppPlatform } = vi.hoisted(() => ({ + mockGetRendererAppPlatform: vi.fn<() => NodeJS.Platform>(() => 'darwin') +})) + +vi.mock('@/lib/renderer-app-platform', () => ({ + getRendererAppPlatform: mockGetRendererAppPlatform +})) + +type GetLocalProjectExecutionRuntimeContext = + typeof localPreflightContext.getLocalProjectExecutionRuntimeContext + +const projectRuntimeMock = vi.hoisted(() => ({ + fn: vi.fn(), + actual: undefined as GetLocalProjectExecutionRuntimeContext | undefined +})) + +vi.mock('@/lib/local-preflight-context', async (importOriginal) => { + const actual = await importOriginal() + projectRuntimeMock.actual = actual.getLocalProjectExecutionRuntimeContext + return { ...actual, getLocalProjectExecutionRuntimeContext: projectRuntimeMock.fn } +}) + +const wslRuntimeResolution: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1|wsl|Ubuntu' + } +} + +const repairRequiredResolution: ProjectExecutionRuntimeResolution = { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'project-override', + cacheKey: 'repo-1|wsl|repair' + } +} + +function stateFor(input: { + connectionId?: string | null + windowsRuntime?: 'windows-host' | 'wsl' + worktreePath?: string +}): AppState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: + input.windowsRuntime === 'wsl' + ? { kind: 'wsl', distro: 'Ubuntu' } + : { kind: 'windows-host' } + } + ], + repos: [{ id: 'repo-1', connectionId: input.connectionId ?? null, path: 'C:\\repo' }], + settings: { experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true }, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: input.worktreePath ?? 'C:\\repo\\worktree' + } + ] + }, + detectedWorktreesByRepo: {} + } as unknown as AppState +} + +describe('canUseStructuredNativeChat', () => { + beforeEach(() => { + mockGetRendererAppPlatform.mockReturnValue('darwin') + projectRuntimeMock.fn.mockReset() + projectRuntimeMock.fn.mockImplementation((...args) => { + if (!projectRuntimeMock.actual) { + throw new Error('real getLocalProjectExecutionRuntimeContext was never captured') + } + return projectRuntimeMock.actual(...args) + }) + }) + + it('allows the structured stack on a local worktree', () => { + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) + }) + + it('keeps the legacy bridge when the updated runtime is opted out', () => { + expect( + canUseStructuredNativeChat( + { + ...stateFor({}), + settings: { + experimentalStructuredNativeChat: false, + openAgentTabsInChatByDefault: true + } + } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses a stale structured opt-in while the default view is Terminal chat', () => { + expect( + canUseStructuredNativeChat( + { + ...stateFor({}), + settings: { + experimentalStructuredNativeChat: true, + openAgentTabsInChatByDefault: false + } + } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses a structured opt-in when the default view was never chosen', () => { + expect( + canUseStructuredNativeChat( + { ...stateFor({}), settings: { experimentalStructuredNativeChat: true } } as AppState, + 'wt-1' + ) + ).toBe(false) + }) + + it('refuses an SSH worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'ssh-a' }), 'wt-1')).toBe(false) + }) + + it('refuses a runtime-paired worktree so the pane stays on the bridge', () => { + expect(canUseStructuredNativeChat(stateFor({ connectionId: 'runtime-ssh-a' }), 'wt-1')).toBe( + false + ) + }) + + it('refuses a WSL project on Windows so the pane stays on the bridge', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'wsl' }), 'wt-1')).toBe(false) + }) + + it('keeps Windows-host projects on the terminal path until native start-time proof is advertised', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + expect(canUseStructuredNativeChat(stateFor({ windowsRuntime: 'windows-host' }), 'wt-1')).toBe( + false + ) + }) + + it('refuses a Windows folder workspace even though its key resolves no project runtime', () => { + mockGetRendererAppPlatform.mockReturnValue('win32') + const state = { + ...stateFor({}), + activeRepoId: null, + activeWorktreeId: null + } as unknown as AppState + expect(canUseStructuredNativeChat(state, 'folder:folder-1')).toBe(false) + }) + + it('allows a folder workspace on a non-Windows platform', () => { + const state = { + ...stateFor({}), + activeRepoId: null, + activeWorktreeId: null + } as unknown as AppState + expect(canUseStructuredNativeChat(state, 'folder:folder-1')).toBe(true) + }) + + it.each(['darwin', 'linux'] as const)('allows a supported local worktree on %s', (platform) => { + mockGetRendererAppPlatform.mockReturnValue(platform) + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(true) + }) + + it.each(['darwin', 'linux'] as const)( + 'refuses a WSL project runtime even when the renderer reports %s', + (platform) => { + mockGetRendererAppPlatform.mockReturnValue(platform) + projectRuntimeMock.fn.mockReturnValue(wslRuntimeResolution) + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(false) + } + ) + + it.each(['darwin', 'linux'] as const)( + 'refuses a repair-required runtime even when the renderer reports %s', + (platform) => { + mockGetRendererAppPlatform.mockReturnValue(platform) + projectRuntimeMock.fn.mockReturnValue(repairRequiredResolution) + expect(canUseStructuredNativeChat(stateFor({}), 'wt-1')).toBe(false) + } + ) +}) diff --git a/src/renderer/src/lib/structured-native-chat-availability.ts b/src/renderer/src/lib/structured-native-chat-availability.ts new file mode 100644 index 00000000000..bc14ccfd4f0 --- /dev/null +++ b/src/renderer/src/lib/structured-native-chat-availability.ts @@ -0,0 +1,30 @@ +import type { AppState } from '@/store/types' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' +import { getRendererAppPlatform } from '@/lib/renderer-app-platform' + +export function canUseStructuredNativeChat(state: AppState, worktreeId: string): boolean { + if (state.settings?.experimentalStructuredNativeChat !== true) { + return false + } + // Structured chat has no entry path of its own — it reuses the Chat UI default view. With + // Terminal chat selected the toggle is hidden but its persisted value survives, so gate on the + // default view too or a stale `true` would silently route new tabs into the structured runtime. + if (state.settings?.openAgentTabsInChatByDefault !== true) { + return false + } + if (getExecutionHostIdForWorktree(state, worktreeId) !== 'local') { + return false + } + // The shipped Windows process-tree addon may not expose creation time. Until + // the host advertises that proof, refuse every local Windows execution path — + // windows-host, WSL, and keys that resolve no project runtime (folder + // workspaces, floating terminal) — so create cannot fail after the click. + if (getRendererAppPlatform() === 'win32') { + return false + } + // Refuse WSL and repair-required runtimes even if resolution ever runs + // off-win32; the gate must not depend on the resolver's platform guard. + const projectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + return !(projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl') +} diff --git a/src/renderer/src/lib/workspace-tab-palette-search.ts b/src/renderer/src/lib/workspace-tab-palette-search.ts index 59515b5699a..8ec344f0544 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.ts @@ -1,6 +1,6 @@ import type { OpenFile } from '@/store/slices/editor' import type { PaletteDocument } from './palette-match/palette-document' -import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { Tab, TabGroup, WorkspaceVisibleTabType } from '../../../shared/tab-types' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' @@ -49,7 +49,7 @@ export type SearchableWorkspaceTab = { // secondary crowds the row. Keep these matchable so typing "terminal" still finds them. export const TERMINAL_TYPE_SEARCH_ALIASES = ['terminal tab', 'terminal'] as const -type WorkspaceTabPaletteActiveTabType = 'browser' | 'editor' | 'terminal' | 'simulator' +type WorkspaceTabPaletteActiveTabType = WorkspaceVisibleTabType export type BuildSearchableWorkspaceTabsOptions = WorkspaceTabAgentMetadataState & { worktrees: readonly Worktree[] diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index dc83cec79bf..1717cba7408 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -14,7 +14,12 @@ import { setWorktreeNavActivator, setWorktreeNavViewActivator } from '@/store/slices/worktree-nav-history' +import { + gateWorktreeAgentActivation, + workspaceHasSleepingAgentSessions +} from '@/lib/worktree-agent-activation-gate' import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' +import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope' import { @@ -62,6 +67,14 @@ function ensureFolderWorkspaceInitialTerminal( return primaryTabId } +function canInspectAgentActivationInventory(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.api?.runtime?.call === 'function' && + typeof window.api?.pty?.listSessions === 'function' + ) +} + export function activateAndRevealFolderWorkspace( folderWorkspaceId: string, opts?: { @@ -120,12 +133,31 @@ export function activateAndRevealFolderWorkspace( if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } - resumeSleepingAgentSessionsForWorktree(workspaceKey) - const primaryTabId = ensureFolderWorkspaceInitialTerminal( - folderWorkspace, - opts?.startup, - opts?.providesInitialSurface - ) + // Why: same ordering as the worktree path — gate first, then resume only when not deferring. + const shouldGateAgentActivation = + !opts?.startup && + (workspaceHasSleepingAgentSessions(state, workspaceKey) || + (canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + state.reconcileWorktreeTabModel(workspaceKey).renderableTabCount + ))) + if (!shouldGateAgentActivation) { + resumeSleepingAgentSessionsForWorktree(workspaceKey) + } + if (shouldGateAgentActivation) { + void gateWorktreeAgentActivation(workspaceKey).then((outcome) => { + if (outcome === 'empty' && useAppStore.getState().activeWorktreeId === workspaceKey) { + ensureFolderWorkspaceInitialTerminal(folderWorkspace) + } + }) + } + const primaryTabId = shouldGateAgentActivation + ? null + : ensureFolderWorkspaceInitialTerminal( + folderWorkspace, + opts?.startup, + opts?.providesInitialSurface + ) if (opts?.sidebarRevealBehavior) { state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) @@ -205,24 +237,47 @@ export function activateAndRevealWorktree( state.recordWorktreeVisit(worktreeId) } - // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should restore those CLI sessions automatically. - // Ordering is load-bearing: resuming synchronously creates the session's tab first, so the - // seeding below sees a renderable surface and doesn't add a bare shell next to it. - resumeSleepingAgentSessionsForWorktree(worktreeId) + // Why: the gate is decided BEFORE resuming. A sleeping session must defer seeding until startup + // restoration is ready (STA-1111) — resuming first would leave nothing to gate on. Structured + // agent inventory hydrates asynchronously too, so an empty tab model can otherwise authorize a + // fallback terminal beside a chat that is about to appear. + const shouldGateAgentActivation = + !hasActivationWork && + (workspaceHasSleepingAgentSessions(postActivationState, worktreeId) || + (canInspectAgentActivationInventory() && + shouldAutoCreateInitialTerminal( + postActivationState.reconcileWorktreeTabModel(worktreeId).renderableTabCount + ))) + if (!shouldGateAgentActivation) { + // Why: sleeping destroys the local PTY but preserves the provider session id, so waking should + // restore those CLI sessions. Ordering is load-bearing: resuming synchronously creates the + // session's tab first, so the seeding below doesn't add a bare shell next to it. + resumeSleepingAgentSessionsForWorktree(worktreeId) + } + if (shouldGateAgentActivation) { + void gateWorktreeAgentActivation(worktreeId).then((outcome) => { + const currentState = useAppStore.getState() + if (outcome === 'empty' && currentState.activeWorktreeId === worktreeId) { + ensureWorktreeHasInitialTerminal(currentState, worktreeId) + } + }) + } // 4. Ensure a focusable surface exists for externally-created worktrees - const primaryTabId = ensureWorktreeHasInitialTerminal( - useAppStore.getState(), - worktreeId, - opts?.startup, - opts?.setup, - opts?.issueCommand, - opts?.defaultTabs, - { - ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), - reseedEmptiedWorkspace: opts?.providesInitialSurface !== true - } - ) + const primaryTabId = shouldGateAgentActivation + ? null + : ensureWorktreeHasInitialTerminal( + useAppStore.getState(), + worktreeId, + opts?.startup, + opts?.setup, + opts?.issueCommand, + opts?.defaultTabs, + { + ...(opts?.backendStartupTerminalSpawned ? { backendStartupTerminalSpawned: true } : {}), + reseedEmptiedWorkspace: opts?.providesInitialSurface !== true + } + ) if (primaryTabId && opts?.initialCwd) { useAppStore.getState().queueTabInitialCwd(primaryTabId, opts.initialCwd) } diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts new file mode 100644 index 00000000000..3b884affca1 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import type { PtyListedSession } from '../../../shared/pty-listed-session' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { TerminalSlice } from '@/store/slices/terminals' +import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate' + +const WORKTREE_ID = 'repo::/worktree' +const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale' +const LIVE_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const DEAD_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +function listed(id: string): PtyListedSession { + return { id, cwd: '/worktree', title: 'Codex', agentOwnership: 'present' } +} + +function sleepingRecord( + tabId: string, + leafId: string, + providerSessionId: string +): SleepingAgentSessionRecord { + return { + paneKey: `${tabId}:${leafId}`, + tabId, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: providerSessionId }, + prompt: 'resume', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } +} + +function runtimeSnapshot( + tabId: string, + leafId: string, + ptyIds: string[] +): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'packaged-run-31759132745', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + ...ptyIds.map((ptyId, index) => ({ + type: 'terminal' as const, + id: `${tabId}:${leafId}:${index}`, + title: 'Codex', + parentTabId: tabId, + leafId, + ptyId, + status: 'ready' as const, + terminal: `term-${index}`, + isActive: false + })), + { + type: 'agent-session' as const, + id: 'structured-agent-session-live-session', + title: 'Codex Chat', + sessionId: 'live-session', + agent: 'codex' as const, + isActive: false + } + ] + } +} + +function testDeps(args: { + sessions?: PtyListedSession[] + sleeping?: SleepingAgentSessionRecord[] + structured?: boolean + resumeCount?: number +}) { + const resume = vi.fn(() => args.resumeCount ?? 1) + const sleeping = args.sleeping ?? [] + const ptyIdsByTabId: Record = {} + const tabsByWorktree: Record = { [WORKTREE_ID]: [] } + let createdCount = 0 + const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => { + createdCount += 1 + const id = options?.id ?? `created-${createdCount}` + const tab: TerminalTab = { + id, + ptyId: options?.initialPtyId ?? null, + worktreeId, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: tabsByWorktree[worktreeId]?.length ?? 0, + createdAt: 1 + } + tabsByWorktree[worktreeId] ??= [] + tabsByWorktree[worktreeId].push(tab) + ptyIdsByTabId[tab.id] = tab.ptyId ? [tab.ptyId] : [] + return tab + }) + const updateTabPtyId = vi.fn((tabId: string, ptyId: string) => { + ptyIdsByTabId[tabId] = [...new Set([...(ptyIdsByTabId[tabId] ?? []), ptyId])] + }) + const store = { + createTab, + ptyIdsByTabId, + tabsByWorktree, + sleepingAgentSessionsByPaneKey: Object.fromEntries( + sleeping.map((record) => [record.paneKey, record]) + ), + updateTabPtyId, + replaceTerminalLayoutPanePtyId: vi.fn(), + terminalLayoutsByTabId: Object.fromEntries( + sleeping.map((record) => { + const leafId = record.paneKey.slice(record.paneKey.indexOf(':') + 1) + return [ + record.tabId!, + { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + ] + }) + ), + unifiedTabsByWorktree: { + [WORKTREE_ID]: args.structured + ? [ + { + id: 'structured-1', + entityId: 'session-1', + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'agent-session' as const, + label: 'Codex', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + : [] + } + } + return { + createTab, + resume, + deps: { + getState: () => store, + listSessions: vi.fn(async () => args.sessions ?? []), + resume + } + } +} + +describe('worktree agent activation gate', () => { + it('uses immediately ready development restore inventory', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + const awaitReady = vi.fn(async () => true) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady }) + ).resolves.toBe('adopted') + + expect(awaitReady).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + }) + + it('waits for packaged restore hydration before reading daemon inventory', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + let releaseReady!: (ready: boolean) => void + const awaitReady = vi.fn(() => new Promise((resolve) => (releaseReady = resolve))) + + const activation = runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady }) + await vi.waitFor(() => expect(awaitReady).toHaveBeenCalledOnce()) + expect(deps.listSessions).not.toHaveBeenCalled() + + releaseReady(true) + await expect(activation).resolves.toBe('adopted') + expect(deps.listSessions).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + }) + + it('reads structured ownership before adopting daemon PTYs', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps } = testDeps({ sessions: [listed(ptyId)] }) + const hasStructuredSession = vi.fn(async () => false) + + await runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + + expect(hasStructuredSession.mock.invocationCallOrder[0]).toBeLessThan( + deps.listSessions.mock.invocationCallOrder[0] ?? Infinity + ) + }) + + it('blocks automatic resume when packaged restore never becomes ready', async () => { + const { deps, createTab, resume } = testDeps({}) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + awaitReady: async () => false + }) + ).resolves.toBe('blocked') + + expect(deps.listSessions).not.toHaveBeenCalled() + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('adopts a live daemon PTY before activation can resume another agent', async () => { + const ptyId = `${WORKTREE_ID}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + expect(resume).not.toHaveBeenCalled() + }) + + it('adopts a daemon PTY minted for a folder workspace', async () => { + const folderWorkspaceId = 'folder:plain-workspace' + const ptyId = `${folderWorkspaceId}@@live-pty` + const { deps, createTab, resume } = testDeps({ sessions: [listed(ptyId)] }) + + await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledWith(folderWorkspaceId, undefined, undefined, { + initialPtyId: ptyId, + activate: false, + recordInteraction: false + }) + expect(resume).not.toHaveBeenCalled() + }) + + it('does not resume when the workspace has only a structured session', async () => { + const { deps, createTab, resume } = testDeps({ structured: true }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('structured') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('does not activate a terminal for a stale structured sleeping projection', async () => { + const tabId = structuredAgentSessionTabId(STALE_STRUCTURED_SESSION_ID) + const stale = sleepingRecord(tabId, LIVE_LEAF_ID, STALE_STRUCTURED_SESSION_ID) + const { deps, createTab, resume } = testDeps({ structured: true, sleeping: [stale] }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('does not resume when the runtime reports a structured session before tab sync', async () => { + const { deps, createTab, resume } = testDeps({}) + const hasStructuredSession = vi.fn(async () => true) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, hasStructuredSession }) + ).resolves.toBe('structured') + + expect(hasStructuredSession).toHaveBeenCalledWith(WORKTREE_ID) + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + + it('keeps the existing resume path when the workspace has no live agent', async () => { + const { deps, createTab, resume } = testDeps({}) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).toHaveBeenCalledOnce() + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + }) + + it('resumes a dead agent when the workspace only has a non-agent PTY', async () => { + const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session') + const plainPtyId = `${WORKTREE_ID}@@plain-shell` + const { deps, resume } = testDeps({ + sessions: [{ ...listed(plainPtyId), title: 'zsh', agentOwnership: 'absent' }], + sleeping: [dead] + }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + }) + + it('suppresses only the exact live agent session while resuming a dead sibling', async () => { + const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') + const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live, dead] }) + const store = deps.getState() + store.terminalLayoutsByTabId['tab-live']!.ptyIdsByLeafId[LIVE_LEAF_ID] = livePtyId + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) + + it('adopts the exact structured TUI surface without creating a duplicate tab', async () => { + const tabId = 'structured-agent-session-live-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, createTab, resume } = testDeps({ + sessions: [listed(livePtyId)], + sleeping: [live], + resumeCount: 0 + }) + const ownerTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d' + const ownerLeafId = '2659ee80-d3fc-454f-b4ea-0638de1ae345' + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui', + terminal: { + paneKey: `${ownerTabId}:${ownerLeafId}`, + ptyId: livePtyId, + tabId: ownerTabId + } + } + ] + ]) + }) + }) + ).resolves.toBe('adopted') + + expect(createTab).toHaveBeenCalledOnce() + expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, { + id: ownerTabId, + initialLeafId: ownerLeafId, + initialPtyId: livePtyId, + activate: false, + recordInteraction: false + }) + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) + + it('does not use an ambiguous tab binding as a live session claim', async () => { + const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + deps.getState().ptyIdsByTabId['tab-live'] = [livePtyId, `${WORKTREE_ID}@@other-agent`] + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() }) + }) + + it('blocks when a structured TUI owner is absent from live inventory', async () => { + const tabId = 'structured-agent-session-live-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui', + terminal: { + paneKey: live.paneKey, + ptyId: `${WORKTREE_ID}@@different-agent`, + tabId + } + } + ] + ]) + }) + }) + ).resolves.toBe('blocked') + + expect(resume).not.toHaveBeenCalled() + }) + + it('uses the restored owner surface even when its tab predates structured naming', async () => { + const tabId = 'structured-agent-session-other-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const livePtyId = `${WORKTREE_ID}@@live-agent` + const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] }) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]), + ownerBySessionId: new Map([ + [ + 'live-session', + { + owner: 'tui', + terminal: { paneKey: live.paneKey, ptyId: livePtyId, tabId } + } + ] + ]) + }) + }) + ).resolves.toBe('resumed') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) + + it('suppresses the exact structured session when native already owns it', async () => { + const tabId = 'structured-agent-session-live-session' + const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session') + const { deps, resume } = testDeps({ sleeping: [live], resumeCount: 0 }) + + await expect( + runWorktreeAgentActivationGate(WORKTREE_ID, { + ...deps, + hasStructuredSession: async () => ({ + snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, []), + ownerBySessionId: new Map([['live-session', { owner: 'native' }]]) + }) + }) + ).resolves.toBe('structured') + + expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { + skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`]) + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts new file mode 100644 index 00000000000..1b0237b6504 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -0,0 +1,278 @@ +import { useAppStore } from '@/store' +import type { PtyListedSession } from '../../../shared/pty-listed-session' +import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { + resumeSleepingAgentSessionsForWorktree, + type ResumeSleepingAgentSessionsOptions +} from './resume-sleeping-agent-session' +import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' +import { bindLivePtyToExactSurface } from './worktree-agent-live-surface-adoption' +import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' +import { + readWorktreeStructuredActivationInventory, + type StructuredActivationInventory +} from './worktree-agent-structured-inventory' + +type ActivationStore = Pick< + ReturnType, + | 'createTab' + | 'ptyIdsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' +> + +type ActivationGateDeps = { + getState: () => ActivationStore + awaitReady?: () => Promise + listSessions: () => Promise + hasStructuredSession?: (worktreeId: string) => Promise + resume: (worktreeId: string, options?: ResumeSleepingAgentSessionsOptions) => number +} + +export type WorktreeAgentActivationOutcome = + | 'adopted' + | 'structured' + | 'resumed' + | 'empty' + | 'blocked' + +const inFlightByWorktreeId = new Map>() +const WORKSPACE_SESSION_READY_TIMEOUT_MS = 30_000 + +function waitForWorkspaceSessionReady(): Promise { + const isReady = () => { + const state = useAppStore.getState() + return state.workspaceSessionReady && state.terminalStartupRestorationReady + } + if (isReady()) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + let unsubscribe: (() => void) | null = null + const settle = (ready: boolean) => { + clearTimeout(timeout) + unsubscribe?.() + resolve(ready) + } + const timeout = setTimeout(() => settle(isReady()), WORKSPACE_SESSION_READY_TIMEOUT_MS) + unsubscribe = useAppStore.subscribe((state) => { + if (state.workspaceSessionReady && state.terminalStartupRestorationReady) { + settle(true) + } + }) + if (isReady()) { + settle(true) + } + }) +} + +export function workspaceHasSleepingAgentSessions( + state: Pick, 'sleepingAgentSessionsByPaneKey'>, + worktreeId: string +): boolean { + return Object.values(state.sleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === worktreeId + ) +} + +function hasStructuredSession(store: ActivationStore, worktreeId: string): boolean { + return (store.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.contentType === 'agent-session' + ) +} + +function ptyIsAlreadyBound(store: ActivationStore, ptyId: string): boolean { + return Object.values(store.ptyIdsByTabId).some((ids) => ids.includes(ptyId)) +} + +function sessionBelongsToWorkspace(sessionId: string, worktreeId: string): boolean { + if (parsePtySessionId(sessionId).worktreeId === worktreeId) { + return true + } + const scope = parseWorkspaceKey(worktreeId) + return ( + scope?.type === 'folder' && + sessionId.startsWith(`${worktreeId}${PTY_SESSION_ID_SEPARATOR}`) && + sessionId.length > worktreeId.length + PTY_SESSION_ID_SEPARATOR.length + ) +} + +function liveSleepingAgentClaimKeys( + store: ActivationStore, + worktreeId: string, + livePtyIds: ReadonlySet, + structuredInventory: StructuredActivationInventory | null +): Set { + const keys = new Set() + for (const record of Object.values(store.sleepingAgentSessionsByPaneKey)) { + if (record.worktreeId !== worktreeId) { + continue + } + const stable = parsePaneKey(record.paneKey) + const tabId = record.tabId ?? stable?.tabId + const layoutPtyId = stable + ? store.terminalLayoutsByTabId[stable.tabId]?.ptyIdsByLeafId?.[stable.leafId] + : undefined + const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined + const structuredOwner = + stable && isStructuredAgentSyntheticSleepingRecord(record) + ? structuredInventory?.ownerBySessionId.get(record.providerSession.id) + : undefined + if (structuredOwner?.owner === 'native') { + keys.add(getProviderSessionClaimKey(record)) + continue + } + // Packaged hydration can omit renderer bindings while main retains this session's exact TUI. + const structuredOwnerPtyId = + structuredOwner?.owner === 'tui' ? structuredOwner.terminal?.ptyId : undefined + const persistedPtyId = + layoutPtyId ?? (tabPtyIds?.length === 1 ? tabPtyIds[0] : undefined) ?? structuredOwnerPtyId + if (persistedPtyId && livePtyIds.has(persistedPtyId)) { + keys.add(getProviderSessionClaimKey(record)) + } + } + return keys +} + +export async function runWorktreeAgentActivationGate( + worktreeId: string, + deps: ActivationGateDeps +): Promise { + try { + if (deps.awaitReady && !(await deps.awaitReady())) { + return 'blocked' + } + } catch { + return 'blocked' + } + let structured = false + let structuredInventory: StructuredActivationInventory | null = null + try { + const reportedStructuredSession = await deps.hasStructuredSession?.(worktreeId) + structuredInventory = + typeof reportedStructuredSession === 'object' ? reportedStructuredSession : null + structured = Boolean( + hasStructuredSession(deps.getState(), worktreeId) || reportedStructuredSession + ) + } catch { + return 'blocked' + } + + const structuredTabs = structuredInventory?.snapshot.tabs.filter( + (tab) => tab.type === 'agent-session' + ) + if ( + structuredTabs?.some((tab) => { + const owner = structuredInventory?.ownerBySessionId.get(tab.sessionId) + return ( + !owner || + (owner.owner === 'tui' && + (!owner.terminal || parsePaneKey(owner.terminal.paneKey)?.tabId !== owner.terminal.tabId)) + ) + }) + ) { + return 'blocked' + } + if ( + structured && + !structuredInventory && + workspaceHasSleepingAgentSessions(deps.getState(), worktreeId) + ) { + return 'blocked' + } + + let sessions: PtyListedSession[] + try { + sessions = await deps.listSessions() + } catch { + // Inventory uncertainty cannot authorize a second writer. + return 'blocked' + } + + const liveWorkspaceSessions = sessions.filter((session) => + sessionBelongsToWorkspace(session.id, worktreeId) + ) + const liveWorkspacePtyIds = new Set(liveWorkspaceSessions.map((session) => session.id)) + for (const owner of structuredInventory?.ownerBySessionId.values() ?? []) { + if (owner.owner !== 'tui') { + continue + } + if ( + !owner.terminal || + !liveWorkspacePtyIds.has(owner.terminal.ptyId) || + !bindLivePtyToExactSurface(deps.getState(), worktreeId, owner.terminal) + ) { + return 'blocked' + } + } + if (liveWorkspaceSessions.length > 0) { + for (const session of liveWorkspaceSessions) { + const store = deps.getState() + if (ptyIsAlreadyBound(store, session.id)) { + continue + } + store.createTab(worktreeId, undefined, undefined, { + initialPtyId: session.id, + activate: false, + recordInteraction: false + }) + } + if (!workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { + return 'adopted' + } + } + + if (structured && !workspaceHasSleepingAgentSessions(deps.getState(), worktreeId)) { + return 'structured' + } + const launched = deps.resume(worktreeId, { + skipClaimKeys: liveSleepingAgentClaimKeys( + deps.getState(), + worktreeId, + liveWorkspacePtyIds, + structuredInventory + ) + }) + return launched > 0 + ? 'resumed' + : liveWorkspaceSessions.length > 0 + ? 'adopted' + : structured + ? 'structured' + : 'empty' +} + +export function gateWorktreeAgentActivation( + worktreeId: string +): Promise { + const existing = inFlightByWorktreeId.get(worktreeId) + if (existing) { + return existing + } + const gate = runWorktreeAgentActivationGate(worktreeId, { + getState: () => useAppStore.getState(), + awaitReady: waitForWorkspaceSessionReady, + listSessions: () => + typeof window === 'undefined' ? Promise.resolve([]) : window.api.pty.listSessions(), + hasStructuredSession: readWorktreeStructuredActivationInventory, + resume: resumeSleepingAgentSessionsForWorktree + }).finally(() => { + if (inFlightByWorktreeId.get(worktreeId) === gate) { + inFlightByWorktreeId.delete(worktreeId) + } + }) + inFlightByWorktreeId.set(worktreeId, gate) + return gate +} + +export function waitForWorktreeAgentActivationGateForTests( + worktreeId: string +): Promise { + return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null) +} diff --git a/src/renderer/src/lib/worktree-agent-activation-seam.test.ts b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts new file mode 100644 index 00000000000..382b86105bd --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-activation-seam.test.ts @@ -0,0 +1,204 @@ +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { useAppStore } from '@/store' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' +import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent-test-state' + +const initialState = useAppStore.getState() + +function baseState(): Partial { + const worktree = makeWorktree() + return { + repos: [ + { + id: worktree.repoId, + path: path.join(path.sep, 'workspace', 'repo'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0 + } + ], + worktreesByRepo: { [worktree.repoId]: [worktree] }, + activeRepoId: worktree.repoId, + activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + activeTabTypeByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + settings: { + agentCmdOverrides: {}, + defaultTuiAgent: 'codex', + setupScriptLaunchMode: 'new-tab' + } as AppState['settings'], + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } +} + +function structuredSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'activation-test', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'agent-session', + id: 'structured-agent-session-chat-1', + title: 'Codex Chat', + sessionId: 'chat-1', + agent: 'codex', + isActive: false + } + ] + } +} + +function stubInventory(args?: { structured?: boolean; livePtyId?: string }): { + runtimeCall: ReturnType + listSessions: ReturnType +} { + const worktree = makeWorktree() + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'session.tabs.listAll') { + return { + ok: true, + result: { snapshots: args?.structured ? [structuredSnapshot(worktree.id)] : [] } + } + } + if (method === 'agentSession.handoffStatus') { + return { ok: true, result: { owner: 'native' } } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const listSessions = vi.fn(async () => + args?.livePtyId + ? [ + { + id: args.livePtyId, + cwd: worktree.path, + title: 'Codex', + agentOwnership: 'present' as const + } + ] + : [] + ) + vi.stubGlobal('window', { api: { runtime: { call: runtimeCall }, pty: { listSessions } } }) + return { runtimeCall, listSessions } +} + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialState, true) +}) + +describe('worktree agent activation seam', () => { + it('keeps a projected chat-only workspace terminal-free', async () => { + const worktree = makeWorktree() + const groupId = 'chat-group' + useAppStore.setState({ + ...baseState(), + unifiedTabsByWorktree: { + [worktree.id]: [ + { + id: 'structured-agent-session-chat-1', + entityId: 'chat-1', + groupId, + worktreeId: worktree.id, + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [worktree.id]: [ + { + id: groupId, + worktreeId: worktree.id, + activeTabId: 'structured-agent-session-chat-1', + tabOrder: ['structured-agent-session-chat-1'] + } + ] + }, + activeGroupIdByWorktree: { [worktree.id]: groupId } + }) + const { runtimeCall, listSessions } = stubInventory() + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(runtimeCall).not.toHaveBeenCalled() + expect(listSessions).not.toHaveBeenCalled() + }) + + it('adopts a live terminal without spawning a fallback', async () => { + const worktree = makeWorktree() + const livePtyId = `${worktree.id}@@live-codex` + useAppStore.setState(baseState()) + stubInventory({ livePtyId }) + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.ptyId).toBe(livePtyId) + }) + + it('spawns a fallback when the workspace has no agent', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + stubInventory() + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? [] + expect(tabs).toHaveLength(1) + expect(tabs[0]?.ptyId).toBeNull() + }) + + it('does not spawn before a structured chat tab hydrates', async () => { + const worktree = makeWorktree() + useAppStore.setState(baseState()) + const { runtimeCall } = stubInventory({ structured: true }) + + expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) + + expect(useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + expect(runtimeCall).toHaveBeenCalledWith({ method: 'session.tabs.listAll', params: {} }) + expect(runtimeCall).toHaveBeenCalledWith({ + method: 'agentSession.handoffStatus', + params: { sessionId: 'chat-1' } + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts new file mode 100644 index 00000000000..df95951ed76 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-live-surface-adoption.ts @@ -0,0 +1,65 @@ +import { parsePaneKey } from '../../../shared/stable-pane-id' +import type { useAppStore } from '@/store' + +type LiveSurfaceAdoptionStore = Pick< + ReturnType, + | 'createTab' + | 'ptyIdsByTabId' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'updateTabPtyId' + | 'replaceTerminalLayoutPanePtyId' +> + +function layoutContainsLeaf( + root: LiveSurfaceAdoptionStore['terminalLayoutsByTabId'][string]['root'], + leafId: string +): boolean { + if (!root) { + return false + } + return root.type === 'leaf' + ? root.leafId === leafId + : layoutContainsLeaf(root.first, leafId) || layoutContainsLeaf(root.second, leafId) +} + +export function bindLivePtyToExactSurface( + store: LiveSurfaceAdoptionStore, + worktreeId: string, + terminal: { paneKey: string; ptyId: string; tabId: string } +): boolean { + const pane = parsePaneKey(terminal.paneKey) + if (!pane || pane.tabId !== terminal.tabId) { + return false + } + const ownerEntries = Object.entries(store.tabsByWorktree).flatMap(([ownerWorktreeId, tabs]) => + tabs.filter((tab) => tab.id === terminal.tabId).map((tab) => ({ ownerWorktreeId, tab })) + ) + const competingBinding = Object.entries(store.ptyIdsByTabId).some( + ([tabId, ptyIds]) => tabId !== terminal.tabId && ptyIds.includes(terminal.ptyId) + ) + if (ownerEntries.length > 1 || competingBinding) { + return false + } + const existing = ownerEntries[0] + if (existing) { + const layout = store.terminalLayoutsByTabId[terminal.tabId] + if ( + existing.ownerWorktreeId !== worktreeId || + !layoutContainsLeaf(layout?.root ?? null, pane.leafId) + ) { + return false + } + store.updateTabPtyId(terminal.tabId, terminal.ptyId) + store.replaceTerminalLayoutPanePtyId(terminal.tabId, pane.leafId, terminal.ptyId) + return true + } + const created = store.createTab(worktreeId, undefined, undefined, { + id: terminal.tabId, + initialLeafId: pane.leafId, + initialPtyId: terminal.ptyId, + activate: false, + recordInteraction: false + }) + return created.id === terminal.tabId +} diff --git a/src/renderer/src/lib/worktree-agent-structured-inventory.ts b/src/renderer/src/lib/worktree-agent-structured-inventory.ts new file mode 100644 index 00000000000..dc58e5cec71 --- /dev/null +++ b/src/renderer/src/lib/worktree-agent-structured-inventory.ts @@ -0,0 +1,78 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' + +export type StructuredActivationInventory = { + snapshot: RuntimeMobileSessionTabsResult + ownerBySessionId: ReadonlyMap< + string, + { + owner: 'native' | 'tui' + terminal?: { paneKey: string; ptyId: string; tabId: string } + } + > +} + +export async function readWorktreeStructuredActivationInventory( + worktreeId: string +): Promise { + if (typeof window === 'undefined') { + return false + } + const response = await window.api.runtime.call({ method: 'session.tabs.listAll', params: {} }) + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshot = (result.snapshots ?? []).find( + (candidate) => + candidate.worktree === worktreeId && + candidate.tabs.some((tab) => tab.type === 'agent-session') + ) + if (!snapshot) { + return false + } + const ownerBySessionId = new Map< + string, + { + owner: 'native' | 'tui' + terminal?: { paneKey: string; ptyId: string; tabId: string } + } + >() + await Promise.all( + snapshot.tabs.flatMap((tab) => + tab.type === 'agent-session' + ? [ + window.api.runtime + .call({ method: 'agentSession.handoffStatus', params: { sessionId: tab.sessionId } }) + .then((statusResponse) => { + if (!statusResponse.ok) { + return + } + const status = statusResponse.result as { + owner?: unknown + terminal?: { paneKey?: unknown; ptyId?: unknown; tabId?: unknown } + } + if (status.owner === 'native') { + ownerBySessionId.set(tab.sessionId, { owner: 'native' }) + } else if ( + status.owner === 'tui' && + typeof status.terminal?.paneKey === 'string' && + typeof status.terminal?.ptyId === 'string' && + status.terminal.ptyId.length > 0 && + typeof status.terminal.tabId === 'string' + ) { + ownerBySessionId.set(tab.sessionId, { + owner: 'tui', + terminal: { + paneKey: status.terminal.paneKey, + ptyId: status.terminal.ptyId, + tabId: status.terminal.tabId + } + }) + } + }) + ] + : [] + ) + ) + return { snapshot, ownerBySessionId } +} diff --git a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts index 9ac7e28ed25..e052b75f7d4 100644 --- a/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts +++ b/src/renderer/src/lib/worktree-creation-agent-seeds.test.ts @@ -42,6 +42,8 @@ function setTabs( ): void { useAppStore.setState({ tabsByWorktree: { 'wt-1': tabs }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, worktreesByRepo: { 'repo-1': [ { @@ -143,18 +145,56 @@ describe('seedAgentTabStateAfterWorktreeCreate', () => { expect(tabViewMode('agent-tab')).toBe('terminal') }) - it('opens a backend-spawned mirrorable draft in chat after host reconciliation', () => { + it('opens a backend-spawned mirrorable draft in chat after host reconciliation', async () => { setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'terminal' }]) - - seedAgentTabStateAfterWorktreeCreate({ - request, - worktreeId: 'wt-1', - primaryTabId: 'agent-tab', - startupTerminalTabId: 'agent-tab', - backendSpawned: true + const runtimeCall = vi.fn(async ({ method }: { method: string }) => { + if (method === 'agentSession.handoffStatus') { + return { + id: 'status', + ok: true, + result: { owner: 'native', direction: null, phase: 'idle' }, + _meta: { runtimeId: 'runtime-1' } + } + } + throw new Error(`Unexpected runtime method: ${method}`) + }) + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { api: { runtime: { call: runtimeCall } } } + }) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'agent-tab': { + activeLeafId: 'leaf-1', + ptyIdsByLeafId: { 'leaf-1': 'pty-1' } + } as never + }, + agentStatusByPaneKey: { + 'agent-tab:leaf-1': { + agentType: 'claude', + providerSession: { id: 'claude-session-1' } + } as never + } }) - expect(tabViewMode('agent-tab')).toBe('chat') + try { + seedAgentTabStateAfterWorktreeCreate({ + request, + worktreeId: 'wt-1', + primaryTabId: 'agent-tab', + startupTerminalTabId: 'agent-tab', + backendSpawned: true + }) + + await vi.waitFor(() => expect(tabViewMode('agent-tab')).toBe('chat')) + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } }) it('still opens a local omp draft in chat, despite the local-transcript gate', () => { diff --git a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts index ff22ddee07d..75be4ea395e 100644 --- a/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' import { makePaneKey } from '../../../shared/stable-pane-id' import type { ExecutionHostId } from '../../../shared/execution-host' @@ -37,6 +38,8 @@ function baseState(worktree: ReturnType): Partial worktreesByRepo: { 'repo-1': [worktree] }, activeRepoId: 'repo-1', activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -132,7 +135,7 @@ afterEach(() => { }) describe('preserved-pane replacement contract on workspace activation', () => { - it('appends exactly one replacement tab for a husk pane and retains the husk across repeats', () => { + it('appends exactly one replacement tab for a husk pane and retains the husk across repeats', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) // Hibernation cleared the pane's PTY binding: the husk cannot resume in place. @@ -141,6 +144,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-A') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const afterFirst = useAppStore.getState() const tabsAfterFirst = afterFirst.tabsByWorktree[worktree.id] ?? [] @@ -163,7 +167,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(afterRepeats.tabsByWorktree[worktree.id]).toHaveLength(2) }) - it('does not append a replacement while the preserved pane still has a live PTY', () => { + it('does not append a replacement while the preserved pane still has a live PTY', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) seedHuskTab(state, worktree.id, 'pty-live-1') @@ -172,6 +176,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-B') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]).toHaveLength(1) @@ -179,7 +184,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined() }) - it('does not fork a NON-group-active restorable pane into a replacement tab', () => { + it('does not fork a NON-group-active restorable pane into a replacement tab', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) seedHuskTab(state, worktree.id, 'pty-old-1') @@ -224,6 +229,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { activateAndRevealWorktree(worktree.id) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([ @@ -235,7 +241,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined() }) - it('does not append a replacement when the preserved pane will cold-restore in place', () => { + it('does not append a replacement when the preserved pane will cold-restore in place', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) // Restorable binding persists, no live PTY: pane-level cold restore owns recovery. @@ -246,6 +252,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { seedSleepingRecord(worktree.id, 'codex-session-C') activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]).toHaveLength(1) @@ -258,7 +265,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { // the replacement it used to append relaunched `codex resume` against a // session the host still held (-32600 "already has an active writer"), // stranding a bare shell while the live agent lost its tab. - it('parks the resume for a still-published web-mirror tab and replays it on the verdict', () => { + it('parks the resume for a still-published web-mirror tab and replays it on the verdict', async () => { const webTabId = 'web-terminal-host-tab' // The workspace is owned by a paired runtime — without that the deferral // takes its no-execution-host early return and pins nothing. @@ -320,7 +327,8 @@ describe('preserved-pane replacement contract on workspace activation', () => { RUNTIME_ENV_ID ) - activateAndRevealWorktree(worktree.id) + activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() expect(after.tabsByWorktree[worktree.id]?.map((tab) => tab.id)).toEqual([webTabId]) @@ -345,7 +353,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { // Why: retraction is the mirror's verdict that the host pane is gone, which // is what re-arms the one-replacement-per-session contract above. - it('appends the replacement once the mirror has retracted the web-mirror tab', () => { + it('appends the replacement once the mirror has retracted the web-mirror tab', async () => { const webTabId = 'web-terminal-host-tab' const worktree = { ...makeWorktree(), createdWithAgent: undefined } const state = baseState(worktree) @@ -379,6 +387,7 @@ describe('preserved-pane replacement contract on workspace activation', () => { })) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts index 84bc814e461..03253f7220f 100644 --- a/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts @@ -4,6 +4,7 @@ import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent-test-state' import { makePaneKey } from '../../../shared/stable-pane-id' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' // Red repro for the aug20 "windows 2" incident (restart-reattach/resume-relaunch): // a runtime-owned (paired remote) worktree's web-mirror tab holds a sleeping @@ -161,9 +162,16 @@ describe('runtime-owned worktree activation with an unhydrated host mirror', () expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('control: a local worktree with a dead pane still gets the resume fallback', () => { - const worktree = { ...makeCreatedAgentWorktree(), createdWithAgent: undefined } + it('control: a local worktree with a dead pane still gets the resume fallback', async () => { + const worktree = { + ...makeCreatedAgentWorktree(), + createdWithAgent: undefined, + hostId: 'local' as const + } const state = baseState(worktree) + state.activeWorkspaceExecutionHostId = null + state.workspaceSessionReady = true + state.terminalStartupRestorationReady = true // Local husk tab: same shape, non-mirror tab id. const localTabId = 'husk-tab-1' state.tabsByWorktree = { @@ -217,6 +225,7 @@ describe('runtime-owned worktree activation with an unhydrated host mirror', () })) activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false }) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const after = useAppStore.getState() const tabs = after.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts index a0c24f24517..ff6731cb810 100644 --- a/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts +++ b/src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts @@ -2,6 +2,7 @@ import path from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { activateAndRevealWorktree } from './worktree-activation' +import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate' import { makeCreatedAgentWorktree as makeWorktree } from '@/lib/worktree-activation-created-agent-test-state' const initialAppStoreState = useAppStore.getState() @@ -20,6 +21,8 @@ function baseState(worktree: ReturnType): Partial worktreesByRepo: { 'repo-1': [worktree] }, activeRepoId: 'repo-1', activeView: 'terminal', + workspaceSessionReady: true, + terminalStartupRestorationReady: true, tabsByWorktree: {}, unifiedTabsByWorktree: {}, groupsByWorktree: {}, @@ -48,13 +51,92 @@ function baseState(worktree: ReturnType): Partial } afterEach(() => { + vi.unstubAllGlobals() useAppStore.setState(initialAppStoreState, true) }) describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { - it('re-captured sleeping codex session resumes once, not once per reopen', () => { + it('defers packaged-startup resume until restored PTYs finish reconnecting', async () => { + const worktree = { ...makeWorktree(), createdWithAgent: undefined } + useAppStore.setState({ + ...baseState(worktree), + workspaceSessionReady: false, + terminalStartupRestorationReady: false + }) + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `packaged-restart-pane:${leafId}` + const livePtyId = `${worktree.id}@@daemon-live` + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'packaged-restart-pane', + worktreeId: worktree.id, + agent: 'codex', + providerSession: { key: 'session_id', id: 'packaged-session' }, + prompt: 'resume prior task', + state: 'working', + origin: 'quit', + capturedAt: 1000, + updatedAt: 1000, + terminalTitle: 'Codex' + } + }, + terminalLayoutsByTabId: { + 'packaged-restart-pane': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: livePtyId } + } + } + }) + vi.stubGlobal('window', { + api: { + runtime: { + call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })) + }, + pty: { + listSessions: vi.fn(async () => [ + { + id: livePtyId, + cwd: worktree.path, + title: 'Codex', + agentOwnership: 'present' as const + } + ]) + } + } + }) + + activateAndRevealWorktree(worktree.id) + const gate = waitForWorktreeAgentActivationGateForTests(worktree.id) + await Promise.resolve() + expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0) + + useAppStore.setState({ + workspaceSessionReady: true, + terminalStartupRestorationReady: true + }) + await gate + const restored = useAppStore.getState() + expect(restored.tabsByWorktree[worktree.id]).toHaveLength(1) + expect(restored.tabsByWorktree[worktree.id]?.[0]?.ptyId).toBe(livePtyId) + expect(restored.automaticAgentResumeClaimsByTabId).toEqual({}) + expect(restored.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() + }) + + it('re-captured sleeping codex session resumes once, not once per reopen', async () => { const worktree = { ...makeWorktree(), createdWithAgent: undefined } useAppStore.setState(baseState(worktree)) + vi.stubGlobal('window', { + api: { + runtime: { + call: vi.fn(async () => ({ ok: true, result: { snapshots: [] } })) + }, + pty: { listSessions: vi.fn(async () => []) } + } + }) const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } let resumedTabId: string | undefined @@ -80,6 +162,7 @@ describe('STA-1111 worktree reopen does not fork-bomb tabs', () => { })) activateAndRevealWorktree(worktree.id) + await waitForWorktreeAgentActivationGateForTests(worktree.id) const state = useAppStore.getState() const tabs = state.tabsByWorktree[worktree.id] ?? [] diff --git a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts index 75a88500462..2b99927cb8c 100644 --- a/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts +++ b/src/renderer/src/runtime/host-session-mirror-settle-census.test.ts @@ -144,7 +144,9 @@ describe('host-session-mirror settle census', () => { // singular frame, scoped active frame. 'runtime/web-session-tabs-sync.ts': 5, // The eager post-create session.tabs.list refresh. - 'runtime/web-runtime-session.ts': 1 + 'runtime/web-runtime-session.ts': 1, + // The local structured-session inventory/subscription frame. + 'runtime/local-structured-session-tabs-sync.ts': 1 }) }) @@ -190,7 +192,8 @@ describe('host-session-mirror settle census', () => { // frame patch, and its patchless twin) and three mirror ones // (visibility-resume repair, global singular frame patch and patchless). 'runtime/web-session-tabs-sync.ts': { settleHydration: 4, settleMirror: 3 }, - 'runtime/web-runtime-session.ts': { settleMirror: 1 } + 'runtime/web-runtime-session.ts': { settleMirror: 1 }, + 'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 } }) }) diff --git a/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts b/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts new file mode 100644 index 00000000000..22af26b6544 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-empty-worktree-visibility.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { collectClientLayoutGroupIds } from './web-session-client-owned-tab-placement' +import { resetWebSessionFocusIntentForTests } from './web-session-focus-intent' + +// Why: a structured session created on a worktree with no prior tabs must land in a +// group the local layout actually renders. The host publishes it inside a +// "headless-terminals:" group; adopting that group while freezing the local layout +// leaves the tab in store but permanently off screen (empty-worktree launch P0). + +const GIT_WT = 'repo-1::/tmp/wt1' +const FOLDER_WT = 'folder:folder-1' +const LOCAL_ROOT = 'local-root-group' + +afterEach(() => { + resetWebSessionFocusIntentForTests() +}) + +function emptyState(overrides: Partial): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: GIT_WT, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0, + ...overrides + } +} + +function headlessSnapshot(worktreeId: string): RuntimeMobileSessionTabsResult { + return { + worktree: worktreeId, + publicationEpoch: 'structured:epoch-1', + snapshotVersion: 1, + activeGroupId: `headless-terminals:${worktreeId}`, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: `headless-terminals:${worktreeId}`, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } +} + +function applyStructured( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult +): WebSessionTabsSyncState { + const patch = applyWebSessionTabsSnapshot( + state, + projectLocalStructuredSessionTabs(snapshot), + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true, terminalPtyMode: 'local' } + ) + return { ...state, ...patch } as WebSessionTabsSyncState +} + +/** The visibility contract: the published chat tab sits in a group the layout renders. */ +function expectChatTabRendered(state: WebSessionTabsSyncState, worktreeId: string): Tab { + const chatTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'agent-session' + ) + expect(chatTab).toBeDefined() + const groups = state.groupsByWorktree[worktreeId] ?? [] + const owningGroup = groups.find((group) => group.tabOrder.includes(chatTab!.id)) + expect(owningGroup).toBeDefined() + expect(chatTab!.groupId).toBe(owningGroup!.id) + const renderedGroupIds = collectClientLayoutGroupIds(state.layoutByWorktree[worktreeId] ?? null) + expect(renderedGroupIds.has(owningGroup!.id)).toBe(true) + return chatTab! +} + +describe('structured session visibility on empty worktrees', () => { + it('adopts the session into the rendered local root leaf when its group record is missing (git worktree)', () => { + // The observed P0 store state: the layout leaf exists but its group record does not. + const state = emptyState({ + layoutByWorktree: { [GIT_WT]: { type: 'leaf', groupId: LOCAL_ROOT } }, + activeGroupIdByWorktree: { [GIT_WT]: LOCAL_ROOT } + }) + + const applied = applyStructured(state, headlessSnapshot(GIT_WT)) + + const chatTab = expectChatTabRendered(applied, GIT_WT) + expect(chatTab.groupId).toBe(LOCAL_ROOT) + expect(applied.layoutByWorktree[GIT_WT]).toEqual({ type: 'leaf', groupId: LOCAL_ROOT }) + }) + + it('materializes a rendered group on a truly empty git worktree', () => { + const applied = applyStructured(emptyState({}), headlessSnapshot(GIT_WT)) + + expectChatTabRendered(applied, GIT_WT) + }) + + it('materializes a rendered group on a truly empty folder workspace', () => { + const applied = applyStructured( + emptyState({ activeWorktreeId: FOLDER_WT }), + headlessSnapshot(FOLDER_WT) + ) + + expectChatTabRendered(applied, FOLDER_WT) + }) + + it('adopts the session into an existing empty local root group', () => { + const state = emptyState({ + groupsByWorktree: { + [GIT_WT]: [{ id: LOCAL_ROOT, worktreeId: GIT_WT, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { [GIT_WT]: { type: 'leaf', groupId: LOCAL_ROOT } }, + activeGroupIdByWorktree: { [GIT_WT]: LOCAL_ROOT } + }) + + const applied = applyStructured(state, headlessSnapshot(GIT_WT)) + + const chatTab = expectChatTabRendered(applied, GIT_WT) + expect(chatTab.groupId).toBe(LOCAL_ROOT) + }) + + it('keeps the folder-workspace local split intact while placing the session beside the terminal', () => { + const terminalTab: Tab = { + id: 'u-term-1', + entityId: 'term-1', + groupId: LOCAL_ROOT, + worktreeId: FOLDER_WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + const state = emptyState({ + activeWorktreeId: FOLDER_WT, + tabsByWorktree: { + [FOLDER_WT]: [ + { + id: 'term-1', + worktreeId: FOLDER_WT, + ptyId: 'pty-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'term-1': ['pty-1'] }, + unifiedTabsByWorktree: { [FOLDER_WT]: [terminalTab] }, + groupsByWorktree: { + [FOLDER_WT]: [ + { + id: LOCAL_ROOT, + worktreeId: FOLDER_WT, + activeTabId: 'u-term-1', + tabOrder: ['u-term-1'] + } + ] + }, + layoutByWorktree: { [FOLDER_WT]: { type: 'leaf', groupId: LOCAL_ROOT } }, + activeGroupIdByWorktree: { [FOLDER_WT]: LOCAL_ROOT } + }) + + const applied = applyStructured(state, headlessSnapshot(FOLDER_WT)) + + const chatTab = expectChatTabRendered(applied, FOLDER_WT) + expect(chatTab.groupId).toBe(LOCAL_ROOT) + const rootGroup = applied.groupsByWorktree[FOLDER_WT]?.find((group) => group.id === LOCAL_ROOT) + expect(rootGroup?.tabOrder).toEqual(['u-term-1', chatTab.id]) + expect(applied.layoutByWorktree[FOLDER_WT]).toEqual({ type: 'leaf', groupId: LOCAL_ROOT }) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts new file mode 100644 index 00000000000..614eae533af --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-host-isolation.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' +import { toWebTerminalSurfaceTabId } from './web-runtime-session' +import { + applyLocalStructuredSessionTabSnapshots, + projectLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' +import { + ENV, + HOST_SURFACE_ID, + LEAF_ID, + NOW, + WT, + makeSnapshot, + makeState, + resetWebSessionTabsSyncTestState +} from './web-session-tabs-sync-test-harness' + +vi.mock('../store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const REMOTE_GROUP = 'remote-group' +const STRUCTURED_GROUP = 'structured-group' +const HOST_TAB_ID = 'host-tab-1' +const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID) + +function terminalSnapshot(version = 1): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: 'Terminal', + status: 'ready', + terminal: 'term-host', + isActive: true + } + ], + { + snapshotVersion: version, + activeGroupId: REMOTE_GROUP, + tabGroups: [{ id: REMOTE_GROUP, activeTabId: HOST_TAB_ID, tabOrder: [HOST_TAB_ID] }], + tabGroupLayout: { type: 'leaf', groupId: REMOTE_GROUP } + } + ) +} + +function pendingTerminalSnapshot(): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'terminal', + id: HOST_SURFACE_ID, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: 'Starting terminal', + status: 'pending-handle', + terminal: null, + isActive: true + } + ], + { + activeGroupId: REMOTE_GROUP, + tabGroups: [{ id: REMOTE_GROUP, activeTabId: HOST_TAB_ID, tabOrder: [HOST_TAB_ID] }], + tabGroupLayout: { type: 'leaf', groupId: REMOTE_GROUP } + } + ) +} + +function structuredSnapshot(): RuntimeMobileSessionTabsResult { + return makeSnapshot( + [ + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ], + { + publicationEpoch: 'structured:epoch-1', + activeGroupId: STRUCTURED_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: STRUCTURED_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: { type: 'leaf', groupId: STRUCTURED_GROUP } + } + ) +} + +function applySnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + options?: Parameters[4] +): WebSessionTabsSyncState { + return { + ...state, + ...applyWebSessionTabsSnapshot(state, snapshot, environmentId, NOW, options) + } as WebSessionTabsSyncState +} + +function expectRemoteTerminalTopology(state: WebSessionTabsSyncState): void { + expect(state.tabsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: MIRRORED_TAB_ID, ptyId: `remote:${ENV}@@term-host` }) + ]) + expect(state.unifiedTabsByWorktree[WT]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: MIRRORED_TAB_ID, contentType: 'terminal' }) + ]) + ) + expect(state.groupsByWorktree[WT]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: REMOTE_GROUP, + tabOrder: expect.arrayContaining([MIRRORED_TAB_ID]) + }) + ]) + ) + expect(state.activeGroupIdByWorktree[WT]).toBe(REMOTE_GROUP) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: REMOTE_GROUP }) + expect(state.activeTabIdByWorktree[WT]).toBe(MIRRORED_TAB_ID) + expect(state.activeTabTypeByWorktree[WT]).toBe('terminal') +} + +describe('local structured session tab host isolation', () => { + beforeEach(resetWebSessionTabsSyncTestState) + + it('materializes a complete terminal group and layout into an otherwise empty paired state', () => { + const state = applySnapshot(makeState(), terminalSnapshot(), ENV) + + expectRemoteTerminalTopology(state) + }) + + it('materializes the active paired worktree without disturbing another worktree layout', () => { + const otherWorktree = 'repo::/other-worktree' + const otherLayout = { type: 'leaf' as const, groupId: 'other-group' } + const state = applySnapshot( + makeState({ layoutByWorktree: { [otherWorktree]: otherLayout } }), + terminalSnapshot(), + ENV + ) + + expectRemoteTerminalTopology(state) + expect(state.layoutByWorktree[otherWorktree]).toBe(otherLayout) + }) + + it('keeps ordinary remote topology stable across agent-only inventory frames', () => { + let state = applySnapshot(makeState(), terminalSnapshot(), ENV) + state = applySnapshot( + state, + projectLocalStructuredSessionTabs(structuredSnapshot()), + 'local-structured-session', + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + expectRemoteTerminalTopology(state) + + state = applySnapshot(state, terminalSnapshot(2), ENV) + expectRemoteTerminalTopology(state) + }) + + it('keeps startup terminal topology through pending, ready, and repeated ready frames', () => { + let state = applySnapshot(makeState(), pendingTerminalSnapshot(), ENV) + expect(state.tabsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: MIRRORED_TAB_ID, ptyId: null }) + ]) + expect(state.groupsByWorktree[WT]).toEqual([ + expect.objectContaining({ id: REMOTE_GROUP, tabOrder: [MIRRORED_TAB_ID] }) + ]) + expect(state.layoutByWorktree[WT]).toEqual({ type: 'leaf', groupId: REMOTE_GROUP }) + + state = applySnapshot(state, terminalSnapshot(2), ENV) + expectRemoteTerminalTopology(state) + const stableGroups = state.groupsByWorktree + const stableLayouts = state.layoutByWorktree + + state = applySnapshot(state, terminalSnapshot(3), ENV) + expectRemoteTerminalTopology(state) + expect(state.groupsByWorktree).toBe(stableGroups) + expect(state.layoutByWorktree).toBe(stableLayouts) + }) + + it.each([ + ['paired', toRuntimeExecutionHostId(ENV)], + ['SSH', toSshExecutionHostId('ssh-target-1')] + ])('does not project local structured inventory into a %s-owned workspace', (_name, hostId) => { + const state = makeState({ + activeWorkspaceExecutionHostId: hostId + } as Partial) + + const next = applyLocalStructuredSessionTabSnapshots( + state, + [structuredSnapshot()], + undefined, + NOW + ) + + expect(next).toBe(state) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts new file mode 100644 index 00000000000..4bd9a2e6d45 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -0,0 +1,458 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs' +import { buildHydratedTabState } from '../store/slices/tabs-hydration' +import { + applyLocalStructuredSessionTabSnapshots, + projectLocalStructuredSessionTabs +} from './local-structured-session-tabs-sync' +import { + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' +import { + recordWebSessionFocusIntent, + resetWebSessionFocusIntentForTests +} from './web-session-focus-intent' + +const WORKTREE_ID = 'repo-1::worktree-1' +const TERMINAL_ID = 'terminal-1' +const STRUCTURED_ID = 'structured-agent-session-codex-1' +const PRIMARY_GROUP = 'primary-group' +const SECONDARY_GROUP = 'secondary-group' + +afterEach(() => { + resetWebSessionFocusIntentForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +function createSnapshot(): WebSessionTabsSyncState { + const tabs: Tab[] = [ + { + id: TERMINAL_ID, + entityId: TERMINAL_ID, + groupId: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: STRUCTURED_ID, + entityId: 'codex-1', + groupId: SECONDARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2 + } + ] + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP }, + activeTabId: STRUCTURED_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: STRUCTURED_ID }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }, + { + id: SECONDARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: STRUCTURED_ID, + tabOrder: [STRUCTURED_ID] + } + ] + }, + layoutByWorktree: { + [WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: SECONDARY_GROUP } + } + }, + openFiles: [], + ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, STRUCTURED_ID] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: tabs }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +function expectExactSplit(state: { + unifiedTabsByWorktree: Record + groupsByWorktree: WebSessionTabsSyncState['groupsByWorktree'] + layoutByWorktree: WebSessionTabsSyncState['layoutByWorktree'] + activeGroupIdByWorktree: Record +}): void { + expect(state.layoutByWorktree[WORKTREE_ID]).toEqual({ + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: SECONDARY_GROUP } + }) + expect(state.groupsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: PRIMARY_GROUP, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }), + expect.objectContaining({ + id: SECONDARY_GROUP, + activeTabId: STRUCTURED_ID, + tabOrder: [STRUCTURED_ID] + }) + ]) + ) + expect(state.groupsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: TERMINAL_ID, groupId: PRIMARY_GROUP, contentType: 'terminal' }), + expect.objectContaining({ + id: STRUCTURED_ID, + groupId: SECONDARY_GROUP, + contentType: 'agent-session' + }) + ]) + expect(state.activeGroupIdByWorktree[WORKTREE_ID]).toBe(SECONDARY_GROUP) +} + +describe('local structured session tab projection', () => { + it('drops terminal topology while retaining structured tabs', () => { + const snapshot = { + worktree: 'workspace-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'structured-group', + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'terminal-group', + activeTabId: 'terminal-1', + tabOrder: ['terminal-1'] + }, + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'terminal-group' }, + second: { type: 'leaf', groupId: 'structured-group' } + }, + tabs: [ + { + type: 'terminal', + id: 'terminal-1', + parentTabId: 'terminal-1', + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + expect(projectLocalStructuredSessionTabs(snapshot)).toMatchObject({ + tabGroups: [ + { + id: 'structured-group', + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: undefined, + tabs: [expect.objectContaining({ type: 'agent-session', agent: 'codex' })] + }) + }) + + it('preserves the exact local split through apply, persistence, and hydration', () => { + const state = createSnapshot() + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session', + tabGroups: [ + { id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] }, + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabGroupLayout: state.layoutByWorktree[WORKTREE_ID], + tabs: [ + { + type: 'terminal', + id: TERMINAL_ID, + parentTabId: TERMINAL_ID, + leafId: 'leaf-1', + title: 'Terminal', + status: 'ready', + terminal: 'term-1', + ptyId: 'pty-1', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex', + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + const projected = projectLocalStructuredSessionTabs(snapshot) + const patch = applyWebSessionTabsSnapshot( + state, + projected, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...state, ...patch } as WebSessionTabsSyncState + + expectExactSplit(applied) + + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: STRUCTURED_ID, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...buildPersistedUnifiedTabSessionData(applied) + } + const hydrated = buildHydratedTabState(session, new Set([WORKTREE_ID])) + + expectExactSplit(hydrated) + }) + + it('repairs stale legacy active pointers when restart republishes the native tab', () => { + const state = createSnapshot() + const restartedState: WebSessionTabsSyncState = { + ...state, + activeTabId: TERMINAL_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: TERMINAL_ID }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeGroupIdByWorktree: { [WORKTREE_ID]: SECONDARY_GROUP } + } + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'structured:restart-1', + snapshotVersion: 1, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session' as const, + tabGroups: [ + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabs: [ + { + type: 'agent-session' as const, + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex' as const, + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + const projected = projectLocalStructuredSessionTabs(snapshot) + const patch = applyWebSessionTabsSnapshot( + restartedState, + projected, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...restartedState, ...patch } as WebSessionTabsSyncState + + expect(applied.activeTabTypeByWorktree[WORKTREE_ID]).toBe('agent-session') + expect(applied.activeTabIdByWorktree[WORKTREE_ID]).toBe(STRUCTURED_ID) + }) + + it('honors the focus intent for a newly published local structured tab', () => { + const initial = createSnapshot() + const state: WebSessionTabsSyncState = { + ...initial, + activeGroupIdByWorktree: { [WORKTREE_ID]: PRIMARY_GROUP }, + activeTabId: TERMINAL_ID, + activeTabIdByWorktree: { [WORKTREE_ID]: TERMINAL_ID }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + groupsByWorktree: { + [WORKTREE_ID]: initial.groupsByWorktree[WORKTREE_ID]!.map((group) => + group.id === PRIMARY_GROUP ? { ...group, activeTabId: TERMINAL_ID } : group + ) + } + } + const snapshot = { + worktree: WORKTREE_ID, + publicationEpoch: 'structured:epoch-1', + snapshotVersion: 1, + activeGroupId: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + activeTabType: 'agent-session' as const, + tabGroups: [ + { id: PRIMARY_GROUP, activeTabId: TERMINAL_ID, tabOrder: [TERMINAL_ID] }, + { + id: SECONDARY_GROUP, + activeTabId: 'agent-session:codex-1', + tabOrder: ['agent-session:codex-1'] + } + ], + tabs: [ + { + type: 'agent-session' as const, + id: 'agent-session:codex-1', + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex' as const, + isActive: true + } + ] + } satisfies RuntimeMobileSessionTabsResult + + recordWebSessionFocusIntent( + { environmentId: 'local-structured-session' }, + WORKTREE_ID, + 'agent-session:codex-1', + undefined, + TERMINAL_ID + ) + const patch = applyWebSessionTabsSnapshot( + state, + snapshot, + 'local-structured-session', + 1_700_000_000_000, + { preserveLocalLayout: true } + ) + const applied = { ...state, ...patch } as WebSessionTabsSyncState + + expect(applied.activeTabIdByWorktree[WORKTREE_ID]).toBe(STRUCTURED_ID) + expect(applied.activeTabTypeByWorktree[WORKTREE_ID]).toBe('agent-session') + expect(applied.groupsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: SECONDARY_GROUP, activeTabId: STRUCTURED_ID }) + ]) + ) + }) + + it('rejects a reordered list reply after a newer subscription frame', () => { + const stale = structuredInventory('epoch-a', 7, 'stale-session') + const fresh = structuredInventory('epoch-a', 8, 'fresh-session') + const afterStale = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [stale]) + const afterFresh = applyLocalStructuredSessionTabSnapshots(afterStale, [fresh]) + const afterReorderedList = applyLocalStructuredSessionTabSnapshots(afterFresh, [stale]) + + expect(afterReorderedList).toBe(afterFresh) + expect(afterReorderedList.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'fresh-session' })]) + ) + expect(afterReorderedList.unifiedTabsByWorktree[WORKTREE_ID]).not.toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'stale-session' })]) + ) + }) + + it('rejects same-version replay but accepts a new owner epoch', () => { + const first = structuredInventory('epoch-a', 8, 'session-a') + const afterFirst = applyLocalStructuredSessionTabSnapshots(createSnapshot(), [first]) + const replayed = applyLocalStructuredSessionTabSnapshots(afterFirst, [first]) + const restarted = applyLocalStructuredSessionTabSnapshots(replayed, [ + structuredInventory('epoch-b', 1, 'session-b') + ]) + + expect(replayed).toBe(afterFirst) + expect(restarted).not.toBe(replayed) + expect(restarted.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ entityId: 'session-b' })]) + ) + }) +}) + +function structuredInventory( + publicationEpoch: string, + snapshotVersion: number, + sessionId: string +): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch, + snapshotVersion, + activeGroupId: SECONDARY_GROUP, + activeTabId: `agent-session:${sessionId}`, + activeTabType: 'agent-session', + tabGroups: [ + { + id: SECONDARY_GROUP, + activeTabId: `agent-session:${sessionId}`, + tabOrder: [`agent-session:${sessionId}`] + } + ], + tabs: [ + { + type: 'agent-session', + id: `agent-session:${sessionId}`, + title: 'Codex Chat', + sessionId, + agent: 'codex', + isActive: true + } + ] + } +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts new file mode 100644 index 00000000000..7d8ee6aa626 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -0,0 +1,184 @@ +import { useEffect } from 'react' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { useAppStore } from '../store' +import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' +import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsStorePatch, + decideWebSessionTabsSnapshot +} from './web-session-tabs-sync' +import type { WebSessionTabsSyncState } from './web-session-tabs-sync' + +export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' +let localStructuredSessionTabsRestorePromise: Promise | null = null + +type SessionTabsEvent = + | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) + | { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] } + | { type: 'end' } + +export function projectLocalStructuredSessionTabs( + snapshot: RuntimeMobileSessionTabsResult +): RuntimeMobileSessionTabsResult { + const structuredIds = new Set( + snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id) + ) + const visibleHostTabIds = structuredIds + const visibleIds = structuredIds + let projectedTabGroups = snapshot.tabGroups + ?.map((group) => ({ + ...group, + tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)), + activeTabId: + group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null, + recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id)) + })) + .filter((group) => group.tabOrder.length > 0) + + return { + ...snapshot, + activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null, + activeTabType: + snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null, + activeGroupId: + snapshot.activeGroupId && + projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId) + ? snapshot.activeGroupId + : (projectedTabGroups?.[0]?.id ?? null), + tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)), + tabGroups: projectedTabGroups, + // Why: group membership locates chats; the renderer's split tree remains locally authoritative. + tabGroupLayout: undefined + } +} + +export function applyStructuredSessionTabSnapshots( + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER +): void { + const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( + (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner), + { frames: [] } + ) + settleStructuredSessionMirror() +} + +export function applyLocalStructuredSessionTabSnapshots< + State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState +>( + state: State, + snapshots: readonly RuntimeMobileSessionTabsResult[], + owner = LOCAL_STRUCTURED_SESSION_OWNER, + now = Date.now() +): State { + let next = state + for (const snapshot of snapshots) { + // Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes. + if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { + continue + } + if (!decideWebSessionTabsSnapshot(snapshot, owner).apply) { + continue + } + const patch = applyWebSessionTabsSnapshot( + next, + projectLocalStructuredSessionTabs(snapshot), + owner, + now, + { + contentScope: 'agent-session', + preserveLocalLayout: true, + terminalPtyMode: 'local' + } + ) + next = patch === next ? next : ({ ...next, ...patch } as State) + } + return next +} + +export function restoreLocalStructuredSessionTabsOnce(): Promise { + localStructuredSessionTabsRestorePromise ??= refreshLocalStructuredSessionTabs() + .then(() => undefined) + .catch((error) => { + localStructuredSessionTabsRestorePromise = null + throw error + }) + return localStructuredSessionTabsRestorePromise +} + +/** Fetch the current host inventory even after the startup restore has settled. */ +export function refreshLocalStructuredSessionTabs(): Promise { + return window.api.runtime + .call({ method: 'session.tabs.listAll', params: {} }) + .then((response) => { + if (!response.ok) { + throw new Error('structured session inventory unavailable') + } + const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] } + const snapshots = result.snapshots ?? [] + applyStructuredSessionTabSnapshots(snapshots) + return snapshots + }) +} + +async function startLocalStructuredSessionTabsSync(args: { + isDisposed: () => boolean + setUnsubscribe: (unsubscribe: () => void) => void +}): Promise { + const status = await window.api.runtime.getStatus() + if (args.isDisposed()) { + return + } + const supported = status.capabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + await restoreLocalStructuredSessionTabsOnce() + if (args.isDisposed()) { + return + } + if (!supported) { + return + } + const handle = await window.api.runtime.subscribe( + { method: 'session.tabs.subscribeAll', params: {} }, + (response) => { + if (args.isDisposed() || !response.ok) { + return + } + const event = response.result as SessionTabsEvent + if (event.type === 'snapshots') { + applyStructuredSessionTabSnapshots(event.snapshots) + } else if (event.type === 'snapshot' || event.type === 'updated') { + applyStructuredSessionTabSnapshots([event]) + } + } + ) + if (args.isDisposed()) { + handle.unsubscribe() + } else { + args.setUnsubscribe(handle.unsubscribe) + } +} + +export function useLocalStructuredSessionTabsSync(): void { + const ready = useAppStore( + (state) => state.workspaceSessionReady && state.terminalStartupRestorationReady + ) + useEffect(() => { + if (!ready) { + return + } + let disposed = false + let unsubscribe = (): void => {} + void startLocalStructuredSessionTabsSync({ + isDisposed: () => disposed, + setUnsubscribe: (next) => { + unsubscribe = next + } + }).catch((error) => console.warn('[structured-session-tabs] sync failed', error)) + return () => { + disposed = true + unsubscribe() + } + }, [ready]) +} diff --git a/src/renderer/src/runtime/runtime-host-connection-state.test.ts b/src/renderer/src/runtime/runtime-host-connection-state.test.ts index 17acb2395bb..893be5abf8e 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.test.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.test.ts @@ -78,6 +78,46 @@ describe('runtime host connection state', () => { ).toBe('reconnecting') }) + function remoteControl( + overrides: Partial> + ): NonNullable { + return { + state: 'ready', + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 0, + lastConnectedAt: null, + lastClose: null, + lastError: null, + ...overrides + } + } + + it('reports a cleanly closed control channel as disconnected even with no error', () => { + // Why: a clean close (server restart, host sleep, network blip) leaves lastError null. + // Requiring an error string to call it disconnected paints a dead host green. + expect( + runtimeHostConnectionState({ + hasStatusEntry: true, + status: { ...makeStatus(), remoteControl: remoteControl({ state: 'closed' }) } + }) + ).toBe('disconnected') + }) + + it('does not call a half-open handshake connected', () => { + // Why: the socket is up but the runtime has not completed ready/auth, so nothing + // can run there yet. Green here is the same lie as a closed channel reading connected. + for (const state of ['awaiting_ready', 'awaiting_authenticated'] as const) { + expect( + runtimeHostConnectionState({ + hasStatusEntry: true, + status: { ...makeStatus(), remoteControl: remoteControl({ state }) } + }), + state + ).toBe('checking') + } + }) + it('does not hide a closed control channel behind workspace-window diagnostics', () => { expect( runtimeHostConnectionState({ diff --git a/src/renderer/src/runtime/runtime-host-connection-state.ts b/src/renderer/src/runtime/runtime-host-connection-state.ts index 6b3f97bd778..c3e65533f2e 100644 --- a/src/renderer/src/runtime/runtime-host-connection-state.ts +++ b/src/renderer/src/runtime/runtime-host-connection-state.ts @@ -31,9 +31,15 @@ export function runtimeHostConnectionState({ if (!status) { return 'disconnected' } - if (remoteControl?.state === 'closed' && remoteControl.lastError) { + // Why no lastError requirement: a clean close (server restart, host sleep, network + // blip) leaves lastError null, and demanding an error string painted those hosts green. + if (remoteControl?.state === 'closed') { return 'disconnected' } + // Why: the socket is up but ready/auth has not completed, so nothing can run there yet. + if (remoteControl && remoteControl.state !== 'ready') { + return 'checking' + } // Why: reachable but graph-less — the transport is fine, so this is not a network // disconnect, but calling it "Connected" hides that nothing will run there. if (isRuntimeWorkspaceWindowClosed(status)) { diff --git a/src/renderer/src/runtime/structured-agent-session-client.test.ts b/src/renderer/src/runtime/structured-agent-session-client.test.ts new file mode 100644 index 00000000000..799be15668d --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-client.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + subscribe: vi.fn() +})) + +vi.mock('./runtime-environment-revision', () => ({ + getRuntimeEnvironmentRevision: () => 7 +})) + +vi.mock('./runtime-rpc-client', () => ({ + callRuntimeRpc: vi.fn() +})) + +import { subscribeStructuredAgentSession } from './structured-agent-session-client' + +describe('subscribeStructuredAgentSession', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.subscribe.mockResolvedValue({ unsubscribe: vi.fn() }) + Object.assign(window, { + api: { + runtimeEnvironments: { subscribe: mocks.subscribe } + } + }) + }) + + it('forwards graceful remote closes to the reconnect owner', async () => { + const onClose = vi.fn() + + await subscribeStructuredAgentSession( + { kind: 'environment', environmentId: 'env-1' }, + { sessionId: 'session-1' }, + vi.fn(), + vi.fn(), + onClose + ) + + const callbacks = mocks.subscribe.mock.calls[0]?.[1] as { onClose?: () => void } + expect(callbacks.onClose).toBe(onClose) + callbacks.onClose?.() + expect(onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-client.ts b/src/renderer/src/runtime/structured-agent-session-client.ts new file mode 100644 index 00000000000..0e8d2ce16f2 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-client.ts @@ -0,0 +1,41 @@ +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' +import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client' + +export function callStructuredAgentSession( + target: RuntimeClientTarget, + method: string, + params?: unknown +): Promise { + return callRuntimeRpc(target, method, params) +} + +export async function subscribeStructuredAgentSession( + target: RuntimeClientTarget, + params: unknown, + onEvent: (event: AgentSessionSubscribeEvent) => void, + onError: (error: unknown) => void, + onClose: () => void +): Promise<{ unsubscribe: () => void }> { + const onResponse = (response: RuntimeRpcResponse): void => { + if (!response.ok) { + onError(response.error) + return + } + onEvent(response.result as AgentSessionSubscribeEvent) + } + if (target.kind === 'local') { + return window.api.runtime.subscribe({ method: 'agentSession.subscribe', params }, onResponse) + } + return window.api.runtimeEnvironments.subscribe( + { + selector: target.environmentId, + method: 'agentSession.subscribe', + params, + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: getRuntimeEnvironmentRevision(target.environmentId) + }, + { onResponse, onError, onClose } + ) +} diff --git a/src/renderer/src/runtime/structured-agent-session-close.test.ts b/src/renderer/src/runtime/structured-agent-session-close.test.ts new file mode 100644 index 00000000000..2f545d41570 --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-close.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + supportsCapability: vi.fn() +})) + +vi.mock('./runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +vi.mock('./structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call +})) + +import { closeStructuredAgentSession } from './structured-agent-session-close' + +beforeEach(() => { + vi.clearAllMocks() + mocks.call.mockResolvedValue({ ok: true }) + mocks.supportsCapability.mockResolvedValue(true) +}) + +describe('closeStructuredAgentSession', () => { + it('closes a local session without a redundant capability probe', async () => { + await expect(closeStructuredAgentSession({ kind: 'local' }, 'codex-session-1')).resolves.toBe( + 'closed' + ) + + expect(mocks.supportsCapability).not.toHaveBeenCalled() + expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.close', { + sessionId: 'codex-session-1' + }) + }) + + it('closes through a paired host that advertises the structured session surface', async () => { + const target = { kind: 'environment', environmentId: 'env-1' } as const + + await expect(closeStructuredAgentSession(target, 'claude-session-1')).resolves.toBe('closed') + + expect(mocks.call).toHaveBeenCalledWith(target, 'agentSession.close', { + sessionId: 'claude-session-1' + }) + }) + + it('does not send an unknown method to a legacy paired host', async () => { + mocks.supportsCapability.mockResolvedValue(false) + + await expect( + closeStructuredAgentSession( + { kind: 'environment', environmentId: 'legacy-env' }, + 'codex-session-1' + ) + ).resolves.toBe('unsupported') + + expect(mocks.call).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-close.ts b/src/renderer/src/runtime/structured-agent-session-close.ts new file mode 100644 index 00000000000..d11094379dd --- /dev/null +++ b/src/renderer/src/runtime/structured-agent-session-close.ts @@ -0,0 +1,23 @@ +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { + runtimeEnvironmentSupportsCapability, + type RuntimeClientTarget +} from './runtime-rpc-client' +import { callStructuredAgentSession } from './structured-agent-session-client' + +export async function closeStructuredAgentSession( + target: RuntimeClientTarget, + sessionId: string +): Promise<'closed' | 'unsupported'> { + if ( + target.kind === 'environment' && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + )) + ) { + return 'unsupported' + } + await callStructuredAgentSession(target, 'agentSession.close', { sessionId }) + return 'closed' +} diff --git a/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts b/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts index 393e64b0621..1df1f4df697 100644 --- a/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts +++ b/src/renderer/src/runtime/web-session-client-owned-tab-placement.ts @@ -144,7 +144,14 @@ export function reconcileClientOwnedTabPlacement( if (working.has(requestedGroupId) || layoutGroupIds.has(requestedGroupId)) { return requestedGroupId } - return activeGroupIdIfValid ?? groupOrder[0] + // Why: with no local group to join, repair the rendered leaf first; failing that, + // materialize the requested group so a tab is never published into an unrendered one. + return ( + activeGroupIdIfValid ?? + groupOrder[0] ?? + layoutGroupIds.values().next().value ?? + requestedGroupId + ) } const movedTabIds = new Set() diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index 5fd06192f40..a23013f46ce 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -82,6 +82,11 @@ export function resolveWebSessionVisibleTabId( const tabId = state.activeTabIdByWorktree?.[worktreeId] return tabId && tabs.some((tab) => tab.id === tabId) ? tabId : null } + // Why: a structured chat tab has no per-worktree active-entity map to address it by, so the + // entityId lookup below would always miss. There is at most one per worktree here. + if (currentType === 'agent-session') { + return tabs.find((tab) => tab.contentType === 'agent-session')?.id ?? null + } const entityId = currentType === 'browser' ? state.activeBrowserTabIdByWorktree?.[worktreeId] diff --git a/src/renderer/src/runtime/web-session-intent-owner.test.ts b/src/renderer/src/runtime/web-session-intent-owner.test.ts index e86ada791b1..137b89ed991 100644 --- a/src/renderer/src/runtime/web-session-intent-owner.test.ts +++ b/src/renderer/src/runtime/web-session-intent-owner.test.ts @@ -6,6 +6,7 @@ import { } from './web-session-close-intent' import { peekWebSessionFocusIntent, + clearWebSessionFocusIntentIfMatches, recordWebSessionFocusIntent, resetWebSessionFocusIntentForTests } from './web-session-focus-intent' @@ -47,6 +48,16 @@ describe('web session intent ownership', () => { expect(peekWebSessionFocusIntent(OWNER_B, WORKTREE_ID)).toBeNull() }) + it('does not let an older failed create clear a newer focus intent', () => { + recordWebSessionFocusIntent(OWNER_A, WORKTREE_ID, 'agent-session:newer') + + clearWebSessionFocusIntentIfMatches(OWNER_A, WORKTREE_ID, 'agent-session:older') + + expect(peekWebSessionFocusIntent(OWNER_A, WORKTREE_ID)).toEqual({ + hostTabId: 'agent-session:newer' + }) + }) + it('isolates reorder intents across runtimes and same-id re-pairs', () => { recordWebSessionReorderIntent(OWNER_A, WORKTREE_ID, 'group-1', ['tab-b', 'tab-a'], 1_000) diff --git a/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts new file mode 100644 index 00000000000..cde8bb66991 --- /dev/null +++ b/src/renderer/src/runtime/web-session-structured-tab-focus.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::/worktree' +const GROUP_ID = 'group-1' + +function structuredTab(sessionId: string, sortOrder: number): Tab { + return { + id: `structured-agent-session-${sessionId}`, + entityId: sessionId, + groupId: GROUP_ID, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1, + isPinned: false + } +} + +describe('web session structured tab focus', () => { + it('keeps the exact active structured tab across a host snapshot', () => { + const first = structuredTab('session-1', 0) + const second = structuredTab('session-2', 1) + const state = { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'terminal' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: GROUP_ID, + worktreeId: WORKTREE_ID, + activeTabId: second.id, + tabOrder: [first.id, second.id] + } + ] + }, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [first.id, second.id] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: [first, second] }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } as WebSessionTabsSyncState + const snapshot: RuntimeMobileSessionTabsResult = { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: GROUP_ID, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabGroups: [ + { + id: GROUP_ID, + activeTabId: 'agent-session:session-1', + tabOrder: ['agent-session:session-1', 'agent-session:session-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex', + isActive: true + }, + { + type: 'agent-session', + id: 'agent-session:session-2', + title: 'Codex Chat', + sessionId: 'session-2', + agent: 'codex', + isActive: false + } + ] + } + + const patch = applyWebSessionTabsSnapshot(state, snapshot, 'environment-1', 10) + + const applied = patch === state ? state : ({ ...state, ...patch } as WebSessionTabsSyncState) + + expect(applied.groupsByWorktree[WORKTREE_ID]?.[0]?.activeTabId).toBe(second.id) + expect(applied.unifiedTabsByWorktree[WORKTREE_ID]).toBe( + state.unifiedTabsByWorktree[WORKTREE_ID] + ) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index e6ddab72906..9e3d53751f6 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -14,6 +14,7 @@ import { acceptReplayedWebSessionTabsSnapshot, applyFreshWebSessionTabsSnapshot, applyWebSessionTabsSnapshot, + resolveHostSessionTabIdForWebSessionTab, shouldApplyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync' @@ -41,6 +42,91 @@ vi.mock('@/hooks/agent-hook-completion-notifications', () => ({ describe('applyWebSessionTabsSnapshot', () => { beforeEach(resetWebSessionTabsSyncTestState) + it('projects structured agent sessions as native unified tabs', () => { + const agentTab = { + type: 'agent-session' as const, + id: 'agent-session:session-1', + title: 'Codex Chat', + sessionId: 'session-1', + agent: 'codex' as const, + isActive: true + } + const patch = applyWebSessionTabsSnapshot( + makeState(), + makeSnapshot([agentTab], { + activeTabId: agentTab.id, + activeTabType: 'agent-session', + tabGroups: [ + { + id: 'host-group-1', + activeTabId: agentTab.id, + tabOrder: [agentTab.id] + } + ] + }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toEqual([ + expect.objectContaining({ + id: 'structured-agent-session-session-1', + entityId: 'session-1', + contentType: 'agent-session', + agentSessionAgent: 'codex' + }) + ]) + expect(patch.activeTabTypeByWorktree?.[WT]).toBe('agent-session') + expect( + resolveHostSessionTabIdForWebSessionTab( + { ...makeState(), ...patch }, + { environmentId: ENV, worktreeId: WT, tabId: 'structured-agent-session-session-1' } + ) + ).toBe(agentTab.id) + }) + + it('removes a restored structured tab when the host publishes no structured sessions', () => { + const structuredTab: Tab = { + id: 'structured-agent-session-session-1', + entityId: 'session-1', + groupId: 'host-group-1', + worktreeId: WT, + contentType: 'agent-session', + agentSessionAgent: 'codex', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + const patch = applyWebSessionTabsSnapshot( + makeState({ + activeTabId: structuredTab.id, + activeTabIdByWorktree: { [WT]: structuredTab.id }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WT]: 'agent-session' }, + unifiedTabsByWorktree: { [WT]: [structuredTab] }, + tabBarOrderByWorktree: { [WT]: [structuredTab.id] }, + groupsByWorktree: { + [WT]: [ + { + id: 'host-group-1', + worktreeId: WT, + activeTabId: structuredTab.id, + tabOrder: [structuredTab.id] + } + ] + } + }), + makeSnapshot([], { activeTabType: null }), + ENV, + NOW + ) + + expect(patch.unifiedTabsByWorktree?.[WT]).toBeUndefined() + expect(patch.activeTabTypeByWorktree?.[WT]).toBe('terminal') + }) + it('ignores stale or duplicate same-epoch snapshots after a newer version was applied', () => { const state = makeState() const newer = makeSnapshot([], { snapshotVersion: 3, activeTabType: null }) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 073263be99a..a213b2dbfe3 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -15,6 +15,7 @@ import { normalizeTurnCompletedAtField } from '../../../shared/agent-status-fiel import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume' import type { RuntimeMobileSessionTabsResult, + RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionFileTab, @@ -96,6 +97,7 @@ import { shouldSkipWebRuntimeWakeTerminalRespawn } from './web-runtime-wake-terminal-respawn' import { isRuntimeSubscriptionReplayResponse } from '../../../shared/runtime-subscription-replay' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { queueAcceptedWebSessionTerminalSnapshot } from './web-session-terminal-handle-events' import { recoverWebSessionTerminalOrphansBeforeApply } from './web-session-terminal-orphan-recovery' import { @@ -166,6 +168,12 @@ type SessionTabsRemovalFence = { pendingCount: number } +export type WebSessionTabsSnapshotApplyOptions = { + contentScope?: 'all' | 'agent-session' + preserveLocalLayout?: boolean + terminalPtyMode?: 'local' | 'remote' +} + type TrackedWebSessionTabsWorktree = { worktree: string freshness: SnapshotFreshness @@ -211,6 +219,11 @@ type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'r type ReadyBrowserSurface = RuntimeMobileSessionBrowserTab & { browserPageId: string } type ReadyEditorSurface = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab +type MirroredAgentTab = { + hostTabId: string + unifiedTab: Tab +} + type MirroredTerminalTab = { tab: TerminalTab hostTabId: string @@ -957,6 +970,45 @@ function isReadyEditorTab( return tab.type === 'markdown' || tab.type === 'file' } +function isAgentSessionTab( + tab: RuntimeMobileSessionTabsResult['tabs'][number] +): tab is RuntimeMobileSessionAgentTab { + return tab.type === 'agent-session' +} + +function buildMirroredAgentTabs( + snapshot: RuntimeMobileSessionTabsResult, + hostGroupIdByTabId: ReadonlyMap, + fallbackGroupId: string, + sortOffset: number, + currentUnifiedTabs: readonly Tab[], + now: number +): MirroredAgentTab[] { + return snapshot.tabs.filter(isAgentSessionTab).map((tab, index) => { + const localId = structuredAgentSessionTabId(tab.sessionId) + const existing = currentUnifiedTabs.find( + (candidate) => candidate.contentType === 'agent-session' && candidate.id === localId + ) + return { + hostTabId: tab.id, + unifiedTab: { + id: localId, + entityId: tab.sessionId, + groupId: hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId, + worktreeId: snapshot.worktree, + contentType: 'agent-session', + agentSessionAgent: tab.agent, + label: tab.title.trim() || 'Codex Chat', + customLabel: null, + color: tab.color !== undefined ? tab.color : (existing?.color ?? null), + sortOrder: sortOffset + index, + createdAt: existing?.createdAt ?? now + sortOffset + index, + isPinned: tab.isPinned !== undefined ? tab.isPinned : existing?.isPinned === true + } + } + }) +} + function localEditorFileId(tab: ReadyEditorSurface): string { if (tab.type === 'markdown' && tab.mode === 'markdown-preview') { return `markdown-preview::${tab.sourceFilePath}` @@ -1074,7 +1126,8 @@ function buildMirroredTerminalTabs( existingLayoutsByTabId: Readonly>, sortOffset: number, now: number, - focusTarget?: { parentTabId: string; leafId: string } + focusTarget?: { parentTabId: string; leafId: string }, + terminalPtyMode: 'local' | 'remote' = 'remote' ): MirroredTerminalTab[] { const groups = new Map() for (const tab of snapshot.tabs.filter(isTerminalSurfaceTab)) { @@ -1097,10 +1150,12 @@ function buildMirroredTerminalTabs( : undefined) ?? surfaces.find((surface) => surface.isActive) ?? surfaces[0]! + const ptyIdForSurface = (handle: string): string => + terminalPtyMode === 'local' ? handle : toRemoteRuntimePtyId(handle, environmentId) const ptyIdsByLeafId = Object.fromEntries( surfaces .filter((surface): surface is ReadyTerminalSurface => surface.status === 'ready') - .map((surface) => [surface.leafId, toRemoteRuntimePtyId(surface.terminal, environmentId)]) + .map((surface) => [surface.leafId, ptyIdForSurface(surface.terminal)]) ) const layout = normalizeTerminalLayoutPtyOwnership( chooseRemoteTerminalLayout(surfaces, ptyIdsByLeafId, existingLayout, requestedActiveLeafId) @@ -2047,12 +2102,14 @@ function buildHostToLocalTabIdMap({ terminalSurfaces, terminalTabs, browserTabs, - editorTabs + editorTabs, + agentTabs }: { terminalSurfaces: readonly TerminalSurface[] terminalTabs: readonly TerminalTab[] browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] }): Map { const hostToLocal = new Map() const terminalIds = new Set(terminalTabs.map((tab) => tab.id)) @@ -2070,6 +2127,9 @@ function buildHostToLocalTabIdMap({ for (const entry of editorTabs) { hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) } + for (const entry of agentTabs) { + hostToLocal.set(entry.hostTabId, entry.unifiedTab.id) + } return hostToLocal } @@ -2080,6 +2140,7 @@ function updateHostSessionTabIdMappings(args: { terminalTabs: readonly TerminalTab[] browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] + agentTabs: readonly MirroredAgentTab[] }): void { clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) @@ -2096,6 +2157,12 @@ function updateHostSessionTabIdMappings(args: { for (const entry of args.editorTabs) { setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } + for (const entry of args.agentTabs) { + hostSessionTabIdByLocalKey.set( + hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), + entry.hostTabId + ) + } } function retainClientPlacedMirroredTabs(args: { @@ -2602,6 +2669,7 @@ function tabEqual(a: Tab, b: Tab): boolean { a.worktreeId === b.worktreeId && a.executionHostId === b.executionHostId && a.contentType === b.contentType && + a.agentSessionAgent === b.agentSessionAgent && a.label === b.label && // Why: the generated label is the visible tab title; ignoring it let the // equality bail keep a unified tab that disagreed with its terminal tab. @@ -2647,6 +2715,9 @@ function sameGroups(a: readonly TabGroup[] | undefined, b: readonly TabGroup[] | } function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] { + if (tab.contentType === 'agent-session') { + return 'agent-session' + } if (tab.contentType === 'browser' || tab.contentType === 'terminal') { return tab.contentType } @@ -2658,7 +2729,8 @@ function applyWebSessionTabsSnapshotWithContext( rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, now = Date.now(), - batchContext?: WebSessionTabsBatchContext + batchContext?: WebSessionTabsBatchContext, + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { if (suppressE2eWebRuntimeBrowserSnapshot(rawSnapshot)) { return state @@ -2730,7 +2802,10 @@ function applyWebSessionTabsSnapshotWithContext( } const currentTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] const existingTerminalById = new Map(currentTerminalTabs.map((tab) => [tab.id, tab])) - const terminalSurfaceTabs = snapshot.tabs.filter(isTerminalSurfaceTab) + const reconcilesNonAgentTabs = options?.contentScope !== 'agent-session' + const terminalSurfaceTabs = reconcilesNonAgentTabs + ? snapshot.tabs.filter(isTerminalSurfaceTab) + : [] const readyTerminalTabs = terminalSurfaceTabs.filter(isReadyTerminalTab) const nextRemotePtyIds = new Set( readyTerminalTabs.map((tab) => toRemoteRuntimePtyId(tab.terminal, environmentId)) @@ -2763,16 +2838,18 @@ function applyWebSessionTabsSnapshotWithContext( } } const exactProvisionalHandoffs = new Set(provisionalHandoffHostTabIds.keys()) - const retainedTerminalTabs = currentTerminalTabs.filter( - (tab) => - !shouldReplaceTerminalTab( - tab, - environmentId, - nextRemotePtyIds, - nextMirroredTerminalIds, - exactProvisionalHandoffs + const retainedTerminalTabs = reconcilesNonAgentTabs + ? currentTerminalTabs.filter( + (tab) => + !shouldReplaceTerminalTab( + tab, + environmentId, + nextRemotePtyIds, + nextMirroredTerminalIds, + exactProvisionalHandoffs + ) ) - ) + : currentTerminalTabs const mirroredTerminalTabs = buildMirroredTerminalTabs( snapshot, environmentId, @@ -2785,7 +2862,8 @@ function applyWebSessionTabsSnapshotWithContext( parentTabId: callerFocusIntentTab.parentTabId, leafId: callerFocusIntentTab.leafId } - : undefined + : undefined, + options?.terminalPtyMode ) const mirroredTerminalTabEntries = mirroredTerminalTabs.map((entry) => entry.tab) const retainedTerminalIds = new Set(retainedTerminalTabs.map((tab) => tab.id)) @@ -2806,10 +2884,9 @@ function applyWebSessionTabsSnapshotWithContext( const targetGroupId = chooseTargetGroupId(state, snapshot) const hostGroupIdByTabId = buildHostGroupIdByTabId(snapshot.tabGroups) - const existingTabIndex = buildWebSessionExistingTabIndex({ - unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? [] - }) - const readyBrowserTabs = snapshot.tabs.filter(isReadyBrowserTab) + const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) + const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) const mirroredBrowserTabs = buildMirroredBrowserTabs( snapshot, @@ -2825,7 +2902,7 @@ function applyWebSessionTabsSnapshotWithContext( ) const currentBrowserTabs = state.browserTabsByWorktree[worktreeId] ?? [] const removedBrowserWorkspaceIds = new Set( - currentBrowserTabs + (reconcilesNonAgentTabs ? currentBrowserTabs : []) .filter((tab) => { if (mirroredBrowserWorkspaceIds.has(tab.id)) { return true @@ -2876,7 +2953,7 @@ function applyWebSessionTabsSnapshotWithContext( retainedBrowserTabs.length + mirroredBrowserTabs.length > 0 ? [...retainedBrowserTabs, ...mirroredBrowserTabs.map((entry) => entry.workspace)] : null - const readyEditorTabs = snapshot.tabs.filter(isReadyEditorTab) + const readyEditorTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyEditorTab) : [] const worktreeOpenFiles = webSessionOpenFilesForWorktree(state, worktreeId, batchContext) const mirroredEditorTabs = buildMirroredEditorTabs( snapshot, @@ -2888,10 +2965,18 @@ function applyWebSessionTabsSnapshotWithContext( mirroredTerminalTabEntries.length + mirroredBrowserTabs.length, now ) + const mirroredAgentTabs = buildMirroredAgentTabs( + snapshot, + hostGroupIdByTabId, + targetGroupId, + mirroredTerminalTabEntries.length + mirroredBrowserTabs.length + mirroredEditorTabs.length, + currentUnifiedTabs, + now + ) const mirroredEditorFileIds = new Set(mirroredEditorTabs.map((entry) => entry.file.id)) const mirroredEditorHostTabIds = new Set(mirroredEditorTabs.map((entry) => entry.hostTabId)) const removedEditorFileIds = new Set( - worktreeOpenFiles + (reconcilesNonAgentTabs ? worktreeOpenFiles : []) .filter( (file) => file.runtimeEnvironmentId === environmentId && @@ -2933,8 +3018,10 @@ function applyWebSessionTabsSnapshotWithContext( return sameOpenFiles(state.openFiles, next) ? state.openFiles : next })() advanceWebSessionOpenFilesIndex(batchContext, nextOpenFiles, worktreeId) - const currentUnifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const retainedUnifiedTabs = currentUnifiedTabs.filter((tab) => { + if (tab.contentType === 'agent-session') { + return false + } if (tab.contentType === 'browser') { return ( !removedBrowserWorkspaceIds.has(tab.entityId) && @@ -2971,10 +3058,12 @@ function applyWebSessionTabsSnapshotWithContext( ) const mirroredBrowserUnifiedTabs = mirroredBrowserTabs.map((entry) => entry.unifiedTab) const mirroredEditorUnifiedTabs = mirroredEditorTabs.map((entry) => entry.unifiedTab) + const mirroredAgentUnifiedTabs = mirroredAgentTabs.map((entry) => entry.unifiedTab) const mirroredUnifiedTabs = [ ...mirroredTerminalUnifiedTabs, ...mirroredBrowserUnifiedTabs, - ...mirroredEditorUnifiedTabs + ...mirroredEditorUnifiedTabs, + ...mirroredAgentUnifiedTabs ] const nextUnifiedTabs = retainedUnifiedTabs.length + mirroredUnifiedTabs.length > 0 @@ -3012,6 +3101,14 @@ function applyWebSessionTabsSnapshotWithContext( : null const activeMirroredEditorFileId = activeMirroredEditor?.file.id ?? null const activeMirroredEditorTabId = activeMirroredEditor?.unifiedTab.id ?? null + const activeHostAgent = + snapshot.tabs + .filter(isAgentSessionTab) + .find((tab) => tab.id === snapshot.activeTabId || tab.isActive) ?? null + const activeMirroredAgentTabId = activeHostAgent + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === activeHostAgent.id)?.unifiedTab.id ?? + null) + : null const intentMirroredTerminalId = navigationIntentTab?.type === 'terminal' ? toWebTerminalSurfaceTabId(navigationIntentTab.parentTabId) @@ -3028,6 +3125,10 @@ function applyWebSessionTabsSnapshotWithContext( navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' ? (mirroredEditorTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) : null + const intentMirroredAgent = + navigationIntentTab?.type === 'agent-session' + ? (mirroredAgentTabs.find((entry) => entry.hostTabId === navigationIntentTab.id) ?? null) + : null const currentActiveTerminalStillExists = state.activeTabIdByWorktree[worktreeId] && (nextTerminalTabs ?? []).some((tab) => tab.id === state.activeTabIdByWorktree[worktreeId]) @@ -3081,6 +3182,13 @@ function applyWebSessionTabsSnapshotWithContext( worktreeId, nextUnifiedTabs ?? [] ) + const currentVisibleStructuredTabId = + currentVisibleUnifiedTabId && + nextUnifiedTabs?.find( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? currentVisibleUnifiedTabId + : null const activeGroupId = state.activeGroupIdByWorktree[worktreeId] // Why: Open Preview to the Side can activate an empty reserved group before the host // browser lands. A snapshot that still has the host terminal active must not treat @@ -3097,31 +3205,36 @@ function applyWebSessionTabsSnapshotWithContext( ? (intentMirroredBrowser?.unifiedTab.id ?? null) : navigationIntentTab?.type === 'terminal' ? intentTerminalId - : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' - ? (intentMirroredEditor?.unifiedTab.id ?? null) - : null + : navigationIntentTab?.type === 'agent-session' + ? (intentMirroredAgent?.unifiedTab.id ?? null) + : navigationIntentTab?.type === 'markdown' || navigationIntentTab?.type === 'file' + ? (intentMirroredEditor?.unifiedTab.id ?? null) + : null : null const nextActiveUnifiedTabId = intentUnifiedTabId ?? currentVisibleUnifiedTabId ?? reservedEmptyPreviewFallbackTabId ?? - (snapshot.activeTabType === 'browser' - ? (activeMirroredBrowserTabId ?? - mirroredBrowserTabs[0]?.unifiedTab.id ?? - state.activeTabIdByWorktree[worktreeId] ?? - nextActiveTerminalId) - : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' - ? (activeMirroredEditorTabId ?? - mirroredEditorTabs[0]?.unifiedTab.id ?? + (snapshot.activeTabType === 'agent-session' + ? (activeMirroredAgentTabId ?? mirroredAgentUnifiedTabs[0]?.id ?? nextActiveTerminalId) + : snapshot.activeTabType === 'browser' + ? (activeMirroredBrowserTabId ?? + mirroredBrowserTabs[0]?.unifiedTab.id ?? state.activeTabIdByWorktree[worktreeId] ?? nextActiveTerminalId) - : nextActiveTerminalId) + : snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file' + ? (activeMirroredEditorTabId ?? + mirroredEditorTabs[0]?.unifiedTab.id ?? + state.activeTabIdByWorktree[worktreeId] ?? + nextActiveTerminalId) + : nextActiveTerminalId) const mirroredUnifiedIds = new Set(mirroredUnifiedTabs.map((tab) => tab.id)) const hostToLocalTabId = buildHostToLocalTabIdMap({ terminalSurfaces: terminalSurfaceTabs, terminalTabs: mirroredTerminalTabEntries, browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs }) updateHostSessionTabIdMappings({ environmentId, @@ -3129,7 +3242,8 @@ function applyWebSessionTabsSnapshotWithContext( terminalSurfaces: terminalSurfaceTabs, terminalTabs: mirroredTerminalTabEntries, browserTabs: mirroredBrowserTabs, - editorTabs: mirroredEditorTabs + editorTabs: mirroredEditorTabs, + agentTabs: mirroredAgentTabs }) const currentGroups = state.groupsByWorktree[worktreeId] ?? [] @@ -3142,7 +3256,11 @@ function applyWebSessionTabsSnapshotWithContext( // append never-seen tabs, drop vanished ones, and honor explicit focus intent. Host order, // host actives, and host layout apply only on first adoption (no client groups yet). const clientOwnedPlacement = (() => { - if (currentGroups.length === 0 || !nextUnifiedTabs) { + // Why: a preserveLocalLayout owner keeps the local layout authoritative, so placement + // is client-owned even before any local group record exists — first adoption on an + // empty worktree must repair a rendered-leaf-without-record or materialize a rendered + // group instead of publishing the tab into a group no local leaf will ever show. + if (!nextUnifiedTabs || (currentGroups.length === 0 && !options?.preserveLocalLayout)) { return null } // Why: an entity-identical replacement (provisional terminal → mirrored surface, local @@ -3587,6 +3705,9 @@ function applyWebSessionTabsSnapshotWithContext( if (!nextGroups) { return state.layoutByWorktree } + // Why: client-owned placement derives its layout from the local one (pruned, plus + // repair leaves for surviving groups the layout lost), so a preserveLocalLayout owner + // still applies it — the option only rejects host-authored layout below. if (clientOwnedPlacement) { const clientLayout = clientOwnedPlacement.layout ?? @@ -3603,6 +3724,9 @@ function applyWebSessionTabsSnapshotWithContext( batchContext ) } + if (options?.preserveLocalLayout) { + return state.layoutByWorktree + } const validGroupIds = new Set(nextGroups.map((group) => group.id)) const hostLayout = pruneTabGroupLayout(snapshot.tabGroupLayout, validGroupIds) const defaultLeafLayout = { type: 'leaf' as const, groupId: nextActiveGroupId ?? targetGroupId } @@ -3653,12 +3777,15 @@ function applyWebSessionTabsSnapshotWithContext( batchContext ) const nextActiveTabIdByWorktree = - (state.activeTabIdByWorktree[worktreeId] ?? null) !== nextActiveTerminalId + (state.activeTabIdByWorktree[worktreeId] ?? null) !== + (intentMirroredAgent?.unifiedTab.id ?? currentVisibleStructuredTabId ?? nextActiveTerminalId) ? withWorktreeEntry( state, 'activeTabIdByWorktree', worktreeId, - nextActiveTerminalId, + intentMirroredAgent?.unifiedTab.id ?? + currentVisibleStructuredTabId ?? + nextActiveTerminalId, (current, next) => (current ?? null) === next, batchContext, false @@ -3690,32 +3817,44 @@ function applyWebSessionTabsSnapshotWithContext( : state.activeFileIdByWorktree const isActiveWorktree = state.activeWorktreeId === worktreeId const focusIntentVisibleTabType = - navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId - ? ('browser' as const) - : navigationIntentTab?.type === 'terminal' && intentTerminalId - ? ('terminal' as const) - : intentEditorFileId - ? ('editor' as const) - : null + navigationIntentTab?.type === 'agent-session' && intentMirroredAgent + ? ('agent-session' as const) + : navigationIntentTab?.type === 'browser' && intentBrowserWorkspaceId + ? ('browser' as const) + : navigationIntentTab?.type === 'terminal' && intentTerminalId + ? ('terminal' as const) + : intentEditorFileId + ? ('editor' as const) + : null const snapshotVisibleTabType = - snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId - ? ('browser' as const) - : snapshot.activeTabType === 'terminal' && nextActiveTerminalId - ? ('terminal' as const) - : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && - nextActiveEditorFileId - ? ('editor' as const) - : null + snapshot.activeTabType === 'agent-session' && activeMirroredAgentTabId + ? ('agent-session' as const) + : snapshot.activeTabType === 'browser' && nextActiveBrowserWorkspaceId + ? ('browser' as const) + : snapshot.activeTabType === 'terminal' && nextActiveTerminalId + ? ('terminal' as const) + : (snapshot.activeTabType === 'markdown' || snapshot.activeTabType === 'file') && + nextActiveEditorFileId + ? ('editor' as const) + : null const currentVisibleTabType = state.activeTabTypeByWorktree[worktreeId] ?? (isActiveWorktree ? state.activeTabType : null) const currentVisibleTabTypeStillValid = - currentVisibleTabType === 'browser' && currentActiveBrowserStillExists - ? ('browser' as const) - : currentVisibleTabType === 'editor' && currentActiveEditorStillExists - ? ('editor' as const) - : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists - ? ('terminal' as const) - : null + currentVisibleStructuredTabId !== null + ? ('agent-session' as const) + : currentVisibleTabType === 'agent-session' && + currentVisibleUnifiedTabId && + nextUnifiedTabs?.some( + (tab) => tab.id === currentVisibleUnifiedTabId && tab.contentType === 'agent-session' + ) + ? ('agent-session' as const) + : currentVisibleTabType === 'browser' && currentActiveBrowserStillExists + ? ('browser' as const) + : currentVisibleTabType === 'editor' && currentActiveEditorStillExists + ? ('editor' as const) + : currentVisibleTabType === 'terminal' && currentActiveTerminalStillExists + ? ('terminal' as const) + : null const activeUnifiedTab = nextActiveUnifiedTabId && nextUnifiedTabs ? (nextUnifiedTabs.find((tab) => tab.id === nextActiveUnifiedTabId) ?? null) @@ -3746,9 +3885,10 @@ function applyWebSessionTabsSnapshotWithContext( ? state.activeFileId : null const nextActiveTabId = isActiveWorktree - ? snapshot.activeTabType === 'terminal' - ? nextActiveTerminalId - : (currentActiveTerminalStillValid ?? nextActiveTerminalId) + ? (intentMirroredAgent?.unifiedTab.id ?? + (snapshot.activeTabType === 'terminal' + ? nextActiveTerminalId + : (currentActiveTerminalStillValid ?? nextActiveTerminalId))) : state.activeTabId const nextActiveBrowserTabId = isActiveWorktree ? nextActiveBrowserWorkspaceId @@ -3873,9 +4013,17 @@ export function applyWebSessionTabsSnapshot( state: WebSessionTabsSyncState, rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, - now = Date.now() + now = Date.now(), + options?: WebSessionTabsSnapshotApplyOptions ): WebSessionTabsSyncState | Partial { - return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now) + return applyWebSessionTabsSnapshotWithContext( + state, + rawSnapshot, + environmentId, + now, + undefined, + options + ) } export function applyWebSessionTabsSnapshots( diff --git a/src/renderer/src/startup/startup-degraded-recovery.ts b/src/renderer/src/startup/startup-degraded-recovery.ts index 98a76f17cc9..987a74a8341 100644 --- a/src/renderer/src/startup/startup-degraded-recovery.ts +++ b/src/renderer/src/startup/startup-degraded-recovery.ts @@ -26,7 +26,10 @@ function forceWorkspaceSessionReady(): void { workspaceSessionReady: true, pendingReconnectWorktreeIds: [], pendingReconnectTabByWorktree: {}, - pendingReconnectPtyIdByTabId: {} + pendingReconnectPtyIdByTabId: {}, + // Why: the activation gate waits on this flag; a degraded boot must still release it + // or no worktree ever gets its fallback terminal. + terminalStartupRestorationReady: true }) } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 4405e8041c8..f54798b089d 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -2,9 +2,9 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { + agentSubagentsEqual, AGENT_STATUS_STALE_AFTER_MS, AGENT_STATE_HISTORY_MAX, - agentSubagentsEqual, type AgentStateHistoryEntry, type AgentStatusEntry, type AgentStatusOrchestrationContext, @@ -133,6 +133,7 @@ export type AgentStatusMetadata = { providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string + terminalResumeEligible?: false } export type AgentStatusUpdate = { @@ -599,7 +600,11 @@ function sleepingRecordFromEntry(args: { origin?: SleepingAgentSessionRecord['origin'] }): SleepingAgentSessionRecord | null { const agent = args.entry.agentType - if (!isResumableTuiAgent(agent) || !args.entry.providerSession) { + if ( + args.entry.terminalResumeEligible === false || + !isResumableTuiAgent(agent) || + !args.entry.providerSession + ) { return null } if (!getAgentResumeArgv(agent, args.entry.providerSession)) { @@ -2359,6 +2364,9 @@ export const createAgentStatusSlice: StateCreator( export function findHostedReviewRepoByPath( repos: readonly Repo[] | undefined, repoPath: string, - repoId?: string | null + repoId?: string | null, + repoOwnerExecutionHostId?: string ): Repo | undefined { - return repos?.find((candidate) => - repoId ? candidate.id === repoId : candidate.path === repoPath + const matches = repos?.filter( + (candidate) => + (repoId ? candidate.id === repoId : candidate.path === repoPath) && + (!repoOwnerExecutionHostId || candidate.path === repoPath) ) + if (repoOwnerExecutionHostId) { + return matches?.find( + (candidate) => getRepoExecutionHostId(candidate) === repoOwnerExecutionHostId + ) + } + return matches?.[0] +} + +export function findHostedReviewRepoForFetch( + repos: readonly Repo[] | undefined, + repoPath: string, + options: HostedReviewFetchOptions | undefined +): Repo | null | undefined { + const repo = findHostedReviewRepoByPath( + repos, + repoPath, + options?.repoId, + options?.repoOwnerExecutionHostId + ) + return options?.repoOwnerExecutionHostId && !repo ? null : repo +} + +export function hostedReviewOwnerIpcArgs(options: HostedReviewFetchOptions | undefined): { + repoOwnerExecutionHostId?: string +} { + return options?.repoOwnerExecutionHostId + ? { repoOwnerExecutionHostId: options.repoOwnerExecutionHostId } + : {} } export function shouldRefetchForLinkedHint( diff --git a/src/renderer/src/store/slices/hosted-review-card-refresh.ts b/src/renderer/src/store/slices/hosted-review-card-refresh.ts index 3ad33722f4a..d211d3be685 100644 --- a/src/renderer/src/store/slices/hosted-review-card-refresh.ts +++ b/src/renderer/src/store/slices/hosted-review-card-refresh.ts @@ -18,6 +18,7 @@ type RefreshHostedReviewCardArgs = { linkedBitbucketPR?: number | null linkedAzureDevOpsPR?: number | null linkedGiteaPR?: number | null + repoOwnerExecutionHostId?: string } export function refreshHostedReviewCard( @@ -28,6 +29,7 @@ export function refreshHostedReviewCard( return fetchHostedReviewForBranch(args.repoPath, args.branch, { force: true, repoId: args.repoId, + repoOwnerExecutionHostId: args.repoOwnerExecutionHostId, linkedGitHubPR: args.linkedGitHubPR ?? null, ...(fallbackGitHubPR !== null ? { fallbackGitHubPR } : {}), linkedGitLabMR: args.linkedGitLabMR ?? null, diff --git a/src/renderer/src/store/slices/hosted-review.ts b/src/renderer/src/store/slices/hosted-review.ts index 8b26f2c9362..f538d78d007 100644 --- a/src/renderer/src/store/slices/hosted-review.ts +++ b/src/renderer/src/store/slices/hosted-review.ts @@ -16,7 +16,9 @@ import { import { canReuseInflightHint, findHostedReviewRepoByPath, + findHostedReviewRepoForFetch, hasNewerHostedReviewCacheEntry, + hostedReviewOwnerIpcArgs, isFreshHostedReview, isStaleMergedGitHubReviewForHead, settingsForHostedReviewActionOwner, @@ -149,18 +151,17 @@ export const createHostedReviewSlice: StateCreator => { - const settings = get().settings - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) - const ownerSettings = settingsForHostedReviewRepoOwner(settings, repo) + const repo = findHostedReviewRepoForFetch(get().repos, repoPath, options) + if (repo === null) { + return null + } + const ownerSettings = settingsForHostedReviewRepoOwner(get().settings, repo) const target = getActiveRuntimeTarget(ownerSettings) - const repoId = options?.repoId ?? repo?.id const cacheKey = getHostedReviewCacheKey( repoPath, branch, ownerSettings, - repoId, + options?.repoId ?? repo?.id, repo?.connectionId, repo?.executionHostId, repo !== undefined @@ -219,6 +220,7 @@ export const createHostedReviewSlice: StateCreator = {}): PersistedUIWriteBaseline { + return { + sidebarWidth: 280, + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + rightSidebarWidth: 350, + markdownTocPanelWidth: 240, + combinedDiffFileTreeWidth: 256, + groupBy: 'repo', + sortBy: 'recent', + projectOrderBy: 'manual', + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + alwaysShowDefaultBranchWorkspace: true, + showDotfilesByWorktree: {}, + filterRepoIds: [], + acknowledgedAgentsByPaneKey: {}, + ...overrides + } +} + +describe('capturePersistedUIWriteBaseline', () => { + it('picks exactly the writer-owned fields off a superset', () => { + const superset = { ...makeBaseline(), persistedUIReady: true, repos: [] } + const captured = capturePersistedUIWriteBaseline(superset) + expect(Object.keys(captured).sort()).toEqual([...PERSISTED_UI_WRITE_BASELINE_FIELDS].sort()) + }) +}) + +describe('PERSISTED_UI_WRITE_BASELINE_FIELDS', () => { + it('covers every writer-owned field (runtime census, independent of the impl list)', () => { + // makeBaseline is a full literal maintained separately from the impl's field + // set; a field dropped from the impl list fails here even when tc is skipped. + expect([...PERSISTED_UI_WRITE_BASELINE_FIELDS].sort()).toEqual( + Object.keys(makeBaseline()).sort() + ) + }) +}) + +describe('diffPersistedUIWriteFields', () => { + it('is empty when values are equal even across fresh array/record identities', () => { + const a = makeBaseline({ + filterRepoIds: ['r1', 'r2'], + showDotfilesByWorktree: { w1: true }, + acknowledgedAgentsByPaneKey: { p1: 5 } + }) + const b = makeBaseline({ + filterRepoIds: ['r1', 'r2'], + showDotfilesByWorktree: { w1: true }, + acknowledgedAgentsByPaneKey: { p1: 5 } + }) + expect(diffPersistedUIWriteFields(a, b)).toEqual({}) + }) + + it('reports only the diverged fields, valued from the current mirror', () => { + const baseline = makeBaseline() + const current = makeBaseline({ showSleepingWorkspaces: false, filterRepoIds: ['r1'] }) + expect(diffPersistedUIWriteFields(current, baseline)).toEqual({ + showSleepingWorkspaces: false, + filterRepoIds: ['r1'] + }) + }) + + it('detects record content changes (added, removed, and re-valued keys)', () => { + const baseline = makeBaseline({ acknowledgedAgentsByPaneKey: { p1: 5, p2: 6 } }) + expect( + diffPersistedUIWriteFields(makeBaseline({ acknowledgedAgentsByPaneKey: { p1: 5 } }), baseline) + ).toEqual({ acknowledgedAgentsByPaneKey: { p1: 5 } }) + expect( + diffPersistedUIWriteFields( + makeBaseline({ acknowledgedAgentsByPaneKey: { p1: 5, p2: 7 } }), + baseline + ) + ).toEqual({ acknowledgedAgentsByPaneKey: { p1: 5, p2: 7 } }) + }) + + it('detects array order changes', () => { + const baseline = makeBaseline({ filterRepoIds: ['r1', 'r2'] }) + const current = makeBaseline({ filterRepoIds: ['r2', 'r1'] }) + expect(diffPersistedUIWriteFields(current, baseline)).toEqual({ filterRepoIds: ['r2', 'r1'] }) + }) +}) + +describe('persistedUIWriteFieldsToWireUpdate', () => { + it('inverts showSleepingWorkspaces to the durable hide form', () => { + expect(persistedUIWriteFieldsToWireUpdate({ showSleepingWorkspaces: true })).toEqual({ + hideSleepingWorkspaces: false + }) + expect(persistedUIWriteFieldsToWireUpdate({ showSleepingWorkspaces: false })).toEqual({ + hideSleepingWorkspaces: true + }) + }) + + it('copies filterRepoIds so main never receives the readonly store array', () => { + const filterRepoIds = ['r1'] + const update = persistedUIWriteFieldsToWireUpdate({ filterRepoIds }) + expect(update.filterRepoIds).toEqual(['r1']) + expect(update.filterRepoIds).not.toBe(filterRepoIds) + }) + + it('passes same-name fields through and never invents keys', () => { + const update = persistedUIWriteFieldsToWireUpdate({ + hideDefaultBranchWorkspace: true, + groupBy: 'none' + }) + expect(update).toEqual({ hideDefaultBranchWorkspace: true, groupBy: 'none' }) + }) +}) diff --git a/src/renderer/src/store/slices/persisted-ui-write-baseline.ts b/src/renderer/src/store/slices/persisted-ui-write-baseline.ts new file mode 100644 index 00000000000..d7a73c78b62 --- /dev/null +++ b/src/renderer/src/store/slices/persisted-ui-write-baseline.ts @@ -0,0 +1,173 @@ +import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' + +/** + * Mirror-shaped snapshot of the fields the debounced persisted-UI writer owns. + * + * Why (STA-5781): the shared PersistedUIState is edited concurrently by desktop, + * web, and mobile clients. Whole-snapshot writes let a stale mirror overwrite + * another client's disjoint fields, so hydration captures this baseline and the + * writer persists only the fields this client actually changed since then. + */ +export type PersistedUIWriteBaseline = { + sidebarWidth: number + rightSidebarOpen: boolean + rightSidebarTab: PersistedUIState['rightSidebarTab'] + rightSidebarExplorerView: PersistedUIState['rightSidebarExplorerView'] + rightSidebarWidth: number + markdownTocPanelWidth: number + combinedDiffFileTreeWidth: number + groupBy: PersistedUIState['groupBy'] + sortBy: PersistedUIState['sortBy'] + projectOrderBy: PersistedUIState['projectOrderBy'] + showSleepingWorkspaces: boolean + hideDefaultBranchWorkspace: boolean + hideAutomationGeneratedWorkspaces: boolean + hideCliCreatedWorkspaces: boolean + hideDetachedHeadWorkspaces: boolean + hideWorkspacesFromOtherDevices: boolean + alwaysShowDefaultBranchWorkspace: boolean + showDotfilesByWorktree: Record + filterRepoIds: readonly string[] + acknowledgedAgentsByPaneKey: Record +} + +// Why `satisfies Record<...>` rather than a keyof[] annotation: a plain `satisfies +// readonly (keyof ...)[]` only validates listed elements, so a field added to the +// type but forgotten here would silently never persist again — the exact bug class +// this module exists to close (see ui-state-schema-parity.ts for the same lesson). +const PERSISTED_UI_WRITE_BASELINE_FIELD_SET = { + sidebarWidth: true, + rightSidebarOpen: true, + rightSidebarTab: true, + rightSidebarExplorerView: true, + rightSidebarWidth: true, + markdownTocPanelWidth: true, + combinedDiffFileTreeWidth: true, + groupBy: true, + sortBy: true, + projectOrderBy: true, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: true, + hideAutomationGeneratedWorkspaces: true, + hideCliCreatedWorkspaces: true, + hideDetachedHeadWorkspaces: true, + hideWorkspacesFromOtherDevices: true, + alwaysShowDefaultBranchWorkspace: true, + showDotfilesByWorktree: true, + filterRepoIds: true, + acknowledgedAgentsByPaneKey: true +} satisfies Record + +export const PERSISTED_UI_WRITE_BASELINE_FIELDS = Object.keys( + PERSISTED_UI_WRITE_BASELINE_FIELD_SET +) as readonly (keyof PersistedUIWriteBaseline)[] + +/** Pick the writer-owned fields off a hydrated mirror (a structural superset). */ +export function capturePersistedUIWriteBaseline( + mirror: PersistedUIWriteBaseline +): PersistedUIWriteBaseline { + const captured = {} as Record + for (const field of PERSISTED_UI_WRITE_BASELINE_FIELDS) { + captured[field] = mirror[field] + } + return captured as PersistedUIWriteBaseline +} + +function shallowRecordEqual( + a: Record | undefined, + b: Record | undefined +): boolean { + if (a === b) { + return true + } + if (!a || !b) { + return false + } + const aKeys = Object.keys(a) + return aKeys.length === Object.keys(b).length && aKeys.every((key) => Object.is(a[key], b[key])) +} + +function stringArrayEqual(a: readonly string[], b: readonly string[]): boolean { + return a === b || (a.length === b.length && a.every((value, i) => value === b[i])) +} + +function writeFieldEqual(field: keyof PersistedUIWriteBaseline, a: unknown, b: unknown): boolean { + if (field === 'filterRepoIds') { + return stringArrayEqual(a as readonly string[], b as readonly string[]) + } + if (field === 'showDotfilesByWorktree' || field === 'acknowledgedAgentsByPaneKey') { + return shallowRecordEqual( + a as Record | undefined, + b as Record | undefined + ) + } + return Object.is(a, b) +} + +/** Fields whose current mirror value diverges from the baseline, valued from the mirror. */ +export function diffPersistedUIWriteFields( + current: PersistedUIWriteBaseline, + baseline: PersistedUIWriteBaseline +): Partial { + const changed = {} as Record + for (const field of PERSISTED_UI_WRITE_BASELINE_FIELDS) { + if (!writeFieldEqual(field, current[field], baseline[field])) { + changed[field] = current[field] + } + } + return changed as Partial +} + +/** + * Convert a mirror-shaped field patch to the ui.set wire shape. Built key-by-key + * (no spread): a spread is not excess-property-checked, so a future mirror field + * whose store name differs from its wire name would ship a bogus key and make the + * strict paired-host UiUpdate schema reject the whole payload. + */ +export function persistedUIWriteFieldsToWireUpdate( + fields: Partial +): Partial { + const update: Partial = {} + for (const field of PERSISTED_UI_WRITE_BASELINE_FIELDS) { + if (!(field in fields)) { + continue + } + if (field === 'showSleepingWorkspaces') { + // The mirror keeps the positive form; the durable file keeps the hide form. + update.hideSleepingWorkspaces = fields.showSleepingWorkspaces !== true + } else if (field === 'filterRepoIds') { + // Why: the store keeps this readonly for identity stability, but PersistedUI crosses to + // main, which owns a mutable array — copy at the boundary rather than widening the wire type. + update.filterRepoIds = [...(fields.filterRepoIds ?? [])] + } else { + assignSameNameWireField( + update, + field, + fields[field] as PersistedUIWriteBaseline[typeof field] + ) + } + } + return update +} + +type SameNameWriteField = Exclude< + keyof PersistedUIWriteBaseline, + 'showSleepingWorkspaces' | 'filterRepoIds' +> + +// Compile check: every non-special mirror field must exist on PersistedUIState +// under the same name with an assignable type — indexing PersistedUIState[K] +// fails to compile for a renamed field, and the conditional flags a type drift. +type MisassignableWireField = { + [K in SameNameWriteField]: PersistedUIWriteBaseline[K] extends PersistedUIState[K] ? never : K +}[SameNameWriteField] +const assertSameNameFieldsAssignable: MisassignableWireField extends never ? true : never = true +void assertSameNameFieldsAssignable + +function assignSameNameWireField( + update: Partial, + field: K, + value: PersistedUIWriteBaseline[K] +): void { + ;(update as Record)[field] = value +} diff --git a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts index 0869b720ec0..cb4719f5dcf 100644 --- a/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts +++ b/src/renderer/src/store/slices/tabs-model-reconciliation.test.ts @@ -266,6 +266,83 @@ describe('TabsSlice', () => { }) }) + it('keeps a structured session activation target during worktree reconciliation', () => { + const groupId = 'g-structured' + store.setState({ + unifiedTabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + entityId: 'terminal-1', + groupId, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + { + id: 'structured-session-1', + entityId: 'session-1', + groupId, + worktreeId: WT, + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 1, + createdAt: 2, + agentSessionAgent: 'codex' + } + ] + }, + groupsByWorktree: { + [WT]: [ + { + id: groupId, + worktreeId: WT, + activeTabId: 'structured-session-1', + tabOrder: ['terminal-1', 'structured-session-1'] + } + ] + }, + activeGroupIdByWorktree: { [WT]: groupId }, + tabsByWorktree: { + [WT]: [ + { + id: 'terminal-1', + ptyId: 'pty-1', + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'terminal-1': ['pty-1'] } + }) + + const result = store.getState().reconcileWorktreeTabModel(WT) + const state = store.getState() + + expect(result).toEqual({ + renderableTabCount: 2, + activeRenderableTabId: 'structured-session-1' + }) + expect(state.unifiedTabsByWorktree[WT].map((tab) => tab.id)).toEqual([ + 'terminal-1', + 'structured-session-1' + ]) + expect(state.groupsByWorktree[WT][0]).toMatchObject({ + activeTabId: 'structured-session-1', + tabOrder: ['terminal-1', 'structured-session-1'] + }) + }) + it('collapses empty split groups when reconciliation drops a stale tab', () => { const terminalGroupId = 'g-terminal' const staleGroupId = 'g-stale' diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 5dad44150a6..1ef2684f3a0 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -712,7 +712,7 @@ export function projectWorktreeTabModelReconciliation( if (tab.contentType === 'browser') { return liveBrowserIds.has(tab.entityId) } - if (tab.contentType === 'simulator') { + if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { return true } return liveEditorIds.has(tab.entityId) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1e1a4b3e699..c6c36b68fd0 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -1,6 +1,8 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TerminalSlice } from '../terminals/terminal-state' + +export type { TerminalSlice } from '../terminals/terminal-state' import { createTerminalEphemeralActions } from '../terminals/terminal-ephemeral-state' import { createTerminalTabCreationActions } from '../terminals/terminal-tab-creation' import { createActiveWorkspaceTerminalActions } from '../terminals/terminal-active-workspace-creation' @@ -47,6 +49,10 @@ export const createTerminalSlice: StateCreator nativeChatLaunchDraftByTabId: {}, tabBarOrderByWorktree: {}, workspaceSessionReady: false, + terminalStartupRestorationReady: false, + setTerminalStartupRestorationReady: (value) => { + set({ terminalStartupRestorationReady: value }) + }, restoredRuntimeHostIdByWorkspaceSessionKey: {}, defaultTerminalTabsAppliedByWorktreeId: {}, closedTerminalTabTombstonesByTabId: {}, diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 9c4c550194f..c2a13d41b93 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -140,6 +140,11 @@ import { buildAgentNotificationId } from '../../../../shared/agent-notification- import { parsePaneKey } from '../../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' import { getRepoHostIdentity } from './repo-host-identity' +import { + capturePersistedUIWriteBaseline, + diffPersistedUIWriteFields, + type PersistedUIWriteBaseline +} from './persisted-ui-write-baseline' export type PendingSidebarWorktreeReveal = { worktreeId: string @@ -1018,6 +1023,19 @@ export type UISlice = { scrollToDiffCommentId: string | null setScrollToDiffCommentId: (id: string | null) => void persistedUIReady: boolean + /** Writer-owned fields as last hydrated from main or flushed by the writer; the debounced writer diffs against this so it only persists fields this client changed (STA-5781). */ + persistedUIWriteBaseline: PersistedUIWriteBaseline | null + /** Fields with a ui.set round-trip in flight; hydration keeps the mirror's value for them so an echo of the in-flight write can't revert a newer flip-back. */ + persistedUIWriteInFlightCounts: Partial> + /** Bumped whenever hydration replaces the baseline; an ack whose write predates the bump must not fold, or it would erase a remote write that landed during the round trip. */ + persistedUIWriteBaselineGeneration: number + notePersistedUIWriteStarted: (fields: readonly (keyof PersistedUIWriteBaseline)[]) => void + /** Settle an in-flight write: fold the acked patch into the baseline (null = rejected, leaving the fields dirty so the next change re-flushes them). */ + notePersistedUIWriteSettled: ( + fields: readonly (keyof PersistedUIWriteBaseline)[], + flushed: Partial | null, + options?: { sentAtGeneration: number } + ) => void uiZoomLevel: number setUIZoomLevel: (level: number) => void editorFontZoomLevel: number @@ -2475,6 +2493,47 @@ export const createUISlice: StateCreator = (set, get) scrollToDiffCommentId: null, setScrollToDiffCommentId: (id) => set({ scrollToDiffCommentId: id }), persistedUIReady: false, + persistedUIWriteBaseline: null, + persistedUIWriteInFlightCounts: {}, + notePersistedUIWriteStarted: (fields) => + set((s) => { + const counts = { ...s.persistedUIWriteInFlightCounts } + for (const field of fields) { + counts[field] = (counts[field] ?? 0) + 1 + } + return { persistedUIWriteInFlightCounts: counts } + }), + persistedUIWriteBaselineGeneration: 0, + notePersistedUIWriteSettled: (fields, flushed, options) => + set((s) => { + const counts = { ...s.persistedUIWriteInFlightCounts } + for (const field of fields) { + const next = (counts[field] ?? 0) - 1 + if (next > 0) { + counts[field] = next + } else { + delete counts[field] + } + } + // Why the generation guard: a hydration during the round trip made the + // baseline authoritative for state NEWER than this write; folding the + // sent values over it would blank the mirror-vs-baseline diff and leave + // mirror and authority divergent with nothing left to reconcile them. + // Skipping the fold keeps the diff alive so the trailing flush re-sends. + // Why options is required for folding: an unguarded fold from a future + // caller could silently erase a remote write that landed mid-round-trip. + const foldable = + flushed && + s.persistedUIWriteBaseline && + options !== undefined && + options.sentAtGeneration === s.persistedUIWriteBaselineGeneration + return { + persistedUIWriteInFlightCounts: counts, + ...(foldable + ? { persistedUIWriteBaseline: { ...s.persistedUIWriteBaseline!, ...flushed } } + : {}) + } + }), uiZoomLevel: 0, setUIZoomLevel: (level) => set({ uiZoomLevel: level }), editorFontZoomLevel: 0, @@ -2692,8 +2751,60 @@ export const createUISlice: StateCreator = (set, get) : s.activeView, persistedUIReady: true } + // The incoming payload is authoritative for the writer-owned fields, so it becomes the + // writer's new diff baseline — but fields with an unflushed local edit (mirror diverged + // from the previous baseline) keep the local value so a broadcast arriving inside the + // writer's debounce window can't silently revert what the user just toggled (STA-5781). + // Order matters: capture the baseline BEFORE overlaying pending edits, or the baseline + // would equal the pending value, the diff would go empty, and the toggle would be dropped. + // Note the width sanitizers above fall back to the CURRENT store value only for + // non-numeric input (numbers are clamped in place), so a captured width can differ + // from what main holds only for garbage payloads; at worst main keeps an + // out-of-range width until the next drag re-writes it. + const nextWriteBaseline = capturePersistedUIWriteBaseline(hydrated) + const previousBaseline = s.persistedUIWriteBaseline + if (previousBaseline) { + const pendingLocalEdits = diffPersistedUIWriteFields( + capturePersistedUIWriteBaseline(s), + previousBaseline + ) + Object.assign(hydrated, pendingLocalEdits) + // In-flight fields too: a flip-back to the baseline value diffs empty, + // yet the in-flight write's echo must not revert it (PR#17057 review). + for (const field of Object.keys( + s.persistedUIWriteInFlightCounts + ) as (keyof PersistedUIWriteBaseline)[]) { + ;(hydrated as Record)[field] = s[field] + } + } // Why: return the same ref on identical hydration so App's debounced writer doesn't echo it back to main. - return hydratedUIPartialMatchesState(s, hydrated) ? s : hydrated + // The baseline must still advance when it moved (a remote same-field write during an in-flight + // ack pins the only visibly differing field, and our own echo precedes every ack) — but only + // the two baseline keys, or every ordinary write's echo would churn the store's collection + // identities and re-render identity-compared selectors once per write. + // Why the generation bumps only on baseline movement: an unrelated-field + // broadcast during an in-flight write would otherwise void that write's + // fold and cost a redundant trailing re-send of identical values. + const writeBaselineMoved = + !previousBaseline || + Object.keys(diffPersistedUIWriteFields(nextWriteBaseline, previousBaseline)).length > 0 + const nextWriteBaselineGeneration = writeBaselineMoved + ? s.persistedUIWriteBaselineGeneration + 1 + : s.persistedUIWriteBaselineGeneration + if (hydratedUIPartialMatchesState(s, hydrated)) { + if (!writeBaselineMoved) { + return s + } + return { + persistedUIWriteBaseline: nextWriteBaseline, + persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration + } + } + return { + ...hydrated, + persistedUIWriteBaseline: nextWriteBaseline, + persistedUIWriteBaselineGeneration: nextWriteBaselineGeneration + } }), updateStatus: { state: 'idle' }, diff --git a/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts b/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts index 09f191ac732..6cbf779255c 100644 --- a/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts +++ b/src/renderer/src/store/slices/worktrees-linked-review-push-target.test.ts @@ -165,6 +165,7 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1[0]?.pushTarget).toBeUndefined() expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'review-branch', { repoId: 'repo1', + repoOwnerExecutionHostId: 'local', linkedGitHubPR: null, linkedGitLabMR: 42, linkedBitbucketPR: null, diff --git a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts index bdbfabf1373..f7770e8b8b8 100644 --- a/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts +++ b/src/renderer/src/store/slices/worktrees-metadata-persistence.test.ts @@ -361,6 +361,7 @@ describe('worktree remote runtime mutations', () => { expect(fetchHostedReviewForBranch).toHaveBeenCalledWith('/repo1', 'review-branch', { repoId: 'repo1', + repoOwnerExecutionHostId: 'local', linkedGitHubPR: null, linkedGitLabMR: 789, linkedBitbucketPR: null, diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts index 6923737f0dc..fdf17d38f8f 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts @@ -51,6 +51,13 @@ export function hasHostedReviewLinkUpdates(updates: Partial): bool return HOSTED_REVIEW_LINK_KEYS.some((key) => key in updates) || 'pushTarget' in updates } +export function hasChangedHostedReviewLinkUpdates( + updates: Partial, + worktree: Worktree +): boolean { + return HOSTED_REVIEW_LINK_KEYS.some((key) => key in updates && updates[key] !== worktree[key]) +} + export function getHostedReviewLinkMutationGeneration(worktreeId: string): number { return hostedReviewLinkMutationGenerationByWorktreeId.get(worktreeId) ?? 0 } diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts index a00f76b30a5..fd8f4881172 100644 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts @@ -15,6 +15,7 @@ import { bumpHostedReviewLinkMutationGeneration, clearOlderHostedReviewLinksForReplacement, getHostedReviewLinkForMetaRefresh, + hasChangedHostedReviewLinkUpdates, hasHostedReviewLinkUpdates } from './hosted-review-link-mutation' import { @@ -111,16 +112,9 @@ export function createUpdateWorktreeMeta( if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) { return { ok: true } } - const shouldRefreshHostedReview = - (normalizedUpdates.linkedPR === null && (worktreeForUpdate?.linkedPR ?? null) !== null) || - (normalizedUpdates.linkedGitLabMR === null && - (worktreeForUpdate?.linkedGitLabMR ?? null) !== null) || - (normalizedUpdates.linkedBitbucketPR === null && - (worktreeForUpdate?.linkedBitbucketPR ?? null) !== null) || - (normalizedUpdates.linkedAzureDevOpsPR === null && - (worktreeForUpdate?.linkedAzureDevOpsPR ?? null) !== null) || - (normalizedUpdates.linkedGiteaPR === null && - (worktreeForUpdate?.linkedGiteaPR ?? null) !== null) + const shouldRefreshHostedReview = Boolean( + worktreeForUpdate && hasChangedHostedReviewLinkUpdates(normalizedUpdates, worktreeForUpdate) + ) const reviewRepo = shouldRefreshHostedReview ? (findRepoForHost(get().repos, worktreeForUpdate?.repoId ?? '', { hostId: executionHostId, @@ -265,6 +259,7 @@ export function createUpdateWorktreeMeta( // Why: refetch against post-update links so a cache entry from the previous provider link can't keep showing the removed review. void get().fetchHostedReviewForBranch(reviewRepo.path, reviewBranch, { repoId: reviewRepo.id, + repoOwnerExecutionHostId: executionHostId ?? worktreeForUpdate?.hostId, linkedGitHubPR: getHostedReviewLinkForMetaRefresh( targetEnriched, worktreeForUpdate, diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 3779ea88cc0..eaa996ad09a 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -1,3 +1,4 @@ +import type { TerminalState } from './terminal-state' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -34,6 +35,7 @@ import type { } from './terminal-contracts' export type TerminalActions = { + setTerminalStartupRestorationReady: (value: boolean) => void setRecentQuickCommandForGroup: (groupId: string, quickCommandId: string) => void claimAutomaticAgentResume: (tabId: string, claim: AutomaticAgentResumeClaim) => void seedNativeChatLaunchPrompt: (prompt: NativeChatLaunchPrompt) => void @@ -66,6 +68,12 @@ export type TerminalActions = { options?: { pendingActivationSpawn?: boolean initialPtyId?: string + /** Stable leaf identity for adopting an already-live pane without changing its pane key. */ + initialLeafId?: string + /** Published atomically with the tab so its first mount cannot spawn a bare shell. */ + pendingStartup?: TerminalState['pendingStartupByTabId'][string] + /** Published atomically with pendingStartup for automatic resume ownership. */ + automaticResumeClaim?: AutomaticAgentResumeClaim activate?: boolean recordInteraction?: boolean id?: string @@ -208,7 +216,10 @@ export type TerminalActions = { ) => void queueTabInitialCwd: (tabId: string, cwd: string) => void consumeTabInitialCwd: (tabId: string) => string | null - consumeTabStartupCommand: (tabId: string) => { + consumeTabStartupCommand: ( + tabId: string, + expected?: TerminalState['pendingStartupByTabId'][string] + ) => { command: string delivery?: 'terminal-paste' startupCommandDelivery?: StartupCommandDelivery diff --git a/src/renderer/src/store/terminals/terminal-startup-queues.ts b/src/renderer/src/store/terminals/terminal-startup-queues.ts index fb7ebdabbe2..0f684e3f8b7 100644 --- a/src/renderer/src/store/terminals/terminal-startup-queues.ts +++ b/src/renderer/src/store/terminals/terminal-startup-queues.ts @@ -53,12 +53,17 @@ export function createTerminalStartupQueueActions( }) return pending }, - consumeTabStartupCommand: (tabId) => { + consumeTabStartupCommand: (tabId, expected) => { const pending = get().pendingStartupByTabId[tabId] - if (!pending) { + // Why identity, not equality: the one-shot settle must only spend the exact captured + // startup; a newer queued command for the same tab is someone else's to consume. + if (!pending || (expected && pending !== expected)) { return null } set((s) => { + if (s.pendingStartupByTabId[tabId] !== pending) { + return {} + } const next = { ...s.pendingStartupByTabId } delete next[tabId] return { pendingStartupByTabId: next } diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index 4d5cbea8256..568cf2776cb 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -89,6 +89,8 @@ export type TerminalState = { tabBarOrderByWorktree: Record /** False until global reconnect publishes every deferred wake hint. */ workspaceSessionReady: boolean + /** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */ + terminalStartupRestorationReady: boolean restoredRuntimeHostIdByWorkspaceSessionKey: Record defaultTerminalTabsAppliedByWorktreeId: Record closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index 5f69aa19e74..11f9d1d2a59 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,6 +1,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' -import { emptyLayoutSnapshot } from '../slices/terminal-helpers' +import { emptyLayoutSnapshot, singlePaneLayoutSnapshot } from '../slices/terminal-helpers' +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds @@ -37,6 +38,19 @@ export function getNextTerminalOrdinal(tabs: TerminalTab[]): number { return nextOrdinal } +type TabStartupCommand = TerminalSlice['pendingStartupByTabId'][string] + +function normalizeTabStartupCommand(startup: TabStartupCommand): TabStartupCommand { + // Why: launchToken is only meaningful for tracked launch-config reuse; plain startup commands must not mint a synthetic token. + const launchToken = startup.launchConfig + ? (startup.launchToken ?? createBrowserUuid()) + : undefined + return { + ...startup, + ...(launchToken ? { launchToken } : {}) + } +} + export function createTerminalTabCreationActions( set: TerminalStoreSet, get: TerminalStoreGet @@ -66,6 +80,15 @@ export function createTerminalTabCreationActions( ) } const id = hintedId !== undefined && !idCollides ? hintedId : createBrowserUuid() + const requestedInitialLeafId = + options?.initialLeafId && isTerminalLeafId(options.initialLeafId) + ? options.initialLeafId + : undefined + // Why: startup delivery is pane-owned; pin its first leaf so an aborted/remounted renderer retries against the same spawn reservation. + const initialLeafId = + options?.initialPtyId || options?.pendingStartup + ? (requestedInitialLeafId ?? createBrowserUuid()) + : undefined const shouldActivate = options?.activate !== false const nextOrdinal = getNextTerminalOrdinal(existing) const defaultTitle = `Terminal ${nextOrdinal}` @@ -228,9 +251,23 @@ export function createTerminalTabCreationActions( ...orphanCleanupPatch.ptyIdsByTabId, [tab.id]: options?.initialPtyId ? [options.initialPtyId] : [] }, + pendingStartupByTabId: options?.pendingStartup + ? { + ...orphanCleanupPatch.pendingStartupByTabId, + [tab.id]: normalizeTabStartupCommand(options.pendingStartup) + } + : orphanCleanupPatch.pendingStartupByTabId, + automaticAgentResumeClaimsByTabId: options?.automaticResumeClaim + ? { + ...orphanCleanupPatch.automaticAgentResumeClaimsByTabId, + [tab.id]: options.automaticResumeClaim + } + : orphanCleanupPatch.automaticAgentResumeClaimsByTabId, terminalLayoutsByTabId: { ...orphanCleanupPatch.terminalLayoutsByTabId, - [tab.id]: emptyLayoutSnapshot() + [tab.id]: initialLeafId + ? singlePaneLayoutSnapshot(initialLeafId, options?.initialPtyId) + : emptyLayoutSnapshot() } } }) diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index 68d9b80eb55..f182b8a7790 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -33,6 +33,7 @@ export function createWebAppApi(): Partial { // Staging already wrote through to browser storage, so there is nothing left to join. awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), + prepareTerminalStartupRestoration: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), getKeyboardInputSourceId: () => Promise.resolve(null), diff --git a/src/renderer/src/web/preload-api/web-gitlab-api.test.ts b/src/renderer/src/web/preload-api/web-gitlab-api.test.ts new file mode 100644 index 00000000000..a52729a139c --- /dev/null +++ b/src/renderer/src/web/preload-api/web-gitlab-api.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callRuntimeResult = vi.hoisted(() => vi.fn()) + +vi.mock('./web-runtime-calls', () => ({ callRuntimeResult })) + +import { createGitLabApi } from './web-gitlab-api' + +describe('web GitLab API routing', () => { + beforeEach(() => { + callRuntimeResult.mockReset().mockResolvedValue(null) + }) + + it('does not forward the desktop repo-owner guard over runtime RPC', async () => { + await createGitLabApi().workItemDetails({ + repoPath: '/workspace/repo', + repoId: 'repo-1', + repoOwnerExecutionHostId: 'ssh:ssh-1', + iid: 42, + type: 'mr' + }) + + expect(callRuntimeResult).toHaveBeenCalledWith('gitlab.workItemDetails', { + repo: 'id:repo-1', + repoId: 'repo-1', + repoPath: '/workspace/repo', + iid: 42, + type: 'mr' + }) + }) +}) diff --git a/src/renderer/src/web/preload-api/web-gitlab-api.ts b/src/renderer/src/web/preload-api/web-gitlab-api.ts index de07c1ad428..dd6fcc1b632 100644 --- a/src/renderer/src/web/preload-api/web-gitlab-api.ts +++ b/src/renderer/src/web/preload-api/web-gitlab-api.ts @@ -40,7 +40,7 @@ export function createGitLabApi(): WebGitLabApi { route>(GITLAB_WEB_RPC_METHODS.listLabels, args), listAssignableUsers: () => Promise.resolve([]), todos: (args) => route>(GITLAB_WEB_RPC_METHODS.todos, args), - workItemDetails: (args) => + workItemDetails: ({ repoOwnerExecutionHostId: _owner, ...args }) => route>(GITLAB_WEB_RPC_METHODS.workItemDetails, args), closeMR: (args) => route>(GITLAB_WEB_RPC_METHODS.closeMR, { diff --git a/src/renderer/src/web/preload-api/web-runtime-api.ts b/src/renderer/src/web/preload-api/web-runtime-api.ts index 47d300d09c0..a12e478ca45 100644 --- a/src/renderer/src/web/preload-api/web-runtime-api.ts +++ b/src/renderer/src/web/preload-api/web-runtime-api.ts @@ -1,6 +1,11 @@ import type { PreloadApi } from '../../../../preload/api-types' import type { RuntimeSyncWindowGraph } from '../../../../shared/runtime-types' import { callRuntimeEnvelope, getRemoteRuntimeStatus } from './web-runtime-calls' +import { + getClientForEnvironment, + manuallyDisconnectedEnvironmentIds, + requireActiveEnvironment +} from './web-runtime-session' import { noopUnsubscribe } from './web-storage' export function createWebRuntimeApi(): NonNullable['runtime']> { @@ -8,6 +13,17 @@ export function createWebRuntimeApi(): NonNullable['runtime' syncWindowGraph: async (_graph: RuntimeSyncWindowGraph) => getRemoteRuntimeStatus(), getStatus: () => getRemoteRuntimeStatus(), call: ({ method, params }) => callRuntimeEnvelope(method, params), + subscribe: async ({ method, params }, callback) => { + const environment = requireActiveEnvironment() + const subscription = await getClientForEnvironment(environment).subscribe(method, params, { + onResponse: callback + }) + if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { + subscription.unsubscribe() + throw new Error('runtime_manually_disconnected') + } + return subscription + }, getTerminalFitOverrides: () => Promise.resolve([]), getTerminalDrivers: () => Promise.resolve([]), getBrowserDrivers: () => Promise.resolve([]), diff --git a/src/renderer/src/web/preload-api/web-ui-api.ts b/src/renderer/src/web/preload-api/web-ui-api.ts index 974132fc49c..8c6e4d73a95 100644 --- a/src/renderer/src/web/preload-api/web-ui-api.ts +++ b/src/renderer/src/web/preload-api/web-ui-api.ts @@ -61,6 +61,16 @@ export function createWebUiApi(): NonNullable['ui']> { // Why: unpaired/offline web clients still need local UI persistence. } }, + // Why a separate entry point: set must stay best-effort for its many fire-and-forget + // callers, but the diff writer must NOT fold a patch the host never received into its + // baseline — that write would silently never be retried (STA-5781). + setWithAck: async (updates) => { + const next = mergeWebUIState(readLocalWebUIState(), updates) + writeJson(UI_STORAGE_KEY, next) + zoomLevel = next.uiZoomLevel + const hostUpdates = omitPairingLocalUiFields(updates) + await callRuntimeResult('ui.set', hostUpdates, 15_000) + }, recordFeatureInteraction: async (id: FeatureInteractionId) => { const current = readLocalWebUIState() const featureInteractions = normalizeFeatureInteractions(current.featureInteractions) diff --git a/src/renderer/src/web/web-preload-api-ui.test.ts b/src/renderer/src/web/web-preload-api-ui.test.ts index 6ec16121513..9222fdbc42a 100644 --- a/src/renderer/src/web/web-preload-api-ui.test.ts +++ b/src/renderer/src/web/web-preload-api-ui.test.ts @@ -800,6 +800,41 @@ describe('web UI preload API', () => { ) }) + it('setWithAck rejects on transport failure while set stays best-effort (STA-5781)', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + if (method === 'ui.set') { + return Promise.reject(new Error('runtime disconnected')) + } + return Promise.resolve({ + id: method, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + // Plain set swallows the failure so fire-and-forget callers stay quiet... + await expect( + globals.window.api.ui.set({ hideDefaultBranchWorkspace: true }) + ).resolves.toBeUndefined() + // ...but the diff writer's ack path must see it, or it folds a patch the + // host never received into its baseline and silently stops retrying it. + await expect( + globals.window.api.ui.setWithAck!({ hideSleepingWorkspaces: true }) + ).rejects.toThrow('runtime disconnected') + }) + it('rejects paired web skill discovery failures instead of returning an empty scan', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/shared/agent-session-journal-item-key.ts b/src/shared/agent-session-journal-item-key.ts new file mode 100644 index 00000000000..4fbb555ebf5 --- /dev/null +++ b/src/shared/agent-session-journal-item-key.ts @@ -0,0 +1,234 @@ +// Item-identity → stable journal key. Pure and shared: the host keys upserts +// with it and clients reconcile optimistic sends against the same string. +// +// Components are percent-encoded before joining so a value containing the +// delimiter cannot collide with a different identity. + +import type { AgentJournalItemIdentity } from './agent-session-journal-types' + +const KEY_DELIMITER = ':' +const VERBATIM_BOUNDED_COMPONENT_TAG = '%FF' +const BOUNDED_COMPONENT_PATTERN = /^[\s\S]{0,40}~orca-oversized~(?:[1-9]\d*)~[0-9a-f]{16}$/ +const PARSED_JOURNAL_ITEM_KEY = Symbol('parsedJournalItemKey') + +type ParsedJournalItemIdentity = AgentJournalItemIdentity & { + readonly [PARSED_JOURNAL_ITEM_KEY]?: string +} + +/** Longest raw component a key may embed. Real provider ids are tens of bytes; + * anything larger would push the composed key past wire page budgets, so it + * travels as a stable digest instead of verbatim. */ +export const MAX_JOURNAL_KEY_COMPONENT_CHARS = 1024 + +/** + * Deterministic stand-in for an oversized or ill-formed key component: same + * input, same output, so revisions and tombstones of one identity still share + * a key, and re-deriving from a parsed key is a fixed point (the bounded form + * is well-formed and far below the cap). The head keeps keys debuggable; + * length plus two independent hashes makes an accidental collision practically + * impossible. Pure JS because clients derive keys too and cannot reach + * node:crypto. + * + * JSON strings are arbitrary UTF-16 code units, so a component can carry a + * lone surrogate that `encodeURIComponent` throws on. Those values take the + * digest form too: the hashes run over the raw code units, so a value and its + * replacement-character spelling keep distinct keys. + */ +export function boundJournalKeyComponent(value: string): string { + if (value.length <= MAX_JOURNAL_KEY_COMPONENT_CHARS && !hasLoneSurrogate(value)) { + return value + } + const h1 = fnv1a32(value, 0x811c9dc5).toString(16).padStart(8, '0') + const h2 = fnv1a32(value, 0x0100_0193).toString(16).padStart(8, '0') + return `${wellFormedBoundedHead(value, 40)}~orca-oversized~${value.length}~${h1}${h2}` +} + +/** The diagnostic head must be valid Unicode for `encodeURIComponent`: a pair + * split by the cut is dropped and a lone surrogate becomes U+FFFD — the + * hashes over the raw units keep the full key collision-safe regardless. + * Digest forms are persisted, so for well-formed input the head must stay + * byte-stable across builds or one identity would stop sharing a key. */ +function wellFormedBoundedHead(value: string, maxUnits: number): string { + let head = '' + let index = 0 + while (index < value.length && index < maxUnits) { + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0 + if (next >= 0xdc00 && next <= 0xdfff) { + if (index + 1 >= maxUnits) { + break + } + head += value.charAt(index) + value.charAt(index + 1) + index += 2 + continue + } + head += '�' + index += 1 + continue + } + head += unit >= 0xdc00 && unit <= 0xdfff ? '�' : value.charAt(index) + index += 1 + } + return head +} + +function hasLoneSurrogate(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index) + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0 + if (next < 0xdc00 || next > 0xdfff) { + return true + } + index += 1 + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return true + } + } + return false +} + +function fnv1a32(value: string, seed: number): number { + let hash = seed >>> 0 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x0100_0193) >>> 0 + } + return hash >>> 0 +} + +function encodePart(value: string | number): string { + const raw = String(value) + const bounded = boundJournalKeyComponent(raw) + const encoded = encodeURIComponent(bounded) + // `%FF` is not valid UTF-8 and cannot be emitted by encodeURIComponent. + return raw === bounded && isBoundedComponentRepresentation(raw) + ? `${VERBATIM_BOUNDED_COMPONENT_TAG}${encoded}` + : encoded +} + +function isBoundedComponentRepresentation(value: string): boolean { + return BOUNDED_COMPONENT_PATTERN.test(value) +} + +/** + * Stable string key for an item identity. + * + * Codex renumbers `item-N` ids on every resume, so its key is the thread, the + * turn, and the item's ordinal WITHIN that turn — a position that survives + * renumbering because a completed turn's item list does not change. `thread/fork` + * copies turns keeping their original turn ids, so the thread id must stay in the + * key. Claude copies item uuids on `--fork-session`, so its key is the session id + * plus the uuid. Text never participates. + */ +export function agentJournalItemKey(identity: AgentJournalItemIdentity): string { + // Parsed pre-tag digest keys must keep addressing their persisted revision chain. + const parsedKey = (identity as ParsedJournalItemIdentity)[PARSED_JOURNAL_ITEM_KEY] + if (parsedKey !== undefined) { + return parsedKey + } + if (identity.provider === 'codex') { + return [ + 'codex', + encodePart(identity.threadId), + encodePart(identity.turnId), + encodePart(identity.ordinal) + ].join(KEY_DELIMITER) + } + if (identity.provider === 'claude') { + return ['claude', encodePart(identity.sessionId), encodePart(identity.uuid)].join(KEY_DELIMITER) + } + if (identity.provider === 'orca') { + return ['orca', encodePart(identity.clientMessageId)].join(KEY_DELIMITER) + } + return [ + 'legacy', + encodePart(identity.agent), + encodePart(identity.sessionId), + encodePart(identity.recordId) + ].join(KEY_DELIMITER) +} + +/** Key for the pre-dispatch submission placeholder, before any provider echo. */ +export function agentJournalSubmissionKey(clientMessageId: string): string { + return agentJournalItemKey({ provider: 'orca', clientMessageId }) +} + +/** + * Inverse of {@link agentJournalItemKey}. Clients hold item KEYS, but an upsert + * needs the identity behind one — answering an approval re-appends the same + * item at the next revision. Components are percent-encoded; raw strings that + * imitate a bounded component carry a reserved encoded-domain tag. + */ +export function parseAgentJournalItemKey(key: string): AgentJournalItemIdentity | null { + // Persisted keys can be corrupted: a malformed percent sequence must fail + // the parse, never throw through journal replay or open. + const parts: string[] = [] + let preserveExactKey = false + for (const part of key.split(KEY_DELIMITER)) { + const decoded = decodePart(part) + if (!decoded) { + return null + } + parts.push(decoded.value) + preserveExactKey ||= decoded.tagged || isBoundedComponentRepresentation(decoded.value) + } + const [provider, ...rest] = parts + if (provider === 'codex' && rest.length === 3) { + const ordinal = Number(rest[2]) + return Number.isSafeInteger(ordinal) && ordinal >= 0 + ? parsedIdentity( + { provider, threadId: rest[0] as string, turnId: rest[1] as string, ordinal }, + key, + preserveExactKey + ) + : null + } + if (provider === 'claude' && rest.length === 2) { + return parsedIdentity( + { provider, sessionId: rest[0] as string, uuid: rest[1] as string }, + key, + preserveExactKey + ) + } + if (provider === 'orca' && rest.length === 1) { + return parsedIdentity({ provider, clientMessageId: rest[0] as string }, key, preserveExactKey) + } + if (provider === 'legacy' && rest.length === 3) { + return parsedIdentity( + { + provider, + agent: rest[0] as string, + sessionId: rest[1] as string, + recordId: rest[2] as string + }, + key, + preserveExactKey + ) + } + return null +} + +function decodePart(part: string): { value: string; tagged: boolean } | null { + const tagged = part.startsWith(VERBATIM_BOUNDED_COMPONENT_TAG) + try { + const value = decodeURIComponent( + tagged ? part.slice(VERBATIM_BOUNDED_COMPONENT_TAG.length) : part + ) + return !tagged || isBoundedComponentRepresentation(value) ? { value, tagged } : null + } catch { + return null + } +} + +function parsedIdentity( + identity: T, + key: string, + preserveExactKey: boolean +): T { + if (preserveExactKey) { + Object.defineProperty(identity, PARSED_JOURNAL_ITEM_KEY, { value: key }) + } + return identity +} diff --git a/src/shared/agent-session-journal-schemas.test.ts b/src/shared/agent-session-journal-schemas.test.ts new file mode 100644 index 00000000000..d855294bbc6 --- /dev/null +++ b/src/shared/agent-session-journal-schemas.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { + isAdmissibleAgentJournalItemBody, + isAdmissibleAgentJournalMessageBody, + isAdmissibleAgentJournalRenderItem, + isAdmissibleAgentJournalSubmission +} from './agent-session-journal-schemas' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' + +const PAYLOAD = { head: 'x', byteLength: 4, digest: 'd'.repeat(64), truncated: true } +const RESOLUTION = { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null +} as const + +// Canonical fixtures are typed: if a shape here stops compiling, the schema +// audit below is validating the wrong model. +const CANONICAL_BODIES: AgentJournalItemBody[] = [ + { + kind: 'message', + role: 'user', + blocks: [ + { + type: 'text', + text: 'hi', + providerFrame: { provider: 'codex', kind: 'raw', payload: PAYLOAD } + }, + { type: 'tool-call', name: 'Read', input: { path: 'a' } }, + { type: 'tool-result', output: 'ok', isError: false }, + { type: 'image-ref', path: '/tmp/a.png', alt: 'screenshot' } + ] + }, + { kind: 'tool-call', name: 'Read', input: undefined, state: 'running' }, + { kind: 'tool-call', name: 'Read', input: {}, state: 'failed', output: PAYLOAD }, + { kind: 'diff', path: 'a.ts', patch: PAYLOAD }, + { + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'a', label: 'Yes' }], + resolution: RESOLUTION + }, + { + kind: 'question', + question: 'Deploy?', + options: [{ id: 'a', label: 'Yes' }], + freeTextQuestionId: 'q-free', + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'client', resolvedAt: 5 } + }, + { kind: 'status', text: 'working' }, + { + kind: 'status', + text: 'turn', + turnLifecycle: { turnId: 'turn-1', state: 'running' }, + providerFrame: { provider: 'codex', kind: 'raw', payload: PAYLOAD } + } +] + +describe('canonical admission', () => { + it('admits every body shape this build writes', () => { + for (const body of CANONICAL_BODIES) { + expect(isAdmissibleAgentJournalItemBody(body)).toBe(true) + } + }) + + it('admits a canonical render item and submission', () => { + const item: AgentJournalRenderItem = { + itemId: 'codex:t:turn:0', + revision: 1, + body: CANONICAL_BODIES[0] as AgentJournalItemBody, + sequence: 1, + observedAt: 1_000, + recovered: true + } + expect(isAdmissibleAgentJournalRenderItem(item)).toBe(true) + const submission: AgentJournalSubmission = { + clientMessageId: 'm-1', + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'unknown', + providerItemId: null, + reason: null, + submittedAt: 1_000, + resolvedAt: null + } + expect(isAdmissibleAgentJournalSubmission(submission)).toBe(true) + }) +}) + +describe('nested corruption is rejected', () => { + it('rejects prompt bodies whose options or resolution cannot be rendered', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'question', + question: 'Deploy?', + options: null, + resolution: { state: 'resolved', selectedOptionId: 'a', resolvedBy: 'c', resolvedAt: 1 } + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'question', + question: 'Deploy?', + options: [], + resolution: null + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'approval', + title: 'Run?', + detail: null, + options: [{ id: 'a' }], + resolution: RESOLUTION + }) + ).toBe(false) + }) + + it('rejects broken payload, lifecycle, and block shapes', () => { + expect( + isAdmissibleAgentJournalItemBody({ kind: 'diff', path: 'a.ts', patch: { head: 'x' } }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ kind: 'status', text: 'x', turnLifecycle: true }) + ).toBe(false) + // A KNOWN block type with a broken payload must not slip through as a + // "future" block. + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: null }] + }) + ).toBe(false) + expect( + isAdmissibleAgentJournalItemBody({ kind: 'message', role: 'user', blocks: 'not-blocks' }) + ).toBe(false) + }) + + it('rejects shallow render items and submissions', () => { + expect( + isAdmissibleAgentJournalRenderItem({ + itemId: 'i-1', + revision: 1, + body: { kind: 'status', text: 'x' } + }) + ).toBe(false) + expect(isAdmissibleAgentJournalSubmission({ clientMessageId: 'm-1' })).toBe(false) + }) + + it('only admits message bodies for submissions', () => { + expect(isAdmissibleAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe(false) + expect(isAdmissibleAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [] })).toBe( + true + ) + }) +}) + +describe('forward tolerance', () => { + it('keeps unknown block types, wider state strings, and extra keys admissible', () => { + expect( + isAdmissibleAgentJournalItemBody({ + kind: 'message', + role: 'narrator', + blocks: [{ type: 'future-block', data: 1 }], + futureField: 'ignored' + }) + ).toBe(true) + expect( + isAdmissibleAgentJournalItemBody({ kind: 'tool-call', name: 'Read', state: 'paused' }) + ).toBe(true) + expect( + isAdmissibleAgentJournalSubmission({ + clientMessageId: 'm-1', + fence: 1, + payloadFingerprint: 'a'.repeat(64), + dispatchState: 'some-future-state', + providerItemId: null, + reason: null, + submittedAt: 1_000, + resolvedAt: null + }) + ).toBe(true) + }) +}) diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts new file mode 100644 index 00000000000..2b1ab5404fc --- /dev/null +++ b/src/shared/agent-session-journal-schemas.ts @@ -0,0 +1,168 @@ +// ─── Canonical runtime schemas for the journal render model ───────────────── +// The journal admits JSON it did not just write — snapshot files and log rows +// re-enter from disk and are republished to clients — while the reducer, the +// shared projection, and the prompt surfaces dereference nested fields without +// guards. These schemas are the single deep validators for that render model: +// admission must reject a JSON-valid but structurally wrong item (a question +// whose `options` are null, a prompt without its `resolution`) so corruption +// lands in quarantine instead of throwing mid-render. +// +// Discriminants (`kind`, known block `type`s) are validated deeply. Open string +// fields (roles, dispatch/tool states) stay type-checked, never enum-checked, +// and unknown object keys pass — a same-version row written by a slightly +// newer build must not be misread as malformed (see journal-row-schema.ts). + +import { z } from 'zod' +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' + +const BoundedPayload = z.object({ + head: z.string(), + byteLength: z.number(), + digest: z.string(), + truncated: z.boolean() +}) + +const ProviderFrame = z.object({ + provider: z.string(), + kind: z.string(), + payload: BoundedPayload +}) + +const KNOWN_BLOCK_TYPES = new Set(['text', 'tool-call', 'tool-result', 'image-ref']) + +/** Renderers select blocks by `type` equality and skip what they cannot draw, + * so an unknown block type stays admissible; a known type with a broken + * payload does not. */ +const Block = z.union([ + z.discriminatedUnion('type', [ + z.object({ + type: z.literal('text'), + text: z.string(), + providerFrame: ProviderFrame.optional() + }), + // `input: undefined` loses its key under JSON.stringify, so a persisted + // canonical tool call may lack it entirely. + z.object({ type: z.literal('tool-call'), name: z.string(), input: z.unknown().optional() }), + z.object({ + type: z.literal('tool-result'), + output: z.string(), + isError: z.boolean().optional() + }), + z.object({ + type: z.literal('image-ref'), + path: z.string().optional(), + url: z.string().optional(), + alt: z.string().optional() + }) + ]), + z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) +]) + +const PromptOption = z.object({ id: z.string(), label: z.string() }) + +const Resolution = z.object({ + state: z.string().min(1), + selectedOptionId: z.string().nullable(), + resolvedBy: z.string().nullable(), + resolvedAt: z.number().nullable() +}) + +const MessageBody = z.object({ + kind: z.literal('message'), + role: z.string().min(1), + blocks: z.array(Block) +}) + +export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ + MessageBody, + z.object({ + kind: z.literal('tool-call'), + name: z.string(), + // See the tool-call block: the key itself is lost when `input` is undefined. + input: z.unknown().optional(), + state: z.string().min(1), + output: BoundedPayload.optional() + }), + z.object({ kind: z.literal('diff'), path: z.string(), patch: BoundedPayload }), + z.object({ + kind: z.literal('approval'), + title: z.string(), + detail: z.string().nullable(), + options: z.array(PromptOption), + resolution: Resolution + }), + z.object({ + kind: z.literal('question'), + question: z.string(), + options: z.array(PromptOption), + freeTextQuestionId: z.string().optional(), + resolution: Resolution + }), + z.object({ + kind: z.literal('status'), + text: z.string(), + turnLifecycle: z.object({ turnId: z.string(), state: z.string().min(1) }).optional(), + providerFrame: ProviderFrame.optional() + }) +]) + +export const AgentJournalRenderItemSchema = z.object({ + itemId: z.string().min(1), + revision: z.number().int(), + body: AgentJournalItemBodySchema, + sequence: z.number().int(), + observedAt: z.number(), + recovered: z.literal(true).optional() +}) + +export const AgentJournalSubmissionSchema = z.object({ + clientMessageId: z.string().min(1), + fence: z.number().int(), + payloadFingerprint: z.string(), + dispatchState: z.string().min(1), + providerItemId: z.string().nullable(), + reason: z.string().nullable(), + submittedAt: z.number(), + resolvedAt: z.number().nullable() +}) + +export function isAdmissibleAgentJournalItemBody(value: unknown): value is AgentJournalItemBody { + return AgentJournalItemBodySchema.safeParse(value).success +} + +/** Submission rows may only carry a user-authored message body. */ +export function isAdmissibleAgentJournalMessageBody( + value: unknown +): value is AgentJournalMessageItem { + return MessageBody.safeParse(value).success +} + +export function isAdmissibleAgentJournalRenderItem( + value: unknown +): value is AgentJournalRenderItem { + return AgentJournalRenderItemSchema.safeParse(value).success +} + +export function isAdmissibleAgentJournalSubmission( + value: unknown +): value is AgentJournalSubmission { + return AgentJournalSubmissionSchema.safeParse(value).success +} + +/** Compile-time proof that every canonical value is admissible, so admission + * can never quarantine a row a writer in this build produced. The schemas are + * deliberately wider on open string fields, so only this direction holds. */ +type Admits = T +export type CanonicalJournalShapesAreAdmissible = [ + Admits ? true : false>, + Admits ? true : false>, + Admits< + AgentJournalRenderItem extends z.input ? true : false + >, + Admits ? true : false> +] diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts new file mode 100644 index 00000000000..17184f00349 --- /dev/null +++ b/src/shared/agent-session-journal-types.ts @@ -0,0 +1,216 @@ +// ─── Canonical agent-session journal: cross-process wire shapes ───────────── +// The host-owned timeline for a structured agent session. Everything here must +// be plain JSON: rows are persisted verbatim and later republished to clients, +// so no class instances, Maps, or Dates. +// +// Rows are append-only. `schemaVersion` is upcast at read time and never +// rewritten in place, so a host that cannot read a row refuses to write the +// journal rather than skipping or compacting past it. + +import type { AgentType } from './agent-status-types' +import type { NativeChatBlock, NativeChatRole } from './native-chat-types' + +export { type AgentType } + +/** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */ +export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 1 + +/** Epoch-qualified position in one journal. `sequence` 0 means "before the first row". */ +export type AgentJournalCursor = { + epoch: string + sequence: number +} + +/** The durable provider session a journal is bound to. + * Codex is one thread id; Claude needs the leaf because concurrent resumes of + * one session id branch the same transcript. */ +export type AgentSessionProviderHandle = + | { kind: 'codex'; threadId: string } + | { kind: 'claude'; sessionId: string; leafUuid: string | null } + | { kind: 'opaque'; agent: AgentType; value: string } + +/** The narrow slice of the durable session record the journal needs. The full + * record (owner, lease, account home) belongs to the session store. */ +export type AgentSessionJournalIdentity = { + /** Orca agent-session id — the journal's primary key. */ + sessionId: string + /** Execution-host workspace key. Identical for a worktree, a folder + * workspace, a WSL distro, and an SSH host; never a path. */ + workspaceId: string + /** Execution host that owns the process, so a client restart adjudicates nothing. */ + hostId: string + agent: AgentType + providerHandle: AgentSessionProviderHandle +} + +// ─── Item identity ────────────────────────────────────────────────────────── +// Reconciliation keys, settled by the provider spikes. Codex renumbers items +// positionally on resume, so a persisted item id is never an identity. Claude +// copies the original uuids on fork, so the uuid is. + +export type AgentJournalItemIdentity = + | { provider: 'codex'; threadId: string; turnId: string; ordinal: number } + | { provider: 'claude'; sessionId: string; uuid: string } + /** A submission Orca minted before any provider echo existed. */ + | { provider: 'orca'; clientMessageId: string } + /** Bridge-era transcript record with no provider-stable identity. */ + | { provider: 'legacy'; agent: AgentType; sessionId: string; recordId: string } + +// ─── Bounded payloads ─────────────────────────────────────────────────────── + +/** A tool output or diff body clipped to a head plus a content-addressed + * remainder. Crossing a bound sets `truncated`; it never silently drops. */ +export type AgentJournalBoundedPayload = { + head: string + /** Byte length of the ORIGINAL payload, not of `head`. */ + byteLength: number + /** sha256 of the original payload, and the blob store key when `truncated`. */ + digest: string + truncated: boolean +} + +// ─── Render-model items ───────────────────────────────────────────────────── + +export type AgentJournalMessageItem = { + kind: 'message' + role: NativeChatRole + blocks: NativeChatBlock[] +} + +export type AgentJournalToolCallState = 'running' | 'completed' | 'failed' + +export type AgentJournalToolCallItem = { + kind: 'tool-call' + name: string + input: unknown + state: AgentJournalToolCallState + output?: AgentJournalBoundedPayload +} + +export type AgentJournalDiffItem = { + kind: 'diff' + path: string + patch: AgentJournalBoundedPayload +} + +export const AGENT_JOURNAL_RESOLUTION_STATES = ['pending', 'resolved', 'cancelled'] as const +export type AgentJournalResolutionState = (typeof AGENT_JOURNAL_RESOLUTION_STATES)[number] + +/** Approvals and questions are durable items with explicit resolution state, so + * a second client answering one prompt loses the compare-and-set instead of + * invoking the provider callback twice. */ +export type AgentJournalResolution = { + state: AgentJournalResolutionState + /** Option id the winner picked; null while pending or cancelled. */ + selectedOptionId: string | null + /** Opaque client identity of the resolver, for "answered on ". */ + resolvedBy: string | null + resolvedAt: number | null +} + +export type AgentJournalPromptOption = { + id: string + label: string +} + +export type AgentJournalApprovalItem = { + kind: 'approval' + title: string + detail: string | null + options: AgentJournalPromptOption[] + resolution: AgentJournalResolution +} + +export type AgentJournalQuestionItem = { + kind: 'question' + question: string + options: AgentJournalPromptOption[] + /** Present when the provider accepts an answer outside the offered options. */ + freeTextQuestionId?: string + resolution: AgentJournalResolution +} + +export type AgentJournalStatusItem = { + kind: 'status' + text: string + /** Durable root-turn lifecycle used by clients to expose cancellation only + * while the provider can still accept it. */ + turnLifecycle?: { turnId: string; state: 'running' | 'completed' } + /** Additive fallback for provider traffic this host cannot model yet. Older + * clients still render `text`; newer clients expose the bounded frame. */ + providerFrame?: { + provider: string + kind: string + payload: AgentJournalBoundedPayload + } +} + +export type AgentJournalItemBody = + | AgentJournalMessageItem + | AgentJournalToolCallItem + | AgentJournalDiffItem + | AgentJournalApprovalItem + | AgentJournalQuestionItem + | AgentJournalStatusItem + +/** One reduced timeline entry. `sequence` orders the list; `observedAt` is the + * provider's own clock and may sort earlier than a later sequence when the row + * was recovered after a crash. */ +export type AgentJournalRenderItem = { + itemId: string + revision: number + body: AgentJournalItemBody + sequence: number + observedAt: number + /** Set when the row was appended by crash reconciliation rather than live. */ + recovered?: true +} + +// ─── Submissions ──────────────────────────────────────────────────────────── + +export const AGENT_JOURNAL_DISPATCH_STATES = ['pending', 'accepted', 'rejected', 'unknown'] as const +export type AgentJournalDispatchState = (typeof AGENT_JOURNAL_DISPATCH_STATES)[number] + +/** The write-ahead submission row, projected. `unknown` is a displayed state: + * the turn reads as delivery unconfirmed, never as sent and never as failed. */ +export type AgentJournalSubmission = { + clientMessageId: string + fence: number + payloadFingerprint: string + dispatchState: AgentJournalDispatchState + /** Provider item identity adopted on accept; null otherwise. */ + providerItemId: string | null + /** Terminal reason on `rejected`. */ + reason: string | null + submittedAt: number + resolvedAt: number | null +} + +/** Durable answer to "did my send land?", keyed by client message id. Only an + * `accepted` dispatch mints one, and it outlives the journal tail. */ +export type AgentJournalAcceptanceReceipt = { + clientMessageId: string + providerItemId: string + cursor: AgentJournalCursor + acceptedAt: number +} + +// ─── Snapshots and cursor resume ──────────────────────────────────────────── + +export type AgentJournalSnapshot = { + sessionId: string + cursor: AgentJournalCursor + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] +} + +/** Why a cursor could not be resumed. Every value forces a clean snapshot + * reload on the client. */ +export const AGENT_JOURNAL_RESET_REASONS = [ + 'epoch_changed', + 'cursor_ahead', + 'cursor_compacted', + 'journal_gap', + 'schema_unreadable' +] as const +export type AgentJournalResetReason = (typeof AGENT_JOURNAL_RESET_REASONS)[number] diff --git a/src/shared/agent-session-lease-adjudication.test.ts b/src/shared/agent-session-lease-adjudication.test.ts new file mode 100644 index 00000000000..dd8b5433efd --- /dev/null +++ b/src/shared/agent-session-lease-adjudication.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from 'vitest' +import { + adjudicateAgentSessionRestart, + agentSessionLeaseAdmitsWriter, + classifyObservedAgentSessionSpawnToken, + evaluateAgentSessionAcquisition, + isProvenAliveProbe, + isProvenDeadProbe, + type AgentSessionOwnerProbe +} from './agent-session-lease-adjudication' +import type { AgentSessionLease } from './agent-session-record' + +const OWNER = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-a' +} + +function lease(overrides: Partial = {}): AgentSessionLease { + return { + sessionId: 'session-alpha-1', + runtimeKind: 'native', + runtimeFence: 7, + handoffStage: null, + provenHandleLinkId: 'link-1', + ownerProcess: OWNER, + reservedSpawnToken: 'spawn-a', + leaseDeadlineAt: 1_000, + lastRenewedAt: 500, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null, + ...overrides + } +} + +const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } +const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } + +function acquire( + leaseState: AgentSessionLease, + probe: AgentSessionOwnerProbe, + handoffOperationId: string | null = null +) { + return evaluateAgentSessionAcquisition({ + lease: leaseState, + expectedFence: leaseState.runtimeFence, + handoffOperationId, + probe + }) +} + +describe('proof classification', () => { + it('treats a pid match with nothing PID-reuse-safe as no proof at all', () => { + // A bare pid match is exactly the case that mints a second writer after pid reuse. + expect(isProvenAliveProbe({ outcome: 'identity-matched', matchedOn: [] })).toBe(false) + expect(isProvenAliveProbe(MATCHED)).toBe(true) + expect(isProvenDeadProbe(INDETERMINATE)).toBe(false) + expect(isProvenAliveProbe(INDETERMINATE)).toBe(false) + }) + + it.each([ + ['exit-observed', { outcome: 'exit-observed' } as AgentSessionOwnerProbe], + ['pid-absent', { outcome: 'pid-absent' } as AgentSessionOwnerProbe], + [ + 'identity-mismatch', + { outcome: 'identity-mismatch', field: 'spawn-token' } as AgentSessionOwnerProbe + ] + ])('accepts %s as proof of death', (_name, probe) => { + expect(isProvenDeadProbe(probe)).toBe(true) + }) + + it('never counts a reservation probe or an indeterminate answer as death', () => { + expect(isProvenDeadProbe({ outcome: 'reservation-unused' })).toBe(false) + expect(isProvenDeadProbe(INDETERMINATE)).toBe(false) + }) +}) + +describe('acquisition compare-and-swap', () => { + it('refuses a stale fence and grants at exactly fence + 1', () => { + const held = lease({ ownerProcess: null, claimStatus: 'released', reservedSpawnToken: null }) + expect( + evaluateAgentSessionAcquisition({ + lease: held, + expectedFence: held.runtimeFence - 1, + handoffOperationId: null, + probe: MATCHED + }) + ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + expect(acquire(held, MATCHED)).toEqual({ decision: 'granted', nextFence: 8 }) + }) + + it('refuses the loser of a concurrent swap: only one caller sees the pre-state fence', () => { + const before = lease({ ownerProcess: null, claimStatus: 'released', reservedSpawnToken: null }) + const winner = acquire(before, MATCHED) + expect(winner).toEqual({ decision: 'granted', nextFence: 8 }) + // The loser still holds the pre-swap fence, which is no longer current. + const after = lease({ ...before, runtimeFence: 8, claimStatus: 'reserved' }) + expect( + evaluateAgentSessionAcquisition({ + lease: after, + expectedFence: 7, + handoffOperationId: null, + probe: MATCHED + }) + ).toEqual({ decision: 'refused', code: 'agent_session_checkpoint_stale' }) + }) + + it('never grants a second owner on expiry alone', () => { + // The recorded owner is long past its deadline; nothing here may consult that deadline. + const expired = lease({ leaseDeadlineAt: 1, lastRenewedAt: 1 }) + expect(acquire(expired, INDETERMINATE)).toEqual({ + decision: 'refused', + code: 'agent_session_ownership_unknown' + }) + expect(acquire(expired, MATCHED)).toEqual({ + decision: 'refused', + code: 'agent_session_conflict' + }) + expect(acquire(expired, { outcome: 'pid-absent' })).toEqual({ + decision: 'granted', + nextFence: 8 + }) + }) + + it('refuses while unreconciled even with proof the owner is dead', () => { + expect(acquire(lease({ unreconciled: true }), { outcome: 'pid-absent' })).toEqual({ + decision: 'refused', + code: 'execution_owner_reconciling' + }) + }) + + it('keeps a conflicted claim conflicted regardless of proof', () => { + expect(acquire(lease({ claimStatus: 'conflicted' }), { outcome: 'exit-observed' })).toEqual({ + decision: 'refused', + code: 'agent_session_conflict' + }) + }) + + it.each([ + ['recovering', 'agent_session_ownership_unknown'], + ['manual-recovery', 'agent_session_ownership_unknown'], + ['preparing', 'agent_session_conflict'] + ] as const)('refuses acquisition in stage %s', (handoffStage, code) => { + expect(acquire(lease({ handoffStage }), { outcome: 'pid-absent' })).toEqual({ + decision: 'refused', + code + }) + }) + + it.each(['old-owner-stopped', 'new-owner-proving'] as const)( + 'refuses a different handoff operation and replays the matching one at %s', + (handoffStage) => { + const mid = lease({ + handoffStage, + handoffOperationId: 'op-1', + ownerProcess: null, + claimStatus: 'reserved' + }) + expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-2')).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict' + }) + expect(acquire(mid, { outcome: 'reservation-unused' }, 'op-1')).toEqual({ + decision: 'retry-reservation', + fence: 7 + }) + } + ) + + it('refuses a reservation whose spawn may have won the race with the crash', () => { + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', handoffStage: null }) + expect(acquire(reserved, INDETERMINATE)).toEqual({ + decision: 'refused', + code: 'agent_session_ownership_unknown' + }) + expect(acquire(reserved, { outcome: 'reservation-unused' })).toEqual({ + decision: 'granted', + nextFence: 8 + }) + }) +}) + +describe('restart reconciliation', () => { + it('re-adopts a proven-live TUI owner without moving the fence', () => { + expect( + adjudicateAgentSessionRestart({ + lease: lease({ runtimeKind: 'tui' }), + probe: MATCHED, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'readopt' }) + }) + + it('routes a surviving native owner to recovery instead of readopting a dead transport', () => { + // The native child's stdio belonged to the runtime that died; readoption would extend + // a lease no process can drive. Recovery stops the orphan and respawns at fence + 1. + expect( + adjudicateAgentSessionRestart({ lease: lease(), probe: MATCHED, observedAt: 9_000 }) + ).toMatchObject({ disposition: 'recovering', stage: 'recovering' }) + }) + + it('bumps the fence exactly once for a proven-dead owner and records the evidence', () => { + const result = adjudicateAgentSessionRestart({ + lease: lease(), + probe: { outcome: 'identity-mismatch', field: 'process-start-time' }, + observedAt: 9_000 + }) + expect(result).toEqual({ + disposition: 'evicted', + nextFence: 8, + evidence: { + kind: 'identity-mismatch', + detail: 'mismatched process-start-time', + observedAt: 9_000 + } + }) + }) + + it('keeps re-asking about an unverifiable owner instead of evicting it', () => { + // A recorded exact identity can still be probed later; manual recovery is reserved + // for leases that name no process at all. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ leaseDeadlineAt: 1 }), + probe: INDETERMINATE, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'recovering', stage: 'recovering', reason: 'no answer' }) + }) + + it('keeps a pre-restart conflict conflicted while its owner cannot be proven gone', () => { + expect( + adjudicateAgentSessionRestart({ + lease: lease({ claimStatus: 'conflicted' }), + probe: INDETERMINATE, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'conflicted', reason: 'claim conflicted before restart' }) + }) + + it('keeps a conflict conflicted when it names no process to prove anything about', () => { + expect( + adjudicateAgentSessionRestart({ + lease: lease({ claimStatus: 'conflicted', ownerProcess: null }), + probe: { outcome: 'pid-absent' }, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'conflicted', reason: 'claim conflicted before restart' }) + }) + + it('frees a conflict whose named owner is proven gone', () => { + // Why: the conflict protects one specific process. Once that process is proven gone there is + // no claimant left, and a conflict with no exit is a session nobody can ever open again. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ claimStatus: 'conflicted' }), + probe: { outcome: 'pid-absent' }, + observedAt: 9_000 + }) + ).toEqual({ + disposition: 'evicted', + nextFence: 8, + evidence: { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt: 9_000 } + }) + }) + + it('frees a lease that names neither an owner nor a reservation, without moving the fence', () => { + // Why: an evicted lease has no owner and no token, so a restart has nothing to probe. + // Calling that an unproven reservation re-latched every released record on every boot. + expect( + adjudicateAgentSessionRestart({ + lease: lease({ + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + handoffStage: 'recovering' + }), + probe: INDETERMINATE, + observedAt: 9_000 + }) + ).toEqual({ disposition: 'free', reason: 'lease has no owner and no reservation' }) + }) + + it('does not infer an ownerless native reservation is unused from restart alone', () => { + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved' }) + expect( + adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) + ).toEqual({ + disposition: 'recovering', + stage: 'manual-recovery', + reason: 'reservation with no proven process' + }) + }) + + it('frees a TUI reservation only when a probe proves nothing ever spawned', () => { + // A TUI child lives in a terminal that outlives the runtime, so absence needs proof. + const reserved = lease({ ownerProcess: null, claimStatus: 'reserved', runtimeKind: 'tui' }) + expect( + adjudicateAgentSessionRestart({ + lease: reserved, + probe: { outcome: 'reservation-unused' }, + observedAt: 9_000 + }) + ).toMatchObject({ disposition: 'evicted', nextFence: 8 }) + expect( + adjudicateAgentSessionRestart({ lease: reserved, probe: INDETERMINATE, observedAt: 9_000 }) + ).toMatchObject({ disposition: 'recovering', stage: 'manual-recovery' }) + }) +}) + +describe('writer admission and orphan spawn tokens', () => { + it('admits a writer only when reconciled, settled, live, and holding a process', () => { + expect(agentSessionLeaseAdmitsWriter(lease())).toBe(true) + expect(agentSessionLeaseAdmitsWriter(lease({ unreconciled: true }))).toBe(false) + expect(agentSessionLeaseAdmitsWriter(lease({ handoffStage: 'new-owner-proving' }))).toBe(false) + expect(agentSessionLeaseAdmitsWriter(lease({ claimStatus: 'reserved' }))).toBe(false) + expect(agentSessionLeaseAdmitsWriter(lease({ ownerProcess: null }))).toBe(false) + }) + + it('calls a spawn token with no matching lease an orphan', () => { + const leases = [lease(), lease({ sessionId: 'session-beta-1', reservedSpawnToken: 'spawn-b' })] + expect(classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-a', leases })).toBe('owned') + expect(classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-b', leases })).toBe('owned') + expect(classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-z', leases })).toBe('orphan') + }) + + it('still recognises an owner whose reservation token was cleared after proving', () => { + const proved = lease({ reservedSpawnToken: null }) + expect( + classifyObservedAgentSessionSpawnToken({ spawnToken: 'spawn-a', leases: [proved] }) + ).toBe('owned') + }) +}) diff --git a/src/shared/agent-session-lease-adjudication.ts b/src/shared/agent-session-lease-adjudication.ts new file mode 100644 index 00000000000..6d9deb54818 --- /dev/null +++ b/src/shared/agent-session-lease-adjudication.ts @@ -0,0 +1,261 @@ +/** + * Single-writer lease adjudication. + * + * Every decision here fails closed: expiry alone never grants a second owner, an unverifiable + * process counts as possibly alive, and a stage that cannot prove an owner keeps re-asking — + * or, when it names no process at all, ends in manual recovery — rather than handing the + * session to the other runtime. This is the opposite polarity + * from daemon adoption checks, which fail open on a missing start time — a wrong answer there + * refuses an adoption, a wrong answer here creates two writers on one provider session. + */ + +import { nextAgentSessionFence } from './agent-session-next-fence' +import type { + AgentSessionDeathEvidence, + AgentSessionHandoffStage, + AgentSessionLease +} from './agent-session-record' + +export type AgentSessionIdentityMatchField = 'process-start-time' | 'spawn-token' + +export type AgentSessionOwnerProbe = + /** Orca watched this exact process exit. */ + | { outcome: 'exit-observed' } + /** The recorded pid is not present on the host. */ + | { outcome: 'pid-absent' } + /** The pid is present but is a different process. */ + | { outcome: 'identity-mismatch'; field: AgentSessionIdentityMatchField | 'command-line' } + /** The pid is present and at least one identity element was verified. */ + | { outcome: 'identity-matched'; matchedOn: readonly AgentSessionIdentityMatchField[] } + /** No process carries the reserved spawn token and the provider saw no activity after it. */ + | { outcome: 'reservation-unused' } + /** The host could not answer — restricted container, no start time, no token echo. */ + | { outcome: 'indeterminate'; reason: string } + +export type AgentSessionLeaseRefusalCode = + | 'agent_session_checkpoint_stale' + | 'agent_session_conflict' + | 'agent_session_ownership_unknown' + | 'agent_session_operation_conflict' + | 'execution_owner_reconciling' + +export type AgentSessionAcquisitionDecision = + | { decision: 'granted'; nextFence: number } + /** The same handoff operation re-entering its own reservation; no new fence, no new spawn. */ + | { decision: 'retry-reservation'; fence: number } + | { decision: 'refused'; code: AgentSessionLeaseRefusalCode } + +export type AgentSessionRestartAdjudication = + | { disposition: 'readopt' } + /** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */ + | { disposition: 'free'; reason: string } + | { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence } + | { disposition: 'recovering'; stage: AgentSessionHandoffStage; reason: string } + | { disposition: 'conflicted'; reason: string } + +/** Stages that can legally admit a new owner at all; the rest have an owner or no evidence. */ +const STAGES_ADMITTING_NEW_OWNER: ReadonlySet = new Set([ + 'old-owner-stopped', + 'new-owner-proving' +]) + +export function isProvenDeadProbe(probe: AgentSessionOwnerProbe): boolean { + return ( + probe.outcome === 'exit-observed' || + probe.outcome === 'pid-absent' || + probe.outcome === 'identity-mismatch' + ) +} + +/** + * A matched pid is only proof of life when something PID-reuse-safe matched with it. A bare pid + * match on a host that can produce neither a start time nor a token echo is indeterminate. + */ +export function isProvenAliveProbe(probe: AgentSessionOwnerProbe): boolean { + return probe.outcome === 'identity-matched' && probe.matchedOn.length > 0 +} + +function deathEvidenceFor( + probe: AgentSessionOwnerProbe, + observedAt: number +): AgentSessionDeathEvidence | null { + if (probe.outcome === 'exit-observed') { + return { kind: 'exit-observed', detail: 'observed process exit', observedAt } + } + if (probe.outcome === 'pid-absent') { + return { kind: 'pid-absent', detail: 'recorded pid absent on host', observedAt } + } + if (probe.outcome === 'identity-mismatch') { + return { kind: 'identity-mismatch', detail: `mismatched ${probe.field}`, observedAt } + } + return null +} + +/** True when the recorded owner may write right now. Used by every mutating path in later parts. */ +export function agentSessionLeaseAdmitsWriter(lease: AgentSessionLease): boolean { + return ( + !lease.unreconciled && + lease.handoffStage === null && + lease.claimStatus === 'live' && + lease.ownerProcess !== null + ) +} + +export function isAgentSessionFenceCurrent(lease: AgentSessionLease, fence: number): boolean { + return Number.isSafeInteger(fence) && fence === lease.runtimeFence +} + +/** + * Compare-and-swap acquisition. `probe` describes what the host could prove about the recorded + * owner; it is only consulted when a recorded owner or an unused reservation stands in the way. + */ +export function evaluateAgentSessionAcquisition(args: { + lease: AgentSessionLease + expectedFence: number + handoffOperationId: string | null + probe: AgentSessionOwnerProbe +}): AgentSessionAcquisitionDecision { + const { lease, expectedFence, handoffOperationId, probe } = args + if (lease.unreconciled) { + return { decision: 'refused', code: 'execution_owner_reconciling' } + } + if (!isAgentSessionFenceCurrent(lease, expectedFence)) { + return { decision: 'refused', code: 'agent_session_checkpoint_stale' } + } + if (lease.claimStatus === 'conflicted') { + return { decision: 'refused', code: 'agent_session_conflict' } + } + if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { + // Why: no stage expires into an owner; recovery is resolved by proof or by the user. + return { decision: 'refused', code: 'agent_session_ownership_unknown' } + } + if (lease.handoffStage === 'preparing') { + // Why: the old owner is quiesced but alive and still authoritative. + return { decision: 'refused', code: 'agent_session_conflict' } + } + if (lease.handoffStage !== null && !STAGES_ADMITTING_NEW_OWNER.has(lease.handoffStage)) { + return { decision: 'refused', code: 'agent_session_conflict' } + } + if (lease.handoffStage !== null && lease.handoffOperationId !== null) { + if (handoffOperationId !== lease.handoffOperationId) { + // Why: the retry key is operation id + fence + stage; a different id is a different intent. + return { decision: 'refused', code: 'agent_session_operation_conflict' } + } + if ( + lease.ownerProcess === null && + STAGES_ADMITTING_NEW_OWNER.has(lease.handoffStage) && + lease.claimStatus === 'reserved' && + lease.reservedSpawnToken !== null + ) { + // Why: an idempotent re-run of a reservation that already exists at this fence. + return { decision: 'retry-reservation', fence: lease.runtimeFence } + } + } + if (lease.ownerProcess !== null) { + if (!isProvenDeadProbe(probe)) { + // Why: a lapsed deadline means Orca stopped hearing from the owner, not that the child + // stopped editing files and spending tokens. + return { + decision: 'refused', + code: isProvenAliveProbe(probe) + ? 'agent_session_conflict' + : 'agent_session_ownership_unknown' + } + } + return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } + } + if (lease.claimStatus === 'reserved' && probe.outcome !== 'reservation-unused') { + // Why: a reservation with no proven process is not a free lease — the crash may have lost + // the race with the spawn rather than beaten it. + return { decision: 'refused', code: 'agent_session_ownership_unknown' } + } + return { decision: 'granted', nextFence: nextAgentSessionFence(lease) } +} + +/** + * Host-restart reconciliation for one persisted lease. Every lease is unreconciled at load and + * grants no writer until this returns. + */ +export function adjudicateAgentSessionRestart(args: { + lease: AgentSessionLease + probe: AgentSessionOwnerProbe + observedAt: number +}): AgentSessionRestartAdjudication { + const { lease, probe, observedAt } = args + if (lease.claimStatus === 'conflicted') { + const conflictedOwnerDeath = + lease.ownerProcess === null ? null : deathEvidenceFor(probe, observedAt) + if (conflictedOwnerDeath) { + // Why: the conflict names one specific process. Present-time proof that THAT process is gone + // leaves no claimant to protect, and a conflict with no exit is a session the user can never + // open again. Without such proof the conflict still outlives the process that observed it. + return { + disposition: 'evicted', + nextFence: nextAgentSessionFence(lease), + evidence: conflictedOwnerDeath + } + } + return { disposition: 'conflicted', reason: 'claim conflicted before restart' } + } + if (lease.ownerProcess === null) { + if (lease.reservedSpawnToken === null && lease.claimStatus !== 'reserved') { + // Why: the spawn token is minted before the child and is the only thing a child could be + // carrying. With no owner and no token nothing can hold this lease, so it is already free — + // treating it as an unproven reservation is what re-latches every released record on restart. + return { disposition: 'free', reason: 'lease has no owner and no reservation' } + } + if (probe.outcome === 'reservation-unused') { + return { + disposition: 'evicted', + nextFence: nextAgentSessionFence(lease), + evidence: { kind: 'pid-absent', detail: 'reservation never spawned', observedAt } + } + } + return { + disposition: 'recovering', + stage: 'manual-recovery', + reason: 'reservation with no proven process' + } + } + if (isProvenAliveProbe(probe)) { + if (lease.runtimeKind === 'native') { + // Why: the surviving child's stdio died with the previous runtime, so readoption + // would renew a lease no host can drive. Recovery stops it and respawns at fence + 1. + return { + disposition: 'recovering', + stage: 'recovering', + reason: 'native owner outlived the runtime that held its transport' + } + } + // Why: re-adoption is not a new generation, so the fence does not move. + return { disposition: 'readopt' } + } + const evidence = deathEvidenceFor(probe, observedAt) + if (evidence) { + return { disposition: 'evicted', nextFence: nextAgentSessionFence(lease), evidence } + } + return { + // Why: an exact recorded identity can still be probed later, so the system keeps + // re-asking; only a record naming nobody (above) needs the user to decide. + disposition: 'recovering', + stage: 'recovering', + reason: + probe.outcome === 'indeterminate' ? probe.reason : 'process identity could not be verified' + } +} + +/** + * A process carrying an Orca spawn token with no matching lease is an orphan: stop it, never + * adopt it. Neither age nor CPU is evidence — only a token match justifies acting on a process. + */ +export function classifyObservedAgentSessionSpawnToken(args: { + spawnToken: string + leases: readonly AgentSessionLease[] +}): 'owned' | 'orphan' { + const owned = args.leases.some( + (lease) => + lease.reservedSpawnToken === args.spawnToken || + lease.ownerProcess?.spawnToken === args.spawnToken + ) + return owned ? 'owned' : 'orphan' +} diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts new file mode 100644 index 00000000000..f488e14e31e --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOperationDecision } from './agent-session-operation-ledger' +import { agentSessionLeaseFixture } from './agent-session-record.test-fixture' +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from './agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' + +const LEASE = agentSessionLeaseFixture({ + sessionId: 'session-1', + runtimeKind: 'native', + runtimeFence: 4 +}) + +function envelope(overrides: Partial = {}) { + return { + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 4, + payloadFingerprint: 'f'.repeat(64), + ...overrides + } +} + +function row(fingerprint: string) { + return { + callerKey: 'caller-1', + operationId: 'op-1', + fingerprint, + operationTimestamp: 1_000, + recordedAt: 1_000, + expiresAt: 100_000, + outcome: { status: 'pending' as const } + } +} + +const ADMIT = (fingerprint: string): AgentSessionOperationDecision => ({ + decision: 'admit', + row: row(fingerprint) +}) + +describe('computeAgentSessionPayloadFingerprint', () => { + it('is stable across key order at every depth', () => { + const a = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { kind: 'message', blocks: [{ type: 'text', text: 'hi' }] }, extra: 1 } + }) + const b = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { extra: 1, body: { blocks: [{ text: 'hi', type: 'text' }], kind: 'message' } } + }) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{64}$/) + }) + + it('separates one payload from another and one method from another', () => { + const send = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi there' } + }) + ).not.toBe(send) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + ).not.toBe(send) + }) +}) + +describe('agentSessionFingerprintConflict', () => { + it('refuses a payload that does not match what the client declared', () => { + const conflict = agentSessionFingerprintConflict(envelope(), 'a'.repeat(64)) + expect(conflict?.code).toBe('agent_session_operation_conflict') + }) + + it('passes a matching declaration', () => { + expect(agentSessionFingerprintConflict(envelope(), 'f'.repeat(64))).toBeNull() + }) +}) + +describe('admitAgentSessionMutation', () => { + const base = { envelope: envelope(), hostFingerprint: 'f'.repeat(64), lease: LEASE } + + it('admits a first-time operation under a live lease at the expected fence', () => { + expect(admitAgentSessionMutation({ ...base, ledger: ADMIT('f'.repeat(64)) }).decision).toBe( + 'admit' + ) + }) + + it('replays a recorded operation without re-checking the fence', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 1 }), + ledger: { decision: 'replay', row: row('f'.repeat(64)) } + }) + expect(admission.decision).toBe('replay') + }) + + it('refuses a stale fence and hands back the current one', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 3 }), + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_checkpoint_stale', currentFence: 4 } + }) + }) + + it('refuses a writer while the lease is unreconciled', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, unreconciled: true }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'execution_owner_reconciling' } + }) + }) + + it('refuses a writer mid-handoff', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, handoffStage: 'new-owner-proving' }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_conflict' } + }) + }) + + it('surfaces a ledger refusal verbatim', () => { + const admission = admitAgentSessionMutation({ + ...base, + ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_operation_expired' } + }) + }) +}) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts new file mode 100644 index 00000000000..aebd2618522 --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.ts @@ -0,0 +1,157 @@ +// Admission for one mutating `agentSession.*` call. +// +// The rules themselves live in the durable ledger and the lease adjudicator; +// this is only the fixed order they are applied in, plus the payload +// fingerprint both peers derive from the same request fields. Nothing here +// re-derives who may write — that answer comes from +// `agentSessionLeaseAdmitsWriter` alone. + +import { createHash } from 'node:crypto' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from './agent-session-operation-ledger' +import { + agentSessionLeaseAdmitsWriter, + isAgentSessionFenceCurrent +} from './agent-session-lease-adjudication' +import type { AgentSessionLease } from './agent-session-record' +import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' + +/** + * Stable digest over the fields that define what this call DOES. Keys are + * emitted in sorted order at every depth so two peers serializing the same + * request in different property order agree, and an undefined field is dropped + * rather than hashed as present-but-empty. + */ +export function computeAgentSessionPayloadFingerprint(input: { + method: string + sessionId: string + fields: Record +}): string { + const canonical = canonicalize({ + method: input.method, + sessionId: input.sessionId, + fields: input.fields + }) + return createHash('sha256').update(canonical).digest('hex') +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}` +} + +/** + * A retry whose payload changed is a different call wearing the same id. + * Checked BEFORE the ledger is consulted, so a refused call never leaves an + * admitted row that a later honest retry would replay as already-done. + */ +export function agentSessionFingerprintConflict( + envelope: AgentSessionMutationEnvelope, + hostFingerprint: string +): AgentSessionWireRefusal | null { + return envelope.payloadFingerprint === hostFingerprint + ? null + : { + code: 'agent_session_operation_conflict', + message: + 'The payload does not match the fingerprint the client declared for this operation.' + } +} + +export type AgentSessionMutationAdmission = + | { decision: 'admit'; row: AgentSessionOperationRow } + /** The recorded outcome answers this call; do not run the effect again. */ + | { decision: 'replay'; row: AgentSessionOperationRow } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +/** + * Fixed order: fingerprint agreement, then the ledger (so a retry replays + * before anything else can refuse it), then the lease, then the fence. Putting + * the ledger ahead of the fence is deliberate — a retry that crossed an owner + * change must still return its recorded answer instead of a stale-checkpoint + * refusal the client would then resend as a second effect. + */ +export function admitAgentSessionMutation(input: { + envelope: AgentSessionMutationEnvelope + /** Fingerprint the host computed from the request it actually received. */ + hostFingerprint: string + /** Decision from the durable ledger, evaluated under `hostFingerprint`. */ + ledger: AgentSessionOperationDecision + lease: AgentSessionLease +}): AgentSessionMutationAdmission { + const { envelope, lease, ledger } = input + const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint) + if (mismatch) { + return { decision: 'refused', refusal: mismatch } + } + if (ledger.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: ledger.code, + message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + } + } + } + if (ledger.decision === 'replay') { + return { decision: 'replay', row: ledger.row } + } + const leaseRefusal = refuseUnlessWriterAdmitted(lease) + if (leaseRefusal) { + return { decision: 'refused', refusal: leaseRefusal } + } + if ( + envelope.expectedRuntimeFence === null || + !isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence) + ) { + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`, + currentFence: lease.runtimeFence + } + } + } + return { decision: 'admit', row: ledger.row } +} + +/** Why the single admission oracle said no, mapped to what the client can do + * about it. The predicate itself is never re-implemented here. */ +function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { + if (lease.runtimeKind === 'native' && agentSessionLeaseAdmitsWriter(lease)) { + return null + } + if (lease.unreconciled) { + return { + code: 'execution_owner_reconciling', + message: 'This host has not yet adjudicated the session lease.' + } + } + if (lease.handoffStage !== null) { + return { + code: 'agent_session_conflict', + message: `The session is mid-handoff (${lease.handoffStage}).` + } + } + if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { + return { + code: 'agent_session_conflict', + message: 'The agent terminal owns this session.' + } + } + return { + code: 'agent_session_ownership_unknown', + message: 'The session has no live owner to accept writes.' + } +} diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts new file mode 100644 index 00000000000..bf2eb9f50c7 --- /dev/null +++ b/src/shared/agent-session-next-fence.ts @@ -0,0 +1,17 @@ +// The only place a new fence number is chosen. +// +// Normally that is just "one past the current fence". After the record store falls back to its +// backup it is not: the commit that never landed may already have granted a fence the backup cannot +// show, and `isAgentSessionFenceCurrent` compares with STRICT EQUALITY, so minting that exact +// number would hand a second writer a lease the first one still believes it holds. +// +// Recovery records the floor instead of rewriting the current fence, because `live` means a handle +// proven at exactly the current fence — moving it would invalidate the very records recovery exists +// to save. Every mint site routes through here so a new transition cannot quietly reintroduce a +// bare `+ 1`; the floor is pinned by a test that drives each transition. + +import type { AgentSessionLease } from './agent-session-record' + +export function nextAgentSessionFence(lease: AgentSessionLease): number { + return Math.max(lease.runtimeFence + 1, lease.minimumNextFence ?? 0) +} diff --git a/src/shared/agent-session-operation-ledger.test.ts b/src/shared/agent-session-operation-ledger.test.ts new file mode 100644 index 00000000000..0710eade461 --- /dev/null +++ b/src/shared/agent-session-operation-ledger.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from './agent-session-host-authority' +import { + agentSessionOperationExpiry, + agentSessionOperationKey, + evaluateAgentSessionOperation, + isAgentSessionOperationRow, + pruneAgentSessionOperationRows, + type AgentSessionOperationRow +} from './agent-session-operation-ledger' + +const NOW = 1_800_000_000_000 + +function operationId(timestamp: number, suffix = 'a'.repeat(32)): string { + return `${String(timestamp).padStart(13, '0')}-${suffix}` +} + +function evaluate( + rows: Map, + overrides: Partial<{ + callerKey: string + operationId: string + fingerprint: string + now: number + perClientLimit: number + globalLimit: number + }> = {} +) { + return evaluateAgentSessionOperation({ + rows, + callerKey: 'client-1', + operationId: operationId(NOW), + fingerprint: 'fp-1', + now: NOW, + ...overrides + }) +} + +function admit( + rows: Map, + overrides: Parameters[1] = {} +): AgentSessionOperationRow { + const decision = evaluate(rows, overrides) + if (decision.decision !== 'admit') { + throw new Error(`expected admit, got ${decision.decision}`) + } + rows.set(agentSessionOperationKey(decision.row.callerKey, decision.row.operationId), decision.row) + return decision.row +} + +describe('operation admission', () => { + it('admits a fresh id once and replays the identical retry', () => { + const rows = new Map() + const row = admit(rows) + const replay = evaluate(rows) + expect(replay).toEqual({ decision: 'replay', row }) + }) + + it('refuses the same id carrying different parameters', () => { + const rows = new Map() + admit(rows) + expect(evaluate(rows, { fingerprint: 'fp-2' })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_conflict' + }) + }) + + it('scopes ids per caller so two clients cannot collide or replay each other', () => { + const rows = new Map() + admit(rows) + expect(evaluate(rows, { callerKey: 'client-2' }).decision).toBe('admit') + }) + + it('refuses a malformed or future-dated id', () => { + const rows = new Map() + expect(evaluate(rows, { operationId: 'not-an-operation-id' })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_invalid' + }) + // Why: a future-dated id would look new again after its own tombstone is collected. + expect( + evaluate(rows, { + operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1) + }) + ).toEqual({ decision: 'refused', code: 'agent_session_operation_invalid' }) + expect( + evaluate(rows, { operationId: operationId(NOW + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS) }) + .decision + ).toBe('admit') + }) + + it('refuses an id older than the admission window instead of treating it as new', () => { + const rows = new Map() + const stale = operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1) + expect(evaluate(rows, { operationId: stale })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_expired' + }) + expect( + evaluate(rows, { operationId: operationId(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) }) + .decision + ).toBe('admit') + }) + + it('refuses new ids at the per-client and global caps rather than evicting tombstones', () => { + const rows = new Map() + admit(rows, { operationId: operationId(NOW, 'b'.repeat(32)) }) + expect(evaluate(rows, { perClientLimit: 1 })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_capacity' + }) + // A different caller is still refused once the global cap is reached. + expect(evaluate(rows, { callerKey: 'client-2', globalLimit: 1 })).toEqual({ + decision: 'refused', + code: 'agent_session_operation_capacity' + }) + expect(evaluate(rows, { callerKey: 'client-2', perClientLimit: 1 }).decision).toBe('admit') + }) +}) + +describe('retention', () => { + it('keeps a tombstone strictly longer than its id can be admitted as new', () => { + const expiry = agentSessionOperationExpiry(NOW, NOW) + const lastAdmissibleAt = NOW + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + expect(expiry).toBeGreaterThan(lastAdmissibleAt) + // Why: a retry landing in that gap would become a second spawn instead of a replay. + const rows = new Map() + admit(rows) + expect(pruneAgentSessionOperationRows(rows, lastAdmissibleAt).size).toBe(1) + expect(evaluate(pruneAgentSessionOperationRows(rows, lastAdmissibleAt)).decision).toBe('replay') + }) + + it('anchors retention to the later of recording and stamping', () => { + const late = agentSessionOperationExpiry(NOW + 10_000, NOW) + expect(late).toBe(agentSessionOperationExpiry(NOW + 10_000, NOW + 10_000)) + expect(late).toBeGreaterThan(agentSessionOperationExpiry(NOW, NOW)) + }) + + it('drops only rows past their own expiry', () => { + const rows = new Map() + const row = admit(rows) + expect(pruneAgentSessionOperationRows(rows, row.expiresAt).size).toBe(0) + expect(pruneAgentSessionOperationRows(rows, row.expiresAt - 1).size).toBe(1) + }) +}) + +describe('persisted row validation', () => { + it('accepts every recorded outcome shape', () => { + const rows = new Map() + const row = admit(rows) + expect(isAgentSessionOperationRow(row)).toBe(true) + expect( + isAgentSessionOperationRow({ ...row, outcome: { status: 'succeeded', sessionId: 's-1' } }) + ).toBe(true) + expect(isAgentSessionOperationRow({ ...row, outcome: { status: 'unknown' } })).toBe(true) + expect(isAgentSessionOperationRow({ ...row, outcome: { status: 'failed', code: 'x' } })).toBe( + true + ) + }) + + it('rejects rows a later build could misread', () => { + const rows = new Map() + const row = admit(rows) + expect(isAgentSessionOperationRow({ ...row, operationId: 'garbage' })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, callerKey: '' })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, expiresAt: 1.5 })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, outcome: { status: 'succeeded' } })).toBe(false) + expect(isAgentSessionOperationRow({ ...row, outcome: null })).toBe(false) + expect(isAgentSessionOperationRow(null)).toBe(false) + }) +}) diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts new file mode 100644 index 00000000000..c1f90a9a59c --- /dev/null +++ b/src/shared/agent-session-operation-ledger.ts @@ -0,0 +1,195 @@ +/** + * Durable client-operation ledger. + * + * `terminal.ensureAgentSession` / `terminal.createAgentSession` already enforce timestamped + * operation ids with fingerprint conflict detection, age expiry, capacity limits, and tombstone + * retention — but in memory, so a host restart turns "replay this create" into "spawn another + * agent". These are the same rules over rows that survive a restart; the store writes a row in + * the same atomic transaction as the lease reservation. + */ + +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS, + parseAgentSessionOperationTimestamp +} from './agent-session-host-authority' + +export const AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT = 512 +export const AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT = 4_096 + +export type AgentSessionOperationOutcome = + | { status: 'pending' } + | { status: 'succeeded'; sessionId: string } + | { status: 'failed'; code: string; message?: string } + /** The effect may or may not have happened; replay this answer instead of spawning again. */ + | { status: 'unknown' } + +export type AgentSessionOperationRow = { + callerKey: string + operationId: string + fingerprint: string + operationTimestamp: number + recordedAt: number + expiresAt: number + outcome: AgentSessionOperationOutcome +} + +export type AgentSessionOperationRefusalCode = + | 'agent_session_operation_invalid' + | 'agent_session_operation_conflict' + | 'agent_session_operation_expired' + | 'agent_session_operation_capacity' + +export type AgentSessionOperationDecision = + | { decision: 'replay'; row: AgentSessionOperationRow } + | { decision: 'admit'; row: AgentSessionOperationRow } + | { decision: 'refused'; code: AgentSessionOperationRefusalCode } + +/** NUL cannot occur in a caller key or operation id, so no pair can forge another pair's key. */ +const OPERATION_KEY_SEPARATOR = '\u0000' + +export function agentSessionOperationKey(callerKey: string, operationId: string): string { + return `${callerKey}${OPERATION_KEY_SEPARATOR}${operationId}` +} + +export function settleAgentSessionOperation( + rows: ReadonlyMap, + args: { + /** Restart reconciliation omits this because the lease persists no client identity. */ + callerKey?: string + operationId: string + outcome: AgentSessionOperationOutcome + } +): Map { + const targetKey = args.callerKey + ? agentSessionOperationKey(args.callerKey, args.operationId) + : null + return new Map( + [...rows].map(([key, row]) => [ + key, + (targetKey ? key === targetKey : row.operationId === args.operationId) + ? { ...row, outcome: args.outcome } + : row + ]) + ) +} + +/** + * Retention floor. The tombstone must outlive the window in which its id could still be admitted + * as new, plus the accepted future skew — otherwise a retry arriving in the gap becomes a second + * spawn instead of a replay. + */ +export function agentSessionOperationExpiry( + operationTimestamp: number, + recordedAt: number +): number { + return ( + Math.max(recordedAt, operationTimestamp) + + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + ) +} + +export function pruneAgentSessionOperationRows( + rows: ReadonlyMap, + now: number +): Map { + const kept = new Map() + for (const [key, row] of rows) { + if (row.expiresAt > now) { + kept.set(key, row) + } + } + return kept +} + +/** + * Decide what a mutating call with this operation id means against the persisted ledger. Callers + * must prune first; a row that is present is a row that is still authoritative. + */ +export function evaluateAgentSessionOperation(args: { + rows: ReadonlyMap + callerKey: string + operationId: string + fingerprint: string + now: number + perClientLimit?: number + globalLimit?: number +}): AgentSessionOperationDecision { + const { rows, callerKey, operationId, fingerprint, now } = args + const operationTimestamp = parseAgentSessionOperationTimestamp(operationId) + if ( + operationTimestamp === null || + operationTimestamp > now + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + ) { + // Why: a future-dated id could look new again after its tombstone is collected. + return { decision: 'refused', code: 'agent_session_operation_invalid' } + } + const key = agentSessionOperationKey(callerKey, operationId) + const existing = rows.get(key) + if (existing) { + return existing.fingerprint === fingerprint + ? { decision: 'replay', row: existing } + : { decision: 'refused', code: 'agent_session_operation_conflict' } + } + if (now - operationTimestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) { + // Why: once a tombstone could have expired, an unseen replay must never be reinterpreted as + // permission to start another fresh agent. + return { decision: 'refused', code: 'agent_session_operation_expired' } + } + const perClientLimit = args.perClientLimit ?? AGENT_SESSION_DURABLE_OPERATION_PER_CLIENT_LIMIT + const globalLimit = args.globalLimit ?? AGENT_SESSION_DURABLE_OPERATION_GLOBAL_LIMIT + let callerCount = 0 + for (const row of rows.values()) { + if (row.callerKey === callerKey) { + callerCount += 1 + } + } + if (callerCount >= perClientLimit || rows.size >= globalLimit) { + // Why: tombstones cannot be evicted early without making an old replay capable of spawning + // again; reject new ids until retained rows age out. + return { decision: 'refused', code: 'agent_session_operation_capacity' } + } + return { + decision: 'admit', + row: { + callerKey, + operationId, + fingerprint, + operationTimestamp, + recordedAt: now, + expiresAt: agentSessionOperationExpiry(operationTimestamp, now), + outcome: { status: 'pending' } + } + } +} + +const OPERATION_ID_MAX_LENGTH = 128 + +export function isAgentSessionOperationRow(value: unknown): value is AgentSessionOperationRow { + if (typeof value !== 'object' || value === null) { + return false + } + const row = value as Partial + const outcome = row.outcome as AgentSessionOperationOutcome | undefined + const outcomeValid = + typeof outcome === 'object' && + outcome !== null && + ((outcome.status === 'pending' && true) || + (outcome.status === 'succeeded' && typeof outcome.sessionId === 'string') || + (outcome.status === 'failed' && typeof outcome.code === 'string') || + outcome.status === 'unknown') + return ( + typeof row.callerKey === 'string' && + row.callerKey.length > 0 && + typeof row.operationId === 'string' && + row.operationId.length <= OPERATION_ID_MAX_LENGTH && + parseAgentSessionOperationTimestamp(row.operationId) !== null && + typeof row.fingerprint === 'string' && + row.fingerprint.length > 0 && + Number.isSafeInteger(row.operationTimestamp) && + Number.isSafeInteger(row.recordedAt) && + Number.isSafeInteger(row.expiresAt) && + outcomeValid + ) +} diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts new file mode 100644 index 00000000000..538ff638983 --- /dev/null +++ b/src/shared/agent-session-provider-handle.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from 'vitest' +import { + agentSessionProviderHandleChainHead, + agentSessionProviderHandleKey, + agentSessionProviderHandleRoot, + agentSessionProviderHandlesEqual, + appendAgentSessionProviderHandleLink, + findAgentSessionProviderHandleLink, + isAgentSessionProviderHandle, + isAgentSessionHandleProvider, + isAgentSessionProviderHandleChain, + MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS, + type AgentSessionProviderHandle, + type AgentSessionProviderHandleLink +} from './agent-session-provider-handle' + +const CLAUDE: AgentSessionProviderHandle = { + provider: 'claude', + sessionId: 'sess-1', + leafUuid: 'leaf-1' +} + +function link(overrides: Partial = {}) { + return { + linkId: 'link-1', + handle: CLAUDE, + origin: 'created', + mintedAtFence: 1, + observedAt: 1_000, + ...overrides + } as AgentSessionProviderHandleLink +} + +describe('handle identity', () => { + it('rejects unknown persisted provider names instead of defaulting to Codex', () => { + expect(isAgentSessionHandleProvider('codex')).toBe(true) + expect(isAgentSessionHandleProvider('claude')).toBe(true) + expect(isAgentSessionHandleProvider('gemini')).toBe(false) + expect(isAgentSessionHandleProvider(undefined)).toBe(false) + }) + + it('keys a Claude handle by session id AND leaf, so two branches are two handles', () => { + // Concurrent resumes branch one transcript silently; the session id alone cannot name a writer. + const branchA = agentSessionProviderHandleKey(CLAUDE) + const branchB = agentSessionProviderHandleKey({ ...CLAUDE, leafUuid: 'leaf-2' }) + expect(branchA).not.toEqual(branchB) + expect(agentSessionProviderHandleRoot(CLAUDE)).toEqual( + agentSessionProviderHandleRoot({ ...CLAUDE, leafUuid: 'leaf-2' }) + ) + }) + + it('keys a Codex handle by thread id alone', () => { + const codex: AgentSessionProviderHandle = { provider: 'codex', threadId: 'thread-1' } + expect(agentSessionProviderHandleKey(codex)).toBe('codex:"thread-1"') + expect(agentSessionProviderHandleRoot(codex)).toBe('codex:"thread-1"') + expect( + agentSessionProviderHandlesEqual(codex, { provider: 'codex', threadId: 'thread-2' }) + ).toBe(false) + }) + + it('distinguishes a null leaf from an empty-string leaf and rejects malformed handles', () => { + expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: null })).toBe(true) + expect(isAgentSessionProviderHandle({ ...CLAUDE, leafUuid: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ provider: 'claude', sessionId: '' })).toBe(false) + expect(isAgentSessionProviderHandle({ provider: 'gemini', sessionId: 'x' })).toBe(false) + expect(isAgentSessionProviderHandle({ ...CLAUDE, sessionId: ' sess-1 ' })).toBe(false) + }) + + it('uses collision-free keys when Claude ids contain delimiters', () => { + const left: AgentSessionProviderHandle = { + provider: 'claude', + sessionId: 'a#b', + leafUuid: 'c' + } + const right: AgentSessionProviderHandle = { + provider: 'claude', + sessionId: 'a', + leafUuid: 'b#c' + } + expect(agentSessionProviderHandleKey(left)).not.toBe(agentSessionProviderHandleKey(right)) + expect(agentSessionProviderHandlesEqual(left, right)).toBe(false) + }) +}) + +describe('chain append', () => { + it('starts only from a created or adopted link', () => { + expect(appendAgentSessionProviderHandleLink([], link())).toEqual([link()]) + expect(appendAgentSessionProviderHandleLink([], link({ origin: 'adopted' }))).toHaveLength(1) + expect(() => appendAgentSessionProviderHandleLink([], link({ origin: 'resumed' }))).toThrow( + 'agent_session_provider_handle_invalid' + ) + }) + + it('refuses to record a fork as a resume', () => { + // --fork-session keeps the original item ids; calling it a resume would claim continuity the + // provider never gave. + const chain = [link()] + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + mintedAtFence: 2 + }) + ) + ).toThrow('agent_session_provider_handle_forked') + }) + + it('records a fork only with a new root and the seed it came from', () => { + const chain = [link()] + const forked = link({ + linkId: 'link-2', + origin: 'forked', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-9' }, + mintedAtFence: 2, + forkedFromKey: agentSessionProviderHandleKey(CLAUDE) + }) + expect(appendAgentSessionProviderHandleLink(chain, forked)).toHaveLength(2) + expect(() => + appendAgentSessionProviderHandleLink(chain, { ...forked, forkedFromKey: 'claude:other' }) + ).toThrow('agent_session_provider_handle_invalid') + expect(() => + appendAgentSessionProviderHandleLink(chain, { + ...forked, + handle: CLAUDE, + forkedFromKey: agentSessionProviderHandleKey(CLAUDE) + }) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('rejects a link minted under an older fence', () => { + const chain = [link({ mintedAtFence: 5 })] + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 4 + }) + ) + ).toThrow('agent_session_provider_handle_stale_fence') + }) + + it('rejects a provider change mid-chain', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [link()], + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { provider: 'codex', threadId: 'thread-1' }, + mintedAtFence: 2 + }) + ) + ).toThrow('agent_session_provider_handle_provider_mismatch') + }) + + it('treats re-proving the same handle at the same fence as a retry, not a new link', () => { + const chain = [link({ mintedAtFence: 3 })] + const retried = appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }) + ) + expect(retried).toHaveLength(1) + expect(retried[0]?.linkId).toBe('link-1') + // A later fence on the same handle is a genuine re-acquisition and does append. + expect( + appendAgentSessionProviderHandleLink( + chain, + link({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 4 }) + ) + ).toHaveLength(2) + }) + + it('rejects reuse of a stable link id for a different proof', () => { + expect(() => + appendAgentSessionProviderHandleLink( + [link()], + link({ + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ) + ).toThrow('agent_session_provider_handle_invalid') + }) + + it('refuses to grow past the cap rather than dropping fork provenance', () => { + const chain: AgentSessionProviderHandleLink[] = [link()] + for (let index = 1; index < MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS; index += 1) { + chain.push( + link({ + linkId: `link-${index + 1}`, + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` }, + mintedAtFence: index + 1 + }) + ) + } + expect(chain).toHaveLength(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) + expect(() => + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-overflow', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-overflow' }, + mintedAtFence: 999 + }) + ) + ).toThrow('agent_session_provider_handle_chain_overflow') + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + expect(agentSessionProviderHandleChainHead(chain)?.linkId).toBe( + `link-${MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS}` + ) + }) + + it('never mutates the chain it was given', () => { + const chain = [link()] + appendAgentSessionProviderHandleLink( + chain, + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ) + expect(chain).toHaveLength(1) + }) +}) + +describe('chain lookup and validation', () => { + it('finds a link by id and reports the head', () => { + const chain = appendAgentSessionProviderHandleLink( + [link()], + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ) + expect(findAgentSessionProviderHandleLink(chain, 'link-1')?.origin).toBe('created') + expect(findAgentSessionProviderHandleLink(chain, 'missing')).toBeNull() + expect(agentSessionProviderHandleChainHead(chain)?.linkId).toBe('link-2') + expect(agentSessionProviderHandleChainHead([])).toBeNull() + }) + + it('rejects a persisted chain that is over the cap or holds a malformed link', () => { + expect(isAgentSessionProviderHandleChain([{ ...link(), mintedAtFence: -1 }])).toBe(false) + expect(isAgentSessionProviderHandleChain([{ ...link(), linkId: 'not a link id!' }])).toBe(false) + expect(isAgentSessionProviderHandleChain([{ ...link(), forkedFromKey: 'claude:seed' }])).toBe( + false + ) + expect( + isAgentSessionProviderHandleChain( + Array.from({ length: MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS + 1 }, (_value, index) => + link({ linkId: `link-${index}` }) + ) + ) + ).toBe(false) + }) + + it('rejects persisted chains that bypass append invariants', () => { + expect( + isAgentSessionProviderHandleChain([ + link(), + link({ + linkId: 'link-2', + origin: 'created', + mintedAtFence: 2 + }) + ]) + ).toBe(false) + expect( + isAgentSessionProviderHandleChain([ + link(), + link({ + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ]) + ).toBe(false) + expect( + isAgentSessionProviderHandleChain([ + link(), + link({ + linkId: 'link-2', + origin: 'resumed', + handle: { provider: 'claude', sessionId: 'sess-2', leafUuid: 'leaf-2' }, + mintedAtFence: 2 + }) + ]) + ).toBe(false) + }) +}) diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts new file mode 100644 index 00000000000..67d6d7f7ce2 --- /dev/null +++ b/src/shared/agent-session-provider-handle.ts @@ -0,0 +1,216 @@ +/** + * Durable provider handle chain for an agent session. + * + * Handles are keyed per provider because the two structured lanes disagree about what + * identifies a conversation. Claude's session id is the identity root and its leaf uuid is a + * branch cursor; Codex's thread id is the whole key. Resumes extend the chain, forks start a new + * identity root, and the chain records which is which so a fork is never presented as a resume. + */ + +export const AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS = ['claude', 'codex'] as const + +export type AgentSessionHandleProvider = (typeof AGENT_SESSION_PROVIDER_HANDLE_PROVIDERS)[number] + +/** Runtime guard for persisted/remote provider metadata. Unknown values must not impersonate Codex. */ +export function isAgentSessionHandleProvider(value: unknown): value is AgentSessionHandleProvider { + return value === 'claude' || value === 'codex' +} + +export type AgentSessionProviderHandle = + | { provider: 'claude'; sessionId: string; leafUuid: string | null } + | { provider: 'codex'; threadId: string } + +export type AgentSessionProviderHandleOrigin = 'created' | 'adopted' | 'resumed' | 'forked' + +export type AgentSessionProviderHandleLink = { + /** Stable id so a lease can name the exact link its owner proved. */ + linkId: string + handle: AgentSessionProviderHandle + origin: AgentSessionProviderHandleOrigin + /** Runtime fence in force when this link was minted; never decreases along the chain. */ + mintedAtFence: number + observedAt: number + /** Key of the link a fork was seeded from. Only set when `origin` is `forked`. */ + forkedFromKey?: string +} + +export type AgentSessionProviderHandleChain = readonly AgentSessionProviderHandleLink[] + +/** Bounded so one session cannot grow an unbounded persisted record. */ +export const MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS = 256 + +const MAX_HANDLE_FIELD_LENGTH = 512 +const LINK_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ + +function isHandleField(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_HANDLE_FIELD_LENGTH && + value === value.trim() + ) +} + +export function isAgentSessionProviderHandle(value: unknown): value is AgentSessionProviderHandle { + if (typeof value !== 'object' || value === null) { + return false + } + const handle = value as Partial & Record + if (handle.provider === 'claude') { + return ( + isHandleField(handle.sessionId) && + (handle.leafUuid === null || isHandleField(handle.leafUuid)) + ) + } + return handle.provider === 'codex' && isHandleField(handle.threadId) +} + +/** Stable string identity for one handle. Two handles with the same key name the same writer target. */ +export function agentSessionProviderHandleKey(handle: AgentSessionProviderHandle): string { + return handle.provider === 'claude' + ? `claude:${JSON.stringify([handle.sessionId, handle.leafUuid])}` + : `codex:${JSON.stringify(handle.threadId)}` +} + +/** + * Identity root: the part that a resume must preserve. A resume that changes the root is a fork, + * whatever the provider called it. + */ +export function agentSessionProviderHandleRoot(handle: AgentSessionProviderHandle): string { + return handle.provider === 'claude' + ? `claude:${JSON.stringify(handle.sessionId)}` + : `codex:${JSON.stringify(handle.threadId)}` +} + +export function agentSessionProviderHandlesEqual( + left: AgentSessionProviderHandle, + right: AgentSessionProviderHandle +): boolean { + return agentSessionProviderHandleKey(left) === agentSessionProviderHandleKey(right) +} + +export function agentSessionProviderHandleChainHead( + chain: AgentSessionProviderHandleChain +): AgentSessionProviderHandleLink | null { + return chain.at(-1) ?? null +} + +export function findAgentSessionProviderHandleLink( + chain: AgentSessionProviderHandleChain, + linkId: string +): AgentSessionProviderHandleLink | null { + return chain.find((link) => link.linkId === linkId) ?? null +} + +export function isAgentSessionProviderHandleLink( + value: unknown +): value is AgentSessionProviderHandleLink { + if (typeof value !== 'object' || value === null) { + return false + } + const link = value as Partial + const originValid = + link.origin === 'created' || + link.origin === 'adopted' || + link.origin === 'resumed' || + link.origin === 'forked' + return ( + typeof link.linkId === 'string' && + LINK_ID_PATTERN.test(link.linkId) && + isAgentSessionProviderHandle(link.handle) && + originValid && + Number.isSafeInteger(link.mintedAtFence) && + (link.mintedAtFence as number) >= 0 && + Number.isSafeInteger(link.observedAt) && + (link.origin === 'forked' + ? isHandleField(link.forkedFromKey) + : link.forkedFromKey === undefined) + ) +} + +export function isAgentSessionProviderHandleChain( + value: unknown +): value is AgentSessionProviderHandleLink[] { + if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + return false + } + let validated: AgentSessionProviderHandleLink[] = [] + try { + for (const link of value) { + if (!isAgentSessionProviderHandleLink(link)) { + return false + } + const next = appendAgentSessionProviderHandleLink(validated, link) + // A persisted chain must name every link exactly once; retry elision belongs at append time. + if (next.length !== validated.length + 1) { + return false + } + validated = next + } + return true + } catch { + return false + } +} + +/** + * Append one link, rejecting anything that would let a fork masquerade as a resume or let a + * late writer rewrite the chain under an older fence. + */ +export function appendAgentSessionProviderHandleLink( + chain: AgentSessionProviderHandleChain, + link: AgentSessionProviderHandleLink +): AgentSessionProviderHandleLink[] { + if (!isAgentSessionProviderHandleLink(link)) { + throw new Error('agent_session_provider_handle_invalid') + } + const head = agentSessionProviderHandleChainHead(chain) + if (!head) { + if (link.origin !== 'created' && link.origin !== 'adopted') { + throw new Error('agent_session_provider_handle_invalid') + } + return [link] + } + if (link.handle.provider !== head.handle.provider) { + throw new Error('agent_session_provider_handle_provider_mismatch') + } + if (link.mintedAtFence < head.mintedAtFence) { + throw new Error('agent_session_provider_handle_stale_fence') + } + if (link.origin === 'created' || link.origin === 'adopted') { + throw new Error('agent_session_provider_handle_invalid') + } + const sameRoot = + agentSessionProviderHandleRoot(link.handle) === agentSessionProviderHandleRoot(head.handle) + if (link.origin === 'resumed' && !sameRoot) { + // Why: a resume that lands on another identity root forked; recording it as a resume would + // make Orca claim continuity the provider never gave. + throw new Error('agent_session_provider_handle_forked') + } + if (link.origin === 'forked') { + if (sameRoot) { + throw new Error('agent_session_provider_handle_invalid') + } + if (link.forkedFromKey !== agentSessionProviderHandleKey(head.handle)) { + throw new Error('agent_session_provider_handle_invalid') + } + } + if ( + link.origin === 'resumed' && + agentSessionProviderHandlesEqual(link.handle, head.handle) && + link.mintedAtFence === head.mintedAtFence + ) { + // Why: re-proving the same handle at the same fence is a retry, not a new identity. + return [...chain] + } + if (findAgentSessionProviderHandleLink(chain, link.linkId)) { + // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. + throw new Error('agent_session_provider_handle_invalid') + } + if (chain.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + // Why: dropping older links would erase fork provenance, so refuse and let the caller roll + // the journal epoch instead of silently losing where this conversation came from. + throw new Error('agent_session_provider_handle_chain_overflow') + } + return [...chain, link] +} diff --git a/src/shared/agent-session-pty-write-admission.test.ts b/src/shared/agent-session-pty-write-admission.test.ts new file mode 100644 index 00000000000..9718fc8d5dc --- /dev/null +++ b/src/shared/agent-session-pty-write-admission.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import { + AgentSessionPtyWriteRefusedError, + describeAgentSessionPtyWriteRefusal, + evaluateAgentSessionPtyWriteAdmission, + isAgentSessionPtyWriteRefusedError, + reevaluateAgentSessionPtyWriteAdmission +} from './agent-session-pty-write-admission' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from './agent-session-record.test-fixture' +import type { AgentSessionLease } from './agent-session-record' +import { AGENT_SESSION_RPC_ERROR_CODES } from './agent-session-host-authority' + +function bindingFor(lease: AgentSessionLease) { + return { sessionId: lease.sessionId, record: agentSessionRecordFixture(lease) } +} + +describe('exemptions', () => { + it('admits a PTY with no binding, which is every ordinary shell and legacy agent terminal', () => { + const admission = evaluateAgentSessionPtyWriteAdmission(null) + expect(admission).toEqual({ admitted: true, sessionId: null, runtimeFence: null }) + }) + + it('admits a proven-live TUI owner', () => { + const lease = agentSessionLeaseFixture() + expect(evaluateAgentSessionPtyWriteAdmission(bindingFor(lease))).toEqual({ + admitted: true, + sessionId: lease.sessionId, + runtimeFence: lease.runtimeFence + }) + }) +}) + +describe('refusal matrix', () => { + const cases: { name: string; lease: Partial; code: string }[] = [ + { + name: 'native chat owns the session', + lease: { runtimeKind: 'native' }, + code: 'agent_session_conflict' + }, + { + name: 'a handoff is preparing', + lease: { handoffStage: 'preparing' }, + code: 'agent_session_conflict' + }, + { + name: 'the old owner stopped mid-handoff', + lease: { handoffStage: 'old-owner-stopped' }, + code: 'agent_session_conflict' + }, + { + name: 'the new owner has not proved itself', + lease: { handoffStage: 'new-owner-proving' }, + code: 'agent_session_conflict' + }, + { + name: 'two claims collided', + lease: { claimStatus: 'conflicted' }, + code: 'agent_session_conflict' + }, + { + name: 'the lease is unreconciled after a host restart', + lease: { unreconciled: true }, + code: 'execution_owner_reconciling' + }, + { + name: 'recovery is running', + lease: { handoffStage: 'recovering' }, + code: 'execution_owner_reconciling' + }, + { + name: 'a human must finish recovery', + lease: { handoffStage: 'manual-recovery' }, + code: 'execution_owner_reconciling' + }, + { + name: 'the claim is reserved but no process exists yet', + lease: { claimStatus: 'reserved', ownerProcess: null }, + code: 'agent_session_ownership_unknown' + }, + { + name: 'the owner released the session', + lease: { claimStatus: 'released' }, + code: 'agent_session_ownership_unknown' + } + ] + + for (const testCase of cases) { + it(`refuses when ${testCase.name}`, () => { + const lease = agentSessionLeaseFixture(testCase.lease) + const admission = evaluateAgentSessionPtyWriteAdmission(bindingFor(lease)) + expect(admission.admitted).toBe(false) + if (admission.admitted) { + return + } + expect(admission.refusal.code).toBe(testCase.code) + expect(admission.refusal.sessionId).toBe(lease.sessionId) + expect(admission.refusal.ownerRuntimeKind).toBe(lease.runtimeKind) + expect(admission.refusal.handoffStage).toBe(lease.handoffStage) + expect(admission.refusal.runtimeFence).toBe(lease.runtimeFence) + }) + } + + it('distinguishes a reconciling window from a session another runtime owns', () => { + // Phase-2 clients render "recovering, retry shortly" only when these two do not collapse. + const reconciling = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ unreconciled: true })) + ) + const owned = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) + ) + expect(reconciling.admitted).toBe(false) + expect(owned.admitted).toBe(false) + if (reconciling.admitted || owned.admitted) { + return + } + expect(reconciling.refusal.code).not.toBe(owned.refusal.code) + }) + + it('fails closed when a bound PTY has no readable record', () => { + const admission = evaluateAgentSessionPtyWriteAdmission({ + sessionId: 'session-alpha-1', + record: null + }) + expect(admission.admitted).toBe(false) + if (admission.admitted) { + return + } + expect(admission.refusal.code).toBe('execution_owner_reconciling') + expect(admission.refusal.ownerRuntimeKind).toBeNull() + }) + + it('fails closed when the record answers for a different session', () => { + const admission = evaluateAgentSessionPtyWriteAdmission({ + sessionId: 'session-beta-2', + record: agentSessionRecordFixture() + }) + expect(admission.admitted).toBe(false) + if (admission.admitted) { + return + } + expect(admission.refusal.code).toBe('agent_session_ownership_unknown') + expect(admission.refusal.sessionId).toBe('session-beta-2') + }) + + it('only emits codes old clients already decode', () => { + const emitted = new Set( + cases.map((testCase) => { + const admission = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture(testCase.lease)) + ) + return admission.admitted ? 'admitted' : admission.refusal.code + }) + ) + for (const code of emitted) { + expect(AGENT_SESSION_RPC_ERROR_CODES).toContain(code) + } + }) +}) + +describe('in-flight fence race', () => { + const admittedLease = agentSessionLeaseFixture() + const admitted = { sessionId: admittedLease.sessionId, runtimeFence: admittedLease.runtimeFence } + + it('lets the rest of a write land while the same fence still holds', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ + admitted, + binding: bindingFor(admittedLease) + }) + expect(next.admitted).toBe(true) + }) + + it('refuses the rest of a write once the fence advanced under it', () => { + // A handoff completed between two chunks: the new owner's lease would otherwise admit them. + const moved = agentSessionLeaseFixture({ runtimeFence: admittedLease.runtimeFence + 1 }) + const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: bindingFor(moved) }) + expect(next.admitted).toBe(false) + if (next.admitted) { + return + } + expect(next.refusal.code).toBe('agent_session_checkpoint_stale') + expect(next.refusal.runtimeFence).toBe(moved.runtimeFence) + }) + + it('refuses the rest of a write when the PTY was rebound to another session', () => { + const other = agentSessionLeaseFixture({ sessionId: 'session-beta-2' }) + const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: bindingFor(other) }) + expect(next.admitted).toBe(false) + }) + + it('refuses the rest of a write when the binding disappeared mid-flight', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ admitted, binding: null }) + expect(next.admitted).toBe(false) + }) + + it('refuses the rest of a write when the lease stopped admitting a writer', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ + admitted, + binding: bindingFor(agentSessionLeaseFixture({ handoffStage: 'preparing' })) + }) + expect(next.admitted).toBe(false) + if (next.admitted) { + return + } + expect(next.refusal.code).toBe('agent_session_conflict') + }) + + it('judges a write admitted while unbound on whatever binding appeared', () => { + const next = reevaluateAgentSessionPtyWriteAdmission({ + admitted: { sessionId: null, runtimeFence: null }, + binding: bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) + }) + expect(next.admitted).toBe(false) + }) +}) + +describe('typed error', () => { + it('carries the refusal and reports its code as the message', () => { + const admission = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native' })) + ) + if (admission.admitted) { + throw new Error('expected a refusal') + } + const error = new AgentSessionPtyWriteRefusedError(admission.refusal) + expect(isAgentSessionPtyWriteRefusedError(error)).toBe(true) + expect(isAgentSessionPtyWriteRefusedError(new Error('agent_session_conflict'))).toBe(false) + expect(error.message).toBe('agent_session_conflict') + expect(error.refusal).toEqual(admission.refusal) + }) + + it('describes who holds the session and what stage it is in', () => { + const admission = evaluateAgentSessionPtyWriteAdmission( + bindingFor(agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'preparing' })) + ) + if (admission.admitted) { + throw new Error('expected a refusal') + } + const described = describeAgentSessionPtyWriteRefusal(admission.refusal) + expect(described).toContain('session-alpha-1') + expect(described).toContain('native chat') + expect(described).toContain('pid 4242') + expect(described).toContain('preparing') + }) +}) diff --git a/src/shared/agent-session-pty-write-admission.ts b/src/shared/agent-session-pty-write-admission.ts new file mode 100644 index 00000000000..78f9b915f69 --- /dev/null +++ b/src/shared/agent-session-pty-write-admission.ts @@ -0,0 +1,177 @@ +/** + * PTY-write admission for agent sessions that have a durable record. + * + * A PTY is the TUI runtime's write surface, so bytes may enter it only while the session's lease + * admits a writer and that writer is the TUI. The admit decision is `agentSessionLeaseAdmitsWriter` + * verbatim; everything here only decides whether the lease is even the TUI's to hold, and — once + * that helper has already refused — which refusal a client should be shown. + * + * A PTY with no binding is a session this host knows nothing about: every ordinary shell and every + * legacy agent terminal. Those are never consulted and never refused. A binding whose record is + * missing is the opposite case — the record was lost, not absent — and fails closed. + */ + +import { + agentSessionLeaseAdmitsWriter, + isAgentSessionFenceCurrent +} from './agent-session-lease-adjudication' +import type { + AgentSessionHandoffStage, + AgentSessionLease, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from './agent-session-record' + +/** + * Every code is already in `AGENT_SESSION_RPC_ERROR_CODES`, so a refusal reaching an old client + * carries a code it has seen since the host-authority release rather than a new one. + */ +export type AgentSessionPtyWriteRefusalCode = + | 'agent_session_conflict' + | 'agent_session_ownership_unknown' + | 'agent_session_checkpoint_stale' + | 'execution_owner_reconciling' + +export type AgentSessionPtyWriteRefusal = { + code: AgentSessionPtyWriteRefusalCode + sessionId: string + /** Runtime the lease names as owner; null once the record is gone. */ + ownerRuntimeKind: AgentSessionOwnerRuntimeKind | null + handoffStage: AgentSessionHandoffStage | null + /** Pid the lease names, so the refusal can say who holds the session. */ + ownerPid: number | null + runtimeFence: number | null +} + +export type AgentSessionPtyWriteAdmission = + | { admitted: true; sessionId: string | null; runtimeFence: number | null } + | { admitted: false; refusal: AgentSessionPtyWriteRefusal } + +/** One PTY's durable binding: the session it belongs to and that session's record, if readable. */ +export type AgentSessionPtyBinding = { + sessionId: string + record: AgentSessionRecord | null +} + +const UNBOUND_ADMISSION: AgentSessionPtyWriteAdmission = { + admitted: true, + sessionId: null, + runtimeFence: null +} + +/** Runs only after `agentSessionLeaseAdmitsWriter` (or the runtime-kind test) already refused. */ +function classifyRefusal(lease: AgentSessionLease): AgentSessionPtyWriteRefusalCode { + if (lease.unreconciled) { + return 'execution_owner_reconciling' + } + if (lease.claimStatus === 'conflicted') { + return 'agent_session_conflict' + } + if (lease.handoffStage === 'recovering' || lease.handoffStage === 'manual-recovery') { + return 'execution_owner_reconciling' + } + if (lease.handoffStage !== null || lease.runtimeKind !== 'tui') { + // Why: a live native owner and a mid-flight handoff are both "someone else holds it", which is + // actionable in a way "we cannot tell" is not. + return 'agent_session_conflict' + } + return 'agent_session_ownership_unknown' +} + +function refuse( + code: AgentSessionPtyWriteRefusalCode, + sessionId: string, + lease: AgentSessionLease | null +): AgentSessionPtyWriteAdmission { + return { + admitted: false, + refusal: { + code, + sessionId, + ownerRuntimeKind: lease?.runtimeKind ?? null, + handoffStage: lease?.handoffStage ?? null, + ownerPid: lease?.ownerProcess?.pid ?? null, + runtimeFence: lease?.runtimeFence ?? null + } + } +} + +export function evaluateAgentSessionPtyWriteAdmission( + binding: AgentSessionPtyBinding | null +): AgentSessionPtyWriteAdmission { + if (!binding) { + return UNBOUND_ADMISSION + } + const record = binding.record + if (!record) { + // Why: a bound PTY whose record cannot be read is a lost lease, not an unmanaged shell. + return refuse('execution_owner_reconciling', binding.sessionId, null) + } + if (record.sessionId !== binding.sessionId) { + return refuse('agent_session_ownership_unknown', binding.sessionId, record.lease) + } + const lease = record.lease + if (lease.runtimeKind === 'tui' && agentSessionLeaseAdmitsWriter(lease)) { + return { admitted: true, sessionId: record.sessionId, runtimeFence: lease.runtimeFence } + } + return refuse(classifyRefusal(lease), binding.sessionId, lease) +} + +/** + * Re-admit a write that already began. Chunked input and the text/suffix pause both yield, so a + * lease transition can land between two writes of one logical send; the fence observed at + * admission is what the remaining bytes are checked against. + */ +export function reevaluateAgentSessionPtyWriteAdmission(args: { + admitted: { sessionId: string | null; runtimeFence: number | null } + binding: AgentSessionPtyBinding | null +}): AgentSessionPtyWriteAdmission { + const { admitted, binding } = args + const next = evaluateAgentSessionPtyWriteAdmission(binding) + if (admitted.sessionId === null || admitted.runtimeFence === null) { + // Why: an unbound write that acquires a binding mid-flight is judged on the new binding alone. + return next + } + if (!next.admitted) { + return next + } + const lease = binding?.record?.lease ?? null + if ( + next.sessionId !== admitted.sessionId || + !lease || + !isAgentSessionFenceCurrent(lease, admitted.runtimeFence) + ) { + return refuse('agent_session_checkpoint_stale', admitted.sessionId, lease) + } + return next +} + +export class AgentSessionPtyWriteRefusedError extends Error { + readonly refusal: AgentSessionPtyWriteRefusal + + constructor(refusal: AgentSessionPtyWriteRefusal) { + // Why: callers that already switch on `error.message` as an RPC code keep working unchanged. + super(refusal.code) + this.name = 'AgentSessionPtyWriteRefusedError' + this.refusal = refusal + } +} + +export function isAgentSessionPtyWriteRefusedError( + error: unknown +): error is AgentSessionPtyWriteRefusedError { + return error instanceof AgentSessionPtyWriteRefusedError +} + +/** Human-readable refusal, so a client that only surfaces a message still names the owner. */ +export function describeAgentSessionPtyWriteRefusal(refusal: AgentSessionPtyWriteRefusal): string { + const owner = + refusal.ownerRuntimeKind === null + ? 'no recorded owner' + : `${refusal.ownerRuntimeKind === 'native' ? 'native chat' : 'the agent TUI'}${ + refusal.ownerPid === null ? '' : ` (pid ${refusal.ownerPid})` + }` + const stage = + refusal.handoffStage === null ? 'no handoff in progress' : `handoff ${refusal.handoffStage}` + return `Agent session ${refusal.sessionId} is held by ${owner}; ${stage} (${refusal.code}).` +} diff --git a/src/shared/agent-session-pty-write-refusal-copy.ts b/src/shared/agent-session-pty-write-refusal-copy.ts new file mode 100644 index 00000000000..6820b6ce44b --- /dev/null +++ b/src/shared/agent-session-pty-write-refusal-copy.ts @@ -0,0 +1,13 @@ +import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' + +export function structuredChatPtyWriteRefusalCopy( + refusal: AgentSessionPtyWriteRefusal, + action: 'terminal-send' | 'worker-start' +): string | null { + if (refusal.ownerRuntimeKind !== 'native') { + return null + } + return action === 'worker-start' + ? 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca orchestration worker-start`.' + : 'The target terminal is in Structured Chat. Switch it to Terminal, then retry `orca terminal send`.' +} diff --git a/src/shared/agent-session-record.test-fixture.ts b/src/shared/agent-session-record.test-fixture.ts new file mode 100644 index 00000000000..65da67c571e --- /dev/null +++ b/src/shared/agent-session-record.test-fixture.ts @@ -0,0 +1,67 @@ +/** Durable-record fixtures shared by the write-admission tests across shared, runtime, and IPC. */ + +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionLease, + type AgentSessionRecord +} from './agent-session-record' + +const OWNER_PROCESS = { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: 'spawn-tui' +} + +/** A proven-live TUI owner: the only lease state that admits a PTY write. */ +export function agentSessionLeaseFixture( + overrides: Partial = {} +): AgentSessionLease { + return { + sessionId: 'session-alpha-1', + runtimeKind: 'tui', + runtimeFence: 7, + handoffStage: null, + provenHandleLinkId: 'link-1', + ownerProcess: OWNER_PROCESS, + reservedSpawnToken: 'spawn-tui', + leaseDeadlineAt: 60_000, + lastRenewedAt: 30_000, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null, + ...overrides + } +} + +export function agentSessionRecordFixture( + lease: AgentSessionLease = agentSessionLeaseFixture() +): AgentSessionRecord { + return { + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, + sessionId: lease.sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + providerHandleChain: [ + { + linkId: 'link-1', + origin: 'created', + mintedAtFence: lease.runtimeFence, + observedAt: 1_000, + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, + lease, + createdAt: 1_000, + updatedAt: 2_000 + } +} diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts new file mode 100644 index 00000000000..f81e1461218 --- /dev/null +++ b/src/shared/agent-session-record.ts @@ -0,0 +1,357 @@ +/** + * Durable agent-session record and its single-writer lease. + * + * The record is the session's identity — where it runs, which provider it talks to, which account + * home is pinned to it — and is independent of any terminal tab. The lease is the separate + * question of which process is currently allowed to write to it. + */ + +import type { ExecutionHostId } from './execution-host' +import { + isAgentSessionProviderHandleChain, + type AgentSessionHandleProvider, + type AgentSessionProviderHandleLink +} from './agent-session-provider-handle' + +export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const + +export type AgentSessionWorkspaceKind = 'git-worktree' | 'folder' + +/** + * Where the provider process actually runs. WSL is called out separately from the execution host + * id because a WSL workspace is served by the local host but is a distinct filesystem, account + * root, and process namespace — two sessions there must never collide with their native twins. + */ +export type AgentSessionExecutionLocation = { + executionHostId: ExecutionHostId + /** Distro name when the provider runs inside WSL; null for native and remote hosts. */ + wslDistro: string | null + workspaceId: string + workspaceKind: AgentSessionWorkspaceKind +} + +/** Account root pinned at launch by the account selector, so a resume cannot drift to another login. */ +export type AgentSessionAccountHome = { + variable: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME' + /** Host-resolved absolute path in the execution host's own path syntax. */ + path: string +} + +/** Provider launch environment captured by the host when the session is created. */ +export type AgentSessionLaunchEnv = Record + +/** Provider CLI arguments captured by the host when the session is created. */ +export type AgentSessionLaunchArgs = string[] + +export type AgentSessionOwnerRuntimeKind = 'native' | 'tui' + +export type AgentSessionHandoffStage = + | 'preparing' + | 'old-owner-stopped' + | 'new-owner-proving' + | 'recovering' + | 'manual-recovery' + +/** + * PID-reuse-safe process identity. `spawnToken` is the only element available on every platform: + * process start time costs a CIM query on Windows and is absent in some containers. An exact + * identity stays in `recovering`; an ownerless, unattributable reservation uses `manual-recovery`. + */ +export type AgentSessionProcessIdentity = { + hostId: string + pid: number + processStartTimeMs: number | null + spawnToken: string +} + +export type AgentSessionJournalCheckpoint = { epoch: number; sequence: number } + +/** + * Mirrors the in-memory claim registry's reserved / live / conflicted states so a conflict + * survives a restart. `released` has no registry equivalent: the registry expresses "no owner" by + * deleting the entry, and a durable record that outlives its owner needs a name for that. + */ +export type AgentSessionClaimStatus = 'reserved' | 'live' | 'conflicted' | 'released' + +export type AgentSessionDeathEvidence = { + kind: 'exit-observed' | 'pid-absent' | 'identity-mismatch' + detail: string + observedAt: number +} + +export type AgentSessionLease = { + sessionId: string + runtimeKind: AgentSessionOwnerRuntimeKind + /** Durable monotonic integer; only acquisition CAS and proven eviction move it. */ + runtimeFence: number + handoffStage: AgentSessionHandoffStage | null + /** Link id of the provider handle this owner proved; the full chain lives on the record. */ + provenHandleLinkId: string | null + /** Null between the durable reservation and the observed spawn. */ + ownerProcess: AgentSessionProcessIdentity | null + /** Reserved before any process exists, then matched against the child's environment. */ + reservedSpawnToken: string | null + /** Set only when acquisition failed before any spawn attempt. */ + processlessAt?: number | null + leaseDeadlineAt: number + lastRenewedAt: number + handoffOperationId: string | null + journalCheckpoint: AgentSessionJournalCheckpoint | null + /** Key id that minted the HMAC claim this lease was granted under. */ + claimKeyId: string + claimStatus: AgentSessionClaimStatus + /** True from load until the host adjudicates it; no writer is granted while set. */ + unreconciled: boolean + /** + * Lowest fence a future grant may use. Set only after the store recovers from its backup, where + * the commit that never landed may already have granted a fence the backup cannot show. The + * CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number, + * so rewriting it would invalidate the record it is trying to save. + */ + minimumNextFence?: number + deathEvidence: AgentSessionDeathEvidence | null +} + +export type AgentSessionRecord = { + schemaVersion: typeof AGENT_SESSION_RECORD_SCHEMA_VERSION + sessionId: string + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + providerHandleChain: AgentSessionProviderHandleLink[] + accountHome: AgentSessionAccountHome + /** Provider options acknowledged for the next turn, restored across owner replacement. */ + options?: Record + launchArgs?: AgentSessionLaunchArgs + lease: AgentSessionLease + createdAt: number + updatedAt: number +} + +export type AgentSessionOptionsReplacement = { + sessionId: string + fence: number + options: Readonly> + now: number +} + +const MAX_ID_LENGTH = 512 +const MAX_PATH_LENGTH = 4096 +const MAX_LAUNCH_ENV_ENTRIES = 256 +const MAX_LAUNCH_ENV_VALUE_LENGTH = 65_536 +const MAX_LAUNCH_ARGS = 256 +const MAX_LAUNCH_ARGS_BYTES = 16 * 1024 +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{8,128}$/ + +function isBoundedString(value: unknown, max: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= max +} + +export function isAgentSessionId(value: unknown): value is string { + return typeof value === 'string' && SESSION_ID_PATTERN.test(value) +} + +/** NUL cannot occur in a host id, distro name, or workspace id, so no component can forge a join. */ +const SCOPE_KEY_SEPARATOR = '\u0000' + +/** + * Scope key for host-and-workspace isolation. Native, WSL, and SSH copies of one workspace id are + * different sessions; collapsing them would let one host adjudicate another host's lease. + */ +export function agentSessionScopeKey(location: AgentSessionExecutionLocation): string { + return [location.executionHostId, location.wslDistro ?? '', location.workspaceId].join( + SCOPE_KEY_SEPARATOR + ) +} + +export function agentSessionExecutionLocationsEqual( + left: AgentSessionExecutionLocation, + right: AgentSessionExecutionLocation +): boolean { + return ( + agentSessionScopeKey(left) === agentSessionScopeKey(right) && + left.workspaceKind === right.workspaceKind + ) +} + +export function isAgentSessionExecutionLocation( + value: unknown +): value is AgentSessionExecutionLocation { + if (typeof value !== 'object' || value === null) { + return false + } + const location = value as Partial + return ( + isBoundedString(location.executionHostId, MAX_ID_LENGTH) && + (location.wslDistro === null || isBoundedString(location.wslDistro, MAX_ID_LENGTH)) && + isBoundedString(location.workspaceId, MAX_ID_LENGTH) && + (location.workspaceKind === 'git-worktree' || location.workspaceKind === 'folder') + ) +} + +export function isAgentSessionProcessIdentity( + value: unknown +): value is AgentSessionProcessIdentity { + if (typeof value !== 'object' || value === null) { + return false + } + const identity = value as Partial + return ( + isBoundedString(identity.hostId, MAX_ID_LENGTH) && + Number.isSafeInteger(identity.pid) && + (identity.pid as number) > 0 && + (identity.processStartTimeMs === null || + (Number.isSafeInteger(identity.processStartTimeMs) && + (identity.processStartTimeMs as number) >= 0)) && + isBoundedString(identity.spawnToken, MAX_ID_LENGTH) + ) +} + +function isAgentSessionAccountHome(value: unknown): value is AgentSessionAccountHome { + if (typeof value !== 'object' || value === null) { + return false + } + const home = value as Partial + return ( + (home.variable === 'CLAUDE_CONFIG_DIR' || home.variable === 'CODEX_HOME') && + isBoundedString(home.path, MAX_PATH_LENGTH) + ) +} + +function isAgentSessionOptions(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const entries = Object.entries(value) + return ( + entries.length <= 32 && + entries.every( + ([key, option]) => + isBoundedString(key, MAX_ID_LENGTH) && isBoundedString(option, MAX_ID_LENGTH) + ) + ) +} + +export function isAgentSessionLaunchEnv(value: unknown): value is AgentSessionLaunchEnv { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false + } + const entries = Object.entries(value) + return ( + entries.length <= MAX_LAUNCH_ENV_ENTRIES && + entries.every( + ([key, entry]) => + isBoundedString(key, MAX_ID_LENGTH) && + typeof entry === 'string' && + entry.length <= MAX_LAUNCH_ENV_VALUE_LENGTH + ) + ) +} + +function isAgentSessionJournalCheckpoint(value: unknown): value is AgentSessionJournalCheckpoint { + if (typeof value !== 'object' || value === null) { + return false + } + const checkpoint = value as Partial + return ( + Number.isSafeInteger(checkpoint.epoch) && + (checkpoint.epoch as number) >= 0 && + Number.isSafeInteger(checkpoint.sequence) && + (checkpoint.sequence as number) >= 0 + ) +} + +function isAgentSessionDeathEvidence(value: unknown): value is AgentSessionDeathEvidence { + if (typeof value !== 'object' || value === null) { + return false + } + const evidence = value as Partial + return ( + (evidence.kind === 'exit-observed' || + evidence.kind === 'pid-absent' || + evidence.kind === 'identity-mismatch') && + isBoundedString(evidence.detail, MAX_ID_LENGTH) && + Number.isSafeInteger(evidence.observedAt) && + (evidence.observedAt as number) >= 0 + ) +} + +function isAgentSessionLease(value: unknown): value is AgentSessionLease { + if (typeof value !== 'object' || value === null) { + return false + } + const lease = value as Partial + return ( + isAgentSessionId(lease.sessionId) && + (lease.runtimeKind === 'native' || lease.runtimeKind === 'tui') && + Number.isSafeInteger(lease.runtimeFence) && + (lease.runtimeFence as number) >= 0 && + (lease.handoffStage === null || + lease.handoffStage === 'preparing' || + lease.handoffStage === 'old-owner-stopped' || + lease.handoffStage === 'new-owner-proving' || + lease.handoffStage === 'recovering' || + lease.handoffStage === 'manual-recovery') && + (lease.provenHandleLinkId === null || isBoundedString(lease.provenHandleLinkId, 128)) && + (lease.ownerProcess === null || isAgentSessionProcessIdentity(lease.ownerProcess)) && + (lease.reservedSpawnToken === null || + isBoundedString(lease.reservedSpawnToken, MAX_ID_LENGTH)) && + (lease.processlessAt === undefined || + lease.processlessAt === null || + (Number.isSafeInteger(lease.processlessAt) && (lease.processlessAt as number) >= 0)) && + Number.isSafeInteger(lease.leaseDeadlineAt) && + Number.isSafeInteger(lease.lastRenewedAt) && + (lease.handoffOperationId === null || + isBoundedString(lease.handoffOperationId, MAX_ID_LENGTH)) && + (lease.journalCheckpoint === null || + isAgentSessionJournalCheckpoint(lease.journalCheckpoint)) && + isBoundedString(lease.claimKeyId, MAX_ID_LENGTH) && + (lease.claimStatus === 'reserved' || + lease.claimStatus === 'live' || + lease.claimStatus === 'conflicted' || + lease.claimStatus === 'released') && + typeof lease.unreconciled === 'boolean' && + (lease.deathEvidence === null || isAgentSessionDeathEvidence(lease.deathEvidence)) + ) +} + +export function isAgentSessionRecord(value: unknown): value is AgentSessionRecord { + if (typeof value !== 'object' || value === null) { + return false + } + const record = value as Partial + const shapeValid = + record.schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION && + isAgentSessionId(record.sessionId) && + isAgentSessionExecutionLocation(record.location) && + (record.provider === 'claude' || record.provider === 'codex') && + isAgentSessionProviderHandleChain(record.providerHandleChain) && + isAgentSessionAccountHome(record.accountHome) && + (record.options === undefined || isAgentSessionOptions(record.options)) && + (record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) && + !Object.hasOwn(record, 'launchEnv') && + isAgentSessionLease(record.lease) && + record.lease.sessionId === record.sessionId && + Number.isSafeInteger(record.createdAt) && + Number.isSafeInteger(record.updatedAt) + if (!shapeValid) { + return false + } + const validated = record as AgentSessionRecord + const head = validated.providerHandleChain.at(-1) + return ( + validated.providerHandleChain.every((link) => link.handle.provider === validated.provider) && + (validated.lease.claimStatus !== 'live' || + (validated.lease.ownerProcess !== null && + head?.linkId === validated.lease.provenHandleLinkId && + head.mintedAtFence === validated.lease.runtimeFence)) + ) +} + +export function isAgentSessionLaunchArgs(value: unknown): value is AgentSessionLaunchArgs { + return ( + Array.isArray(value) && + value.length <= MAX_LAUNCH_ARGS && + value.every((arg) => typeof arg === 'string' && !arg.includes('\0')) && + Buffer.byteLength(JSON.stringify(value), 'utf8') <= MAX_LAUNCH_ARGS_BYTES + ) +} diff --git a/src/shared/agent-session-refusal-retry.ts b/src/shared/agent-session-refusal-retry.ts new file mode 100644 index 00000000000..94c8f65c413 --- /dev/null +++ b/src/shared/agent-session-refusal-retry.ts @@ -0,0 +1,39 @@ +import type { AgentSessionWireRefusalCode } from './agent-session-wire' + +export type AgentSessionRefusalOperationState = 'settled-rejected' | 'pending-admission' | 'unknown' + +const HANDOFF_SETTLED_REFUSALS = new Set([ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_operation_conflict' +]) + +export function agentSessionRefusalOperationState( + method: string, + code: AgentSessionWireRefusalCode +): AgentSessionRefusalOperationState { + if (method === 'agentSession.requestHandoff' && HANDOFF_SETTLED_REFUSALS.has(code)) { + return 'settled-rejected' + } + switch (code) { + case 'agent_session_operation_conflict': + case 'agent_session_operation_expired': + case 'agent_session_operation_invalid': + case 'agent_session_item_revision_stale': + case 'agent_session_already_resolved': + return 'settled-rejected' + case 'agent_session_operation_unknown': + return 'unknown' + case 'structured_agent_session_unsupported': + case 'agent_session_checkpoint_stale': + case 'agent_session_conflict': + case 'agent_session_ownership_unknown': + case 'agent_session_operation_capacity': + case 'agent_session_identity_required': + case 'agent_session_journal_unreadable': + case 'execution_owner_reconciling': + // These refusals do not prove the operation reached durable settlement. + return 'pending-admission' + } +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts new file mode 100644 index 00000000000..893534f7f10 --- /dev/null +++ b/src/shared/agent-session-wire.ts @@ -0,0 +1,258 @@ +// ─── Structured agent-session wire contract ───────────────────────────────── +// The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider +// adapters and clients against exactly these types, so everything here must be +// plain JSON. The whole surface is gated by agent-session.structured.v1, which +// no released baseline advertises; after that capability ships, every new field +// must remain optional to old readers (docs/reference/remote-wire-compatibility.md). + +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalResetReason, + AgentJournalResolution, + AgentJournalSubmission +} from './agent-session-journal-types' +import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record' +import type { AgentProviderSessionMetadata } from './agent-session-resume' + +export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' +export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' +export type AgentSessionHandoffAction = 'start' | 'cancel-queued' | 'retry' | 'recover' + +export type AgentSessionHandoffStatus = { + owner: AgentSessionOwnerRuntimeKind | 'none' + direction: AgentSessionHandoffDirection | null + phase: 'idle' | 'queued' | 'switching' | 'waiting-for-exit' | 'failed' + stage: AgentSessionHandoffStage | null + operationId: string | null + hostLabel?: string + terminal?: { + handle: string + tabId: string + paneKey: string + ptyId?: string + } + error?: { + message: string + details?: string + recoverableOwner: AgentSessionOwnerRuntimeKind | 'none' + canRetryProof?: boolean + } +} + +export type AgentSessionHandoffRequest = { + envelope: AgentSessionMutationEnvelope + direction: AgentSessionHandoffDirection + mode: AgentSessionHandoffMode + action?: AgentSessionHandoffAction +} + +export type AgentSessionHandoffResult = { status: AgentSessionHandoffStatus } + +/** Backward paging is the client's normal read; 40 matches the page size the + * mobile list renders without a visible fill-in. */ +export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 +export const AGENT_SESSION_HISTORY_MAX_LIMIT = 200 + +export const AGENT_SESSION_HISTORY_DIRECTIONS = ['tail', 'before', 'after'] as const +/** `tail` is the newest page, `before` pages backward, `after` catches a live + * reader up. Only `after` needs replayable rows; the other two read the + * reduced timeline and so survive compaction. */ +export type AgentSessionHistoryDirection = (typeof AGENT_SESSION_HISTORY_DIRECTIONS)[number] + +export type AgentSessionHistoryRequest = { + sessionId: string + direction: AgentSessionHistoryDirection + /** Required for `before` and `after`; ignored for `tail`. */ + cursor?: AgentJournalCursor + limit?: number +} + +export type AgentSessionHistoryPage = { + sessionId: string + epoch: string + /** Optional for mixed-version readers; write-capable clients use the + * checkpoint without forcing a second attach or a redundant snapshot. */ + fence?: number + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + /** Populated by `after` reads so a disconnected client can apply tombstones. */ + removedItemIds: string[] + /** Submissions overlapping this page, so an unconfirmed bubble renders with + * its dispatch state instead of as a plain message. */ + submissions: AgentJournalSubmission[] + /** Page edges. `nextCursor` is what the client sends back for the same + * direction; it equals the request cursor when the page is empty. */ + window: { + oldest: AgentJournalCursor | null + newest: AgentJournalCursor | null + nextCursor: AgentJournalCursor + } + /** Current journal head for switching from a bounded page to live subscribe. */ + liveCursor?: AgentJournalCursor + hasOlder: boolean + hasNewer: boolean +} + +export type AgentSessionHistoryResult = + | { ok: true; page: AgentSessionHistoryPage; providerSession?: AgentProviderSessionMetadata } + /** Every reset carries a byte-bounded tail page so recovery cannot exceed + * remote outbound admission or require another call before resubscribing. */ + | { + ok: false + reset: AgentJournalResetReason + page: AgentSessionHistoryPage + fence?: number + providerSession?: AgentProviderSessionMetadata + } + +/** Cursor-qualified incremental publication. Items and submissions carry their + * CURRENT reduced state rather than a delta, so applying a batch twice + * converges instead of double-appending. */ +export type AgentSessionJournalBatch = { + cursor: AgentJournalCursor + items: AgentJournalRenderItem[] + removedItemIds: string[] + submissions: AgentJournalSubmission[] +} + +export type AgentSessionSubscribeEvent = + | { + type: 'snapshot' + sessionId: string + page: AgentSessionHistoryPage + fence: number + handoff?: AgentSessionHandoffStatus + } + | { + type: 'batch' + sessionId: string + batch: AgentSessionJournalBatch + /** Added with handoff state so mixed-version cursors retain the ownership fence. */ + fence?: number + handoff?: AgentSessionHandoffStatus + } + | { + type: 'reset' + sessionId: string + reset: AgentJournalResetReason + page: AgentSessionHistoryPage + fence: number + handoff?: AgentSessionHandoffStatus + } + | { type: 'end' } + +// ─── Mutation envelope ────────────────────────────────────────────────────── + +/** + * The four fields every mutating call carries. Same operation id and same + * fingerprint replays the recorded outcome; a different fingerprint under one + * operation id is a conflict, never a second effect. + */ +export type AgentSessionMutationEnvelope = { + sessionId: string + clientOperationId: string + /** Null only on a create for a session that does not exist yet. */ + expectedRuntimeFence: number | null + /** Client-declared; the host recomputes it and compares. */ + payloadFingerprint: string +} + +export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity', + 'agent_session_operation_invalid', + 'agent_session_operation_unknown', + 'agent_session_item_revision_stale', + 'agent_session_already_resolved', + 'agent_session_identity_required', + 'agent_session_journal_unreadable', + 'execution_owner_reconciling' +] as const +export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + +export type AgentSessionWireRefusal = { + code: AgentSessionWireRefusalCode + message: string + /** On a stale fence, so the client can retry without another round trip. */ + currentFence?: number + /** On a lost compare-and-set: the winning answer and who gave it. */ + resolution?: AgentJournalResolution + /** On a lost compare-and-set: the revision the host actually holds. */ + currentRevision?: number +} + +export type AgentSessionMutationResult = + | { + ok: true + /** True when the recorded outcome was returned instead of a new effect. */ + replayed: boolean + fence: number + cursor: AgentJournalCursor + value: TValue + } + | { ok: false; refusal: AgentSessionWireRefusal } + +// ─── Per-method payloads ──────────────────────────────────────────────────── + +export type AgentSessionAttachResult = { + sessionId: string + fence: number + page: AgentSessionHistoryPage + /** Submissions the crash boundary settled as `unknown` while attaching. */ + unconfirmedClientMessageIds: string[] +} + +export type AgentSessionSendResult = { + clientMessageId: string + submission: AgentJournalSubmission +} + +export type AgentSessionCancelResult = { + /** The turn the client named, echoed so a late reply can be matched. */ + turnId: string + cancelled: boolean +} + +export type AgentSessionPromptResult = { + itemId: string + revision: number + resolution: AgentJournalResolution +} + +export type AgentSessionOptionResult = { + key: string + value: string + /** Full effective next-turn values when the provider reconciled related options. */ + options?: Record +} + +export type AgentSessionOptionChoice = { + value: string + label: string + description?: string +} + +export type AgentSessionModelOption = { + id: string + label: string + description?: string + isDefault: boolean + defaultEffort?: string + efforts: AgentSessionOptionChoice[] +} + +/** Provider-reported choices and effective next-turn values. Additive read-only + * surface so older hosts can reject it without changing structured v1 writes. */ +export type AgentSessionOptionsResult = { + models: AgentSessionModelOption[] + current: { + model: string + effort?: string + } +} diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index de177dcd4b8..70509ea495a 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -154,6 +154,8 @@ export type AgentStatusEntry = { /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata + /** False when the status belongs to a non-terminal owner that restores itself. */ + terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string /** True for a nonterminal state hydrated from last-status.json with no live hook since: diff --git a/src/shared/ai-vault-resume-preparation.ts b/src/shared/ai-vault-resume-preparation.ts index 39edd01038a..64797710537 100644 --- a/src/shared/ai-vault-resume-preparation.ts +++ b/src/shared/ai-vault-resume-preparation.ts @@ -3,7 +3,8 @@ import type { AiVaultSession } from './ai-vault-types' export type AiVaultPrepareSessionResumeArgs = Pick< AiVaultSession, 'agent' | 'filePath' | 'codexHome' | 'executionHostId' -> +> & + Partial> export type AiVaultPrepareSessionResumeResult = { useRealCodexHome: boolean diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 00f25510922..6adc59c0396 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -120,6 +120,11 @@ export type AiVaultSession = { subagentTranscriptCount: number resumeCommand: string subagent: AiVaultSessionSubagentInfo | null + /** Present only when the negotiated client can open the native structured owner. */ + structuredSession?: { + sessionId: string + workspaceId: string + } } export type AiVaultSubagentListArgs = { diff --git a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt index 03e06eb374f..874ab972787 100644 --- a/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt +++ b/src/shared/child-process/__fixtures__/child-process-import-allowlist.txt @@ -14,6 +14,8 @@ # `detached: true` for a process-group kill, a synchronous taskkill, and an stdio shape other # than all-pipes (a long-lived `ssh -N` must not be handed a stdin pipe nobody closes). # Migrating them means teaching run-process.ts those options first. +# codex-app-server-posix-supervisor.ts requires child_process only inside its +# dependency-free POSIX supervisor script, never in Orca's process. # # The src/main/git/command-runner/* entries are the seven spawn sites that used # to live in the single line src/main/git/runner.ts; splitting that file moved @@ -44,6 +46,7 @@ src/main/claude-accounts/keychain.ts src/main/codex-accounts/runtime-home-service.ts src/main/codex-accounts/service.ts src/main/codex/codex-app-server-client.ts +src/main/codex/codex-app-server-posix-supervisor.ts src/main/codex/codex-app-server-session.ts src/main/codex/codex-state-db-backfill-recovery.ts src/main/codex/codex-wsl-hook-install-plan.ts diff --git a/src/shared/child-process/cancel-process-acquisition.ts b/src/shared/child-process/cancel-process-acquisition.ts new file mode 100644 index 00000000000..073e6998c45 --- /dev/null +++ b/src/shared/child-process/cancel-process-acquisition.ts @@ -0,0 +1,27 @@ +type ExitProvenConnection = { + close: () => Promise +} + +export async function cancelProcessAcquisition(input: { + cancel: () => void + connection: () => ExitProvenConnection | null + exitProven: () => boolean + finished: Promise +}): Promise { + input.cancel() + const connectionBeforeFinish = input.connection() + if (connectionBeforeFinish) { + if ((await connectionBeforeFinish.close()) !== true) { + return false + } + } + await input.finished + if (input.exitProven()) { + return true + } + const connectionAfterFinish = input.connection() + if (!connectionAfterFinish || connectionAfterFinish === connectionBeforeFinish) { + return true + } + return (await connectionAfterFinish.close()) === true +} diff --git a/src/shared/child-process/close-process-registry.test.ts b/src/shared/child-process/close-process-registry.test.ts new file mode 100644 index 00000000000..6a3831a1e56 --- /dev/null +++ b/src/shared/child-process/close-process-registry.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { closeProcessRegistry } from './close-process-registry' + +describe('closeProcessRegistry', () => { + it('continues closing sibling processes when one close rejects', async () => { + const entries = new Set(['first', 'second']) + const closeEntry = vi.fn(async (id: string) => { + if (id === 'first' && closeEntry.mock.calls.filter(([entry]) => entry === id).length === 1) { + throw new Error('transient close failure') + } + entries.delete(id) + return true + }) + + await expect( + closeProcessRegistry({ + attempts: 3, + hasEntries: () => entries.size > 0, + entryIds: () => entries, + closeEntry, + failureMessage: 'processes remain live' + }) + ).resolves.toBeUndefined() + + expect(closeEntry.mock.calls.map(([id]) => id)).toEqual(['first', 'second', 'first']) + }) + + it('reports every rejected proof after the bounded retries', async () => { + const failure = new Error('close failed') + + await expect( + closeProcessRegistry({ + attempts: 3, + hasEntries: () => true, + entryIds: () => ['session-1'], + closeEntry: async () => { + throw failure + }, + failureMessage: 'processes remain live' + }) + ).rejects.toMatchObject({ + message: 'processes remain live', + errors: [failure, failure, failure] + }) + }) +}) diff --git a/src/shared/child-process/close-process-registry.ts b/src/shared/child-process/close-process-registry.ts new file mode 100644 index 00000000000..04b5282aff5 --- /dev/null +++ b/src/shared/child-process/close-process-registry.ts @@ -0,0 +1,26 @@ +export async function closeProcessRegistry(input: { + attempts: number + hasEntries: () => boolean + entryIds: () => Iterable + closeEntry: (id: string) => Promise + failureMessage: string +}): Promise { + const errors: unknown[] = [] + for (let attempt = 0; attempt < input.attempts && input.hasEntries(); attempt += 1) { + await Promise.all( + [...input.entryIds()].map(async (id) => { + try { + await input.closeEntry(id) + } catch (error) { + errors.push(error) + } + }) + ) + } + if (!input.hasEntries()) { + return + } + throw errors.length > 0 + ? new AggregateError(errors, input.failureMessage) + : new Error(input.failureMessage) +} diff --git a/src/shared/child-process/retryable-process-exit-proof.test.ts b/src/shared/child-process/retryable-process-exit-proof.test.ts new file mode 100644 index 00000000000..a7bc5797645 --- /dev/null +++ b/src/shared/child-process/retryable-process-exit-proof.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' + +import { RetryableProcessExitProof } from './retryable-process-exit-proof' + +describe('RetryableProcessExitProof', () => { + it('shares a concurrent attempt and retains proven exit', async () => { + const proof = new RetryableProcessExitProof() + const proveExit = vi.fn(async () => true) + + const first = proof.run(proveExit) + const concurrent = proof.run(proveExit) + + await expect(Promise.all([first, concurrent])).resolves.toEqual([true, true]) + await expect(proof.run(proveExit)).resolves.toBe(true) + expect(proveExit).toHaveBeenCalledOnce() + }) + + it('permits another attempt after exit was not proven', async () => { + const proof = new RetryableProcessExitProof() + const proveExit = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + await expect(proof.run(proveExit)).resolves.toBe(false) + await expect(proof.run(proveExit)).resolves.toBe(true) + expect(proveExit).toHaveBeenCalledTimes(2) + }) + + it('permits another attempt after proof rejects', async () => { + const proof = new RetryableProcessExitProof() + const proveExit = vi + .fn() + .mockRejectedValueOnce(new Error('probe failed')) + .mockResolvedValue(true) + + await expect(proof.run(proveExit)).rejects.toThrow('probe failed') + await expect(proof.run(proveExit)).resolves.toBe(true) + }) +}) diff --git a/src/shared/child-process/retryable-process-exit-proof.ts b/src/shared/child-process/retryable-process-exit-proof.ts new file mode 100644 index 00000000000..f8c3a89de31 --- /dev/null +++ b/src/shared/child-process/retryable-process-exit-proof.ts @@ -0,0 +1,26 @@ +export class RetryableProcessExitProof { + private inFlight: Promise | null = null + + run(proveExit: () => Promise): Promise { + if (this.inFlight) { + return this.inFlight + } + const attempt = proveExit() + this.inFlight = attempt + void attempt.then( + (proven) => { + if (!proven) { + this.clear(attempt) + } + }, + () => this.clear(attempt) + ) + return attempt + } + + private clear(attempt: Promise): void { + if (this.inFlight === attempt) { + this.inFlight = null + } + } +} diff --git a/src/shared/child-process/run-process.ts b/src/shared/child-process/run-process.ts index bd0507608a3..781afb30eee 100644 --- a/src/shared/child-process/run-process.ts +++ b/src/shared/child-process/run-process.ts @@ -2,6 +2,7 @@ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type ChildProcess, + type ChildProcessWithoutNullStreams, type SpawnOptions as NodeSpawnOptions } from 'node:child_process' import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line' @@ -148,9 +149,13 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso * `runProcess` handles that for you; here it cannot, because a blanket handler * would also defeat callers that track and remove their own listeners. */ -export function spawnProcess(spec: ProcessSpec): ChildProcess { +export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams { const resolved = resolveSpawn(spec, process.platform) - return nodeSpawn(resolved.file, [...resolved.args], resolved.options) + return nodeSpawn( + resolved.file, + [...resolved.args], + resolved.options + ) as ChildProcessWithoutNullStreams } /** diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index a501dfd6ec6..f7a05d8c3f1 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -125,6 +125,7 @@ export function buildDefaultSettings(args: { terminalLinkActionPopoverEnabled: true, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, + experimentalStructuredNativeChat: false, nativeChatSessionOptions: {}, openInApplications: [...DEFAULT_OPEN_IN_APPLICATIONS], rightSidebarOpenByDefault: true, diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 0ed2c72154c..5b746515548 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -205,6 +205,8 @@ export type GlobalSettings = { openAgentTabsInChatByDefault?: boolean /** Experimental native chat surface for Claude/Codex sessions; off by default. */ experimentalNativeChat?: boolean + /** Opt-in updated structured runtime; off keeps the existing PTY-backed native chat path. */ + experimentalStructuredNativeChat?: boolean /** Last explicit native-chat model + option selections; live panes need an applied/dispatched record before showing a value. */ nativeChatSessionOptions?: PersistedNativeChatSessionOptions /** Extra launcher rows for the worktree "Open in" submenu. VS Code is always shown first. */ diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 2d0b218e9b1..7837c2cc370 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -55,6 +55,8 @@ export type HostedReviewInfo = { export type HostedReviewForBranchArgs = { repoPath: string repoId?: string + /** Desktop IPC-only owner guard; runtime RPC callers omit this field. */ + repoOwnerExecutionHostId?: string branch: string linkedGitHubPR?: number | null fallbackGitHubPR?: number | null diff --git a/src/shared/native-chat-diff.ts b/src/shared/native-chat-diff.ts index 0e9e242af6e..1df82db4800 100644 --- a/src/shared/native-chat-diff.ts +++ b/src/shared/native-chat-diff.ts @@ -80,6 +80,35 @@ function toLines(value: unknown, maxLines: number): { lines: string[]; truncated return { lines: bounded, truncated } } +function patchTextFromToolInput(value: Record): string | null { + if (typeof value.patch === 'string') { + return value.patch + } + if (typeof value.diff === 'string') { + return value.diff + } + if (!Array.isArray(value.changes)) { + return null + } + const sections = value.changes.flatMap((entry) => { + if (typeof entry !== 'object' || entry === null) { + return [] + } + const change = entry as Record + if (typeof change.diff !== 'string') { + return [] + } + const path = typeof change.path === 'string' ? change.path : 'file' + const kind = + typeof change.kind === 'object' && change.kind !== null + ? (change.kind as Record) + : null + const nextPath = kind && typeof kind.move_path === 'string' ? kind.move_path : path + return [`--- ${path}\n+++ ${nextPath}\n${change.diff}`] + }) + return sections.length > 0 ? sections.join('\n') : null +} + export function diffFromToolCall( name: string, input: unknown, @@ -89,6 +118,10 @@ export function diffFromToolCall( return null } const value = input as Record + const patchText = patchTextFromToolInput(value) + if (patchText !== null) { + return diffFromText(patchText, maxLines) + } const oldLines = toLines(value.old_string ?? value.oldString ?? value.old, maxLines) const newLines = toLines( value.new_string ?? value.newString ?? value.new ?? value.content ?? value.file_text, diff --git a/src/shared/native-chat-provider-frame-summary.ts b/src/shared/native-chat-provider-frame-summary.ts new file mode 100644 index 00000000000..ef4e21563b9 --- /dev/null +++ b/src/shared/native-chat-provider-frame-summary.ts @@ -0,0 +1,11 @@ +import type { NativeChatBlock } from './native-chat-types' + +type ProviderFrameTextBlock = Extract + +export function nativeChatProviderFrameSummary(block: ProviderFrameTextBlock): string { + const frame = block.providerFrame + if (!frame) { + return block.text + } + return block.text === `${frame.provider} · ${frame.kind}` ? frame.kind : block.text +} diff --git a/src/shared/native-chat-session-option-snapshot.ts b/src/shared/native-chat-session-option-snapshot.ts index 21139d765f1..76556211ab0 100644 --- a/src/shared/native-chat-session-option-snapshot.ts +++ b/src/shared/native-chat-session-option-snapshot.ts @@ -15,6 +15,7 @@ import { } from './native-chat-session-option-state' export type NativeChatSessionOptionMode = 'draft' | 'live' +export type NativeChatLiveOptionTransport = 'catalog' | 'agent-session' function choiceWithCurrent( choices: readonly SessionOptionSelectChoice[], @@ -30,6 +31,7 @@ function choiceWithCurrent( function settableState(args: { mode: NativeChatSessionOptionMode + liveTransport: NativeChatLiveOptionTransport apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply } composedModelApply?: { midSession?: CatalogMidSessionApply } }): Pick { @@ -38,6 +40,9 @@ function settableState(args: { ? { settable: true } : { settable: false, disabledReason: 'available-after-session-start' } } + if (args.liveTransport === 'agent-session') { + return { settable: true } + } if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') { return { settable: true } } @@ -50,9 +55,10 @@ function settableState(args: { function actionForApply( apply: { midSession?: CatalogMidSessionApply }, tracked: TrackedNativeChatSessionOption | undefined, - mode: NativeChatSessionOptionMode + mode: NativeChatSessionOptionMode, + liveTransport: NativeChatLiveOptionTransport ): SessionOptionDescriptor['action'] { - if (mode !== 'live') { + if (mode !== 'live' || liveTransport === 'agent-session') { return undefined } if (apply.midSession?.kind === 'agent-picker') { @@ -67,12 +73,13 @@ function optionDescriptor(args: { option: CatalogOption tracked: TrackedNativeChatSessionOption | undefined mode: NativeChatSessionOptionMode + liveTransport: NativeChatLiveOptionTransport modelIsCliDefault: boolean composedModelApply: AgentSessionOptionCatalog['modelApply'] }): SessionOptionDescriptor | null { - const { option, tracked, mode, modelIsCliDefault, composedModelApply } = args - const action = actionForApply(option.apply, tracked, mode) - const settable = settableState({ mode, apply: option.apply, composedModelApply }) + const { option, tracked, mode, liveTransport, modelIsCliDefault, composedModelApply } = args + const action = actionForApply(option.apply, tracked, mode, liveTransport) + const settable = settableState({ mode, liveTransport, apply: option.apply, composedModelApply }) // Why: the launch only emits `values[id] ?? defaultValue` alongside a model flag, so // a draft names this option's value exactly when a model was picked. Under the CLI's // own default no flag is sent at all, and the CLI's unstated choice is not ours to name. @@ -195,8 +202,9 @@ export function buildNativeChatSessionOptionSnapshot(args: { record: NativeChatSessionOptionRecord mode: NativeChatSessionOptionMode modelLabel: string + liveTransport?: NativeChatLiveOptionTransport }): SessionOptionDescriptor[] { - const { catalog, models, record, mode, modelLabel } = args + const { catalog, models, record, mode, modelLabel, liveTransport = 'catalog' } = args if (models.length === 0) { return [] } @@ -212,7 +220,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { const trackedModelId = typeof modelTracked?.value === 'string' ? modelTracked.value : null const defaultModelId = cliDefaultModelId(catalog, models, trackedModelId) const effectiveModelId = trackedModelId ?? defaultModelId - const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) + const modelAction = actionForApply(catalog.modelApply, modelTracked, mode, liveTransport) const snapshot: SessionOptionDescriptor[] = [ { id: 'model', @@ -224,7 +232,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { choices: modelChoices }, valueSource: modelTracked?.source ?? (defaultModelId ? 'default' : 'unknown'), - ...settableState({ mode, apply: catalog.modelApply }), + ...settableState({ mode, liveTransport, apply: catalog.modelApply }), ...(modelAction ? { action: modelAction } : {}) } ] @@ -238,6 +246,7 @@ export function buildNativeChatSessionOptionSnapshot(args: { option, tracked: trackedValues[option.id], mode, + liveTransport, modelIsCliDefault: effectiveModelId === defaultModelId, composedModelApply: catalog.modelApply }) diff --git a/src/shared/native-chat-tool-summary.ts b/src/shared/native-chat-tool-summary.ts index a048e16da0e..9954521bed4 100644 --- a/src/shared/native-chat-tool-summary.ts +++ b/src/shared/native-chat-tool-summary.ts @@ -123,10 +123,27 @@ function normalizedToolFilePath(input: unknown): string | null { // target would label the row with the scan root and link to a folder. Costs the // link on a file-scoped search; a dead link on every other search is worse. const directory = isSearchToolInput(value) ? undefined : value.path - const path = value.file_path ?? value.filePath ?? directory ?? value.notebook_path + const path = + value.file_path ?? + value.filePath ?? + directory ?? + value.notebook_path ?? + firstPatchChangePath(value) return typeof path === 'string' && path.length > 0 ? path : null } +function firstPatchChangePath(value: Record): unknown { + if (!Array.isArray(value.changes)) { + return undefined + } + for (const change of value.changes) { + if (typeof change === 'object' && change !== null && typeof change.path === 'string') { + return change.path + } + } + return undefined +} + export function briefToolArg(input: unknown): string { const normalized = normalizeToolInput(input) if (normalized && typeof normalized === 'object') { diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index 96d01f64181..370037c4bb2 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -31,6 +31,17 @@ export type NativeChatRole = (typeof NATIVE_CHAT_ROLES)[number] export type NativeChatTextBlock = { type: 'text' text: string + /** Optional structured detail for an otherwise ordinary fallback line. */ + providerFrame?: { + provider: string + kind: string + payload: { + head: string + byteLength: number + digest: string + truncated: boolean + } + } } /** A tool invocation by the agent. `input` is the (already-serialized) tool diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index a0297e5c33e..0bd70cba10d 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -171,7 +171,6 @@ const INVENTORY: readonly InventoryGroup[] = [ helper: 'resolveCommittedTitleAgentType', classification: 'identity-consumer', paths: [ - ['src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx', 3], ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 4], ['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2] ] diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index bfd721ce672..2a52e153916 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -116,6 +116,15 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const +// Why: structured sessions are journal-backed, not PTY-backed, so a client that +// cannot read them must not see them at all — it would render an agent tab it +// can neither display nor drive. The host also refuses every agentSession.* +// method from a connection that does not advertise this. +export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: paired structured clients explicitly hold every visible session surface, allowing the host +// to stop provider children after the last surface closes without tying lifetime to a transport. +export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY = + 'agent-session.structured.hold.v1' as const // Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an // older host answers the unknown member with invalid_argument — a code the launch fallback does // not retry on — so clients must probe before taking the host-authority path. @@ -207,6 +216,8 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY, diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 0a327b0fbb7..04d5d3807ae 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -317,6 +317,11 @@ describe('RemoteRuntimeSharedControlConnection', () => { ) expect(onError).toHaveBeenCalledTimes(1) expect(onClose).not.toHaveBeenCalled() + expect(connection.getDiagnostics()).toMatchObject({ + state: 'ready', + lastError: null, + lastClose: null + }) connection.close() }) diff --git a/src/shared/remote-runtime-shared-control-connection.ts b/src/shared/remote-runtime-shared-control-connection.ts index 958cf92f5d0..af1f21e3155 100644 --- a/src/shared/remote-runtime-shared-control-connection.ts +++ b/src/shared/remote-runtime-shared-control-connection.ts @@ -211,6 +211,10 @@ export class RemoteRuntimeSharedControlConnection { sendEncrypted: (payload) => this.sendEncrypted(payload), markReady: () => { this.lastConnectedAt = Date.now() + // Why cleared here: these describe the attempt that just succeeded's predecessor. + // Left set, a recovered host reads "Connected" next to a stale failure forever. + this.lastError = null + this.lastClose = null this.readyStableReset.schedule({ getState: () => this.state, getSocket: () => this.ws, diff --git a/src/shared/runtime-mobile-session-tab-contracts.ts b/src/shared/runtime-mobile-session-tab-contracts.ts new file mode 100644 index 00000000000..40f60230218 --- /dev/null +++ b/src/shared/runtime-mobile-session-tab-contracts.ts @@ -0,0 +1,116 @@ +import type { AgentStatusEntry } from './agent-status-types' +import type { BrowserCertificateFailure, BrowserLoadError } from './browser-workspace-types' +import type { RuntimeBrowserPlacement } from './runtime-browser-placement' +import type { TerminalColorOverrides } from './terminal-color-overrides' +import type { TerminalLayoutSnapshot } from './terminal-tab-types' +import type { TuiAgent } from './tui-agent' + +export type RuntimeMobileSessionTerminalTab = { + type: 'terminal' + id: string + title: string + quickCommandLabel?: string | null + parentTabId: string + leafId: string + ptyId?: string | null + terminalTheme?: RuntimeMobileTerminalTheme + agentStatus?: AgentStatusEntry | null + /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ + turnCompletedAt?: number + launchAgent?: TuiAgent + startupCwd?: string + parentLayout?: TerminalLayoutSnapshot + color?: string | null + isPinned?: boolean + viewMode?: 'terminal' | 'chat' + launchDraft?: string + launchDraftCreatedAt?: number + isActive: boolean +} + +export type RuntimeMobileTerminalTheme = { + mode: 'dark' | 'light' + theme: TerminalColorOverrides +} + +export type RuntimeMobileSessionMarkdownTab = { + type: 'markdown' + id: string + title: string + filePath: string + relativePath: string + language: 'markdown' + mode: 'edit' | 'markdown-preview' + isDirty: boolean + isActive: boolean + sourceFileId: string + sourceFilePath: string + sourceRelativePath: string + documentVersion: string + color?: string | null + isPinned?: boolean +} + +export type RuntimeMobileSessionFileTab = { + type: 'file' + id: string + title: string + filePath: string + relativePath: string + language: string + mode?: 'edit' | 'diff' + diffSource?: 'staged' | 'unstaged' + isDirty: boolean + color?: string | null + isPinned?: boolean + isActive: boolean +} + +export type RuntimeMobileSessionBrowserTab = { + type: 'browser' + id: string + title: string + browserWorkspaceId: string + browserPageId: string | null + browserProfileId?: string + executionHostKey?: string + placement?: RuntimeBrowserPlacement + url: string + loading: boolean + canGoBack: boolean + canGoForward: boolean + loadError?: BrowserLoadError | null + certificateFailure?: BrowserCertificateFailure | null + color?: string | null + isPinned?: boolean + isActive: boolean +} + +export type RuntimeMobileSessionAgentTab = { + type: 'agent-session' + id: string + title: string + sessionId: string + agent: 'codex' + color?: string | null + isPinned?: boolean + isActive: boolean +} + +export type RuntimeMobileSessionSnapshotTab = + | RuntimeMobileSessionTerminalTab + | RuntimeMobileSessionMarkdownTab + | RuntimeMobileSessionFileTab + | RuntimeMobileSessionBrowserTab + | RuntimeMobileSessionAgentTab + +export type RuntimeMobileSessionTerminalClientTab = + | (RuntimeMobileSessionTerminalTab & { status: 'pending-handle'; terminal: null }) + | (RuntimeMobileSessionTerminalTab & { status: 'ready'; terminal: string }) + +export type RuntimeMobileSessionClientTab = + | RuntimeMobileSessionTerminalClientTab + | RuntimeMobileSessionMarkdownTab + | RuntimeMobileSessionFileTab + | RuntimeMobileSessionBrowserTab + | RuntimeMobileSessionAgentTab diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 8bf41a9cdc5..1249870c548 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -1,17 +1,20 @@ -import type { AgentStatusEntry, AgentStatusOrchestrationContext } from './agent-status-types' -import type { BrowserCertificateFailure, BrowserLoadError } from './browser-workspace-types' +import type { AgentStatusOrchestrationContext } from './agent-status-types' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' -import type { RuntimeBrowserPlacement } from './runtime-browser-placement' import type { RuntimeCapability } from './protocol-version' import type { RuntimeBrowserUnavailableReason, RuntimeDegradation } from './runtime-capability-degradation' import type { TabGroupLayoutNode } from './tab-types' -import type { TerminalColorOverrides } from './terminal-color-overrides' -import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from './terminal-tab-types' -import type { TuiAgent } from './tui-agent' +import type { TerminalPaneLayoutNode } from './terminal-tab-types' +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTerminalClientTab +} from './runtime-mobile-session-tab-contracts' + +export * from './runtime-mobile-session-tab-contracts' export type RuntimeGraphStatus = 'ready' | 'reloading' | 'unavailable' @@ -160,103 +163,6 @@ export type RuntimeSyncWindowGraphResult = RuntimeStatus & { mobileSessionResyncWorktrees?: string[] } -export type RuntimeMobileSessionTerminalTab = { - type: 'terminal' - id: string - title: string - quickCommandLabel?: string | null - parentTabId: string - leafId: string - ptyId?: string | null - terminalTheme?: RuntimeMobileTerminalTheme - agentStatus?: AgentStatusEntry | null - /** Event-only lead-turn end time for paired clients; never persisted in AgentStatusEntry. */ - turnCompletedAt?: number - launchAgent?: TuiAgent - startupCwd?: string - parentLayout?: TerminalLayoutSnapshot - color?: string | null - isPinned?: boolean - viewMode?: 'terminal' | 'chat' - launchDraft?: string - launchDraftCreatedAt?: number - isActive: boolean -} - -export type RuntimeMobileTerminalTheme = { - mode: 'dark' | 'light' - theme: TerminalColorOverrides -} - -export type RuntimeMobileSessionMarkdownTab = { - type: 'markdown' - id: string - title: string - filePath: string - relativePath: string - language: 'markdown' - mode: 'edit' | 'markdown-preview' - isDirty: boolean - isActive: boolean - sourceFileId: string - sourceFilePath: string - sourceRelativePath: string - documentVersion: string - color?: string | null - isPinned?: boolean -} - -export type RuntimeMobileSessionFileTab = { - type: 'file' - id: string - title: string - filePath: string - relativePath: string - language: string - mode?: 'edit' | 'diff' - diffSource?: 'staged' | 'unstaged' - isDirty: boolean - color?: string | null - isPinned?: boolean - isActive: boolean -} - -export type RuntimeMobileSessionBrowserTab = { - type: 'browser' - id: string - title: string - browserWorkspaceId: string - browserPageId: string | null - browserProfileId?: string - executionHostKey?: string - placement?: RuntimeBrowserPlacement - url: string - loading: boolean - canGoBack: boolean - canGoForward: boolean - loadError?: BrowserLoadError | null - certificateFailure?: BrowserCertificateFailure | null - color?: string | null - isPinned?: boolean - isActive: boolean -} - -export type RuntimeMobileSessionSnapshotTab = - | RuntimeMobileSessionTerminalTab - | RuntimeMobileSessionMarkdownTab - | RuntimeMobileSessionFileTab - | RuntimeMobileSessionBrowserTab - -export type RuntimeMobileSessionTerminalClientTab = - | (RuntimeMobileSessionTerminalTab & { status: 'pending-handle'; terminal: null }) - | (RuntimeMobileSessionTerminalTab & { status: 'ready'; terminal: string }) - -export type RuntimeMobileSessionClientTab = - | RuntimeMobileSessionTerminalClientTab - | RuntimeMobileSessionMarkdownTab - | RuntimeMobileSessionFileTab - | RuntimeMobileSessionBrowserTab - export type RuntimeMobileSessionTabGroup = { id: string activeTabId: string | null @@ -310,7 +216,7 @@ export type RuntimeMobileSessionTabsSnapshot = { snapshotVersion: number activeGroupId: string | null activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null + activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null tabs: RuntimeMobileSessionSnapshotTab[] @@ -323,7 +229,7 @@ export type RuntimeMobileSessionTabsResult = { navigationIntent?: 'follow' activeGroupId: string | null activeTabId: string | null - activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | null + activeTabType: 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session' | null tabGroups?: RuntimeMobileSessionTabGroup[] tabGroupLayout?: TabGroupLayoutNode | null tabs: RuntimeMobileSessionClientTab[] diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 8e889129949..d863fada9ba 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -1,3 +1,4 @@ +import type { AgentSessionPtyWriteRefusal } from './agent-session-pty-write-admission' import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig @@ -202,6 +203,11 @@ export type RuntimeTerminalSend = { accepted: boolean bytesWritten: number refusedReason?: 'no-agent' | 'permission' + /** + * Present only when a durable agent-session lease refused the write. Additive and optional: an + * old client sees the `accepted: false` it already handles and ignores this field. + */ + agentSessionRefusal?: AgentSessionPtyWriteRefusal } export type RuntimeTerminalAgentStatusState = 'working' | 'permission' | 'idle' | null @@ -255,6 +261,8 @@ export type RuntimeTerminalCreate = { warning?: string agentSessionDisposition?: 'created' | 'adopted' isReattach?: true + /** Spawn process identity for host-internal ownership proof. */ + processId?: number } export type RuntimeTerminalSplit = { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 6ca9dd9bf58..856268217aa 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -114,6 +114,7 @@ export type { RuntimeBrowserDriverState, RuntimeDesktopWindowStatus, RuntimeGraphStatus, + RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, RuntimeMobileSessionClientTab, RuntimeMobileSessionCreateTerminalResult, diff --git a/src/shared/sha256.ts b/src/shared/sha256.ts new file mode 100644 index 00000000000..10350ddf7c8 --- /dev/null +++ b/src/shared/sha256.ts @@ -0,0 +1,80 @@ +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 +]) + +function rotateRight(value: number, bits: number): number { + return (value >>> bits) | (value << (32 - bits)) +} + +export function sha256(message: Uint8Array): Uint8Array { + const hash = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 + ]) + const bitLength = message.length * 8 + const paddedLength = ((message.length + 8) >> 6) * 64 + 64 + const bytes = new Uint8Array(paddedLength) + bytes.set(message) + bytes[message.length] = 0x80 + const view = new DataView(bytes.buffer) + view.setUint32(paddedLength - 4, bitLength >>> 0, false) + view.setUint32(paddedLength - 8, Math.floor(bitLength / 0x100000000), false) + + const words = new Uint32Array(64) + for (let offset = 0; offset < paddedLength; offset += 64) { + for (let index = 0; index < 16; index += 1) { + words[index] = view.getUint32(offset + index * 4, false) + } + for (let index = 16; index < 64; index += 1) { + const s0 = + rotateRight(words[index - 15], 7) ^ + rotateRight(words[index - 15], 18) ^ + (words[index - 15] >>> 3) + const s1 = + rotateRight(words[index - 2], 17) ^ + rotateRight(words[index - 2], 19) ^ + (words[index - 2] >>> 10) + words[index] = (words[index - 16] + s0 + words[index - 7] + s1) | 0 + } + + let [a, b, c, d, e, f, g, h] = hash + for (let index = 0; index < 64; index += 1) { + const sigma1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25) + const choice = (e & f) ^ (~e & g) + const first = (h + sigma1 + choice + K[index] + words[index]) | 0 + const sigma0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22) + const majority = (a & b) ^ (a & c) ^ (b & c) + const second = (sigma0 + majority) | 0 + h = g + g = f + f = e + e = (d + first) | 0 + d = c + c = b + b = a + a = (first + second) | 0 + } + + hash[0] = (hash[0] + a) | 0 + hash[1] = (hash[1] + b) | 0 + hash[2] = (hash[2] + c) | 0 + hash[3] = (hash[3] + d) | 0 + hash[4] = (hash[4] + e) | 0 + hash[5] = (hash[5] + f) | 0 + hash[6] = (hash[6] + g) | 0 + hash[7] = (hash[7] + h) | 0 + } + + const digest = new Uint8Array(32) + const digestView = new DataView(digest.buffer) + for (let index = 0; index < 8; index += 1) { + digestView.setUint32(index * 4, hash[index], false) + } + return digest +} diff --git a/src/shared/structured-agent-session-coalescer.ts b/src/shared/structured-agent-session-coalescer.ts new file mode 100644 index 00000000000..51bc7fa0537 --- /dev/null +++ b/src/shared/structured-agent-session-coalescer.ts @@ -0,0 +1,81 @@ +import type { AgentSessionSubscribeEvent } from './agent-session-wire' + +export const STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS = 48 + +function bypassCoalescing(event: AgentSessionSubscribeEvent): boolean { + return ( + event.type !== 'batch' || + event.batch.items.some((item) => item.body.kind !== 'message' || item.body.role !== 'assistant') + ) +} + +function mergeBatch( + left: Extract, + right: Extract +): Extract { + const items = new Map(left.batch.items.map((item) => [item.itemId, item])) + for (const item of right.batch.items) { + items.set(item.itemId, item) + } + const submissions = new Map( + left.batch.submissions.map((submission) => [submission.clientMessageId, submission]) + ) + for (const submission of right.batch.submissions) { + submissions.set(submission.clientMessageId, submission) + } + return { + type: 'batch', + sessionId: right.sessionId, + batch: { + cursor: right.batch.cursor, + items: [...items.values()], + removedItemIds: [...new Set([...left.batch.removedItemIds, ...right.batch.removedItemIds])], + submissions: [...submissions.values()] + }, + ...(right.fence !== undefined || left.fence !== undefined + ? { fence: right.fence ?? left.fence } + : {}), + ...(right.handoff || left.handoff ? { handoff: right.handoff ?? left.handoff } : {}) + } +} + +export function createStructuredAgentSessionEventCoalescer( + emit: (event: AgentSessionSubscribeEvent) => void, + delayMs = STRUCTURED_AGENT_SESSION_CLIENT_COALESCE_MS +): { push: (event: AgentSessionSubscribeEvent) => void; flush: () => void; dispose: () => void } { + let pending: Extract | null = null + let timer: ReturnType | null = null + const flush = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + if (pending) { + const event = pending + pending = null + emit(event) + } + } + return { + push(event) { + if (bypassCoalescing(event)) { + flush() + emit(event) + return + } + if (event.type !== 'batch') { + return + } + pending = pending ? mergeBatch(pending, event) : event + timer ??= setTimeout(flush, delayMs) + }, + flush, + dispose() { + if (timer) { + clearTimeout(timer) + } + timer = null + pending = null + } + } +} diff --git a/src/shared/structured-agent-session-composer.ts b/src/shared/structured-agent-session-composer.ts new file mode 100644 index 00000000000..420651aba5b --- /dev/null +++ b/src/shared/structured-agent-session-composer.ts @@ -0,0 +1,103 @@ +import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles' +import type { AgentType } from './agent-status-types' +import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import type { SlashCommandSuggestion } from './native-chat-slash-commands' + +const EFFORT_COMMAND: SlashCommandSuggestion = { + name: 'effort', + description: 'Choose reasoning effort' +} + +export const STRUCTURED_AGENT_SESSION_SLASH_COMMANDS: readonly SlashCommandSuggestion[] = [ + ...getVerifiedNativeChatCommands('codex').slice(0, 1), + EFFORT_COMMAND, + ...getVerifiedNativeChatCommands('codex').slice(1) +] + +export type StructuredAgentSessionComposerOptions = { + agent?: AgentType + snapshot: readonly SessionOptionDescriptor[] + invokeAction: (id: string) => Promise + setOption: (id: string, value: SessionOptionValue) => Promise +} + +export type StructuredAgentSessionCommandOutcome = { + handled: boolean + accepted: boolean + error: string | null +} + +function commandParts(text: string): { name: string; argument: string } | null { + if (!text.startsWith('/')) { + return null + } + const match = /^\/([^\s]+)(?:\s+(.*))?$/.exec(text.trimEnd()) + return match ? { name: match[1]!.toLowerCase(), argument: match[2]?.trim() ?? '' } : null +} + +function structuredSlashCommands(agent: AgentType): readonly SlashCommandSuggestion[] { + if (agent === 'codex') { + return STRUCTURED_AGENT_SESSION_SLASH_COMMANDS + } + return [...getVerifiedNativeChatCommands(agent), EFFORT_COMMAND] +} + +export function isStructuredAgentSessionComposerCommand( + text: string, + agent: AgentType = 'codex' +): boolean { + const command = commandParts(text) + return Boolean( + command && structuredSlashCommands(agent).some((entry) => entry.name === command.name) + ) +} + +function unavailable(name: string): StructuredAgentSessionCommandOutcome { + return { handled: true, accepted: true, error: `/${name} is not available in chat sessions.` } +} + +export async function dispatchStructuredAgentSessionComposerCommand( + text: string, + controller: StructuredAgentSessionComposerOptions +): Promise { + const command = commandParts(text) + if (!command || !isStructuredAgentSessionComposerCommand(text, controller.agent)) { + return { handled: false, accepted: false, error: null } + } + if (command.name !== 'model' && command.name !== 'effort') { + return unavailable(command.name) + } + const descriptor = controller.snapshot.find((entry) => entry.id === command.name) + if (!descriptor || descriptor.kind.type !== 'select') { + return { + handled: true, + accepted: true, + error: `${command.name === 'model' ? 'Models' : 'Reasoning effort'} are unavailable for this chat session.` + } + } + if (!command.argument) { + const opened = await controller.invokeAction(command.name) + return { + handled: true, + accepted: opened, + error: opened ? null : `Could not open the ${command.name} picker.` + } + } + const normalized = command.argument.toLowerCase() + const choice = descriptor.kind.choices.find( + (entry) => entry.value.toLowerCase() === normalized || entry.label.toLowerCase() === normalized + ) + if (!choice) { + return { + handled: true, + accepted: false, + error: `${command.argument} is not an available ${command.name} for this chat session.` + } + } + const applied = await controller.setOption(command.name, choice.value) + return { + handled: true, + accepted: applied, + error: applied ? null : `Could not apply ${command.name} ${choice.label}.` + } +} diff --git a/src/shared/structured-agent-session-mutation.test.ts b/src/shared/structured-agent-session-mutation.test.ts new file mode 100644 index 00000000000..ea92d8f0fd6 --- /dev/null +++ b/src/shared/structured-agent-session-mutation.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { computeAgentSessionPayloadFingerprint } from './agent-session-mutation-envelope' + +describe('structured agent session client mutations', () => { + it('canonicalizes payload fields before hashing', () => { + const first = structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { role: 'user', kind: 'message' }, omitted: undefined } + }) + const second = structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { kind: 'message', role: 'user' } } + }) + + expect(first).toBe(second) + expect(first).toMatch(/^[a-f0-9]{64}$/) + }) + + it('matches host code-unit ordering for mixed-case and non-ASCII keys', () => { + const input = { + method: 'agentSession.send', + sessionId: 'session-1', + fields: { a: 1, A: 2, é: 3, 中: 4 } + } + + expect(structuredAgentSessionPayloadFingerprint(input)).toBe( + computeAgentSessionPayloadFingerprint(input) + ) + }) +}) diff --git a/src/shared/structured-agent-session-mutation.ts b/src/shared/structured-agent-session-mutation.ts new file mode 100644 index 00000000000..ccc80475c93 --- /dev/null +++ b/src/shared/structured-agent-session-mutation.ts @@ -0,0 +1,39 @@ +import { sha256 } from './sha256' + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}` +} + +export function structuredAgentSessionPayloadFingerprint(input: { + method: string + sessionId: string + fields: Record +}): string { + const bytes = sha256( + new TextEncoder().encode( + canonicalize({ method: input.method, sessionId: input.sessionId, fields: input.fields }) + ) + ) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} + +export function createStructuredAgentSessionOperationId( + randomUuid: () => string, + now: number = Date.now() +): string { + const timestamp = Math.trunc(now).toString() + const entropy = randomUuid().replaceAll('-', '').toLowerCase() + if (!/^\d{13}$/.test(timestamp) || !/^[0-9a-f]{32}$/.test(entropy)) { + throw new Error('Unable to create a durable operation id') + } + return `${timestamp}-${entropy}` +} diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts new file mode 100644 index 00000000000..72f817c7646 --- /dev/null +++ b/src/shared/structured-agent-session-options.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { CODEX_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-claude-codex' +import { buildNativeChatSessionOptionSnapshot } from './native-chat-session-option-snapshot' +import { createNativeChatSessionOptionRecord } from './native-chat-session-option-state' +import { + applyStructuredAgentSessionOptions, + createStructuredAgentSessionOptionState, + structuredAgentSessionOptionSnapshot +} from './structured-agent-session-options' + +describe('structured agent session options', () => { + it('projects native Codex selects while bridge Codex keeps its agent picker', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [ + { + id: 'account-model', + label: 'Account Model', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { model: 'account-model', effort: 'medium' } + } + ) + + const structured = structuredAgentSessionOptionSnapshot(state) + expect(structured.map((descriptor) => descriptor.id)).toEqual(['model', 'effort']) + expect(structured[0]).toMatchObject({ + settable: true, + kind: { type: 'select', currentValue: 'account-model' } + }) + expect(structured[0]).not.toHaveProperty('action') + expect(structured[1]).toMatchObject({ + settable: true, + kind: { type: 'select', currentValue: 'medium' } + }) + + const bridgeRecord = createNativeChatSessionOptionRecord('codex') + bridgeRecord.model = { value: 'gpt-5.6-sol', source: 'reported' } + const bridge = buildNativeChatSessionOptionSnapshot({ + catalog: CODEX_SESSION_OPTION_CATALOG, + models: CODEX_SESSION_OPTION_CATALOG.models, + record: bridgeRecord, + mode: 'live', + modelLabel: 'Model' + }) + expect(bridge[0]).toMatchObject({ action: { type: 'agent-picker' } }) + expect(bridge.find((descriptor) => descriptor.id === 'effort')).toMatchObject({ + action: { type: 'agent-picker' } + }) + }) + + it('uses provider-scoped models and retains the current unknown id', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [ + { + id: 'account-model', + label: 'Account Model', + isDefault: false, + efforts: [] + } + ], + current: { model: 'persisted-unknown' } + } + ) + const model = structuredAgentSessionOptionSnapshot(state)[0] + expect( + model.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] + ).toEqual(['account-model', 'persisted-unknown']) + expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown') + }) + + it('projects live options as directly settable descriptors', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { + models: [ + { + id: 'account-model', + label: 'Account Model', + isDefault: true, + defaultEffort: 'medium', + efforts: [ + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' } + ] + } + ], + current: { model: 'account-model', effort: 'medium' } + } + ) + + const snapshot = structuredAgentSessionOptionSnapshot(state) + expect(snapshot.map((descriptor) => descriptor.id)).toEqual(['model', 'effort']) + expect(snapshot.every((descriptor) => descriptor.settable)).toBe(true) + expect(snapshot.every((descriptor) => descriptor.action === undefined)).toBe(true) + }) +}) diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts new file mode 100644 index 00000000000..94f5f45351a --- /dev/null +++ b/src/shared/structured-agent-session-options.ts @@ -0,0 +1,146 @@ +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog' +import { + buildNativeChatSessionOptionSnapshot, + resolveEffectiveNativeChatModelId +} from './native-chat-session-option-snapshot' +import { + applyNativeChatReportedSessionOptions, + createNativeChatSessionOptionRecord, + setTrackedSessionOption, + type NativeChatSessionOptionRecord +} from './native-chat-session-option-state' +import type { SessionOptionDescriptor, SessionOptionValue } from './native-chat-session-options' +import type { AgentSessionOptionsResult } from './agent-session-wire' + +function effortOption(model: AgentSessionOptionsResult['models'][number]): CatalogOption | null { + if (model.efforts.length <= 1) { + return null + } + return { + id: 'effort', + label: 'Reasoning effort', + category: 'thought_level', + kind: { + type: 'select', + choices: model.efforts, + defaultValue: model.defaultEffort ?? model.efforts[0]!.value + }, + apply: { midSession: { kind: 'command', build: (value) => `/effort ${String(value)}` } } + } +} + +function discoveredModel(model: AgentSessionOptionsResult['models'][number]): CatalogModel { + const effort = effortOption(model) + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + ...(model.isDefault ? { isDefault: true } : {}), + options: effort ? [effort] : [] + } +} + +export function structuredAgentSessionOptionCatalog( + seed: AgentSessionOptionCatalog, + result: AgentSessionOptionsResult +): AgentSessionOptionCatalog { + const models: CatalogModel[] = result.models.map(discoveredModel) + if (!models.some((model) => model.id === result.current.model)) { + models.push({ + id: result.current.model, + label: result.current.model, + options: seed.unknownModelOptions ?? [] + }) + } + return { ...seed, models, defaultModelIsCliDefault: true } +} + +export type StructuredAgentSessionOptionState = { + catalog: AgentSessionOptionCatalog | null + record: NativeChatSessionOptionRecord + pendingId: string | null +} + +export function createStructuredAgentSessionOptionState( + agent = 'codex' +): StructuredAgentSessionOptionState { + return { catalog: null, record: createNativeChatSessionOptionRecord(agent), pendingId: null } +} + +export function applyStructuredAgentSessionOptions( + state: StructuredAgentSessionOptionState, + seed: AgentSessionOptionCatalog, + result: AgentSessionOptionsResult +): StructuredAgentSessionOptionState { + applyNativeChatReportedSessionOptions(state.record, { + model: result.current.model, + ...(result.current.effort ? { effort: result.current.effort } : {}) + }) + return { ...state, catalog: structuredAgentSessionOptionCatalog(seed, result) } +} + +export function structuredAgentSessionOptionSnapshot( + state: StructuredAgentSessionOptionState +): SessionOptionDescriptor[] { + if (!state.catalog) { + return [] + } + return buildNativeChatSessionOptionSnapshot({ + catalog: state.catalog, + models: state.catalog.models, + record: state.record, + mode: 'live', + modelLabel: 'Model', + liveTransport: 'agent-session' + }) +} + +export function canSetStructuredAgentSessionOption( + state: StructuredAgentSessionOptionState, + id: string, + value: SessionOptionValue +): boolean { + const descriptor = structuredAgentSessionOptionSnapshot(state).find((entry) => entry.id === id) + return Boolean( + state.catalog && + typeof value === 'string' && + state.pendingId === null && + descriptor?.kind.type === 'select' && + descriptor.kind.choices.some((choice) => choice.value === value) + ) +} + +export function commitStructuredAgentSessionOption( + state: StructuredAgentSessionOptionState, + id: string, + value: string +): StructuredAgentSessionOptionState { + if (!state.catalog) { + return state + } + const effectiveModel = resolveEffectiveNativeChatModelId( + state.catalog, + state.catalog.models, + state.record + ) + setTrackedSessionOption(state.record, id, value, 'dispatched', effectiveModel) + return { ...state, pendingId: null } +} + +export function commitStructuredAgentSessionOptionValues( + state: StructuredAgentSessionOptionState, + values: Readonly> +): StructuredAgentSessionOptionState { + let next = state + for (const id of ['model', 'effort']) { + const value = values[id] + if (value) { + next = commitStructuredAgentSessionOption(next, id, value) + } + } + return next +} diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts new file mode 100644 index 00000000000..697bf0254b9 --- /dev/null +++ b/src/shared/structured-agent-session-outbox.ts @@ -0,0 +1,174 @@ +import type { AgentJournalMessageItem, AgentJournalSubmission } from './agent-session-journal-types' +import { agentSessionRefusalOperationState } from './agent-session-refusal-retry' +import type { AgentSessionWireRefusalCode } from './agent-session-wire' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' + +export type StructuredAgentSessionOutboxState = 'queued' | 'dispatching' | 'unconfirmed' + +export type StructuredAgentSessionOutboxEntry = { + clientMessageId: string + sessionId: string + body: AgentJournalMessageItem + previewUris: string[] + state: StructuredAgentSessionOutboxState + queuedAt: number + lastAttemptAt: number | null + retryAfterUnknownSubmittedAt: number | null +} + +export type StructuredAgentSessionAttachment = { + path: string + previewUri: string +} + +export function structuredAgentSessionSendBody( + text: string, + attachments: readonly StructuredAgentSessionAttachment[] +): AgentJournalMessageItem { + return { + kind: 'message', + role: 'user', + blocks: [ + ...(text.trim().length > 0 ? [{ type: 'text' as const, text: text.trimEnd() }] : []), + ...attachments.map((attachment) => ({ type: 'image-ref' as const, path: attachment.path })) + ] + } +} + +export function createStructuredAgentSessionOutboxEntry(args: { + clientMessageId: string + sessionId: string + text: string + attachments: readonly StructuredAgentSessionAttachment[] + queuedAt: number +}): StructuredAgentSessionOutboxEntry { + return { + clientMessageId: args.clientMessageId, + sessionId: args.sessionId, + body: structuredAgentSessionSendBody(args.text, args.attachments), + previewUris: args.attachments.map((attachment) => attachment.previewUri), + state: 'queued', + queuedAt: args.queuedAt, + lastAttemptAt: null, + retryAfterUnknownSubmittedAt: null + } +} + +export function updateStructuredAgentSessionOutboxEntry( + entries: readonly StructuredAgentSessionOutboxEntry[], + id: string, + update: (entry: StructuredAgentSessionOutboxEntry) => StructuredAgentSessionOutboxEntry | null +): StructuredAgentSessionOutboxEntry[] { + return entries.flatMap((entry) => { + if (entry.clientMessageId !== id) { + return [entry] + } + const next = update(entry) + return next ? [next] : [] + }) +} + +export function requeueStructuredAgentSessionSendRefusal( + entry: StructuredAgentSessionOutboxEntry, + code: AgentSessionWireRefusalCode, + createOperationId: () => string +): StructuredAgentSessionOutboxEntry { + if (agentSessionRefusalOperationState('agentSession.send', code) !== 'settled-rejected') { + return { ...entry, state: 'queued' } + } + return { + ...entry, + clientMessageId: createOperationId(), + state: 'queued', + retryAfterUnknownSubmittedAt: null + } +} + +export function reconcileStructuredAgentSessionOutbox( + entries: readonly StructuredAgentSessionOutboxEntry[], + submissions: readonly AgentJournalSubmission[] +): StructuredAgentSessionOutboxEntry[] { + const settled = new Map(submissions.map((entry) => [entry.clientMessageId, entry])) + return entries.flatMap((entry) => { + const submission = settled.get(entry.clientMessageId) + if (submission?.dispatchState === 'accepted') { + return [] + } + if ( + submission?.dispatchState === 'unknown' && + entry.retryAfterUnknownSubmittedAt !== -1 && + entry.retryAfterUnknownSubmittedAt !== submission.submittedAt + ) { + return [{ ...entry, state: 'unconfirmed' as const }] + } + return [entry] + }) +} + +export function parseStructuredAgentSessionOutboxEntry( + value: unknown, + sessionId: string +): StructuredAgentSessionOutboxEntry | null { + if (typeof value !== 'object' || value === null) { + return null + } + const entry = value as Partial + const body = entry.body + if ( + entry.sessionId !== sessionId || + typeof entry.clientMessageId !== 'string' || + typeof entry.queuedAt !== 'number' || + !body || + body.kind !== 'message' || + body.role !== 'user' || + !Array.isArray(body.blocks) || + !Array.isArray(entry.previewUris) || + !entry.previewUris.every((uri) => typeof uri === 'string') || + !['queued', 'dispatching', 'unconfirmed'].includes(entry.state ?? '') + ) { + return null + } + return { + clientMessageId: entry.clientMessageId, + sessionId, + body, + previewUris: entry.previewUris, + state: entry.state as StructuredAgentSessionOutboxState, + queuedAt: entry.queuedAt, + lastAttemptAt: typeof entry.lastAttemptAt === 'number' ? entry.lastAttemptAt : null, + retryAfterUnknownSubmittedAt: + typeof entry.retryAfterUnknownSubmittedAt === 'number' + ? entry.retryAfterUnknownSubmittedAt + : null + } +} + +export function structuredAgentSessionSendRequest( + entry: StructuredAgentSessionOutboxEntry, + expectedRuntimeFence: number +): Record { + const fields = { body: entry.body } + return { + envelope: { + sessionId: entry.sessionId, + clientOperationId: entry.clientMessageId, + expectedRuntimeFence, + payloadFingerprint: structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: entry.sessionId, + fields + }) + }, + ...(entry.retryAfterUnknownSubmittedAt !== null ? { retryUnknown: true } : {}), + ...fields + } +} + +export type StructuredAgentSessionSendFailure = 'delivery-unknown' | 'failed' + +export function classifyStructuredAgentSessionSendFailure( + error: unknown, + isDeliveryUnknown: (error: unknown) => boolean +): StructuredAgentSessionSendFailure { + return isDeliveryUnknown(error) ? 'delivery-unknown' : 'failed' +} diff --git a/src/shared/structured-agent-session-projection.test.ts b/src/shared/structured-agent-session-projection.test.ts new file mode 100644 index 00000000000..bdd4b4c8f07 --- /dev/null +++ b/src/shared/structured-agent-session-projection.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { parsePaneKey } from './stable-pane-id' +import { + activeStructuredAgentSessionTurnId, + hasPersistedStructuredAgentSessionTurn, + projectStructuredItemToNativeChat, + projectStructuredAgentSessionStatus, + structuredAgentSessionPaneKey +} from './structured-agent-session-projection' + +function item( + itemId: string, + sequence: number, + body: AgentJournalRenderItem['body'] +): AgentJournalRenderItem { + return { itemId, sequence, revision: 1, observedAt: sequence, body } +} + +describe('structured agent session status projection', () => { + it('projects running, attention, and completed lifecycle states', () => { + const running = item('running', 1, { + kind: 'status', + text: 'Working', + turnLifecycle: { turnId: 'turn-1', state: 'running' } + }) + const prompt = item('prompt', 2, { + kind: 'approval', + title: 'Run command?', + detail: null, + options: [{ id: 'yes', label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) + const completed = item('completed', 3, { + kind: 'status', + text: 'Done', + turnLifecycle: { turnId: 'turn-1', state: 'completed' } + }) + + expect(activeStructuredAgentSessionTurnId([running])).toBe('turn-1') + expect(projectStructuredAgentSessionStatus([running])).toBe('working') + expect(projectStructuredAgentSessionStatus([running, prompt])).toBe('attention') + expect(activeStructuredAgentSessionTurnId([running, completed])).toBeNull() + expect(projectStructuredAgentSessionStatus([running, completed])).toBe('idle') + }) + + it('creates a deterministic pane identity for status stores', () => { + const paneKey = structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1') + + expect(structuredAgentSessionPaneKey('structured-agent-session-1', 'session-1')).toBe(paneKey) + expect(parsePaneKey(paneKey)).toMatchObject({ tabId: 'structured-agent-session-1' }) + }) + + it('requires a persisted provider conversation turn before TUI resume', () => { + const status = item('status', 1, { kind: 'status', text: 'Connected' }) + const user = item('user', 2, { kind: 'message', role: 'user', blocks: [] }) + + expect(hasPersistedStructuredAgentSessionTurn([])).toBe(false) + expect(hasPersistedStructuredAgentSessionTurn([status])).toBe(false) + expect(hasPersistedStructuredAgentSessionTurn([status, user])).toBe(true) + }) + + it('preserves provider-frame detail on the backward-compatible status line', () => { + const projected = projectStructuredItemToNativeChat( + item('frame', 1, { + kind: 'status', + text: 'codex · notification:new/event', + providerFrame: { + provider: 'codex', + kind: 'notification:new/event', + payload: { head: '{}', byteLength: 2, digest: 'digest', truncated: false } + } + }) + ) + + expect(projected?.blocks).toEqual([ + expect.objectContaining({ + type: 'text', + text: 'codex · notification:new/event', + providerFrame: expect.objectContaining({ kind: 'notification:new/event' }) + }) + ]) + }) +}) diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts new file mode 100644 index 00000000000..27a7cd2458f --- /dev/null +++ b/src/shared/structured-agent-session-projection.ts @@ -0,0 +1,154 @@ +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { sha256 } from './sha256' + +function boundedText(payload: { head: string; truncated: boolean; byteLength: number }): string { + return payload.truncated ? `${payload.head}\n… (${payload.byteLength} bytes)` : payload.head +} + +function itemBlocks(item: AgentJournalRenderItem): { + role: NativeChatMessage['role'] + blocks: NativeChatBlock[] +} | null { + const body = item.body + if (body.kind === 'message') { + return { role: body.role, blocks: body.blocks } + } + if (body.kind === 'tool-call') { + return { + role: 'assistant', + blocks: [ + { type: 'tool-call', name: body.name, input: body.input }, + ...(body.output + ? [ + { + type: 'tool-result' as const, + output: boundedText(body.output), + isError: body.state === 'failed' + } + ] + : []) + ] + } + } + if (body.kind === 'diff') { + return { + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'Diff', input: { path: body.path } }, + { type: 'tool-result', output: boundedText(body.patch) } + ] + } + } + if (body.kind === 'approval') { + if (body.resolution.state === 'pending') { + return null + } + return { + role: 'system', + blocks: [ + { + type: 'text', + text: `${body.title}\n${body.detail ?? ''}\n${body.resolution.state}`.trim() + } + ] + } + } + if (body.kind === 'question') { + if (body.resolution.state === 'pending') { + return null + } + const choices = body.options.map((option) => option.label).join(' · ') + return { + role: 'system', + blocks: [{ type: 'text', text: `${body.question}\n${choices}`.trim() }] + } + } + if (body.turnLifecycle) { + return null + } + return { + role: 'system', + blocks: [ + { + type: 'text', + text: body.text, + ...(body.providerFrame ? { providerFrame: body.providerFrame } : {}) + } + ] + } +} + +export function projectStructuredItemsToNativeChat( + items: readonly AgentJournalRenderItem[] +): NativeChatMessage[] { + return items.flatMap((item) => { + const projected = itemBlocks(item) + return projected + ? [ + { + id: item.itemId, + role: projected.role, + blocks: projected.blocks, + timestamp: item.observedAt, + source: 'transcript' + } + ] + : [] + }) +} + +export function projectStructuredItemToNativeChat( + item: AgentJournalRenderItem +): NativeChatMessage | null { + return projectStructuredItemsToNativeChat([item])[0] ?? null +} + +export function activeStructuredAgentSessionTurnId( + items: readonly AgentJournalRenderItem[] +): string | null { + for (let index = items.length - 1; index >= 0; index -= 1) { + const body = items[index]?.body + if (body?.kind === 'status' && body.turnLifecycle) { + return body.turnLifecycle.state === 'running' ? body.turnLifecycle.turnId : null + } + } + return null +} + +export function hasPersistedStructuredAgentSessionTurn( + items: readonly AgentJournalRenderItem[] +): boolean { + return items.some( + (item) => + item.body.kind === 'message' && (item.body.role === 'user' || item.body.role === 'assistant') + ) +} + +export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle' + +export function structuredAgentSessionTabId(sessionId: string): string { + return `structured-agent-session-${sessionId}` +} + +export function projectStructuredAgentSessionStatus( + items: readonly AgentJournalRenderItem[] +): StructuredAgentSessionProjectedStatus { + if ( + items.some( + (item) => + (item.body.kind === 'approval' || item.body.kind === 'question') && + item.body.resolution.state === 'pending' + ) + ) { + return 'attention' + } + return activeStructuredAgentSessionTurnId(items) ? 'working' : 'idle' +} + +export function structuredAgentSessionPaneKey(tabId: string, sessionId: string): string { + const bytes = sha256(new TextEncoder().encode(sessionId)) + const hex = Array.from(bytes.slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('') + const leaf = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}` + return `${tabId}:${leaf}` +} diff --git a/src/shared/structured-agent-session-reducer.test.ts b/src/shared/structured-agent-session-reducer.test.ts new file mode 100644 index 00000000000..222db53a564 --- /dev/null +++ b/src/shared/structured-agent-session-reducer.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem, AgentJournalSubmission } from './agent-session-journal-types' +import type { AgentSessionHistoryPage } from './agent-session-wire' +import { + EMPTY_STRUCTURED_AGENT_SESSION, + reduceStructuredAgentSession +} from './structured-agent-session-reducer' + +function item(id: string, sequence: number): AgentJournalRenderItem { + return { + itemId: id, + revision: 1, + sequence, + observedAt: sequence, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: id }] } + } +} + +function submission(index: number) { + return { + clientMessageId: `client-${index}`, + fence: 1, + payloadFingerprint: `fingerprint-${index}`, + dispatchState: 'accepted' as const, + providerItemId: `provider-${index}`, + reason: null, + submittedAt: index, + resolvedAt: index + } +} + +function hydrationPage( + items: AgentJournalRenderItem[], + submissions: AgentJournalSubmission[] = [] +): AgentSessionHistoryPage { + const oldest = items[0]?.sequence ?? 0 + const newest = items.at(-1)?.sequence ?? 0 + return { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items, + removedItemIds: [], + submissions, + window: { + oldest: items[0] ? { epoch: 'epoch-a', sequence: oldest } : null, + newest: items.at(-1) ? { epoch: 'epoch-a', sequence: newest } : null, + nextCursor: { epoch: 'epoch-a', sequence: oldest } + }, + liveCursor: { epoch: 'epoch-a', sequence: newest }, + hasOlder: false, + hasNewer: false + } +} + +describe('structured agent session reducer', () => { + it('uses the bounded hydration page pagination boundary', () => { + const restored = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage( + Array.from({ length: 84 }, (_, index) => item(`item-${index}`, index + 1)) + ) + } + }) + + expect(restored.items).toHaveLength(84) + expect(restored.hasOlder).toBe(false) + }) + + it('does not let a stale focus refresh replace newer streamed state', () => { + const streamed = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('streamed', 50)]) + } + }) + const afterRefresh = reduceStructuredAgentSession(streamed, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('stale', 40)], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: 40 }, + newest: { epoch: 'epoch-a', sequence: 40 }, + nextCursor: { epoch: 'epoch-a', sequence: 40 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 40 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(afterRefresh).toBe(streamed) + }) + + it('keeps paged-in older items when a focus refresh carries nothing new', () => { + const snapshot = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('newest', 50)]) + } + }) + const withOlder = reduceStructuredAgentSession(snapshot, { + type: 'older-page', + requestedEpoch: 'epoch-a', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'before', + items: [item('older', 10)], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: 10 }, + newest: { epoch: 'epoch-a', sequence: 10 }, + nextCursor: { epoch: 'epoch-a', sequence: 10 } + }, + hasOlder: false, + hasNewer: true + } + }) + const afterRefresh = reduceStructuredAgentSession(withOlder, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('newest', 50)], + removedItemIds: [], + submissions: [], + window: { + oldest: { epoch: 'epoch-a', sequence: 50 }, + newest: { epoch: 'epoch-a', sequence: 50 }, + nextCursor: { epoch: 'epoch-a', sequence: 50 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 50 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(afterRefresh).toBe(withOlder) + expect(afterRefresh.items.map((entry) => entry.itemId)).toEqual(['older', 'newest']) + }) + + it('accepts a newer fence from an equal-cursor tail refresh', () => { + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('newest', 50)]) + } + }) + const page = { ...hydrationPage([item('newest', 50)]), fence: 2 } + + const refreshed = reduceStructuredAgentSession(initial, { type: 'tail-page', page }) + + expect(refreshed.fence).toBe(2) + expect(refreshed.items).toBe(initial.items) + }) + + it('keeps rapid-send submissions when a newer tail refresh contains only the last one', () => { + const initial = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage( + [item('first', 10)], + Array.from({ length: 8 }, (_, index) => submission(index)) + ) + } + }) + const refreshed = reduceStructuredAgentSession(initial, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item('latest', 11)], + removedItemIds: [], + submissions: [submission(7)], + window: { + oldest: { epoch: 'epoch-a', sequence: 11 }, + newest: { epoch: 'epoch-a', sequence: 11 }, + nextCursor: { epoch: 'epoch-a', sequence: 11 } + }, + liveCursor: { epoch: 'epoch-a', sequence: 11 }, + hasOlder: true, + hasNewer: false + } + }) + + expect(refreshed.submissions.map((entry) => entry.clientMessageId)).toEqual( + Array.from({ length: 8 }, (_, index) => `client-${index}`) + ) + }) + + it('bounds retained submission identities across repeated tail refreshes', () => { + let state = reduceStructuredAgentSession(EMPTY_STRUCTURED_AGENT_SESSION, { + type: 'event', + event: { + type: 'snapshot', + sessionId: 'session-a', + fence: 1, + page: hydrationPage([item('first', 1)]) + } + }) + + for (let index = 0; index < 300; index += 1) { + state = reduceStructuredAgentSession(state, { + type: 'tail-page', + page: { + sessionId: 'session-a', + epoch: 'epoch-a', + direction: 'tail', + items: [item(`item-${index}`, index + 2)], + removedItemIds: [], + submissions: [submission(index)], + window: { + oldest: { epoch: 'epoch-a', sequence: index + 2 }, + newest: { epoch: 'epoch-a', sequence: index + 2 }, + nextCursor: { epoch: 'epoch-a', sequence: index + 2 } + }, + liveCursor: { epoch: 'epoch-a', sequence: index + 2 }, + hasOlder: true, + hasNewer: false + } + }) + } + + expect(state.submissions).toHaveLength(256) + expect(state.submissions[0]?.clientMessageId).toBe('client-44') + expect(state.submissions.at(-1)?.clientMessageId).toBe('client-299') + }) +}) diff --git a/src/shared/structured-agent-session-reducer.ts b/src/shared/structured-agent-session-reducer.ts new file mode 100644 index 00000000000..24b500fc2b3 --- /dev/null +++ b/src/shared/structured-agent-session-reducer.ts @@ -0,0 +1,188 @@ +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalSubmission +} from './agent-session-journal-types' +import type { + AgentSessionHandoffStatus, + AgentSessionHistoryPage, + AgentSessionSubscribeEvent +} from './agent-session-wire' + +export type StructuredAgentSessionState = { + epoch: string | null + cursor: AgentJournalCursor | null + fence: number | null + items: AgentJournalRenderItem[] + submissions: AgentJournalSubmission[] + hasOlder: boolean + status: 'idle' | 'loading' | 'ready' | 'error' + error?: string + handoff: AgentSessionHandoffStatus | null +} + +export type StructuredAgentSessionAction = + | { type: 'loading' } + | { type: 'error'; message: string } + | { type: 'handoff'; handoff: AgentSessionHandoffStatus } + | { type: 'event'; event: AgentSessionSubscribeEvent } + | { type: 'tail-page'; page: AgentSessionHistoryPage } + | { type: 'older-page'; requestedEpoch: string; page: AgentSessionHistoryPage } + +export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = { + epoch: null, + cursor: null, + fence: null, + items: [], + submissions: [], + hasOlder: false, + status: 'idle', + handoff: null +} + +const MAX_RETAINED_SUBMISSIONS = 256 + +function replacePage( + page: AgentSessionHistoryPage, + fence: number, + handoff?: AgentSessionHandoffStatus +): StructuredAgentSessionState { + return { + epoch: page.epoch, + cursor: page.liveCursor ?? page.window.nextCursor, + fence, + items: [...page.items].sort((left, right) => left.sequence - right.sequence), + submissions: page.submissions, + hasOlder: page.hasOlder, + status: 'ready', + handoff: handoff ?? null + } +} + +function mergeItems( + current: readonly AgentJournalRenderItem[], + incoming: readonly AgentJournalRenderItem[], + removedIds: readonly string[] +): AgentJournalRenderItem[] { + const removed = new Set(removedIds) + const byId = new Map( + current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item]) + ) + for (const item of incoming) { + const prior = byId.get(item.itemId) + if (!prior || item.revision >= prior.revision) { + byId.set(item.itemId, item) + } + } + return [...byId.values()].sort((left, right) => left.sequence - right.sequence) +} + +function mergeSubmissions( + current: readonly AgentJournalSubmission[], + incoming: readonly AgentJournalSubmission[] +): AgentJournalSubmission[] { + const byId = new Map(current.map((submission) => [submission.clientMessageId, submission])) + for (const submission of incoming) { + byId.set(submission.clientMessageId, submission) + } + return [...byId.values()] + .sort((left, right) => left.submittedAt - right.submittedAt) + .slice(-MAX_RETAINED_SUBMISSIONS) +} + +export function reduceStructuredAgentSession( + state: StructuredAgentSessionState, + action: StructuredAgentSessionAction +): StructuredAgentSessionState { + if (action.type === 'loading') { + return { ...EMPTY_STRUCTURED_AGENT_SESSION, status: 'loading' } + } + if (action.type === 'error') { + return { ...state, status: 'error', error: action.message } + } + if (action.type === 'handoff') { + return { ...state, handoff: action.handoff } + } + if (action.type === 'tail-page') { + const pageCursor = action.page.liveCursor ?? action.page.window.newest + // An equal cursor means the page holds nothing the stream has not already + // delivered; replacing would throw away paged-in older items mid-scroll. + if ( + state.epoch === action.page.epoch && + state.cursor && + (!pageCursor || pageCursor.sequence <= state.cursor.sequence) + ) { + if ( + pageCursor?.sequence === state.cursor.sequence && + action.page.fence !== undefined && + action.page.fence !== state.fence + ) { + return { ...state, fence: action.page.fence, status: 'ready', error: undefined } + } + return state + } + const sameEpoch = state.epoch === action.page.epoch + return { + epoch: action.page.epoch, + cursor: action.page.liveCursor ?? null, + fence: action.page.fence ?? null, + items: action.page.items, + submissions: sameEpoch + ? mergeSubmissions(state.submissions, action.page.submissions) + : action.page.submissions, + hasOlder: action.page.hasOlder, + status: 'ready', + handoff: state.handoff + } + } + if (action.type === 'older-page') { + if (state.epoch !== action.requestedEpoch || action.page.epoch !== action.requestedEpoch) { + return state + } + return { + ...state, + items: mergeItems(state.items, action.page.items, action.page.removedItemIds), + submissions: mergeSubmissions(state.submissions, action.page.submissions), + hasOlder: action.page.hasOlder + } + } + const event = action.event + if (event.type === 'end') { + return state + } + if (event.type === 'snapshot' || event.type === 'reset') { + return replacePage(event.page, event.fence, event.handoff) + } + if (state.epoch !== event.batch.cursor.epoch) { + return state + } + if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) { + return state + } + return { + ...state, + cursor: event.batch.cursor, + fence: event.fence ?? state.fence, + items: mergeItems(state.items, event.batch.items, event.batch.removedItemIds), + submissions: mergeSubmissions(state.submissions, event.batch.submissions), + status: 'ready', + error: undefined, + handoff: event.handoff ?? state.handoff + } +} + +export function oldestStructuredAgentSessionCursor( + state: StructuredAgentSessionState +): AgentJournalCursor | null { + const oldest = state.items[0] + return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null +} + +export function shouldAdvanceStructuredResumeCursor( + current: AgentJournalCursor | null, + incoming: AgentJournalCursor +): boolean { + return ( + current === null || (current.epoch === incoming.epoch && incoming.sequence >= current.sequence) + ) +} diff --git a/src/shared/tab-types.ts b/src/shared/tab-types.ts index 0b5f263ff1c..5f2b74d7127 100644 --- a/src/shared/tab-types.ts +++ b/src/shared/tab-types.ts @@ -1,4 +1,5 @@ import type { AiVaultSessionTitle } from './ai-vault-session-title' +import type { AgentType } from './agent-status-types' import type { ExecutionHostId } from './execution-host' // ─── Tab Group Layout ─────────────────────────────────────────────── @@ -22,16 +23,27 @@ export type TabContentType = | 'diff' | 'conflict-review' | 'check-details' + | 'agent-session' | 'browser' | 'simulator' -export type WorkspaceVisibleTabType = 'terminal' | 'editor' | 'browser' | 'simulator' +export type WorkspaceVisibleTabType = + | 'terminal' + | 'editor' + | 'agent-session' + | 'browser' + | 'simulator' export type CtrlTabOrderMode = 'mru' | 'sequential' // Why: many-to-one — every editor-family kind collapses to 'editor'. Never invert it by equality; // resolve the concrete tab and project forward instead. export function toVisibleTabType(contentType: TabContentType): WorkspaceVisibleTabType { - if (contentType === 'browser' || contentType === 'terminal' || contentType === 'simulator') { + if ( + contentType === 'agent-session' || + contentType === 'browser' || + contentType === 'terminal' || + contentType === 'simulator' + ) { return contentType } return 'editor' @@ -56,6 +68,10 @@ export type Tab = { createdAt: number isPreview?: boolean // preview tabs get replaced by next single-click open isPinned?: boolean // pinned tabs survive "close others" + /** Provider backing a structured agent-session tab. */ + agentSessionAgent?: AgentType + /** Structured session adopted from this terminal's Codex TUI. */ + structuredSessionId?: string /** Why: per-tab rendering mode for coding-agent terminals. `'chat'` shows the * native chat view as an overlay while the live terminal stays mounted * underneath; `'terminal'` (the default for legacy/missing) shows the raw diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 687749a5eee..00515bfa513 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -576,6 +576,15 @@ describe('workspace_create_failed schema', () => { }) describe('settings_changed schema', () => { + it('accepts structured native chat as a boolean adoption signal', () => { + expect( + eventSchemas.settings_changed.safeParse({ + setting_key: 'experimentalStructuredNativeChat', + value_kind: 'bool' + }).success + ).toBe(true) + }) + it('accepts whitelisted setting keys', () => { for (const key of SETTINGS_CHANGED_WHITELIST) { const parsed = eventSchemas.settings_changed.safeParse({ diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 0466ca79430..9253d5d075d 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -246,6 +246,7 @@ export const SETTINGS_CHANGED_WHITELIST = [ 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', + 'experimentalStructuredNativeChat', 'experimentalActivity', 'experimentalAgentDashboardPopout', 'experimentalTerminalAttention', diff --git a/src/shared/tui-agent-resume-startup.ts b/src/shared/tui-agent-resume-startup.ts new file mode 100644 index 00000000000..f4924b83c47 --- /dev/null +++ b/src/shared/tui-agent-resume-startup.ts @@ -0,0 +1,71 @@ +import { + getAgentResumeArgv, + type AgentProviderSessionMetadata, + type ResumableTuiAgent +} from './agent-session-resume' +import type { SessionOptionValue } from './native-chat-session-options' +import { buildSleepingAgentLaunchConfig } from './sleeping-agent-launch-config' +import { resolveAgentLaunchCommand } from './tui-agent-launch-command' +import type { AgentStartupPlan } from './tui-agent-startup' +import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import { TUI_AGENT_CONFIG } from './tui-agent-config' +import type { TuiAgent } from './tui-agent' +import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command' + +export function buildAgentResumeStartupPlan(args: { + agent: ResumableTuiAgent + providerSession: AgentProviderSessionMetadata + cmdOverrides: Partial> + platform: NodeJS.Platform + shell?: AgentStartupShell + agentArgs?: string | null + agentEnv?: Record | null + agentCommand?: string | null + ompResumeFilePath?: string | null + sessionOptions?: Record + sessionOptionsOverrideAgentArgs?: boolean + isRemote?: boolean +}): AgentStartupPlan | null { + const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath) + if (!argv) { + return null + } + const shell = resolveStartupShell(args.platform, args.shell) + const resolvedAgentCommand = args.agentCommand?.trim() + const baseCommand = resolvedAgentCommand + ? ({ + ok: true, + command: resolvedAgentCommand, + commandWithoutSessionOptions: resolvedAgentCommand, + appliedSessionOptions: {} + } as const) + : resolveAgentLaunchCommand({ + agent: args.agent, + cmdOverrides: args.cmdOverrides, + platform: args.platform, + shell, + agentArgs: args.agentArgs, + sessionOptions: args.sessionOptions, + sessionOptionsOverrideAgentArgs: args.sessionOptionsOverrideAgentArgs, + isRemote: args.isRemote + }) + if (!baseCommand.ok) { + return null + } + const launchConfig = buildSleepingAgentLaunchConfig({ + ...args, + agentCommand: baseCommand.commandWithoutSessionOptions + }) + const launchCommand = buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell) + const applied = baseCommand.appliedSessionOptions + return { + agent: args.agent, + launchCommand, + expectedProcess: TUI_AGENT_CONFIG[args.agent].expectedProcess, + followupPrompt: null, + launchConfig, + ...(args.agent === 'codex' ? { startupCommandDelivery: 'shell-ready' as const } : {}), + ...(Object.keys(applied).length > 0 ? { sessionOptions: { ...applied } } : {}), + ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) + } +} diff --git a/src/shared/tui-agent-startup-hermes.test.ts b/src/shared/tui-agent-startup-hermes.test.ts new file mode 100644 index 00000000000..bcf5f43750c --- /dev/null +++ b/src/shared/tui-agent-startup-hermes.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' +import { + unwrapPosixShellScript, + unwrapPowerShellScript +} from './tui-agent-startup-script.test-fixture' + +// Hermes is the only agent whose launch Orca rewrites token by token — it owns the startup query, +// the TUI mode, and where an override's flags may sit relative to the chat subcommand. Its cases +// outgrew the general startup-plan file. + +describe('hermes startup plans', () => { + it('moves Hermes command override flags after the chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run privately', + cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, + agentArgs: '--yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') + }) + + it.each([ + { + shell: 'powershell' as const, + override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', + expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" + }, + { + shell: 'cmd' as const, + override: 'C:\\Tools\\hermes.exe --tui', + expected: "& 'C:\\Tools\\hermes.exe' 'chat'" + } + ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: testCase.override }, + platform: 'win32', + shell: testCase.shell + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) + }) + + it('removes a configured duplicate chat subcommand', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, + agentArgs: '--provider copilot chat --yolo', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--provider' 'copilot' '--yolo'") + }) + + it('preserves an option value named chat', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes --profile chat --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") + }) + + it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'automation prompt', + cmdOverrides: { hermes: 'hermes --query override --cli' }, + agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/--query=/g)).toHaveLength(1) + expect(script).not.toContain('override') + expect(script).not.toContain("'--cli'") + expect(script.match(/'--tui'/g)).toHaveLength(1) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') + }) + + it('preserves wrapper tokens before the Hermes executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") + }) + + it('selects the final Hermes executable token in a wrapper', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'sudo' '-u' 'hermes' 'hermes' 'chat'" + ) + }) + + it('selects the wrapped executable when the wrapper and command both name Hermes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") + expect(script.match(/'chat'/g)).toHaveLength(1) + }) + + it('does not mistake a Hermes option value for a wrapped executable', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, + platform: 'linux' + }) + + const script = unwrapPosixShellScript(plan?.launchCommand) + expect(script.match(/'chat'/g)).toHaveLength(1) + expect(script).toContain("'--resume' 'hermes'") + }) + + it('preserves POSIX environment-assignment command prefixes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, + platform: 'linux' + }) + + expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( + "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" + ) + }) + + it('rejects a Hermes command override with no identifiable executable', () => { + expect( + buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run it', + cmdOverrides: { hermes: 'custom-agent --tui' }, + platform: 'linux' + }) + ).toBeNull() + }) + + it('rejects Hermes queries that exceed the safe Windows environment limit', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'x'.repeat(24_000), + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan).toBeNull() + }) + + it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( + 'keeps a complex cmd Hermes query out of command text: %s', + (prompt) => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt, + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).not.toContain(prompt) + expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) + } + ) + + it('uses the Windows remote default shell for SSH Hermes queries', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: 'run remotely', + cmdOverrides: {}, + platform: 'win32', + isRemote: true + }) + + expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( + "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" + ) + }) + + it('measures POSIX Hermes query limits in UTF-8 bytes', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '界'.repeat(50_000), + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan).toBeNull() + }) + + it('keeps empty Hermes launches on the interactive TUI command', () => { + const plan = buildAgentStartupPlan({ + agent: 'hermes', + prompt: '', + cmdOverrides: {}, + platform: 'linux', + allowEmptyPromptLaunch: true + }) + + expect(plan?.launchCommand).toBe('hermes --tui') + expect(plan?.followupPrompt).toBeNull() + }) +}) diff --git a/src/shared/tui-agent-startup-script.test-fixture.ts b/src/shared/tui-agent-startup-script.test-fixture.ts new file mode 100644 index 00000000000..b76197edcaa --- /dev/null +++ b/src/shared/tui-agent-startup-script.test-fixture.ts @@ -0,0 +1,24 @@ +import { expect } from 'vitest' +import { tokenizeStartupCommand } from './tui-agent-startup-shell' + +// A startup plan's launch command carries the real script encoded for the target shell — octal +// escapes fed to `printf %b` on POSIX, UTF-16 base64 for PowerShell. Assertions want the script, +// so these decode it back. + +export function unwrapPosixShellScript(command: string | undefined): string { + const tokenized = tokenizeStartupCommand(command ?? '', 'posix') + expect(tokenized.ok).toBe(true) + const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' + const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] + if (!encoded) { + return wrapper + } + const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function unwrapPowerShellScript(command: string | undefined): string { + const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeDefined() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} diff --git a/src/shared/tui-agent-startup-session-options.test.ts b/src/shared/tui-agent-startup-session-options.test.ts index 45d83900b86..a8c05f4e68b 100644 --- a/src/shared/tui-agent-startup-session-options.test.ts +++ b/src/shared/tui-agent-startup-session-options.test.ts @@ -140,15 +140,22 @@ describe('tui agent startup session options', () => { expect(plan?.sessionOptions).toEqual({ model: 'opus', effort: 'high' }) }) - it('never injects session options into resume commands', () => { + it('applies explicit session options to resume commands', () => { const plan = buildAgentResumeStartupPlan({ agent: 'codex', providerSession: { key: 'session_id', id: 'thread-1' }, cmdOverrides: {}, platform: 'linux', - sessionOptions: { model: 'gpt-5.5', effort: 'high' } + agentArgs: '-m gpt-5.6-sol -c model_reasoning_effort=medium', + sessionOptions: { model: 'gpt-5.5', effort: 'high' }, + sessionOptionsOverrideAgentArgs: true }) - expect(plan?.launchCommand).toBe("codex 'resume' 'thread-1'") - expect(plan?.sessionOptions).toBeUndefined() + expect(plan?.launchCommand).toBe( + "codex '-m' 'gpt-5.5' '-c' 'model_reasoning_effort=high' 'resume' 'thread-1'" + ) + expect(plan?.launchConfig.agentCommand).toBe( + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium'" + ) + expect(plan?.sessionOptions).toEqual({ model: 'gpt-5.5', effort: 'high' }) }) }) diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 12c9f56e270..e5e26f46802 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -9,24 +9,10 @@ import { import { TUI_AGENT_CONFIG } from './tui-agent-config' import { normalizeTuiAgentArgsRecord, resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' import { tokenizeStartupCommand } from './tui-agent-startup-shell' - -function unwrapPosixShellScript(command: string | undefined): string { - const tokenized = tokenizeStartupCommand(command ?? '', 'posix') - expect(tokenized.ok).toBe(true) - const wrapper = tokenized.ok ? (tokenized.tokens[2] ?? '') : '' - const encoded = wrapper.match(/printf %b "([\\0-7]+)"/)?.[1] - if (!encoded) { - return wrapper - } - const bytes = [...encoded.matchAll(/\\0([0-7]{3})/g)].map((match) => Number.parseInt(match[1], 8)) - return new TextDecoder().decode(new Uint8Array(bytes)) -} - -function unwrapPowerShellScript(command: string | undefined): string { - const encoded = command?.match(/-EncodedCommand\s+(\S+)/)?.[1] - expect(encoded).toBeDefined() - return Buffer.from(encoded!, 'base64').toString('utf16le') -} +import { + unwrapPosixShellScript, + unwrapPowerShellScript +} from './tui-agent-startup-script.test-fixture' describe('draft prefill teardown ordering (#14975)', () => { // Why pinned: the teardown mutates the calling shell, so it must reference @@ -212,224 +198,6 @@ describe('tui agent startup plans', () => { expect(tokens.ok && tokens.tokens.at(-1)).toMatch(/\\0[0-7]{3}/) }) - it('moves Hermes command override flags after the chat subcommand', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run privately', - cmdOverrides: { hermes: 'hermes --tui --provider anthropic' }, - agentArgs: '--yolo', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script).toContain("'--provider' 'anthropic' '--yolo' '--tui'") - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('run privately') - }) - - it.each([ - { - shell: 'powershell' as const, - override: '"C:\\Program Files\\Hermes\\hermes.exe" --tui', - expected: "& 'C:\\Program Files\\Hermes\\hermes.exe' 'chat'" - }, - { - shell: 'cmd' as const, - override: 'C:\\Tools\\hermes.exe --tui', - expected: "& 'C:\\Tools\\hermes.exe' 'chat'" - } - ])('preserves Windows paths in Hermes command overrides on $shell', (testCase) => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: testCase.override }, - platform: 'win32', - shell: testCase.shell - }) - - expect(unwrapPowerShellScript(plan?.launchCommand)).toContain(testCase.expected) - }) - - it('removes a configured duplicate chat subcommand', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes --provider copilot chat --tui' }, - agentArgs: '--provider copilot chat --yolo', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/'chat'/g)).toHaveLength(1) - expect(script).toContain("'--provider' 'copilot' '--yolo'") - }) - - it('preserves an option value named chat', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes --profile chat --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'--profile' 'chat'") - }) - - it('keeps Orca ownership of the Hermes startup query and TUI mode', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'automation prompt', - cmdOverrides: { hermes: 'hermes --query override --cli' }, - agentArgs: '-q=second-override --query=third-override -qfourth-override --tui', - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/--query=/g)).toHaveLength(1) - expect(script).not.toContain('override') - expect(script).not.toContain("'--cli'") - expect(script.match(/'--tui'/g)).toHaveLength(1) - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe('automation prompt') - }) - - it('preserves wrapper tokens before the Hermes executable', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'uv run hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain("'uv' 'run' 'hermes' 'chat'") - }) - - it('selects the final Hermes executable token in a wrapper', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'sudo -u hermes hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( - "'sudo' '-u' 'hermes' 'hermes' 'chat'" - ) - }) - - it('selects the wrapped executable when the wrapper and command both name Hermes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'sudo -u hermes hermes chat --tui' }, - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script).toContain("'sudo' '-u' 'hermes' 'hermes' 'chat'") - expect(script.match(/'chat'/g)).toHaveLength(1) - }) - - it('does not mistake a Hermes option value for a wrapped executable', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'hermes chat --resume hermes --tui' }, - platform: 'linux' - }) - - const script = unwrapPosixShellScript(plan?.launchCommand) - expect(script.match(/'chat'/g)).toHaveLength(1) - expect(script).toContain("'--resume' 'hermes'") - }) - - it('preserves POSIX environment-assignment command prefixes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'HERMES_HOME=/tmp/test uv run hermes --tui' }, - platform: 'linux' - }) - - expect(unwrapPosixShellScript(plan?.launchCommand)).toContain( - "'env' 'HERMES_HOME=/tmp/test' 'uv' 'run' 'hermes' 'chat'" - ) - }) - - it('rejects a Hermes command override with no identifiable executable', () => { - expect( - buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run it', - cmdOverrides: { hermes: 'custom-agent --tui' }, - platform: 'linux' - }) - ).toBeNull() - }) - - it('rejects Hermes queries that exceed the safe Windows environment limit', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'x'.repeat(24_000), - cmdOverrides: {}, - platform: 'win32' - }) - - expect(plan).toBeNull() - }) - - it.each(['quote "this"', 'print %PATH%', 'toggle !feature!', 'inspect C:\\repo\\'])( - 'keeps a complex cmd Hermes query out of command text: %s', - (prompt) => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt, - cmdOverrides: {}, - platform: 'win32', - shell: 'cmd' - }) - - expect(plan?.launchCommand).not.toContain(prompt) - expect(plan?.env?.ORCA_HERMES_STARTUP_QUERY).toBe(prompt) - } - ) - - it('uses the Windows remote default shell for SSH Hermes queries', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: 'run remotely', - cmdOverrides: {}, - platform: 'win32', - isRemote: true - }) - - expect(unwrapPowerShellScript(plan?.launchCommand)).toContain( - "& 'hermes' 'chat' \"--query=$orcaHermesNativeQuery\" '--tui'" - ) - }) - - it('measures POSIX Hermes query limits in UTF-8 bytes', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: '界'.repeat(50_000), - cmdOverrides: {}, - platform: 'linux' - }) - - expect(plan).toBeNull() - }) - - it('keeps empty Hermes launches on the interactive TUI command', () => { - const plan = buildAgentStartupPlan({ - agent: 'hermes', - prompt: '', - cmdOverrides: {}, - platform: 'linux', - allowEmptyPromptLaunch: true - }) - - expect(plan?.launchCommand).toBe('hermes --tui') - expect(plan?.followupPrompt).toBeNull() - }) - it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex', @@ -614,6 +382,7 @@ describe('tui agent startup plans', () => { }) expect(plan?.launchCommand).toBe("codex 'resume' 's1'") + expect(plan?.startupCommandDelivery).toBe('shell-ready') }) it('quotes Windows resume argv for cmd.exe when shell is cmd', () => { diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 7b013c4ffd7..fba16776378 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -1,10 +1,5 @@ import { isShellProcess } from './agent-detection' -import { - getAgentResumeArgv, - type AgentProviderSessionMetadata, - type ResumableTuiAgent, - type SleepingAgentLaunchConfig -} from './agent-session-resume' +import type { SleepingAgentLaunchConfig } from './agent-session-resume' import { clearEnvCommand, commandSeparator, @@ -20,7 +15,8 @@ import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' import type { TuiAgent } from './tui-agent' import type { SessionOptionValue } from './native-chat-session-options' import { resolveAgentLaunchCommand } from './tui-agent-launch-command' -import { buildAgentResumeLaunchCommand } from './agent-resume-launch-command' + +export { buildAgentResumeStartupPlan } from './tui-agent-resume-startup' export type AgentStartupPlan = { agent: TuiAgent @@ -185,54 +181,6 @@ export function buildAgentStartupPlan(args: { } } -export function buildAgentResumeStartupPlan(args: { - agent: ResumableTuiAgent - providerSession: AgentProviderSessionMetadata - cmdOverrides: Partial> - platform: NodeJS.Platform - shell?: AgentStartupShell - agentArgs?: string | null - agentEnv?: Record | null - agentCommand?: string | null - ompResumeFilePath?: string | null - sessionOptions?: Record - /** Why: see buildAgentStartupPlan — remote launches use the plain `orca` shim. */ - isRemote?: boolean -}): AgentStartupPlan | null { - const argv = getAgentResumeArgv(args.agent, args.providerSession, args.ompResumeFilePath) - if (!argv) { - return null - } - const shell = resolveStartupShell(args.platform, args.shell) - const config = TUI_AGENT_CONFIG[args.agent] - const resolvedAgentCommand = args.agentCommand?.trim() - const baseCommand = resolvedAgentCommand - ? ({ ok: true, command: resolvedAgentCommand } as const) - : resolveAgentLaunchCommand({ - agent: args.agent, - cmdOverrides: args.cmdOverrides, - platform: args.platform, - shell, - agentArgs: args.agentArgs, - isRemote: args.isRemote - }) - if (!baseCommand.ok) { - return null - } - const launchConfig = buildSleepingAgentLaunchConfig({ - ...args, - agentCommand: baseCommand.command - }) - return { - agent: args.agent, - launchCommand: buildAgentResumeLaunchCommand(args.agent, baseCommand.command, argv, shell), - expectedProcess: config.expectedProcess, - followupPrompt: null, - launchConfig, - ...(args.agentEnv ? { env: { ...args.agentEnv } } : {}) - } -} - export type AgentDraftLaunchPlan = { agent: TuiAgent launchCommand: string diff --git a/src/shared/workspace-session-schema.test.ts b/src/shared/workspace-session-schema.test.ts index 20d11ba5fc2..66e2ddcc514 100644 --- a/src/shared/workspace-session-schema.test.ts +++ b/src/shared/workspace-session-schema.test.ts @@ -547,6 +547,45 @@ describe('parseWorkspaceSession', () => { } }) + it('preserves a structured agent session tab and its active projection', () => { + const result = parseWorkspaceSession({ + activeRepoId: null, + activeWorktreeId: 'wt', + activeTabId: 'session-1', + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + wt: [ + { + id: 'session-1', + entityId: 'session-1', + groupId: 'group1', + worktreeId: 'wt', + contentType: 'agent-session', + agentSessionAgent: 'codex', + structuredSessionId: 'codex-session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + activeTabTypeByWorktree: { wt: 'agent-session' } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.value.unifiedTabs?.wt[0]).toMatchObject({ + contentType: 'agent-session', + agentSessionAgent: 'codex', + structuredSessionId: 'codex-session-1' + }) + expect(result.value.activeTabTypeByWorktree?.wt).toBe('agent-session') + } + }) + it('degrades an unknown viewMode to the safe default instead of failing parse', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-schema.ts b/src/shared/workspace-session-schema.ts index 2cc23106f9e..2ed368baedf 100644 --- a/src/shared/workspace-session-schema.ts +++ b/src/shared/workspace-session-schema.ts @@ -32,6 +32,10 @@ import { clientHostedBrowserCloseIntentSchema } from './client-hosted-browser-cl import { persistedClientHostedBrowserPageSchema } from './client-hosted-browser-page-record' import { persistedOpenFileSchema } from './workspace-session-editor-schema' import { sleepingAgentSessionsByPaneKeySchema } from './workspace-session-sleeping-agents' +import { + tabContentTypeSchema, + workspaceVisibleTabTypeSchema +} from './workspace-session-tab-type-schema' import { salvagedField, salvagedOptional, salvagingArray, salvagingRecord } from './zod-salvage' // ─── Terminal pane layout (recursive) ─────────────────────────────── @@ -109,18 +113,6 @@ const terminalTabSchema = z.object({ // ─── Unified tab model ────────────────────────────────────────────── -const tabContentTypeSchema = z.enum([ - 'terminal', - 'editor', - 'diff', - 'conflict-review', - 'check-details', - 'browser', - 'simulator' -]) - -const workspaceVisibleTabTypeSchema = z.enum(['terminal', 'editor', 'browser', 'simulator']) - const executionHostIdSchema = z.custom( (value) => typeof value === 'string' && Boolean(parseExecutionHostId(value)) ) @@ -132,6 +124,10 @@ const tabSchema = z.object({ worktreeId: z.string(), executionHostId: executionHostIdSchema.optional(), contentType: tabContentTypeSchema, + agentSessionAgent: z.enum(['codex', 'claude']).optional().catch(undefined), + // Why: a structured terminal tab must recover its durable host session after + // restart; omitting this additive field silently routes it back through PTY. + structuredSessionId: z.string().min(1).optional().catch(undefined), label: z.string(), generatedLabel: z.string().nullable().optional(), aiVaultTitle: z diff --git a/src/shared/workspace-session-tab-type-schema.ts b/src/shared/workspace-session-tab-type-schema.ts new file mode 100644 index 00000000000..aee022ec6b4 --- /dev/null +++ b/src/shared/workspace-session-tab-type-schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const tabContentTypeSchema = z.enum([ + 'terminal', + 'editor', + 'diff', + 'conflict-review', + 'check-details', + 'agent-session', + 'browser', + 'simulator' +]) + +export const workspaceVisibleTabTypeSchema = z.enum([ + 'terminal', + 'editor', + 'agent-session', + 'browser', + 'simulator' +]) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts new file mode 100644 index 00000000000..093547102c6 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -0,0 +1,667 @@ +// Cross-version coverage for the structured agent-session surface, paired the same +// way the terminal wire harness is: current code against a real published release. +// +// Three skews matter here, and none can be checked from one build alone — an old +// client must not be shown a session it cannot render, a new client must find an +// old host's missing surface cleanly, and a client's cursor must survive the host +// process that minted it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { resolveBaselineReleaseRef } from './release-checkout' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const SESSION = 'session-alpha' +const WORKSPACE = 'workspace-1' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const NOW = 1_800_000_000_000 + +/** Every method the structured surface publishes, paired with the host method it + * must reach — a gate that hides one method and leaks another is the bug. */ +const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ + { method: 'agentSession.createSupport', hostMethod: null }, + { method: 'agentSession.create', hostMethod: 'attach' }, + { method: 'agentSession.ensure', hostMethod: 'attach' }, + { method: 'agentSession.send', hostMethod: 'send' }, + { method: 'agentSession.cancel', hostMethod: 'cancel' }, + { method: 'agentSession.close', hostMethod: 'close' }, + { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus' }, + { method: 'agentSession.options', hostMethod: 'readOptions' }, + { method: 'agentSession.hold', hostMethod: 'hold' }, + { method: 'agentSession.release', hostMethod: 'release' }, + { method: 'agentSession.history', hostMethod: 'history' }, + { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, + // Teardown runs through the runtime's subscription registry rather than the + // host, so its reply is the only signal that the gate opened. + { method: 'agentSession.unsubscribe', hostMethod: null } +] + +let baselineRef: string +let current: AgentSessionWireBuild +let baseline: AgentSessionWireBuild +let operations = 0 + +beforeAll(async () => { + baselineRef = resolveBaselineReleaseRef() + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = await loadAgentSessionWireBuild(baselineRef) +}, SUITE_TIMEOUT_MS) + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(args: { + method: string + fields: Record + fence: number | null +}): Record { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: args.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: args.method, + sessionId: SESSION, + fields: args.fields + }) + } +} + +function attachParams(fence: number | null): Record { + const params = { + envelope: { sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: THREAD } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params as unknown as AgentSessionAttachParams) + }) + } + } +} + +function createIntentParams(): Record { + const worktree = `id:${WORKSPACE}` + const fields = { worktree, agent: 'codex' } + return { envelope: envelope({ method: 'agentSession.create', fields, fence: null }), ...fields } +} + +function sendParams(text: string, fence: number): Record { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body } +} + +/** Schema-valid params per method; values only need to survive validation. */ +function paramsFor(method: string): unknown { + const fence = 1 + switch (method) { + case 'agentSession.createSupport': + return { worktree: `id:${WORKSPACE}`, agent: 'codex' } + case 'agentSession.create': + return createIntentParams() + case 'agentSession.ensure': + return attachParams(fence) + case 'agentSession.send': + return sendParams('hi', fence) + case 'agentSession.cancel': + return { + envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }), + turnId: 'turn-1' + } + case 'agentSession.respondToApproval': + case 'agentSession.respondToQuestion': { + const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.setOption': { + const fields = { key: 'model', value: 'gpt-5' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.history': + return { sessionId: SESSION, direction: 'tail' } + case 'agentSession.hold': + case 'agentSession.release': + return { sessionId: SESSION, holderId: 'surface-1' } + default: + return { sessionId: SESSION } + } +} + +function runtimeStub(): unknown { + const cleanups = new Map void>() + return { + getRuntimeId: () => 'runtime-1', + ensureStructuredAgentSessionHost: async () => undefined, + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup), + cleanupSubscription: (id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }, + cleanupSubscriptionsByPrefix: (prefix: string) => { + for (const [id, cleanup] of cleanups) { + if (id.startsWith(prefix)) { + cleanup() + cleanups.delete(id) + } + } + } + } +} + +/** Every reply one call produced. Streaming methods answer more than once, and a + * refusal has to arrive as a reply rather than as silence. */ +async function callBuild( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime: unknown = runtimeStub() +): Promise { + const replies: RpcReply[] = [] + await build + .createDispatcher(runtime) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + client + ) + return replies +} + +describe('cross-version structured agent sessions', () => { + it( + 'skews current code against a real published release', + () => { + expect(baselineRef).toMatch(/^v?\d/) + expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/) + expect(baseline.revision).not.toBe(current.revision) + // The anti-vacuous oracle for the source scan: a scan that found nothing + // would make every "no structured method here" claim below meaningless. + expect(baseline.methodNames).toContain('terminal.create') + expect(current.methodNames).toContain('terminal.create') + }, + SUITE_TIMEOUT_MS + ) + + describe('a client that never asked for structured sessions', () => { + let hostCalls: Record> + + beforeEach(() => { + operations = 0 + hostCalls = { + attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId: SESSION } })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + close: vi.fn(async () => undefined), + hold: vi.fn(async () => undefined), + release: vi.fn(() => undefined), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })), + handoffStatus: vi.fn(async () => ({ owner: 'native' })), + readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + setStructuredAgentSessionHost(hostCalls as unknown as StructuredAgentSessionHost) + }) + + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('is told the whole surface does not exist, and reaches no host method', async () => { + // The old build cannot name the capability, so its clients never send it. + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'runtime', + clientCapabilities: baseline.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + } + for (const [name, spy] of Object.entries(hostCalls)) { + expect(spy, `${name} ran for a client without the capability`).not.toHaveBeenCalled() + } + }) + + it('is served the same calls once it advertises the capability', async () => { + for (const { method, hostMethod } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'runtime', + clientCapabilities: [ + ...baseline.capabilities, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + }) + // A subscription that opens with nothing to say answers with no reply at + // all, so reaching the host is the signal that the gate opened. + if (hostMethod) { + expect(hostCalls[hostMethod], `${method} did not reach the host`).toHaveBeenCalled() + } else { + expect(replies[0], `${method} was refused`).toMatchObject({ ok: true }) + } + for (const reply of replies) { + expect(reply, `${method} was refused: ${JSON.stringify(reply)}`).toMatchObject({ + ok: true + }) + } + } + }) + }) + + describe('a new client against an old host', () => { + it('finds no structured method registered on the old build', () => { + expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) + expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( + STRUCTURED_CALLS.length + ) + }) + + it('can detect the absence during negotiation instead of by calling', () => { + expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + // Additive surface: bumping the protocol number would strand every paired + // device on this release rather than degrade one feature. + expect(current.protocolVersion).toBe(baseline.protocolVersion) + }) + + it('gets a clean method_not_found from the old dispatcher rather than silence', async () => { + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(baseline, method, paramsFor(method), { + clientKind: 'runtime', + clientCapabilities: current.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0], `${method} on the old host`).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + } + }) + }) + + describe('an old client against a structured-owned AI Vault row', () => { + let root: string + let store: AgentSessionRecordStore + let runtime: Record + let createMobileSessionTerminal: ReturnType + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: NOW, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-vault' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ state: 'accepted' }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-vault', + now: () => NOW + }) + setStructuredAgentSessionHost(host) + const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never) + expect(attached.ok).toBe(true) + createMobileSessionTerminal = vi.fn() + runtime = { + ...(runtimeStub() as Record), + listAiVaultSessions: vi.fn(async () => ({ + sessions: [ + { + id: `local:codex:${THREAD}:/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`, + executionHostId: 'local', + agent: 'codex', + sessionId: THREAD, + title: 'Owned thread', + cwd: '/repo', + branch: null, + model: null, + filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`, + codexHome: '/home/dev/.codex', + createdAt: null, + updatedAt: null, + modifiedAt: '2026-08-11T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: `codex resume '${THREAD}'`, + subagent: null + } + ], + issues: [], + scannedAt: '2026-08-11T00:00:00.000Z' + })), + prepareAiVaultSessionResume: vi.fn(), + createMobileSessionTerminal + } + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('hides the row from the old client and annotates it for a capable client', async () => { + const oldReply = ( + await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'runtime', + clientCapabilities: baseline.capabilities + }, + runtime + ) + )[0] + expect(oldReply).toMatchObject({ ok: true, result: { sessions: [] } }) + + const capableReply = ( + await callBuild( + current, + 'aiVault.listSessions', + {}, + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] + }, + runtime + ) + )[0] + expect(capableReply).toMatchObject({ + ok: true, + result: { + sessions: [ + { + structuredSession: { sessionId: SESSION, workspaceId: WORKSPACE } + } + ] + } + }) + }) + + it('refuses cached prepare and both legacy launch deliveries before a second writer starts', async () => { + const params = { + agent: 'codex', + filePath: `/home/dev/.codex/sessions/rollout-${THREAD}.jsonl`, + codexHome: '/home/dev/.codex' + } + expect( + ( + await callBuild( + current, + 'aiVault.prepareSessionResume', + params, + { + clientKind: 'runtime', + clientCapabilities: baseline.capabilities + }, + runtime + ) + )[0] + ).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } }) + + expect( + ( + await callBuild( + current, + 'session.tabs.createTerminal', + { worktree: `id:${WORKSPACE}`, command: `codex resume '${THREAD}'` }, + { clientKind: 'runtime', clientCapabilities: baseline.capabilities }, + runtime + ) + )[0] + ).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } }) + expect( + ( + await callBuild( + current, + 'terminal.send', + { terminal: 'terminal-1', text: `codex resume '${THREAD}'`, enter: true }, + { clientKind: 'runtime', clientCapabilities: baseline.capabilities }, + runtime + ) + )[0] + ).toMatchObject({ ok: false, error: { code: 'agent_session_conflict' } }) + expect(createMobileSessionTerminal).not.toHaveBeenCalled() + }) + }) + + describe('a cursor across a host restart', () => { + let root: string + let store: AgentSessionRecordStore + let runtime: unknown + + /** Phase 2 owns provider processes; the adapter is the only stub here. */ + function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + } + + /** Reopens the store from disk and installs a fresh host over the same journal + * root — what a process restart actually leaves behind. */ + async function bootHost(generation: string): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation}`, + // The provider died with the host that spawned it, which is what makes + // the restarted host the legitimate next writer. + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + setStructuredAgentSessionHost(host) + return host + } + + type HostAnswer = { + ok: boolean + fence: number + cursor: { epoch: string; sequence: number } + refusal?: { code: string; currentFence?: number } + } + + /** Reattaching after a restart: the client's fence died with the previous + * host, and the refusal that says so is what hands it the live one. */ + async function reattach(staleFence: number): Promise { + const refused = await answer('agentSession.ensure', attachParams(staleFence)) + expect(refused).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + const currentFence = refused.refusal?.currentFence + expect(currentFence).toBeGreaterThan(staleFence) + const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0)) + expect(reattached).toMatchObject({ ok: true }) + return reattached + } + + async function call(method: string, params: unknown): Promise { + return callBuild( + current, + method, + params, + { + clientKind: 'runtime', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientId: 'paired-device-1', + connectionId: 'connection-1' + }, + runtime + ) + } + + /** The host's own answer, which carries its refusals inside a successful RPC. */ + async function answer(method: string, params: unknown): Promise { + const reply = (await call(method, params))[0] + if (!reply?.ok) { + throw new Error(`${method} failed at the wire: ${JSON.stringify(reply?.error ?? reply)}`) + } + return reply.result as HostAnswer + } + + beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) + runtime = runtimeStub() + await bootHost('a') + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('resumes from the cursor the client held, with no snapshot and no replay', async () => { + const created = await answer('agentSession.create', createIntentParams()) + expect(created.ok).toBe(true) + const first = await answer('agentSession.send', sendParams('before restart', created.fence)) + expect(first.ok).toBe(true) + const held = first.cursor + + const restarted = await bootHost('b') + await restarted.restoreReadableSessions() + // Restart restores the session for READING. The chat the client still has open takes its + // hold, and that is what gives the session a provider child again. + await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) + const resumedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(resumedFence).toBeGreaterThan(created.fence) + const second = await answer('agentSession.send', sendParams('after restart', resumedFence)) + expect(second.ok).toBe(true) + + const events = ( + await call('agentSession.subscribe', { sessionId: SESSION, cursor: held }) + ).map((reply) => reply.result as AgentSessionSubscribeEvent) + expect(events.map((event) => event.type)).toEqual(['batch']) + const batch = events[0]?.type === 'batch' ? events[0].batch : null + const rendered = JSON.stringify(batch?.items ?? []) + expect(rendered).toContain('after restart') + // Everything the client already had stays out of the resume. + expect(rendered).not.toContain('before restart') + expect(batch?.cursor.epoch).toBe(held.epoch) + expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) + }) + + it('refuses a write still fenced to the host generation that died', async () => { + const created = await answer('agentSession.create', createIntentParams()) + await bootHost('b') + const reattached = await reattach(created.fence) + expect(reattached.fence).toBeGreaterThan(created.fence) + + expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + }) + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts index 1580f2fccc9..7a55bab6d50 100644 --- a/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts @@ -4,7 +4,11 @@ import { BROWSER_NETWORK_TUNNEL_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { BrowserTabCreateParams } from '../../../src/main/runtime/rpc/methods/browser-tab-create-schema' -import { materializeReleaseCheckout } from './release-checkout' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + type ReleaseCheckout +} from './release-checkout' type Schema = { parse: (value: unknown) => Record } @@ -19,12 +23,13 @@ const BASELINE_TAB_CREATE_SOURCES = [ ['browser-schemas.ts', 'TabCreate'] ] as const -async function importBaselineTabCreate(root: string): Promise { +async function importBaselineTabCreate(checkout: ReleaseCheckout): Promise { const attempted: string[] = [] for (const [file, exportName] of BASELINE_TAB_CREATE_SOURCES) { attempted.push(`${file}#${exportName}`) - const loaded = await import( - /* @vite-ignore */ `${root}/src/main/runtime/rpc/methods/${file}` + const loaded = await importReleaseCheckoutModule( + checkout, + `/src/main/runtime/rpc/methods/${file}` ).catch(() => null) const schema = loaded?.[exportName] as Schema | undefined if (schema?.parse) { @@ -32,7 +37,7 @@ async function importBaselineTabCreate(root: string): Promise { } } throw new Error( - `Baseline release at ${root} exposes no tab-create schema (tried ${attempted.join(', ')}).` + `Baseline release at ${checkout.root} exposes no tab-create schema (tried ${attempted.join(', ')}).` ) } @@ -52,11 +57,11 @@ let baselineProtocol: Record beforeAll(async () => { baselineRef = LEGACY_BROWSER_PLACEMENT_RELEASE_REF - const checkout = materializeReleaseCheckout(baselineRef) + const checkout = await materializeReleaseCheckout(baselineRef) baselineRevision = checkout.commit const [tabCreate, protocol] = await Promise.all([ - importBaselineTabCreate(checkout.root), - import(/* @vite-ignore */ `${checkout.root}/src/shared/protocol-version.ts`) + importBaselineTabCreate(checkout), + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts') ]) baselineTabCreate = tabCreate baselineProtocol = protocol diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts new file mode 100644 index 00000000000..14674bfeefd --- /dev/null +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -0,0 +1,139 @@ +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { dirname, join, relative } from 'node:path' + +const CHECKOUT_PROCESS_TIMEOUT_MS = 45_000 +const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024 + +// Why: the wire endpoints only need the runtime RPC host, the renderer client, and +// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds. +const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types'] + +const ALIAS_SPECIFIER = + /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g + +function isRewritableSource(name: string): boolean { + return name.endsWith('.ts') || name.endsWith('.tsx') +} + +function isTestSource(name: string): boolean { + return /\.(test|bench|spec)\.(ts|tsx)$/.test(name) +} + +/** Keep renderer aliases inside the extracted release rather than the working tree. */ +async function rewriteRendererAliases(file: string, rendererRoot: string): Promise { + const source = await readFile(file, 'utf8') + if (!source.includes("'@/") && !source.includes('"@/') && !source.includes('@renderer/')) { + return false + } + const rewritten = source.replace( + ALIAS_SPECIFIER, + (_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => { + const absolute = join(rendererRoot, target) + let relativePath = relative(dirname(file), absolute).split('\\').join('/') + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}` + } + return `${keyword}${quote}${relativePath}${quote}` + } + ) + if (rewritten === source) { + return false + } + await writeFile(file, rewritten) + return true +} + +async function prepareExtractedTree(root: string): Promise { + const rendererRoot = join(root, 'src', 'renderer', 'src') + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const full = join(directory, entry.name) + if (entry.isDirectory()) { + await walk(full) + continue + } + if (!entry.isFile()) { + continue + } + // Why: stale specs must not enter repo-wide tool walks through the cache. + if (isTestSource(entry.name)) { + await rm(full) + continue + } + if (isRewritableSource(entry.name)) { + await rewriteRendererAliases(full, rendererRoot) + } + } + } + await walk(join(root, 'src')) +} + +function checkoutTarProgram(): string { + if (process.platform !== 'win32') { + return 'tar' + } + const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows' + return join(systemRoot, 'System32', 'tar.exe') +} + +async function runCheckoutProcess( + repoRoot: string, + program: string, + args: string[], + deadline: number +): Promise { + // Kept lazy so plain Node 24 contention children never load Vite's TS graph. + const { runProcess } = await import('../../../src/shared/child-process/run-process') + const result = await runProcess({ + program, + args, + cwd: repoRoot, + timeoutMs: Math.max(1, deadline - Date.now()), + maxOutputBytes: CHECKOUT_MAX_OUTPUT_BYTES, + terminationBarrier: true + }) + if (result.code === 0 && !result.timedOut) { + return + } + const detail = result.timedOut + ? `timed out after ${CHECKOUT_PROCESS_TIMEOUT_MS}ms` + : result.stderr.trim() || `exited with ${result.code}` + throw new Error(`${program} ${args[0] ?? ''} ${detail}`) +} + +export async function extractReleaseCheckoutTree( + repoRoot: string, + staging: string, + commit: string +): Promise { + const archive = join(staging, '.release-checkout.tar') + const deadline = Date.now() + CHECKOUT_PROCESS_TIMEOUT_MS + try { + await runCheckoutProcess( + repoRoot, + 'git', + ['archive', '--format=tar', `--output=${archive}`, commit, '--', ...ARCHIVE_PATHS], + deadline + ) + await runCheckoutProcess( + repoRoot, + checkoutTarProgram(), + ['-xf', archive, '-C', staging], + deadline + ) + } finally { + await rm(archive, { force: true }) + } + await prepareExtractedTree(staging) +} + +export async function scavengeReleaseCheckoutStaging( + directory: string, + prefix: string +): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith(prefix)) { + await rm(join(directory, entry.name), { recursive: true, force: true }) + } + } +} diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 069375f4c18..eafee3802be 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -1,24 +1,18 @@ import { execFileSync } from 'node:child_process' +import { constants } from 'node:fs' +import { access, mkdtemp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { lock } from 'proper-lockfile' import { - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync -} from 'node:fs' -import { dirname, join, relative, resolve } from 'node:path' + extractReleaseCheckoutTree, + scavengeReleaseCheckoutStaging +} from './release-checkout-tree.ts' export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') -const CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') +const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') // Bump when extraction or the alias rewrite changes so cached trees are rebuilt. -const CHECKOUT_FORMAT = 1 - -// Why: the wire endpoints only need the runtime RPC host, the renderer client, and -// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds. -const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types'] +const CHECKOUT_FORMAT = 3 const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ @@ -34,6 +28,56 @@ export type ReleaseCheckout = { root: string } +export type MaterializeReleaseCheckoutOptions = { + cacheRoot?: string + /** Test-only lifecycle seams; production callers must use the defaults. */ + testHooks?: MaterializeReleaseCheckoutTestHooks +} + +export type CheckoutLockOptions = { + realpath: false + stale: number + update: number + retries: { + retries: number + factor: number + minTimeout: number + maxTimeout: number + randomize: boolean + } +} + +type CheckoutLockRelease = () => Promise +type AcquireCheckoutLock = ( + root: string, + options: CheckoutLockOptions +) => Promise + +export type CheckoutLifecycleContext = { + root: string + stagingPrefix: string +} + +export type CheckoutStagingContext = CheckoutLifecycleContext & { + staging: string +} + +export type MaterializeReleaseCheckoutTestHooks = { + lockOptions?: CheckoutLockOptions + acquireLock?: AcquireCheckoutLock + onLockAttempt?: (context: CheckoutLifecycleContext) => void + onLockAcquired?: (context: CheckoutLifecycleContext) => void | Promise + onStagingCreated?: (context: CheckoutStagingContext) => void | Promise + populateStaging?: (context: CheckoutStagingContext) => Promise +} + +const DEFAULT_LOCK_OPTIONS: CheckoutLockOptions = { + realpath: false, + stale: 60_000, + update: 10_000, + retries: { retries: 480, factor: 1, minTimeout: 250, maxTimeout: 250, randomize: true } +} + function git(args: string[]): string { return execFileSync('git', args, { cwd: REPO_ROOT, @@ -112,130 +156,142 @@ function resolveCommit(ref: string): string { } } -function isRewritableSource(name: string): boolean { - return name.endsWith('.ts') || name.endsWith('.tsx') -} - -function isTestSource(name: string): boolean { - return /\.(test|bench|spec)\.(ts|tsx)$/.test(name) -} - -const ALIAS_SPECIFIER = - /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g - -/** - * The extracted tree is imported directly, so `@/…` must resolve inside that tree. - * Vite's alias is global and points at the working tree, which would silently run - * current renderer code inside the "old" client. Rewrite to relative paths instead. - */ -function rewriteRendererAliases(file: string, rendererRoot: string): boolean { - const source = readFileSync(file, 'utf8') - if (!source.includes("'@/") && !source.includes('"@/') && !source.includes('@renderer/')) { - return false - } - const rewritten = source.replace( - ALIAS_SPECIFIER, - (_match, keyword: string, quote: string, _renderer: string | undefined, target: string) => { - const absolute = join(rendererRoot, target) - let relativePath = relative(dirname(file), absolute).split('\\').join('/') - if (!relativePath.startsWith('.')) { - relativePath = `./${relativePath}` - } - return `${keyword}${quote}${relativePath}${quote}` - } - ) - if (rewritten === source) { - return false - } - writeFileSync(file, rewritten) - return true -} - -function prepareExtractedTree(root: string): { rewritten: number; pruned: number } { - const rendererRoot = join(root, 'src', 'renderer', 'src') - let rewritten = 0 - let pruned = 0 - const walk = (directory: string): void => { - for (const entry of readdirSync(directory, { withFileTypes: true })) { - const full = join(directory, entry.name) - if (entry.isDirectory()) { - walk(full) - continue - } - if (!entry.isFile()) { - continue - } - // Why: the old tree is imported, never collected. Dropping its tests keeps the - // cache small and keeps stale specs out of every repo-wide tool's file walk. - if (isTestSource(entry.name)) { - rmSync(full) - pruned++ - continue - } - if (isRewritableSource(entry.name) && rewriteRendererAliases(full, rendererRoot)) { - rewritten++ - } - } - } - walk(join(root, 'src')) - return { rewritten, pruned } -} - type CheckoutStamp = { commit: string; format: number } -function readStamp(root: string): CheckoutStamp | null { +async function readStamp(root: string): Promise { try { - return JSON.parse(readFileSync(join(root, 'checkout-stamp.json'), 'utf8')) as CheckoutStamp + return JSON.parse(await readFile(join(root, 'checkout-stamp.json'), 'utf8')) as CheckoutStamp } catch { return null } } +async function checkoutMatches(root: string, commit: string): Promise { + const stamp = await readStamp(root) + return stamp?.commit === commit && stamp.format === CHECKOUT_FORMAT +} + +async function assertCheckoutWireSurface(root: string, ref: string): Promise { + try { + await access(join(root, 'src', 'shared', 'terminal-stream-protocol.ts'), constants.F_OK) + } catch { + throw new Error( + `Cross-version checkout for ${ref} is missing the terminal stream protocol; ` + + 'the wire surface moved and the harness needs updating.' + ) + } +} + +function checkoutModulePath(checkout: ReleaseCheckout, rootRelativePath: string): string { + const fromRoot = rootRelativePath.replace(/^[/\\]+/, '') + const absolute = resolve(checkout.root, fromRoot) + const fromCheckout = relative(checkout.root, absolute) + if ( + !fromRoot || + fromCheckout === '..' || + fromCheckout.startsWith(`..${sep}`) || + isAbsolute(fromCheckout) + ) { + throw new Error( + `Cross-version module path must stay inside the release checkout: ${rootRelativePath}` + ) + } + return absolute.split('\\').join('/') +} + +/** + * Import a source module with `/src/...` anchored to the extracted release root. + * + * The specifier handed to `importModule` is a raw absolute forward-slash path, + * never a `file://` URL: CI vite-node resolves URL specifiers as root-relative + * ids and fails with `ERR_MODULE_NOT_FOUND` (run 33049571360). `importModule` + * is injectable only so tests can pin that contract deterministically. + */ +export function importReleaseCheckoutModule( + checkout: ReleaseCheckout, + rootRelativePath: string, + importModule: (specifier: string) => Promise> = (specifier) => + import(/* @vite-ignore */ specifier) as Promise> +): Promise> { + return importModule(checkoutModulePath(checkout, rootRelativePath)) +} + /** * Extract `src/` at `ref` into a cached, gitignored checkout the test can import. * Cached by resolved commit, so a moved tag or a bumped rewrite format re-extracts. */ -export function materializeReleaseCheckout(ref: string): ReleaseCheckout { +export async function materializeReleaseCheckout( + ref: string, + options: MaterializeReleaseCheckoutOptions = {} +): Promise { const commit = resolveCommit(ref) const label = ref.replace(/[^A-Za-z0-9._-]/g, '_') - const root = join(CACHE_ROOT, label) - const stamp = readStamp(root) - if (stamp?.commit === commit && stamp.format === CHECKOUT_FORMAT) { + const cacheRoot = options.cacheRoot ?? DEFAULT_CACHE_ROOT + const root = join(cacheRoot, label, `${commit}-format-${CHECKOUT_FORMAT}`) + if (await checkoutMatches(root, commit)) { return { ref, commit, label, root } } - mkdirSync(CACHE_ROOT, { recursive: true }) - const staging = join(CACHE_ROOT, `.staging-${label}-${process.pid}`) - rmSync(staging, { recursive: true, force: true }) - mkdirSync(staging, { recursive: true }) + const stagingPrefix = `.staging-${commit}-format-${CHECKOUT_FORMAT}-` + const lifecycleContext = { root, stagingPrefix } + const hooks = options.testHooks + const lockOptions = hooks?.lockOptions ?? DEFAULT_LOCK_OPTIONS + const acquireLock: AcquireCheckoutLock = + hooks?.acquireLock ?? ((target, value) => lock(target, value)) + await mkdir(dirname(root), { recursive: true }) + let releaseLock: CheckoutLockRelease | undefined try { - // `git archive | tar -x` keeps the extraction independent of the working tree, - // so an injected violation in the working tree cannot leak into the old side. - execFileSync( - 'sh', - ['-c', `git archive ${commit} ${ARCHIVE_PATHS.join(' ')} | tar -x -C "${staging}"`], - { cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'] } - ) - prepareExtractedTree(staging) - writeFileSync( + const acquiring = acquireLock(root, lockOptions) + try { + hooks?.onLockAttempt?.(lifecycleContext) + } catch (error) { + await acquiring.then( + async (release) => release(), + () => undefined + ) + throw error + } + releaseLock = await acquiring + } catch (error) { + if (await checkoutMatches(root, commit)) { + return { ref, commit, label, root } + } + throw new Error(`Cross-version harness could not lock ${ref} (${commit}): ${String(error)}`) + } + + let staging: string | undefined + try { + await hooks?.onLockAcquired?.(lifecycleContext) + if (await checkoutMatches(root, commit)) { + return { ref, commit, label, root } + } + await scavengeReleaseCheckoutStaging(dirname(root), stagingPrefix) + staging = await mkdtemp(join(dirname(root), stagingPrefix)) + const stagingContext = { ...lifecycleContext, staging } + await hooks?.onStagingCreated?.(stagingContext) + await (hooks?.populateStaging + ? hooks.populateStaging(stagingContext) + : extractReleaseCheckoutTree(REPO_ROOT, staging, commit)) + await assertCheckoutWireSurface(staging, ref) + await writeFile( join(staging, 'checkout-stamp.json'), `${JSON.stringify({ commit, format: CHECKOUT_FORMAT } satisfies CheckoutStamp, null, 2)}\n` ) - rmSync(root, { recursive: true, force: true }) - renameSync(staging, root) + await rm(root, { recursive: true, force: true }) + await rename(staging, root) + staging = undefined } catch (error) { - rmSync(staging, { recursive: true, force: true }) - if (readStamp(root)?.commit === commit) { + if (await checkoutMatches(root, commit)) { return { ref, commit, label, root } } throw new Error(`Cross-version harness failed to extract ${ref} (${commit}): ${String(error)}`) + } finally { + if (staging) { + await rm(staging, { recursive: true, force: true }) + } + await releaseLock() } - if (!existsSync(join(root, 'src', 'shared', 'terminal-stream-protocol.ts'))) { - throw new Error( - `Cross-version checkout for ${ref} is missing the terminal stream protocol; ` + - 'the wire surface moved and the harness needs updating.' - ) - } + await assertCheckoutWireSurface(root, ref) return { ref, commit, label, root } } diff --git a/tests/e2e/cross-version-wire/release-checkout.unit.test.ts b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts new file mode 100644 index 00000000000..7057a38babd --- /dev/null +++ b/tests/e2e/cross-version-wire/release-checkout.unit.test.ts @@ -0,0 +1,510 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { lock } from 'proper-lockfile' +import { afterEach, describe, expect, it } from 'vitest' +import { forceTerminateProcessTree } from '../../../src/shared/child-process/process-tree-termination' +import { spawnProcess } from '../../../src/shared/child-process/run-process' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + REPO_ROOT, + type CheckoutLockOptions, + type CheckoutStagingContext, + type ReleaseCheckout +} from './release-checkout' +const temporaryRoots: string[] = [] + +const COMPRESSED_LOCK_OPTIONS: CheckoutLockOptions = { + realpath: false, + stale: 2_500, + update: 1_000, + retries: { retries: 200, factor: 1, minTimeout: 50, maxTimeout: 50, randomize: false } +} + +function temporaryCacheRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-cross-version-checkout-')) + temporaryRoots.push(root) + return root +} + +function git(args: string[]): string { + return execFileSync('git', args, { cwd: REPO_ROOT, encoding: 'utf8' }).trim() +} + +function syntheticCheckout(): ReleaseCheckout { + // Why realpath: vite-node reports module urls through macOS's /var -> /private/var + // symlink, so provenance assertions need the resolved form. + const root = realpathSync(temporaryCacheRoot()) + return { ref: 'v0.0.0-synthetic', commit: 'f'.repeat(40), label: 'v0.0.0-synthetic', root } +} + +function waitForCondition( + description: string, + condition: () => boolean, + timeoutMs: number +): Promise { + const startedAt = Date.now() + return new Promise((resolvePoll, rejectPoll) => { + const poll = (): void => { + if (condition()) { + resolvePoll() + return + } + if (Date.now() - startedAt > timeoutMs) { + rejectPoll(new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`)) + return + } + setTimeout(poll, 25) + } + poll() + }) +} + +function waitForFile(path: string, timeoutMs: number): Promise { + return waitForCondition(path, () => existsSync(path), timeoutMs) +} + +async function populateMinimalStaging({ staging }: CheckoutStagingContext): Promise { + const shared = join(staging, 'src', 'shared') + mkdirSync(shared, { recursive: true }) + writeFileSync(join(shared, 'terminal-stream-protocol.ts'), 'export const synthetic = true\n') +} + +type MaterializerChildConfig = { + cacheRoot: string + ref: string + resultPath?: string + attemptMarker?: string + acquiredMarker?: string + stagingMarker?: string + proceedPath?: string + ablateLock?: boolean + populateStaging?: boolean + hangAfterStaging?: boolean + lockOptions?: CheckoutLockOptions +} + +type ObservedMaterializerChild = { + child: ReturnType + exited: Promise + output: () => string +} + +function startMaterializerChild( + scratch: string, + name: string, + config: MaterializerChildConfig +): ObservedMaterializerChild { + const script = join(scratch, `${name}.mjs`) + const harnessUrl = new URL('./release-checkout.ts', import.meta.url).href + writeFileSync( + script, + [ + `const { existsSync, mkdirSync, writeFileSync } = await import('node:fs')`, + `const { join } = await import('node:path')`, + `const harness = await import(${JSON.stringify(harnessUrl)})`, + `const config = ${JSON.stringify(config)}`, + `const waitForPath = async (path) => {`, + ` const startedAt = Date.now()`, + ` while (!existsSync(path)) {`, + ` if (Date.now() - startedAt > 30000) throw new Error('timed out waiting for ' + path)`, + ` await new Promise((resolve) => setTimeout(resolve, 10))`, + ` }`, + `}`, + `const hooks = { lockOptions: config.lockOptions }`, + `if (config.ablateLock) hooks.acquireLock = async () => async () => {}`, + `if (config.attemptMarker) hooks.onLockAttempt = () => writeFileSync(config.attemptMarker, '')`, + `if (config.acquiredMarker) hooks.onLockAcquired = () => writeFileSync(config.acquiredMarker, '')`, + `if (config.stagingMarker) {`, + ` hooks.onStagingCreated = async ({ root, staging }) => {`, + ` writeFileSync(config.stagingMarker, JSON.stringify({ root, staging }))`, + ` if (config.hangAfterStaging) await new Promise(() => setInterval(() => {}, 1000))`, + ` if (config.proceedPath) await waitForPath(config.proceedPath)`, + ` }`, + `}`, + `if (config.populateStaging) {`, + ` hooks.populateStaging = async ({ staging }) => {`, + ` const shared = join(staging, 'src', 'shared')`, + ` mkdirSync(shared, { recursive: true })`, + ` writeFileSync(join(shared, 'terminal-stream-protocol.ts'), 'export const synthetic = true\\n')`, + ` }`, + `}`, + `try {`, + ` const checkout = await harness.materializeReleaseCheckout(config.ref, { cacheRoot: config.cacheRoot, testHooks: hooks })`, + ` if (config.resultPath) writeFileSync(config.resultPath, JSON.stringify({ root: checkout.root }))`, + `} catch (error) {`, + ` if (config.resultPath) writeFileSync(config.resultPath, JSON.stringify({ error: String(error) }))`, + ` process.exitCode = 1`, + `}`, + '' + ].join('\n') + ) + + const child = spawnProcess({ + program: process.execPath, + args: [script], + cwd: REPO_ROOT, + env: { ...process.env, NODE_OPTIONS: '' }, + terminationBarrier: true + }) + let childOutput = '' + child.stdout.on('data', (chunk) => { + childOutput += String(chunk) + }) + child.stderr.on('data', (chunk) => { + childOutput += String(chunk) + }) + const exited = new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit) + child.once('close', resolveExit) + }) + return { child, exited, output: () => childOutput } +} + +async function stopMaterializerChild(observed: ObservedMaterializerChild): Promise { + if (observed.child.exitCode === null && observed.child.signalCode === null) { + await forceTerminateProcessTree(observed.child) + } + await observed.exited.catch(() => null) +} + +async function runContentionPhase( + published: ReleaseCheckout, + scratch: string, + phase: string, + ablateLock: boolean +): Promise { + const sentinel = join(published.root, `in-use-sentinel-${phase}.mjs`) + const aside = join(scratch, `published-aside-${phase}`) + const attemptMarker = join(scratch, `rival-attempted-${phase}`) + const acquiredMarker = join(scratch, `rival-acquired-${phase}`) + const stagingMarker = join(scratch, `rival-staging-${phase}`) + const proceedPath = join(scratch, `rival-proceed-${phase}`) + const resultPath = join(scratch, `rival-result-${phase}.json`) + writeFileSync(sentinel, "export const sentinel = 'published-tree'\n") + renameSync(published.root, aside) + const releaseLock = await lock(published.root, { realpath: false, stale: 60_000 }) + let released = false + let rival: ObservedMaterializerChild | undefined + const releaseOnce = async (): Promise => { + if (!released) { + released = true + await releaseLock() + } + } + + try { + rival = startMaterializerChild(scratch, `rival-${phase}`, { + cacheRoot: join(published.root, '..', '..'), + ref: published.ref, + resultPath, + attemptMarker, + acquiredMarker, + ...(ablateLock ? { ablateLock, stagingMarker, proceedPath, populateStaging: true } : {}) + }) + // onLockAttempt runs only after the rival's first stamp miss and invocation + // of the actual lock function; no elapsed-time guess stands in for contention. + await waitForFile(ablateLock ? stagingMarker : attemptMarker, 30_000) + if (!ablateLock) { + expect(existsSync(acquiredMarker), rival.output()).toBe(false) + } + + renameSync(aside, published.root) + const consuming = importReleaseCheckoutModule(published, `/in-use-sentinel-${phase}.mjs`).then( + (value) => value, + (error: unknown) => error + ) + if (ablateLock) { + // The staging marker is after the second stamp miss. Publishing before this + // acknowledgement would let the ablation pass without exercising deletion. + writeFileSync(proceedPath, '') + } + await releaseOnce() + + const exitCode = await rival.exited + const result = JSON.parse(readFileSync(resultPath, 'utf8')) as Record + expect(result, rival.output()).toEqual({ root: published.root }) + expect(exitCode, rival.output()).toBe(0) + expect(existsSync(acquiredMarker), rival.output()).toBe(true) + const consumerResult = await consuming + if (!ablateLock) { + expect(consumerResult).toMatchObject({ sentinel: 'published-tree' }) + } + return existsSync(sentinel) + } finally { + if (rival) { + await stopMaterializerChild(rival) + } + if (existsSync(aside) && !existsSync(published.root)) { + renameSync(aside, published.root) + } + await releaseOnce() + } +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +describe('release checkout materialization', () => { + it('single-flights concurrent consumers of one release identity', async () => { + const cacheRoot = temporaryCacheRoot() + const checkouts = await Promise.all([ + materializeReleaseCheckout('v1.4.190', { cacheRoot }), + materializeReleaseCheckout('v1.4.190', { cacheRoot }), + materializeReleaseCheckout('v1.4.190', { cacheRoot }) + ]) + + expect(new Set(checkouts.map(({ root }) => root))).toHaveLength(1) + expect(relative(cacheRoot, checkouts[0]!.root)).not.toMatch(/^\.\./) + }) + + it('loads a baseline module whose source imports another checkout-root file', async () => { + const cacheRoot = temporaryCacheRoot() + const checkout = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) + const protocol = await importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts') + + expect(protocol.REMOTE_SERVER_UPDATE_CAPABILITY).toBe('updater.remote-control.v1') + expect(relative(cacheRoot, checkout.root)).not.toMatch(/^\.\./) + }) + + it('keeps an import live while another colliding release label materializes', async () => { + const merge = git(['rev-list', '--merges', '-1', 'HEAD']) + const firstRef = `${merge}~2` + const secondRef = `${merge}^2` + expect(git(['rev-parse', `${firstRef}^{commit}`])).not.toBe( + git(['rev-parse', `${secondRef}^{commit}`]) + ) + + const cacheRoot = temporaryCacheRoot() + const first = await materializeReleaseCheckout(firstRef, { cacheRoot }) + const dependency = join(first.root, 'delayed-dependency.mjs') + const entry = join(first.root, 'delayed-entry.mjs') + writeFileSync(dependency, "export const loaded = 'first-release'\n") + writeFileSync( + entry, + 'await new Promise((resolve) => setTimeout(resolve, 100))\n' + + "export const loaded = (await import('./delayed-dependency.mjs')).loaded\n" + ) + + const loading = importReleaseCheckoutModule(first, '/delayed-entry.mjs') + const second = await materializeReleaseCheckout(secondRef, { cacheRoot }) + + await expect(loading).resolves.toMatchObject({ loaded: 'first-release' }) + expect(first.root).not.toBe(second.root) + }) + + it('causally single-flights a rival process before publishing an in-use checkout', async () => { + const cacheRoot = temporaryCacheRoot() + const scratch = temporaryCacheRoot() + const published = await materializeReleaseCheckout('v1.4.190', { cacheRoot }) + + await expect(runContentionPhase(published, scratch, 'locked', false)).resolves.toBe(true) + // In the same causally acknowledged interleaving, a no-lock materializer + // deletes the newly published tree. This makes the lock assertion non-vacuous. + await expect(runContentionPhase(published, scratch, 'ablated', true)).resolves.toBe(false) + }, 120_000) + + it('keeps the real lock live while publication work exceeds its stale interval', async () => { + const cacheRoot = temporaryCacheRoot() + const scratch = temporaryCacheRoot() + const attemptMarker = join(scratch, 'heartbeat-rival-attempted') + const acquiredMarker = join(scratch, 'heartbeat-rival-acquired') + const resultPath = join(scratch, 'heartbeat-rival-result.json') + let releaseWork!: () => void + const workGate = new Promise((resolveWork) => { + releaseWork = resolveWork + }) + let acknowledgeStaging!: (context: CheckoutStagingContext) => void + const stagingReady = new Promise((resolveStaging) => { + acknowledgeStaging = resolveStaging + }) + const publisher = materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { + lockOptions: COMPRESSED_LOCK_OPTIONS, + onStagingCreated: async (context) => { + acknowledgeStaging(context) + await workGate + }, + populateStaging: populateMinimalStaging + } + }) + const active = await stagingReady + const rival = startMaterializerChild(scratch, 'heartbeat-rival', { + cacheRoot, + ref: 'v1.4.190', + resultPath, + attemptMarker, + acquiredMarker, + lockOptions: COMPRESSED_LOCK_OPTIONS + }) + + try { + await waitForFile(attemptMarker, 30_000) + const blockedAt = Date.now() + const mtimes = new Set() + await waitForCondition( + 'multiple lock heartbeats beyond the stale interval', + () => { + if (existsSync(`${active.root}.lock`)) { + mtimes.add(statSync(`${active.root}.lock`).mtimeMs) + } + return Date.now() - blockedAt > COMPRESSED_LOCK_OPTIONS.stale + 250 && mtimes.size >= 3 + }, + 15_000 + ) + expect(existsSync(acquiredMarker), rival.output()).toBe(false) + expect(existsSync(active.staging)).toBe(true) + + releaseWork() + await publisher + const exitCode = await rival.exited + expect(exitCode, rival.output()).toBe(0) + expect(existsSync(acquiredMarker), rival.output()).toBe(true) + expect(JSON.parse(readFileSync(resultPath, 'utf8')), rival.output()).toEqual({ + root: active.root + }) + } finally { + releaseWork() + await stopMaterializerChild(rival) + await publisher.catch(() => undefined) + } + }, 30_000) + + it('recovers a crashed lock owner and scavenges only its orphaned staging trees', async () => { + const cacheRoot = temporaryCacheRoot() + const scratch = temporaryCacheRoot() + const stagingMarker = join(scratch, 'crashed-staging') + const crashed = startMaterializerChild(scratch, 'crashing-publisher', { + cacheRoot, + ref: 'v1.4.190', + stagingMarker, + hangAfterStaging: true, + lockOptions: COMPRESSED_LOCK_OPTIONS + }) + + try { + await waitForFile(stagingMarker, 30_000) + const crashedContext = JSON.parse(readFileSync(stagingMarker, 'utf8')) as { + root: string + staging: string + } + const lockPath = `${crashedContext.root}.lock` + expect(existsSync(crashedContext.staging)).toBe(true) + expect(existsSync(lockPath)).toBe(true) + await forceTerminateProcessTree(crashed.child) + const crashExit = await crashed.exited + expect(crashExit, crashed.output()).not.toBe(0) + expect(existsSync(lockPath)).toBe(true) + + const unrelated = join(crashedContext.staging, '..', '.staging-unrelated-live-owner') + mkdirSync(unrelated) + const recovered = await materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { + lockOptions: COMPRESSED_LOCK_OPTIONS, + populateStaging: populateMinimalStaging + } + }) + + expect(existsSync(crashedContext.staging)).toBe(false) + expect(existsSync(unrelated)).toBe(true) + expect(existsSync(join(recovered.root, 'src', 'shared', 'terminal-stream-protocol.ts'))).toBe( + true + ) + } finally { + await stopMaterializerChild(crashed) + } + }, 30_000) + + it('never stamps an incomplete tree produced through the injectable test seam', async () => { + const cacheRoot = temporaryCacheRoot() + await expect( + materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { populateStaging: async () => undefined } + }) + ).rejects.toThrow(/missing the terminal stream protocol/) + + let populated = 0 + const recovered = await materializeReleaseCheckout('v1.4.190', { + cacheRoot, + testHooks: { + populateStaging: async (context) => { + populated++ + await populateMinimalStaging(context) + } + } + }) + expect(populated).toBe(1) + expect(existsSync(join(recovered.root, 'src', 'shared', 'terminal-stream-protocol.ts'))).toBe( + true + ) + }) +}) + +describe('release checkout module importer', () => { + it('hands the importer a raw absolute forward-slash specifier, never a file URL', async () => { + const checkout = syntheticCheckout() + const captured: string[] = [] + const capture = (specifier: string): Promise> => { + captured.push(specifier) + return Promise.resolve({}) + } + + await importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/dispatcher.ts', capture) + await importReleaseCheckoutModule(checkout, '\\src\\shared\\protocol-version.ts', capture) + + const normalizedRoot = checkout.root.split('\\').join('/') + expect(captured).toEqual([ + `${normalizedRoot}/src/main/runtime/rpc/dispatcher.ts`, + `${normalizedRoot}/src/shared/protocol-version.ts` + ]) + for (const specifier of captured) { + expect(specifier).not.toMatch(/^file:/) + expect(specifier).not.toContain('\\') + } + }) + + it('refuses module paths that escape the checkout root', () => { + const checkout = syntheticCheckout() + const escape = /stay inside the release checkout/ + expect(() => importReleaseCheckoutModule(checkout, '/src/../../escape.ts')).toThrow(escape) + expect(() => importReleaseCheckoutModule(checkout, '..')).toThrow(escape) + expect(() => importReleaseCheckoutModule(checkout, '')).toThrow(escape) + }) + + it('anchors root-relative modules to the checkout root, never the working tree', async () => { + const checkout = syntheticCheckout() + mkdirSync(join(checkout.root, 'src'), { recursive: true }) + writeFileSync( + join(checkout.root, 'src', 'provenance-probe.mjs'), + 'export const moduleUrl = import.meta.url\n' + ) + + const probe = await importReleaseCheckoutModule(checkout, '/src/provenance-probe.mjs') + expect(String(probe.moduleUrl)).toContain(checkout.root.split('\\').join('/')) + + // The working tree has this module and the synthetic checkout does not: + // resolving it would mean a root-relative specifier silently ran current + // code as the "old" side — the exact poison this harness exists to prevent. + await expect( + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts') + ).rejects.toThrow() + }) +}) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts new file mode 100644 index 00000000000..420efcbd994 --- /dev/null +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -0,0 +1,155 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + REPO_ROOT, + type ReleaseCheckout +} from './release-checkout' + +/** + * The two things that decide whether a structured agent session exists for a given + * pairing: the capability strings a build can name, and the RPC methods it + * registers. Both are read per build, so "the old side does not have it" is a fact + * about a real release rather than a hand-written list. + */ + +export const WORKING_TREE = 'working-tree' as const + +export type RpcReply = { + id: string + ok: boolean + streaming?: true + result?: unknown + error?: { code: string; message: string } +} + +export type RpcClientIdentity = { + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly string[] + connectionId?: string + clientId?: string +} + +export type AgentSessionDispatcher = { + dispatchStreaming: ( + request: { id: string; authToken: string; method: string; params?: unknown }, + reply: (message: string) => void, + options?: RpcClientIdentity + ) => Promise +} + +export type AgentSessionWireBuild = { + /** Human label used in test names and failure messages. */ + label: string + /** `working-tree` for current code, otherwise the resolved release commit. */ + revision: string + /** Capability strings this build defines. A peer cannot advertise — nor a client + * ask for — a string its own source never names. */ + capabilities: readonly string[] + protocolVersion: number + /** RPC method names the build registers, read from source. */ + methodNames: readonly string[] + /** A dispatcher carrying a method set this build really ships, so an + * unknown-method answer is about the method and not an empty registry. */ + createDispatcher: (runtime: unknown) => AgentSessionDispatcher +} + +type DispatcherModule = { + RpcDispatcher: new (options: { runtime: unknown; methods: unknown[] }) => AgentSessionDispatcher +} + +// A dotted literal in a `name:` position. Deliberately loose: over-matching only +// makes "this build registers no agentSession method" a stronger claim. +const METHOD_NAME = /\bname:\s*'([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)'/g + +/** + * Method names declared under `runtime/rpc/methods`, scanned rather than imported: + * a released build's method manifest reaches Electron, which cannot load here. + */ +function scanMethodNames(root: string): string[] { + const names = new Set() + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = join(directory, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + for (const match of readFileSync(full, 'utf8').matchAll(METHOD_NAME)) { + names.add(match[1]!) + } + } + } + } + walk(join(root, 'src', 'main', 'runtime', 'rpc', 'methods')) + return [...names].sort() +} + +function capabilityStrings(module: Record): readonly string[] { + const declared = module.RUNTIME_CAPABILITIES + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error('Cross-version harness found no RUNTIME_CAPABILITIES to compare') + } + return declared as readonly string[] +} + +async function loadWorkingTreeBuild(): Promise { + const [protocol, dispatcher, structured, aiVault, sessionTabs, terminal] = await Promise.all([ + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/rpc/dispatcher'), + import('../../../src/main/runtime/rpc/methods/structured-agent-session'), + import('../../../src/main/runtime/rpc/methods/ai-vault'), + import('../../../src/main/runtime/rpc/methods/session-tabs'), + import('../../../src/main/runtime/rpc/methods/terminal') + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: WORKING_TREE, + revision: WORKING_TREE, + capabilities: capabilityStrings(protocol as unknown as Record), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, + methodNames: scanMethodNames(REPO_ROOT), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: [ + ...(structured.STRUCTURED_AGENT_SESSION_METHODS as unknown[]), + ...(aiVault.AI_VAULT_METHODS as unknown[]), + ...(sessionTabs.SESSION_TAB_METHODS as unknown[]), + ...(terminal.TERMINAL_METHODS as unknown[]) + ] + }) + } +} + +async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const [protocol, dispatcher, terminalMethods] = await Promise.all([ + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/dispatcher.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/methods/terminal.ts') + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: checkout.ref, + revision: checkout.commit, + capabilities: capabilityStrings(protocol), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION as number, + methodNames: scanMethodNames(checkout.root), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: terminalMethods.TERMINAL_METHODS as unknown[] + }) + } +} + +/** + * Load the structured-session wire surface for one build. `WORKING_TREE` imports + * current source; any other value is a git ref extracted into a cached checkout. + */ +export async function loadAgentSessionWireBuild(ref: string): Promise { + if (ref === WORKING_TREE) { + return loadWorkingTreeBuild() + } + return loadReleaseBuild(await materializeReleaseCheckout(ref)) +} diff --git a/tests/e2e/cross-version-wire/versioned-terminal-wire.ts b/tests/e2e/cross-version-wire/versioned-terminal-wire.ts index 6a0fd0f467b..08e721900d6 100644 --- a/tests/e2e/cross-version-wire/versioned-terminal-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-terminal-wire.ts @@ -1,4 +1,8 @@ -import { materializeReleaseCheckout, type ReleaseCheckout } from './release-checkout' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + type ReleaseCheckout +} from './release-checkout' /** * Structural views of the three modules that make up the remote terminal wire. @@ -112,19 +116,15 @@ async function loadWorkingTreeBuild(): Promise { } } -// Why @vite-ignore: the checkout is created at run time, so Vite cannot glob it at -// transform time. Vite-node still resolves and transforms the target on demand. -function importFromCheckout(specifier: string): Promise> { - return import(/* @vite-ignore */ specifier) as Promise> -} - async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { - const base = `${checkout.root}/src` const [codec, dispatcher, terminalMethods, client] = await Promise.all([ - importFromCheckout(`${base}/shared/terminal-stream-protocol.ts`), - importFromCheckout(`${base}/main/runtime/rpc/dispatcher.ts`), - importFromCheckout(`${base}/main/runtime/rpc/methods/terminal.ts`), - importFromCheckout(`${base}/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts`) + importReleaseCheckoutModule(checkout, '/src/shared/terminal-stream-protocol.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/dispatcher.ts'), + importReleaseCheckoutModule(checkout, '/src/main/runtime/rpc/methods/terminal.ts'), + importReleaseCheckoutModule( + checkout, + '/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' + ) ]) return { label: checkout.ref, @@ -147,5 +147,5 @@ export async function loadTerminalWireBuild(ref: string): Promise { const root = join('workspace', 'orca') const mainPath = join(root, 'out', 'main', 'index.js') - expect(getOrcaElectronLaunchArgs(mainPath, true)).toEqual([root]) + const args = getOrcaElectronLaunchArgs(mainPath, true) + expect(args.at(-1)).toBe(root) + if (process.platform === 'darwin') { + expect(args.slice(0, -1)).toEqual(['--password-store=basic', '--use-mock-keychain']) + } expect(getOrcaElectronLaunchArgs(mainPath, false).at(-1)).toBe(root) }) })