From d17351401d7372f3f48cc3cdb423147d8c8e61c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:43:43 +0000 Subject: [PATCH 1/9] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index fa480fb7498..8d69c2e1d64 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 92m + + downloads: 93m @@ -15,7 +15,7 @@ downloads downloads - 92m - 92m + 93m + 93m From 2a68ea94967caf926aa60ebbd2bb32797bebb146 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:39:21 -0400 Subject: [PATCH 2/9] refactor(terminal): one commit module for terminal topology closes, with a boundary check (#25329) * refactor(terminal): move the topology revision and leaf-lookup helpers into terminal-topology advanceTerminalTopologyRevision, findTerminalTabIdForLeaf and hasHostAuthoritativeTerminalMembership move verbatim from the renderer-save membership rebase into persistence/terminal-topology, the home of the commit boundary. Importers are repointed; no behavior change. * test(terminal): test-only guard for topology writes outside the commit boundary The three session sinks now publish through one commitWorkspaceSessionPartition helper, which hands the prior and published partition to the topology write guard. Production never arms the guard, so each sink pays one global lookup. The unit suite arms it in report mode: a sink write that changes class-(a) topology (membership, root, bindings, titles, incarnations, sleeping records, remote session ids, tombstones, default-applied, revision) outside a commit scope is attributed to its writer's file by stack, and fails the test unless the writer is on the unrouted-writers allowlist that later routing PRs shrink. Renderer saves and test seeding are exempt. Deep-freeze is available but stays off suite-wide until the in-place writers return new sessions. * refactor(terminal): add the topology commit module with bindLeaf, closeLeaf and closeTab terminal-topology-commit.ts is the boundary for class-(a) terminal topology. bindLeaf forwards to persistPtyBinding, whose write now runs in a commit scope; the spawn commits and the relay reattach bind through it. closeLeaf and closeTab wrap the existing close mutation in a commit scope and one persistence.terminal-topology span (kind, outcome, refusal reason; no ids), and the runtime close goes through them. Session output is unchanged: tests compare it byte for byte with the old writers for local, ssh: and folder workspaces. * chore(terminal): drop an unused lint suppression from the topology write guard * fix(terminal): attribute topology guard writers relative to the repo root The guard read a frame's file through its last /src/ segment, so a test under tests/ (folder-upgrade-identity-persistence.unit.test.ts) had no source frame and failed as an unknown writer. Frames are now taken relative to the repo root the setup passes in, tests/ counts as test seeding, Windows backslashes are normalized before the node_modules skip, and nested src/ paths keep their full path. The two runtime funnel files skip only their funnel function, so another writer in them still shows. The R9 allowlist key names the file whose frame actually writes. The class-(a) diff and the attribution move into their own files; the stack limit is restored in a finally. * refactor(terminal): drop bindLeaf until binding reaches a sink; add the boundary ratchet bindLeaf and the commit scope inside persistPtyBinding changed nothing: the binding write never reaches a session sink, and a scope inside the Store method would have admitted every direct caller once it did. Both return in B1-4; the spawn commits and the relay reattach call persistPtyBinding directly again. The runtime close now calls one closeLeafOrTab entry, so its callbacks keep their contextual types. A census test is the primary enforcement: only persistence/terminal-topology and the callers it lists may call persistPtyBinding, the session setters or the three sinks, and every unrouted-writer allowlist entry must name an existing file. * fix(test): resolve the topology guard's repo root without the global URL Under happy-dom the global URL is not Node's, so fileURLToPath(new URL(...)) threw in the setup file and failed every happy-dom test file. * refactor(terminal): drop the runtime topology write guard; the boundary ratchet enforces The AST boundary ratchet is the enforcement for B1. The stack-attributed runtime guard, its class-(a) diff, the unrouted-writer allowlist, the vitest setup and the freeze option are removed; it saw two writers in the whole unit suite and its real value starts only once binding reaches a sink (B1-4). Also: one closeLeafOrTab wraps the close in the span (no per-kind copies or narrowed types), the span has one finish like persistence.pty-binding, the sink helper is publishWorkspaceSessionPartition (it publishes; the commit boundary is the module), the ratchet drops the private publishSession row and checks that every listed caller file exists, and the close comparison keeps its two meaningful cases with span cases chosen by name. * refactor(terminal): trim the B1-1 commit module and ratchet to what they enforce - Point the acknowledged-tab-retirement audit fixture at the moved advanceTerminalTopologyRevision; its old import no longer resolved. - Drop publishWorkspaceSessionPartition: it was the removed guard's interception point, so the three session sinks return to origin/main. - One traced(kind, mutate) wrapper in the commit file replaces the span factory; closeLeafOrTab is one call. - The ratchet walks src/main with the shared scanSourceTree, drops the loading-store-internal rows and the redundant file-exists test; exact-set equality already fails on a missing file. - The commit test is a pure unit test of the span outcomes: no Store harness, electron mock or self-comparing close. * refactor(terminal): census the runtime session controller's write and drop stage ids from comments The controller's setter was named set, which the boundary census could not list without matching every Map.set, so a new OrcaRuntime mixin could write sessions through it unseen. Rename it setForWorktree and census it. Comments now describe state instead of citing plan stage ids. * refactor(terminal): census writer references and trace refusals by callback - traced() takes refusalOf instead of assuming an Error refusal, and only mutate() sits in the try, so a span outcome of threw means the write threw. - The boundary ratchet counts references, not just direct calls: non-null calls, bracket keys, aliases, destructures, .call/.bind and parenthesized callees all count; declared names and type positions do not. - Census terminalSurfaceCloseMutation (boundary-only) and the partition sinks setLocalWorkspaceSession / setHostWorkspaceSession. * test(terminal): count writer uses in extends clauses and instantiations, skip type-only imports and local declarations The census skipped ExpressionWithTypeArguments as a type, which also holds `extends f(x)` and `x` value expressions. Type-only import/export specifiers and declared names (variables, parameters, accessors, enum members) no longer count as uses. The audit fixture is listed in the table instead of a separate exemption. * test(terminal): count quoted and assignment-pattern destructures of layout writers * test(terminal): count every mention of a layout writer except its definition Telling definitions from uses per syntax kind kept missing nested and for-of destructures. Exempt only the writer's own function or class-member definition; any other mention (including object-literal keys) counts, so the census errs toward a loud false alarm rather than a silent miss. Quoted names count only in member-name position. * test(terminal): count every string literal naming a layout writer Member-name positions missed wrapped keys like store[('name')] and store['name' as const]. Counting every string literal outside types is shorter and errs toward a loud false alarm. * test(terminal): exempt only class members and functions as writer definitions Object-literal methods and accessors were exempt while equivalent arrow properties counted; all object-literal keys now count alike. * test(terminal): parse files with unicode escapes in the writer census A name spelled with a \u escape never appears verbatim, so the text prefilter skipped it. * test(terminal): parse any file with an escape in the writer census \x, identity and line-continuation escapes also decode to a writer name without it appearing verbatim. --- .../fixture.test.ts | 2 +- ...ce-pty-binding-leaf-tab-resolution.test.ts | 2 +- ...terminal-topology-boundary-ratchet.test.ts | 133 +++++++++++++++ .../terminal-topology-commit.test.ts | 152 ++++++++++++++++++ .../terminal-topology-commit.ts | 57 +++++++ .../terminal-topology-membership.ts | 56 +++++++ ...wledged-terminal-tab-retirement-fixture.ts | 2 +- ...knowledged-terminal-tab-retirement.test.ts | 2 +- ...session-terminal-persistence-retirement.ts | 6 +- ...ld-headless-mobile-session-browser-tabs.ts | 4 +- .../runtime/orca-runtime-get-runtime-id.ts | 2 +- ...le-headless-mobile-session-browser-tabs.ts | 2 +- ...rca-runtime-terminal-close-records.test.ts | 2 +- ...rca-runtime-terminal-surface-close.test.ts | 2 +- ...-touch-mobile-session-tabs-for-worktree.ts | 2 +- ...untime-terminal-orphan-session-adoption.ts | 2 +- .../runtime-workspace-session-controller.ts | 2 +- .../terminal-intentional-stop-exit.test.ts | 2 +- .../terminal-leaf-tab-resolution.test.ts | 2 +- src/main/runtime/terminal-surface-close.ts | 2 +- ...e-session-terminal-membership-authority.ts | 54 ------- .../ssh-reattach-pane-cardinality.test.ts | 2 +- src/main/ssh/ssh-relay-session.ts | 2 +- 23 files changed, 418 insertions(+), 76 deletions(-) create mode 100644 src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-commit.test.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-commit.ts create mode 100644 src/main/persistence/terminal-topology/terminal-topology-membership.ts diff --git a/docs/audits/acknowledged-tab-retirement/fixture.test.ts b/docs/audits/acknowledged-tab-retirement/fixture.test.ts index b7eff444c5d..4036c246734 100644 --- a/docs/audits/acknowledged-tab-retirement/fixture.test.ts +++ b/docs/audits/acknowledged-tab-retirement/fixture.test.ts @@ -13,7 +13,7 @@ import { buildWorkspaceSessionPayload } from '../../../src/renderer/src/lib/work import { buildHeadlessMobileSessionTerminalTabs } from '../../../src/main/runtime/mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from '../../../src/main/runtime/runtime-desktop-surface' import { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' -import { advanceTerminalTopologyRevision } from '../../../src/main/runtime/workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../../../src/main/persistence/terminal-topology/terminal-topology-membership' import type { ExecutionHostId } from '../../../src/shared/execution-host' class AuditRuntime extends OrcaRuntimeService { diff --git a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts index 14ac4de0a12..5fce5868848 100644 --- a/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts +++ b/src/main/persistence-pty-binding-leaf-tab-resolution.test.ts @@ -9,7 +9,7 @@ import { rmSync, mkdtempSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { getDefaultWorkspaceSession } from '../shared/constants' -import { findTerminalTabIdForLeaf } from './runtime/workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from './persistence/terminal-topology/terminal-topology-membership' import { TEST_LEAF_1, TEST_LEAF_2 } from './persistence-session-fixtures' diff --git a/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts new file mode 100644 index 00000000000..27aa67c7d6e --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-boundary-ratchet.test.ts @@ -0,0 +1,133 @@ +/** + * Ratchet: outside `persistence/terminal-topology/`, only the files listed here may reference a + * named layout writer. Blind to in-place mutation of the object `getWorkspaceSession` returns. + */ +import { resolve } from 'node:path' +import ts from 'typescript-api' +import { describe, expect, it } from 'vitest' +import { scanSourceTree } from '../../../shared/source-scan/source-tree-scan' + +const MAIN_ROOT = resolve(__dirname, '../..') +const BOUNDARY_DIR = 'persistence/terminal-topology/' + +/** + * Files (relative to `src/main`) outside the boundary referencing each writer; each routing change + * deletes its own rows. Not listed: lifecycle writers (repo/worktree removal, identity rekey) and + * SSH lease marks that drop a dead PTY's binding; they delete or rekey layout, never choose it. + */ +const ALLOWED_REFERENCES: Record = { + terminalSurfaceCloseMutation: [], + persistPtyBinding: [ + 'ipc/pty/ipc/spawn-commit-persist.ts', + 'ipc/pty/pane/stable-owner.ts', + 'ipc/pty/runtime/spawn-commit.ts', + 'ssh/ssh-relay-session.ts' + ], + // Several runtime files only check it exists, then write through setWorkspaceSessionForWorktree. + setWorkspaceSession: [ + 'ipc/pty/pane/stable-owner.ts', + 'ipc/session.ts', + // Store-internal: patchWorkspaceSession -> setWorkspaceSession. + 'persistence/loading-store/session-snapshot-operations.ts', + // Test support: seeds sessions for the acknowledged-tab retirement audit. + 'runtime/acknowledged-terminal-tab-retirement-fixture.ts', + 'runtime/client-hosted-browser-page-persistence.ts', + 'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts', + 'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts', + 'runtime/orca-runtime-attach-window.ts', + 'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts', + 'runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'runtime/orca-runtime-persist-headless-session-tab-props.ts', + 'runtime/orca-runtime-persist-headless-terminal-title.ts', + 'runtime/orca-runtime-persist-terminal-surface-retirements.ts', + 'runtime/orca-runtime-pty-foreground-process-reads.ts', + 'runtime/orca-runtime-stop-terminals-for-worktree.ts', + 'runtime/runtime-legacy-worker-terminal-recovery-persistence.ts', + 'runtime/runtime-workspace-session-controller.ts' + ], + // The partition sinks under setWorkspaceSession and stageWorkspaceSessionBeforeUnload. + setLocalWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], + setHostWorkspaceSession: ['persistence/loading-store/session-snapshot-operations.ts'], + // The runtime's session controller, reachable from every OrcaRuntime mixin. + setForWorktree: ['runtime/orca-runtime-get-runtime-id.ts'], + patchWorkspaceSession: ['ipc/session.ts'], + stageWorkspaceSessionBeforeUnload: ['ipc/renderer-shutdown-checkpoint.ts'], + setWorkspaceSessionForWorktree: [ + 'runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts', + 'runtime/orca-runtime-apply-mobile-session-tab-navigation.ts', + 'runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts', + 'runtime/orca-runtime-move-headless-mobile-session-tab.ts', + 'runtime/orca-runtime-persist-headless-session-tab-props.ts', + 'runtime/orca-runtime-persist-headless-terminal-title.ts', + 'runtime/orca-runtime-pty-foreground-process-reads.ts' + ] +} + +/** A writer's own definition (a function or class member), as opposed to any other mention. */ +function isDefinitionName(node: ts.Node): boolean { + const parent = node.parent + return ( + (ts.isFunctionDeclaration(parent) || + (ts.isClassElement(parent) && ts.isClassLike(parent.parent))) && + parent.name === node + ) +} + +function isTypeOnlyImportOrExport(node: ts.Node): boolean { + return ( + ((ts.isImportSpecifier(node) || ts.isExportSpecifier(node)) && node.isTypeOnly) || + (ts.isImportClause(node) && node.isTypeOnly) || + (ts.isExportDeclaration(node) && node.isTypeOnly) + ) +} + +/** Any name or string literal except the writer's own definition; types are skipped. */ +function referencedName(node: ts.Node): string | undefined { + if (ts.isIdentifier(node) || ts.isStringLiteralLike(node)) { + return isDefinitionName(node) ? undefined : node.text + } + return undefined +} + +function referencingFilesByWriter(): Map> { + const writers = Object.keys(ALLOWED_REFERENCES) + const references = new Map(writers.map((writer) => [writer, new Set()])) + for (const file of scanSourceTree(MAIN_ROOT)) { + // Why prefilter: parsing every main-process file would dominate the test's budget. A matched + // name appears verbatim in the text unless it is spelled with an escape. + if ( + file.relativePath.startsWith(BOUNDARY_DIR) || + (!file.source.includes('\\') && !writers.some((writer) => file.source.includes(writer))) + ) { + continue + } + const source = ts.createSourceFile(file.relativePath, file.source, ts.ScriptTarget.Latest, true) + const visit = (node: ts.Node): void => { + // Why: ExpressionWithTypeArguments is a type node but also holds `extends f(x)` and `x`. + const typeOnly = + (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node)) || + ts.isInterfaceDeclaration(node) || + isTypeOnlyImportOrExport(node) + if (typeOnly) { + return + } + const name = referencedName(node) + if (name !== undefined) { + references.get(name)?.add(file.relativePath) + } + ts.forEachChild(node, visit) + } + visit(source) + } + return references +} + +describe('terminal topology boundary ratchet', () => { + const references = referencingFilesByWriter() + + for (const [writer, allowed] of Object.entries(ALLOWED_REFERENCES)) { + it(`only the boundary and listed files reference ${writer}`, () => { + expect(references.get(writer)).toEqual(new Set(allowed)) + }) + } +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts new file mode 100644 index 00000000000..8b086c56e39 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.test.ts @@ -0,0 +1,152 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalSurfaceCloseTarget } from '../../../shared/terminal-surface-close-target' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { _resetTracerForTests, setActiveSink } from '../../observability/tracer' +import type { TerminalSurfaceCloseCommit } from '../../runtime/terminal-surface-close' +import { closeLeafOrTab } from './terminal-topology-commit' + +// An ssh: partition, so a leaked host id or worktree path would show in the span. +const HOST_ID: ExecutionHostId = 'ssh:target-1' +const WORKTREE_ID = 'ssh-repo::/srv/app' +const LEAF_1 = '11111111-1111-4111-8111-111111111111' +const LEAF_2 = '22222222-2222-4222-8222-222222222222' +const SPLIT_TAB = 'tab-split' +const PINNED_TAB = 'tab-pinned' +const CLOSED_TAB = 'tab-closed-earlier' +const NOW = 1_700_000_000_000 + +function tab(id: string, ptyId: string, isPinned = false) { + return { + id, + ptyId, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...(isPinned ? { isPinned } : {}) + } +} + +function session(): WorkspaceSessionState { + return { + activeRepoId: 'ssh-repo', + activeWorktreeId: WORKTREE_ID, + activeTabId: SPLIT_TAB, + tabsByWorktree: { [WORKTREE_ID]: [tab(SPLIT_TAB, 'pty-1'), tab(PINNED_TAB, 'pty-3', true)] }, + terminalLayoutsByTabId: { + [SPLIT_TAB]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_1 }, + second: { type: 'leaf', leafId: LEAF_2 } + }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_1]: 'pty-1', [LEAF_2]: 'pty-2' } + } + }, + closedTerminalTabTombstonesByTabId: { + [CLOSED_TAB]: { closedAt: NOW - 1000, worktreeId: WORKTREE_ID, reason: 'user' } + }, + terminalTopologyRevisionByRepoId: { 'ssh-repo': 3 } + } +} + +function commitFor( + target: TerminalSurfaceCloseTarget, + overrides: Partial = {} +): TerminalSurfaceCloseCommit { + let current = session() + return { + worktreeId: WORKTREE_ID, + target, + options: {}, + requestedSession: current, + ownerMatches: () => true, + hostId: () => HOST_ID, + getSession: () => current, + setSession: (next) => { + current = next + }, + onClosed: () => {}, + ...overrides + } +} + +describe('persistence.terminal-topology span', () => { + let records: { name: string; attributes: Record; exit: unknown }[] + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) + records = [] + setActiveSink({ + push: (record) => { + records.push(JSON.parse(JSON.stringify(record))) + }, + flush: () => {}, + close: () => {} + }) + }) + afterEach(() => { + vi.useRealTimers() + _resetTracerForTests() + }) + + function attributesAfter(commit: TerminalSurfaceCloseCommit): Record { + closeLeafOrTab(commit)() + expect(records).toHaveLength(1) + expect(records[0].name).toBe('persistence.terminal-topology') + return records[0].attributes + } + + it('records a committed pane close without ids', () => { + expect(attributesAfter(commitFor({ kind: 'pane', tabId: SPLIT_TAB, leafId: LEAF_2 }))).toEqual({ + kind: 'persistence', + 'topology.kind': 'close_leaf', + 'topology.outcome': 'committed' + }) + expect(JSON.stringify(records[0])).not.toMatch(/pty-|tab-split|ssh-repo|srv|target-1/) + }) + + it('records a committed tab close', () => { + expect(attributesAfter(commitFor({ kind: 'tab', tabId: SPLIT_TAB }))).toMatchObject({ + 'topology.kind': 'close_tab', + 'topology.outcome': 'committed' + }) + }) + + it('records a refusal with its reason code', () => { + expect(attributesAfter(commitFor({ kind: 'tab', tabId: PINNED_TAB }))).toMatchObject({ + 'topology.kind': 'close_tab', + 'topology.outcome': 'refused', + 'topology.refusal': 'terminal_tab_pinned' + }) + }) + + it('records a close that changes nothing as a noop', () => { + const echo = commitFor({ kind: 'tab', tabId: CLOSED_TAB }, { options: { allowMissing: true } }) + expect(attributesAfter(echo)).toMatchObject({ 'topology.outcome': 'noop' }) + }) + + it('records a thrown commit as a failed span and rethrows', () => { + const mutation = closeLeafOrTab( + commitFor( + { kind: 'tab', tabId: SPLIT_TAB }, + { + getSession: () => { + throw new Error('read failed') + } + } + ) + ) + expect(mutation).toThrow('read failed') + expect(records).toHaveLength(1) + expect(records[0].attributes).toMatchObject({ 'topology.outcome': 'threw' }) + expect(records[0].exit).toMatchObject({ _tag: 'Failure' }) + }) +}) diff --git a/src/main/persistence/terminal-topology/terminal-topology-commit.ts b/src/main/persistence/terminal-topology/terminal-topology-commit.ts new file mode 100644 index 00000000000..dee0f6283a9 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-commit.ts @@ -0,0 +1,57 @@ +import { startSpan } from '../../observability/tracer' +import { + terminalSurfaceCloseMutation, + type TerminalSurfaceCloseCommit +} from '../../runtime/terminal-surface-close' +import type { DurableProfileStateMutation } from '../loading-store/store-runtime-state' + +// The commit boundary for terminal layout (tabs, panes, pane-to-PTY bindings). Today it wraps only +// the close, whose transform still lives in runtime/; the other writers move here later. + +/** Bindings are not listed: `persistPtyBinding` already records `persistence.pty-binding`. */ +type TerminalTopologyCommitKind = 'close_leaf' | 'close_tab' + +export function closeLeafOrTab( + commit: TerminalSurfaceCloseCommit +): () => DurableProfileStateMutation { + return traced( + commit.target.kind === 'pane' ? 'close_leaf' : 'close_tab', + terminalSurfaceCloseMutation(commit), + // Refusals are fixed reason codes, never ids. + (refusal) => refusal?.message + ) +} + +/** + * One `persistence.terminal-topology` span per commit, from admission to the in-memory write. + * Attributes stay low-cardinality: no pane key, PTY id or path. + */ +function traced( + kind: TerminalTopologyCommitKind, + mutate: () => DurableProfileStateMutation, + refusalOf: (value: T) => string | undefined +): () => DurableProfileStateMutation { + return () => { + const span = startSpan('persistence.terminal-topology', { + attributes: { kind: 'persistence', 'topology.kind': kind } + }) + let result: DurableProfileStateMutation + // Why only mutate(): `threw` must mean the write failed, never that tracing did. + try { + result = mutate() + } catch (error) { + span.setAttribute('topology.outcome', 'threw') + span.fail(error instanceof Error ? error : String(error)) + throw error + } + const refusal = refusalOf(result.value) + if (refusal !== undefined) { + span.setAttribute('topology.outcome', 'refused') + span.setAttribute('topology.refusal', refusal) + } else { + span.setAttribute('topology.outcome', result.persist === false ? 'noop' : 'committed') + } + span.end() + return result + } +} diff --git a/src/main/persistence/terminal-topology/terminal-topology-membership.ts b/src/main/persistence/terminal-topology/terminal-topology-membership.ts new file mode 100644 index 00000000000..99437157437 --- /dev/null +++ b/src/main/persistence/terminal-topology/terminal-topology-membership.ts @@ -0,0 +1,56 @@ +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' +import { layoutContainsLeafId } from '../restoring-sessions/terminal-layout-normalization' + +export function advanceTerminalTopologyRevision( + session: WorkspaceSessionState, + worktreeId: string +): WorkspaceSessionState { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return { + ...session, + terminalTopologyRevisionByRepoId: { + ...session.terminalTopologyRevisionByRepoId, + [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 + } + } +} + +/** + * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — + * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the + * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. + * + * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the + * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — + * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded + * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). + */ +export function findTerminalTabIdForLeaf( + session: WorkspaceSessionState | undefined, + leafId: string +): string | undefined { + const layouts = session?.terminalLayoutsByTabId + if (!layouts) { + return undefined + } + for (const tabId of Object.keys(layouts)) { + if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { + return tabId + } + } + return undefined +} + +export function hasHostAuthoritativeTerminalMembership( + session: WorkspaceSessionState | undefined, + worktreeId: string +): boolean { + const repoId = getRepoIdFromWorktreeId(worktreeId) + return ( + (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || + Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( + (tombstone) => tombstone.worktreeId === worktreeId + ) + ) +} diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts index 12b3340d2ac..32af5b46dd5 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement-fixture.ts @@ -12,7 +12,7 @@ import { Store } from '../persistence/loading-store/store' import { OrcaRuntimeService } from './orca-runtime' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' import { setRuntimeDesktopSurface } from './runtime-desktop-surface' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' export const ACK_WORKTREE = 'repo1::/tmp/worktree' export const ACK_TAB = '11111111-1111-4111-8111-111111111111' diff --git a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts index 179dba29bd1..6e55edb32df 100644 --- a/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts +++ b/src/main/runtime/acknowledged-terminal-tab-retirement.test.ts @@ -7,7 +7,7 @@ import { ACK_WORKTREE, createAcknowledgedTabRetirementFixture } from './acknowledged-terminal-tab-retirement-fixture' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import { delegatedMobileSessionTabClose } from './mobile-session-tab-close-outcome' const fixtures: ReturnType[] = [] diff --git a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts index f08c9a4a705..25ed04c2f32 100644 --- a/src/main/runtime/mobile-session-terminal-persistence-retirement.ts +++ b/src/main/runtime/mobile-session-terminal-persistence-retirement.ts @@ -6,10 +6,8 @@ import { retireLeavesFromTerminalLayout, type RetiredTerminalSurface } from './mobile-session-terminal-retirement' -import { - advanceTerminalTopologyRevision, - rebaseWorkspaceSessionTerminalMembership -} from './workspace-session-terminal-membership-authority' +import { rebaseWorkspaceSessionTerminalMembership } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' function visibleTypeForContentType( contentType: string | undefined diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index a4d01045475..35f9e7991a5 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -12,7 +12,6 @@ import { holdAgentSessionInventory } from './structured-agent-session-inventory- import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, - terminalSurfaceCloseMutation, type PaneCloseResolution, type RendererTerminalClose, type TerminalSurfaceCloseOptions @@ -25,6 +24,7 @@ import { retireTerminalSurfacesFromSnapshot } from './mobile-session-terminal-re import type { PtyControllerInventory } from './runtime-pty-controller-contract' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { captureAcknowledgedTerminalTabRetirement } from './workspace-session-terminal-tab-retirement-identity' +import { closeLeafOrTab } from '../persistence/terminal-topology/terminal-topology-commit' export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithPersistTerminalSurfaceRetirements { // Why: headless serve backs browser panes with offscreen WebContents that live @@ -131,7 +131,7 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu let refusal: Error | undefined try { refusal = await store.runDurableMutation( - terminalSurfaceCloseMutation({ + closeLeafOrTab({ worktreeId, target, options, diff --git a/src/main/runtime/orca-runtime-get-runtime-id.ts b/src/main/runtime/orca-runtime-get-runtime-id.ts index ee7fe60e85e..59da5242d64 100644 --- a/src/main/runtime/orca-runtime-get-runtime-id.ts +++ b/src/main/runtime/orca-runtime-get-runtime-id.ts @@ -188,7 +188,7 @@ export class OrcaRuntimeWithGetRuntimeId extends OrcaRuntimeWithHasExactPersiste worktreeId: string, session: WorkspaceSessionState ): void { - this.workspaceSessions.set(worktreeId, session) + this.workspaceSessions.setForWorktree(worktreeId, session) } protected getKnownWorkspaceSessionWorktreeIds(): Set { diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index ddb74df4dc8..6ce5f8a3650 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -15,7 +15,7 @@ import { sshRemotePtyLeaseAllowsReattach } from '../../shared/ssh-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import type { RuntimeStore } from './runtime-store-contract' import { SSH_PANE_RECOVERY_GRACE_MS } from './orca-runtime-core' -import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership' export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession { // Why: keep an existing snapshot's browser tabs in sync with the live bridge diff --git a/src/main/runtime/orca-runtime-terminal-close-records.test.ts b/src/main/runtime/orca-runtime-terminal-close-records.test.ts index f05ac78957a..28bb3e62d2e 100644 --- a/src/main/runtime/orca-runtime-terminal-close-records.test.ts +++ b/src/main/runtime/orca-runtime-terminal-close-records.test.ts @@ -19,7 +19,7 @@ import { WORKTREE_PATH, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-fixtures' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const SSH_REPO_ID = 'ssh-repo' const SSH_HOST_ID = 'ssh:target-1' diff --git a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts index c9bd30b7199..98e22b90b56 100644 --- a/src/main/runtime/orca-runtime-terminal-surface-close.test.ts +++ b/src/main/runtime/orca-runtime-terminal-surface-close.test.ts @@ -23,7 +23,7 @@ import { retireTerminalSurfaceFromPersistence, sanitizeWorkspaceSessionTerminalRetirements } from './mobile-session-terminal-persistence-retirement' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const splitLayout = { root: { diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index 3c18eb68def..57621cc5113 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -2,7 +2,7 @@ import { OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal } from './orca-runtime-publish-pty-backed-mobile-session-terminal' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parsePaneKey } from '../../shared/stable-pane-id' -import { hasHostAuthoritativeTerminalMembership } from './workspace-session-terminal-membership-authority' +import { hasHostAuthoritativeTerminalMembership } from '../persistence/terminal-topology/terminal-topology-membership' import type { RuntimeMobileSessionTabsSnapshot, RuntimeSyncedLeaf diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index 106799cf7e4..e2a4cd1df5d 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -5,7 +5,7 @@ import { collectPersistedTerminalLeafIds } from './mobile-session-layout-project import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' type Claim = RuntimeTerminalOrphanAdoptionRequest['claims'][number] type Topology = NonNullable diff --git a/src/main/runtime/runtime-workspace-session-controller.ts b/src/main/runtime/runtime-workspace-session-controller.ts index e45425b8dd3..f6bb0014169 100644 --- a/src/main/runtime/runtime-workspace-session-controller.ts +++ b/src/main/runtime/runtime-workspace-session-controller.ts @@ -116,7 +116,7 @@ export class RuntimeWorkspaceSessionController { : null } - set(worktreeId: string, session: WorkspaceSessionState): void { + setForWorktree(worktreeId: string, session: WorkspaceSessionState): void { this.deps.getStore()?.setWorkspaceSession?.(session, this.getHostId(worktreeId)) } diff --git a/src/main/runtime/terminal-intentional-stop-exit.test.ts b/src/main/runtime/terminal-intentional-stop-exit.test.ts index 0c3f996153f..279955fde1f 100644 --- a/src/main/runtime/terminal-intentional-stop-exit.test.ts +++ b/src/main/runtime/terminal-intentional-stop-exit.test.ts @@ -29,7 +29,7 @@ import { WORKTREE_PATH, makeSession } from './__fixtures__/orca-runtime-terminal-close-continuity-state-fixture' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' const REPLACEMENT_PTY_ID = 'pty-close-continuity-replacement' const REPLACEMENT_INCARNATION_ID = '77777777-7777-4777-8777-777777777777' diff --git a/src/main/runtime/terminal-leaf-tab-resolution.test.ts b/src/main/runtime/terminal-leaf-tab-resolution.test.ts index 2fe8a6d0344..8f57c05f59c 100644 --- a/src/main/runtime/terminal-leaf-tab-resolution.test.ts +++ b/src/main/runtime/terminal-leaf-tab-resolution.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { findTerminalTabIdForLeaf } from './workspace-session-terminal-membership-authority' +import { findTerminalTabIdForLeaf } from '../persistence/terminal-topology/terminal-topology-membership' function layout(...leafIds: string[]): TerminalLayoutSnapshot { let root = { type: 'leaf' as const, leafId: leafIds[0] } diff --git a/src/main/runtime/terminal-surface-close.ts b/src/main/runtime/terminal-surface-close.ts index 0feee12402c..636834a6178 100644 --- a/src/main/runtime/terminal-surface-close.ts +++ b/src/main/runtime/terminal-surface-close.ts @@ -14,7 +14,7 @@ import { type WorkspaceSessionTerminalTabCloseResult } from '../../shared/workspace-session-terminal-tab-close' import { retireTerminalSurfaceFromPersistence } from './mobile-session-terminal-persistence-retirement' -import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' +import { advanceTerminalTopologyRevision } from '../persistence/terminal-topology/terminal-topology-membership' import type { DurableProfileStateMutation } from '../persistence/loading-store/store-runtime-state' import type { ExecutionHostId } from '../../shared/execution-host' diff --git a/src/main/runtime/workspace-session-terminal-membership-authority.ts b/src/main/runtime/workspace-session-terminal-membership-authority.ts index 7798ae7831c..4c2ea719da5 100644 --- a/src/main/runtime/workspace-session-terminal-membership-authority.ts +++ b/src/main/runtime/workspace-session-terminal-membership-authority.ts @@ -5,7 +5,6 @@ import type { } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' -import { layoutContainsLeafId } from '../persistence/restoring-sessions/terminal-layout-normalization' import { pruneTabGroupLayoutAfterRetirement } from './mobile-session-terminal-retirement' function collectLeafIds(node: TerminalPaneLayoutNode | null, ids: Set): void { @@ -148,59 +147,6 @@ function rebaseIncarnationBindings( return Object.keys(retained).length > 0 ? retained : undefined } -export function advanceTerminalTopologyRevision( - session: WorkspaceSessionState, - worktreeId: string -): WorkspaceSessionState { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return { - ...session, - terminalTopologyRevisionByRepoId: { - ...session.terminalTopologyRevisionByRepoId, - [repoId]: (session.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) + 1 - } - } -} - -/** - * The tab whose live layout holds this leaf. Only the leaf half of a pane key is remint-stable — - * `detachTerminalPaneToTab` moves a live pane into a new tab, so a stored tabId names the tab the - * pane left. Callers fencing on location must resolve it here rather than trust a frozen tabId. - * - * Stateless on purpose: writers graft leaves by assigning into a layout that is already inside the - * layouts record, so any cache here would need a revalidation key that is itself O(tabs) per read — - * the same cost as this walk, with a staleness invariant to keep. `Object.keys` over a guarded - * `for...in` is deliberate too: the key array is cheaper than a `hasOwn` call per tab (measured). - */ -export function findTerminalTabIdForLeaf( - session: WorkspaceSessionState | undefined, - leafId: string -): string | undefined { - const layouts = session?.terminalLayoutsByTabId - if (!layouts) { - return undefined - } - for (const tabId of Object.keys(layouts)) { - if (layoutContainsLeafId(layouts[tabId]?.root ?? null, leafId)) { - return tabId - } - } - return undefined -} - -export function hasHostAuthoritativeTerminalMembership( - session: WorkspaceSessionState | undefined, - worktreeId: string -): boolean { - const repoId = getRepoIdFromWorktreeId(worktreeId) - return ( - (session?.terminalTopologyRevisionByRepoId?.[repoId] ?? 0) > 0 || - Object.values(session?.terminalSurfaceTombstonesByPaneKey ?? {}).some( - (tombstone) => tombstone.worktreeId === worktreeId - ) - ) -} - export function rebaseWorkspaceSessionTerminalMembership( incoming: WorkspaceSessionState, prior: WorkspaceSessionState | undefined diff --git a/src/main/ssh-reattach-pane-cardinality.test.ts b/src/main/ssh-reattach-pane-cardinality.test.ts index 889f792abd4..d4c795817a3 100644 --- a/src/main/ssh-reattach-pane-cardinality.test.ts +++ b/src/main/ssh-reattach-pane-cardinality.test.ts @@ -183,7 +183,7 @@ describe('STA-3077: an SSH reattach binds panes without grafting them back', () ) const { findTerminalTabIdForLeaf } = - await import('./runtime/workspace-session-terminal-membership-authority') + await import('./persistence/terminal-topology/terminal-topology-membership') // The relay resolves the tab from the live layout before binding, exactly as the production // path does; forwarding the lease's frozen `TAB` here is what would strand the pane. const resolvedTabId = findTerminalTabIdForLeaf(store.getWorkspaceSession(), TEST_LEAF_1) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2cd1f394cd7..63516856036 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -94,7 +94,7 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { findTerminalTabIdForLeaf, hasHostAuthoritativeTerminalMembership -} from '../runtime/workspace-session-terminal-membership-authority' +} from '../persistence/terminal-topology/terminal-topology-membership' import { DEFAULT_PTY_SOURCE_WINDOW_SU } from '../../shared/pty-source-credit-contract' import { PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } from '../../shared/pty-consumer-session' import { From 8f846d471ef68803d551a37b6c0ded19143dd729 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:40:38 -0700 Subject: [PATCH 3/9] fix(native-chat): a Stop binds only the turn it actually stopped (#24864) * fix(native-chat): a Stop binds only the turn it actually stopped A Stop pressed before any turn showed used to claim, at end-write time, whatever turn the stopped send later opened, even when the Stop stopped nothing. A turn that then died on its own read as "Interrupted" (your cancellation) instead of "Failed". Now a person's Stop that named no turn binds, in memory only, every turn that ends while the Stop settles, and afterwards only the turn its interrupt took. The settle ends a still-running stopped turn once. A relaunch finds nothing in memory, so an unsettled turnless Stop binds no turn. A Codex Stop whose answered turn does not open within its wait, or whose send's answer was lost, now answers refused, so the host ends the child and the turn can never run. * fix(native-chat): keep the person's queue pause and close binding after a Stop settles A host stop or eviction with no turn running now defers to a person's Stop while its queue pause still holds with nothing sent since, read from rows, so a held card is not handed off on reopen after a Stop that did nothing or whose kill failed. A person's close that named no turn opens a settle around its child's end, so a turn that end cuts reads as theirs. A press opens its settle only when the latest Stop event is its own or the one in force it repeats: a late Stop, a card's interrupt or a lost event row reopens no earlier Stop. A Codex Stop that cannot reach a turn still able to open says the Stop is unconfirmed rather than that no turn ran, and a second Stop still reaches a turn an earlier wait left unopened. Also drops the unused openedBy plumbing and the unreachable "a written cancellation stays one" rule, and pins a relaunch after a named Stop. * fix(native-chat): a Codex Stop agrees on both presses when a turn is still owed, and pin the close's settle A Codex Stop that waited for a turn Codex answered a send into now answers "may still open" whenever that turn neither opened nor ended and its send is still owed, however the wait ended (it ran out, or the thread went idle). Before, a first press after an idle thread said no turn was running and kept Codex, while an identical second press ended it. Adds a test that a person's close the conversation outlives (as /clear does) closes its settle, so a later turn that ends on its own reads as a failure. * test(native-chat): a Stop whose event row failed binds no turn to an earlier Stop With one ordered journal writer the Stop's event is in the fold when its write returns, so the press reads whether it owns the latest Stop from the fold instead of awaiting the write. Pins the case the read must refuse. * test(native-chat): name the settle, not a stream drain, in the Stop's own-end test * test(native-chat): a Codex Stop answered before Codex ends the turn reads interrupted throughout Codex answers an interrupt it took before it sends turn/completed (interrupted): on TurnAborted the app-server answers pending interrupts, then ends the turn, on one channel. The test fake did the reverse. It now answers first and ends the turn on a later read, and the tests that read the turn's end right after a Stop wait for it. New end-to-end test through the shipped host, journal and Codex adapter: with the real order, every end row of the stopped turn reads interrupted by the Stop (named, unnamed, and a Stop pressed while turn/start was in flight). Breaking the settle window turns the in-flight case red: the Stop's own end row then has no verdict, which reads as failed until Codex's end lands. * fix(native-chat): a Codex Stop interrupts a turn Codex answered but has not opened at once A Stop that named no turn, made after Codex answered a send but before the turn opened, used to wait up to 5 s for the turn to open before interrupting, and ended the Codex process when it didn't. The stated reason, that Codex refuses an interrupt until it opens the turn, holds only part of the time: with no turn active, Codex takes an interrupt once its thread runs (turn_interrupt_inner), and refuses it with -32600 "no active turn to interrupt" before that or once the turn has ended. The Stop now sends the interrupt at once. Only on that refusal, while the turn has neither opened nor ended, does it wait for the turn to open (bounded at 5 s) and send it once more. A turn that ended meanwhile was nothing to stop. One that never opens, or that an earlier wait already gave up on, fails the Stop, and the host ends the child as before. Sends still wait for the turn to open before steering into it. The test fake models Codex taking an interrupt once the thread runs (run()). * fix(native-chat): every Codex Stop waits for an answered turn to start, as the first did A Stop whose interrupt Codex refused as finding no active turn skipped the wait when an earlier wait, a Stop's or a send's, had already given up on that turn. Every press now waits its own bound and retries once if the turn starts, so a turn that opens during a later press is still stopped. Both presses still reach the same verdict when it never starts. * fix(native-chat): a Codex Stop that ends the child before any turn opened withdraws its send A Stop on a Codex turn that was answered but never opened ends the Codex process. That end settled the send as in doubt (unknown, recovered), and the client's outbox holds every later send behind a send in doubt until the person presses Retry, which re-sends the very message they stopped. The chat looked stuck. Codex records a prompt only once its turn has started, so a send whose turn never opened never ran. When the Stop's refusal says so (turnMayOpen), the child end now settles the unanswered sends as withdrawn, the verdict Codex's own interrupted-turn end already gives an unechoed send. The flag rides on the owed wind-down, so a retry after a failed child end withdraws them too. Claude's child end still leaves its unanswered send in doubt. * fix(native-chat): derive the withdrawal of a Codex send whose turn never opened Replaces the flag the Stop carried to the child's end, and its copy on the owed wind-down, with a reading of the journal at the settlement that lands. A Codex child's unanswered send is withdrawn when a person's Stop is in force since it was sent and no turn row ran, or was written, after it; any other end (a turn that opened, a host's close, a crash, Claude) still leaves it in doubt. A retried wind-down reads the same rows, so it withdraws the same sends. * fix(native-chat): keep a send in doubt when a turn was open for it The derived withdrawal read a turn as open for a send only if it still ran or was written after the send. A send steered into a running Codex turn whose interrupt failed met neither once the adapter's end settled that turn ahead of the host's settle, so it read withdrawn, though Codex drains a steer into the running turn and may hold it. A turn that ended after the send was handed over was open for it too: such a send stays in doubt, as before. Pins that case, and that a send made after the Stop, to a child that then dies before its turn opens, stays in doubt. * fix(native-chat): withdraw a Codex send by whether it started its own turn, not by timing Whether a turn was open for a send was read from end times: a turn that ended after the send's handover counted. A send made while a Stop ended the turn is handed over once that turn reads ended, yet Codex's own end for it can arrive later, so such a send whose own turn never opened read in doubt again, and the chat's queue held behind it. The handover already records where the send went: its message joins the turn running then (a steer) or belongs to no turn (it starts its own). Only a send that started its own turn, with none opened since, is withdrawn; one that joined a running turn, or has no recorded place, stays in doubt. The Codex test fake takes an answered interrupt as Codex does, dropping the turn before its end arrives. * fix(native-chat): leave no Stop row when the Stop took back a send that never ran A Stop on a Codex send whose turn never opened ends the child, and the child's end takes the send back into the composer. The Stop still wrote "Cancellation requested." at the conversation level, so with the send gone it sat under the previous finished turn and read as if that turn had been stopped. A Stop that found no turn running and whose child end took back every send it found now writes no row; a Stop of a running turn, or one that leaves a send in doubt, still does. * fix(native-chat): count a send whose answer was lost when a Stop takes it back The no-row rule counted only pending sends, but the child's end also takes back a send this process left in doubt when Codex's turn/start answer was lost. That case still wrote "Cancellation requested." under the previous turn. Both now read one predicate, so they cannot drift apart. * test(native-chat): pin which sends a Stop's child end can take back A send an earlier process left in doubt is never withdrawn and never holds the row back, and a queued card's send is never counted. * test(native-chat): read the outbox reconcile from where main moved it --- src/main/claude/claude-message-journaling.ts | 5 +- src/main/claude/claude-open-turn.ts | 5 - src/main/claude/claude-result-journaling.ts | 4 +- .../claude-structured-journal-translation.ts | 9 +- src/main/claude/claude-turn-lifecycle-item.ts | 2 - src/main/claude/claude-turn-opening.ts | 5 +- ...codex-structured-conversation-stop.test.ts | 5 +- .../codex/codex-structured-dispatch-echo.ts | 31 +- .../codex-structured-prompt-ownership.test.ts | 2 +- .../codex-structured-prompt-ownership.ts | 60 +++- .../codex-structured-session-cancel.test.ts | 6 +- .../codex-structured-turn-open-wait.test.ts | 139 +++++++-- .../codex/codex-structured-turn-open-wait.ts | 90 +++++- .../codex/codex-structured-turn-steer.test.ts | 14 +- src/main/codex/codex-turn-lifecycle-fake.ts | 75 ++++- .../journal-stop-marks.ts | 25 +- .../journal-stop-turn-end.test.ts | 90 ++++++ .../journal-stop-turn-end.ts | 106 +++---- .../agent-session-journal/journal-store.ts | 4 + .../queued-message-pause.ts | 6 +- .../structured-agent-session-adapter-stop.ts | 7 +- .../structured-agent-session-chat-stop.ts | 15 +- ...ed-agent-session-conversation-stop.test.ts | 13 +- ...gent-session-dead-generation-settlement.ts | 16 +- ...ructured-agent-session-event-sink-queue.ts | 4 +- .../structured-agent-session-event-sink.ts | 5 +- .../structured-agent-session-host-lifetime.ts | 5 +- ...tructured-agent-session-host-stop-event.ts | 34 +- .../structured-agent-session-queued-stop.ts | 33 +- ...d-agent-session-stop-event-binding.test.ts | 170 +++++++++- ...d-agent-session-stop-event-restart.test.ts | 60 +++- ...t-session-stop-note-withdrawn-send.test.ts | 148 +++++++++ ...-agent-session-stop-settle-binding.test.ts | 197 ++++++++++++ .../structured-agent-session-turns-cancel.ts | 169 ++++++++-- ...t-session-unopened-send-withdrawal.test.ts | 107 +++++++ ...-agent-session-unopened-send-withdrawal.ts | 94 ++++++ ...gent-session-codex-interrupt-order.test.ts | 238 ++++++++++++++ ...-session-codex-turn-end-settlement.test.ts | 294 ++++++++++++++++-- ...gent-session-outbox-withdrawn-send.test.ts | 78 +++++ 39 files changed, 2072 insertions(+), 298 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts create mode 100644 src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts create mode 100644 src/shared/structured-agent-session-outbox-withdrawn-send.test.ts diff --git a/src/main/claude/claude-message-journaling.ts b/src/main/claude/claude-message-journaling.ts index 0ade48d5812..9d75db1fca9 100644 --- a/src/main/claude/claude-message-journaling.ts +++ b/src/main/claude/claude-message-journaling.ts @@ -63,9 +63,7 @@ export function journalClaudeMessage( startsTurn: boolean, observedAt: number, /** Host clock on the submission that produced this send, when known. */ - requestedAt?: number, - /** The submission this send echo acknowledged. */ - openedBy?: string + requestedAt?: number ): boolean { const envelope = readClaudeMessageEnvelope(message) if (!envelope) { @@ -178,7 +176,6 @@ export function journalClaudeMessage( startsTurn, observedAt, ...(requestedAt === undefined ? {} : { requestedAt }), - ...(openedBy === undefined ? {} : { openedBy }), userItemId: agentJournalItemKey(identity) }) if (sendEchoTurn) { diff --git a/src/main/claude/claude-open-turn.ts b/src/main/claude/claude-open-turn.ts index ddc1bd55390..1cf0a8d048c 100644 --- a/src/main/claude/claude-open-turn.ts +++ b/src/main/claude/claude-open-turn.ts @@ -60,11 +60,6 @@ export class ClaudeOpenTurn { } /** The open turn's row, where a fact about the running turn lands. */ - /** The submission that opened the open turn, when known (`ClaudeCurrentTurn.openedBy`). */ - get openedBy(): string | null { - return this.current?.openedBy ?? null - } - get identity(): AgentJournalItemIdentity | null { return this.current ? claudeCurrentTurnIdentity(this.current) : null } diff --git a/src/main/claude/claude-result-journaling.ts b/src/main/claude/claude-result-journaling.ts index a4226067b98..1473886b9ea 100644 --- a/src/main/claude/claude-result-journaling.ts +++ b/src/main/claude/claude-result-journaling.ts @@ -59,9 +59,7 @@ export function journalClaudeResult( // Read before the settle below closes the turn: an error end the journal's Stop rule makes a // person's cancellation is theirs to decide as the end is written (`turnEndAfterStop`). const turnId = settlesTurn ? turn.id : null - const leftToStop = - turnId !== null && - sink.journalStopDecidesTurn?.(turnId, observedAt, turn.openedBy ?? undefined) === true + const leftToStop = turnId !== null && sink.journalStopDecidesTurn?.(turnId, observedAt) === true if (settlesTurn) { prompts.retryPendingCancellations() turn.suppressReopenOnFailure(message.is_error === true) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index 3442dcc956b..070798bc521 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -179,10 +179,8 @@ export function createClaudeJournalTranslator( message: Record, startsTurn: boolean, observedAt: number, - requestedAt?: number, - openedBy?: string - ): boolean => - journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt, openedBy) + requestedAt?: number + ): boolean => journalClaudeMessage(messageContext, message, startsTurn, observedAt, requestedAt) return { handle: (event) => { @@ -250,8 +248,7 @@ export function createClaudeJournalTranslator( event.message, event.startsTurn === true, event.observedAt ?? Date.now(), - event.requestedAt, - event.clientMessageId + event.requestedAt ) ) { providerFallback.append( diff --git a/src/main/claude/claude-turn-lifecycle-item.ts b/src/main/claude/claude-turn-lifecycle-item.ts index 1d0db5fa21f..e816be7f2b2 100644 --- a/src/main/claude/claude-turn-lifecycle-item.ts +++ b/src/main/claude/claude-turn-lifecycle-item.ts @@ -19,8 +19,6 @@ export type ClaudeCurrentTurn = { /** Provider key of the user echo, or the lifecycle row itself when provider * output opened a turn with no user row to receive its timing. */ userItemId: string - /** The submission whose send opened the turn, while its echo has yet to land in the journal. */ - openedBy?: string /** Present when the turn is the host's record of a conversation command. */ command?: ClaudeCommandTurn } diff --git a/src/main/claude/claude-turn-opening.ts b/src/main/claude/claude-turn-opening.ts index b5e13a39c71..1406928b37e 100644 --- a/src/main/claude/claude-turn-opening.ts +++ b/src/main/claude/claude-turn-opening.ts @@ -30,8 +30,6 @@ export type ClaudeSendEchoTurnInput = { requestedAt?: number /** Provider key of the user row this turn is anchored to. */ userItemId: string - /** The submission this echo acknowledged. */ - openedBy?: string } /** The turn a replayed send echo opens, or null when this frame is not one. */ @@ -48,8 +46,7 @@ export function claudeTurnOpenedBySendEcho( turnId: envelope.uuid, startedAt: input.observedAt, ...(input.requestedAt === undefined ? {} : { requestedAt: input.requestedAt }), - userItemId: input.userItemId, - ...(input.openedBy === undefined ? {} : { openedBy: input.openedBy }) + userItemId: input.userItemId } : null } diff --git a/src/main/codex/codex-structured-conversation-stop.test.ts b/src/main/codex/codex-structured-conversation-stop.test.ts index 62ecaf8f2ea..1e548dd0909 100644 --- a/src/main/codex/codex-structured-conversation-stop.test.ts +++ b/src/main/codex/codex-structured-conversation-stop.test.ts @@ -1,5 +1,6 @@ // A Codex Stop that names no turn. The fake keeps Codex 0.157's turn bookkeeping: it answers -// `turn/start` before it opens the turn, and refuses an interrupt until then. +// `turn/start` before it opens the turn, and refuses an interrupt with no turn active until the +// thread runs. import { describe, expect, it, vi } from 'vitest' import { structuredAgentSessionCommandTurn } from '../native-chat/agent-session-wire/structured-agent-session-command-turn' @@ -29,7 +30,7 @@ describe('a Codex Stop that names no turn', () => { const rig = await codexTurnLifecycleRig() await openedTurn(rig) - await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true }) + await expect(stop(rig, () => null)).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) diff --git a/src/main/codex/codex-structured-dispatch-echo.ts b/src/main/codex/codex-structured-dispatch-echo.ts index 775cac5bca4..1eee3bc9a0a 100644 --- a/src/main/codex/codex-structured-dispatch-echo.ts +++ b/src/main/codex/codex-structured-dispatch-echo.ts @@ -44,6 +44,9 @@ export type CodexDispatchEchoes = { answeredUnopenedTurn: (threadId: string, openTurnIds: ReadonlySet) => string | null /** Codex did not open this answered turn within a wait, so no later wait is spent on it. */ leftUnopened: (threadId: string, turnId: string) => void + /** The latest armed send's answered turn a wait left unopened, neither open nor ended: Codex may + * still open it, though no wait is spent on it again. */ + answeredTurnLeftUnopened: (threadId: string, openTurnIds: ReadonlySet) => string | null /** * Records a turn's end and returns the sends bound to it that it settles: all of them unless it * completed, which echoes its pending input first, so one it never echoed waits for recovery. An @@ -68,6 +71,19 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { let nextSequence = 0 const turnKey = (threadId: string, turnId: string): string => JSON.stringify([threadId, turnId]) const settles = (end: CodexTurnEnd): boolean => end.status !== 'completed' + const answeredTurn = ( + threadId: string, + openTurnIds: ReadonlySet, + admits: (key: string) => boolean + ): string | null => { + const answered = [...armed.values()].flatMap(({ turn }) => { + const key = turn?.threadId === threadId ? turnKey(threadId, turn.turnId) : null + return turn && key && !openTurnIds.has(turn.turnId) && !endedTurns.has(key) && admits(key) + ? [turn.turnId] + : [] + }) + return answered.at(-1) ?? null + } return { arm(clientMessageId, requestedAt) { const existing = armed.get(clientMessageId) @@ -97,17 +113,10 @@ export function createCodexDispatchEchoes(): CodexDispatchEchoes { } return end }, - answeredUnopenedTurn: (threadId, openTurnIds) => { - const answered = [...armed.values()].flatMap(({ turn }) => - turn?.threadId === threadId && - !openTurnIds.has(turn.turnId) && - !endedTurns.has(turnKey(threadId, turn.turnId)) && - !unopenedTurns.has(turnKey(threadId, turn.turnId)) - ? [turn.turnId] - : [] - ) - return answered.at(-1) ?? null - }, + answeredUnopenedTurn: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => !unopenedTurns.has(key)), + answeredTurnLeftUnopened: (threadId, openTurnIds) => + answeredTurn(threadId, openTurnIds, (key) => unopenedTurns.has(key)), leftUnopened: (threadId, turnId) => { unopenedTurns.add(turnKey(threadId, turnId)) for (const oldest of unopenedTurns) { diff --git a/src/main/codex/codex-structured-prompt-ownership.test.ts b/src/main/codex/codex-structured-prompt-ownership.test.ts index ef1f8cc426f..a0b2dc4146e 100644 --- a/src/main/codex/codex-structured-prompt-ownership.test.ts +++ b/src/main/codex/codex-structured-prompt-ownership.test.ts @@ -509,7 +509,7 @@ describe('Codex live prompt ownership', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(completionThreads(events)).toEqual([]) }) diff --git a/src/main/codex/codex-structured-prompt-ownership.ts b/src/main/codex/codex-structured-prompt-ownership.ts index effab9662bf..fdef6e750f4 100644 --- a/src/main/codex/codex-structured-prompt-ownership.ts +++ b/src/main/codex/codex-structured-prompt-ownership.ts @@ -12,7 +12,7 @@ import { } from './codex-structured-prompt-replies' import { requireLiveCodexSession, type CodexSession } from './codex-structured-session-state' import { interruptCodexTurn } from './codex-structured-turn-cancellation' -import { codexRunningOrOpeningTurn } from './codex-structured-turn-open-wait' +import { codexStopTarget, interruptOpeningCodexTurn } from './codex-structured-turn-open-wait' type CancelInput = Parameters[0] type AnswerInput = Parameters[0] @@ -22,10 +22,25 @@ function providerTurnId(session: CodexSession, turnId: string): string | undefin return session.translator ? session.translator.commandProviderTurnId(turnId) : turnId } +/** A send whose answer this child lost: Codex may still open a turn for it that no Stop can name. + * A pending one Codex answered is not: its turn is the one the Stop interrupts. */ +function sendInDoubt(dispatchStatus: CancelInput['dispatchStatus']): boolean { + return dispatchStatus?.state === 'unknown' && !dispatchStatus.recovered +} + +/** The Stop's own answer, naming the journal turn it stopped when it took. */ +function stoppedTurn( + outcome: AgentSessionCancelOutcome, + turnId: string +): AgentSessionCancelOutcome { + return outcome.cancelled ? { ...outcome, turnId } : outcome +} + /** * A Stop that names no turn: interrupt the turn the journal shows, or else the one Codex reported * started and not yet ended, which the journal can trail by a publish, or else the one Codex - * answered a send into, once it opens. + * answered a send into, at once (`interruptOpeningCodexTurn`). A turn that may still open but + * cannot be reached, or a send whose answer was lost, answers refused, and the host ends the child. */ async function cancelCodexConversation( input: Parameters[0], @@ -34,20 +49,32 @@ async function cancelCodexConversation( const { request, sessions, requestTimeoutMs } = input const liveTurnId = request.resolveLiveTurnId?.() ?? null // A turn the journal shows that Codex has not started yet (a compaction's) has nothing to stop. - const turnId = - liveTurnId === null - ? await codexRunningOrOpeningTurn(session) - : providerTurnId(session, liveTurnId) - // The wait for the turn to open can outlive the session it began on. - if ( - !turnId || - sessions.get(request.sessionId) !== session || - session.ended || - session.fence !== request.fence - ) { + const target = liveTurnId === null ? codexStopTarget(session) : { turnId: liveTurnId } + // Read again after any wait for the turn to open, which can outlive the session it began on. + const current = (): boolean => + sessions.get(request.sessionId) === session && !session.ended && session.fence === request.fence + if (!current()) { return { cancelled: false } } - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (target === null) { + return sendInDoubt(request.dispatchStatus) + ? { cancelled: false, refusal: { turnMayOpen: true } } + : { cancelled: false } + } + const journalTurnId = 'opening' in target ? target.opening : target.turnId + const turnId = liveTurnId === null ? journalTurnId : providerTurnId(session, liveTurnId) + if (!turnId) { + return { cancelled: false } + } + const interrupt = () => + interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + if (!('opening' in target)) { + return stoppedTurn(await interrupt(), journalTurnId) + } + return stoppedTurn( + await interruptOpeningCodexTurn(session, turnId, interrupt, current), + journalTurnId + ) } export async function cancelCodexStructuredTurn(input: { @@ -67,7 +94,10 @@ export async function cancelCodexStructuredTurn(input: { return { cancelled: false } } if (!prompt) { - return interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }) + return stoppedTurn( + await interruptCodexTurn({ session, threadId: session.threadId, turnId, requestTimeoutMs }), + requestedTurnId + ) } if (session.fence !== request.fence) { return { cancelled: false } diff --git a/src/main/codex/codex-structured-session-cancel.test.ts b/src/main/codex/codex-structured-session-cancel.test.ts index cd30bfa2e6d..939d5faf0cd 100644 --- a/src/main/codex/codex-structured-session-cancel.test.ts +++ b/src/main/codex/codex-structured-session-cancel.test.ts @@ -143,7 +143,7 @@ describe('CodexStructuredSessionAdapter.cancelTurn', () => { await expect( adapter.cancelTurn({ sessionId: 'session-1', turnId: 'turn-1', fence: 7 }) - ).resolves.toEqual({ cancelled: true }) + ).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) expect(codex.connections[0].calls.at(-1)).toEqual({ method: 'turn/interrupt', params: { threadId: THREAD_ID, turnId: 'turn-1' } @@ -288,7 +288,7 @@ describe('a Codex Stop is the interrupt alone', () => { params: expect.objectContaining({ turn: { id: 'turn-1', status: 'interrupted' } }) }) ) - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('reads and kills no processes, from the send through the Stop', async () => { @@ -299,7 +299,7 @@ describe('a Codex Stop is the interrupt alone', () => { for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } - await expect(stopped).resolves.toEqual({ cancelled: true }) + await expect(stopped).resolves.toEqual({ cancelled: true, turnId: 'turn-1' }) for (const work of Object.values(processWork)) { expect(work).not.toHaveBeenCalled() } diff --git a/src/main/codex/codex-structured-turn-open-wait.test.ts b/src/main/codex/codex-structured-turn-open-wait.test.ts index c86a39fb59e..d669c088d08 100644 --- a/src/main/codex/codex-structured-turn-open-wait.test.ts +++ b/src/main/codex/codex-structured-turn-open-wait.test.ts @@ -1,7 +1,8 @@ // A Codex Stop that names no turn, sent after Codex answered a cold send and before it opened that -// turn, waits for the turn to open, or provably not to, and never the send itself. The fake keeps -// Codex 0.157's turn bookkeeping: it answers before it opens the turn, and refuses an interrupt -// until then. +// turn, interrupts that turn at once. Refused as finding no turn while the turn has yet to run, it +// waits for the turn to open, or provably not to, and interrupts once more; it never waits for the +// send itself. The fake keeps Codex 0.157's turn bookkeeping: it answers before it opens the turn, +// and refuses an interrupt with no turn active until the thread runs. import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -15,6 +16,7 @@ type Rig = Awaited> const ADMITTED = { state: 'admitted' } const REFUSED = { cancelled: false } +const MAY_OPEN = { cancelled: false, refusal: { turnMayOpen: true } } const stop = (rig: Rig) => rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) @@ -26,12 +28,23 @@ async function answeredColdSend(rig: Rig): Promise { expect(await settledWithin(sending)).toEqual(ADMITTED) } -/** A Stop sent in the window, which Codex would refuse if it reached Codex now. */ +const interruptedTurns = (rig: Rig) => rig.interrupts().map((call) => call.params?.turnId) + +/** A Stop under fake timers, read without awaiting it. */ +function pressed(rig: Rig): () => unknown { + let outcome: unknown = 'held' + void stop(rig).then((value) => { + outcome = value + }) + return () => outcome +} + +/** A Stop sent in the window before Codex's thread runs the turn: Codex refused its interrupt. */ async function waitingStop(rig: Rig) { await answeredColdSend(rig) const stopping = stop(rig) expect(await settledWithin(stopping)).toBe('held') - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) // Wrapped: an async function returning the promise itself would wait for it. return { stopping } } @@ -51,15 +64,26 @@ describe('a Codex send answered before its turn opens', () => { }) describe("a no-turn Stop in the window between Codex's answer and its turn opening", () => { - it('waits for the turn to open, then stops it', async () => { + it("is taken at once once Codex's thread runs the turn", async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + rig.turns.run() + + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) + }) + + it('refused before the thread runs, waits for the turn to open and interrupts once more', async () => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) - expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) - expect(rig.turns.turnId).toBeNull() + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) + // Codex answers the interrupt, then ends the turn. + await vi.waitFor(() => expect(rig.turns.turnId).toBeNull()) }) it('does not wait when Codex opened the turn before its answer was read', async () => { @@ -71,7 +95,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi release() await sending - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('does not wait for a send Codex steered into the running turn', async () => { @@ -80,7 +104,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.start() expect(await settledWithin(rig.send('client-2'))).toEqual(ADMITTED) - expect(await settledWithin(stop(rig))).toEqual({ cancelled: true }) + expect(await settledWithin(stop(rig))).toEqual({ cancelled: true, turnId: 'turn-1' }) expect(rig.interrupts().map((call) => call.params?.turnId)).toEqual(['turn-1']) }) @@ -91,19 +115,41 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.turns.end('interrupted') expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) + // Codex ended the turn as the interrupt reached it: it refuses the turn as its last ended one. + it('stops nothing, and waits for nothing, when Codex already ended the turn', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + const interrupt = rig.codex.routes['turn/interrupt']! + rig.codex.routes['turn/interrupt'] = (params) => { + rig.turns.end('completed') + return interrupt(params) + } + + expect(await settledWithin(stop(rig))).toEqual(REFUSED) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + }) + + // The turn neither opened nor ended, and its send is still owed: no press calls that nothing. it.each(['idle', 'systemError'])( - 'stops nothing when Codex reports the thread %s', + 'answers refused, on the first press and on a second alike, when Codex reports the thread %s', async (type) => { const rig = await codexTurnLifecycleRig() const { stopping } = await waitingStop(rig) rig.notify('thread/status/changed', { threadId: CODEX_TEST_THREAD_ID, status: { type } }) - expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(await settledWithin(stopping)).toEqual(MAY_OPEN) + // The second press's interrupt is refused too; it waits its own bound, then agrees. + vi.useFakeTimers() + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + await vi.advanceTimersByTimeAsync(1) + expect(second()).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1']) } ) @@ -115,7 +161,7 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi expect(await settledWithin(stopping)).toBe('held') rig.turns.start() - expect(await settledWithin(stopping)).toEqual({ cancelled: true }) + expect(await settledWithin(stopping)).toEqual({ cancelled: true, turnId: 'turn-1' }) }) it('stops nothing when the child exits', async () => { @@ -125,23 +171,68 @@ describe("a no-turn Stop in the window between Codex's answer and its turn openi rig.codex.connections[0]!.handlers.onExit?.(new Error('codex app-server exited')) expect(await settledWithin(stopping)).toEqual(REFUSED) - expect(rig.interrupts()).toEqual([]) + expect(interruptedTurns(rig)).toEqual(['turn-1']) }) - it('stops nothing once its bound runs out', async () => { + // The turn may still open and run: the host ends the child (`performCancel`). + it('answers refused once its bound runs out', async () => { const rig = await codexTurnLifecycleRig() await answeredColdSend(rig) vi.useFakeTimers() - let outcome: unknown = 'held' - void stop(rig).then((value) => { - outcome = value - }) + const first = pressed(rig) await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) - expect(outcome).toBe('held') + expect(first()).toBe('held') await vi.advanceTimersByTimeAsync(1) - expect(outcome).toEqual(REFUSED) + expect(first()).toEqual(MAY_OPEN) + expect(interruptedTurns(rig)).toEqual(['turn-1']) + }) + + // A press after an earlier wait gave up waits again, so a turn that opens then is still stopped. + it('waits again on a second press, and stops the turn if it opens then', async () => { + const rig = await codexTurnLifecycleRig() + await answeredColdSend(rig) + vi.useFakeTimers() + const first = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS) + expect(first()).toEqual(MAY_OPEN) + + const second = pressed(rig) + await vi.advanceTimersByTimeAsync(CODEX_TURN_OPEN_WAIT_MS - 1) + expect(second()).toBe('held') + rig.turns.start() + await vi.advanceTimersByTimeAsync(0) + + expect(second()).toEqual({ cancelled: true, turnId: 'turn-1' }) + expect(interruptedTurns(rig)).toEqual(['turn-1', 'turn-1', 'turn-1']) + }) +}) + +describe('a no-turn Stop with no turn Codex answered', () => { + it('answers refused when the answer to the latest send was lost: Codex may still open its turn', async () => { + const rig = await codexTurnLifecycleRig() + + const outcome = await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: false } + }) + + expect(outcome).toEqual({ cancelled: false, refusal: { turnMayOpen: true } }) expect(rig.interrupts()).toEqual([]) + }) + + it('stops nothing when nothing was sent, or the lost answer outlived its child', async () => { + const rig = await codexTurnLifecycleRig() + + expect(await stop(rig)).toEqual(REFUSED) + expect( + await rig.adapter.cancelTurn({ + sessionId: 'session-1', + fence: 7, + dispatchStatus: { state: 'unknown', recovered: true } + }) + ).toEqual(REFUSED) }) }) diff --git a/src/main/codex/codex-structured-turn-open-wait.ts b/src/main/codex/codex-structured-turn-open-wait.ts index 9f30b292ff0..9dd071656f3 100644 --- a/src/main/codex/codex-structured-turn-open-wait.ts +++ b/src/main/codex/codex-structured-turn-open-wait.ts @@ -1,6 +1,7 @@ // A Stop's or a send's wait for the turn Codex answered a send into to open, or provably not // to: it ended, the thread stopped running, or the child is gone. Held in memory only. +import type { AgentSessionCancelOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { CodexDispatchEchoes } from './codex-structured-dispatch-echo' import { codexThreadStoppedRunning, @@ -61,18 +62,88 @@ export function createCodexTurnOpenWaits(): CodexTurnOpenWaits { } } -/** - * The turn a Stop or a send names: the latest Codex reported started and not ended, or else the - * one Codex answered a send into, once it opens. Null when none is running and that one ends, the - * thread stops running or the child exits first, or the wait runs out; each such turn is waited - * for once. - */ -export async function codexRunningOrOpeningTurn(session: { +/** What a send finds to steer, or a Stop to interrupt: a turn running, or one Codex answered a send + * into that has not opened and has not ended, so may still open while its send is owed; null when + * neither. */ +export type CodexStopTarget = { turnId: string } | { opening: string } | null + +type CodexOpeningTurnSession = { threadId: string activeTurnIds?: ReadonlySet - dispatchEchoes: Pick + dispatchEchoes: Pick< + CodexDispatchEchoes, + 'answeredUnopenedTurn' | 'leftUnopened' | 'answeredTurnLeftUnopened' + > turnOpenWaits: Pick -}): Promise { +} + +/** The latest turn Codex reported started and not ended, or else the one it answered a send into + * that has neither opened nor ended, whether or not a wait already gave up on it. */ +export function codexStopTarget(session: CodexOpeningTurnSession): CodexStopTarget { + const running = [...(session.activeTurnIds ?? [])].at(-1) + if (running) { + return { turnId: running } + } + const open = session.activeTurnIds ?? new Set() + const opening = + session.dispatchEchoes.answeredUnopenedTurn(session.threadId, open) ?? + session.dispatchEchoes.answeredTurnLeftUnopened(session.threadId, open) + return opening ? { opening } : null +} + +/** Codex's -32600 for an interrupt that finds no turn: before the turn has started running, or + * once it has ended. */ +const CODEX_NO_ACTIVE_TURN_TO_INTERRUPT = 'no active turn to interrupt' + +/** + * A Stop of a turn Codex answered a send into that has not opened. Codex takes an interrupt as + * soon as the turn starts, which can be before its `turn/started` is read, so it goes out at once. + * Refused as finding no turn while that turn has yet to start, the Stop waits for it to open, + * bounded, and sends it once more. A turn that ended meanwhile was nothing to stop. One that never + * opens fails the Stop (`turnMayOpen`): it may still open and run, so the host ends the child. + */ +export async function interruptOpeningCodexTurn( + session: CodexOpeningTurnSession, + turnId: string, + interrupt: () => Promise, + /** Whether the session the Stop began on still runs it, read after the wait. */ + stillCurrent: () => boolean +): Promise { + const open = (): ReadonlySet => session.activeTurnIds ?? new Set() + const first = await interrupt() + if (first.cancelled || first.refusal?.detail?.text !== CODEX_NO_ACTIVE_TURN_TO_INTERRUPT) { + return first + } + const opened = (): boolean => open().has(turnId) + const stillOpening = (): boolean => { + const target = codexStopTarget(session) + return target !== null && 'opening' in target && target.opening === turnId + } + if (!opened() && stillOpening()) { + await session.turnOpenWaits.wait(turnId, CODEX_TURN_OPEN_WAIT_MS) + if (!stillCurrent()) { + return { cancelled: false } + } + } + if (opened()) { + return interrupt() + } + if (!stillOpening()) { + // It ended: the Stop found no turn running. + return { cancelled: false } + } + return { cancelled: false, refusal: { turnMayOpen: true } } +} + +/** + * The turn a send steers into: the running one, or else the one Codex answered a send into, once + * it opens. Before 0.148 Codex refuses a steer until it opens the turn, so the send waits for that, + * once per turn: a turn a wait left unopened is not waited for again. Null when no turn is running + * by then. + */ +export async function codexRunningOrOpeningTurn( + session: CodexOpeningTurnSession +): Promise { const running = [...(session.activeTurnIds ?? [])].at(-1) if (running) { return running @@ -84,7 +155,6 @@ export async function codexRunningOrOpeningTurn(session: { if (!answered) { return null } - // Codex refuses an interrupt, and before 0.148 a steer, until it opens the turn. await session.turnOpenWaits.wait(answered, CODEX_TURN_OPEN_WAIT_MS) if (session.activeTurnIds?.has(answered)) { return answered diff --git a/src/main/codex/codex-structured-turn-steer.test.ts b/src/main/codex/codex-structured-turn-steer.test.ts index c1b982709b3..15f95aa2927 100644 --- a/src/main/codex/codex-structured-turn-steer.test.ts +++ b/src/main/codex/codex-structured-turn-steer.test.ts @@ -223,12 +223,16 @@ describe('a Codex send made after Codex answered an earlier one, before it opene expect(await rig.sending).toEqual({ state: 'admitted' }) expect(rig.methods()).toEqual(['thread/start', 'turn/start', 'turn/steer']) expect(await rig.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 })).toEqual({ - cancelled: true + cancelled: true, + turnId: 'turn-1' }) - expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ - 'client-1', - 'client-2' - ]) + // Codex answers the interrupt, then ends the turn, which settles both. + await vi.waitFor(() => + expect(rig.settlements.map(({ clientMessageId }) => clientMessageId).sort()).toEqual([ + 'client-1', + 'client-2' + ]) + ) }) it('starts its own turn when that turn ends without opening', async () => { diff --git a/src/main/codex/codex-turn-lifecycle-fake.ts b/src/main/codex/codex-turn-lifecycle-fake.ts index a958faca1ea..e0747f38b0b 100644 --- a/src/main/codex/codex-turn-lifecycle-fake.ts +++ b/src/main/codex/codex-turn-lifecycle-fake.ts @@ -2,9 +2,13 @@ // it, for tests. From app-server `turn_processor.rs`: `turn/start` picks the turn before it // answers, a send while a turn is open is steered into it under the same id with no // second `turn/started`, `turn/steer` refuses with -32600 unless its `expectedTurnId` is -// the running turn, and `turn_interrupt_inner` refuses with -32600 until the -// turn has started. The answer can be held, so a test can deliver it after the -// turn's own frames, as the wire allows. `legacyStartAnswers` is a Codex before 0.148, +// the running turn, and `turn_interrupt_inner`, with no turn active, refuses with -32600 "no +// active turn to interrupt" until the thread runs (`run`, or `start`) and once the turn has ended. +// An interrupt Codex takes is answered before its turn ends: on `TurnAborted`, +// `bespoke_event_handling.rs` answers pending interrupts, then sends `turn/completed` +// (interrupted) on the same channel; here that end comes on a later read. The `turn/start` +// answer can be held, so a test can deliver it after the turn's own frames, as the wire +// allows. `legacyStartAnswers` is a Codex before 0.148, // whose `turn/start` answers a steered send with its submission id, a turn that never opens, // and whose `turn/steer` finds no turn to steer until the picked one has started. @@ -22,7 +26,13 @@ export type CodexTurnLifecycleFake = { holdNextAnswer: () => () => void /** Codex emits `turn/started` for the turn it picked last. */ start: () => void - /** Codex ends the picked or running turn on its own. */ + /** The thread runs the picked turn, whose `turn/started` has yet to be read: an interrupt of it + * is taken, and that `turn/started` comes ahead of the answer. */ + run: () => void + /** Codex takes an interrupt of the running turn: it drops it as its active turn before it + * answers, and that turn's `turn/completed` comes later (`end`). */ + takeInterrupt: () => string + /** Codex ends the picked or running turn on its own, or the one whose interrupt it took. */ end: (status: 'completed' | 'interrupted' | 'failed', errorMessage?: string) => void /** Codex fails the picked turn before starting it, which reports an `error` and no turn end. */ failUnopened: (message: string) => void @@ -51,14 +61,41 @@ export function codexTurnLifecycleFake( let active: string | null = null let lastTurn: string | null = null let held: Promise | null = null + let pickedRuns = false + // The turn whose interrupt Codex took and whose `turn/completed` is still to come. + let aborting: string | null = null const finish = (turnId: string, status: string, errorMessage?: string): void => { - picked = null - active = null + // A taken interrupt's turn is no longer active: a turn picked since stays picked. + if (aborting === turnId) { + aborting = null + } else { + picked = null + active = null + pickedRuns = false + } notify()('turn/completed', { threadId, turn: { id: turnId, status, ...(errorMessage ? { error: { message: errorMessage } } : {}) } }) } + + const takeInterrupt = (): string => { + if (!active) { + throw new Error('no running turn to interrupt') + } + aborting = active + active = null + picked = null + return aborting + } + const open = (): void => { + if (!picked) { + throw new Error('no picked turn to start') + } + active = picked + pickedRuns = false + notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + } return { routes: { 'turn/start': () => { @@ -88,6 +125,9 @@ export function codexTurnLifecycleFake( }, 'turn/interrupt': (params) => { const turnId = params?.turnId + if (!active && pickedRuns && picked === turnId) { + open() + } if (!active) { throw refusal('turn/interrupt', 'no active turn to interrupt') } @@ -97,8 +137,13 @@ export function codexTurnLifecycleFake( `expected active turn id ${String(turnId)} but found ${active}` ) } - // Codex answers the interrupt once the turn has aborted. - finish(active, 'interrupted') + const aborted = takeInterrupt() + // Answered, then the turn's end on the next read; a turn that ends meanwhile is not ended twice. + setTimeout(() => { + if (aborting === aborted) { + finish(aborted, 'interrupted') + } + }, 0) return {} } }, @@ -109,15 +154,17 @@ export function codexTurnLifecycleFake( }) return () => release() }, - start: () => { - if (!picked) { - throw new Error('no picked turn to start') + start: () => open(), + takeInterrupt, + run: () => { + if (!picked || active) { + throw new Error('no picked turn to run') } - active = picked - notify()('turn/started', { threadId, turn: { id: active, status: 'inProgress' } }) + pickedRuns = true }, end: (status, errorMessage) => { - const turnId = active ?? picked + // A taken interrupt's turn/completed comes ahead of anything a later turn sends. + const turnId = aborting ?? active ?? picked if (!turnId) { throw new Error('no turn to end') } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts index e79958a952c..baba17c0058 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-marks.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-marks.ts @@ -1,12 +1,15 @@ // What the journal answers about its Stops beyond the queue's pause: the latest Stop event, which -// the turn-end rule reads (`journal-stop-turn-end.ts`), and whether a person's still decides. +// the turn-end rule reads (`journal-stop-turn-end.ts`), whether a person's still decides, and what +// a person's Stop that named no turn binds while and after it settles. import type { JournalReducerState } from './journal-reducer' import { + beginJournalStopSettle, latestAcceptedSendUnopened, personStopDecidesTurn, type JournalLatestStop } from './journal-stop-turn-end' +import type { JournalStopSettle } from './queued-message-pause' export class JournalStopMarks { constructor(private readonly deps: { state: () => JournalReducerState }) {} @@ -21,7 +24,23 @@ export class JournalStopMarks { } /** `personStopDecidesTurn`: a person's Stop decides how turn `turnId` ends. */ - personStopDecides(turnId: string | null, endedAt?: number, openedBy?: string): boolean { - return personStopDecidesTurn(this.deps.state(), turnId, endedAt, openedBy) + personStopDecides(turnId: string | null, endedAt?: number): boolean { + return personStopDecidesTurn(this.deps.state(), turnId, endedAt) + } + + /** `beginJournalStopSettle`: a turn that ends from here until `settled` is the Stop's. */ + beginSettle(): JournalStopSettle | null { + return beginJournalStopSettle(this.deps.state()) + } + + /** Closes a settle `beginSettle` opened, binding `turnId` when the Stop stopped one. */ + settled(settle: JournalStopSettle | null, turnId?: string): void { + if (!settle) { + return + } + settle.settling = false + if (turnId !== undefined) { + settle.turnId = turnId + } } } diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts new file mode 100644 index 00000000000..59ab1bbb990 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.test.ts @@ -0,0 +1,90 @@ +// The turn-end rule as a Stop that named no turn binds it: every turn that ends while the Stop +// settles, then only the turn it stopped. + +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, type JournalReducerState } from './journal-reducer' +import type { JournalStopEvent } from './journal-row-schema' +import { + beginJournalStopSettle, + personStopDecidesTurn, + turnEndAfterStop +} from './journal-stop-turn-end' +import { JournalStopMarks } from './journal-stop-marks' + +const STOPPED_AT = 1_000 + +function stateWith(event?: JournalStopEvent): JournalReducerState { + const state = createJournalReducerState('session-1', 'epoch-1') + state.queuePauseMarks.latestStop = event ? { sequence: 5, event } : null + return state +} + +type TurnBody = Extract + +function ended(turnId: string): TurnBody { + return { kind: 'turn', turnId, state: 'interrupted', completedAt: STOPPED_AT + 10 } +} + +const turnless: JournalStopEvent = { reason: 'user-stop', at: STOPPED_AT } + +describe("a person's Stop that named no turn", () => { + it('makes any turn that ends while it settles theirs', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.beginSettle() + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(personStopDecidesTurn(state, null)).toBe(true) + }) + + it('once settled, binds only the turn it stopped', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + expect(turnEndAfterStop(state, 'other-item', ended('turn-b'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + expect(personStopDecidesTurn(state, 'turn-a')).toBe(true) + }) + + it('binds nothing once it settled having stopped nothing', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + }) + + it('binds nothing before it settles: a relaunch folds the event alone', () => { + const state = stateWith(turnless) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).not.toHaveProperty('outcome') + expect(personStopDecidesTurn(state, null)).toBe(false) + }) + + it('keeps the turn an earlier press bound when pressed again', () => { + const state = stateWith(turnless) + const marks = new JournalStopMarks({ state: () => state }) + marks.settled(marks.beginSettle(), 'turn-a') + marks.settled(marks.beginSettle()) + + expect(turnEndAfterStop(state, 'turn-item', ended('turn-a'))).toMatchObject({ + outcome: 'cancellation' + }) + }) +}) + +describe('a Stop with nothing in memory to bind', () => { + it.each([ + ['one that named its turn', { reason: 'user-stop', turnId: 'turn-a', at: STOPPED_AT }], + ['a host stop', { reason: 'host-stop', at: STOPPED_AT }] + ] satisfies [string, JournalStopEvent][])('opens no settle: %s', (_label, event) => { + expect(beginJournalStopSettle(stateWith(event))).toBeNull() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts index 4c28a0422a6..6b71477f74e 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts @@ -1,11 +1,11 @@ -// What a Stop decides about the turn it named: the one rule every turn-end write passes through. +// What a Stop decides about the turn it stopped: the one rule every turn-end write passes through. // -// A turn a person's Stop or close of this chat named, or, when it named none, a turn opened by a -// send it stopped, ending with no verdict of its own after that Stop's event, ends as their -// cancellation. A host stop, an eviction and no Stop at all leave the end as written. It runs -// where each row is built, inside the journal's serialized write, so it reads every Stop folded -// before the end: the adapter's settle, the host's fallback and a relaunch's settle all write -// through it, and every client folds the row it wrote. +// A turn a person's Stop or close of this chat named, or, when it named none, one that ended while +// that Stop settled or that its settle bound, ending with no verdict of its own after that Stop's +// event, ends as their cancellation. A host stop, an eviction and no Stop at all leave the end as +// written. It runs where each row is built, inside the journal's serialized write: the adapter's +// end, the host's fallback and a relaunch's settle all write through it, and every client folds +// the row it wrote. What an unnamed Stop binds is held in memory, so after a relaunch it binds none. import { agentJournalSubmissionKey, @@ -16,10 +16,9 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch' import type { JournalReducerState } from './journal-reducer' import type { JournalStopEvent } from './journal-row-schema' -import type { JournalQueuePauseMarks } from './queued-message-pause' +import type { JournalQueuePauseMarks, JournalStopSettle } from './queued-message-pause' export type JournalLatestStop = NonNullable @@ -40,7 +39,8 @@ type TurnEndState = Pick< 'items' | 'queuePauseMarks' | 'submissions' | 'aliases' > -/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. */ +/** The send whose journal item `userItemId` (a turn's opener, by its own or its provider key) is. + * Only says whether a send's turn has opened; it never decides whose a turn's end is. */ function openingSubmission( state: TurnEndState, userItemId: string | undefined @@ -83,72 +83,36 @@ export function latestAcceptedSendUnopened(state: TurnEndState): boolean { return true } -/** A send a Stop that named no turn stopped: one already handed to the agent at the Stop's - * position, whose turn had not opened. A card the Stop held, or anything sent after it, is not. */ -function isStopTarget( - state: TurnEndState, - stop: JournalLatestStop, - submission: AgentJournalSubmission -): boolean { - if ( - submission.dispatchState === 'rejected' || - (submission.handoverRecorded === true && submission.handedOverAt === undefined) - ) { - return false - } - // A handed-over send's item sits at its handover (`placeHandedOverMessage`). - const handedOver = state.items.get(agentJournalSubmissionKey(submission.clientMessageId)) - return handedOver !== undefined && handedOver.sequence < stop.sequence -} - /** Whether `stop`, a person's, makes the end of turn `turnId` theirs: it named that turn, or named - * none and stopped the send that opened it (`userItemId`). */ -function stopIsTurnCancellation( - state: TurnEndState, - stop: JournalLatestStop, - turnId: string, - userItemId: string | undefined -): boolean { + * none and the turn ends while it settles, or its settle bound that turn. */ +function stopIsTurnCancellation(stop: JournalLatestStop, turnId: string): boolean { if (!stopIsAPersons(stop.event.reason)) { return false } if (stop.event.turnId !== undefined) { return stop.event.turnId === turnId } - const opener = openingSubmission(state, userItemId) - return opener !== undefined && isStopTarget(state, stop, opener) + return stop.settle?.settling === true || stop.settle?.turnId === turnId } -/** THE rule: whether the latest Stop makes turn `turnId`, opened by `userItemId` and ending at - * `endedAt` with no verdict of its own, a person's cancellation. An exit the provider saw before - * the Stop was news, whenever its end is written. */ +/** THE rule: whether the latest Stop makes turn `turnId`, ending at `endedAt` with no verdict of its + * own, a person's cancellation. An exit the provider saw before the Stop was news, whenever its + * end is written. */ function stopEndsTurnAsCancellation( state: TurnEndState, turnId: string, - userItemId: string | undefined, endedAt: number | undefined ): boolean { const stop = state.queuePauseMarks.latestStop return ( stop !== null && - stopIsTurnCancellation(state, stop, turnId, userItemId) && + stopIsTurnCancellation(stop, turnId) && (endedAt === undefined || endedAt >= stop.event.at) ) } -/** With no turn running, the work in flight is the person's Stop's: every send still unanswered is - * one it stopped. */ -function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestStop): boolean { - const unanswered = [...state.submissions.values()].filter((submission) => - isUnansweredStructuredAgentSessionDispatch(submission) - ) - return ( - unanswered.length > 0 && unanswered.every((submission) => isStopTarget(state, stop, submission)) - ) -} - /** - * Whether a person's Stop decides the end of turn `turnId` (null: the sends in flight with no turn + * Whether a person's Stop decides the end of turn `turnId` (null: the work in flight with no turn * running), by `turnEndAfterStop`'s rule: ending at `endedAt` it is their cancellation, and still * running it is theirs to end. For a writer that must choose before the end is written: a host * stop must not supersede it, and a Claude error result naming no reason leaves its verdict to it. @@ -156,29 +120,36 @@ function unansweredSendsAreStopTargets(state: TurnEndState, stop: JournalLatestS export function personStopDecidesTurn( state: TurnEndState, turnId: string | null, - endedAt?: number, - /** The submission that opened the turn, for one whose rows have yet to land. */ - openedBy?: string + endedAt?: number ): boolean { const stop = state.queuePauseMarks.latestStop if (stop === null || !stopIsAPersons(stop.event.reason)) { return false } if (turnId === null) { - return stop.event.turnId === undefined && unansweredSendsAreStopTargets(state, stop) + return stop.event.turnId === undefined && stop.settle?.settling === true } - const turn = [...state.items.values()] - .map((item) => readAgentJournalTurn(item.body)) - .find((candidate) => candidate?.turnId === turnId) - const userItemId = - turn?.userItemId ?? (openedBy === undefined ? undefined : agentJournalSubmissionKey(openedBy)) - return stopEndsTurnAsCancellation(state, turnId, userItemId, endedAt) + return stopEndsTurnAsCancellation(state, turnId, endedAt) +} + +/** Opens the settle of the latest Stop, a person's that named no turn: until it closes, every turn + * that ends is that Stop's. Null when there is none, or it named its turn. */ +export function beginJournalStopSettle( + state: Pick +): JournalStopSettle | null { + const stop = state.queuePauseMarks.latestStop + if (stop === null || stop.event.turnId !== undefined || !stopIsAPersons(stop.event.reason)) { + return null + } + // A repeat press keeps the turn an earlier one bound. + stop.settle = { settling: true, ...(stop.settle?.turnId ? { turnId: stop.settle.turnId } : {}) } + return stop.settle } /** * The body to write for item `itemId`: unchanged unless it ends, with no verdict of its own and no - * earlier than the latest Stop event, a person's, which named it, or stopped the send that opened - * it, while it was still open (running, or unproven). A provider's own verdict always stands. + * earlier than the latest Stop event, a person's that named it or bound it, while it was still open + * (running, or unproven). A provider's own verdict always stands. */ export function turnEndAfterStop( state: TurnEndState, @@ -193,8 +164,7 @@ export function turnEndAfterStop( if (previous && previous.state !== 'running' && previous.state !== 'unverifiable') { return body } - const userItemId = body.userItemId ?? previous?.userItemId - return stopEndsTurnAsCancellation(state, body.turnId, userItemId, body.completedAt) + return stopEndsTurnAsCancellation(state, body.turnId, body.completedAt) ? { ...body, outcome: 'cancellation' } : body } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 2349524ccfe..db63aea8870 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -155,6 +155,10 @@ export class AgentSessionJournal { return this.state.epoch } + get agent(): AgentSessionJournalIdentity['agent'] { + return this.identity.agent + } + /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ wroteBeforeOpen(sequence: number | undefined): boolean { diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.ts index 9039760ba43..63dc6823df2 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.ts @@ -15,9 +15,13 @@ import type { JournalStopEvent, JournalTombstoneRow } from './journal-row-schema export type QueuePauseReason = 'stopped' | 'cleared' | 'restarted' +/** What a person's Stop that named no turn binds, held in memory and never read from a row, so a + * reopen binds nothing: while it settles, every turn that ends; once settled, the turn it stopped. */ +export type JournalStopSettle = { settling: boolean; turnId?: string } + /** The latest Stop event, whatever its reason, and the latest Resume row, folded by the reducer. */ export type JournalQueuePauseMarks = { - latestStop: { sequence: number; event: JournalStopEvent } | null + latestStop: { sequence: number; event: JournalStopEvent; settle?: JournalStopSettle } | null /** 0 when none. */ resumedSequence: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts index 4709234ad5c..a6b12b4f4fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-stop.ts @@ -10,10 +10,13 @@ import type { ProviderDiagnostic } from '../../../shared/agent-session-failure' /** `refusal`: the provider answered the Stop and declined it, in its own words when it gave any. * `turnNotRunning`: its refusal is the kind it gives for a turn not running there, so the Stop keeps - * the child; absent, it could not interrupt that turn, which may run on. */ + * the child; absent, it could not interrupt that turn, which may run on. `turnMayOpen`: no turn + * was there to interrupt, but one a send is owed may still open. `turnId`: the journal turn a Stop + * that took interrupted, which the Stop then binds. */ export type AgentSessionCancelOutcome = { cancelled: boolean - refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true } + refusal?: { detail?: ProviderDiagnostic; turnNotRunning?: true; turnMayOpen?: true } + turnId?: string } /** Where a card's Cancel goes: a dismissal (`dismissPrompt`), or the chat's Stop. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index c89c0fc3d6b..b55ebbda2d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -109,9 +109,17 @@ export function mutateWithChatStop( } return { ok: true, value: { ...named, cancelled: withdrewAny } } } + const reach = hadQueued ? 'unrecorded' : stopReachesUnrecordedWork(ctx, turnId) + const priorStop = ctx.journal.stopMarks.latest() // Issued, not awaited, before the interrupt or any child end; the `finally` awaits it. - const effect = - hadQueued || stopReachesUnrecordedWork(ctx, turnId) ? tookEffect() : Promise.resolve() + const effect = reach === 'unrecorded' ? tookEffect() : Promise.resolve() + // Its settle binds the latest Stop only when that Stop is this press's own, or the one in + // force this press repeats; a late Stop or an event not yet written binds nothing. A write + // is in the fold by its call's return, so a newer latest Stop is this press's event. + const ownsLatestStop = + reach === 'unrecorded' + ? ctx.journal.stopMarks.latest()?.sequence !== priorStop?.sequence + : reach === 'repeat' try { return await performCancel( { ...ctx, failureTextContext: structuredAgentSessionFailureWordsContext(record) }, @@ -130,7 +138,8 @@ export function mutateWithChatStop( endSession: (owed) => { windDown = owed }, - withdrewQueued: withdrew + withdrewQueued: withdrew, + ...(ownsLatestStop ? { opensSettle: true as const } : {}) } ) } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index aab219c779d..6f03cc1345c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -240,7 +240,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it refused because its turn is not running', async () => { @@ -271,7 +273,9 @@ describe('a Stop that names no turn', () => { expect(await stop()).toMatchObject({ ok: true, value: { cancelled: true } }) expect(closeSession).toHaveBeenCalledExactlyOnceWith(SESSION) - expect(await statusRows()).toEqual(['Cancellation requested.']) + // Its turn never opened, so the child's end took the send back: no row stays to report on it. + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the agent did not stop, in its words, when it could not interrupt and the child end is unproven', async () => { @@ -295,7 +299,7 @@ describe('a Stop that names no turn', () => { expect(await statusRows()).toEqual(["Codex didn't stop: failed to interrupt turn."]) }) - it('reads as requested when the child exit was proven and only a later cleanup step failed', async () => { + it('counts as stopped when the child exit was proven and only a later cleanup step failed', async () => { const { id, result } = send('hello') await result await eventually(async () => expect((await submission(id))?.handedOverAt).toBeDefined()) @@ -312,7 +316,8 @@ describe('a Stop that names no turn', () => { fields: expect.objectContaining({ scope: 'stop-child', sessionId: SESSION }) }) ) - expect(await statusRows()).toEqual(['Cancellation requested.']) + expect((await submission(id))?.dispatchState).toBe('rejected') + expect(await statusRows()).toEqual([]) }) it('says the Stop is unconfirmed, not that nothing ran, when neither the interrupt nor the child end is proven', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts index f0372c8e58d..639b34cb795 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts @@ -38,23 +38,20 @@ import { exitedRootTurnScope, runningRootTurnScope } from './structured-agent-session-exit-turn-scope' +import { + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' /** Bounds the exit reason the lease keeps as log evidence; a provider diagnostic is held to the * same cap. */ export const MAX_UNEXPECTED_EXIT_REASON_CHARS = MAX_PROVIDER_DIAGNOSTIC_CHARS -type DeadGenerationSubmission = Pick< - ReturnType[number], - 'clientMessageId' | 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' -> - -export type DeadGenerationJournal = { +export type DeadGenerationJournal = UnopenedSendJournal & { appendLifecycleBatch: AgentSessionJournal['appendLifecycleBatch'] markPendingSubmissionsUnknown: AgentSessionJournal['markPendingSubmissionsUnknown'] rejectPendingSubmissions: AgentSessionJournal['rejectPendingSubmissions'] - snapshot: () => Pick, 'items'> pendingSubmissions?: AgentSessionJournal['pendingSubmissions'] - submissions?: () => DeadGenerationSubmission[] } export type StructuredAgentSessionUnfinishedWork = { @@ -139,6 +136,9 @@ export async function settleStructuredAgentSessionDeadGeneration(input: { const startupFailure = input.exitedDuringStartup ? structuredAgentSessionStartFailure({ exit: input.exitFailure }, input.failureTextContext) : null + if (!startupFailure) { + await withdrawCodexSendsNoTurnOpenedFor(input.journal, input.fence) + } await (startupFailure ? input.journal.rejectPendingSubmissions(input.fence, startupFailure) : input.journal.markPendingSubmissionsUnknown(input.fence, input.pendingSubmissionReason)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index bdc07a3b3d5..afcecadc744 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -85,8 +85,8 @@ export class StructuredAgentSessionSinkQueue { journalLinkage = (): StructuredAgentSessionLinkageJournal | null => this.target?.journal ?? null - journalStopDecidesTurn = (turnId: string, endedAt: number, openedBy?: string): boolean => - this.target?.journal.stopMarks.personStopDecides(turnId, endedAt, openedBy) ?? false + journalStopDecidesTurn = (turnId: string, endedAt: number): boolean => + this.target?.journal.stopMarks.personStopDecides(turnId, endedAt) ?? false bindReadingControl(control: StructuredAgentSessionReadingControl): () => void { this.readingControl = control 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 index 49572abdcc0..12966b73a97 100644 --- 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 @@ -142,9 +142,8 @@ export type StructuredAgentSessionEventSink = { /** The bound journal's producer linkage; null until bound. */ journalLinkage?(): StructuredAgentSessionLinkageJournal | null /** Whether the bound journal's Stop rule makes turn `turnId`, ending at `endedAt` with no verdict - * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. `openedBy`: - * the submission that opened it, for a turn whose rows have yet to land. */ - journalStopDecidesTurn?(turnId: string, endedAt: number, openedBy?: string): boolean + * of its own, a person's cancellation (`personStopDecidesTurn`); false until bound. */ + journalStopDecidesTurn?(turnId: string, endedAt: number): boolean appendLifecycleBatch?( settlementId: string, mutations: readonly JournalLifecycleMutationInput[], 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 index 60c20cb768d..2c312ef17e8 100644 --- 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 @@ -140,7 +140,7 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( // Judged before the kill: a stop that ends nothing writes nothing. const recorded = (await stopEndsWork(context, sessionId, session, ending)) ? recordStopEvent(context, sessionId, session, ending) - : Promise.resolve() + : Promise.resolve(null) // The obligation OUTLIVES the child. `child` is ended the instant the adapter proves the exit, // so a step that aborts after that point would otherwise leave the retry reading "no child // here" and skipping the settlement and the lease release it still owes. @@ -233,6 +233,9 @@ export async function stopStructuredAgentSessionAgentUnderSerialize( session.owesProviderChildWindDown = { ...owed, failedAt: session.journal.cursor() } } throw error + } finally { + // A person's close binds what its child's end cut; done, proven or not, it binds no more. + void recorded.then((settle) => session.journal.stopMarks.settled(settle)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts index 757a76a9059..70cfd07bb7a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts @@ -7,6 +7,8 @@ import type { StructuredAgentSessionStopCause } from './structured-agent-session import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { JournalStopSettle } from '../agent-session-journal/queued-message-pause' +import { sentSinceStop } from './structured-agent-session-queued-stop' /** How a stop ends the child, and why (`lastEndedChild`). A person's Stop wrote its event in its * own step (`recorded` names its reason); any other stop names the reason its event records, with @@ -63,23 +65,37 @@ export async function stopEndsWork( journal.submissions(), child.fence ) - // A host stop of work a person's Stop is already ending must not supersede that Stop's reason. + return working && (ending.cause === 'user-close' || !defersToPersonsStop(session)) +} + +/** A host stop of work a person's Stop is already ending must not supersede that Stop's reason: + * the turn it decides, or, with no turn running, the work under the queue pause it still holds, + * with nothing sent since, which a host's event would lift. */ +function defersToPersonsStop(session: StructuredAgentSessionHostSession): boolean { + const { journal } = session + const live = journal.activeTurnId() + if (live !== null) { + return journal.stopMarks.personStopDecides(live) + } + const inForce = journal.queuedMessages.userStopInForce() return ( - working && - (ending.cause === 'user-close' || !journal.stopMarks.personStopDecides(journal.activeTurnId())) + (inForce !== null && !sentSinceStop(journal, inForce)) || + journal.stopMarks.personStopDecides(null) ) } /** Writes this stop's event (`JournalStopEvent`). Issued before the kill and never awaited by it: - * bookkeeping, reported on failure. */ + * bookkeeping, reported on failure. A person's close that named no turn opens its settle once its + * event lands, as a person's Stop does, so a turn the child's end cuts is theirs; the caller + * closes it once that end is done. */ export function recordStopEvent( context: StructuredAgentSessionLifetimeContext, sessionId: string, session: StructuredAgentSessionHostSession, ending: StructuredAgentSessionStopEnding -): Promise { +): Promise { if ('recorded' in ending) { - return Promise.resolve() + return Promise.resolve(null) } const turnId = session.journal.activeTurnId() return session.journal @@ -88,12 +104,14 @@ export function recordStopEvent( structuredAgentSessionConversationFence(context.deps.store, sessionId) ) .then( - () => undefined, - (error: unknown) => + () => (ending.cause === 'user-close' ? session.journal.stopMarks.beginSettle() : null), + (error: unknown) => { context.deps.logger.warn("a host stop's Stop event row skipped", { scope: 'stop-event', sessionId, error }) + return null + } ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts index ccdc429e34e..03c1042309f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts @@ -58,30 +58,42 @@ export async function runRecordedStop( } /** - * Whether a Stop reaching a running agent stops anything no Stop event records yet. Not when it - * names a turn already over (a late Stop from a phone), nor when it repeats the Stop still in force - * with nothing sent since, on the same turn or one that opened after a Stop pressed before any - * turn showed: a card queued between the presses then sends normally, as after one Stop. + * What a Stop reaching a running agent stops: work no Stop event records yet (`unrecorded`), or + * only what the Stop still in force already records, which it repeats with nothing sent since, on + * the same turn or one that opened after a Stop pressed before any turn showed (`repeat`): a card + * queued between the presses then sends normally, as after one Stop. `late`: it names a turn + * already over, as a late Stop from a phone can. */ export function stopReachesUnrecordedWork( ctx: Pick, namedTurnId: string | undefined -): boolean { +): 'unrecorded' | 'repeat' | 'late' { const live = ctx.journal.activeTurnId() // No turn published yet while the agent works: the named one may still be opening. if ( structuredAgentSessionStopNamesTurnNotLive(namedTurnId, live) && (live !== null || !isMainAgentWorking(ctx)) ) { - return false + return 'late' } const inForce = ctx.journal.queuedMessages.userStopInForce() if (inForce === null) { - return true + return 'unrecorded' } - // Sent after that Stop and not refused, even if its fate is unknown: this interrupt may send it - // back to waiting, so this Stop must hold it. A send with no sequence is an older host's. - const sentSince = ctx.journal + // This interrupt may send a later send back to waiting, so this Stop must hold it. + return sentSinceStop(ctx.journal, inForce) || + structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) + ? 'unrecorded' + : 'repeat' +} + +/** Whether anything was sent after the Stop in force and not refused, even if its fate is + * unknown. A send with no sequence is an older host's. */ +export function sentSinceStop( + journal: Pick, + inForce: { sequence: number } +): boolean { + return journal .submissions() .some( (entry) => @@ -89,7 +101,6 @@ export function stopReachesUnrecordedWork( entry.acceptedSequence !== undefined && entry.acceptedSequence > inForce.sequence ) - return sentSince || structuredAgentSessionStopNamesTurnNotLive(inForce.event.turnId, live) } /** The Stop's withdrawal of every queued send, issued at once and never awaited ahead of the diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2654b20ac73..a91ed7c8cbd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -1,9 +1,9 @@ -// Which turn a person's Stop that named no turn binds: only a turn a send it stopped opens. A Stop -// of a start that never landed stopped a send that opens no turn; a card it held, which Resume -// releases, and anything sent after it open their own; a rewind keeps the binding. Turn rows name -// the send that opened them, as Codex writes them. +// Which turn a person's Stop that named no turn binds: one that ends while the Stop settles, and +// the turn its interrupt took. A Stop that stopped nothing binds no turn that opens after it, nor +// does a Stop of a start that never landed; a card it held, which Resume releases, and anything +// sent after it end as their own. Turn rows name the send that opened them, as Codex writes them. -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { AGENT_JOURNAL_THREAD_SCOPE, @@ -12,10 +12,11 @@ import { import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { JournalStopEvent } from '../agent-session-journal/journal-row-schema' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' -import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION, hostTestOperationId } from './structured-agent-session-host-test-data' import { createQueuedMessageTestRig, eventually, + QUEUED_RIG_CALLER, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' @@ -63,6 +64,18 @@ async function laterTurn() { .find((turn) => turn?.turnId === 'turn-later') } +/** Every end row written for the later turn, in order: a relabel would show as two. */ +function laterTurnEndRows() { + const since = journal().readSince({ epoch: journal().epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + return since.rows.flatMap((row) => { + const turn = row.kind === 'item' ? readAgentJournalTurn(row.body) : undefined + return turn?.turnId === 'turn-later' && turn.state !== 'running' ? [turn] : [] + }) +} + /** The turn send `clientMessageId` opens, running, named by its row as Codex writes it. */ async function turnOpenedBy(clientMessageId: string, state: 'running' | 'interrupted' = 'running') { await journal().appendItem( @@ -185,19 +198,22 @@ describe('a Stop of a start that never landed binds no later turn', () => { }) }) -describe('a Stop pressed before its send opened a turn binds only that turn', () => { +describe('a Stop pressed before its send opened a turn binds only the turn it stopped', () => { /** The send the Stop stopped is handed over and unopened; a card waits behind it. */ - async function stopBeforeTheTurnShowed(): Promise<{ stopped: string; held: string }> { + async function stopBeforeTheTurnShowed( + answer: Awaited> = { cancelled: true } + ): Promise<{ stopped: string; held: string }> { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() const held = await queuedDraft('queued behind the turn') + rig.cancelTurn.mockResolvedValueOnce(answer) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') return { stopped, held } } - it("binds the stopped send's own turn", async () => { - const { stopped } = await stopBeforeTheTurnShowed() + it('binds the turn its interrupt took, whose end lands after the Stop answered', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: true, turnId: 'turn-later' }) await rig.settleAccepted(stopped, 'stopped') await turnOpenedBy(stopped, 'interrupted') @@ -205,6 +221,34 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) }) + // The interrupt reaches the turn as it opens; its end is written before the Stop answers. + it('reads a turn that ends while the Stop settles as interrupted from its first end row', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + return { cancelled: true, turnId: 'turn-later' } + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(laterTurnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + expect(journal().activeTurnId()).toBeNull() + }) + + it('binds no turn that opens after a Stop that stopped nothing', async () => { + const { stopped } = await stopBeforeTheTurnShowed({ cancelled: false }) + await rig.settleAccepted(stopped, 'stopped') + + await turnOpenedBy(stopped) + await turnOpenedBy(stopped, 'interrupted') + + await expectNews() + }) + it("writes the host's event when it evicts the turn of the card Resume sent", async () => { const { stopped, held } = await stopBeforeTheTurnShowed() await rig.settleAccepted(stopped, 'stopped') @@ -229,14 +273,116 @@ describe('a Stop pressed before its send opened a turn binds only that turn', () }) }) -describe('a host stop with no turn running after a Stop that named none', () => { - it('writes nothing while every unanswered send is one the Stop stopped', async () => { +describe('a press that writes no Stop event of its own', () => { + // A turnless Stop long since settled, then a later turn the person's own send opened. + async function laterTurnAfterAnEarlierStop(): Promise { rig = await createQueuedMessageTestRig() await rig.workingSend() expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + const sent = await rig.workingSend() + await rig.settleAccepted(sent, 'sent') + await turnOpenedBy(sent) + return sent + } + + it('reopens no earlier Stop: a late Stop naming a turn already over binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + const late = await rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: 'turn-gone' }, 'agentSession.cancel', hostTestOperationId()), + turnId: 'turn-gone' + }) + + expect(late).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) + + // The event is in the fold by its write's return, so one that failed leaves the earlier Stop latest. + it('reopens no earlier Stop: a press whose event row failed binds no turn', async () => { + const sent = await laterTurnAfterAnEarlierStop() + const earlier = journal().stopMarks.latest() + vi.spyOn(journal(), 'appendStopEvent').mockRejectedValueOnce(new Error('disk full')) + rig.cancelTurn.mockImplementationOnce(async () => { + await turnOpenedBy(sent, 'interrupted') + return { cancelled: false } + }) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(journal().stopMarks.latest()).toBe(earlier) + await expectNews() + }) +}) + +describe("a Stop's settle that ends the turn its interrupt took", () => { + it("ends a turn still running at the Stop's settle, as the Stop's, once", async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + await rig.settleAccepted(stopped, 'stopped') + await turnOpenedBy(stopped) + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: 'turn-later' }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(journal().activeTurnId()).toBeNull() + }) +}) + +describe('a host stop with no turn running after a Stop that named none', () => { + it('defers to that Stop while it settles, and not after', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + let whileSettling: boolean | undefined + rig.cancelTurn.mockImplementationOnce(async () => { + whileSettling = journal().stopMarks.personStopDecides(null) + return { cancelled: true } + }) + + expect(await rig.stop()).toMatchObject({ ok: true }) + + expect(whileSettling).toBe(true) + expect(journal().stopMarks.personStopDecides(null)).toBe(false) + }) + + it.each([ + ['stopped nothing', { cancelled: false }], + ['took', { cancelled: true }] + ] as const)( + 'writes nothing while the Stop, which %s, still pauses the queue, and the card stays held', + async (_, answer) => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce(answer) + expect(await rig.stop()).toMatchObject({ ok: true }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() + } + ) + + // Codex could not reach a turn still able to open, and its process could not be ended. + it('writes nothing after a Stop whose kill failed, and the card stays held', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + const card = await queuedDraft('queued behind the send') + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) expect(await evictedAt()).toEqual(['user-stop']) + expect(await rig.queuePause()).toMatchObject({ reason: 'stopped' }) + expect(await rig.handoff(card)).toBeUndefined() }) it("writes the host's event when a send after the Stop is unanswered beside the stopped one", async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 50056eedd03..916654cac64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -1,7 +1,7 @@ -// A Stop that took effect still decides its turn after Orca restarts before the turn's end was -// written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" -// with the muted mark, not "Failed". A turn nobody stopped, and one a Stop never named, still read -// as the news they are. +// A Stop that took effect still decides the turn it named after Orca restarts before the turn's end +// was written: the relaunch's settle reads the Stop's event, so the turn reads "Interrupted after N" +// with the muted mark, not "Failed". A turn nobody stopped, one a Stop never named, and one a +// turnless Stop was still settling when Orca died, still read as the news they are. import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' @@ -138,6 +138,26 @@ describe('a restart between a Stop and its turn end', () => { expect(errorRows).toEqual([]) }) + // A Stop naming its turn, as the phone sends one: its event names the turn, which is what a + // relaunch reads, so it still counts though Orca died with its interrupt out. + it.each([ + ['Claude', CLAUDE_TURN], + ['Codex', CODEX_TURN] + ])('reads Interrupted after N for a %s Stop that named its turn', async (_label, identity) => { + await runningTurn(identity) + rig.cancelTurn.mockImplementationOnce(() => new Promise(() => undefined)) + void rig.host.cancel(QUEUED_RIG_CALLER, { + envelope: rig.envelope({ turnId: TURN }, 'agentSession.cancel', hostTestOperationId()), + turnId: TURN + }) + await expect.poll(() => journal().stopMarks.latest()?.event.turnId).toBe(TURN) + + await restartAndSettle() + + expect(settled().turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(settled().label).toMatch(/^Interrupted after /) + }) + it('reads Interrupted after N, marked interrupted: a close of the chat that died midway', async () => { await runningTurn(CODEX_TURN) // The host dies inside the close: the provider's close never answers, and nothing settles. @@ -273,10 +293,11 @@ describe('a restart between a Stop and its turn end', () => { expect(settled().turn).not.toHaveProperty('outcome') }) - // A Stop pressed before any turn showed stopped the turn its send was about to open, and no other. + // A Stop pressed before any turn showed stopped the turn its interrupt took, and no other. it('reads a turn a host send opened after the turnless Stop ended its own turn as no Stop of its', async () => { rig = await createQueuedMessageTestRig() const stopped = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: true, turnId: TURN }) expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') @@ -323,6 +344,35 @@ describe('a restart between a Stop and its turn end', () => { expect(settled(NEXT_TURN).turn).not.toHaveProperty('outcome') }) + // What a turnless Stop binds is held in memory: a relaunch finds it unsettled, and it binds none. + it('reads the turn a turnless Stop was settling when Orca died as no Stop of its', async () => { + rig = await createQueuedMessageTestRig() + const stopped = await rig.workingSend() + // Orca dies while the interrupt is out: the Stop never settles. + rig.cancelTurn.mockImplementationOnce(async () => { + await journal().appendItem( + CODEX_TURN, + { + kind: 'turn', + turnId: TURN, + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(stopped) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + return new Promise(() => undefined) + }) + void rig.stop() + await expect.poll(() => journal().activeTurnId()).toBe(TURN) + expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() + + await restartAndSettle('exit-observed') + + expect(settled(TURN).turn).toMatchObject({ state: 'interrupted' }) + expect(settled(TURN).turn).not.toHaveProperty('outcome') + }) + // A refusal is no record: the first Stop stays the one in force, so pressing again repeats it. it('writes nothing for a Stop pressed again after the provider refused one', async () => { await runningTurn(CODEX_TURN) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts new file mode 100644 index 00000000000..a3c8853ef67 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-note-withdrawn-send.test.ts @@ -0,0 +1,148 @@ +// A Stop that ends the child after its interrupt failed: its row reports on the run it stopped, so +// one whose child end took back the send it found, with no turn running, leaves none. Its message +// is back in the composer, and a row would sit under the turn before as if that turn were stopped. + +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 { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string | null = null +const journals = createTrackedJournalOpener() + +afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } +}) + +async function stopEndingTheChild(options: { + turnRunning: boolean + takesSendBack: boolean + earlierProcessSendInDoubt?: boolean +}) { + root = await mkdtemp(join(tmpdir(), 'orca-stop-note-withdrawn-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + if (options.turnRunning) { + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + if (options.earlierProcessSendInDoubt) { + await journal.appendSubmission({ + clientMessageId: 'send-0', + payloadFingerprint: 'send-0', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'before the crash' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-0', + state: 'unknown', + reason: 'host_restarted', + fence: 1, + recovered: true + }) + } + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + fence: 1 + }) + const ctx: AgentSessionTurnContext = { + logger: createStructuredAgentSessionLogger(), + sessionId: 'session-1', + journal, + fence: 1, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + closeSession: vi.fn(), + cancelTurn: vi.fn(async () => ({ + cancelled: false, + refusal: { detail: { text: 'failed to interrupt turn', audience: 'person' as const } } + })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + persistOptions: async () => undefined, + resolvedBy: 'client-1', + publish: vi.fn(), + now: () => 1 + } + const result = await performCancel(ctx, { + clientOperationId: 'stop-1', + stopChild: async () => { + if (options.takesSendBack) { + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }), + fence: 1, + recovered: true + }) + } + } + }) + const rows = journal + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.text] : [])) + return { cancelled: result.ok && result.value.cancelled, rows } +} + +describe('a Stop that ends the child after its interrupt failed', () => { + it('leaves no row when the child end took back the send, with no turn running', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: true })).toEqual({ + cancelled: true, + rows: [] + }) + }) + + it('says cancellation was requested when the send stays in doubt', async () => { + expect(await stopEndingTheChild({ turnRunning: false, takesSendBack: false })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) + + // That send was never this child's to take back, so it does not keep the row. + it("leaves no row when the only other send in doubt was an earlier process's", async () => { + expect( + await stopEndingTheChild({ + turnRunning: false, + takesSendBack: true, + earlierProcessSendInDoubt: true + }) + ).toEqual({ cancelled: true, rows: [] }) + }) + + it('says cancellation was requested on the turn it stopped, whatever became of the send', async () => { + expect(await stopEndingTheChild({ turnRunning: true, takesSendBack: true })).toEqual({ + cancelled: true, + rows: ['Cancellation requested.'] + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts new file mode 100644 index 00000000000..14e62854bbc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-settle-binding.test.ts @@ -0,0 +1,197 @@ +// A Stop pressed before its send's turn showed binds the turn that send opens only by stopping it: +// a Codex turn that did not open in time ends with the child the Stop ends, and a Claude turn that +// opens before the Stop's child end is proven ends as the Stop's. Nothing reads as running after. + +import { afterEach, describe, expect, it } from 'vitest' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +let rig: QueuedMessageTestRig + +afterEach(() => rig.dispose()) + +const LATER: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-later', + ordinal: 1000 +} + +const OPENED: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-opened', + ordinal: 999 +} + +function journal() { + const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal + if (!open) { + throw new Error('expected the conversation open') + } + return open +} + +/** The turn `clientMessageId` opens, running, as the provider writes it. */ +async function turnOpens(clientMessageId: string): Promise { + await journal().appendItem( + OPENED, + { + kind: 'turn', + turnId: 'turn-opened', + state: 'running', + startedAt: Date.now(), + userItemId: agentJournalSubmissionKey(clientMessageId) + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function openedTurn() { + return journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-opened') +} + +function nothingRuns(): boolean { + return !isStructuredAgentSessionMainAgentWorking( + journal().activeTurnId(), + journal().submissions() + ) +} + +/** Drains the Stop's next step on the session's lane: a wind-down it handed the child's end to. */ +async function windDown(): Promise { + await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {}) +} + +describe('a Codex Stop whose answered turn did not open in time', () => { + // Codex admitted the send, answered into a turn it has not opened; the Stop's wait ran out. + async function stopOfAnsweredSend(): Promise { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + return sent + } + + it('ends the child, so the turn can never run', async () => { + await stopOfAnsweredSend() + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toBeUndefined() + expect(nothingRuns()).toBe(true) + }) + + it('reads a turn that opened before the child end as interrupted by the Stop', async () => { + const sent = await stopOfAnsweredSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) + +describe('a Codex Stop that could not reach a turn still able to open, whose kill failed', () => { + it('says the Stop is unconfirmed, never that no turn was running', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + rig.cancelTurn.mockResolvedValueOnce({ cancelled: false, refusal: { turnMayOpen: true } }) + rig.closeSession.mockRejectedValueOnce(new Error('the kill timed out')) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: false } }) + + const rows = journal() + .snapshot() + .items.flatMap((item) => (item.body.kind === 'status' ? [item.body.failure?.kind] : [])) + expect(rows).toEqual(['cancelUnconfirmed']) + }) +}) + +describe('a Claude Stop pressed before its send echoed', () => { + it('reads the turn that opens before its child end is proven as interrupted by the Stop', async () => { + rig = await createQueuedMessageTestRig({ stopEndsSession: true }) + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } }) + await windDown() + + expect(rig.closeSession).toHaveBeenCalled() + expect(openedTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + expect(nothingRuns()).toBe(true) + }) +}) + +describe("a person's close pressed before its send's turn showed", () => { + it('reads the turn its child end cut as interrupted by the person', async () => { + rig = await createQueuedMessageTestRig() + const sent = await rig.workingSend() + rig.closeSession.mockImplementationOnce(async () => { + await turnOpens(sent) + return true + }) + + await rig.host.close(HOST_TEST_SESSION, 'user-close') + + const { items } = await rig.host.journalSnapshot(HOST_TEST_SESSION) + const turn = items + .map((item) => readAgentJournalTurn(item.body)) + .find((entry) => entry?.turnId === 'turn-opened') + expect(turn).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + }) + + // A /clear stops the agent as the person's close and keeps the conversation, so the close's + // settle must have closed by the time a later turn ends. + it('binds no turn that ends on its own after a close the conversation outlived', async () => { + rig = await createQueuedMessageTestRig() + await rig.workingSend() + await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' }) + expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' }) + expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') + + await journal().appendItem( + LATER, + { kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal().appendItem( + LATER, + { + kind: 'turn', + turnId: 'turn-later', + state: 'interrupted', + startedAt: Date.now(), + completedAt: Date.now() + 5 + }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const later = journal() + .snapshot() + .items.map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-later') + expect(later).toMatchObject({ state: 'interrupted' }) + expect(later).not.toHaveProperty('outcome') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index 5e26d1b620f..6337d65f553 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -2,7 +2,11 @@ import { agentChildWorkStopTargets } from '../../../shared/agent-child-work-stop import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import type { AgentJournalStatusItem } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalStatusItem, + AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionCancelResult } from '../../../shared/agent-session-wire' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' import type { AgentSessionCancelOutcome } from './structured-agent-session-adapter' @@ -22,6 +26,8 @@ import { structuredAgentSessionStoppedTurnId } from './structured-agent-session-turn-stop-notes' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' +import { runningTurnLifecycleRevisions } from './structured-agent-session-stale-turn-verdict' +import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' /** Whether the fold reads working. Every write has landed by its call's return, and the open paid @@ -52,25 +58,46 @@ function stillRunsStoppedTurn( return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId } -/** The row for a Stop the provider declined, in its words when it gave any. */ +/** Whether the child's end took back every send the Stop found in flight, none of them having run + * (`withdrawCodexSendsNoTurnOpenedFor`). */ +function tookBackEverySend( + ctx: Pick, + inFlight: readonly AgentJournalSubmission[] +): boolean { + const rejected = new Set( + ctx.journal + .submissions() + .flatMap((entry) => (entry.dispatchState === 'rejected' ? [entry.clientMessageId] : [])) + ) + return inFlight.length > 0 && inFlight.every((entry) => rejected.has(entry.clientMessageId)) +} + +/** The row for a Stop the provider declined, in its words when it gave any. One that could not + * reach a turn still able to open says the Stop is unconfirmed, never that nothing ran. */ function stopRefusedNote( ctx: Pick, refusal: AgentSessionCancelOutcome['refusal'] ): AgentJournalStatusItem { const detail = refusal?.detail + const fact = refusal?.turnMayOpen + ? agentSessionFailureFact('cancelUnconfirmed') + : agentSessionFailureFact('stopRefused', detail ? { detail } : {}) return { kind: 'status', - ...agentSessionFailureWords(agentSessionFailureFact('stopRefused', detail ? { detail } : {}), { - ...ctx.failureTextContext, - surface: 'row' - }) + ...agentSessionFailureWords(fact, { ...ctx.failureTextContext, surface: 'row' }) } } /** What a Stop that ends the provider's session leaves its next serialized step: whether the * provider took the interrupt, so its wind-down is worth waiting on, and when the interrupt went - * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. */ -export type StructuredAgentSessionStopWindDown = { waitsForProvider: boolean; stoppedAt: number } + * out. No turn id: that step runs right behind the Stop, so no later turn can slip in between. + * `settled` closes the Stop's settle once the wind-down finishes, whether or not the child's exit + * was proven: until then, what ends is the Stop's. */ +export type StructuredAgentSessionStopWindDown = { + waitsForProvider: boolean + stoppedAt: number + settled?: () => void +} /** * A session-ending Stop's second step, queued behind its first in the same tick so nothing sent @@ -91,35 +118,45 @@ export async function endStoppedStructuredAgentSession( await stopChild() } catch (error) { onError(error) + } finally { + windDown.settled?.() } } +/** A turn the Stop's interrupt took that still reads running: the Stop ends it, once, while it + * still binds it. The provider's stream lands as it arrives, so the fold already holds any end it + * sent. Bookkeeping: a failure is reported, never the Stop's. */ +async function endStoppedTurnAtSettle(ctx: AgentSessionTurnContext, turnId: string): Promise { + try { + const running = ctx.journal + .snapshot() + .items.filter((item) => readAgentJournalTurn(item.body)?.turnId === turnId) + const mutations = runningTurnLifecycleRevisions(running, { + state: 'interrupted', + completedAt: ctx.now() + }) + if (mutations.length > 0) { + await ctx.journal.appendLifecycleBatch({ + settlementId: `stop-settled:${turnId}`, + mutations, + fence: ctx.fence + }) + } + } catch (error) { + ctx.logger.warn("ending a stopped turn at its Stop's settle failed", { + scope: 'stop-settle', + sessionId: ctx.sessionId, + error + }) + } +} + +/** What the Stop's settle binds: the turn it stopped, and whether its wind-down closes it. */ +type StopSettleBinding = { turnId?: string; closedByWindDown?: true } + export async function performCancel( ctx: AgentSessionTurnContext, - input: { - clientOperationId: string - /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ - turnId?: string - scope?: 'background-tasks' - taskId?: string - prompt?: { itemId: string; expectedRevision: number } - /** Ends the provider child, for a running command the provider did not take the Stop on, or a - * turn whose interrupt failed. */ - stopChild?: () => Promise - /** A child end after a failed interrupt that threw. */ - onStopChildError?: (error: unknown) => void - /** After that throw: whether the host let go of the child, its exit proven before a later - * cleanup step failed. */ - childReleased?: () => boolean - /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends - * its session. */ - endSession?: (windDown: StructuredAgentSessionStopWindDown) => void - /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only - * after the interrupt. */ - withdrewQueued?: Promise - /** The session's child records: a background Stop reaches the tasks they offer a stop. */ - childWork?: () => readonly AgentChildWorkView[] | undefined - } + input: PerformCancelInput ): Promise> { if (input.prompt) { const validated = validatePendingPrompt(ctx, input.prompt) @@ -127,6 +164,52 @@ export async function performCancel( return answerCancelOfSettledPrompt(ctx, { ...input, prompt: input.prompt }, validated) } } + // A person's Stop that named no turn binds what ends while it settles (`beginJournalStopSettle`). + const settle = input.opensSettle ? ctx.journal.stopMarks.beginSettle() : null + const binding: StopSettleBinding = {} + const close = (): void => ctx.journal.stopMarks.settled(settle, binding.turnId) + try { + return await cancelAndNote(ctx, input, binding, close) + } finally { + if (!binding.closedByWindDown) { + close() + } + } +} + +type PerformCancelInput = { + clientOperationId: string + /** Absent: whatever the conversation has in flight; present: only while that turn is current. */ + turnId?: string + scope?: 'background-tasks' + taskId?: string + prompt?: { itemId: string; expectedRevision: number } + /** Ends the provider child, for a running command the provider did not take the Stop on, or a + * turn whose interrupt failed. */ + stopChild?: () => Promise + /** A child end after a failed interrupt that threw. */ + onStopChildError?: (error: unknown) => void + /** After that throw: whether the host let go of the child, its exit proven before a later + * cleanup step failed. */ + childReleased?: () => boolean + /** Hands the child's end to the Stop's next serialized step, for a provider whose Stop ends + * its session. */ + endSession?: (windDown: StructuredAgentSessionStopWindDown) => void + /** Whether the host's withdrawal of queued messages for this Stop withdrew any; awaited only + * after the interrupt. */ + withdrewQueued?: Promise + /** The session's child records: a background Stop reaches the tasks they offer a stop. */ + childWork?: () => readonly AgentChildWorkView[] | undefined + /** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */ + opensSettle?: true +} + +async function cancelAndNote( + ctx: AgentSessionTurnContext, + input: PerformCancelInput, + binding: StopSettleBinding, + closeSettle: () => void +): Promise> { let cancelled = false let note: AgentJournalStatusItem | null = { kind: 'status', @@ -155,11 +238,15 @@ export async function performCancel( const endsSession = input.endSession !== undefined && ctx.adapter.stopEndsSession?.(ctx.sessionId) === true const stoppedAt = Date.now() + // Read before the cancel: the sends a child end may take back. + const sentBeforeStop = ctx.journal.submissions().filter(sendStopCanTakeBack) // The provider's own answer; unset when its cancel threw, leaving the effect unknown. let taken: boolean | undefined // The provider could not interrupt the turn, or its cancel threw: the turn may run on. let interruptFailed = false let refusal: AgentSessionCancelOutcome['refusal'] + // The turn the provider says this Stop's interrupt took. + let stoppedTurn: string | undefined try { const dispatchStatus = latestJournalDispatchObservation(ctx.journal, ctx.fence) const outcome: AgentSessionCancelOutcome = stoppedBefore @@ -187,6 +274,7 @@ export async function performCancel( taken = outcome.cancelled cancelled = outcome.cancelled refusal = outcome.refusal + stoppedTurn = outcome.turnId interruptFailed = refusal !== undefined && refusal.turnNotRunning !== true // Working is read first: a working session never takes this branch, so a child end below never // waits on the withdrawal. On an idle queue the withdrawal has already landed in the fold. @@ -217,7 +305,8 @@ export async function performCancel( if (endsSession && (!namesTurnNotLive || taken !== false)) { // An interrupt the provider took is worth waiting on, turn row or not: a Stop before the echo // has none, and the echo still opens the turn the Stop interrupted. - input.endSession?.({ waitsForProvider: taken === true, stoppedAt }) + binding.closedByWindDown = true + input.endSession?.({ waitsForProvider: taken === true, stoppedAt, settled: closeSettle }) cancelled = true // The child's end confirms the Stop, so a refused or unconfirmed interrupt says nothing more. if (note !== null) { @@ -246,7 +335,13 @@ export async function performCancel( } if (ended) { cancelled = true - note = { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } + // A child end that took back every send it found, with no turn running, ended a run that + // never started: its message is back in the composer, and a row would sit under the turn + // before as if that turn were stopped. + note = + stoppedTurnId === null && tookBackEverySend(ctx, sentBeforeStop) + ? null + : { kind: 'status', text: STOP_NOTE_CANCELLATION_REQUESTED } } else if (taken !== undefined) { // The turn was just read running, so a named Stop says it was refused rather than nothing. note = stopRefusedNote(ctx, refusal) @@ -255,6 +350,12 @@ export async function performCancel( // Nothing was left of the turn it named and nothing else ended: a Stop that ends nothing writes no row. note = null } + if (cancelled) { + binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined + } + if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown && !input.scope) { + await endStoppedTurnAtSettle(ctx, stoppedTurn) + } const value = { ...(input.turnId !== undefined ? { turnId: input.turnId } : {}), cancelled } if (input.scope || note === null) { return { ok: true, value } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts new file mode 100644 index 00000000000..d4a78117a1e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.test.ts @@ -0,0 +1,107 @@ +// Which sends a Stop's child end takes back: the same rule decides the withdrawal and whether the +// Stop writes a row, so a send it must leave alone is left alone by both. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + sendStopCanTakeBack, + withdrawCodexSendsNoTurnOpenedFor, + type UnopenedSendJournal +} from './structured-agent-session-unopened-send-withdrawal' + +type Send = Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' +> + +/** A send that started its own turn before a person's Stop at sequence 10, no turn having opened. */ +function sendBeforeStop(overrides: Partial = {}): Send { + return { + clientMessageId: 'send-1', + dispatchState: 'pending', + handoverRecorded: true, + handedOverAt: 5, + acceptedSequence: 4, + ...overrides + } +} + +function journalWith(send: Send) { + const resolveDispatch = vi.fn(async () => ({ epoch: 'epoch-1', sequence: 11 })) + const journal: UnopenedSendJournal = { + agent: 'codex', + queuedMessages: { + userStopInForce: () => ({ sequence: 10, event: { reason: 'user-stop', at: 1 } }) + }, + snapshot: () => ({ + items: [ + { + itemId: agentJournalSubmissionKey(send.clientMessageId), + revision: 0, + sequence: 5, + observedAt: 5, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'look around' }] }, + turnScope: { kind: 'thread' } + } + ] + }), + submissions: () => [send], + resolveDispatch + } + return { journal, resolveDispatch } +} + +describe('a send a Stop can take back', () => { + it('is one still being handed over, or one this process lost the answer to', () => { + expect(sendStopCanTakeBack(sendBeforeStop())).toBe(true) + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown' }))).toBe(true) + }) + + // An earlier process's send: its child may have started it before the crash. + it('is never one an earlier process left in doubt', () => { + expect(sendStopCanTakeBack(sendBeforeStop({ dispatchState: 'unknown', recovered: true }))).toBe( + false + ) + }) + + // The card holds its text and returns paused; nothing was handed to the agent. + it("is never a queued card's send that was not handed over", () => { + expect(sendStopCanTakeBack(sendBeforeStop({ handedOverAt: undefined }))).toBe(false) + }) +}) + +describe("a Codex child's end under a person's Stop", () => { + it('withdraws a send whose answer this process lost', async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ dispatchState: 'unknown' })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).toHaveBeenCalledWith( + expect.objectContaining({ clientMessageId: 'send-1', state: 'rejected' }) + ) + }) + + it('leaves a send an earlier process left in doubt as it is', async () => { + const { journal, resolveDispatch } = journalWith( + sendBeforeStop({ dispatchState: 'unknown', recovered: true }) + ) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) + + it("leaves a queued card's send as it is", async () => { + const { journal, resolveDispatch } = journalWith(sendBeforeStop({ handedOverAt: undefined })) + + await withdrawCodexSendsNoTurnOpenedFor(journal, 1) + + expect(resolveDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts new file mode 100644 index 00000000000..64a5296c008 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-send-withdrawal.ts @@ -0,0 +1,94 @@ +// Which unanswered sends a dying Codex child takes with it as never sent, read from the journal at +// the settlement that lands, so a retried wind-down reads the same rows. + +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentType } from '../../../shared/agent-status-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' + +/** What the derivation reads; absent members (a narrow double) withdraw nothing. */ +export type UnopenedSendJournal = { + agent?: AgentType + queuedMessages?: Pick + snapshot: () => Pick, 'items'> + submissions?: () => Pick< + AgentJournalSubmission, + | 'clientMessageId' + | 'dispatchState' + | 'recovered' + | 'handoverRecorded' + | 'handedOverAt' + | 'acceptedSequence' + >[] + resolveDispatch?: AgentSessionJournal['resolveDispatch'] +} + +/** A send a dying child can take back: one still being handed over, or one this process left in + * doubt (its answer was lost). Never a queued card's, nor a send an earlier process left behind. */ +export function sendStopCanTakeBack( + entry: Pick< + AgentJournalSubmission, + 'dispatchState' | 'recovered' | 'handoverRecorded' | 'handedOverAt' + > +): boolean { + return ( + !isQueuedAgentJournalSubmission(entry) && + (entry.dispatchState === 'pending' || + (entry.dispatchState === 'unknown' && entry.recovered !== true)) + ) +} + +/** + * Withdraws the sends a Codex child left unanswered when a person's Stop, in force since they were + * sent, ends it: each one that started its own turn (handed over with no turn running, so its + * message belongs to no turn) when no turn has opened since. Codex records a prompt only once its + * turn starts (core tasks/regular.rs:50, session/turn.rs:886-902), so they never ran. A send that + * joined a running turn may be in it, so it, a send with no recorded place, and any other end stay + * in doubt. + */ +export async function withdrawCodexSendsNoTurnOpenedFor( + journal: UnopenedSendJournal, + fence: number +): Promise { + const stop = journal.agent === 'codex' ? journal.queuedMessages?.userStopInForce() : null + if (!stop || !journal.submissions || !journal.resolveDispatch) { + return + } + const { items } = journal.snapshot() + const turns = items.flatMap((item) => { + const turn = readAgentJournalTurn(item.body) + return turn ? [{ running: turn.state === 'running', sequence: item.sequence }] : [] + }) + // Its message's place, recorded at handover: the turn it joined, or the conversation. + const startedOwnTurn = (clientMessageId: string): boolean => + items.find((item) => item.itemId === agentJournalSubmissionKey(clientMessageId))?.turnScope + ?.kind === 'thread' + const opened = (acceptedSequence: number): boolean => + turns.some((turn) => turn.running || turn.sequence > acceptedSequence) + const unopened = journal + .submissions() + .filter( + (entry) => + sendStopCanTakeBack(entry) && + entry.acceptedSequence !== undefined && + entry.acceptedSequence < stop.sequence && + startedOwnTurn(entry.clientMessageId) && + !opened(entry.acceptedSequence) + ) + const withdrawn = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + for (const entry of unopened) { + await journal.resolveDispatch({ + clientMessageId: entry.clientMessageId, + state: 'rejected', + ...withdrawn, + fence, + recovered: true + }) + } +} diff --git a/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts new file mode 100644 index 00000000000..de09e72b7e5 --- /dev/null +++ b/src/main/runtime/structured-agent-session-codex-interrupt-order.test.ts @@ -0,0 +1,238 @@ +// Codex answers an interrupt it took before it ends the turn: on `TurnAborted` the app-server +// answers pending interrupts, then sends `turn/completed` (interrupted) on the same channel. The +// Stop's settle, which runs between the two, must label the turn the Stop's: no end row for it +// ever reads as a failure. Driven through the shipped host, journal and Codex adapter; only the +// Codex child is fake, and the test delivers the turn's end after the Stop has answered. + +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 { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentJournalTurnItem } from '../../shared/agent-session-journal-types' +import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage +} from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { + liveTestJournalRows, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { parseJournalRow } from '../native-chat/agent-session-journal/journal-row-schema' +import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const CALLER = { callerKey: 'codex-interrupt-order-test' } +const MODEL = { + model: 'gpt-test', + displayName: 'GPT Test', + hidden: false, + supportedReasoningEfforts: [], + defaultReasoningEffort: null, + isDefault: true +} + +let root: string +let host: StructuredAgentSessionHost +let fence: number +let handlers: CodexAppServerConnectionHandlers | undefined +let answers: number +let interrupts: number +let turns: ReturnType +let opened: Set +let operations = 0 + +/** The durable ledger stamps its own clock and refuses an id far from it. */ +const operationId = (): string => `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}` + +function envelope(method: string, fields: Record) { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }) + } +} + +async function send(text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (!sent.ok) { + throw new Error(JSON.stringify(sent.refusal)) + } + return sent.value.clientMessageId +} + +async function stop(turnId?: string): Promise { + const stopped = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', turnId === undefined ? {} : { turnId }), + ...(turnId === undefined ? {} : { turnId }) + }) + if (!stopped.ok) { + throw new Error(JSON.stringify(stopped.refusal)) + } +} + +/** Every end row written for turn-1, as a row or a lifecycle batch's mutation, in order. */ +function turnEndRows(): AgentJournalTurnItem[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, SESSION).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + return [] + } + const { row } = parsed + const bodies = row.kind === 'lifecycle-batch' ? row.mutations : [row] + return bodies.flatMap((entry) => + entry.kind === 'item' && + entry.body.kind === 'turn' && + entry.body.turnId === 'turn-1' && + entry.body.state !== 'running' + ? [entry.body] + : [] + ) + }) +} + +/** The Stop has answered with Codex's end still to come; then Codex ends the turn. */ +async function expectInterruptedThroughout(): Promise { + expect(interrupts).toBe(1) + expect(turnEndRows()).toEqual([ + expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' }) + ]) + + turns.end('interrupted') + + await vi.waitFor(async () => expect(await owesWork()).toBe(false)) + const ends = turnEndRows() + expect(ends.length).toBeGreaterThan(0) + for (const end of ends) { + expect(end).toMatchObject({ state: 'interrupted', outcome: 'cancellation' }) + } +} + +async function owesWork(): Promise { + const snapshot = await host.journalSnapshot(SESSION) + return owesStructuredAgentSessionWork(snapshot.items, snapshot.submissions, fence) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-codex-interrupt-order-')) + answers = 0 + interrupts = 0 + opened = new Set() + turns = codexTurnLifecycleFake(THREAD, () => (method, params) => { + if (method === 'turn/started') { + opened.add(turns.turnId ?? '') + } + handlers?.onNotification?.(method, params) + }) + const openConnection: typeof openCodexAppServerConnection = async ( + _launch, + connectionHandlers = {} + ) => { + handlers = connectionHandlers + const connection: CodexAppServerConnection = { + pid: 4321, + closed: false, + request: async (method) => { + if (method === 'thread/start' || method === 'thread/resume') { + return { thread: { id: THREAD } } + } + if (method === 'model/list') { + return { data: [MODEL], nextCursor: null } + } + if (method === 'turn/start') { + answers += 1 + return turns.routes['turn/start']() + } + if (method === 'turn/interrupt') { + // Taken: answered now; the test sends the turn's end once the Stop has answered. A turn + // Codex runs but whose turn/started was not yet read gets that frame ahead of the answer. + interrupts += 1 + if (turns.turnId !== null && !opened.has(turns.turnId)) { + turns.start() + } + return {} + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => true + } + return connection + } + host = await ensureStructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveCodexCommand: () => 'codex', + resolveEnvironment: async () => ({ PATH: process.env.PATH }), + openCodexConnection: openConnection, + readProcessStartTime: async () => 1_700_000_000_000 + }) + const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) + attachParams.envelope.clientOperationId = operationId() + const attached = await host.attach(CALLER, attachParams) + if (!attached.ok) { + throw new Error(JSON.stringify(attached.refusal)) + } + fence = attached.value.fence +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + await rm(root, { recursive: true, force: true }) +}) + +describe("a Codex Stop answered before Codex's turn/completed (interrupted)", () => { + it.each([ + ['names the running turn', 'turn-1'], + ['names no turn', undefined] + ] as const)( + 'reads the turn as interrupted by the Stop, never failed, when it %s', + async (_, named) => { + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + + await stop(named) + + await expectInterruptedThroughout() + } + ) + + // No turn showed when the person pressed Stop, which interrupts the answered turn at once: the + // turn that opens is bound by the Stop's settle. + it('reads the turn that opens behind an in-flight turn/start as interrupted, never failed', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + await stopping + + await expectInterruptedThroughout() + }) +}) diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index 57e632a6c85..b7bbd283fd8 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -1,7 +1,8 @@ // A Codex send the turn it went into never took: the Stop that interrupts that turn // withdraws it, and nothing reads as working after. A send made while that turn runs, a -// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop or a send made -// before Codex opens that turn waits for it to open, since Codex refuses an interrupt until then. +// queued card's Send-now included, goes in as `turn/steer` naming it. A Stop made before Codex +// opens that turn interrupts it at once; refused before Codex's thread runs it, the Stop waits +// for it to open and interrupts once more. A send made then waits for it to open. // Driven through the shipped host, journal and Codex adapter; only the Codex child is fake, // keeping Codex 0.157's turn bookkeeping. @@ -9,10 +10,11 @@ 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 { - CodexAppServerConnection, - CodexAppServerConnectionHandlers, - openCodexAppServerConnection +import { + CodexAppServerRequestError, + type CodexAppServerConnection, + type CodexAppServerConnectionHandlers, + type openCodexAppServerConnection } from '../codex/codex-app-server-connection' import { codexTurnLifecycleFake } from '../codex/codex-turn-lifecycle-fake' import { settledWithin } from '../codex/codex-structured-dispatch-test-support' @@ -22,6 +24,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import type { AgentJournalSubmission } from '../../shared/agent-session-journal-types' import { classifyDispatchRejection } from '../../shared/structured-agent-session-dispatch-rejection' import { owesStructuredAgentSessionWork } from '../../shared/structured-agent-session-owed-work' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, @@ -87,6 +90,13 @@ let answers: number let steers: number let interrupts: number let childCloses: number +let connections: number +/** Closes that fail before one goes through, as a kill that times out. */ +let failingCloses: number +/** Codex fails every interrupt it is sent, as one it could not submit (-32603). */ +let interruptsFail: boolean +/** Codex takes the interrupt and answers it; the test sends that turn's end later. */ +let interruptEndHeld: boolean let turns: ReturnType let operations = 0 @@ -171,6 +181,12 @@ async function settled(): Promise<{ } } +async function statusRows(): Promise { + return (await host.journalSnapshot(SESSION)).items.flatMap((item) => + item.body.kind === 'status' ? [item.body.text] : [] + ) +} + function verdictOf(submissions: readonly AgentJournalSubmission[], clientMessageId: string) { const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) return submission?.dispatchState === 'rejected' @@ -187,6 +203,10 @@ beforeEach(async () => { steers = 0 interrupts = 0 childCloses = 0 + connections = 0 + failingCloses = 0 + interruptsFail = false + interruptEndHeld = false turns = codexTurnLifecycleFake( THREAD, () => (method, params) => handlers?.onNotification?.(method, params) @@ -196,6 +216,7 @@ beforeEach(async () => { connectionHandlers = {} ) => { handlers = connectionHandlers + connections += 1 const connection: CodexAppServerConnection = { pid: 4321, closed: false, @@ -214,6 +235,20 @@ beforeEach(async () => { steers += 1 return turns.routes['turn/steer'](params) } + if (method === 'turn/interrupt' && interruptsFail) { + interrupts += 1 + throw new CodexAppServerRequestError( + 'turn/interrupt', + -32603, + 'codex app-server turn/interrupt failed: could not submit', + 'could not submit' + ) + } + if (method === 'turn/interrupt' && interruptEndHeld) { + interrupts += 1 + turns.takeInterrupt() + return {} + } if (method === 'turn/interrupt') { interrupts += 1 return turns.routes['turn/interrupt'](params) @@ -224,6 +259,10 @@ beforeEach(async () => { respond: () => {}, respondWithError: () => {}, close: async () => { + if (failingCloses > 0) { + failingCloses -= 1 + throw new Error('the kill timed out') + } childCloses += 1 return true } @@ -472,7 +511,7 @@ describe('a second send made after Codex answered the first, before it opened th }) describe('a Stop sent after Codex answered a cold send, before it opened the turn', () => { - it('waits for the turn to open, then stops it and withdraws the send', async () => { + it('refused before the thread runs it, waits for the turn to open, then stops it and withdraws the send', async () => { const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) @@ -482,17 +521,16 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) const stopping = stop() await began.promise - // Without the wait, it would reach Codex now, which would refuse it, and be done. const held = settledWithin(stopping, 1_000) await vi.advanceTimersByTimeAsync(1_000) expect(openWaits.ended).toEqual([]) expect(await held).toBe('held') - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) turns.start() await stopping - expect(interrupts).toBe(1) - expect(turns.turnId).toBeNull() + expect(interrupts).toBe(2) + await vi.waitFor(() => expect(turns.turnId).toBeNull()) await vi.waitFor(async () => expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') ) @@ -501,14 +539,8 @@ describe('a Stop sent after Codex answered a cold send, before it opened the tur }) describe('a Stop in that window that the turn never opens for', () => { - async function statusRows(): Promise { - return (await host.journalSnapshot(SESSION)).items.flatMap((item) => - item.body.kind === 'status' ? [item.body.text] : [] - ) - } - - async function waitingStop(): Promise<{ stopping: Promise }> { - await send('look around') + async function waitingStop(): Promise<{ stopping: Promise; sent: string }> { + const sent = await send('look around') await vi.waitFor(() => expect(answers).toBe(1)) await host.flushStreamedEvents(SESSION) const began = Promise.withResolvers() @@ -519,7 +551,7 @@ describe('a Stop in that window that the turn never opens for', () => { const held = settledWithin(stopping, 200) await vi.advanceTimersByTimeAsync(200) expect(await held).toBe('held') - return { stopping } + return { stopping, sent } } it('says Codex had no turn running when that turn ends first', async () => { @@ -528,12 +560,13 @@ describe('a Stop in that window that the turn never opens for', () => { turns.end('interrupted') await stopping - expect(interrupts).toBe(0) + expect(interrupts).toBe(1) + expect(childCloses).toBe(0) expect(await statusRows()).toContain('Codex had no turn running to stop.') }) it('lets a chat closed behind it close within its bound and one eviction', async () => { - const { stopping } = await waitingStop() + const { stopping, sent } = await waitingStop() const closing = host.close(SESSION, 'evict') const closedWithinBound = settledWithin( @@ -549,8 +582,10 @@ describe('a Stop in that window that the turn never opens for', () => { expect(await closedWithinBound).not.toBe('held') expect(await settledWithin(stopping, 0)).not.toBe('held') expect(childCloses).toBe(1) - expect(interrupts).toBe(0) - expect(await statusRows()).toContain('Codex had no turn running to stop.') + expect(interrupts).toBe(1) + // Its wait ran out with the turn still able to open, so the Stop ended the child and took the + // send back. + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') }) it('lets the app quit behind it within the eviction budget, and still close the child', async () => { @@ -570,6 +605,206 @@ describe('a Stop in that window that the turn never opens for', () => { }) }) +describe("a Stop pressed while Codex's turn/start is in flight", () => { + function turnRow( + items: Awaited>['items'] + ) { + return items + .map((item) => readAgentJournalTurn(item.body)) + .find((turn) => turn?.turnId === 'turn-1') + } + + it("interrupts the turn that opens, which reads as the Stop's, and nothing reads as running", async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + // Queued behind the send's handover on the session's lane, so it runs after Codex answers. + const stopping = stop() + release() + await vi.waitFor(() => expect(openWaits.turnIds).toContain('turn-1')) + turns.start() + await stopping + + // Refused before the thread ran the turn, then taken once it opened. + expect(interrupts).toBe(2) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'interrupted', + outcome: 'cancellation' + }) + // Codex's end, after its answer, settles the send. + await vi.waitFor(async () => expect((await settled()).owesWork).toBe(false)) + }) + + it('ends the child when its interrupt was refused and the turn has not opened by the end of its wait', async () => { + const release = turns.holdNextAnswer() + await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(interrupts).toBe(1) + expect(childCloses).toBe(1) + expect((await settled()).owesWork).toBe(false) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toBeUndefined() + }) + + // Codex records a prompt only once its turn starts, so a send whose turn never opened never ran: + // the Stop takes it back, and no send in doubt holds the client's queue. + async function stoppedBeforeItsTurnOpened(): Promise { + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + return sent + } + + async function nextSendStartsANewChild(): Promise { + turns = codexTurnLifecycleFake( + THREAD, + () => (method, params) => handlers?.onNotification?.(method, params) + ) + const next = await send('try again') + await vi.waitFor(() => expect(answers).toBe(2)) + expect(connections).toBe(2) + return next + } + + it('withdraws the send whose turn never opened, and the next send starts a new child', async () => { + const sent = await stoppedBeforeItsTurnOpened() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + await nextSendStartsANewChild() + }) + + // Nothing it stopped stays in the transcript, so a Stop row would read as stopping the turn before. + it('leaves no Stop row under the turn before once it takes the send back', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const release = turns.holdNextAnswer() + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + const stopping = stop() + release() + expect(await settledWithin(stopping, CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + expect(turnRow((await host.journalSnapshot(SESSION)).items)).toMatchObject({ + state: 'completed' + }) + }) + + // Its turn/start answer lost, the send is in doubt rather than pending; the child's end takes it + // back all the same, so it writes no row either. + it('leaves no Stop row under the turn before when it takes back a send whose answer was lost', async () => { + const warmUp = await send('warm up') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(warmUp) + turns.end('completed') + const answer = turns.routes['turn/start'] + turns.routes['turn/start'] = () => { + turns.routes['turn/start'] = answer + throw new Error('codex app-server request timed out: turn/start') + } + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(2)) + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + ) + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + expect(await statusRows()).toEqual([]) + }) + + it('withdraws it too when the child end fails and a later retry lands it', async () => { + failingCloses = 1 + const sent = await stoppedBeforeItsTurnOpened() + expect(childCloses).toBe(0) + + await nextSendStartsANewChild() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('withdrawn') + }) + + // A turn that opened may hold the send: the child's end leaves it in doubt, as before. + it('leaves a send in doubt when its turn opened before the child end', async () => { + const sent = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + interruptsFail = true + + await stop() + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + expect(await statusRows()).toEqual(['Cancellation requested.']) + }) + + // Handed over once the stopped turn read ended, it started its own turn, however late that + // turn's own end then arrives; a second Stop finds its turn never opened. + it('withdraws a send made after a Stop that started its own turn, which never opened', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + interruptEndHeld = true + await stop() + interruptEndHeld = false + const next = await send('run this after the stop') + await vi.waitFor(() => expect(answers).toBe(2)) + // The stopped turn's own end arrives after the next send was handed over. + turns.end('interrupted') + + expect(await settledWithin(stop(), CODEX_TURN_OPEN_WAIT_MS + 2_000)).not.toBe('held') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, next)).toBe('withdrawn') + }) + + // Codex drains a steer into the running turn, so it may hold one it never echoed. + it('leaves a send steered into the open turn in doubt when the child end follows', async () => { + const opening = await send('look around') + await vi.waitFor(() => expect(answers).toBe(1)) + turns.start() + turns.echo(opening) + const steered = await send('and check the tests') + await vi.waitFor(() => expect(steers).toBe(1)) + interruptsFail = true + + await stop('turn-1') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, steered)).toBe('unknown') + }) + + // Sent after the Stop, to a child that then dies before its turn opens: the Stop, still in force, + // was never this send's. + it('leaves a send made after the Stop in doubt when its child dies', async () => { + await stoppedBeforeItsTurnOpened() + const next = await nextSendStartsANewChild() + + handlers?.onExit?.(new Error('codex app-server exited')) + + await vi.waitFor(async () => + expect(verdictOf((await settled()).submissions, next)).not.toBe('pending') + ) + expect(verdictOf((await settled()).submissions, next)).toBe('unknown') + }) +}) + describe('a cold send with no Stop behind it', () => { /** Well inside one eviction step's budget, and far inside the bound a Stop waits. */ const PROMPTLY_MS = 1_000 @@ -587,6 +822,17 @@ describe('a cold send with no Stop behind it', () => { expect(childCloses).toBe(1) }) + // Only a person's Stop takes a send back: a host's close leaves it in doubt, as before. + it('leaves its send in doubt when the host closes the chat', async () => { + await answeredColdSend() + const sent = (await settled()).submissions[0]?.clientMessageId ?? '' + + await host.close(SESSION, 'evict') + + expect(childCloses).toBe(1) + expect(verdictOf((await settled()).submissions, sent)).toBe('unknown') + }) + it('never delays quitting the app', async () => { await answeredColdSend() diff --git a/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts new file mode 100644 index 00000000000..214ed97872a --- /dev/null +++ b/src/shared/structured-agent-session-outbox-withdrawn-send.test.ts @@ -0,0 +1,78 @@ +// A send the host withdrew (a Stop took it back before it ran) leaves the outbox with no notice, so +// the next queued send goes out. One the host left in doubt holds the queue until its Retry. + +import { describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from './agent-session-failure' +import { agentSessionFailureWords } from './agent-session-failure-words' +import type { AgentJournalSubmission } from './agent-session-journal-types' +import { + createStructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' +import { reconcileStructuredAgentSessionOutbox } from './structured-agent-session-outbox-reconcile' +import { admitStructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox-admission' + +function entry( + clientMessageId: string, + patch: Partial = {} +): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId, + sessionId: 'session-1', + text: clientMessageId, + attachments: [], + queuedAt: 1 + }), + ...patch + } +} + +function submission(patch: Partial): AgentJournalSubmission { + return { + clientMessageId: 'stopped', + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: 2, + ...patch + } +} + +const OUTBOX = [entry('stopped', { state: 'dispatching', lastAttemptAt: 1 }), entry('next')] + +describe('the send a Stop ended before its turn opened', () => { + it('leaves the outbox when withdrawn, and the next send goes out', () => { + const withdrawn = submission({ + dispatchState: 'rejected', + recovered: true, + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }) + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [withdrawn], []) + + expect(reconciled.map((each) => each.clientMessageId)).toEqual(['next']) + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'dispatch', + entry: { clientMessageId: 'next' } + }) + }) + + it('holds the queue when left in doubt', () => { + const inDoubt = submission({ + dispatchState: 'unknown', + recovered: true, + reason: 'provider_closed_before_acknowledgement' + }) + + const reconciled = reconcileStructuredAgentSessionOutbox(OUTBOX, [inDoubt], []) + + expect(admitStructuredAgentSessionOutboxEntry(reconciled)).toMatchObject({ + state: 'blocked', + entry: { clientMessageId: 'stopped' } + }) + }) +}) From ad5aa010b6287d30a73a5fee3b0908120288f770 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:43:59 -0700 Subject: [PATCH 4/9] fix(native-chat): a chat's finished-turn alerts come from the host recorded on its tab, not its workspace id (#25081) * test(native-chat): a mirrored paired chat carries its host, so a workspace-id collision no longer hides its owner * fix(native-chat): a chat's finished-turn alerts come from the host stamped on its tab, not its workspace id * fix(native-chat): the attention bridge reads a chat's owner from the same rule as the chat pane --- ...cturedAgentSessionAttentionBridge.test.tsx | 158 +++++++++++++++++- .../StructuredAgentSessionAttentionBridge.tsx | 33 ++-- .../mirrored-agent-tab-label.test.ts | 8 + 3 files changed, 184 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx index e5bfb32be4f..7d10b767f94 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.test.tsx @@ -486,7 +486,148 @@ describe('StructuredAgentSessionAttentionBridge', () => { expect(store.getState().unreadTerminalTabs[tab.id]).toBe('agent-completion') }) - it('keeps a local subscription and delivers without a source when tab ownership is unresolved', async () => { + it.each<{ selected: string; activeAfterCollision: Partial }>([ + { selected: 'the paired workspace', activeAfterCollision: {} }, + { + selected: 'another workspace', + activeAfterCollision: { + activeWorktreeId: 'repo1::/tmp/other', + activeWorkspaceExecutionHostId: 'local' + } + } + ])( + 'keeps a published paired chat subscribed and delivers after a workspace-id collision with $selected selected', + async ({ activeAfterCollision }) => { + const store = mocks.store + if (!store) { + throw new Error('test store was not initialized') + } + const remoteWorktree = makeWorktree({ + id: WORKSPACE, + repoId: 'repo1', + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + }) + store.setState({ + activeWorktreeId: WORKSPACE, + activeWorkspaceExecutionHostId: 'runtime:env-1', + worktreesByRepo: { repo1: [remoteWorktree] }, + unifiedTabsByWorktree: {}, + runtimeEnvironments: [ + { + id: 'env-1', + name: 'Paired server', + createdAt: 1, + updatedAt: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: 'endpoint' + } + ] + }) + store.setState( + applyWebSessionTabsSnapshot( + store.getState(), + { + worktree: WORKSPACE, + publicationEpoch: 'remote-epoch', + snapshotVersion: 1, + activeGroupId: GROUP, + activeTabId: 'agent-session:session-1', + activeTabType: 'agent-session', + tabs: [ + { + type: 'agent-session', + id: 'agent-session:session-1', + title: 'Remote chat', + sessionId: SESSION, + agent: 'codex', + isActive: true + } + ] + }, + 'env-1', + 100 + ) + ) + const tab = store.getState().unifiedTabsByWorktree[WORKSPACE][0] + if (!tab) { + throw new Error('snapshot did not publish a chat tab') + } + expect(tab.contentType).toBe('agent-session') + // The snapshot stamps each mirrored chat with its host, so a later id collision cannot reassign it. + expect(tab.executionHostId).toBe('runtime:env-1') + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + expect(mocks.subscribeCompletions.mock.calls[0]?.[0]).toEqual({ + kind: 'environment', + environmentId: 'env-1' + }) + + await act(async () => + store.setState({ + ...activeAfterCollision, + worktreesByRepo: { + repo1: [ + remoteWorktree, + makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'local' }) + ] + }, + groupsByWorktree: { + [WORKSPACE]: store + .getState() + .groupsByWorktree[WORKSPACE].map((group) => ({ ...group, activeTabId: 'other-tab' })) + } + }) + ) + expect(resolveNotificationTabOwner(store.getState(), tab)).toEqual({ + executionHostId: 'runtime:env-1', + runtimeEnvironmentId: 'env-1' + }) + expect(mocks.unsubscribe).not.toHaveBeenCalled() + expect(mocks.subscribeCompletions).toHaveBeenCalledOnce() + + act(() => hostStream()(completionFrame())) + const paneKey = structuredAgentSessionPaneKey(tab.id, SESSION) + expect(onlyDispatch()).toMatchObject({ notificationSourceId: 'runtime:env-1', paneKey }) + expect(store.getState().unreadAgentCompletionPanes[paneKey]).toBe('agent-completion') + expect(store.getState().unreadTerminalTabs[tab.id]).toBe('agent-completion') + } + ) + + it('keeps a local chat on this machine when a paired workspace with its id is selected', async () => { + mocks.store?.setState({ + unifiedTabsByWorktree: { [WORKSPACE]: [chatTab({ executionHostId: 'local' })] } + }) + render() + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) + expect(mocks.subscribeCompletions.mock.calls[0]?.[0]).toEqual({ kind: 'local' }) + + await act(async () => + mocks.store?.setState({ + activeWorktreeId: WORKSPACE, + activeWorkspaceExecutionHostId: 'runtime:env-1', + worktreesByRepo: { + repo1: [ + makeWorktree({ id: WORKSPACE, repoId: 'repo1', hostId: 'local' }), + makeWorktree({ + id: WORKSPACE, + repoId: 'repo1', + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + }) + ] + } + }) + ) + expect(mocks.unsubscribe).not.toHaveBeenCalled() + expect(mocks.subscribeCompletions).toHaveBeenCalledOnce() + }) + + // An unrecorded tab follows the chat pane's rule: an id two hosts share names no owner until + // the tab's first snapshot records its host. + it('pauses an unrecorded chat while its workspace id names two hosts, until its host is recorded', async () => { render() await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledOnce()) expect(mocks.subscribeCompletions.mock.calls[0]?.[0]).toEqual({ kind: 'local' }) @@ -500,10 +641,17 @@ describe('StructuredAgentSessionAttentionBridge', () => { } }) ) - expect(mocks.unsubscribe).not.toHaveBeenCalled() - expect(mocks.subscribeCompletions).toHaveBeenCalledOnce() - act(() => hostStream()(completionFrame())) - expect(onlyDispatch().notificationSourceId).toBeUndefined() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + + await act(async () => + mocks.store?.setState({ + unifiedTabsByWorktree: { [WORKSPACE]: [chatTab({ executionHostId: 'local' })] } + }) + ) + await waitFor(() => expect(mocks.subscribeCompletions).toHaveBeenCalledTimes(2)) + expect(mocks.subscribeCompletions.mock.calls[1]?.[0]).toEqual({ kind: 'local' }) + act(() => hostStream(1)(completionFrame())) + expect(onlyDispatch().notificationSourceId).toBe('local') expect(indicators().paneDot).toBe('agent-completion') }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx index 7c18ea6bfdd..29e469935a5 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionAttentionBridge.tsx @@ -1,8 +1,11 @@ import { useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' -import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { useAppStore } from '@/store' -import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + structuredAgentSessionOwnerForTab, + structuredAgentSessionTargetForHost +} from '@/runtime/structured-agent-session-owner' import { getStructuredAgentSessionTurnCompletionFeed } from '@/runtime/structured-agent-session-turn-completion-feed' import { dispatchStructuredTurnCompletionAttention } from './structured-attention-dispatch' import { getStructuredAgentSessionTabs, type StructuredTab } from './structured-agent-session-tabs' @@ -15,14 +18,24 @@ import { getStructuredAgentSessionTabs, type StructuredTab } from './structured- * the tab IS the attention surface. A completion for a session with no open tab has no surface to * mark and no liveness evidence in this process, and the surface adapter would reject it anyway. */ -function StructuredAgentSessionAttention({ tab }: { tab: StructuredTab }): null { - const environmentId = useAppStore((state) => - getRuntimeEnvironmentIdForWorktree(state, tab.worktreeId) - ) - const target = useMemo( - () => getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId }), - [environmentId] - ) +function StructuredAgentSessionAttention({ + tab +}: { + tab: StructuredTab +}): React.JSX.Element | null { + // The same owner the chat pane and status bridge read, so all of them follow one host. + const owner = useAppStore((state) => structuredAgentSessionOwnerForTab(state, tab)) + const target = useMemo(() => structuredAgentSessionTargetForHost(owner), [owner]) + return target ? : null +} + +function StructuredAgentSessionOwnedAttention({ + tab, + target +}: { + tab: StructuredTab + target: RuntimeClientTarget +}): null { const feed = useMemo(() => getStructuredAgentSessionTurnCompletionFeed(target), [target]) useEffect(() => feed.activate(), [feed]) useEffect( diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts index 5e36883dfe5..97a9f3f29ca 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-agent-tab-label.test.ts @@ -68,6 +68,14 @@ describe('buildMirroredAgentTabs', () => { expect(tab.customLabel).toBeNull() }) + it('records the publishing host on a tab restored from before tabs carried one', () => { + const snapshot = snapshotWith('codex', 'Codex Chat') + const { executionHostId: _recorded, ...unrecorded } = build(snapshot) + const mirrored = build(snapshot, [unrecorded]) + expect(mirrored.id).toBe(unrecorded.id) + expect(mirrored.executionHostId).toBe('runtime:env-1') + }) + it('keeps the provisional tab group when the host publishes a different group', () => { const snapshot = snapshotWith('codex', 'Codex Chat') const provisional = build(snapshot) From 98171a893464d71fbf0f79d873c87dcb99414919 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:45:58 -0700 Subject: [PATCH 5/9] fix(native-chat): never delete chat history on read; a chat Orca can't load says why once (#24576) * fix(native-chat): an older Orca keeps a chat with newer content read-only, and an unreadable annotation costs only itself A body or lifecycle mutation of a kind this build does not know, inside a row of a known kind, now latches the chat read-only with every row kept, as a newer row kind or version already does. It used to read as damage, and the open deleted the journal from that row on. Each optional field of a body schema now declares what an unparseable value means: - droppable (an annotation): removed from the row in memory; the chat stays writable; - must-understand (a prompt's questions, a plan approval's plan, a turn's lifecycle, a goal change): the row is unreadable and the chat latches read-only. Only a required field that fails is damage, repaired as before. A test holds every optional field reachable from a body schema to a policy; the known body kinds are read off the schema. Prompt options and questions no longer reject a key this build does not know, which deleted the journal from that row on. The context-usage drop is now one case of the droppable rule. * feat(native-chat): a chat an older Orca keeps read-only says so before a send is refused A chat a newer Orca wrote opens read-only, and until now nothing said so: the person found out when a send failed with "Chats were saved by a newer Orca". The host now names the reason on every whole history page it serves (`page.readOnly: 'written-by-newer-orca'`: hydration, history and catch-up resets), the shared client reducer keeps it from the latest whole page, and the desktop status strip and the phone's composer area say "This chat was saved by a newer Orca, so it's read-only here. Update Orca to continue this chat." Every read-only latch the host has is a newer Orca's (its database, a row version, a row kind, a body kind or must-understand fact), so the reason is derived from the journal's latch, not stored. The field is optional: older clients ignore it (cross-version test against the newest release's reducer), and older hosts never send it, so a new client against one says nothing, as today. A reason this client does not know shows nothing rather than words that may be wrong. The window-merge helpers move out of the client reducer into their own module. * fix(native-chat): only a newer build's value goes read-only; damage repairs as before An older Orca keeps a chat read-only only on evidence a newer build wrote it: a value outside a closed set this build knows (a body kind, a nested discriminant or literal, a mutation kind), read off zod's issues. That evidence wins over damage beside it. Anything else that fails (a wrong type, a missing field, a bad optional value) is damage, repaired as before, so damage no longer freezes a chat behind an "update Orca" it cannot fix. Drops the per-field droppable/must-understand policy. A lifecycle mutation's kind is decided before its item id, so a newer kind without one is kept. The context-usage drop is unchanged from main. * fix(native-chat): a read-only chat locks its composer and says why there Removes the separate read-only line (desktop status strip, phone notice component). The host's `readOnly` on whole pages now feeds the existing composer lock instead: desktop disables the composer with "Saved by a newer Orca. Update Orca to continue this chat." as its placeholder, and the phone adds a 'read-only' input-lock reason with the same words. A read-only chat shows no prompt card it would refuse; the draft stays in the composer. The phone's send-failure line is back exactly as on main. * fix(native-chat): a read-only chat pages back through its history A read-only journal answered every history request with a reset, so a chat this PR now keeps read-only more often could show only its newest page. Backward reads now serve the snapshot the open folded, as the first page already does; a forward read of rows still resets. * test(native-chat): an unknown key in a question entry is read and kept * test(mobile): the read-only overlay test typechecks * chore(native-chat): satisfy the changed-code gate in admission and the read-only overlay test * fix(native-chat): a new value inside a known block or goal arm reads as a newer build's The open fallback arm of the block and goal unions now aborts, so zod reports the known arm's own failure instead of only the fallback's; a future closed set there reads unreadable, damage there still repairs. The schema headers name the context-usage exception and say a new open-string value must be safe for every older build (rewind keeps only known states; pages carry no row version). * fix(native-chat): a read-only chat reconnects for a whole page, so an updated host unlocks it The client keeps `readOnly` until a whole page replaces it, but reconnected at its cursor and got only batches, so a desktop attached to a host updated in place stayed locked with "Update Orca". While it holds the latch it now subscribes without a cursor; the snapshot re-derives the state on any host version. The phone already subscribes without one. * fix(native-chat): every desktop write control follows the one read-only fact The structured session's transport state derives one fact from the page's `readOnly`: the words for why the host refuses writes. Every write control reads it. While it holds, the chat has no turn and no work, as the host projects it, so the working row and Stop go away; a pending prompt stays shown above the composer with its card disabled; queued cards' Send now, Delete and Edit, the queue's Resume, the goal banner's actions, the model and option pickers, dictation and background-task Stop are disabled or withheld. The composer placeholder stays the one place the reason is said. `lockReason` now locks the composer by itself. * fix(mobile): every phone write control follows the one read-only fact The phone session derives one fact from the page's `readOnly`, the words for why the host refuses writes, and every write control reads it: no turn or work while it holds, so no Stop; the pending prompt stays shown with its card disabled; queued cards and Resume are inert; the model and option pickers stay shut; the composer field is not editable, so its placeholder keeps saying why, and the lock applies at once instead of after the transport lock's settle. * test(native-chat): the newest release hydrates a read-only chat and scrolls back through it * test(native-chat): a read-only chat reads idle even with a send its provider never answered * test(native-chat): composer lock test without type assertions * fix(native-chat): round-3 read-only fixes - An old /model or option request no longer reopens its picker when a read-only chat unlocks: the menu is keyed by the request alone, and a lock only keeps it from mounting open. - A disabled question card still steps through its questions; only answering is locked. - A failed send's notice in a read-only chat says only that the message was not sent, with no Retry: the composer already says why, and no retry can land. Its wiring moves into useStructuredAgentSessionDeliveryNotices. - The host's background-task stop flags stand; the client no longer overrides them. - The view reads the read-only fact through one local flag. - The schema header says what an older build's rewind does with each open string. * test(native-chat): count picker mounts without an effect * fix(native-chat): read-only keeps other failures' words and shuts open option menus - In a read-only chat, only a send the newer-Orca refusal stopped is shortened to "Your message was not sent."; any other saved failure keeps its own words, and none offers a Retry. - A model or option menu open when the lock lands has its items disabled, so it cannot send a change the host would refuse. * test(native-chat): lock-lands picker test uses a typed surface * fix(native-chat): a locked composer draws its reason The editor's placeholder used the default that draws nothing while the editor is not editable, so a read-only chat's composer was empty and its reason lived only in the aria-label. The placeholder now draws while disabled, and its words are empty unless the composer is locked with a reason, so every other disabled composer (a pending prompt, no terminal) looks as before. * fix(native-chat): sync the locked-placeholder flag in an effect, not during render * fix(native-chat): a read-only chat is not offered for resume or counted as failed to resume After a relaunch, a chat whose journal this build keeps read-only (a newer Orca's) was listed in "Resume interrupted chats?", failed with "Orca couldn't resume this chat. Open it to continue manually.", and left a status-bar "N chats failed to resume" that never cleared, since nothing can continue a chat this build cannot write. The resume set now skips such a chat after the checks that end an offer (fork, moved on), so the offer is kept, not spent, and an updated Orca offers it again; the failure ledger keeps an already-filed failure for it but does not show it. Both read the chat's open journal, which listing and acting open first. * fix(native-chat): a refused resume continuation keeps the refusal's reason A send refused while continuing a chat after a restart was filed with its code alone, so a newer Orca's refusal lost its `journalWrittenByNewerOrca` reason. The continuation now carries the whole refusal, as a refusal from the agent's start already did, so the filed failure keeps it. * fix(native-chat): a newer Orca's chat keeps its resume offer however it is met - A newer Orca's whole database counts as read-only for resume, so a chat whose journal cannot be opened at all (a table the newer schema changed) is not offered, run or counted either. - The checks made right before sending no longer skip read-only chats: turning one away there was read as the user having moved on and deleted the offer. Its send is refused instead. - Settling a resume files nothing for a newer Orca's refusal; the existing rollback reopens the offer for an updated Orca. - The continuation records the refusal as a reference, without the wire prose. * chore(native-chat): one import of the session wire types in the continuation * docs(native-chat): resume and read-only comments match the current rule * test(native-chat): a closed set of a journal row cannot change without the row version The test walks the body schema and the row and mutation kind tables, collects every closed set (a row kind, a mutation kind, each enum, literal or discriminant), and compares them with a snapshot recorded beside the row version. A new value makes older builds go read-only, so it fails until the reader ships first or `v` is bumped and the snapshot updated. Tags a catch-all arm accepts as any string (block types, goal states) are listed apart: older builds read a new one as-is. * feat(native-chat): a body or plan subject of a newer kind is kept and the chat stays writable An item body kind and an approval subject kind this build does not know now read through the same catch-all blocks and goal states use (`openDiscriminatedUnion`), instead of making the chat read-only. The item is kept, drawn by nobody, and ignored by everything that reads items (turns, prompts, status); a rewind carries it as it was, kept by its place like every other row. An approval whose subject this build cannot draw shows its `detail`, which Orca's writers fill with the subject's text. A sent message of a kind this build does not know stays a newer build's. * fix(native-chat): rewind narrows a retained body by its kind before normalizing it * fix(native-chat): one chat that cannot open no longer stops the startup restore of the rest * fix(native-chat): a damaged chat fails to load and nothing is deleted A row this build cannot parse, a gap, or an epoch without its first row used to be repaired on open: every row from the damage on was deleted and a rebuild from the provider transcript was attempted. The open now fails through one refusal (failLoadOnJournalDamage, journalCorrupt), every row stays, and a damaged per-chat file is kept whole and never copied. A newer build's row still wins and keeps the chat read-only. The body classifier for closed-set values, the repair marker and disclosure, and journal recovery from the transcript go. * chore(native-chat): no reset or adapter left on the open's fixtures * feat(native-chat): an approval of a newer Orca's subject kind can only be cancelled Desktop and phone show its detail and the line "This request needs a newer version of Orca.", disable every answer, and keep the card's cancel, which ends the turn. The chat stays writable. * test(native-chat): the closed-set guard says a missed version bump fails older builds' load * test(native-chat): the closed-set guard names what a missed version bump does * fix(native-chat): a row the reader rejects is never written, and a chat that cannot load keeps its tab Every journal insert reads its serialized row back with the reader inside the write's transaction and refuses one it rejects, so Orca's own writer can no longer leave a chat that fails to load. A restored chat whose open fails keeps its tab and says why when opened. An epoch named with no rows is founded afresh again, deleting nothing. The at-rest test now expects a damaged chat to be refused; the replay gate keeps its 2026-09-11 evidence as it was run. * fix(native-chat): a newer Orca's approval subject is carried as it was, and its card cancels with or without a turn The approval subject's type is open, so code that reads a plan narrows first; the host's prompt bounding carries an unknown subject instead of rebuilding it as a plan, which threw in every start's stale settlement. A Codex rewind keeps a newer Orca's item in its place. A whitespace-only tag is damage. The card's cancel reaches the host without a running turn on hosts that take that. A provider row the reader rejects ends its turn as interrupted and the next send works. A guard fails if an approval subject kind is added before clients can be gated (STA-9262). * fix(native-chat): a card's cancel sends nothing without a turn again, and rewind keeps a newer row after any held row The host's cancel request has to name a turn on every version, so the turnless prompt cancel is reverted on desktop and phone. The rewind merge moves its anchor for every row the provider still holds, so a newer Orca's row stays after one whose kind this build does not know. * fix(native-chat): a card this build cannot answer leaves the composer open, and a send starts a turn An approval of a subject kind this build cannot draw, raised by a newer host's background agent while no turn runs, left nothing to press: its answers are disabled, its cancel needs a turn, and the desktop composer gave the card its slot. A send the host queues waits behind any pending prompt, so even the phone's open composer only queued. While every pending prompt is one this build cannot answer, the desktop composer stays open, and desktop and phone send without asking to queue: the send starts a turn, and the card's cancel then settles it. A chat a dead run left is unaffected: opening it already cancels those prompts. * feat(native-chat): a chat a newer Orca saved fails to load and says to update, with nothing to send into Opening a chat a newer Orca saved (a row kind, batch-change kind or sent-message kind this build does not know, a newer row version, or a newer history store) used to open it read-only: the history shown, the composer locked with a reason, every control greyed. Now it fails the load, like a damaged chat, with its own words: "This chat was saved by a newer Orca. Update Orca to open it." Every row is kept. The read is final, so the pane stops retrying; reopening the tab reads again. A chat whose load failed for good (damaged, or a newer Orca's) offers no composer under the error, on desktop and on the phone, so the failure is said once and no send can be refused a second time. A restart offer for a newer Orca's chat is spent without a word: no failure filed, nothing counted. Removed with the read-only mode: the journal's read-only latch and every check of it, the history page's readOnly field (never released), the cursorless reconnect, read-only paging, the composer lock and placeholder, the greyed controls, and the resume exclusion. * test(native-chat): a newer Orca's records open no chat, and a status fake carries its submissions * test(native-chat): the status fake's cast says what the feed reads * fix(native-chat): a chat's read that failed for good takes the whole pane, over a loaded transcript too * test(native-chat): the final-read-failure test names its reasons * fix(native-chat): a newer Orca's chat keeps its restart offer, and its refused load is logged once * refactor(native-chat): the newer-Orca chats a restart listing skips live beside the candidate reader * fix(native-chat): a refused chat load keeps where it failed, so the log names it and a new reason logs again * chore(native-chat): the reducer's window merge back where main has it, and comments say a newer Orca's chat fails to load * test(agent-hooks): the rename test's journal fakes carry submissions, as a journal snapshot does * test(agent-hooks): the rename test's journal fakes say what the status feed reads * fix(native-chat): Dismiss all ends the restart offers this Orca lists, and keeps a newer Orca's hidden ones * test(native-chat): a resend against a newer Orca's store answers unknown, and the phone says nothing beside the read's words This build never opens a chat a newer Orca's database holds, so a resent send id there cannot be answered from its journal: it answers "outcome unknown", never a refusal and never a made-up record, with no second delivery and no write. On the phone, a send error left beside a read that failed for good is not shown; the pane keeps only the read's words. --- config/reliability-gates.jsonc | 36 +- .../src/session/MobileNativeChatOverlay.tsx | 1 + .../MobileNativeChatPermission.test.ts | 52 ++ .../session/MobileNativeChatPermission.tsx | 21 +- .../src/session/MobileNativeChatView.test.ts | 36 ++ mobile/src/session/MobileNativeChatView.tsx | 24 +- .../mobile-structured-agent-session-rpc.ts | 11 +- ...tured-agent-session-thrown-refusal.test.ts | 13 + .../session/use-mobile-native-chat-session.ts | 3 + ...agent-session-newer-approval-send.test.tsx | 129 ++++ ...ctured-agent-session-read-failure.test.tsx | 104 ++++ .../use-mobile-structured-agent-session.ts | 2 + .../use-mobile-structured-agent-state.ts | 21 +- ...use-mobile-structured-send-with-outcome.ts | 9 +- .../first-work-branch-rename.test.ts | 9 +- .../claude-background-wake-retention.test.ts | 3 +- .../claude/claude-structured-prompt-items.ts | 3 +- src/main/codex/codex-close-turn-end.test.ts | 1 - .../codex-requested-close-turn-timing.test.ts | 1 - .../codex/codex-structured-session-acquire.ts | 1 - ...ructured-session-adapter-lifecycle.test.ts | 8 +- .../codex-structured-session-adapter.test.ts | 12 - .../codex/codex-structured-session-adapter.ts | 9 +- .../codex-structured-session-close.test.ts | 1 - ...structured-session-options-catalog.test.ts | 1 - .../codex-structured-session-options.test.ts | 2 - .../codex/codex-structured-session-state.ts | 1 - .../codex/codex-structured-thread-facts.ts | 7 - .../codex-structured-thread-open.test.ts | 1 - .../codex/codex-structured-thread-open.ts | 5 +- .../journal-approval-subject-kinds.test.ts | 22 + .../journal-closed-sets.test.ts | 109 ++++ .../journal-corruption-repair.test.ts | 304 ---------- .../agent-session-journal/journal-cursor.ts | 7 +- .../journal-damage.test.ts | 245 ++++++++ .../journal-database-schema.ts | 8 +- .../journal-database.test.ts | 6 +- .../journal-epoch-controller.ts | 6 +- .../journal-epoch-replacement.ts | 7 +- .../journal-epoch-rollover.ts | 9 +- .../journal-file-format-remnant.test.ts | 45 +- .../journal-host-database-test-support.ts | 8 + .../journal-legacy-import.test.ts | 3 +- .../journal-legacy-import.ts | 2 +- .../journal-open-failure.test.ts | 66 +- .../journal-open-failure.ts | 87 ++- .../agent-session-journal/journal-open.ts | 141 ++--- .../journal-per-session-import.test.ts | 83 ++- .../journal-per-session-import.ts | 52 +- .../journal-per-session-source.ts | 20 +- .../journal-prompt-body-bounds.ts | 19 +- .../journal-repair-disclosure.ts | 35 -- .../journal-repair-marker.ts | 65 -- .../journal-row-context-usage.test.ts | 2 - .../journal-row-schema.test.ts | 19 +- .../journal-row-schema.ts | 240 ++++---- .../journal-row-table.ts | 24 +- .../journal-row-unusable-annotations.ts | 83 +++ .../journal-stop-and-resume-rows.test.ts | 20 +- .../journal-store-collaborators.ts | 6 - .../journal-store-open.ts | 70 +-- .../journal-store-restore.ts | 75 +-- .../journal-store-schema.test.ts | 104 +--- .../journal-store.test.ts | 14 +- .../agent-session-journal/journal-store.ts | 40 +- .../journal-streaming-replay.test.ts | 20 +- .../journal-unknown-content.test.ts | 271 +++++++++ .../journal-unknown-row-kind.test.ts | 94 ++- .../journal-write-guards.ts | 7 +- ...queued-message-bookkeeping-failure.test.ts | 1 - .../queued-message-store.test.ts | 3 +- .../agent-session-history-page.ts | 3 - .../agent-session-journal-recovery.test.ts | 574 ------------------ .../agent-session-journal-recovery.ts | 99 --- .../agent-session-subscriber-catch-up.ts | 2 +- ...-agent-session-acquisition-options.test.ts | 23 +- ...structured-agent-session-adapter-router.ts | 3 - .../structured-agent-session-adapter.ts | 3 - ...structured-agent-session-attach-context.ts | 7 - ...-agent-session-attach-test-conversation.ts | 6 +- ...-agent-session-command-publication.test.ts | 2 +- ...uctured-agent-session-conversation-open.ts | 26 +- ...ctured-agent-session-forget-status.test.ts | 2 +- .../structured-agent-session-host-delivery.ts | 7 +- .../structured-agent-session-host-tabs.ts | 21 +- .../structured-agent-session-host-types.ts | 7 +- .../structured-agent-session-host.ts | 10 +- ...d-agent-session-newer-content-host.test.ts | 223 +++++++ ...ctured-agent-session-prompt-cancel.test.ts | 44 +- ...tructured-agent-session-queued-messages.ts | 6 +- ...ed-agent-session-queued-newer-orca.test.ts | 39 +- .../structured-agent-session-queued-wiring.ts | 2 +- ...uctured-agent-session-read-restore.test.ts | 1 - ...ed-agent-session-readable-restorer.test.ts | 1 - ...ctured-agent-session-refusal-retry.test.ts | 11 +- ...ctured-agent-session-resend-answer.test.ts | 11 +- ...ctured-agent-session-restart-candidates.ts | 40 +- ...agent-session-restart-continuation.test.ts | 22 +- ...ured-agent-session-restart-continuation.ts | 14 +- ...ed-agent-session-restart-failure-ledger.ts | 29 +- ...t-session-restart-offer-newer-orca.test.ts | 198 ++++++ ...red-agent-session-restart-offer-records.ts | 19 +- ...ured-agent-session-restart-restore.test.ts | 47 +- ...tructured-agent-session-restart-restore.ts | 14 +- ...tured-agent-session-restart-resume-host.ts | 34 +- ...ctured-agent-session-restart-resume-set.ts | 7 + ...ent-session-restart-resume-test-harness.ts | 10 +- ...red-agent-session-restart-resume-wiring.ts | 9 +- ...tured-agent-session-restart-resume.test.ts | 41 +- .../structured-agent-session-reveal.ts | 10 +- .../structured-agent-session-rewind.test.ts | 47 +- .../structured-agent-session-rewind.ts | 3 - ...agent-session-settled-attach-retry.test.ts | 13 +- ...-session-startup-reconcile-failure.test.ts | 4 +- ...ructured-agent-session-status-feed.test.ts | 8 +- .../structured-agent-session-status-feed.ts | 6 +- ...agent-session-status-journal-projection.ts | 18 +- ...ructured-agent-session-subscribers.test.ts | 9 +- .../structured-agent-session-subscribers.ts | 30 +- ...red-agent-session-surface-lifetime.test.ts | 30 + ...ured-agent-session-turn-completion-feed.ts | 2 +- ...uctured-agent-session-unopened-tab.test.ts | 115 ++++ ...tured-agent-session-working-at-teardown.ts | 3 +- .../structured-rewind-journal-body.test.ts | 16 + .../structured-rewind-journal-body.ts | 21 +- ...ructured-rewind-retained-host-rows.test.ts | 24 + .../structured-rewind-retained-host-rows.ts | 19 +- .../agent-session-recovery-capsule-entries.ts | 17 + .../runtime/agent-session-recovery-capsule.ts | 21 +- .../structured-agent-session-at-rest.test.ts | 26 +- .../structured-agent-session-reveal.ts | 4 +- ...ructured-agent-session-rpc.test-fixture.ts | 4 +- .../methods/structured-chat-tab-table.test.ts | 4 +- ...ured-agent-session-failure-logging.test.ts | 2 +- ...d-agent-session-integration-replay.test.ts | 47 +- ...-agent-session-startup-tab-restore.test.ts | 20 +- .../NativeChatApprovalCard.test.tsx | 71 +++ .../native-chat/NativeChatApprovalCard.tsx | 19 +- ...ucturedSession.final-read-failure.test.tsx | 117 ++++ ...tStructuredSession.newer-approval.test.tsx | 123 ++++ ...eChatStructuredSession.read-error.test.tsx | 12 +- .../NativeChatStructuredSession.tsx | 211 ++++--- .../agent-session-write-notice-text.ts | 4 + .../native-chat/native-chat-view-state.ts | 5 +- ...tured-agent-session-read-transport.test.ts | 76 +-- ...tured-agent-session.queued-gating.test.tsx | 40 ++ .../use-structured-agent-session.ts | 20 +- .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 3 + src/renderer/src/i18n/locales/es.json | 3 + src/renderer/src/i18n/locales/fr.json | 3 + src/renderer/src/i18n/locales/ja.json | 3 + src/renderer/src/i18n/locales/ko.json | 3 + src/renderer/src/i18n/locales/zh.json | 3 + .../agent-session-approval-subject.test.ts | 59 ++ src/shared/agent-session-approval-subject.ts | 45 ++ ...ent-session-journal-body-admission.test.ts | 157 +++++ .../agent-session-journal-body-admission.ts | 30 + .../agent-session-journal-open-union.ts | 32 + src/shared/agent-session-journal-schemas.ts | 86 +-- src/shared/agent-session-journal-types.ts | 19 +- .../agent-session-refusal-notice.test.ts | 7 +- src/shared/agent-session-refusal-notice.ts | 196 +----- .../agent-session-refusal-reason-words.ts | 192 ++++++ src/shared/agent-session-write-notice-copy.ts | 10 +- .../structured-agent-session-read-refusal.ts | 10 +- ...uctured-agent-session-unknown-item.test.ts | 38 ++ ...nt-session-newer-chat-refusal.unit.test.ts | 236 +++++++ ...-session-stop-event-downgrade.unit.test.ts | 16 +- 169 files changed, 4424 insertions(+), 2536 deletions(-) create mode 100644 mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx create mode 100644 mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx create mode 100644 src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-damage.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-repair-marker.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.final-read-failure.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatStructuredSession.newer-approval.test.tsx create mode 100644 src/shared/agent-session-approval-subject.test.ts create mode 100644 src/shared/agent-session-approval-subject.ts create mode 100644 src/shared/agent-session-journal-body-admission.test.ts create mode 100644 src/shared/agent-session-journal-body-admission.ts create mode 100644 src/shared/agent-session-journal-open-union.ts create mode 100644 src/shared/agent-session-refusal-reason-words.ts create mode 100644 src/shared/structured-agent-session-unknown-item.test.ts create mode 100644 tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 642918a992c..bc3b05b8d31 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -639,7 +639,7 @@ "protection": "partial", "owner": "agent-session-runtime", "layer": "runtime-unit", - "surfaces": ["structured chat journal replay", "structured chat recovery"], + "surfaces": ["structured chat journal replay", "structured chat damaged history"], "platforms": ["macos", "linux", "windows"], "providers": ["local", "remote-runtime"], "coveredPlatforms": ["macos"], @@ -648,15 +648,17 @@ "motivatingLinks": [ "https://github.com/stablyai/orca/blob/main/src/main/native-chat/agent-session-journal/journal-open.ts" ], - "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, repair precedence, read-only schema latching and cursor cleanup while retaining live items rather than all historical bodies.", - "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; preserve prefix and future-schema latching after a gap, malformed suffix repair precedence, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content and recovery.", + "invariant": "Replay preserves latest revisions, original item order, fences, aliases, submissions, a newer schema winning over damage and cursor cleanup while retaining live items rather than all historical bodies; damage fails the load and deletes nothing.", + "oracle": "Replay 2,048 16 KiB revisions into one latest item with less than 8 MiB sampled live heap growth; name a future-schema row read past a gap, name the first damage, fail a damaged load with every row kept, and hold no SQLite read snapshot across reduction (a mid-replay checkpoint is not busy). Existing journal and subscriber tests cover replayed content.", "commands": [ + // As the 2026-09-11 evidence run ran it; the recovery test it names went with the read-time repair. "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts", - "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", + "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts src/main/native-chat/agent-session-journal/journal-damage.test.ts" ], "testFiles": [ "src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts", - "src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts" + "src/main/native-chat/agent-session-journal/journal-damage.test.ts" ], "assertionRefs": [ { @@ -664,9 +666,18 @@ "assertions": [ "releases superseded revision bodies while reducing a long journal", "holds no read snapshot while reducing, so a checkpoint can pass mid-replay", - "keeps the prefix but latches read-only for a future row beyond a gap", - "keeps gap repair precedence when a later row is malformed", - "rejects an unanchored prefix before a later gap" + "reads past a gap to a future row, which names the newer row and no damage", + "names the first damage when a later row is malformed too", + "names a missing epoch row before a later gap" + ] + }, + { + "file": "src/main/native-chat/agent-session-journal/journal-damage.test.ts", + "assertions": [ + "fails to load and keeps every row: %s", + "reads past damage to a newer build's row, which fails the load as a newer Orca's instead", + "is refused when it is written, and the chat still loads with every other row", + "is founded afresh on open, with nothing deleted, and takes writes" ] } ], @@ -679,6 +690,15 @@ "result": "passed", "durationSeconds": 7.71, "summary": "245 tests passed across 22 files. Retained-heap oracle fails on baseline at 68.6 MB and passes under 8 MiB with streaming; gap/schema and cursor-cleanup assertions passed." + }, + { + "date": "2026-10-02", + "runner": "local", + "platform": "macos", + "command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts", + "result": "passed", + "durationSeconds": 5.56, + "summary": "450 tests passed across 42 files, run as the vitest invocation the test script makes. Damage fails the load with every row kept; the retained-heap, newer-row-past-a-gap and cursor-cleanup assertions passed." } ], "runtimeBudget": { diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 3650528d466..d3d6632b68e 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -80,6 +80,7 @@ export function MobileNativeChatOverlay({ folded={folded} status={session.status} error={session.error} + readFailedFinally={session.readFailedFinally === true} agent={controller.nativeChatAgent} agentWorking={controller.nativeChatAgentWorking} canStop={controller.nativeChatCanStop} diff --git a/mobile/src/session/MobileNativeChatPermission.test.ts b/mobile/src/session/MobileNativeChatPermission.test.ts index 68e5c21df8a..9d82f254ffc 100644 --- a/mobile/src/session/MobileNativeChatPermission.test.ts +++ b/mobile/src/session/MobileNativeChatPermission.test.ts @@ -145,4 +145,56 @@ describe('MobileNativeChatPermission', () => { expect(content.findAllByProps({ children: 'raw json that must not be shown' })).toHaveLength(0) expect(actions.findAllByProps({ children: 'Approve plan' })).toHaveLength(1) }) + + describe("a subject of a kind this build cannot draw: a newer Orca's", () => { + const NEEDS_NEWER_ORCA = 'This request needs a newer version of Orca.' + + async function renderNewer(detail: string | undefined) { + const onRespond = vi.fn(async () => true) + const onCancel = vi.fn(async () => true) + await act(async () => { + renderer = create( + createElement(MobileNativeChatPermission, { + permission: { + title: 'Review proposed change', + ...(detail ? { detail } : {}), + // A subject kind a newer build wrote; this build draws only plans. + subject: JSON.parse('{"kind":"diff","path":"a.ts"}'), + prompt: { itemId: 'approval-1', expectedRevision: 2 }, + options: [ + { label: 'Approve', send: 'allow' }, + { label: 'Deny', send: 'deny' } + ] + }, + onRespond, + onCancel + }) + ) + }) + const actions = renderer!.root.findByProps({ testID: 'native-chat-approval-actions' }) + return { onRespond, onCancel, options: actions.findAllByType('Pressable') } + } + + it('with no detail: says so, answers nothing, and only its cancel reaches the host', async () => { + const { onRespond, onCancel, options } = await renderNewer(undefined) + expect( + renderer!.root.findByProps({ testID: 'native-chat-approval-needs-newer-orca' }).props + .children + ).toBe(NEEDS_NEWER_ORCA) + expect(options).toHaveLength(2) + expect(options.every((option) => option.props.disabled === true)).toBe(true) + const cancel = renderer!.root.findByProps({ accessibilityLabel: 'Cancel' }) + expect(cancel.props.disabled).toBe(false) + await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 2 }) + expect(onRespond).not.toHaveBeenCalled() + }) + + it('with a detail: shows it, and still approves nothing', async () => { + const { options } = await renderNewer('# Release') + const content = renderer!.root.findByProps({ testID: 'native-chat-approval-content' }) + expect(content.findAllByProps({ children: '# Release' })).toHaveLength(1) + expect(options.every((option) => option.props.disabled === true)).toBe(true) + }) + }) }) diff --git a/mobile/src/session/MobileNativeChatPermission.tsx b/mobile/src/session/MobileNativeChatPermission.tsx index 29f3e421981..ab7dda7374e 100644 --- a/mobile/src/session/MobileNativeChatPermission.tsx +++ b/mobile/src/session/MobileNativeChatPermission.tsx @@ -3,6 +3,10 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' import { ShieldQuestion, X } from 'lucide-react-native' import { MobileMarkdown } from '../components/MobileMarkdown' import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { + isNewerApprovalSubject, + isPlanApprovalSubject +} from '../../../src/shared/agent-session-approval-subject' import type { MobileChatPermission } from './mobile-native-chat-permission' // Renders a detected agent permission ask as a card with tappable options. @@ -19,6 +23,8 @@ function MobileNativeChatPermissionImpl({ }): React.JSX.Element { const [submitting, setSubmitting] = useState(false) const submittingRef = useRef(false) + // A newer Orca's subject: its detail is shown, and only the card's cancel answers. + const newerSubject = isNewerApprovalSubject(permission.subject) const hasContext = Boolean( permission.description || permission.decisionReason || @@ -93,7 +99,7 @@ function MobileNativeChatPermissionImpl({ {permission.matchedAskRule.source} ) : null} - {permission.subject?.kind === 'plan' ? ( + {isPlanApprovalSubject(permission.subject) ? ( {permission.subject.filePath ? ( @@ -103,6 +109,11 @@ function MobileNativeChatPermissionImpl({ ) : permission.detail ? ( {permission.detail} ) : null} + {newerSubject ? ( + + This request needs a newer version of Orca. + + ) : null} ) : null} @@ -114,11 +125,12 @@ function MobileNativeChatPermissionImpl({ style={({ pressed }) => [ styles.option, isPrimary ? styles.optionPrimary : styles.optionSecondary, - pressed && !submitting && styles.optionPressed + pressed && !submitting && styles.optionPressed, + newerSubject && styles.disabled ]} hitSlop={6} onPress={() => respond(option.send)} - disabled={submitting} + disabled={submitting || newerSubject} > {option.label} @@ -212,6 +224,9 @@ const styles = StyleSheet.create({ optionPressed: { opacity: 0.7 }, + disabled: { + opacity: 0.5 + }, optionText: { color: colors.textPrimary, fontSize: typography.bodySize, diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index d224c2e34a0..ff2637f8a45 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -93,6 +93,9 @@ type Overrides = { keyboardInset?: number hasMore?: boolean onLoadEarlier?: () => void + status?: Parameters[0]['status'] + error?: string + readFailedFinally?: boolean } function assistantTurn(id: string, text: string): NativeChatMessage { @@ -214,6 +217,39 @@ describe('MobileNativeChatView', () => { }) } + // A read no retry gets past takes the whole pane, even over a transcript already on screen: its + // words alone, and nothing that could only be refused again. + it('leaves only the words of a read that failed for good, over a loaded transcript', async () => { + const loaded = [assistantTurn('m1', 'earlier reply')] + const words = 'This chat was saved by a newer Orca. Update Orca to open it.' + const shown = (text: string) => + renderer!.root.findAll((node) => node.type === 'Text' && node.props.children === text) + const composers = () => renderer!.root.findAll((node) => node.type === 'Composer') + const lists = () => renderer!.root.findAll((node) => node.type === 'FlatList') + const failure = { + messages: loaded, + folded: loaded, + status: 'error' as const, + error: words, + canStop: true, + permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] }, + // A resend answered unknown says nothing beside the read's words. + sendErrorMessage: 'Message unconfirmed — check chat before retrying' + } + await render({ ...failure, readFailedFinally: true }) + expect(shown(words)).toHaveLength(1) + expect(composers()).toHaveLength(0) + expect(lists()).toHaveLength(0) + expect(banners()).toHaveLength(0) + expect(renderer!.root.findAllByProps({ accessibilityLabel: 'Stop the agent' })).toHaveLength(0) + + // A failure that can clear keeps the transcript and the composer. + await update({ ...failure, error: "Orca couldn't open this chat's history right now." }) + expect(listIds()).toEqual(['m1']) + expect(composers()).toHaveLength(1) + expect(banners()).toHaveLength(1) + }) + it('renders the route-reported failure verbatim', async () => { await render({ sendErrorMessage: 'Permission reply failed' }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 469abba5388..8e729e5e46b 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -53,6 +53,8 @@ type Props = MobileQueuedSlotProps & { folded: NativeChatMessage[] status: MobileNativeChatStatus error?: string + /** The read failed for good (damage, a newer Orca's chat): its error takes the whole pane. */ + readFailedFinally?: boolean /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ agent?: string | null agentWorking?: boolean @@ -145,6 +147,7 @@ export function MobileNativeChatView({ folded, status, error, + readFailedFinally = false, agent, agentWorking, canStop = agentWorking, @@ -307,6 +310,18 @@ export function MobileNativeChatView({ const showLoading = status === 'loading' && messages.length === 0 const lockReason = useSettledMobileNativeChatInputLock(inputLockReason) + const emptyStateView = emptyState ? ( + + {emptyState.title} + {emptyState.subtitle} + + ) : null + + // Whatever was already on screen: nothing here can act on a chat that cannot load, and its words + // say why once, as a fresh open's do. + if (readFailedFinally && emptyStateView) { + return {emptyStateView} + } return ( @@ -354,14 +369,7 @@ export function MobileNativeChatView({ turns.waitingRows, renderItem )} - ListEmptyComponent={ - emptyState ? ( - - {emptyState.title} - {emptyState.subtitle} - - ) : null - } + ListEmptyComponent={emptyStateView} /> {/* Jump-to-latest control. */} diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index 7ccbc10e219..b5d7959eb46 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -55,13 +55,18 @@ class AgentSessionRpcResponseError extends Error { } } +/** The refusal a failed read met, from a thrown error or a stream's error frame. */ +export function agentSessionReadFailureRefusal(failure: unknown) { + return readAgentSessionErrorRefusal( + typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure + ) +} + /** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error * frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come * from the refusal in the error's data. */ export function agentSessionReadFailureText(failure: unknown): string { - const refusal = readAgentSessionErrorRefusal( - typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure - ) + const refusal = agentSessionReadFailureRefusal(failure) if (refusal) { return agentSessionWriteNoticeEnglish( agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), 'read-history') diff --git a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts index 065845640b3..0a682f1c937 100644 --- a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { + agentSessionReadFailureRefusal, agentSessionReadFailureText, callAgentSession, requestStructuredAgentSessionMutation @@ -43,6 +44,18 @@ describe('a refusal the host threw', () => { expect(agentSessionReadFailureText({ type: 'error', message: 'Connection interrupted' })).toBe( 'Connection interrupted' ) + // The refusal itself rides beside the words, so the view can tell a failure no retry gets past. + expect( + agentSessionReadFailureRefusal({ + type: 'error', + message: THROWN_JOURNAL_REFUSAL.message, + error: THROWN_JOURNAL_REFUSAL + }) + ).toEqual(THROWN_JOURNAL_REFUSAL.data.refusal) + expect(agentSessionReadFailureRefusal(thrown)).toEqual(THROWN_JOURNAL_REFUSAL.data.refusal) + expect( + agentSessionReadFailureRefusal({ type: 'error', message: 'Connection interrupted' }) + ).toBeUndefined() }) it("fails a write with the refusal's words, not as unconfirmed", async () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 1f8bdae2d48..c3add0c67b4 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -35,6 +35,9 @@ export type MobileNativeChatSession = { * wait for this to clear. */ transcriptLoading: boolean error?: string + /** The read failed for good (damage, or a newer Orca's chat): the error says why, once, and + * there is nothing to send into. */ + readFailedFinally?: boolean /** True when an older page may exist (the last read filled the window). */ hasMore: boolean /** Whether an older-history page is currently loading. */ diff --git a/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx new file mode 100644 index 00000000000..594a84acf5f --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-newer-approval-send.test.tsx @@ -0,0 +1,129 @@ +// A host's queue waits on any pending prompt. While every pending prompt is an approval this build +// cannot answer, nothing here can settle it, so a send goes out without `delivery` and starts a +// turn, whose card cancel then works. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' +import type { RpcClient } from '../transport/rpc-client' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { + CAPABLE, + SESSION_ID, + fieldsOf, + mutationOk, + ok, + snapshotEvent +} from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +const sendRequest = vi.fn() +const client: RpcClient = { + sendRequest, + subscribe: vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport: CAPABLE, + appendComposerText: () => true, + onSendError: vi.fn() + }) + return null +} + +function approval(subject: Record): AgentJournalRenderItem { + return JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 1, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} + +async function sentDelivery(items: AgentJournalRenderItem[]): Promise { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + const event = snapshotEvent() + if (event.type === 'snapshot') { + event.page.items = items + } + act(() => listener?.(event)) + await act(async () => { + await hook!.sendWithOutcome('carry on') + }) + const send = sendRequest.mock.calls.find(([method]) => method === 'agentSession.send') + return fieldsOf(send?.[1]).delivery +} + +beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + sendRequest.mockImplementation(async (method) => + method === 'agentSession.send' + ? mutationOk({ clientMessageId: 'client-1' }) + : method === 'agentSession.options' + ? ok({ models: [], current: {} }) + : ok({}) + ) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +it('sends without queueing while every pending prompt is one this build cannot answer', async () => { + expect(await sentDelivery([approval({ kind: 'diff', path: 'a.ts' })])).toBeUndefined() +}) + +it('still queues beside a prompt this build can answer', async () => { + expect( + await sentDelivery([ + approval({ kind: 'diff', path: 'a.ts' }), + approval({ kind: 'plan', text: 'do it' }) + ]) + ).toBe('queue-if-active') +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx new file mode 100644 index 00000000000..0c1c1a7f711 --- /dev/null +++ b/mobile/src/session/use-mobile-structured-agent-session-read-failure.test.tsx @@ -0,0 +1,104 @@ +// A refused read of a chat's history: the phone keeps the refusal beside its words, so a failure no +// retry gets past (damage, a newer Orca's chat) shows no composer under it. + +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { resetMobileStructuredSendOperationJournalForTests } from './mobile-structured-send-operation-journal' +import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session' +import { CAPABLE, SESSION_ID, ok } from './use-mobile-structured-agent-session-queued.test-fixture' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +let renderer: ReactTestRenderer | null = null +let hook: ReturnType | null = null +let listener: ((value: unknown) => void) | null = null +const sendRequest = vi.fn() +const client: RpcClient = { + sendRequest, + subscribe: vi.fn((_method, _params, onData) => { + listener = onData + return vi.fn() + }), + updateTerminalSubscriptionViewport: () => {}, + getState: () => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} +} + +function Harness(): null { + hook = useMobileStructuredAgentSession({ + client, + sessionId: SESSION_ID, + sourceIdentity: 'host-a\0workspace-a', + enabled: true, + connected: true, + agent: 'claude', + hostSupport: CAPABLE, + appendComposerText: () => true, + onSendError: vi.fn() + }) + return null +} + +/** The stream's error frame for a refused read, as the RPC client hands it over. */ +function refusedRead(reason: string) { + const error = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + } + return { type: 'error', message: error.message, error } +} + +async function readFailedWith(reason: string) { + act(() => { + renderer = create(createElement(Harness)) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(refusedRead(reason))) + return hook!.session +} + +beforeEach(() => { + vi.clearAllMocks() + resetMobileStructuredSendOperationJournalForTests() + sendRequest.mockImplementation(async (method) => + method === 'agentSession.options' ? ok({ models: [], current: {} }) : ok({}) + ) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + hook = null + listener = null +}) + +it.each([ + ['journalCorrupt', 'Unable to load this chat.'], + ['journalWrittenByNewerOrca', 'This chat was saved by a newer Orca. Update Orca to open it.'] +])('reads %s as a failure no retry gets past, in its words', async (reason, words) => { + expect(await readFailedWith(reason)).toMatchObject({ + status: 'error', + error: words, + readFailedFinally: true + }) +}) + +it('reads a failure that can clear as one the chat keeps trying past', async () => { + expect(await readFailedWith('journalUnavailable')).toMatchObject({ + status: 'error', + readFailedFinally: false + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index b0ecfe9832c..7bec053c9d6 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -5,6 +5,7 @@ import { projectStructuredAgentSessionMessages } from '../../../src/shared/struc import { withNativeChatCutTurnNotices } from '../../../src/shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' import { isStructuredAgentSessionMainAgentWorking } from '../../../src/shared/structured-agent-session-main-agent-working' +import { isFinalAgentSessionReadRefusal } from '../../../src/shared/structured-agent-session-read-refusal' import { activeStructuredAgentSessionTurnId, isStructuredAgentSessionThinking @@ -239,6 +240,7 @@ export function useMobileStructuredAgentSession(args: { status, transcriptLoading: status === 'loading', error: state.error, + readFailedFinally: status === 'error' && isFinalAgentSessionReadRefusal(state.readRefusal), hasMore: state.hasOlder, loadingEarlier: loadingOlder, loadEarlier diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index 9eca412c541..b903e42de00 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -17,6 +17,7 @@ import { } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' import { + agentSessionReadFailureRefusal, agentSessionReadFailureText, callAgentSession } from './mobile-structured-agent-session-rpc' @@ -138,6 +139,18 @@ export function useMobileStructuredAgentState(args: { }, [sessionKey] ) + // The refusal rides beside its words, so the view can tell a failure no retry gets past. + const applyReadFailure = useCallback( + (failure: unknown) => { + const refusal = agentSessionReadFailureRefusal(failure) + apply({ + type: 'error', + message: agentSessionReadFailureText(failure), + ...(refusal ? { refusal } : {}) + }) + }, + [apply] + ) const applyQueued = useCallback( (event: AgentSessionSubscribeEvent) => { @@ -187,7 +200,7 @@ export function useMobileStructuredAgentState(args: { }) const endStream = openTranscriptAfterHold(client, sessionId, held, (raw) => { if (typeof raw === 'object' && raw !== null && 'type' in raw && raw.type === 'error') { - apply({ type: 'error', message: agentSessionReadFailureText(raw) }) + applyReadFailure(raw) return } if (isSubscribeEvent(raw)) { @@ -212,7 +225,7 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, applyQueued, client, connected, enabled, sessionId, sessionKey]) + }, [apply, applyQueued, applyReadFailure, client, connected, enabled, sessionId, sessionKey]) const loadEarlier = useCallback(() => { const current = stateRef.current @@ -280,7 +293,7 @@ export function useMobileStructuredAgentState(args: { })() .catch((error: unknown) => { if (isCurrentRead()) { - apply({ type: 'error', message: agentSessionReadFailureText(error) }) + applyReadFailure(error) } }) .finally(() => { @@ -288,7 +301,7 @@ export function useMobileStructuredAgentState(args: { setLoadingOlder(false) } }) - }, [apply, client, loadingOlder, sessionId, sessionKey]) + }, [apply, applyReadFailure, client, loadingOlder, sessionId, sessionKey]) // A window of only a subagent's rows draws nothing, and an empty list cannot be scrolled // to ask for more, so it reads back once from each such head. A first page can be one: an diff --git a/mobile/src/session/use-mobile-structured-send-with-outcome.ts b/mobile/src/session/use-mobile-structured-send-with-outcome.ts index 44c94f777a2..613dd14fe08 100644 --- a/mobile/src/session/use-mobile-structured-send-with-outcome.ts +++ b/mobile/src/session/use-mobile-structured-send-with-outcome.ts @@ -1,10 +1,11 @@ // The structured composer's one send seam: a slash command dispatches as a // conversation command, and everything else goes out as an // `agentSession.send` — carrying `delivery: 'queue-if-active'` only when the -// host advertises the queue. +// host advertises the queue and no pending prompt is one this build cannot answer. import { useCallback } from 'react' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-live-turn' +import { pendingPromptsAllUnanswerableHere } from '../../../src/shared/agent-session-approval-subject' import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment @@ -116,7 +117,11 @@ export function useMobileStructuredSendWithOutcome(args: { expectedRuntimeFence: currentFence, text, attachments: sendAttachments, - ...(queueCapable ? { delivery: 'queue-if-active' as const } : {}), + // The host's queue waits on any pending prompt; one this build cannot answer would hold + // the send forever, so it starts a turn, whose card cancel then works. + ...(queueCapable && !pendingPromptsAllUnanswerableHere(stateRef.current.items) + ? { delivery: 'queue-if-active' as const } + : {}), deadline, onError: onSendError }) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0e17feb9874..101297a6cbb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -104,10 +104,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. const journal = { - snapshot: () => ({ items }), + snapshot: () => ({ items, submissions: [] }), lastActivityAt: () => 1, - isReadOnly: false, cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal const pending: Promise[] = [] @@ -192,8 +192,8 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. const journal = { - isReadOnly: false, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: 1 }), snapshot: () => ({ @@ -206,7 +206,8 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { turnLifecycle: { turnId: 'turn-1', state: 'running' } } } - ] + ], + submissions: [] }) } as unknown as AgentSessionJournal const location = { diff --git a/src/main/claude/claude-background-wake-retention.test.ts b/src/main/claude/claude-background-wake-retention.test.ts index a865e1c1b76..7eb09546e72 100644 --- a/src/main/claude/claude-background-wake-retention.test.ts +++ b/src/main/claude/claude-background-wake-retention.test.ts @@ -31,7 +31,6 @@ async function wiredSession() { let sequence = 0 const journal = { cursor: () => ({ epoch: 'epoch-1', sequence: ++sequence }), - isReadOnly: false, lastActivityAt: () => 1, snapshot: () => ({ items: [...run.journalItems.values()] @@ -46,7 +45,7 @@ async function wiredSession() { [ parent.sessionId, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, read-only flag, activity clock and snapshot served here. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the feed reads only the cursor, activity clock and snapshot served here. journal: journal as unknown as Journal, params: { location: { diff --git a/src/main/claude/claude-structured-prompt-items.ts b/src/main/claude/claude-structured-prompt-items.ts index c74e4741fc1..cfb30d7363a 100644 --- a/src/main/claude/claude-structured-prompt-items.ts +++ b/src/main/claude/claude-structured-prompt-items.ts @@ -6,6 +6,7 @@ import type { AgentJournalQuestionItem } from '../../shared/agent-session-journal-types' import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-tool-summary' +import { isPlanApprovalSubject } from '../../shared/agent-session-approval-subject' import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds' import { claudeRecord, claudeText } from './claude-structured-item-translation' import { @@ -44,7 +45,7 @@ export function claudePromptIdentity(input: { } export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem { - const planSubject = prompt.subject?.kind === 'plan' ? prompt.subject : null + const planSubject = isPlanApprovalSubject(prompt.subject) ? prompt.subject : null const detail = truncateToolDetail(planSubject?.text ?? formatToolInput(prompt.input)) return boundJournalPromptBody({ kind: 'approval', diff --git a/src/main/codex/codex-close-turn-end.test.ts b/src/main/codex/codex-close-turn-end.test.ts index 1e3475301bf..98ae89a7347 100644 --- a/src/main/codex/codex-close-turn-end.test.ts +++ b/src/main/codex/codex-close-turn-end.test.ts @@ -62,7 +62,6 @@ function sessionWithRunningTurn() { fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexPromptRegistry(), options: new Map(), reportedOptions: {}, diff --git a/src/main/codex/codex-requested-close-turn-timing.test.ts b/src/main/codex/codex-requested-close-turn-timing.test.ts index a7baf6a14b5..454c3ccd4a0 100644 --- a/src/main/codex/codex-requested-close-turn-timing.test.ts +++ b/src/main/codex/codex-requested-close-turn-timing.test.ts @@ -67,7 +67,6 @@ describe('requested-close durable turn timing', () => { fence: 7, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexPromptRegistry(), options: new Map(), reportedOptions: {}, diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 37c34a4bf3a..ae965795f7d 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -234,7 +234,6 @@ export async function acquireCodexStructuredSession(input: { connection, ...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string), threadId: opened.threadId, - historyPath: opened.historyPath, historyMode: opened.historyMode, activeTurnIds: new Set(), prompts: acquisition.prompts, diff --git a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts index e3211f320e8..2e553cb9e0b 100644 --- a/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts +++ b/src/main/codex/codex-structured-session-adapter-lifecycle.test.ts @@ -188,9 +188,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { fence: 7 }) ).rejects.toThrow('no live codex app-server') - expect(await adapter.historyFilePath({ identity: identityFor('session-1') })).toBe( - '/rollouts/abc.jsonl' - ) + expect(adapter.backgroundTaskStops('session-1')).toBeDefined() await expect(adapter.closeSession('session-1')).resolves.toBe(false) expect(events.filter((event) => event.type === 'ended')).toHaveLength(1) }) @@ -205,9 +203,7 @@ describe('CodexStructuredSessionAdapter lifecycle', () => { 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' - ) + expect(adapter.backgroundTaskStops('session-1')).toBeDefined() }) it('ignores Codex traffic that arrives after the session is gone', async () => { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 9276d670b4c..0e674bf10de 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -291,18 +291,6 @@ describe('CodexStructuredSessionAdapter.acquire', () => { 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 => {} diff --git a/src/main/codex/codex-structured-session-adapter.ts b/src/main/codex/codex-structured-session-adapter.ts index 13a687a72cd..6f67b68f8e6 100644 --- a/src/main/codex/codex-structured-session-adapter.ts +++ b/src/main/codex/codex-structured-session-adapter.ts @@ -1,8 +1,5 @@ import * as codexRewind from './codex-structured-rewind' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire' import { isCodexAppServerRequestError } from './codex-app-server-connection' import type { @@ -284,10 +281,6 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap 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 => this.teardown.close(sessionId) forceCloseSession = (sessionId: string): Promise => this.teardown.forceClose(sessionId) disposeSession = (sessionId: string): Promise => this.teardown.close(sessionId) diff --git a/src/main/codex/codex-structured-session-close.test.ts b/src/main/codex/codex-structured-session-close.test.ts index 9d80a0fa982..4906b45a529 100644 --- a/src/main/codex/codex-structured-session-close.test.ts +++ b/src/main/codex/codex-structured-session-close.test.ts @@ -100,7 +100,6 @@ describe('Codex structured session close lifecycle', () => { fence: 7, acquisitionGeneration: 'generation-1', threadId: THREAD, - historyPath: null, prompts, options: new Map(), reportedOptions: {}, diff --git a/src/main/codex/codex-structured-session-options-catalog.test.ts b/src/main/codex/codex-structured-session-options-catalog.test.ts index aac9e2946cf..ad4935e5d4b 100644 --- a/src/main/codex/codex-structured-session-options-catalog.test.ts +++ b/src/main/codex/codex-structured-session-options-catalog.test.ts @@ -57,7 +57,6 @@ function storeSession( fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexAcquisitionWindow().prompts, options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, diff --git a/src/main/codex/codex-structured-session-options.test.ts b/src/main/codex/codex-structured-session-options.test.ts index d99ecc0305e..4df25a9477c 100644 --- a/src/main/codex/codex-structured-session-options.test.ts +++ b/src/main/codex/codex-structured-session-options.test.ts @@ -31,7 +31,6 @@ function optionSession(request: CodexAppServerConnection['request']): CodexSessi fence: 1, acquisitionGeneration: 'generation-1', threadId: 'thread-1', - historyPath: null, prompts: new CodexAcquisitionWindow().prompts, options: new Map(), reportedOptions: { model: 'gpt-live', effort: 'high' }, @@ -137,7 +136,6 @@ describe('structured Codex session options', () => { expect( reportedCodexThreadOptions({ threadId: 'thread-1', - historyPath: null, model: 'gpt-live', effort: 'high' }) diff --git a/src/main/codex/codex-structured-session-state.ts b/src/main/codex/codex-structured-session-state.ts index a47cb5a78fe..6ddc11ac1f6 100644 --- a/src/main/codex/codex-structured-session-state.ts +++ b/src/main/codex/codex-structured-session-state.ts @@ -109,7 +109,6 @@ export type CodexSession = { fence: number acquisitionGeneration: string threadId: string - historyPath: string | null historyMode?: 'legacy' | 'paginated' /** Primary-thread turns Codex reported started and not yet ended, as read off the wire: what * rewind waits out and what a Stop naming no turn interrupts when the journal shows none. */ diff --git a/src/main/codex/codex-structured-thread-facts.ts b/src/main/codex/codex-structured-thread-facts.ts index f9f2b78b7f9..96359286b67 100644 --- a/src/main/codex/codex-structured-thread-facts.ts +++ b/src/main/codex/codex-structured-thread-facts.ts @@ -21,13 +21,6 @@ export function readCodexThreadId(payload: unknown): string | 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 { diff --git a/src/main/codex/codex-structured-thread-open.test.ts b/src/main/codex/codex-structured-thread-open.test.ts index fede869a83c..efd32b666cd 100644 --- a/src/main/codex/codex-structured-thread-open.test.ts +++ b/src/main/codex/codex-structured-thread-open.test.ts @@ -95,7 +95,6 @@ describe('openCodexThread', () => { ).resolves.toEqual({ threadId: 'thread-standard', thread: { id: 'thread-standard' }, - historyPath: null, serviceTier: null }) }) diff --git a/src/main/codex/codex-structured-thread-open.ts b/src/main/codex/codex-structured-thread-open.ts index 0f8b530d624..19a3980cb20 100644 --- a/src/main/codex/codex-structured-thread-open.ts +++ b/src/main/codex/codex-structured-thread-open.ts @@ -10,15 +10,13 @@ import { type CodexAppServerConnection } from './codex-app-server-connection' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' -import { readCodexThreadId, readCodexThreadPath } from './codex-structured-thread-facts' +import { readCodexThreadId } from './codex-structured-thread-facts' export type CodexOpenedThread = { threadId: string /** The unsaved thread this new one was started in place of. */ supersededThreadId?: string thread?: Record - /** Rollout file Codex named, when it named one. */ - historyPath: string | null historyMode?: 'legacy' | 'paginated' model?: string effort?: string @@ -145,7 +143,6 @@ export async function openCodexThread( threadId, ...(supersededThreadId === undefined ? {} : { supersededThreadId }), thread, - historyPath: readCodexThreadPath(opened), ...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated' ? { historyMode: thread.historyMode } : {}), diff --git a/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts b/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts new file mode 100644 index 00000000000..063e5d48f82 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-approval-subject-kinds.test.ts @@ -0,0 +1,22 @@ +// Every approval subject kind the journal schema knows, against the kinds every supported client +// draws. A client from before unknown subjects were refused leaves Approve live for a kind it cannot +// draw, so a new kind ships only behind a client capability that withholds it from such clients. + +import { expect, it } from 'vitest' +import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from '../../../shared/agent-session-journal-schemas' + +/** Drawn by every supported client. */ +const SUBJECT_KINDS_EVERY_CLIENT_DRAWS = ['plan'] +/** Kinds the host withholds from clients without the capability; none exists yet (STA-9262). */ +const CAPABILITY_GATED_SUBJECT_KINDS: string[] = [] + +it('adds no approval subject kind without a client-capability gate', () => { + const ungated = [...AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS] + .filter((kind) => !CAPABILITY_GATED_SUBJECT_KINDS.includes(kind)) + .sort() + expect( + ungated, + 'New approval subject kinds need a client-capability gate first: older clients would let ' + + 'users approve it blind. See https://linear.app/stably/issue/STA-9262' + ).toEqual(SUBJECT_KINDS_EVERY_CLIENT_DRAWS) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts new file mode 100644 index 00000000000..acaaf99e0ae --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-closed-sets.test.ts @@ -0,0 +1,109 @@ +// Every closed set a journal row is read against: a row kind, a lifecycle mutation kind, and each +// enum, literal or discriminant in a body. A new row or mutation kind, or a bumped row version, +// makes older builds refuse the chat as a newer Orca's ("Update Orca to open it"); a new value in a +// body's closed set at the same version is damage to them ("Unable to load this chat"), where a +// turn's context usage is dropped instead. This snapshot sits beside the row version so that a +// change to either is deliberate. + +import { expect, it } from 'vitest' +import { z } from 'zod' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' +import { KNOWN_MUTATION_KINDS, KNOWN_ROW_KINDS } from './journal-row-schema' + +type ClosedValue = string | number | boolean | null +type ClosedSets = Record + +/** Nodes that hold no closed set and nothing nested. */ +const LEAVES = new Set(['string', 'number', 'boolean', 'unknown', 'any', 'null', 'undefined']) + +function isClosedValue(value: unknown): value is ClosedValue { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) +} + +/** Walks a schema and collects, by path, every value a closed node allows. A plain union whose + * other arm takes any string for a discriminated arm's tag (blocks, goal states) is open there: + * older builds read a new tag as-is, so that path is listed apart. Throws on a node kind it does + * not know, so a new kind of schema cannot hide a closed set from this test. */ +function collectClosedSets( + schema: z.core.$ZodType, + path: string, + into: { closed: ClosedSets; catchAll: string[] } +): void { + const join = (key: string) => (path ? `${path}.${key}` : key) + if (schema instanceof z.ZodLiteral || schema instanceof z.ZodEnum) { + const kept = (into.closed[path] ??= []) + const values = schema instanceof z.ZodLiteral ? [...schema.values] : schema.options + for (const value of values) { + if (isClosedValue(value) && !kept.includes(value)) { + kept.push(value) + } + } + kept.sort((left, right) => String(left).localeCompare(String(right))) + } else if (schema instanceof z.ZodObject) { + for (const [key, field] of Object.entries(schema.shape)) { + collectClosedSets(field, join(key), into) + } + } else if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) { + collectClosedSets(schema.unwrap(), path, into) + } else if (schema instanceof z.ZodArray) { + collectClosedSets(schema.element, `${path}[]`, into) + } else if (schema instanceof z.ZodUnion) { + for (const option of schema.options) { + collectClosedSets(option, path, into) + } + for (const tag of catchAllTags(schema.options)) { + delete into.closed[join(tag)] + into.catchAll.push(join(tag)) + } + } else if (!LEAVES.has(schema._zod.def.type)) { + throw new Error(`no rule for a ${schema._zod.def.type} schema at ${path || 'the root'}`) + } +} + +/** Tags a discriminated arm keys on that another arm of the same union accepts as any string. */ +function catchAllTags(options: readonly z.core.$ZodType[]): string[] { + const tags = options.flatMap((option) => + option instanceof z.ZodDiscriminatedUnion ? [option.def.discriminator] : [] + ) + return tags.filter((tag) => + options.some( + (option) => option instanceof z.ZodObject && option.shape[tag] instanceof z.ZodString + ) + ) +} + +function journalClosedSets() { + const closed: ClosedSets = { + 'row.kind': [...KNOWN_ROW_KINDS.keys()].sort(), + 'row.mutations[].kind': [...KNOWN_MUTATION_KINDS.keys()].sort() + } + const into = { closed, catchAll: new Array() } + collectClosedSets(AgentJournalItemBodySchema, 'body', into) + return { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, ...into } +} + +/** Recorded with the row version it holds for. Change it only together with that decision. */ +const SNAPSHOT = { + v: 3, + closed: { + 'row.kind': ['dispatch', 'epoch', 'item', 'lifecycle-batch', 'submission', 'tombstone'], + 'row.mutations[].kind': ['item', 'tombstone'], + 'body.contextUsage.used.kind': ['estimate', 'report', 'unknown'], + 'body.contextUsage.used.categories[].deferred': [true] + }, + catchAll: ['body.blocks[].type', 'body.subject.kind', 'body.threadGoal.state', 'body.kind'] +} + +it('changes no closed set of a journal row without the row version', () => { + expect( + journalClosedSets(), + 'A new closed-set value without a row-version bump makes older builds call the chat ' + + 'damaged, where with the bump they say to update Orca. Bump v, then update this snapshot.' + ).toEqual(SNAPSHOT) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts deleted file mode 100644 index 8c4db243aa7..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// A repair drops what it cannot replay, and says so. -// -// Two things make a suffix unreplayable: a row this build cannot parse, and a -// sequence gap that makes every later row unanchored. The rejected suffix is -// DELETED; the load reports `corrupt`, and recovery rebuilds the epoch from -// provider history. Every case here asserts the same two halves: the live epoch -// holds only the replayable prefix, AND the epoch stays anchored so nothing -// replays a repaired journal as a clean timeline. - -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, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' -import { parseJournalRow, type JournalRow } from './journal-row-schema' -import type { openAgentSessionJournal } from './journal-store-factory' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - liveTestJournalRows, - updateTestJournalRowJson, - deleteTestJournalRow -} from './journal-host-database-test-support' - -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 -const journals = createTrackedJournalOpener() - -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 }] } -} - -function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -async function withJournalDatabase(run: (db: Database.Database) => void): Promise { - const opened = openTestJournalHostDatabase(root) - try { - run(opened.db) - } finally { - opened.close() - } -} - -/** The row replay anchors on, parsed exactly as replay parses it. */ -function firstLiveRow(): Promise { - let row: JournalRow | null = null - return withJournalDatabase((db) => { - const stored = liveTestJournalRows(db, IDENTITY.sessionId)[0] - const parsed = stored ? parseJournalRow(stored.rowJson) : null - row = parsed?.ok ? parsed.row : null - }).then(() => row) -} - -function liveSequences(): Promise { - let sequences: number[] = [] - return withJournalDatabase((db) => { - sequences = liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.seq) - }).then(() => sequences) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-repair-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a malformed row', () => { - it('keeps the readable prefix live and drops the rest of the epoch', async () => { - const journal = await open() - await journal.appendItem(item(0), body('readable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(1), body('unreadable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(2), body('after the fault'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withJournalDatabase((db) => { - updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '{"not":"a row"}') - }) - - const reopened = await open() - expect(reopened.repair.malformedRows).toBe(1) - // 1..2 is the surviving prefix; 3 is the disclosure the repair appends. - expect(await liveSequences()).toEqual([1, 2, 3]) - }) - - it('discloses the line it could not read', async () => { - const journal = await open() - await journal.appendItem(item(0), body('readable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(1), body('later'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withJournalDatabase((db) => { - updateTestJournalRowJson(db, IDENTITY.sessionId, 2, '}{') - }) - - const reopened = await open() - const disclosure = reopened - .snapshot() - .items.map((entry) => entry.body) - .find((entry) => entry.kind === 'status') - // Named for the client to word, with English for a client that can't. - expect(disclosure).toEqual({ - kind: 'status', - text: "Part of this chat's history couldn't be loaded.", - presentation: 'history-repaired' - }) - }) -}) - -describe('a sequence gap', () => { - it('drops every row after the hole and reports the epoch corrupt', async () => { - const journal = await open() - for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves - // 5 and 6 valid but unanchored. - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 4) - }) - - const reopened = await open() - expect(await liveSequences()).toEqual([1, 2, 3]) - expect(reopened.repair).toEqual({ malformedRows: 0 }) - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0'), body('m1')]) - }) - - // The prefix survives, so there is no emptied epoch to re-anchor and — a gap - // costing no malformed row — no disclosure either. Without a durable marker - // the next probe reads a contiguous anchored prefix and calls it clean, and - // the rows the repair deleted are never asked for again. - it('still reports corrupt on the next probe, with the deleted suffix unrebuilt', async () => { - const journal = await open() - for (let ordinal = 0; ordinal < 5; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 4) - }) - - const repaired = await open() - await repaired.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) - - // Same policy the emptied-epoch repair takes: a session that writes into the - // epoch owns it, and a later import must not replace rows the user has seen. - const writable = await open() - await writable.appendItem(item(9), body('typed after the repair'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await writable.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) - }) - - // The disclosure is the repair talking about itself, not the session writing: - // counting it as content would retire the marker the instant it was raised. - it('is not settled by the repair disclosure it appends for a malformed row', async () => { - const journal = await open() - for (let ordinal = 0; ordinal < 3; ordinal += 1) { - await journal.appendItem(item(ordinal), body(`m${ordinal}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - await withJournalDatabase((db) => { - updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') - }) - - const repaired = await open() - expect(repaired.repair.malformedRows).toBe(1) - await repaired.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) - }) -}) - -describe('a missing epoch row', () => { - // Sequence 1 is the anchor for the whole epoch. Validating from the first row - // that HAPPENS to remain declares the leftovers contiguous, and replay then - // renders a repaired journal as a clean timeline. - it('rejects the whole surviving range rather than declaring it contiguous', async () => { - const journal = await open() - await journal.appendItem(item(0), body('anchor'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendSubmission({ - clientMessageId: 'client-message-1', - payloadFingerprint: 'fingerprint-1', - body: { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'the user typed this' }] - }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-message-1', - state: 'accepted', - providerIdentity: item(1), - fence: 1 - }) - await journal.close() - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 1) - }) - - const reopened = await open() - expect(reopened.repair).toEqual({ malformedRows: 0 }) - expect(reopened.snapshot().items).toEqual([]) - - // The epoch cannot be left row-less. An ordinary append would then take - // sequence 1, and replay would call that non-epoch row a clean timeline. - expect(await liveSequences()).toEqual([1]) - const anchor = await firstLiveRow() - expect(anchor).toMatchObject({ kind: 'epoch', reason: 'unreconcilable_prefix' }) - }) - - // The repair epoch is a placeholder for history it could not rebuild. Left - // clean it would end automatic recovery: the provider transcript is never - // consulted again and the dropped rows never come back. - it('keeps asking for provider history until the epoch has content of its own', async () => { - const journal = await open() - await journal.appendItem(item(0), body('anchor'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withJournalDatabase((db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 1) - }) - - const repaired = await open() - await repaired.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) - - // A session that writes into the epoch owns it: its own rows are not a - // repair placeholder, and a later import must not replace them. - const writable = await open() - await writable.appendItem(item(1), body('typed after the repair'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await writable.close() - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-cursor.ts b/src/main/native-chat/agent-session-journal/journal-cursor.ts index bd43623ae49..3e79cf326a5 100644 --- a/src/main/native-chat/agent-session-journal/journal-cursor.ts +++ b/src/main/native-chat/agent-session-journal/journal-cursor.ts @@ -72,20 +72,15 @@ export function findSequenceGap( 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. */ +/** Rows appended after `cursor`, or the reset a client must take instead. */ export function readJournalSince( source: { state: { epoch: string; lastSequence: number; oldestSequence: number } rowsAfter: (afterSequence: number) => 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 } diff --git a/src/main/native-chat/agent-session-journal/journal-damage.test.ts b/src/main/native-chat/agent-session-journal/journal-damage.test.ts new file mode 100644 index 00000000000..2ced3e4b2a9 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-damage.test.ts @@ -0,0 +1,245 @@ +// Damage fails the chat's load, and nothing is deleted. +// +// A row this build cannot parse, a row numbered against its key, a gap in the sequence and an epoch +// without its first row are each damage. The open refuses the chat as history that cannot be +// loaded, and every stored row is where it was. + +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 { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import type { openAgentSessionJournal } from './journal-store-factory' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + liveTestJournalRows, + updateTestJournalRowJson, + deleteTestJournalRow, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const UNLOADABLE = { + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +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 }] } +} + +function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) +} + +function withJournalDatabase(run: (db: Database.Database) => T): T { + const opened = openTestJournalHostDatabase(root) + try { + return run(opened.db) + } finally { + opened.close() + } +} + +function storedRows(): { seq: number; rowJson: string }[] { + return withJournalDatabase((db) => liveTestJournalRows(db, IDENTITY.sessionId)) +} + +/** A chat of an epoch row and five messages: sequences 1 to 6. */ +async function writeChat(): Promise { + const journal = await open() + for (let ordinal = 0; ordinal < 5; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`m${ordinal}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + await journal.close() +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-damage-')) + clock = 1_000 +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('a damaged chat', () => { + it.each([ + [ + 'a row that is not a row', + (db: Database.Database) => updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') + ], + [ + 'a row of a known kind missing a field', + (db: Database.Database) => + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + 3, + JSON.stringify({ ...JSON.parse(storedRowJson(db, 3)), itemId: 5 }) + ) + ], + [ + 'a row numbered against its key', + (db: Database.Database) => + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + 3, + JSON.stringify({ ...JSON.parse(storedRowJson(db, 3)), seq: 9 }) + ) + ], + [ + 'a gap in the sequence', + (db: Database.Database) => deleteTestJournalRow(db, IDENTITY.sessionId, 4) + ], + ['no epoch row', (db: Database.Database) => deleteTestJournalRow(db, IDENTITY.sessionId, 1)] + ])('fails to load and keeps every row: %s', async (_name, damage) => { + await writeChat() + withJournalDatabase(damage) + const before = storedRows() + + await expect(open()).rejects.toMatchObject(UNLOADABLE) + await expect(open()).rejects.toMatchObject(UNLOADABLE) + + expect(storedRows()).toEqual(before) + }) + + it('names where the damage starts', async () => { + await writeChat() + withJournalDatabase((db) => deleteTestJournalRow(db, IDENTITY.sessionId, 4)) + + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ + newer: null, + damage: { sequence: 4, cause: 'sequence-gap' } + }) + }) + + it("reads past damage to a newer build's row, which fails the load as a newer Orca's instead", async () => { + await writeChat() + withJournalDatabase((db) => { + updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') + const newer = { ...JSON.parse(storedRowJson(db, 5)), kind: 'checkpoint' } + updateTestJournalRowJson(db, IDENTITY.sessionId, 5, JSON.stringify(newer)) + }) + const before = storedRows() + + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(storedRows()).toEqual(before) + }) +}) + +function storedRowJson(db: Database.Database, seq: number): string { + const row = liveTestJournalRows(db, IDENTITY.sessionId).find((entry) => entry.seq === seq) + if (!row) { + throw new Error(`no row at ${seq}`) + } + return row.rowJson +} + +describe('a row the reader would reject', () => { + // TypeScript accepts it; the persisted reader rejects a call id that is only spaces. + const blankCallId: AgentJournalItemBody = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] + } + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + + it('is refused when it is written, and the chat still loads with every other row', async () => { + const journal = await open() + await journal.appendItem(item(0), body('before'), scope) + await expect(journal.appendItem(item(1), blankCallId, scope)).rejects.toMatchObject({ + code: 'journal_row_rejected' + }) + await journal.appendItem(item(2), body('after'), scope) + await journal.close() + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('before'), + body('after') + ]) + expect(storedRows().map((row) => row.seq)).toEqual([1, 2, 3]) + }) + + it('rolls back a whole epoch replacement that holds one, keeping the epoch it would replace', async () => { + const journal = await open() + await journal.appendItem(item(0), body('kept'), scope) + // The open journal's own database: closing it here would close the journal's too. + const liveRows = () => + liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) + const before = liveRows() + + await expect( + journal.replaceEpochItems('legacy_import', 1, [ + { identity: item(5), body: body('replacement') }, + { identity: item(6), body: blankCallId } + ]) + ).rejects.toMatchObject({ code: 'journal_row_rejected' }) + + expect(liveRows()).toEqual(before) + await journal.appendItem(item(1), body('still writable'), scope) + await journal.close() + await expect(open()).resolves.toBeTruthy() + }) +}) + +describe('an epoch named with no rows at all', () => { + // A crash inside an older build's repair: the suffix deleted, its new epoch not yet published. + it('is founded afresh on open, with nothing deleted, and takes writes', async () => { + await writeChat() + withJournalDatabase((db) => { + for (const row of liveTestJournalRows(db, IDENTITY.sessionId)) { + deleteTestJournalRow(db, IDENTITY.sessionId, row.seq) + } + }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ + newer: null, + damage: null, + state: { lastSequence: 0 } + }) + + const journal = await open() + await journal.appendItem(item(0), body('after'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.close() + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('after')]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index e110b9221c4..00538fe75f9 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -2,16 +2,16 @@ // // `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. // `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so -// an append is one INSERT. `journal_repairs` carries at most one row per chat: the standing demand -// for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports` +// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it +// on this same schema version; this build does neither. `journal_imports` // records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and // `journal_set_aside` each chat whose per-chat file is not this build's history and is never read // again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the // retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version - * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, - * so every change stays additive. */ + * (`JournalRow.v`): a newer build can change either alone. A newer version is opened read-only here + * and its chats are refused as a newer Orca's, so every change stays additive. */ export const JOURNAL_DB_SCHEMA_VERSION = 4 /** The first version a release wrote; 1 and 2 only ever came from development builds. */ diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 677efc21c4d..649cfccdf1a 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -16,7 +16,6 @@ import { journalDatabasePath } from './journal-host-database' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { deleteJournalEpochRows, - deleteJournalRowSuffix, deleteUnpublishedJournalRows, insertJournalRow, iterateJournalEpochRows, @@ -130,7 +129,7 @@ describe('the host journal database open', () => { }) describe('journal row statements', () => { - it('serves replay, resume, suffix truncation and an epoch discard', () => { + it('serves replay, resume and an epoch discard', () => { const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { db.exec('BEGIN IMMEDIATE') @@ -149,9 +148,6 @@ describe('journal row statements', () => { 4, 5 ]) - expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2) - expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3]) - // Another chat in the same file is untouched by this chat's discard. deleteJournalEpochRows(db, 'session-1', 'epoch-1') expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index 485e4d78417..3a79762ee2d 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -20,7 +20,6 @@ export class JournalEpochController { serialize: (run: JournalWriteBody) => Promise database: () => JournalHostDatabase readOnly: () => boolean - setReadOnly: (readOnly: boolean) => void highestFence: () => number /** What of the live epoch's Stop and Resume a replacement restates. */ queuePauseRestatement: () => JournalQueuePauseRestatement @@ -42,8 +41,8 @@ export class JournalEpochController { } /** - * Every reason takes the same writable guard. A latched store refuses a roll - * like any other write, and `schema_unreadable` has no production caller. + * Every reason takes the same writable guard: a newer Orca's database refuses a roll like any + * other write, and `schema_unreadable` has no production caller. * * Serialized like every other write, so the discard cannot land between an * admitted append's sequence assignment and its commit. @@ -52,7 +51,6 @@ export class JournalEpochController { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId) this.start(reason, fence) - this.deps.setReadOnly(false) return this.deps.cursor() }) } 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 index 70efc891283..1921bc82a45 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -1,8 +1,7 @@ // Republishing a live item set into a fresh epoch. // // One transaction: discard the old epoch's rows, insert the epoch row plus the -// replacement items, move the session projection, and retire any repair marker -// — this republished history is exactly what the marker was holding out for. +// replacement items, and move the session projection. import type { AgentJournalItemBody, @@ -13,7 +12,6 @@ import type { } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' -import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { @@ -97,7 +95,6 @@ export function replaceJournalEpoch(input: { if (retired !== null) { deleteJournalEpochRows(db, sessionId, retired) } - clearJournalRepairMarker(db, sessionId) for (const row of rows) { insertJournalRow(db, sessionId, row) } @@ -108,5 +105,5 @@ export function replaceJournalEpoch(input: { // live one. The caller adopts that immediately, or a later failure leaves the // live store writing into an epoch whose rows were just deleted. state.oldestSequence = 1 - input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + input.onPublished({ state, newer: null, damage: null }) } 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 index 855f60ac326..7f9abfc408f 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -1,15 +1,13 @@ // Opening a new epoch. // // One transaction: discard every row of the superseded epoch, insert the new -// epoch row at sequence 1, move the session projection onto it, and retire any -// repair marker the superseded epoch was carrying. Superseded rows are DELETED -// rather than retained — nothing would ever shed them. +// epoch row at sequence 1, and move the session projection onto it. Superseded +// rows are DELETED rather than retained — nothing would ever shed them. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' -import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { deleteJournalEpochRows, @@ -47,7 +45,6 @@ export function publishNewEpoch(input: { if (retired !== null) { deleteJournalEpochRows(db, sessionId, retired) } - clearJournalRepairMarker(db, sessionId) insertJournalRow(db, sessionId, row) publishJournalSessionEpoch(db, input.identity, input.epoch) }) @@ -58,5 +55,5 @@ export function publishNewEpoch(input: { const state = createJournalReducerState(sessionId, input.epoch) applyJournalRow(state, row) state.oldestSequence = 1 - input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + input.onPublished({ state, newer: null, damage: null }) } diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index ed27ed6fa0b..84fb299fb05 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -21,7 +21,9 @@ import { openTestJournalHostDatabase, loadTestJournal, deleteTestJournalRow, - insertTestJournalRowJson + insertTestJournalRowJson, + liveTestJournalRows, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -142,11 +144,8 @@ describe('a chat whose history is still in the pre-SQLite format', () => { expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) }) - // A repair's epoch is the marker that history was deleted and never rebuilt, - // and any row that is not the repair's own disclosure retires it. Appending - // here would silently stop the session ever asking the provider for that - // history — with the journal still holding none. - it('stays out of a journal this open just repaired', async () => { + // A damaged journal fails its open before this branch, so nothing is appended to it. + it('writes nothing into a damaged journal', async () => { const journal = await open() await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, @@ -154,10 +153,6 @@ describe('a chat whose history is still in the pre-SQLite format', () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() - // Deleting the anchor leaves every row unanchored: replay keeps nothing, so - // the repair publishes an empty `unreconcilable_prefix` epoch and — costing - // no malformed row — appends no disclosure of its own. That is the one state - // where this branch and a repair meet. const opened = openTestJournalHostDatabase(root) try { deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) @@ -166,16 +161,22 @@ describe('a chat whose history is still in the pre-SQLite format', () => { } await writeRemnant() - const repaired = await open() - - expect(disclosure(repaired)).toBeNull() - // Still asking the provider for the history the repair dropped. - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + await expect(open()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ + damage: { sequence: 1, cause: 'no-epoch-row' } + }) + const stored = openTestJournalHostDatabase(root) + try { + expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2]) + } finally { + stored.close() + } }) - // A journal latched by a newer row reaches the same branch — and an append into one throws, - // which would make the session unopenable rather than read-only. - it('writes nothing into a journal latched by a newer row', async () => { + // A newer row fails the load before the empty-epoch branch would write a notice. + it("writes nothing into a newer Orca's journal, whose open is refused", async () => { const founded = await open() const epoch = founded.epoch await founded.close() @@ -197,10 +198,12 @@ describe('a chat whose history is still in the pre-SQLite format', () => { ) await writeRemnant() - const latched = await open() + const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) - expect(latched.isReadOnly).toBe(true) - expect(disclosure(latched)).toBeNull() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual( + before + ) }) // A row nothing projects is a row nobody reads. diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index dbe15b4709e..ad151d10ec7 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -81,6 +81,14 @@ export function createTrackedJournalOpener(): TrackedJournalOpener { } /** What a fresh open of the chat would replay, read from the test state directory's database. */ +/** What an open of a chat a newer Orca saved is refused with. */ +export const SAVED_BY_NEWER_ORCA = { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } +} + export function loadTestJournal(stateDirectory: string, sessionId: string): JournalLoad | null { return replayJournal(openTestJournalHostDatabase(stateDirectory).db, sessionId) } 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 index a38a37fd0c8..3d835c21b73 100644 --- 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 @@ -566,8 +566,7 @@ describe('import failures', () => { }) // A transcript with no decodable messages recovers nothing. Publishing an - // empty replacement would roll the epoch and drop whatever the journal held — - // including a repair's own anchor and disclosure. + // empty replacement would roll the epoch and drop whatever the journal held. it('leaves the epoch untouched when the transcript decodes to no messages', async () => { const journal = await open('codex', CODEX_SESSION) await journal.appendItem( 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 index ba6a918cc6e..bdb95a6fc8e 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -73,7 +73,7 @@ export async function importLegacyTranscriptIntoJournal(input: { if (!prepared.ok) { return prepared } - // An empty import must preserve any existing repair anchor and disclosure. + // An empty import leaves the epoch as it stands. if (prepared.items.length === 0) { const current = input.journal.cursor() return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 787b1aa6491..c61057ac1b9 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -9,11 +9,13 @@ import { createJournalOpenReadRefusals, journalOpenReadRefusal, journalOpenRefusal, - journalOpenRefusalError + journalOpenRefusalError, + failLoadOnUnloadableJournal } from './journal-open-failure' import { AgentSessionJournalError } from './journal-write-guards' import { journalDatabasePath } from './journal-host-database' import { replayJournal } from './journal-open' +import { createJournalReducerState } from './journal-reducer' import { recordingStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger-test-support' let root: string @@ -169,6 +171,68 @@ describe('createJournalOpenReadRefusals', () => { 'open-for-read' ]) }) + + // Where it failed rides as the refusal's cause, so a chat whose reason changes is logged again + // and the log names the row. + it('logs a chat again when its load fails for another reason, naming where each failed', () => { + const log = recordingStructuredAgentSessionLogger() + const refusals = createJournalOpenReadRefusals(log.logger) + const state = createJournalReducerState('session-1', 'epoch-1') + const refusedFor = (load: Parameters[1]) => { + try { + failLoadOnUnloadableJournal('session-1', load) + } catch (error) { + return error + } + throw new Error('the load was not refused') + } + const newer = refusedFor({ state, newer: { sequence: 7 }, damage: null }) + const damaged = refusedFor({ + state, + newer: null, + damage: { sequence: 3, cause: 'sequence-gap' } + }) + + for (const error of [newer, newer, damaged, damaged]) { + refusals.refusal('session-1', error) + } + + const causes = log.entries.map((entry) => { + const error = entry.fields.error + return error instanceof Error && error.cause instanceof Error ? error.cause.message : '' + }) + expect(causes).toEqual([ + expect.stringContaining("newer Orca's row at sequence 7"), + expect.stringContaining('damaged at sequence 3: sequence-gap') + ]) + }) + + // A load refused on its rows arrives already classified, and is logged once per session too. + it("logs a load refused as a newer Orca's chat, or as damage, once per session until it opens", () => { + const log = recordingStructuredAgentSessionLogger() + const refusals = createJournalOpenReadRefusals(log.logger) + const newer = journalOpenRefusalError( + new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote session-1') + ) + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(refusals.refusal('session-1', newer)).toBe(newer) + } + expect(log.entries.map((entry) => entry.fields.sessionId)).toEqual(['session-1']) + refusals.refusal('session-2', newer) + refusals.forget('session-1') + refusals.refusal('session-1', newer) + expect(log.entries.map((entry) => entry.fields.sessionId)).toEqual([ + 'session-1', + 'session-2', + 'session-1' + ]) + // A refusal that is not about loading the history is not this door's to log. + refusals.refusal( + 'session-3', + agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) + ) + expect(log.entries).toHaveLength(3) + }) }) // Only an update gets past a journal a newer Orca wrote, so it has a reason of its own: a client diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index d154e1b29e6..8ae45ca4a31 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -6,12 +6,12 @@ import type { AgentSessionRefusalReason } from '../../../shared/agent-session-re import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import { AgentSessionRefusalError, - agentSessionRefusalError, isAgentSessionRefusalError, refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' +import type { JournalLoad } from './journal-open' import { AgentSessionJournalError } from './journal-write-guards' import type { StructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' @@ -25,6 +25,32 @@ export class JournalImportMismatchError extends Error { override readonly name = 'JournalImportMismatchError' } +/** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */ +export class JournalDamageError extends Error { + override readonly name = 'JournalDamageError' +} + +/** The one way a chat's load fails on its rows, wherever they are read, and every row is left where + * it is. A newer Orca's row is refused as one only an update opens, and wins over damage beside + * it; damage is refused as history that cannot be loaded. */ +export function failLoadOnUnloadableJournal(sessionId: string, load: JournalLoad): void { + if (load.newer) { + throw journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + `journal of ${sessionId} holds a newer Orca's row at sequence ${load.newer.sequence}` + ) + ) + } + if (!load.damage) { + return + } + const { sequence, cause } = load.damage + throw journalOpenRefusalError( + new JournalDamageError(`journal of ${sessionId} is damaged at sequence ${sequence}: ${cause}`) + ) +} + /** A database only an unreleased development build wrote: left as found, never migrated. */ export class JournalUnreleasedSchemaError extends Error { override readonly name = 'JournalUnreleasedSchemaError' @@ -33,14 +59,15 @@ export class JournalUnreleasedSchemaError extends Error { // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Unusable only where proven: damage the storage reports, a copy that did not verify, or a file - * only an unreleased build wrote. Anything unproven can clear. */ +/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify, + * or a file only an unreleased build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { if ( isSqliteCorruption(current) || current instanceof JournalImportMismatchError || + current instanceof JournalDamageError || current instanceof JournalUnreleasedSchemaError ) { return 'journalCorrupt' @@ -94,13 +121,17 @@ export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { return refuse('agent_session_journal_unreadable', { reason }, message) } -/** The same refusal, thrown: for a host that could not open the journal at all. */ +/** The same refusal, thrown: for a host that could not open the journal at all. It keeps what + * failed as its cause, for the log. */ export function journalOpenRefusalError(error: unknown): AgentSessionRefusalError { if (isAgentSessionRefusalError(error)) { return error } const { reason, message } = journalOpenFailureWords(error) - return agentSessionRefusalError('agent_session_journal_unreadable', { reason }, message) + return new AgentSessionRefusalError( + refuse('agent_session_journal_unreadable', { reason }, message), + { cause: error } + ) } /** @@ -127,19 +158,33 @@ const MAX_LOGGED_SESSIONS = 256 */ export function createJournalOpenReadRefusals(logger: StructuredAgentSessionLogger) { const logged = new Map() + /** Whether this failure is news for the session, noting it if so. */ + const firstSeen = (sessionId: string, failure: string): boolean => { + const repeat = logged.get(sessionId) === failure + // Past the cap a new session logs every failure rather than evict another's. + if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { + logged.set(sessionId, failure) + } + return !repeat + } return { refusal: (sessionId: string, error: unknown): AgentSessionRefusalError => { if (isAgentSessionRefusalError(error)) { + // A load refused on its rows (damage, a newer Orca's chat) arrives classified. + const { refusal } = error + const cause = error.cause instanceof Error ? error.cause.message : String(error.cause) + if ( + refusal.code === 'agent_session_journal_unreadable' && + firstSeen(sessionId, `${refusal.details?.reason}:${cause}`) + ) { + logOpenForReadFailure(logger, sessionId, error) + } return error } const reason = journalRefusalReason(error) const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` - const repeat = logged.get(sessionId) === failure - // Past the cap a new session logs every failure rather than evict another's. - if (!repeat && (logged.has(sessionId) || logged.size < MAX_LOGGED_SESSIONS)) { - logged.set(sessionId, failure) - } - return unreadableRefusal(error, reason, repeat ? null : { logger, sessionId }) + const log = firstSeen(sessionId, failure) ? { logger, sessionId } : null + return unreadableRefusal(error, reason, log) }, /** The session opened or closed: its next failure is news. */ forget: (sessionId: string): void => { @@ -153,11 +198,21 @@ function unreadableRefusal( reason: JournalRefusalReason, log: { logger: StructuredAgentSessionLogger; sessionId: string } | null ): AgentSessionRefusalError { - log?.logger.warn('opening the conversation for a read failed', { - scope: 'open-for-read', - sessionId: log.sessionId, - error - }) + if (log) { + logOpenForReadFailure(log.logger, log.sessionId, error) + } const code = 'agent_session_journal_unreadable' return new AgentSessionRefusalError(refuse(code, { reason }, code), { cause: error }) } + +function logOpenForReadFailure( + logger: StructuredAgentSessionLogger, + sessionId: string, + error: unknown +): void { + logger.warn('opening the conversation for a read failed', { + scope: 'open-for-read', + sessionId, + error + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 4e64b646824..2c066601ea7 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -2,9 +2,10 @@ // epoch's rows are folded through the reducer in sequence order. // // There is no snapshot to anchor to and no superseded-epoch rows to drop — a -// roll deletes them in the same transaction that publishes the new epoch. A gap -// in the surviving sequence is corruption, and the caller rolls the epoch -// rather than rendering a partial timeline. +// roll deletes them in the same transaction that publishes the new epoch. A row +// this build cannot parse, a gap, or a first row that is not the epoch's is +// damage, and a row a newer Orca wrote is one this build cannot place: the load +// names either and the open fails on it. Nothing is deleted. import type Database from '../../sqlite/sync-database' import { @@ -17,27 +18,25 @@ import { readJournalRowsAfter, readJournalSessionEpoch } from './journal-row-table' -import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' -import { pendingJournalRepairSequence } from './journal-repair-marker' -import { isJournalStopOrResumeRow, parseJournalRow, type JournalRow } from './journal-row-schema' +import { parseJournalRow, type JournalRow } from './journal-row-schema' /** Every epoch row is sequence 1, and no compaction moves that floor. */ const FIRST_JOURNAL_SEQUENCE = 1 +/** Where a journal stops being one this build can read as written. */ +export type JournalDamage = { + /** The first sequence that is missing, or not what the rows before it promise. */ + sequence: number + cause: 'unparseable-row' | 'misnumbered-row' | 'sequence-gap' | 'no-epoch-row' +} + export type JournalLoad = { state: JournalReducerState - /** A row from a future schema, or of a kind this build does not know, was met: no writes, no - * deletion. */ - readOnly: boolean - /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ - corrupt: boolean - /** Rows dropped because they failed to parse or name another sequence than their key (an - * unreadable row latches `readOnly`, never counted here). The store discloses these in the - * timeline. */ - malformedRows: number - /** Directory-internal: the first sequence of an unusable suffix. The store - * deletes from here before it accepts a write; a probe leaves it alone. */ - truncateFrom?: number + /** The first row a newer Orca wrote: a future row version, or a row kind this build does not + * know. Only an update opens the chat. */ + newer: { sequence: number } | null + /** Set when the history is damaged; a newer build's row wins over damage beside it. */ + damage: JournalDamage | null } /** Replays one chat from the host's database. Returns null when the chat has no journal yet. */ @@ -46,20 +45,8 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal if (epoch === null) { return null } - return foldJournalRows({ - sessionId, - epoch, - repairedFrom: pendingJournalRepairSequence(db, sessionId, epoch), - rows: iterateJournalEpochRows(db, sessionId, epoch) - }) -} - -/** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ -function foldJournalRows( - input: JournalRowFoldInput & { rows: Iterable<{ seq: number; rowJson: string }> } -): JournalLoad { - const fold = startJournalRowFold(input) - for (const entry of input.rows) { + const fold = startJournalRowFold({ sessionId, epoch }) + for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) { if (!fold.add(entry)) { break } @@ -67,85 +54,63 @@ function foldJournalRows( return fold.finish() } -type JournalRowFoldInput = { - sessionId: string - epoch: string - /** The sequence a pending repair on this epoch left free. */ - repairedFrom: number | null -} - /** The same fold, fed a row at a time, for a caller that yields between batches of rows. */ -export function startJournalRowFold(input: JournalRowFoldInput): { +export function startJournalRowFold(input: { sessionId: string; epoch: string }): { /** False once the fold has stopped: the rest of the rows are not read. */ add: (entry: { seq: number; rowJson: string }) => boolean finish: () => JournalLoad } { - const { repairedFrom } = input const state = createJournalReducerState(input.sessionId, input.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE - let gapSequence: number | undefined - let unanchoredSequence: number | undefined - let anchor: Extract | undefined - let repairHasContent = false - let providerHasContent = false - let malformedRows = 0 - let latched = false - let truncateFrom: number | undefined + let damage: JournalDamage | null = null + let anchored = false + let newer: { sequence: number } | null = null + let empty = true const add = (entry: { seq: number; rowJson: string }): boolean => { + empty = false const parsed = parseJournalRow(entry.rowJson) - // A body naming another sequence than its key is malformed there: writes number past the key. - if (!parsed.ok || parsed.row.seq !== entry.seq) { - truncateFrom = entry.seq - latched = !parsed.ok && parsed.unreadable - malformedRows = latched ? 0 : 1 + if (!parsed.ok && parsed.unreadable) { + newer = { sequence: entry.seq } return false } - const row = parsed.row - // Parse past a gap so an unreadable future row still latches read-only. - if (gapSequence !== undefined) { + // Damage is read past, so a newer build's row further on still says to update. + if (!parsed.ok) { + damage ??= { sequence: entry.seq, cause: 'unparseable-row' } return true } + // A body naming another sequence than its key: writes would number past the key. + if (parsed.row.seq !== entry.seq) { + damage ??= { sequence: entry.seq, cause: 'misnumbered-row' } + return true + } + if (damage) { + return true + } + const row = parsed.row if (row.seq !== expectedSequence) { - gapSequence = row.seq + const cause = expectedSequence === FIRST_JOURNAL_SEQUENCE ? 'no-epoch-row' : 'sequence-gap' + damage = { sequence: expectedSequence, cause } return true } expectedSequence += 1 - if (row.seq === FIRST_JOURNAL_SEQUENCE) { - if (row.kind === 'epoch') { - anchor = row - } else { - unanchoredSequence = row.seq - } - } - if (!anchor) { + if (row.seq === FIRST_JOURNAL_SEQUENCE && row.kind !== 'epoch') { + damage = { sequence: row.seq, cause: 'no-epoch-row' } return true } + anchored = true applyJournalRow(state, row) - const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID - // A Stop or Resume is no history, so it never reads as a rebuilt or provider-backed epoch. - if (!disclosure && !isJournalStopOrResumeRow(row)) { - repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom - providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 - } return true } const finish = (): JournalLoad => { - // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. - truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom state.oldestSequence = FIRST_JOURNAL_SEQUENCE - return { - state, - readOnly: latched, - corrupt: - gapSequence !== undefined || - malformedRows > 0 || - (!latched && !anchor) || - (repairedFrom !== null && !repairHasContent) || - (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), - malformedRows, - ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) - } + // An epoch with no rows at all holds nothing to lose: the open founds a fresh one over it. + const found = + damage ?? + (anchored || empty + ? null + : { sequence: FIRST_JOURNAL_SEQUENCE, cause: 'no-epoch-row' as const }) + return { state, newer, damage: newer ? null : found } } return { add, finish } } @@ -160,8 +125,8 @@ export function journalRowsAfterReader( return (afterSequence) => readJournalRowsAfterCursor(db, sessionId, epoch, afterSequence, limit) } -/** Rows after a cursor, in sequence order. Stops at the first row this build - * cannot parse, exactly as replay does. */ +/** Rows after a cursor, in sequence order. Stops at the first row this build cannot parse: rows + * past it are never served. */ export function readJournalRowsAfterCursor( db: Database.Database, sessionId: string, diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts index ebe2da08a55..6c291c3a378 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -2,7 +2,10 @@ // that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that // reappears after a downgrade is set aside on disk, and the chat keeps this build's history. -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION +} from '../../../shared/agent-session-journal-types' import type * as NodeFs from 'node:fs' import { existsSync, rmSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' @@ -23,10 +26,11 @@ import { liveTestJournalRows, loadTestJournal, openTestJournalHostDatabase, - readTestJournalRows + readTestJournalRows, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import { importPerSessionJournal } from './journal-per-session-import' +import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' @@ -170,7 +174,7 @@ function alteringFirstCopiedRead( } } -const losingFirstCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(1)) +const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1)) /** Every row still there and still parsing, but the reply's words changed. */ const garblingCopiedReply = alteringFirstCopiedRead((rows) => @@ -385,7 +389,7 @@ describe('importing a per-chat journal', () => { database, identity: IDENTITY, legacyDirectory: legacyDir(), - openSource: losingFirstCopiedRow + openSource: losingLastCopiedRow } const before = await readFile(legacyJournalDatabaseFile(legacyDir())) @@ -410,6 +414,75 @@ describe('importing a per-chat journal', () => { expect(rowCount(database.db)).toBe(rows.length) }) + it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => { + const { epoch } = await historyRows() + await writeLegacyJournal(epoch, []) + const database = openTestJournalHostDatabase(root) + const db = new Database(legacyJournalDatabaseFile(legacyDir())) + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) + db.close() + + await expect( + previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() }) + ).resolves.toBeNull() + const journal = await openChat() + + expect(journal.snapshot().items).toEqual([]) + expect(journal.cursor().sequence).toBe(1) + }) + + it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => { + const { epoch, rows } = await historyRows() + // A gap: the reply's row is gone from the file. + await writeLegacyJournal( + epoch, + rows.filter((row) => row.seq !== 2) + ) + const database = openTestJournalHostDatabase(root) + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + const unloadable = { + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + } + const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } + + await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable) + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable) + } + await expect(openChat()).rejects.toMatchObject(unloadable) + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect(rowCount(database.db)).toBe(0) + expect( + database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() + ).toMatchObject({ total: 0 }) + }) + + it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => { + const { epoch, rows } = await historyRows() + const last = rows.at(-1)! + const newer = { + ...last, + rowJson: JSON.stringify({ + ...JSON.parse(last.rowJson), + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 + }) + } + await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer]) + const database = openTestJournalHostDatabase(root) + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } + + await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect(rowCount(database.db)).toBe(0) + }) + // A copy that keeps every count but not every byte is no copy: the file is all there is. it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { const { epoch, rows } = await historyRows() diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts index 5f85fdded43..66f1f139ea3 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts @@ -9,10 +9,10 @@ // The copy runs in bounded batches, each its own transaction, yielding the event loop between them. // The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader // sees them. Once they read back as the file does (every row's sequence, time and bytes), one -// transaction publishes the chat's pointer with its repair and import markers, so the chat is -// imported all at once or not at all. A try that stops midway leaves only unpublished rows, which -// the next try deletes before it copies again. A copy that does not read back as the file is never -// published: the file stays, and the chat is refused as unreadable. +// transaction publishes the chat's pointer with its import marker, so the chat is imported all at +// once or not at all. A try that stops midway leaves only unpublished rows, which the next try +// deletes before it copies again. A copy that does not read back as the file, or a file whose +// history is damaged, is never published: the file stays, and the chat is refused as unloadable. // // Only after that commit is the file deleted, its connection closed first. A read that fails // leaves the file where it is for the next open, and the open is refused rather than served empty: @@ -24,8 +24,12 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import type { JournalHostDatabase } from './journal-host-database' -import type { JournalLoad } from './journal-open' -import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure' +import { startJournalRowFold, type JournalLoad } from './journal-open' +import { + failLoadOnUnloadableJournal, + JournalImportMismatchError, + journalOpenRefusalError +} from './journal-open-failure' import { legacyJournalDatabaseFile } from './journal-paths' import { planPerSessionImport, @@ -42,7 +46,6 @@ import { legacyRowBatches, openLegacySource, readLegacyHead, - readLegacyRepair, retireLegacyJournal, type ImportBatch } from './journal-per-session-source' @@ -57,10 +60,6 @@ import { const INSERT_ROW = 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' -const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) -VALUES (?, ?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET - epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database @@ -151,7 +150,8 @@ async function importOnce(input: ImportInput): Promise @@ -173,16 +173,18 @@ export async function previewPerSessionJournal( return null } const loaded = await foldLegacyJournal(source, sessionId, legacy) - return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded + failLoadOnUnloadableJournal(sessionId, loaded) + // An empty one is copied now, so its open founds the chat's epoch in the host's database. + return loaded.state.lastSequence === 0 ? null : loaded } finally { source.close() } } /** - * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Once - * the rows read back as the file does, one transaction publishes the pointer with the chat's repair - * marker and the import marker. + * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each + * batch is folded as it is copied, so a damaged file is refused before anything is published. Once + * the rows read back as the file does, one transaction publishes the pointer and the import marker. */ async function copyLegacyJournal( input: ImportInput, @@ -191,13 +193,15 @@ async function copyLegacyJournal( ): Promise { const { sessionId } = input.identity const { epoch } = legacy - const repair = readLegacyRepair(source, sessionId) const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + const fold = startJournalRowFold({ sessionId, epoch }) + let folding = true let first = true for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { if (!first) { await yieldToEventLoop() } + folding &&= batch.rows.every(fold.add) // Unsynced: no reader follows these rows, and the publish's synced commit covers them. input.database.unsyncedTransaction((db) => { if (first) { @@ -212,17 +216,15 @@ async function copyLegacyJournal( }) first = false } + const copied = fold.finish() + if (copied.damage || copied.newer) { + // Never published, so the copy goes; the file it came from stays. + input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId)) + } + failLoadOnUnloadableJournal(sessionId, copied) await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) input.database.transaction((db) => { publishJournalSessionEpoch(db, input.identity, epoch) - if (repair) { - db.prepare(UPSERT_REPAIR).run( - sessionId, - repair.epoch, - repair.content_from, - repair.repaired_at - ) - } writePerSessionImportMarker(db, sessionId, legacy) }) } diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts index dc548e2f3dd..a58a1de3bf5 100644 --- a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts +++ b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts @@ -4,11 +4,9 @@ import { rmdirSync, rmSync } from 'node:fs' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import Database from '../../sqlite/sync-database' -import type { SqliteRow } from '../../sqlite/sqlite-statement' import { startJournalRowFold, type JournalLoad } from './journal-open' import { legacyJournalDatabaseFile } from './journal-paths' import type { PerSessionJournalHead } from './journal-per-session-reimport' -import { pendingJournalRepairSequence } from './journal-repair-marker' /** The newest per-chat file shape any build wrote. */ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 @@ -21,8 +19,6 @@ const SELECT_LEGACY_TIP = const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" -const SELECT_LEGACY_REPAIR = - 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' type ImportedRow = { seq: number; ts: number; rowJson: string } export type ImportBatch = { rows: ImportedRow[]; last: boolean } @@ -81,27 +77,13 @@ export function* legacyRowBatches( } } -/** The per-chat repair marker, as stored; v1 files predate the table. */ -export function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { - if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) { - return null - } - return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null -} - /** The chat as its file holds it, folded the way a replay folds the host's database. */ export async function foldLegacyJournal( source: Database.Database, sessionId: string, legacy: PerSessionJournalHead ): Promise { - const fold = startJournalRowFold({ - sessionId, - epoch: legacy.epoch, - repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') - ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) - : null - }) + const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch }) let first = true for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { // A batch per turn: a large chat's file read in one task holds up everything else at startup. diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index fe7b974d77e..4aa27599434 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -5,6 +5,7 @@ import type { AgentJournalQuestion, AgentJournalQuestionItem } from '../../../shared/agent-session-journal-types' +import { isPlanApprovalSubject } from '../../../shared/agent-session-approval-subject' import { boundInlineText, boundPayload, @@ -68,16 +69,20 @@ export function boundJournalPromptBody( : { ruleContent: boundPromptText(body.matchedAskRule.ruleContent) }) } }), + // A newer Orca's subject is carried as it was: rebuilt as a plan, it would be a different + // request, and its fields are not this build's to bound. ...(body.subject === undefined ? {} : { - subject: { - kind: 'plan', - text: boundPromptText(body.subject.text), - ...(body.subject.filePath === undefined - ? {} - : { filePath: boundPromptText(body.subject.filePath) }) - } + subject: isPlanApprovalSubject(body.subject) + ? { + kind: 'plan', + text: boundPromptText(body.subject.text), + ...(body.subject.filePath === undefined + ? {} + : { filePath: boundPromptText(body.subject.filePath) }) + } + : body.subject }), detail: body.detail === null ? null : boundPromptText(body.detail), options: boundPromptOptions(body.options) diff --git a/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts b/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts deleted file mode 100644 index 83846333300..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-repair-disclosure.ts +++ /dev/null @@ -1,35 +0,0 @@ -// What a repair tells the user it did. -// -// The identity is a constant because replay reads it back: an epoch holding -// nothing but its anchor and this row is a repair that has not been -// reconstructed yet, not a timeline. - -import { agentSessionHostStatusBody } from '../../../shared/agent-session-host-status-rows' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalPlainStatusItem -} from '../../../shared/agent-session-journal-types' - -/** One stable identity, so a reopen upserts the same row instead of adding one. */ -export const JOURNAL_REPAIR_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { - provider: 'orca', - clientMessageId: 'journal-malformed-lines' -} - -export const JOURNAL_REPAIR_DISCLOSURE_ITEM_ID = agentJournalItemKey( - JOURNAL_REPAIR_DISCLOSURE_IDENTITY -) - -export type JournalRepairDisclosure = { - identity: AgentJournalItemIdentity - body: AgentJournalPlainStatusItem -} - -/** Disclosed when a repair skipped a row it could not read. */ -export function journalRepairDisclosure(): JournalRepairDisclosure { - return { - identity: JOURNAL_REPAIR_DISCLOSURE_IDENTITY, - body: agentSessionHostStatusBody('history-repaired') - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts deleted file mode 100644 index 78533945671..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts +++ /dev/null @@ -1,65 +0,0 @@ -// The standing demand for a rebuild that a repair leaves behind. -// -// A repair that empties the epoch republishes an `unreconcilable_prefix` anchor, -// and replay reads that back as history still owed. A repair that KEEPS a prefix -// has no such anchor to publish and — for a plain sequence gap — no malformed -// row to disclose either, so nothing on disk would record that the deleted -// suffix was never reconstructed. This marker is that record, written in the -// SAME transaction as the deletion: a crash between the two would otherwise -// leave the rows gone with nothing left asking for them back. -// -// It records the first sequence at which the epoch would hold content of its -// own again, because it retires under exactly the rule the emptied-epoch anchor -// takes: a fresh epoch carries the rebuild, and a session that writes past that -// sequence owns the epoch and stops the retry. - -import type Database from '../../sqlite/sync-database' -import type { JournalHostDatabase } from './journal-host-database' -import { deleteJournalRowSuffix } from './journal-row-table' - -const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?' -const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) -VALUES (?, ?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET - epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` -const DELETE_REPAIR = 'DELETE FROM journal_repairs WHERE session_id = ?' - -/** - * The sequence a pending repair on THIS epoch left free, or null when none is - * pending. Epoch-scoped: a marker raised on an epoch that has since been - * superseded says nothing about the live one. - */ -export function pendingJournalRepairSequence( - db: Database.Database, - sessionId: string, - epoch: string -): number | null { - const row = db.prepare(SELECT_REPAIR).get(sessionId) as - | { epoch?: string; content_from?: number } - | undefined - return row?.epoch === epoch ? (row.content_from ?? null) : null -} - -/** Retires the marker. Called from inside the epoch transactions, whose new - * epoch is the rebuilt history the marker was holding out for. */ -export function clearJournalRepairMarker(db: Database.Database, sessionId: string): void { - db.prepare(DELETE_REPAIR).run(sessionId) -} - -/** Drop the rejected suffix and record that it is owed, atomically. */ -export function deleteJournalRepairedSuffix(input: { - database: JournalHostDatabase - sessionId: string - epoch: string - /** First sequence of the rejected suffix. */ - fromSeq: number - /** First sequence left free once the suffix is gone. */ - contentFrom: number - now: number -}): number { - return input.database.transaction((db) => { - const deleted = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq) - db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) - return deleted - }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index 623c97445a6..9dcced9944a 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -104,7 +104,6 @@ describe('context facts on replayed turn rows', () => { await journal.close() const reopened = await open() - expect(reopened.repair.malformedRows).toBe(0) expect(reopened.snapshot().items.map((item) => item.body)).toEqual(written) expect(written).toHaveLength(FACTS.length) }) @@ -132,7 +131,6 @@ describe('context facts on replayed turn rows', () => { } const reopened = await open() - expect(reopened.repair.malformedRows).toBe(0) expect(reopened.snapshot().items.map((item) => item.body)).toEqual([ turn('turn-0'), { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] } 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 index 1d013255b21..b72a24d70bb 100644 --- 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 @@ -290,8 +290,8 @@ describe('producer linkage on the persisted row', () => { it('writes and reads the bundle back without bumping the schema version', () => { const row = roundTrip(true) expect(row).toMatchObject(linkage) - // Deliberately NOT a version bump: an unknown `v` is unreadable and latches - // the host read-only, while an unknown KEY is simply ignored by an older host. + // Deliberately NOT a version bump: an unknown `v` is unreadable and costs an older + // host the chat, while an unknown KEY is simply ignored by an older host. expect(row?.v).toBe(AGENT_SESSION_JOURNAL_SCHEMA_VERSION) }) @@ -373,7 +373,7 @@ describe('producer linkage on the persisted row', () => { expect(mutations[0]).toMatchObject(linkage) expect(mutations[1] && 'agentId' in mutations[1]).toBe(false) // The same batch without the stamp writes the same version: an older host - // ignores the unknown keys rather than latching the journal read-only. + // ignores the unknown keys rather than losing the chat to an unknown `v`. expect(row.v).toBe( build({ kind: 'item', identity, body, turnScope: AGENT_JOURNAL_THREAD_SCOPE }).v ) @@ -421,7 +421,7 @@ describe('producer linkage on the persisted row', () => { expect(parsed.ok && 'agentId' in parsed.row).toBe(false) }) - it('leaves a strict prompt shape able to parse, because linkage rides the row', () => { + it('parses a prompt shape with linkage on the row', () => { const question = { ...BASE, v: 3, @@ -440,10 +440,9 @@ describe('producer linkage on the persisted row', () => { expect(parsed.ok).toBe(true) }) - it('rejects the same row when linkage is put INSIDE the strict shape', () => { - // The positive control for the test above: proof the strictness it avoids is - // real, rather than the row parsing for some unrelated reason. This is why - // the bundle rides the row base and never a body. + it('reads the same row with linkage put INSIDE a prompt option, ignoring the key there', () => { + // Older builds read prompt options strictly and dropped the journal from such a row, which is + // why the bundle rides the row base and never a body. This build ignores and keeps the key. const smuggled = { ...BASE, v: 3, @@ -457,6 +456,8 @@ describe('producer linkage on the persisted row', () => { resolution } } - expect(parseJournalRow(JSON.stringify(smuggled)).ok).toBe(false) + const parsed = parseJournalRow(JSON.stringify(smuggled)) + expect(parsed.ok && parsed.row.kind === 'item' && parsed.row.body).toEqual(smuggled.body) + expect(parsed.ok && 'agentId' in parsed.row).toBe(false) }) }) 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 index 9276b1a8c05..f0ebac8622b 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -2,8 +2,8 @@ // // 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. +// UNREADABLE, not skippable: the caller fails the load as a newer Orca's chat rather +// than render a partial timeline or compact past a row it cannot interpret. // // A row whose KIND this build does not know is UNREADABLE the same way, and kept // on disk, when it has the envelope every row keeps: a non-empty `epoch`, an @@ -12,6 +12,14 @@ // rule delete the journal from an unknown kind, so a new kind either ships its // reader first and is written only once no supported build lacks that reader, // or is written at a bumped `v`. +// +// Inside a known row with that envelope the same holds a level down. A lifecycle mutation of a kind +// this build does not know, or a sent message of a kind it does not know, is UNREADABLE, whatever +// else in the row fails; unusable annotations are dropped. A body of a kind it does not know is +// READABLE and drawn by nobody (agent-session-journal-schemas.ts). Any other failure is damage, and +// the chat fails to load with every row kept (journal-open.ts). Builds from before this rule delete +// the journal from such a row, so the same reader-first-or-`v` rule applies to a new mutation or +// sent-message kind. import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure' import { @@ -24,17 +32,19 @@ import { type AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' import { - isAdmissibleAgentJournalItemBody, - isAdmissibleAgentJournalMessageBody -} from '../../../shared/agent-session-journal-schemas' -import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' + readAgentJournalItemBody, + readAgentJournalMessageBody, + type AgentJournalContentVerdict +} from '../../../shared/agent-session-journal-body-admission' +import { isJournalTag } from '../../../shared/agent-session-journal-open-union' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' +import { dropUnusableRowAnnotations } from './journal-row-unusable-annotations' -/** Producer linkage rides the row BASE rather than the body: the two nested - * prompt shapes are `.strict()`, so an unknown key on a body would make the - * whole row parse as malformed. It is also deliberately not a `v` bump — an - * unknown `v` makes a row unreadable and latches the host read-only, while an - * unknown KEY is ignored below, so an older host reads a stamped row and +/** Producer linkage rides the row BASE rather than the body: older builds read + * the two nested prompt shapes strictly, so an unknown key there made the whole + * row parse as malformed. It is also deliberately not a `v` bump — an + * unknown `v` makes a row unreadable (released older hosts keep the chat read-only; + * this build fails its load), while an unknown KEY is ignored below, so an older host reads a stamped row and * behaves exactly as it does today. */ type JournalRowBase = AgentJournalProducerLinkage & { /** Schema version of THIS row. */ @@ -219,85 +229,11 @@ export function parseJournalRow(line: string): JournalRowParse { return { ok: false, unreadable: true } } const upcast = upcastRow(record, version) - dropUnusableProducerLinkage(upcast) - dropUnusableTurnScope(upcast) - if (upcast.kind === 'lifecycle-batch' && Array.isArray(upcast.mutations)) { - for (const mutation of upcast.mutations) { - if (isPlainObject(mutation)) { - dropUnusableProducerLinkage(mutation) - dropUnusableTurnScope(mutation) - } - } - } - dropUnusableContextUsage(upcast) + dropUnusableRowAnnotations(upcast) if (isJournalRow(upcast)) { return { ok: true, row: upcast } } - // A newer build's kind is placed by the envelope every row keeps; one without it is damage. - const { kind } = upcast - const unknownKind = typeof kind === 'string' && kind !== '' && !KNOWN_ROW_KINDS.has(kind) - return { ok: false, unreadable: unknownKind && hasJournalRowEnvelope(upcast) } -} - -/** Linkage ids this build cannot trust, removed from a row it still keeps. - * - * Deliberately NOT part of `isJournalRow`: rejecting a row there drops it from - * the timeline, so a validator tightened against one bad field becomes a - * whole-store kill switch. Dropping the field degrades the row to the - * session's own agent — what every row said before linkage existed — while - * keeping the content, which is always the safer direction. An `agentId` that - * survives is a real one: the reader scopes on PRESENCE, so `''` or a - * non-string left in place would hide the row from its own author for good. */ -function dropUnusableProducerLinkage(record: Record): void { - for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { - const value = record[field] - if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { - delete record[field] - } - } - if (record.attempt !== undefined && !Number.isInteger(record.attempt)) { - delete record.attempt - } -} - -/** A scope this build cannot place, removed like unusable linkage: the row then reads as one - * written before scopes existed, and the reducer derives its scope. */ -function dropUnusableTurnScope(record: Record): void { - const scope = record.turnScope - if ( - scope !== undefined && - !( - isPlainObject(scope) && - (scope.kind === 'thread' || - (scope.kind === 'turn' && - typeof scope.turnItemId === 'string' && - scope.turnItemId.length > 0)) - ) - ) { - delete record.turnScope - } -} - -/** Context facts this build cannot read, removed from the turn row that carries - * them. Same reasoning as linkage: they are an annotation on the turn, and - * rejecting the row for them would truncate the journal from that row on. */ -function dropUnusableContextUsage(record: Record): void { - const bodies = [ - record.kind === 'item' ? record.body : undefined, - ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) - ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) - : []) - ] - for (const body of bodies) { - if ( - isPlainObject(body) && - body.kind === 'turn' && - body.contextUsage !== undefined && - !isAdmissibleAgentSessionContextUsage(body.contextUsage) - ) { - delete body.contextUsage - } - } + return { ok: false, unreadable: journalRowContent(upcast) === 'unreadable' } } /** Read-time upcast chain. Each step raises a row exactly one version. */ @@ -322,46 +258,84 @@ function isPlainObject(value: unknown): value is Record { * 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 journalRowContent(record: Record): AgentJournalContentVerdict { + if (!hasJournalRowEnvelope(record)) { + return 'malformed' + } + const { kind } = record + const contentCheck = typeof kind === 'string' ? KNOWN_ROW_KINDS.get(kind) : undefined + if (contentCheck) { + return contentCheck(record) + } + // A newer build's kind is placed by the envelope every row keeps. + return isJournalTag(kind) ? 'unreadable' : 'malformed' +} + function isJournalRow(record: Record): record is JournalRow { - const fieldCheck = typeof record.kind === 'string' ? KNOWN_ROW_KINDS.get(record.kind) : undefined - return fieldCheck !== undefined && hasJournalRowEnvelope(record) && fieldCheck(record) + return journalRowContent(record) === 'readable' +} + +/** A newer build's content anywhere wins over damage beside it: never delete what it wrote. */ +function combinedContent(...verdicts: AgentJournalContentVerdict[]): AgentJournalContentVerdict { + if (verdicts.includes('unreadable')) { + return 'unreadable' + } + return verdicts.includes('malformed') ? 'malformed' : 'readable' +} + +function fieldsContent(hold: boolean): AgentJournalContentVerdict { + return hold ? 'readable' : 'malformed' } /** Each kind's own fields, keyed by every kind the union holds: a kind without a check here fails * to compile, never reads as a newer build's kind. */ -const ROW_FIELD_CHECK_BY_KIND: Record< +const ROW_CONTENT_CHECK_BY_KIND: Record< JournalRow['kind'], - (record: Record) => boolean + (record: Record) => AgentJournalContentVerdict > = { - epoch: (record) => typeof record.reason === 'string' && isPlainObject(record.providerHandle), + epoch: (record) => + fieldsContent(typeof record.reason === 'string' && isPlainObject(record.providerHandle)), item: (record) => - typeof record.itemId === 'string' && - Number.isInteger(record.revision) && - isAdmissibleAgentJournalItemBody(record.body), - tombstone: (record) => typeof record.itemId === 'string' && Number.isInteger(record.revision), + combinedContent( + fieldsContent(typeof record.itemId === 'string' && Number.isInteger(record.revision)), + readAgentJournalItemBody(record.body) + ), + tombstone: (record) => + fieldsContent(typeof record.itemId === 'string' && Number.isInteger(record.revision)), submission: (record) => - typeof record.clientMessageId === 'string' && - record.clientMessageId.length > 0 && - typeof record.payloadFingerprint === 'string' && - isPlainObject(record.providerHandle) && - isAdmissibleAgentJournalMessageBody(record.body), + combinedContent( + fieldsContent( + typeof record.clientMessageId === 'string' && + record.clientMessageId.length > 0 && + typeof record.payloadFingerprint === 'string' && + isPlainObject(record.providerHandle) + ), + readAgentJournalMessageBody(record.body) + ), dispatch: (record) => - 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'), - 'lifecycle-batch': (record) => - typeof record.settlementId === 'string' && - record.settlementId.length > 0 && - Array.isArray(record.mutations) && - record.mutations.length > 0 && - record.mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && - Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES && - record.mutations.every(isLifecycleMutation) + fieldsContent( + 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') + ), + 'lifecycle-batch': (record) => { + const mutations = Array.isArray(record.mutations) ? record.mutations : [] + return combinedContent( + fieldsContent( + typeof record.settlementId === 'string' && + record.settlementId.length > 0 && + mutations.length > 0 && + mutations.length <= MAX_JOURNAL_LIFECYCLE_BATCH_MUTATIONS && + Buffer.byteLength(JSON.stringify(record), 'utf8') + 1 <= MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + ), + ...mutations.map(lifecycleMutationContent) + ) + } } -const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_FIELD_CHECK_BY_KIND)) +export const KNOWN_ROW_KINDS = new Map(Object.entries(ROW_CONTENT_CHECK_BY_KIND)) /** The fields every row keeps whatever its kind: its epoch, its place in it, its writer, its time. */ function hasJournalRowEnvelope(record: Record): boolean { @@ -375,18 +349,32 @@ function hasJournalRowEnvelope(record: Record): boolean { ) } -function isLifecycleMutation(value: unknown): value is JournalLifecycleMutation { - if (!isPlainObject(value) || typeof value.itemId !== 'string') { - return false +/** Each mutation kind's own fields, keyed like the row kinds and for the same reason. */ +const MUTATION_CONTENT_CHECK_BY_KIND: Record< + JournalLifecycleMutation['kind'], + (mutation: Record) => AgentJournalContentVerdict +> = { + item: (mutation) => + combinedContent( + fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision)), + readAgentJournalItemBody(mutation.body) + ), + tombstone: (mutation) => + fieldsContent(typeof mutation.itemId === 'string' && Number.isInteger(mutation.revision)) +} +export const KNOWN_MUTATION_KINDS = new Map(Object.entries(MUTATION_CONTENT_CHECK_BY_KIND)) + +/** The kind first: a newer build's kind needs none of the fields this build's kinds have. */ +function lifecycleMutationContent(value: unknown): AgentJournalContentVerdict { + if (!isPlainObject(value)) { + return 'malformed' } - if (value.kind === 'tombstone') { - return Number.isInteger(value.revision) + const { kind } = value + const contentCheck = typeof kind === 'string' ? KNOWN_MUTATION_KINDS.get(kind) : undefined + if (contentCheck) { + return contentCheck(value) } - return ( - value.kind === 'item' && - Number.isInteger(value.revision) && - isAdmissibleAgentJournalItemBody(value.body) - ) + return isJournalTag(kind) ? 'unreadable' : 'malformed' } /** Approximate on-disk cost of a row, used for the per-session size bound. */ diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index 4665a057115..a558252a899 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -5,7 +5,8 @@ // always named: `SELECT *` is uncacheable and can drop a column. import type Database from '../../sqlite/sync-database' -import { serializeJournalRow, type JournalRow } from './journal-row-schema' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import { AgentSessionJournalError } from './journal-write-guards' export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string } @@ -19,7 +20,6 @@ const INSERT_ROW = const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` -const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?' const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` @@ -44,6 +44,15 @@ export function insertJournalRow( row: JournalRow ): number { const rowJson = serializeJournalRow(row) + // A row the reader rejects would fail the chat's next load, so it is never written: the throw + // rolls back the caller's transaction. + const readBack = parseJournalRow(rowJson) + if (!readBack.ok || readBack.row.seq !== row.seq) { + throw new AgentSessionJournalError( + 'journal_row_rejected', + `the ${row.kind} row for ${sessionId} would not read back, so it was not written` + ) + } db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson) return Buffer.byteLength(rowJson, 'utf8') } @@ -102,14 +111,3 @@ export function deleteJournalEpochRows( export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) } - -/** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */ -export function deleteJournalRowSuffix( - db: Database.Database, - sessionId: string, - epoch: string, - fromSeq: number -): number { - const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq) - return Number(deleted.changes ?? 0) -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts new file mode 100644 index 00000000000..6c8811eb4a5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-unusable-annotations.ts @@ -0,0 +1,83 @@ +// Annotations on a persisted row this build cannot use, removed from a row it still keeps, before +// the row's content is checked (journal-row-schema.ts). + +import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' + +export function dropUnusableRowAnnotations(record: Record): void { + dropUnusableProducerLinkage(record) + dropUnusableTurnScope(record) + if (record.kind === 'lifecycle-batch' && Array.isArray(record.mutations)) { + for (const mutation of record.mutations) { + if (isPlainObject(mutation)) { + dropUnusableProducerLinkage(mutation) + dropUnusableTurnScope(mutation) + } + } + } + dropUnusableContextUsage(record) +} + +/** Linkage ids this build cannot trust, removed from a row it still keeps. + * + * Deliberately NOT part of `isJournalRow`: rejecting a row there drops it from + * the timeline, so a validator tightened against one bad field becomes a + * whole-store kill switch. Dropping the field degrades the row to the + * session's own agent — what every row said before linkage existed — while + * keeping the content, which is always the safer direction. An `agentId` that + * survives is a real one: the reader scopes on PRESENCE, so `''` or a + * non-string left in place would hide the row from its own author for good. */ +function dropUnusableProducerLinkage(record: Record): void { + for (const field of ['agentId', 'parentAgentId', 'providerParentRef', 'producerKind']) { + const value = record[field] + if (value !== undefined && (typeof value !== 'string' || value.length === 0)) { + delete record[field] + } + } + if (record.attempt !== undefined && !Number.isInteger(record.attempt)) { + delete record.attempt + } +} + +/** A scope this build cannot place, removed like unusable linkage: the row then reads as one + * written before scopes existed, and the reducer derives its scope. */ +function dropUnusableTurnScope(record: Record): void { + const scope = record.turnScope + if ( + scope !== undefined && + !( + isPlainObject(scope) && + (scope.kind === 'thread' || + (scope.kind === 'turn' && + typeof scope.turnItemId === 'string' && + scope.turnItemId.length > 0)) + ) + ) { + delete record.turnScope + } +} + +/** Context facts this build cannot read, removed from the turn row that carries + * them. Same reasoning as linkage: they are an annotation on the turn, and + * rejecting the row for them would truncate the journal from that row on. */ +function dropUnusableContextUsage(record: Record): void { + const bodies = [ + record.kind === 'item' ? record.body : undefined, + ...(record.kind === 'lifecycle-batch' && Array.isArray(record.mutations) + ? record.mutations.map((mutation) => (isPlainObject(mutation) ? mutation.body : undefined)) + : []) + ] + for (const body of bodies) { + if ( + isPlainObject(body) && + body.kind === 'turn' && + body.contextUsage !== undefined && + !isAdmissibleAgentSessionContextUsage(body.contextUsage) + ) { + delete body.contextUsage + } + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts index 8eee42a70d0..7b8e9fe5935 100644 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts @@ -46,8 +46,8 @@ afterEach(async () => { }) /** One epoch's rows, folded as an open folds them. */ -function fold(rows: readonly JournalRow[], repairedFrom: number | null = null) { - const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH, repairedFrom }) +function fold(rows: readonly JournalRow[]) { + const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH }) for (const row of rows) { folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) }) } @@ -110,13 +110,13 @@ describe("a Stop's event and a Resume", () => { expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } }) }) - it('are no provider history: an unreconcilable epoch holding only them still reads corrupt', () => { - expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume'])).corrupt).toBe(true) - expect(fold(epochWith('unreconcilable_prefix', ['stop', 'item'])).corrupt).toBe(false) - }) - - it('are no rebuilt history: a pending repair followed only by them still reads corrupt', () => { - expect(fold(epochWith('handle_forked', ['stop', 'resume']), 2).corrupt).toBe(true) - expect(fold(epochWith('handle_forked', ['stop', 'item']), 2).corrupt).toBe(false) + // An older build founded `unreconcilable_prefix` epochs when it deleted history; this one opens + // such an epoch as it stands. + it('fold as history in an epoch an older build founded after deleting rows', () => { + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume']))).toMatchObject({ + newer: null, + damage: null, + state: { lastSequence: 3 } + }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index ba879696469..dc270bc97a6 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -40,14 +40,9 @@ export type JournalStoreHost = { database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean - setReadOnly: (readOnly: boolean) => void cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void commit: (row: JournalRow) => void - /** Records whether the open's replay found an unusable prefix. */ - setOpenedCorrupt: (corrupt: boolean) => void - malformedRows: () => number - setMalformedRows: (count: number) => void journal: () => AgentSessionJournal enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise } @@ -72,7 +67,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal serialize: host.serialize, database: host.database, readOnly: host.readOnly, - setReadOnly: host.setReadOnly, highestFence: () => host.state().highestFence, queuePauseRestatement: () => journalQueuePauseRestatement( diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index c8674e0dbd5..cac1517c409 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -8,33 +8,17 @@ import { journalFileFormatRemnantDisclosure } from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' -import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure' +import { failLoadOnUnloadableJournal } from './journal-open-failure' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' -/** What any of this file's disclosures hands the store — a repair's, or the - * pre-SQLite notice's. Same shape, and neither is only a repair. */ -type JournalDisclosure = JournalRepairDisclosure - -export function journalStoreLoadedFields(loaded: JournalLoad) { - return { - state: loaded.state, - readOnly: loaded.readOnly, - malformedRows: loaded.malformedRows - } -} +type JournalDisclosure = ReturnType export async function openJournalStoreState(input: { + sessionId: string legacyDirectory: string replay: () => JournalLoad | null - /** Drops the rejected suffix and records the rebuild it owes, in ONE - * transaction. Corruption is not preserved; replay keeps reporting `corrupt` - * until provider history republishes the epoch or the session writes past - * `contentFrom`, the first sequence the repair left free. */ - deleteSuffix: (fromSeq: number, contentFrom: number) => number start: () => void adopt: (loaded: JournalLoad) => void - /** Republishes an anchor row for an epoch a repair emptied. */ - publishRepairEpoch: () => void appendItem: ( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, @@ -42,46 +26,24 @@ export async function openJournalStoreState(input: { ) => Promise agent: AgentType highestFence: () => number - malformedRows: () => number - setMalformedRows: (count: number) => void - readOnly: () => boolean }): Promise { const loaded = input.replay() - if (!loaded) { + // An epoch named but holding no row (a crash inside an older build's repair) has nothing to + // keep, so it is founded afresh like a chat with no journal; no row is deleted. + if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) { input.start() await discloseFileFormatRemnant(input) return } + failLoadOnUnloadableJournal(input.sessionId, loaded) input.adopt(loaded) - if (loaded.truncateFrom !== undefined && !loaded.readOnly) { - input.deleteSuffix(loaded.truncateFrom, loaded.state.lastSequence + 1) - } - // A repair that took every live row leaves the epoch with no anchor. Publish - // one before anything can append into it: an ordinary row at sequence 1 would - // replay as a clean timeline and hide that the history was never rebuilt. - if (!loaded.readOnly && loaded.state.lastSequence === 0) { - input.publishRepairEpoch() - // The replacement epoch adopts a clean load; what this open's repair did is - // still the answer `repair` and the disclosure below owe the caller. - input.setMalformedRows(loaded.malformedRows) - } - if (input.malformedRows() > 0 && !input.readOnly()) { - const disclosure = journalRepairDisclosure() - await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) - } await settleStaleSubagentRosters(input, loaded) // Founding the epoch and appending the row are two transactions, and a // committed epoch sends every later open down this branch instead. Anything // that interrupts between them — a quit during startup restore, a failed // append — would otherwise lose the message for good. An epoch holding nothing // is exactly the state that append was owed, so offer it again. - // - // Never onto a repair, though: `loaded.state` is the PRE-repair load, so a - // journal this open just emptied looks identical. The repair's epoch is the - // marker that its history was deleted and never rebuilt, and any row that is - // not the repair's own disclosure retires it — this row would silently stop - // the session ever asking the provider for that history again. - if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { + if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { await discloseFileFormatRemnant(input) } } @@ -98,11 +60,7 @@ async function discloseFileFormatRemnant(input: { fence: number ) => Promise highestFence: () => number - readOnly: () => boolean }): Promise { - if (input.readOnly()) { - return - } const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) if (!transcriptPath) { return @@ -111,13 +69,7 @@ async function discloseFileFormatRemnant(input: { await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) } -/** - * Retires a `working` subagent roster the previous host never got to settle. - * - * Skipped on a corrupt load: that journal is still owed a rebuild from provider - * history, and content written past the repair's free sequence retires the - * demand for it. - */ +/** Retires a `working` subagent roster the previous host never got to settle. */ async function settleStaleSubagentRosters( input: { appendItem: ( @@ -126,13 +78,9 @@ async function settleStaleSubagentRosters( fence: number ) => Promise highestFence: () => number - readOnly: () => boolean }, loaded: JournalLoad ): Promise { - if (input.readOnly() || loaded.corrupt) { - return - } for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) { await input.appendItem(revision.identity, revision.body, input.highestFence()) } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 8fce8457b63..d8c44f964bd 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -1,18 +1,17 @@ // Bringing a store's in-memory state up from disk. // // Split out of the store for the same reason its collaborators were: this is the -// ORDERING between replay, suffix repair and disclosure, and none of it belongs +// ORDERING between import, replay and disclosure, and none of it belongs // to the store's public surface. Every step here reads or writes through the // same host the collaborators use, so the store keeps the state and this owns // the sequence. import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' -import { replayJournal, type JournalLoad } from './journal-open' -import { createJournalReducerState } from './journal-reducer' +import { replayJournal } from './journal-open' +import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' -import { deleteJournalRepairedSuffix } from './journal-repair-marker' import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { AgentSessionJournalError } from './journal-write-guards' @@ -26,7 +25,13 @@ export async function restoreJournalStore( legacyDirectory: host.legacyDirectory } if (source.database.readOnly) { - return restoreFromNewerDatabase(host, source) + // A newer Orca's database: nothing in it is read as this build's, and nothing is written. + throw journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + `agent-session journal for ${host.identity.sessionId} is in a newer Orca's database` + ) + ) } // A restore reads a chat still in its per-chat file from there, and copies it before its first use. const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null @@ -37,6 +42,7 @@ export async function restoreJournalStore( if (!imported) { throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) } + failLoadOnUnloadableJournal(host.identity.sessionId, imported) host.adopt(imported) }) } else { @@ -44,66 +50,15 @@ export async function restoreJournalStore( await importPerSessionJournal(source) } return openJournalStoreState({ + sessionId: host.identity.sessionId, legacyDirectory: host.legacyDirectory, - replay: () => { - const loaded = preview ?? replayJournal(host.database().db, host.identity.sessionId) - host.setOpenedCorrupt(loaded?.corrupt ?? false) - return loaded - }, - deleteSuffix: (fromSeq, contentFrom) => - deleteJournalRepairedSuffix({ - database: host.database(), - sessionId: host.identity.sessionId, - epoch: host.state().epoch, - fromSeq, - contentFrom, - now: host.now() - }), + replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId), start: () => collaborators.epochController.start('session_created', 0), - // `unreconcilable_prefix` is the durable statement that this epoch exists - // because a repair emptied one: replay reads it back and keeps asking for - // provider history until the timeline is rebuilt or the session writes. - publishRepairEpoch: () => - collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence), adopt: host.adopt, - // Repair and file-format notices are about the conversation, not any turn in it. + // File-format and roster notices are about the conversation, not any turn in it. appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), agent: host.identity.agent, - highestFence: () => host.state().highestFence, - malformedRows: host.malformedRows, - setMalformedRows: host.setMalformedRows, - readOnly: host.readOnly - }) -} - -/** - * A newer Orca's database: each chat shows what this build can read of it, from the database or a - * per-chat file never copied in, and nothing is written, copied, repaired or founded. Every write - * the store is asked for is refused as read-only. - */ -async function restoreFromNewerDatabase( - host: JournalStoreHost, - source: Parameters[0] -): Promise { - const { sessionId } = host.identity - let loaded: JournalLoad | null - try { - loaded = - (await previewPerSessionJournal(source)) ?? replayJournal(source.database.db, sessionId) - } catch (error) { - // Tables the newer schema changed: still a chat only an update opens, not a damaged one. - throw new AgentSessionJournalError( - 'journal_read_only', - `agent-session journal for ${sessionId} could not be read under a newer schema`, - { cause: error } - ) - } - host.setOpenedCorrupt(false) - host.adopt({ - state: loaded?.state ?? createJournalReducerState(sessionId, ''), - readOnly: true, - corrupt: false, - malformedRows: 0 + highestFence: () => host.state().highestFence }) } diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index d7c5e521c82..18cb7efa47a 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -18,14 +18,14 @@ import type { } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalRepairDisclosure } from './journal-repair-disclosure' import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener, openTestJournalHostDatabase, liveTestJournalRows, - insertTestJournalRowJson + insertTestJournalRowJson, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -110,10 +110,9 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -const REPAIR_TEXT = journalRepairDisclosure().body.text describe('axis 1: the database shape', () => { - // A newer build's database opens read-only: its chats read, and nothing here writes to it. - it('reads a database a newer build stamped, refuses every write, and writes nothing', async () => { + // A newer build's database: none of its chats open here, and nothing here writes to it. + it('refuses every chat in a database a newer build stamped, and writes nothing', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -123,32 +122,17 @@ describe('axis 1: the database shape', () => { await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) const before = await digest(journalDatabasePath(root)) - const reopened = await open() - expect(reopened.isReadOnly).toBe(true) - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a')]) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + // A chat the database never held is refused the same way rather than being founded. await expect( - reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toMatchObject({ - code: 'journal_read_only' - }) - // A chat the database never held opens empty rather than being founded, and refuses the same way. - const unwritten = await journals.open({ - identity: { ...IDENTITY, sessionId: 'session-2' }, - stateDirectory: root - }) - expect(unwritten.isReadOnly).toBe(true) - expect(unwritten.snapshot().items).toEqual([]) - await expect( - unwritten.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toMatchObject({ - code: 'journal_read_only' - }) + journals.open({ identity: { ...IDENTITY, sessionId: 'session-2' }, stateDirectory: root }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) await journals.closeAll() expect(await digest(journalDatabasePath(root))).toBe(before) }) // Tables a newer schema changed read as a chat only an update opens, not as damage. - it('refuses as read-only a chat whose tables a newer build changed', async () => { + it("refuses as a newer Orca's a chat whose tables a newer build changed", async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -160,23 +144,7 @@ describe('axis 1: the database shape', () => { db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) }) - await expect(open()).rejects.toMatchObject({ code: 'journal_read_only' }) - }) - - it('refuses the schema escape hatch on a store latched by a newer row', async () => { - const journal = await open() - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) - - const reopened = await open() - // With byte-copy quarantine gone there is nothing for `schema_unreadable` to - // do differently, so it takes the same writable guard as every other reason. - await expect(reopened.rollEpoch('schema_unreadable', 2)).rejects.toMatchObject({ - code: 'journal_read_only' - }) - expect(reopened.isReadOnly).toBe(true) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) }) // The only older version brought forward; 1 and 2 are refused (journal-database.test.ts). @@ -190,7 +158,6 @@ describe('axis 1: the database shape', () => { await withDatabase((db) => db.pragma('user_version = 0')) const reopened = await open() - expect(reopened.isReadOnly).toBe(false) expect(reopened.snapshot().items).toHaveLength(1) await reopened.close() await withDatabase((db) => { @@ -200,7 +167,7 @@ describe('axis 1: the database shape', () => { }) describe('axis 2: the row body shape', () => { - it('degrades to read-only on a row from a newer build, without skipping it', async () => { + it("refuses a journal holding a row from a newer build as a newer Orca's, and keeps that row", async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -211,14 +178,7 @@ describe('axis 2: the row body shape', () => { await journal.close() await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) - const reopened = await open() - expect(reopened.isReadOnly).toBe(true) - await expect( - reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toMatchObject({ - code: 'journal_read_only' - }) - await reopened.close() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) // Never skipped, never deleted: the row this build cannot read is still there. await withDatabase((db) => { const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === nextSeq) @@ -226,7 +186,7 @@ describe('axis 2: the row body shape', () => { }) }) - it('skips a malformed row without giving up the journal, and discloses the skip', async () => { + it('refuses a journal holding a row that is not a row, and keeps that row', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -237,35 +197,16 @@ describe('axis 2: the row body shape', () => { await journal.close() await appendRawRow(epoch, nextSeq, '{not json') - 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 === REPAIR_TEXT) - ).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, - turnScope: AGENT_JOURNAL_THREAD_SCOPE + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(open()).rejects.toMatchObject({ + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } + }) + } + await withDatabase((db) => { + const stored = liveTestJournalRows(db, IDENTITY.sessionId) + expect(stored.map((row) => row.seq)).toEqual([1, 2, nextSeq]) + expect(stored.at(-1)?.rowJson).toBe('{not json') }) - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - await appendRawRow(epoch, nextSeq, '{not json') - - await open().then((first) => first.close()) - const reopened = await open() - expect( - reopened - .snapshot() - .items.filter((entry) => entry.body.kind === 'status' && entry.body.text === REPAIR_TEXT) - ).toHaveLength(1) }) it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { @@ -296,7 +237,6 @@ describe('axis 2: the row body shape', () => { ) const reopened = await open() - expect(reopened.isReadOnly).toBe(false) expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true) }) }) 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 index 871aca7880d..fcb541b8faa 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -294,7 +294,7 @@ describe('replay', () => { expect(reopened.snapshot().items).toHaveLength(0) }) - it('keeps the intact prefix and drops the rejected suffix', async () => { + it('refuses a journal with a gap and keeps every row', async () => { const journal = await open() for (let index = 0; index < 4; index += 1) { await journal.appendItem(item(index), body(`m${index}`), { @@ -302,22 +302,18 @@ describe('replay', () => { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) } - const before = journal.epoch await journal.close() await withJournalDatabase(root, (db) => { deleteTestJournalRow(db, IDENTITY.sessionId, 3) }) - const reopened = await open() - expect(reopened.epoch).toBe(before) - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('m0')]) - // Sequences 4 and 5 are VALID rows that the gap at 3 made unreplayable. - // Nothing preserves them; recovery rebuilds the epoch from provider history. + await expect(open()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) await withJournalDatabase(root, (db) => { const rows = liveTestJournalRows(db, IDENTITY.sessionId) - expect(rows.map((row) => row.seq)).toEqual([1, 2]) + expect(rows.map((row) => row.seq)).toEqual([1, 2, 4, 5]) }) - expect(reopened.repair).toEqual({ malformedRows: 0 }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index db63aea8870..855c6ab3e98 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -67,7 +67,6 @@ import type { JournalOperationReceipt, JournalRowWriter } from './journal-row-wr import type { JournalEpochController } from './journal-epoch-controller' import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' -import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender, JournalResolvedItem } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' import type { JournalStopMarks } from './journal-stop-marks' @@ -81,9 +80,6 @@ export class AgentSessionJournal { private readonly mintEpoch: () => string private state: JournalReducerState - private readOnly = false - private malformedRows = 0 - private openedCorrupt = false private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue @@ -114,10 +110,8 @@ export class AgentSessionJournal { owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, - readOnly: () => this.readOnly, - setReadOnly: (readOnly) => { - this.readOnly = readOnly - }, + // A newer Orca's database: nothing it holds opens, and every write is refused. + readOnly: () => this.database.readOnly, cursor: this.cursor, adopt: (loaded) => { this.adoptLoadedJournal(loaded) @@ -127,14 +121,7 @@ export class AgentSessionJournal { applyJournalRow(this.state, row) this.onCommitted?.() }, - setOpenedCorrupt: (corrupt) => { - this.openedCorrupt = corrupt - }, notifyCommitted: () => this.onCommitted?.(), - malformedRows: () => this.malformedRows, - setMalformedRows: (count) => { - this.malformedRows = count - }, journal: () => this, enqueue: (build) => this.rowWriter.enqueue(build) }) @@ -147,10 +134,6 @@ export class AgentSessionJournal { this.restore = collaborators.restore } - get isReadOnly(): boolean { - return this.readOnly - } - get epoch(): string { return this.state.epoch } @@ -169,16 +152,6 @@ export class AgentSessionJournal { ) } - /** What the last open's repair did. */ - get repair(): { malformedRows: number } { - return { malformedRows: this.malformedRows } - } - - /** The open replayed an unusable prefix: the chat is owed a rebuild from provider history. */ - get needsRebuild(): boolean { - return this.openedCorrupt - } - async open(): Promise { await this.restore() this.openedThrough = this.cursor() @@ -289,8 +262,7 @@ export class AgentSessionJournal { readSince(cursor: AgentJournalCursor, limit?: number): JournalReadSince { const { sessionId } = this.identity const rowsAfter = journalRowsAfterReader(this.database.db, sessionId, this.state.epoch, limit) - const source = { state: this.state, rowsAfter, readOnly: this.readOnly } - return readJournalSince(source, cursor, () => this.cursor()) + return readJournalSince({ state: this.state, rowsAfter }, cursor, () => this.cursor()) } /** Upsert by stable identity. The revision is assigned here so a caller @@ -388,8 +360,8 @@ export class AgentSessionJournal { return rejectJournalQueuedSubmissions(this, fence, rejection, which) } - /** The escape hatch for corruption, an unreconcilable prefix, a forked handle, - * and an unreadable schema. It invalidates every cursor; clients reload. */ + /** The escape hatch for a forked handle and an unreadable schema. It invalidates every cursor; + * clients reload. */ async rollEpoch(reason: AgentJournalEpochReason, fence: number): Promise { return this.epochController.roll(reason, fence) } @@ -403,6 +375,6 @@ export class AgentSessionJournal { } private adoptLoadedJournal(loaded: JournalLoad): void { - Object.assign(this, journalStoreLoadedFields(loaded)) + this.state = loaded.state } } diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts index c000ffe9fba..86ff11c54a3 100644 --- a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts @@ -117,40 +117,40 @@ describe('streaming journal replay', () => { expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0]) }) - it('keeps the prefix but latches read-only for a future row beyond a gap', () => { + it('reads past a gap to a future row, which names the newer row and no damage', () => { put(anchor()) put(revision(2)) put(revision(4)) put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 }) const loaded = replayJournal(opened.db, sessionId)! - expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 }) - expect(loaded.truncateFrom).toBeUndefined() + expect(loaded).toMatchObject({ newer: { sequence: 5 }, damage: null }) expect(loaded.state.items.get('message-1')?.revision).toBe(2) expect(loaded.state.lastSequence).toBe(2) const checkpoint = opened.db.pragma('wal_checkpoint(TRUNCATE)') as { busy: number }[] expect(checkpoint[0].busy).toBe(0) }) - it('keeps gap repair precedence when a later row is malformed', () => { + it('names the first damage when a later row is malformed too', () => { put(anchor()) put(revision(2)) put(revision(4)) insertTestJournalRowJson(opened.db, sessionId, 5, '{') const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ - readOnly: false, - corrupt: true, - malformedRows: 1, - truncateFrom: 4 + newer: null, + damage: { sequence: 3, cause: 'sequence-gap' } }) expect(loaded.state.lastSequence).toBe(2) }) - it('rejects an unanchored prefix before a later gap', () => { + it('names a missing epoch row before a later gap', () => { put(revision(1)) put(revision(3)) const loaded = replayJournal(opened.db, sessionId)! - expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 }) + expect(loaded).toMatchObject({ + newer: null, + damage: { sequence: 1, cause: 'no-epoch-row' } + }) expect(loaded.state.items.size).toBe(0) expect(loaded.state.lastSequence).toBe(0) }) diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts new file mode 100644 index 00000000000..e56bcc92c96 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts @@ -0,0 +1,271 @@ +// Inside a row of a known kind, what a newer build wrote is never damage. A body of a kind this +// build does not know (or a plan subject of one) is kept and the chat stays writable; a mutation or +// sent message of a newer kind fails the load as a newer Orca's chat with every row kept as it was, +// even beside damage. Anything else that fails is damage: the chat fails to load, every row kept. + +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 { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-content', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} +const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-content-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) +} + +function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) +} + +/** An anchor, two items, then `rows` as a newer build (or damage) wrote them, then one more item + * of this build's: closed, so the next open replays all of it. */ +async function journalWith(rows: (epoch: string) => Record[]) { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'one' }, SCOPE) + await first.appendItem(item(1), { kind: 'status', text: 'two' }, SCOPE) + const epoch = first.epoch + let seq = first.cursor().sequence + await journals.closeAll() + const tail = itemRow(epoch, 'codex:thread-1:turn-1:9', { kind: 'status', text: 'after' }) + const { db } = openTestJournalHostDatabase(root) + for (const row of [...rows(epoch), tail]) { + seq += 1 + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, JSON.stringify({ ...row, seq }), 5_000) + } + closeTestJournalHostDatabase(root) + return { written: stored() } +} + +function itemRow(epoch: string, itemId: string, body: Record) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'item', + itemId, + revision: 1, + body + } +} + +function batchRow(epoch: string, mutations: Record[]) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations + } +} + +const NEWER_BODY = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } +const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'plan', text: 'Step one' } +} + +/** Refused as a newer Orca's chat on every open, with every row left as it was. */ +async function expectNewerAndKept(written: { seq: number; rowJson: string }[]) { + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(stored()).toEqual(written) +} + +/** Opens writable, keeps the newer item in the snapshot, takes a write, and leaves every earlier + * row byte-identical. */ +async function expectWritableAndKept(written: { seq: number; rowJson: string }[], itemId: string) { + const journal = await open() + expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true) + await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE) + await journals.closeAll() + expect(stored().slice(0, written.length)).toEqual(written) +} + +describe("a newer build's body kind, kept and writable", () => { + it.each([ + ['an item body of a newer kind', NEWER_BODY], + ['a plan subject of a newer kind', { ...PLAN_APPROVAL, subject: { kind: 'diff' } }] + ])('keeps %s and stays writable', async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + await expectWritableAndKept(written, 'codex:thread-1:turn-1:2') + }) + + it('keeps a lifecycle mutation whose body is a newer kind, and stays writable', async () => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [ + { kind: 'item', itemId: 'codex:thread-1:turn-1:4', revision: 1, body: NEWER_BODY } + ]) + ]) + await expectWritableAndKept(written, 'codex:thread-1:turn-1:4') + }) +}) + +describe("a newer build's row content that stays a closed set", () => { + it.each([['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }]])( + 'fails the load on a lifecycle mutation %s', + async (_name, mutation) => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [ + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { kind: 'status', text: 'ok' } + }, + mutation + ]) + ]) + await expectNewerAndKept(written) + } + ) + + it('fails the load on a submission whose body is a newer kind', async () => { + const { written } = await journalWith((epoch) => [ + { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 'f', + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + } + ]) + await expectNewerAndKept(written) + }) + + it('fails the load on a newer mutation kind that carries no item id', async () => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }]) + ]) + await expectNewerAndKept(written) + }) + + it.each([ + [ + 'a row', + (epoch: string) => ({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 7, + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + }) + ], + [ + 'a batch', + (epoch: string) => + batchRow(epoch, [ + { kind: 'item', itemId: 'codex:thread-1:turn-1:3', revision: 1, body: { kind: 'diff' } }, + { kind: 'pin' } + ]) + ] + ])('fails the load as newer when damage sits beside newer content in %s', async (_where, row) => { + const { written } = await journalWith((epoch) => [row(epoch)]) + await expectNewerAndKept(written) + }) +}) + +describe("a turn's context usage of a newer shape", () => { + it('is dropped like an unusable annotation: the chat stays writable and keeps the row', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + contextUsage: { used: { kind: 'measured', capturedAt: 1 } } + }) + ]) + const journal = await open() + expect(journal.snapshot().items[2]?.body).toEqual({ + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + await journals.closeAll() + expect(stored()).toEqual(written) + }) +}) + +describe('damage', () => { + it.each([ + ['a broken required field', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + [ + 'a broken optional value', + { kind: 'turn', turnId: 'turn-1', state: 'done', durationMs: null } + ], + [ + 'a turn lifecycle whose turn is a number', + { kind: 'status', text: 'Turn started', turnLifecycle: { turnId: 7, state: 'running' } } + ], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }] + ])('fails the load from %s, and every row is kept', async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(open()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + }) + } + expect(stored()).toEqual(written) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts index 794ca52ab7d..bb965817b59 100644 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts @@ -1,6 +1,7 @@ -// A newer build's row kind is never repaired away: like a newer row version, it latches this build -// read-only with every row kept, so the newer build still reads the whole chat after an upgrade. -// A row whose stored sequence and body disagree is damage at its stored sequence. +// A newer build's row kind is never damage: like a newer row version, it fails this build's load as +// a newer Orca's chat with every row kept, so the newer build still reads the whole chat after an +// upgrade. +// A row whose stored sequence and body disagree is damage, which fails the load with every row kept. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -17,10 +18,15 @@ import { createTrackedJournalOpener, insertTestJournalRowJson, liveTestJournalRows, - openTestJournalHostDatabase + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA } from './journal-host-database-test-support' import { parseJournalRow, type JournalRow } from './journal-row-schema' +const UNLOADABLE = { + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } +} + const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-newer-kind', workspaceId: 'ws-1', @@ -70,7 +76,8 @@ function open() { /** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and * reopens it as a restarted host would. */ -async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { +/** Rewrites rows with every journal closed, so the caller's next open replays them. */ +async function rewriteClosed(edit: (put: (seq: number, json: string) => void) => void = () => {}) { await journals.closeAll() const { db } = openTestJournalHostDatabase(root) edit((seq, json) => { @@ -81,6 +88,10 @@ async function restartAfter(edit: (put: (seq: number, json: string) => void) => insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) }) closeTestJournalHostDatabase(root) +} + +async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { + await rewriteClosed(edit) return open() } @@ -167,6 +178,7 @@ describe('parsing a row of a kind this build does not know', () => { ['a string timestamp', { ts: '5007' }], ['an empty epoch', { epoch: '' }], ['an empty kind', { kind: '' }], + ['a kind of only whitespace', { kind: ' \t' }], ['a kind that is not a string', { kind: 7 }] ])('reads one with %s as malformed', (_name, broken) => { const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) @@ -189,7 +201,7 @@ describe('parsing a row of a kind this build does not know', () => { }) describe("a journal holding a newer build's row kind", () => { - it('opens read-only with the rows before it, refuses writes, keeps every row, and reads whole once the kind is known', async () => { + it("fails the load as a newer Orca's chat, keeps every row, and reads whole once the kind is known", async () => { const first = await open() for (const ordinal of [0, 1, 2, 3]) { await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) @@ -199,32 +211,24 @@ describe("a journal holding a newer build's row kind", () => { const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' const newer = JSON.stringify(newerRow(epoch, 3)) - const journal = await restartAfter((put) => put(3, newer)) - const latched = stored() - expect(journal.isReadOnly).toBe(true) - expect(journal.repair).toEqual({ malformedRows: 0 }) - expect(itemIds(journal)).toEqual(['codex:thread-1:turn-1:0']) - await expect( - journal.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) - ).rejects.toMatchObject({ code: 'journal_read_only' }) - - const reopened = await restartAfter() - expect(reopened.isReadOnly).toBe(true) - expect(stored()).toEqual(latched) + await rewriteClosed((put) => put(3, newer)) + const refused = stored() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(stored()).toEqual(refused) expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. const upgraded = await restartAfter((put) => put(3, known)) - expect(upgraded.isReadOnly).toBe(false) expect(itemIds(upgraded)).toHaveLength(4) await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) expect(upgraded.cursor().sequence).toBe(6) }) - it('stays read-only with nothing deleted when the row names another sequence than its key', async () => { + it("fails the load as a newer Orca's chat with nothing deleted when the row names another sequence than its key", async () => { const epoch = await journalWithOneItem() - const journal = await restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) - expect(journal.isReadOnly).toBe(true) + await rewriteClosed((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) expect(storedKinds()).toEqual([ [1, 'epoch'], [2, 'item'], @@ -232,18 +236,15 @@ describe("a journal holding a newer build's row kind", () => { ]) }) - it('reads a newer kind whose envelope is broken as malformed and drops from it', async () => { + it('reads a newer kind whose envelope is broken as damage, and keeps it', async () => { const epoch = await journalWithOneItem() - const journal = await restartAfter((put) => - put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' }))) - ) - expect(journal.isReadOnly).toBe(false) - expect(journal.repair).toEqual({ malformedRows: 1 }) - // 3 is the disclosure the repair appends where the broken row was. + await expect( + restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' })))) + ).rejects.toMatchObject(UNLOADABLE) expect(storedKinds()).toEqual([ [1, 'epoch'], [2, 'item'], - [3, 'item'] + [3, 'future-mark'] ]) }) }) @@ -252,25 +253,16 @@ describe('a row whose body names another sequence than its stored key', () => { it.each([ ['an earlier', 2], ['a later', 9] - ])( - 'is dropped at its key when it names %s one, and the next write succeeds', - async (_name, seq) => { - const epoch = await journalWithOneItem() - const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } - const journal = await restartAfter((put) => put(3, JSON.stringify(tombstone))) - expect(journal.repair).toEqual({ malformedRows: 1 }) - expect(journal.needsRebuild).toBe(true) - // The valid row at 2 stays; 3 is the disclosure the repair appends. - expect(storedKinds()).toEqual([ - [1, 'epoch'], - [2, 'item'], - [3, 'item'] - ]) - - await journal.appendItem(item(1), { kind: 'status', text: 'after' }, SCOPE) - const reopened = await restartAfter() - expect(reopened.cursor().sequence).toBe(4) - expect(itemIds(reopened)).toHaveLength(3) - } - ) + ])('fails the load and is kept when it names %s one', async (_name, seq) => { + const epoch = await journalWithOneItem() + const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } + await expect(restartAfter((put) => put(3, JSON.stringify(tombstone)))).rejects.toMatchObject( + UNLOADABLE + ) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'tombstone'] + ]) + }) }) 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 index 10e709d174f..8da990a4eac 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -9,7 +9,8 @@ export class AgentSessionJournalError extends Error { | 'journal_read_only' | 'journal_stale_fence' | 'journal_closed' - | 'journal_submission_exists', + | 'journal_submission_exists' + | 'journal_row_rejected', message: string, options?: ErrorOptions ) { @@ -18,8 +19,8 @@ export class AgentSessionJournalError extends Error { } } -/** A journal written by a newer schema is readable but never writable: this - * host cannot represent rows it does not understand. */ +/** A newer Orca's database is never written: this host cannot represent rows it does not + * understand. */ export function assertJournalWritable(readOnly: boolean, sessionId: string): void { if (readOnly) { throw new AgentSessionJournalError( diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 638a62bdf02..15911b5e080 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -136,7 +136,6 @@ describe('draft bookkeeping inside a journal append', () => { PRIMARY KEY (session_id, message_id))`) db.close() const journal = await open() - expect(journal.isReadOnly).toBe(false) await queueAndConsume(journal, 'draft-1') await journal.resolveDispatch({ clientMessageId: 'sub-draft-1', diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index c3910e46685..6c609853d69 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -125,7 +125,7 @@ describe('draft rows', () => { try { const version = Number(db.pragma('user_version', { simple: true })) // An old build compares stored == supported and keeps writing; a bump - // would latch it read-only after downgrade. + // would cost it every chat after a downgrade. expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) const table = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") @@ -150,7 +150,6 @@ describe('draft rows', () => { db.exec('DROP TABLE queued_messages') db.close() const journal = await open() - expect(journal.isReadOnly).toBe(false) const row = await queueDraft(journal, 'draft-1') expect(row.position).toBe(1) }) 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 index b780e3d1154..ba9ee8a5453 100644 --- 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 @@ -80,9 +80,6 @@ export function readAgentSessionHistory( snapshot: AgentJournalSnapshot = journal.snapshot(), scope: AgentSessionHistoryScope = 'every-agent' ): AgentSessionHistoryResult { - if (journal.isReadOnly) { - return historyReset(snapshot, 'schema_unreadable') - } const limit = resolveHistoryLimit(request.limit) if (request.direction === 'after') { return readForward(journal, snapshot, request.cursor, limit) 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 deleted file mode 100644 index 52fc3c86b9b..00000000000 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ /dev/null @@ -1,574 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Recovery drives the real journal loader against real on-disk damage: a hole -// punched in the row sequence, and a row stamped with a schema this host cannot -// read — on both version axes, because only one of them is detectable before a -// read. - -import { createHash } from 'node:crypto' -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 { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' -import { journalDatabasePath } from '../agent-session-journal/journal-host-database' -import { replayJournal } from '../agent-session-journal/journal-open' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - readTestJournalRows, - deleteTestJournalRow, - insertTestJournalRowJson, - updateTestJournalRowJson -} from '../agent-session-journal/journal-host-database-test-support' -import type Database from '../../sqlite/sync-database' -import { - openAgentSessionJournalWithRecovery, - providerHistoryId -} from './agent-session-journal-recovery' -import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' -import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' - -// The store's replay goes through the mock: it is the only read of the journal an open makes. -vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { - const actual = await importOriginal<{ replayJournal: typeof replayJournal }>() - return { ...actual, replayJournal: vi.fn(actual.replayJournal) } -}) - -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 -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal } -} - -/** Fills a journal with `count` items and hands back its epoch. */ -async function seedJournal(count: number): Promise { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: 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, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const epoch = journal.epoch - await journal.close() - return epoch -} - -/** A journal whose epoch row is gone: every surviving row is unanchored, so a - * repair has to set aside the whole range. */ -async function seedRepairableSession(): Promise { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) - await journal.appendSubmission({ - clientMessageId: 'client-message-1', - payloadFingerprint: 'fingerprint-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-message-1', - state: 'accepted', - providerIdentity: item(1), - fence: 1 - }) - await journal.close() - await deleteRow(1) -} - -async function withJournalDatabase( - directory: string, - run: (db: Database.Database) => void -): Promise { - run(openTestJournalHostDatabase(directory).db) -} - -async function digest(path: string): Promise { - return createHash('sha256') - .update(await readFile(path)) - .digest('hex') -} - -/** The same logical hole `findSequenceGap` detects at replay. */ -async function deleteRow(seq: number): Promise { - await withJournalDatabase(journalDir, (db) => { - deleteTestJournalRow(db, CODEX_SESSION, seq) - }) -} - -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 journals.closeAll() - 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 = journals.track( - await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }).then((result) => result.journal) - ) - expect(opened.snapshot().items).toHaveLength(2) - }) - - it("reads the journal once: the store's replay is the open", async () => { - await seedJournal(2) - vi.mocked(replayJournal).mockClear() - const opened = journals.track( - await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }).then((result) => result.journal) - ) - expect(opened.snapshot().items).toHaveLength(2) - expect(replayJournal).toHaveBeenCalledOnce() - }) - - it('rebuilds a holed journal in place on a fresh epoch', async () => { - await seedJournal(3) - await deleteRow(3) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - 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) - }) - - // A newer build's row is never rewritten under it, and there is no sibling to write instead: - // the chat reads what it can and refuses writes. - it('opens a future row-body version read-only and leaves it exactly as found', async () => { - const epoch = await seedJournal(1) - await withJournalDatabase(journalDir, (db) => { - insertTestJournalRowJson( - db, - CODEX_SESSION, - 3, - JSON.stringify({ v: 99, seq: 3, epoch, kind: 'item', fence: 1, ts: 1 }) - ) - }) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - - expect(opened.recovery).toBeNull() - expect(opened.journal.isReadOnly).toBe(true) - // A send says to update, as a database a newer Orca wrote does. - const sent = await performSend( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. - { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, - { - clientMessageId: 'client-after-downgrade', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - } - ) - expect(sent).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalWrittenByNewerOrca' }, - message: JOURNAL_NEWER_SCHEMA_MESSAGE - } - }) - await withJournalDatabase(journalDir, (db) => { - const rows = readTestJournalRows(db, CODEX_SESSION, epoch) - expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true) - expect(rows).toHaveLength(3) - }) - }) - - // A downgrade: the database a newer Orca stamped opens read-only. The chat shows its history, a - // send says to update, and the file is left byte-identical. - it('shows a chat from a database a newer Orca stamped, and a send says to update', async () => { - await seedJournal(2) - await withJournalDatabase(journalDir, (db) => { - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - }) - closeTestJournalHostDatabases() - const path = journalDatabasePath(journalDir) - const before = await digest(path) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - - expect(opened.recovery).toBeNull() - expect(opened.journal.isReadOnly).toBe(true) - const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) - expect(texts.filter((text) => /item-[12]/.test(text))).toHaveLength(2) - const sent = await performSend( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. - { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, - { - clientMessageId: 'client-after-downgrade', - payloadFingerprint: 'fp', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - } - ) - expect(sent).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalWrittenByNewerOrca' }, - message: JOURNAL_NEWER_SCHEMA_MESSAGE - } - }) - await journals.closeAll() - expect(await digest(path)).toBe(before) - }) - - // T-logical-per-chat: one file holds every chat, and damage one chat's replay finds is still - // that chat's alone. Only it rebuilds; a neighbour's rows and epoch are not touched. - it('rebuilds only the damaged chat and leaves another chat in the same file untouched', async () => { - const neighbour: AgentSessionJournalIdentity = { - ...IDENTITY, - sessionId: 'neighbour-session', - providerHandle: { kind: 'codex', threadId: 'neighbour-thread' } - } - const other = await journals.open({ identity: neighbour, stateDirectory: journalDir }) - for (let ordinal = 1; ordinal <= 3; ordinal += 1) { - await other.appendItem( - item(ordinal), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `n-${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const neighbourEpoch = other.epoch - await other.close() - const database = openTestJournalHostDatabase(journalDir) - const neighbourBefore = readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch) - await seedJournal(3) - await withJournalDatabase(journalDir, (db) => { - updateTestJournalRowJson(db, CODEX_SESSION, 3, '}{') - }) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database, - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - - expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir }) - expect(reopened.epoch).toBe(neighbourEpoch) - expect(readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( - neighbourBefore - ) - }) - - // The rehydrate deletes every live row to publish its replacement epoch, so - // everything replay rejected is gone for good by the time the import runs. - // Orca minted the submission, receipt and lifecycle identities; no provider - // transcript can hand them back. - it('rebuilds from provider history when the epoch row itself is gone', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) - await journal.appendSubmission({ - clientMessageId: 'client-message-1', - payloadFingerprint: 'fingerprint-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'client-message-1', - state: 'accepted', - providerIdentity: item(1), - fence: 1 - }) - await journal.appendLifecycleBatch({ - settlementId: 'settlement-1', - fence: 1, - mutations: [ - { - kind: 'item', - identity: { provider: 'orca', clientMessageId: 'approval-1' }, - body: { kind: 'status', text: 'approved' }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - }) - await journal.close() - // Sequence 1 is the epoch row: everything behind it is valid but unanchored. - await deleteRow(1) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt' }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - expect(JSON.stringify(opened.journal.snapshot().items.map((entry) => entry.body))).toContain( - 'add a retry' - ) - }) - - it('still opens the session when provider history cannot be read', async () => { - await seedJournal(3) - await deleteRow(3) - - const opened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - journals.track(opened.journal) - 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.map((entry) => entry.body.kind)).toEqual(['message']) - }) - - // A repair that KEEPS a prefix has no emptied epoch to anchor, so nothing - // about the surviving rows records that the deleted suffix was never rebuilt. - // Unmarked, the next probe reads a contiguous anchored prefix, calls it clean, - // and the dropped stretch of timeline is gone for good. - it('keeps a partially repaired journal corrupt until provider history replaces it', async () => { - await seedJournal(3) - await deleteRow(3) - const empty = join(root, 'empty.jsonl') - await writeFile(empty, '', 'utf-8') - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: empty - }) - journals.track(first.journal) - expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - expect(first.recovery?.error).toBeTruthy() - // Only the unanchored suffix went; the prefix the repair kept is still live. - expect(first.journal.snapshot().items.map((entry) => entry.body.kind)).toEqual(['message']) - await first.journal.close() - - // The deletion is durable, so the demand for a rebuild has to be too. - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) - - // A readable transcript rebuilds the epoch, and THAT is what retires it. - const retried = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(retried.journal) - expect(retried.recovery?.imported).toBeGreaterThan(0) - await retried.journal.close() - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false }) - }) - - // The reproduced path. Deleting sequence 1 leaves every surviving row - // unanchored, so the repair drops ALL of them — and provider history is not - // there to publish a replacement. A journal in that state used to reopen as - // clean: an append took sequence 1 as an ordinary row, replay accepted it, - // and recovery never asked the provider for the timeline again. - it('does not normalize an epoch a repair emptied while provider history was unavailable', async () => { - await seedRepairableSession() - const missing = join(root, 'missing.jsonl') - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: missing - }) - journals.track(first.journal) - expect(first.recovery?.error).toBeTruthy() - expect(first.recovery?.imported).toBe(0) - await first.journal.close() - - // Reopen: the epoch still holds nothing but the repair, so recovery runs again. - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) - const reopened = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: missing - }) - journals.track(reopened.journal) - expect(reopened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - // Nothing but the anchor: the repair rebuilt no history of its own. - expect(reopened.journal.snapshot().items).toEqual([]) - - // The append lands ABOVE the epoch anchor, never on top of it. - await reopened.journal.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'typed later' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const epoch = reopened.journal.epoch - await reopened.journal.close() - await withJournalDatabase(journalDir, (db) => { - const rows = readTestJournalRows(db, CODEX_SESSION, epoch) - expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1 }) - }) - }) - - // An empty transcript is a plausible transient provider state, and it used to - // end recovery for good: the import published an empty replacement epoch that - // deleted the repair's anchor, the next probe called that clean, and the user's - // timeline was never rebuilt. - it('does not retire the repair marker when provider history exists but holds no messages', async () => { - await seedRepairableSession() - const empty = join(root, 'empty.jsonl') - await writeFile(empty, '', 'utf-8') - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: empty - }) - journals.track(first.journal) - expect(first.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) - expect(first.recovery?.error).toBeTruthy() - // The anchor the repair published is still the epoch; the empty import did - // not replace it with a clean one. - expect(first.journal.snapshot().items).toEqual([]) - const epoch = first.journal.epoch - await first.journal.close() - await withJournalDatabase(journalDir, (db) => { - const rows = readTestJournalRows(db, CODEX_SESSION, epoch) - expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ - kind: 'epoch', - seq: 1, - reason: 'unreconcilable_prefix' - }) - }) - - // The session still reports corrupt, so the next attach retries. - expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) - - // And a transcript that DOES have content still rebuilds the timeline. - const retried = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(retried.journal) - expect(retried.recovery?.imported).toBeGreaterThan(0) - expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain( - 'add a retry' - ) - }) - - it('rebuilds the emptied epoch once provider history is readable again', async () => { - await seedRepairableSession() - - const first = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - journals.track(first.journal) - await first.journal.close() - - const retried = await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - database: openTestJournalHostDatabase(journalDir), - fence: 1, - historyFilePath - }) - journals.track(retried.journal) - expect(retried.recovery?.imported).toBeGreaterThan(0) - expect(JSON.stringify(retried.journal.snapshot().items.map((entry) => entry.body))).toContain( - 'add a retry' - ) - }) -}) 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 deleted file mode 100644 index 42da9a986da..00000000000 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Journal recovery: rehydrate a chat's timeline from provider history. -// -// One trigger: a chat whose replayed prefix is unusable (a gap, an unanchored prefix, a malformed -// row). Its journal stays writable, so it is rebuilt in place on a fresh epoch, and only that chat -// is touched. Damage SQLite itself reports is not recovered here: the open fails, and the chat -// says it cannot be loaded. - -import type { AgentType } from '../../../shared/agent-status-types' -import type { - AgentJournalResetReason, - AgentSessionJournalIdentity, - AgentSessionProviderHandle -} from '../../../shared/agent-session-journal-types' -import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' -import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' - -export type AgentSessionJournalRecovery = { - trigger: 'journal_corrupt' - /** What subscribers are told; it forces a clean snapshot reload. */ - reset: AgentJournalResetReason - epoch: string - imported: number - /** Set when provider history could not be read, or held nothing to restore; the - * intact journal prefix remains live. */ - error?: string -} - -export type AgentSessionJournalOpened = { - journal: AgentSessionJournal - recovery: AgentSessionJournalRecovery | null -} - -/** 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 - database: JournalHostDatabase - fence: number - /** Resolve directly to a transcript instead of discovering it by session id. */ - historyFilePath?: string | null - deferPerSessionImport?: boolean -}): Promise { - const journal = await openAgentSessionJournal({ - identity: input.identity, - database: input.database, - deferPerSessionImport: input.deferPerSessionImport - }) - if (!journal.needsRebuild) { - return { journal, recovery: null } - } - // `open()` dropped the unusable suffix; a successful import rolls once more so - // the rebuilt timeline is the only content of its epoch. A throw leaves nothing to release: - // the store holds no connection. - 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 = '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 } } : {}) - }) - // A transcript that held nothing is the same outcome as one that could not be - // read: nothing was restored, so the repair's marker has to stand and be - // retried on a later attach rather than being retired as a completed recovery. - if (!result.ok || !result.replaced) { - return { - trigger: input.trigger, - reset, - epoch: input.journal.epoch, - imported: 0, - error: result.ok ? 'Provider history held no messages to restore' : result.error - } - } - return { - trigger: input.trigger, - reset, - epoch: result.epoch, - imported: result.imported - } -} diff --git a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts index 5ff159ce2e6..6350ba67198 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-subscriber-catch-up.ts @@ -53,7 +53,7 @@ export function deliverToSubscriber( ...(publishedActivity !== undefined ? { activity: publishedActivity } : {}) } // Caught up, so there are no rows to read: every publish behind a commit's own delivery. - if (!journal.isReadOnly && sameJournalCursor(subscriber.cursor, journal.cursor())) { + if (sameJournalCursor(subscriber.cursor, journal.cursor())) { emitCaughtUp(port, subscriber, emitCheckpoint, shared) return } 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 index c98854292d5..d73cbb7e0bd 100644 --- 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 @@ -473,15 +473,8 @@ describe('structured session acquisition options', () => { } return cleanup }) - const failingAdapter = { - ...base, - acquire, - readOptions, - releaseAcquisition, - ...(failurePoint === 'journal' - ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) } - : {}) - } + const failingAdapter = { ...base, acquire, readOptions, releaseAcquisition } + let journalFailure = failurePoint === 'journal' ? injected : null const perform = ( target: AgentSessionRecordStore, operationId: string, @@ -491,10 +484,14 @@ describe('structured session acquisition options', () => { logger: createStructuredAgentSessionLogger(), store: target, adapter: failingAdapter, - openConversation: openTestAttachConversation( - openTestJournalHostDatabase(root!), - failingAdapter - ), + openConversation: async (record) => { + const failure = journalFailure + journalFailure = null + if (failure) { + throw failure + } + return openTestAttachConversation(openTestJournalHostDatabase(root!))(record) + }, authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index b5d431c859a..5edc3793d02 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -163,9 +163,6 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi readOptionRestoreFailures = (sessionId: string): readonly string[] => this.owner(sessionId).readOptionRestoreFailures?.(sessionId) ?? [] - historyFilePath = (input: { identity: AgentSessionJournalIdentity }) => - this.requireAgent(input.identity).historyFilePath?.(input) ?? Promise.resolve(null) - providerHistoryWindow = (input: { identity: AgentSessionJournalIdentity accountHome: AgentSessionAccountHome 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 index 318bada1fe5..9c34c184104 100644 --- 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 @@ -363,9 +363,6 @@ export type StructuredAgentSessionAdapter = StructuredAgentSessionAdapterStop & readOptions?(input: { sessionId: string; fence: number }): Promise /** Option keys skipped after a provider rejected their persisted restore value. */ readOptionRestoreFailures?(sessionId: string): readonly string[] - /** 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 /** Provider history for restart reconciliation, bounded to what the provider * recorded after the journal's last committed item. Only the adapter can say * whether the read has a proven start and whether a turn is still running, so 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 index 90c638c23fb..fe99ad90bc3 100644 --- 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 @@ -7,7 +7,6 @@ import type { AgentSessionTurnActivity, 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, @@ -22,12 +21,6 @@ export type StructuredAgentSessionAttachContext = { 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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index 7a9f758858a..e6498597646 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -1,20 +1,18 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' /** For a test that attaches without a host: the conversation's journal, through the one open a * host would take, so the attach under test adopts it the way it adopts the host's. */ export function openTestAttachConversation( - journalDatabase: JournalHostDatabase, - adapter: Pick = {} + journalDatabase: JournalHostDatabase ): (record: AgentSessionRecord) => Promise { return async (record) => ( await openStructuredAgentSessionConversationJournal( - { journalDatabase, adapter, logger: recordingStructuredAgentSessionLogger().logger }, + { journalDatabase, logger: recordingStructuredAgentSessionLogger().logger }, record, { acquisition: true diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index a001eddb3f8..a18b29b8e92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -101,7 +101,7 @@ it('delivers catalog changes through existing frames without resending them on o }) coalescer.flush() expect(state.commands).toEqual(commands) - subscribers.reset(sessionId, journal, 'epoch_changed', 8) + subscribers.snapshot(sessionId, journal, 8) expect(state.commands).toEqual(commands) commands = undefined subscribers.open({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 73285ee1de4..dc3d4fc42ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -1,17 +1,16 @@ // The one way a conversation's journal becomes open on this host: for a send, for a reader, and // for an attach that finds none open. // -// It opens with recovery, so an unusable journal is rebuilt rather than refused, and it marks what -// an earlier host process handed over and left unanswered as in doubt, and settles what it left -// running — the crash boundary. That +// A damaged journal fails the open, which refuses it as unloadable. It marks what an earlier host +// process handed over and left unanswered as in doubt, and settles what it left running — the +// crash boundary. That // needs no lease: provider history decides such a row later, under a won lease, in the attach. A // row an earlier process accepted and never handed over is the delivery loop's, which the open // wakes. Nothing here starts a provider child. -import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { @@ -20,7 +19,6 @@ import { type AgentSessionAttachParams } from './structured-agent-session-attach' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation' import type { @@ -30,13 +28,10 @@ import type { export type OpenedStructuredAgentSessionConversation = { session: StructuredAgentSessionHostSession - /** Set when the journal was rebuilt on the way; readers reload from a snapshot. */ - reset: AgentJournalResetReason | null } export type StructuredAgentSessionConversationOpenDeps = { store: Pick - adapter: Pick journalDatabase: JournalHostDatabase logger: StructuredAgentSessionHostDeps['logger'] } @@ -92,17 +87,15 @@ export async function openStructuredAgentSessionConversationJournal( expectedRuntimeFence: fence }) const identity = journalIdentityFor(record, params) - const opened = await openAgentSessionJournalWithRecovery({ + const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase, - fence, - historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null, deferPerSessionImport: options.deferPerSessionImport }) try { // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over // one is only doubt, which provider history decides under a won lease. - await opened.journal.markPendingSubmissionsUnknown(fence) + await journal.markPendingSubmissionsUnknown(fence) } catch (error) { deps.logger.warn('marking pending sends unknown on open failed', { scope: 'open-pending-unknown', @@ -114,12 +107,9 @@ export async function openStructuredAgentSessionConversationJournal( // belongs to a generation that is gone, whatever the lease still claims. Settled before any // reader or child sees it. if (!options.acquisition) { - await settleGoneGeneration(deps, record, opened.journal) - } - return { - session: { journal: opened.journal, params, child: null }, - reset: opened.recovery?.reset ?? null + await settleGoneGeneration(deps, record, journal) } + return { session: { journal, params, child: null } } } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index c0e78b5a0c5..764d54377d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -191,7 +191,7 @@ function attachContext( discardEventSink: () => undefined }, sessions, - subscribers: { reset: () => undefined, snapshot: () => undefined, publish: () => undefined }, + subscribers: { snapshot: () => undefined, publish: () => undefined }, tasks: { trackAttach: (task: Promise) => task }, reconcileLeases: async () => null, serialize: (_sessionId: string, task: () => Promise) => task(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts index 696236805ac..5da54bebfc5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-delivery.ts @@ -4,7 +4,6 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { abandonQueuedStructuredAgentSessionMessages } from './structured-agent-session-host-lifetime' -import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openStructuredAgentSessionConversation, @@ -54,7 +53,6 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, startedFor: string ) => Promise - reset: (sessionId: string, journal: AgentSessionJournal, reset: AgentJournalResetReason) => void clientDelivery: Pick }): StructuredAgentSessionConversationDelivery { const { deps, sessions } = input @@ -86,11 +84,8 @@ export function createStructuredAgentSessionConversationDelivery(input: { sessionId: string, opened: OpenedStructuredAgentSessionConversation ): Promise => { - const { session, reset } = opened + const { session } = opened sessions.set(sessionId, session) - if (reset) { - input.reset(sessionId, session.journal, reset) - } input.clientDelivery.publishRestored(sessionId) await settleInterruptedCommands(deps, sessionId, session) if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) { 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 index f74532ac9ae..763df3080c3 100644 --- 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 @@ -70,15 +70,29 @@ export function createStructuredAgentSessionTabSurface( deps: { store: Pick< AgentSessionRecordStore, - 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' + 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getRecord' > } }, sessions: TabSessions, forgetStatus: (sessionId: string) => void ) { + // Restored chats that could not be opened. Each keeps its tab, whose read says why. + const unopened = new Set() return { - listSessionTabs: () => listStructuredAgentSessionTabs(sessions), + listSessionTabs: (): StructuredAgentSessionTab[] => [ + ...listStructuredAgentSessionTabs(sessions), + ...[...unopened].flatMap((sessionId) => { + const record = sessions.has(sessionId) ? null : host.deps.store.getRecord(sessionId) + return record + ? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }] + : [] + }) + ], + /** A restore target with a record whose open failed. */ + markUnopened: (sessionId: string): void => { + unopened.add(sessionId) + }, getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(), getSessionTabId: (sessionId: string): string | null => host.deps.store.getSessionTabId(sessionId), @@ -89,6 +103,9 @@ export function createStructuredAgentSessionTabSurface( tabId?: string ): Promise => { await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) + if (!visible) { + unopened.delete(sessionId) + } // The tab edge of the row's lifetime; the handle close is the other. if (!visible && !sessions.get(sessionId)?.child) { forgetStatus(sessionId) 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 index 6552f8a2601..ae75f476591 100644 --- 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 @@ -2,7 +2,10 @@ import type { SubmissionRejectionFact } from '../../../shared/agent-session-fail import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { + AgentSessionStatusSummary, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' @@ -30,6 +33,8 @@ export type StructuredAgentSessionReveal = { workspaceId: string agent: 'claude' | 'codex' readable: boolean + /** Why the journal did not open, as a read would be refused. Host-side only: never published. */ + openRefusal?: AgentSessionWireRefusal } /** Which provider child: the adapter acquisition and the lease fence it writes at. */ 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 index 5b5a4d19c3c..8256f90e53c 100644 --- 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 @@ -126,13 +126,6 @@ export class StructuredAgentSessionHost { trackStart: (start) => this.tasks.trackAttach(start), ensureProviderChild: (sessionId, startedFor) => agentStart.ensureStructuredAgentSessionAgent(this.attachContext(), sessionId, startedFor), - reset: (sessionId, journal, reset) => - this.subscribers.reset( - sessionId, - journal, - reset, - structuredAgentSessionConversationFence(deps.store, sessionId) - ), clientDelivery: this.clientDelivery }) this.restore = createStructuredAgentSessionHostRestore(deps, { @@ -142,7 +135,8 @@ export class StructuredAgentSessionHost { hasSession: this.hasSession, // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. - onReadable: this.conversationDelivery.adoptOpened + onReadable: this.conversationDelivery.adoptOpened, + onUnopened: (sessionId) => this.tabs.markUnopened(sessionId) }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts new file mode 100644 index 00000000000..f3ce31243da --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-newer-content-host.test.ts @@ -0,0 +1,223 @@ +// On a real host: what a newer Orca left in a chat, and a provider row the reader would reject, +// never leave the chat unable to take its next message. + +import { cp, rm } from 'node:fs/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const relaunchedRoots: string[] = [] + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +function providerEvents() { + const events = hostTestState().acquire.mock.calls.at(-1)?.[0].events + if (!events) { + throw new Error('no acquired provider') + } + return events +} + +function emit(ordinal: number, body: AgentJournalItemBody): void { + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal }, + body, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) +} + +function send(host: StructuredAgentSessionHost, text: string) { + const body = hostTestMessage(text) + return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) +} + +async function historyBodies(host: StructuredAgentSessionHost): Promise { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + if (!page.ok) { + throw new Error('history refused') + } + return page.page.items.map((item) => item.body) +} + +/** A restarted process over the same files, holding no chat open and owning no provider. */ +async function relaunch(): Promise { + const before = hostTestState() + await before.host.flushAllStreamedEvents() + await before.store.renewLeases([]) + const relaunched = `${before.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(before.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const store = await openTestAgentSessionRecordStore(relaunched) + const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + now: () => NOW + }) + replaceHostTestState({ store, host }) + return host +} + +// A subject kind this build does not know. +const NEWER_APPROVAL: AgentJournalItemBody = JSON.parse( + JSON.stringify({ + kind: 'approval', + title: 'Review proposed change', + detail: null, + subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' }, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }) +) + +it("settles a newer Orca's pending approval as it was, and the chat takes its next message", async () => { + await attach() + // Still pending when its provider went away. + emit(7, NEWER_APPROVAL) + const host = await relaunch() + + expect(await send(host, 'after the newer approval')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalled()) + + const approval = (await historyBodies(host)).find((body) => body.kind === 'approval') + expect(approval).toMatchObject({ + subject: { kind: 'diff', path: 'a.ts', text: 'not a plan' }, + resolution: { state: 'cancelled' } + }) + await host.flushAllStreamedEvents() +}) + +// A newer host's background agent can hold such an approval while no turn runs. A queued send would +// wait behind it forever; the plain send the client makes instead starts a turn, whose card cancel +// then settles it. +it("takes a send past a newer Orca's live approval, and the next turn's card cancel settles it", async () => { + // A provider whose card Cancel goes through the Stop: a card no live turn raised is declined. + const dismissPrompt = vi.fn(async ({ commit }: { commit: () => Promise }) => commit()) + Object.assign(hostTestState().host.deps.adapter, { + routePromptCancel: () => ({ kind: 'stop' }), + dismissPrompt + }) + await attach() + emit(7, NEWER_APPROVAL) + const { host } = hostTestState() + await host.flushStreamedEvents(SESSION) + + const body = hostTestMessage('queued behind it') + const queuedFields = { body, delivery: 'queue-if-active' as const } + expect( + await host.send(CALLER, { + envelope: envelope('agentSession.send', queuedFields), + ...queuedFields, + userSend: true + }) + ).toMatchObject({ ok: true, value: { queued: { state: 'waiting' } } }) + expect(hostTestState().dispatch).not.toHaveBeenCalled() + + const sent = await send(host, 'carry on') + expect(sent).toMatchObject({ ok: true, value: { submission: expect.any(Object) } }) + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalledOnce()) + providerEvents().appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 8 }, + { kind: 'turn', turnId: 'turn-2', state: 'running', startedAt: NOW }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + + const card = (await host.journalSnapshot(SESSION)).items.find( + (item) => item.body.kind === 'approval' + )! + const fields = { + turnId: 'turn-2', + prompt: { itemId: card.itemId, expectedRevision: card.revision } + } + expect( + await host.cancel(CALLER, { envelope: envelope('agentSession.cancel', fields), ...fields }) + ).toMatchObject({ ok: true }) + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(hostTestState().cancelTurn).not.toHaveBeenCalled() + expect((await historyBodies(host)).find((entry) => entry.kind === 'approval')).toMatchObject({ + subject: { kind: 'diff' }, + resolution: { state: 'cancelled' } + }) + await host.flushAllStreamedEvents() +}) + +it('ends a turn whose provider output would not read back as failed, and the next send works', async () => { + const closeSession = vi.fn(async () => true) + Object.assign(hostTestState().host.deps.adapter, { closeSession }) + // A provider that names its generation, as the real adapters do, so its stop settles the turn. + const { acquire } = hostTestState() + const acquired = acquire.getMockImplementation()! + acquire.mockImplementation(async (input) => ({ + ...(await acquired(input)), + acquisitionGeneration: `generation-${acquire.mock.calls.length}` + })) + await attach() + emit(1, { + kind: 'status', + text: 'Turn started', + turnLifecycle: { turnId: 'turn-1', state: 'running', startedAt: NOW } + }) + emit(2, { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'written first' }] }) + // TypeScript accepts it; the persisted reader rejects a call id that is only spaces. + emit(3, { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] + }) + const { host, store } = hostTestState() + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + }) + + // The turn is over, with the notice any failed journal write gives. + expect(await historyBodies(host)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'turn', turnId: 'turn-1', state: 'interrupted' }), + expect.objectContaining({ kind: 'status', failure: { kind: 'hostFault' } }) + ]) + ) + expect(await send(host, 'the next message')).toMatchObject({ ok: true }) + await vi.waitFor(() => expect(hostTestState().acquire).toHaveBeenCalledTimes(2)) + + const reopened = await relaunch() + expect(await historyBodies(reopened)).toContainEqual( + expect.objectContaining({ blocks: [{ type: 'text', text: 'written first' }] }) + ) + await reopened.flushAllStreamedEvents() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index 3fed3c96291..f98959bc0ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -3,7 +3,10 @@ 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 { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalApprovalSubject, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { @@ -43,7 +46,8 @@ afterEach(async () => { async function pendingPrompt( options = [{ id: 'allow', label: 'Allow' }], /** Raise the card in a turn that is still running, rather than on the conversation. */ - inLiveTurn = false + inLiveTurn = false, + subject?: AgentJournalApprovalSubject ): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) @@ -60,6 +64,7 @@ async function pendingPrompt( kind: 'approval', title: 'Approve?', detail: null, + ...(subject ? { subject } : {}), options, resolution: { state: 'pending', @@ -282,14 +287,16 @@ describe("a card's own Cancel, as its provider answers it", () => { answer: AgentSessionPromptCancelRoute | undefined, revision = 1, endsSession = true, - inLiveTurn = true + inLiveTurn = true, + subject?: AgentJournalApprovalSubject ) { const { journal, itemId } = await pendingPrompt( [ { id: 'allow', label: 'Allow' }, { id: 'deny', label: 'Deny' } ], - inLiveTurn + inLiveTurn, + subject ) const ctx = context( journal, @@ -320,6 +327,35 @@ describe("a card's own Cancel, as its provider answers it", () => { return { result, routes, answerPrompt, dismissPrompt, card } } + describe('on an approval whose subject a newer Orca wrote, which only its Cancel answers', () => { + const NEWER_SUBJECT: AgentJournalApprovalSubject = JSON.parse('{"kind":"diff","path":"a.ts"}') + + it('has Claude decline the request, the turn going on, and keeps the subject as it was', async () => { + const { routes, dismissPrompt, card } = await cancelCard( + { kind: 'dismiss' }, + 1, + true, + true, + NEWER_SUBJECT + ) + + expect(dismissPrompt).toHaveBeenCalledWith(expect.objectContaining({ answer: true })) + expect(routes.interrupt).not.toHaveBeenCalled() + expect(routes.stop).not.toHaveBeenCalled() + expect(card).toMatchObject({ + subject: { kind: 'diff', path: 'a.ts' }, + resolution: { state: 'cancelled' } + }) + }) + + it('has Codex end the turn holding it', async () => { + const { routes, dismissPrompt } = await cancelCard(undefined, 1, true, true, NEWER_SUBJECT) + + expect(routes.interrupt).toHaveBeenCalledOnce() + expect(dismissPrompt).not.toHaveBeenCalled() + }) + }) + it('interrupts the turn holding the card for a provider that gives no answer', async () => { const { routes, answerPrompt } = await cancelCard(undefined) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 176fd8e11fb..248534097b8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -211,9 +211,7 @@ export async function maybeQueueStructuredAgentSessionSend( if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { return null } - // A newer Orca's journal takes no new draft: the immediate path refuses the send. if ( - ctx.journal.isReadOnly || !shouldQueueStructuredAgentSessionSend({ journal: ctx.journal, record: context.deps.store.getRecord(ctx.sessionId), @@ -269,7 +267,7 @@ export class StructuredAgentSessionQueuedMessageDrain { schedule(sessionId: string): void { const journal = this.deps.sessions.get(sessionId)?.journal - if (!journal || journal.isReadOnly) { + if (!journal) { return } // Cheap pre-check so token streams do not pay a serialized step per delta. @@ -312,7 +310,7 @@ export class StructuredAgentSessionQueuedMessageDrain { private async step(sessionId: string): Promise { const session = this.deps.sessions.get(sessionId) - if (!session || session.journal.isReadOnly) { + if (!session) { return } const journal = session.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts index 2dd2f567b87..eeaafbbaff8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -1,8 +1,11 @@ -// A chat journal a newer Orca wrote opens read-only here: its cards still show, and every queue -// write (a queued send, Send-now, Delete, Resume) is refused with the words a send gets there. +// A chat journal a newer Orca wrote does not open here: its history read and every queue write (a +// queued send, Send-now, Delete, Resume) are refused as a newer Orca's chat, and nothing is written. import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from '../agent-session-journal/journal-host-database-test-support' import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' import { @@ -42,15 +45,11 @@ async function reopenOnNewerOrcaDatabase(close: () => Promise): Promise Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true }) } -/** A read-only journal's history answers as a `schema_unreadable` reset carrying its page. */ -async function readOnlyQueue() { - const answer = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) - expect(answer).toMatchObject({ ok: false, reset: 'schema_unreadable' }) - const page = 'page' in answer ? answer.page : undefined - return { - cards: (page?.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })), - pause: page?.queuePause ?? null - } +/** The history read a newer Orca's chat gets: refused, as every reader's is. */ +async function expectHistoryRefused(): Promise { + await expect( + rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) } async function twoCardsBehindWork() { @@ -68,26 +67,22 @@ async function twoCardsBehindWork() { } describe("a newer Orca's journal", () => { - it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { - const { first, second, cards } = await twoCardsBehindWork() + it('refuses the history read, a queued send, Send-now and Delete with the update words', async () => { + const { first, second } = await twoCardsBehindWork() await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION, 'evict')) - expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + await expectHistoryRefused() - // The waiting cards would queue this send behind them; the journal takes no new draft. expect(await rig.send('third', 'queue-if-active').result).toMatchObject(REFUSED_BY_NEWER_ORCA) expect(await rig.sendNow(first)).toMatchObject(REFUSED_BY_NEWER_ORCA) expect(await rig.deleteQueued(second)).toMatchObject(REFUSED_BY_NEWER_ORCA) - - expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + await expectHistoryRefused() }) it('refuses Resume of the queue a restart paused with the update words', async () => { - const { cards } = await twoCardsBehindWork() + await twoCardsBehindWork() await reopenOnNewerOrcaDatabase(() => rig.restartHostProcess()) - expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) + await expectHistoryRefused() expect(await rig.resume()).toMatchObject(REFUSED_BY_NEWER_ORCA) - - expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts index a0d3dbe7b52..6fd6bb13612 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts @@ -44,7 +44,7 @@ export function wireStructuredAgentSessionQueuedMessages( onJournalActivity: (sessionId: string) => { sessions.touch(sessionId) const journal = sessions.get(sessionId)?.journal - if (journal && !journal.isReadOnly) { + if (journal) { void adoptEndedRestartPause(sessionId, journal, context().deps.logger) } drain.schedule(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 945f2cefbad..6f59e5e7d5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -56,7 +56,6 @@ let journalRoot: string const openDeps = () => ({ store, journalDatabase: openTestJournalHostDatabase(journalRoot), - adapter: {}, logger: recordingStructuredAgentSessionLogger().logger }) const opened: AgentSessionJournal[] = [] 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 index 8f110c3e69a..3c3cc124003 100644 --- 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 @@ -37,7 +37,6 @@ describe('StructuredAgentSessionReadableRestorer', () => { openDeps: { store: { getRecord: () => null, listRecords: () => records }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {}, logger: recordingStructuredAgentSessionLogger().logger }, supportsRecord: () => true, 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 index d5899c794e3..fefd91a8478 100644 --- 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 @@ -349,9 +349,12 @@ describe('agentSessionRefusalOperationState host oracle', () => { const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') - unreadable.host.deps.adapter.historyFilePath = async () => { - throw new Error('transcript unreadable') - } + // The journal's open asks where the chat's per-chat file lives before it reads anything. + const unreadableOpen = vi + .spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor') + .mockImplementation(() => { + throw new Error('journal path unreadable') + }) const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( @@ -359,7 +362,7 @@ describe('agentSessionRefusalOperationState host oracle', () => { unreadableSend, 'agent_session_journal_unreadable', async () => { - delete unreadable.host.deps.adapter.historyFilePath + unreadableOpen.mockRestore() return { harness: unreadable, spec: unreadableSend } } ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts index 74e4cfb4815..bc40f51c466 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -198,21 +198,24 @@ describe('a resent send id', () => { expect(store.getRecord(SESSION)?.rewind?.phase).toBe('prepared') }) - it('is answered from a store a newer Orca wrote, which takes no write', async () => { + it('answers unknown, never a refusal, from a store a newer Orca wrote, which takes no write', async () => { await attach() const params = sendParams('sent, then a newer Orca wrote the store') await host.send(CALLER, params) await deliveredOnce() await host.close(SESSION, 'evict') + const rowsBefore = store.listOperationRows() Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) expect(store.readOnly).toBe(true) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // This build never reads a newer Orca's chat, so the answer its journal holds stays unknown. await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true, - value: { submission: { clientMessageId: params.envelope.clientOperationId } } + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } }) expect(dispatch).toHaveBeenCalledTimes(1) + expect(store.listOperationRows()).toEqual(rowsBefore) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index b16f76c1633..93986d5988b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -2,9 +2,10 @@ // // A different question from storage: the durable record decides which markers are still present; // this decides which of those a resume may act on. The offer, the click and the pre-send check all -// ask it, and all get the same answer. +// ask it. import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -30,6 +31,8 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { adapter: StructuredAgentSessionAdapter /** Whether the chat moved on since the offer was taken; see the offer withdrawal. */ movedOn: (marker: AgentSessionResumeMarker) => boolean + /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */ + savedByNewerOrca: (sessionId: string) => boolean }): StructuredAgentSessionRestartCandidateReader { return (markers, leaseState) => structuredAgentSessionResumableSet({ @@ -37,6 +40,7 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { getRecord: deps.getRecord, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), movedOn: deps.movedOn, + savedByNewerOrca: deps.savedByNewerOrca, latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt( deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] @@ -44,3 +48,37 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: { leaseState }) } + +/** The listing's reader, which skips a newer Orca's chats, and the one right before sending, which + * skips nothing for them: turning one away there would spend its offer, so its send is refused + * instead and settling keeps the offer. */ +export function createStructuredAgentSessionRestartCandidateReaders( + deps: Parameters[0] +): { + derive: StructuredAgentSessionRestartCandidateReader + deriveAtSend: StructuredAgentSessionRestartCandidateReader +} { + return { + derive: createStructuredAgentSessionRestartCandidateReader(deps), + deriveAtSend: createStructuredAgentSessionRestartCandidateReader({ + ...deps, + savedByNewerOrca: () => false + }) + } +} + +/** Chats the latest reveal found saved by a newer Orca: listing skips them and never spends their + * offers. Each reveal re-derives its chat's entry; nothing is stored. */ +export function createNewerOrcaChats(databaseIsNewer: () => boolean) { + const chats = new Set() + return { + has: (sessionId: string): boolean => databaseIsNewer() || chats.has(sessionId), + note: (sessionId: string, revealed: { openRefusal?: AgentSessionWireRefusal } | null): void => { + if (revealed?.openRefusal?.details?.reason === 'journalWrittenByNewerOrca') { + chats.add(sessionId) + } else { + chats.delete(sessionId) + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index 973d7758358..8f846bb6f1a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -105,12 +105,32 @@ it.each([ ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', - reason: code + reason: code, + refusal: { code } }) expect(deps.awaitSettlement).not.toHaveBeenCalled() expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) }) +// A newer Orca's refusal keeps its reason, as a reference without the wire prose. +it('reports a newer-Orca send refusal with its reason', async () => { + const deps = dependencies('accepted') + const refusal = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } as const + deps.send.mockResolvedValue({ ok: false, refusal: { ...refusal, message: 'Update Orca.' } }) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker(), 'operation-1') + ).resolves.toEqual({ + sessionId: SESSION, + outcome: 'refused', + reason: 'agent_session_journal_unreadable', + refusal + }) +}) + it('reports an unattached chat without sending', async () => { const deps = dependencies('accepted') deps.currentFence = () => null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index c748748ccd4..1a66b556103 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -14,12 +14,16 @@ import { readAgentSessionFailureFact, type UnreadAgentSessionFailureFact } from '../../../shared/agent-session-failure' -import type { AgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' +import { + agentSessionRefusalReference, + type AgentSessionRefusalReference +} from '../../../shared/agent-session-wire-refusals' import { agentSessionSendSubmission, type AgentSessionMutationEnvelope, type AgentSessionMutationResult, - type AgentSessionSendResult + type AgentSessionSendResult, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { RESTART_CONTINUATION_ROW_PREFIX } from '../../../shared/agent-session-stop-row-identity' @@ -164,7 +168,7 @@ export type StructuredAgentSessionContinuationDeps = { body: AgentJournalMessageItem }) => Promise<{ ok: boolean - refusal?: { code: string } + refusal?: AgentSessionWireRefusal /** The submission is where the provider's answer lives; the envelope only says Orca took it. * A continuation never sends `delivery`, so a queued answer cannot arrive; the key exists so * the host's union return stays assignable. */ @@ -314,7 +318,9 @@ async function sendContinuation( done: { sessionId, outcome: 'refused', - reason: sent.refusal?.code ?? 'agent_session_send_failed' + reason: sent.refusal?.code ?? 'agent_session_send_failed', + // Its details too, so a newer Orca's refusal is filed as one, not as a bare code. + ...(sent.refusal ? { refusal: agentSessionRefusalReference(sent.refusal) } : {}) } } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts index 63722d1737c..f69e9ced6dc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -58,10 +58,11 @@ export type StructuredAgentSessionRestartFailureLedger = { failureReason: (sessionId: string) => string } ) => Promise - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ dismiss: ( sessionIds: readonly string[] | undefined, - beforeClearAll: () => void + beforeClearAll: () => void | Promise ) => Promise } @@ -78,6 +79,9 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { adapter: StructuredAgentSessionAdapter /** The predicate a retry applies to the failure's marker. */ retryable: (marker: AgentSessionResumeMarker) => boolean + /** Whether a newer Orca saved the chat: its failure is kept for that Orca but not shown here, + * where no retry can land. */ + savedByNewerOrca: (sessionId: string) => boolean /** Makes the failed chats readable here, so `retryable` reads each one's journal. */ reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise logger: StructuredAgentSessionLogger @@ -101,7 +105,11 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { failure: AgentSessionResumeFailureRecord ): StructuredAgentSessionResumeFailure[] => { const record = deps.getRecord(failure.marker.sessionId) - if (!record || !adapterSupportsRecord(deps.adapter, record)) { + if ( + !record || + !adapterSupportsRecord(deps.adapter, record) || + deps.savedByNewerOrca(failure.marker.sessionId) + ) { return [] } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) @@ -153,6 +161,15 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { ]) for (const outcome of outcomes) { const resumed = outcome.outcome === 'resumed' + // A newer Orca's refusal spends nothing: the rollback below reopens the offer for the Orca + // that can continue it, and nothing is filed for a chat no retry here could continue. + if ( + !resumed && + outcome.reason === 'agent_session_journal_unreadable' && + outcome.details?.reason === 'journalWrittenByNewerOrca' + ) { + continue + } // Ineligible means the offer no longer applies (record gone, conversation forked), and // superseded means the user's own message came first: nothing to retry, and the offer is spent. const failure = resumed @@ -216,8 +233,10 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: { if (sessionIds !== undefined) { return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 } - beforeClearAll() - return (await deps.capsule?.clearAll(deps.now())) ?? 0 + await beforeClearAll() + // A newer Orca's offers and failures were never shown here, so "dismiss all" keeps them. + const keep = (marker: AgentSessionResumeMarker) => deps.savedByNewerOrca(marker.sessionId) + return (await deps.capsule?.clearAll(deps.now(), keep)) ?? 0 }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts new file mode 100644 index 00000000000..e2f2087d442 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts @@ -0,0 +1,198 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + AgentSessionRecoveryCapsule +} from '../../runtime/agent-session-recovery-capsule' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { AgentSessionJournalError } from '../agent-session-journal/journal-write-guards' +import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' +import { attachParams, CALLER, hostTestState } from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +// Nothing here can continue a chat a newer Orca saved, so it is not offered after a restart and +// nothing is counted. Its offer is kept: the offers file is shared by every Orca on the host, and +// the newer one can still act on it. Nothing here writes to that file for such a chat. + +afterEach(() => vi.restoreAllMocks()) + +/** Writes a row of a kind this build does not know into the chat's journal, as a newer Orca would. */ +function newerOrcaRow(root: string, sessionId: string = SESSION): void { + const { db } = openTestJournalHostDatabase(root) + const last = liveTestJournalRows(db, sessionId).at(-1) + const parsed: unknown = last ? JSON.parse(last.rowJson) : null + if (!last || typeof parsed !== 'object' || parsed === null || !('epoch' in parsed)) { + throw new Error('the chat has no journal row to follow') + } + const { epoch } = parsed + const seq = last.seq + 1 + insertTestJournalRowJson( + db, + sessionId, + seq, + JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch, + seq, + fence: 1, + ts: NOW + }), + NOW + ) +} + +function offersFile(root: string): Promise { + return readFile(join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE)) +} + +/** Lists and acts with nothing offered, run or counted, and the offers file left byte-identical. */ +async function keptWithoutAWord( + host: Awaited>['host'], + root: string, + acquire: ReturnType +) { + const before = await offersFile(root) + expect(await host.restartResume.list()).toEqual([]) + expect(await offersFile(root)).toEqual(before) + expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([]) + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.listFailures()).toEqual([]) + expect(await offersFile(root)).toEqual(before) + const capsule = new AgentSessionRecoveryCapsule(root) + expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION]) + expect(await capsule.listFailed(NOW)).toEqual([]) +} + +it('keeps the offer of a chat whose journal a newer Orca wrote, and offers, runs and counts nothing', async () => { + const { host, root, acquire } = await interruptedRestart() + newerOrcaRow(root) + await keptWithoutAWord(host, root, acquire) +}) + +// A newer Orca's whole database: every chat in it is a newer Orca's, even one whose tables changed. +it('keeps the offer of every chat in a newer Orca database, and offers, runs and counts nothing', async () => { + const { host, root, acquire } = await interruptedRestart() + const database = openTestJournalHostDatabase(root) + Object.defineProperty(database, 'readOnly', { value: true }) + database.db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_newer') + await keptWithoutAWord(host, root, acquire) +}) + +// However a newer Orca's refusal reaches a send (a journal first opened when sending), it files +// nothing and the offer is reopened. +it('keeps the offer and files nothing when the send is refused as a newer Orca chat', async () => { + const { host, root } = await interruptedRestart() + await host.restartResume.list() + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockRejectedValue( + new AgentSessionJournalError('journal_read_only', 'a newer Orca wrote this journal') + ) + + const action = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(action.continued).toMatchObject([ + { + outcome: 'refused', + reason: 'agent_session_journal_unreadable', + refusal: { details: { reason: 'journalWrittenByNewerOrca' } } + } + ]) + const capsule = new AgentSessionRecoveryCapsule(root) + expect(await capsule.listFailed(NOW)).toEqual([]) + expect((await capsule.list(NOW)).map((marker) => marker.sessionId)).toEqual([SESSION]) + expect(await host.restartResume.listFailures()).toEqual([]) +}) + +// A failure filed before the chat became a newer Orca's stays for that Orca, and is not shown here. +it('keeps a failure already filed for a chat a newer Orca saved since, but does not show it', async () => { + const { host, root, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(await host.restartResume.listFailures()).toMatchObject([{ sessionId: SESSION }]) + await host.close(SESSION, 'evict') + newerOrcaRow(root) + + expect(await host.restartResume.listFailures()).toEqual([]) + expect( + (await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).map((f) => f.marker.sessionId) + ).toEqual([SESSION]) +}) + +// What listing does with a journal that throws for another reason: a damaged chat stays offered, +// and acting on it files the failure, which names the damage. +it('still offers a damaged chat, and files its failure when acting on it', async () => { + const { host, root, acquire } = await interruptedRestart() + updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{') + + expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ + SESSION + ]) + await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(acquire).not.toHaveBeenCalled() + expect(await host.restartResume.listFailures()).toMatchObject([ + { + sessionId: SESSION, + reason: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + ]) +}) + +// "Dismiss all" ends what the person was shown. A newer Orca's offer was never shown here, so it +// stays, byte for byte, for the Orca that can act on it. +it.each([ + ['after the dialog listed the offers', true], + ['on a host that has not listed them yet', false] +])( + "dismisses every listed offer and leaves a newer Orca's hidden one as it was, %s", + async (_when, listFirst) => { + const NEWER = 'session-newer-orca' + // A second chat, made before the restart; a newer Orca then saves it with a row this build + // can't place. + const created = await hostTestState().host.attach( + CALLER, + attachParams({ envelope: { ...attachParams().envelope, sessionId: NEWER } }) + ) + expect(created.ok).toBe(true) + hostTestState().acquire.mockClear() + const { host, root, marker } = await interruptedRestart() + newerOrcaRow(root, NEWER) + const path = join(root, AGENT_SESSION_RECOVERY_CAPSULE_FILE) + const capsule = JSON.parse(await readFile(path, 'utf8')) + capsule.entries.push({ state: 'pending', marker: { ...marker, sessionId: NEWER } }) + await writeFile(path, JSON.stringify(capsule)) + const newerEntry = () => + readFile(path, 'utf8').then((text) => + JSON.stringify( + JSON.parse(text).entries.find( + (entry: { marker: { sessionId: string } }) => entry.marker.sessionId === NEWER + ) + ) + ) + const before = await newerEntry() + + if (listFirst) { + expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([ + SESSION + ]) + } + await host.restartResume.dismiss() + + const left = await new AgentSessionRecoveryCapsule(root).list(NOW) + expect(left.map((offer) => offer.sessionId)).toEqual([NEWER]) + expect(await newerEntry()).toBe(before) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts index 95c97be63a9..4b458002ab9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-records.ts @@ -14,6 +14,8 @@ export type StructuredAgentSessionRestartOfferRecords = { sessionIds: readonly string[] | undefined ) => Promise revealMarkers: (markers: readonly AgentSessionResumeMarker[]) => Promise + /** Reveals the chat of every offer and every recorded failure. */ + revealEvery: () => Promise /** A user's newer message ended these offers; delete them rather than re-filter forever. * Advisory: a failed prune must never fail the read that noticed it. */ retireSuperseded: (superseded: readonly AgentSessionResumeMarker[]) => void @@ -41,6 +43,13 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { return [] } } + const revealMarkers = async (markers: readonly AgentSessionResumeMarker[]): Promise => { + for (const marker of markers) { + if (!deps.hasSession(marker.sessionId)) { + await deps.reveal(marker.sessionId) + } + } + } return { readMarkers, readActionMarkers: async (sessionIds) => { @@ -54,13 +63,9 @@ export function createStructuredAgentSessionRestartOfferRecords(deps: { ) return [...pending, ...retried] }, - revealMarkers: async (markers) => { - for (const marker of markers) { - if (!deps.hasSession(marker.sessionId)) { - await deps.reveal(marker.sessionId) - } - } - }, + revealMarkers, + revealEvery: async () => + revealMarkers([...(await readMarkers()), ...(await deps.readFailedMarkers())]), retireSuperseded: (superseded) => { const capsule = deps.capsule if (!capsule || superseded.length === 0) { 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 index 441d4e80626..7df838d04e8 100644 --- 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 @@ -30,7 +30,6 @@ afterAll(() => { const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, journalDatabase: openTestJournalHostDatabase(stateDirectory), - adapter: {}, logger: recordingStructuredAgentSessionLogger().logger } @@ -52,8 +51,7 @@ describe('restart journal restoration', () => { params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' }, child: null, sessionId - }, - reset: null + } } }) const records = Array.from( @@ -139,8 +137,7 @@ describe('restart journal restoration', () => { runtimeKind: 'native' } const restored = { - session: { journal: {}, params, child: null }, - reset: null + session: { journal: {}, params, child: null } } // The open is what settles: it runs after recovery resolution and before the publish. restoreRead.mockImplementation(async () => { @@ -219,10 +216,46 @@ describe('restart journal restoration', () => { }) }) + it('opens every other chat when one chat cannot be opened, and the pass still settles', async () => { + restoreRead.mockImplementation(async (_deps, sessionId: string) => { + if (sessionId === 'session-0') { + throw new Error('this chat cannot be opened') + } + return { session: { journal: {}, params: {}, child: null, sessionId } } + }) + const readable: string[] = [] + const log = recordingStructuredAgentSessionLogger() + const records = Array.from( + { length: 12 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + + await expect( + restoreStructuredAgentSessionsOnRestart({ + openDeps: { ...NO_OPEN_DEPS, logger: log.logger }, + records, + reconcile: async () => true, + resolveRecovery: async () => true, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: (sessionId) => { + readable.push(sessionId) + } + }) + ).resolves.toBeUndefined() + + expect(readable).toEqual(records.slice(1).map((record) => record.sessionId)) + expect(log.entries).toEqual([ + expect.objectContaining({ + fields: expect.objectContaining({ scope: 'restart-restore', sessionId: 'session-0' }) + }) + ]) + }) + it('does not settle again when a second restore finds the session already open', async () => { restoreRead.mockResolvedValue({ - session: { journal: {}, params: {}, child: null }, - reset: null + session: { journal: {}, params: {}, child: null } }) await restoreStructuredAgentSessionsOnRestart({ 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 index 29ea17845d8..6c6efc49d72 100644 --- 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 @@ -34,6 +34,8 @@ export type StructuredAgentSessionReadRestoreDeps = { resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean + /** A chat whose open failed: it still has its record, so it keeps its tab. */ + onUnopened?: (sessionId: string) => void onReadable: ( sessionId: string, opened: OpenedStructuredAgentSessionConversation @@ -92,6 +94,16 @@ export async function restoreStructuredAgentSessionsOnRestart( await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. await yieldToEventLoop() - await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) + // One chat that cannot open must not keep the rest from restoring; a read of it still refuses. + await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases).catch( + (error: unknown) => { + input.openDeps.logger.warn('restoring a chat at startup failed', { + scope: 'restart-restore', + sessionId, + error + }) + input.onUnopened?.(sessionId) + } + ) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index ef8481e2b1f..83dedb1709d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -14,7 +14,10 @@ import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' +import { + createNewerOrcaChats, + createStructuredAgentSessionRestartCandidateReaders +} from './structured-agent-session-restart-candidates' import { continuationFailureOutcome, createStructuredAgentSessionRestartFailureLedger @@ -44,6 +47,7 @@ import { import type { StructuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring' import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' type LiveSession = StructuredAgentSessionRestartOfferSession @@ -67,7 +71,8 @@ export type StructuredAgentSessionRestartResume = { sessions?: StructuredAgentSessionResumeCandidate[] failed?: StructuredAgentSessionResumeFailure[] }> - /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + /** Named sessions forget their offer or failure; unnamed, every record this host + * lists goes (a newer Orca's stay). */ dismiss: (sessionIds?: readonly string[]) => Promise /** The chat's agent proved a start: its offer ends unless the start is a resume's own. */ onAgentStarted: (sessionId: string) => void @@ -79,6 +84,7 @@ export function createStructuredAgentSessionRestartResume( adapter: StructuredAgentSessionAdapter recoveryCapsule?: AgentSessionRecoveryCapsule logger: StructuredAgentSessionLogger + journalDatabase: Pick }, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces @@ -102,30 +108,33 @@ export function createStructuredAgentSessionRestartResume( now: surfaces.now, enqueue: enqueueRecoveryOperation }) - const derive = createStructuredAgentSessionRestartCandidateReader({ + const newerOrca = createNewerOrcaChats(() => deps.journalDatabase.readOnly) + const { derive, deriveAtSend } = createStructuredAgentSessionRestartCandidateReaders({ sessions, getRecord: deps.store.getRecord, adapter: deps.adapter, - movedOn: withdrawal.movedOn + movedOn: withdrawal.movedOn, + savedByNewerOrca: newerOrca.has }) const failures = createStructuredAgentSessionRestartFailureLedger({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), getRecord: deps.store.getRecord, adapter: deps.adapter, retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1, + savedByNewerOrca: newerOrca.has, reveal: (markers) => revealMarkers(markers), logger: deps.logger, now: surfaces.now, enqueue: enqueueRecoveryOperation }) - const { readMarkers, readActionMarkers, revealMarkers, retireSuperseded } = + const { readMarkers, readActionMarkers, revealMarkers, revealEvery, retireSuperseded } = createStructuredAgentSessionRestartOfferRecords({ ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), readFailedMarkers: async () => (await failures.read()).map((failure) => failure.marker), hasSession: (sessionId) => sessions.has(sessionId), reveal: async (sessionId) => { - await surfaces.revealSession(sessionId).catch(() => null) + newerOrca.note(sessionId, await surfaces.revealSession(sessionId).catch(() => null)) }, logger: deps.logger, now: surfaces.now, @@ -150,7 +159,7 @@ export function createStructuredAgentSessionRestartResume( deps.store.getRecord(sessionId) ? structuredAgentSessionConversationFence(deps.store, sessionId) : null, - stillResumable: (marker) => derive([marker], 'may-be-held').candidates.length === 1 + stillResumable: (marker) => deriveAtSend([marker], 'may-be-held').candidates.length === 1 } /** One explicit action: reserve the offers, then continue each through `continueOne`, a few at @@ -194,7 +203,9 @@ export function createStructuredAgentSessionRestartResume( admission, consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) - return marker !== undefined && derive([marker], 'may-be-held').candidates.length === 1 + return ( + marker !== undefined && deriveAtSend([marker], 'may-be-held').candidates.length === 1 + ) }, resume: async (sessionId) => { const marker = markersBySession.get(sessionId) @@ -310,7 +321,12 @@ export function createStructuredAgentSessionRestartResume( listFailures: failures.list, // Do not let a teardown witness already captured in this host republish after explicit // dismissal. A later capture is a new interruption and may create a fresh offer normally. - dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), + // "Dismiss all" keeps what this host does not list, so it reveals every record's chat first. + dismiss: (sessionIds) => + failures.dismiss(sessionIds, async () => { + witnesses.clear() + await revealEvery() + }), continueAfterRestart, onAgentStarted: withdrawal.onAgentStarted } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 3c91a9732b9..9df4e392bc3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -79,6 +79,8 @@ export type StructuredAgentSessionResumeSetInput = { latestPrompt: (sessionId: string) => string /** Whether the chat moved on since the offer was taken; false when its journal is not open here. */ movedOn: (marker: AgentSessionResumeMarker) => boolean + /** Whether the chat was saved by a newer Orca: its whole database, or its journal's open. */ + savedByNewerOrca: (sessionId: string) => boolean /** * Whether the lease must be free. * @@ -119,6 +121,11 @@ export function structuredAgentSessionResumableSet( superseded.push(marker) continue } + // Nothing here can continue a chat a newer Orca saved, so it is not offered. Its offer is kept, + // not spent: the offers file is shared by every Orca on the host, and the newer one can act on it. + if (input.savedByNewerOrca(marker.sessionId)) { + continue + } const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) candidates.push({ sessionId: marker.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 93c33146743..9f00c69aca4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -136,7 +136,6 @@ export function claudeRecord( /** The fake journal's own shape, so callers can wire `appendItem` without reaching into `unknown`. * The code under test sees the real `AgentSessionJournal` type; tests see this. */ export type HarnessJournal = { - isReadOnly: boolean snapshot: () => { items: AgentJournalRenderItem[] submissions: AgentJournalSubmission[] @@ -150,12 +149,10 @@ export type HarnessSession = { journal: HarnessJournal; child: { fence: number } export function journal( items: AgentJournalRenderItem[], - isReadOnly = false, submissions: AgentJournalSubmission[] = [] ) { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only isReadOnly, snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store. return { - isReadOnly, snapshot: () => ({ items, submissions, cursor: { epoch: EPOCH, sequence: items.length } }), submissions: () => submissions, appendItem: async () => undefined @@ -219,12 +216,15 @@ export function resumableSet(input: { markers: AgentSessionResumeMarker[] items?: AgentJournalRenderItem[] chain?: AgentSessionRecord['providerHandleChain'] + /** Sessions a newer Orca saved. */ + savedByNewerOrca?: string[] }) { return structuredAgentSessionResumableSet({ markers: input.markers, getRecord: () => record(input.chain === undefined ? {} : { chain: input.chain }), supportsRecord: () => true, latestPrompt: () => 'fix the auth bug', - movedOn: () => false + movedOn: () => false, + savedByNewerOrca: (sessionId) => input.savedByNewerOrca?.includes(sessionId) === true }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts index 2284367c14b..1c17dc11e30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-wiring.ts @@ -8,14 +8,17 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-jour import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, - AgentSessionSendResult + AgentSessionSendResult, + AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { MAX_TIMER_DELAY_MS } from '../../../shared/timer-delay' import type { SendSettlementWaitOptions } from './structured-agent-session-send-settlement' export type StructuredAgentSessionRestartResumeSurfaces = { - revealSession: (sessionId: string) => Promise<{ readable: boolean }> + revealSession: ( + sessionId: string + ) => Promise<{ readable: boolean; openRefusal?: AgentSessionWireRefusal }> send: (input: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem @@ -41,7 +44,7 @@ export const STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER = /** Exactly the host members this binds. Structural, so the host satisfies it without declaring a * dependency, and nothing outside this list is reachable from here. */ type RestartResumeHostBindings = { - revealSession: (sessionId: string) => Promise<{ readable: boolean }> + revealSession: StructuredAgentSessionRestartResumeSurfaces['revealSession'] send: ( caller: { callerKey: string }, params: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index a2ed07db291..b16c7277fc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -288,7 +288,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([], false, [submission('msg-1', 'pending')]), + journal: journal([], [submission('msg-1', 'pending')]), child: { fence: 1 } } ] @@ -311,9 +311,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [ - submission('msg-1', 'pending') - ]), + journal: journal([turnItem('turn-0', 'completed')], [submission('msg-1', 'pending')]), child: { fence: 1 } } ] @@ -338,7 +336,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [queued]), + journal: journal([turnItem('turn-0', 'completed')], [queued]), child: { fence: 1 } } ] @@ -364,14 +362,14 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-0', 'completed')], false, [handedOver, queued]), + journal: journal([turnItem('turn-0', 'completed')], [handedOver, queued]), child: { fence: 1 } } ], [ 'session-running', { - journal: journal([turnItem('turn-1', 'running')], false, [queued]), + journal: journal([turnItem('turn-1', 'running')], [queued]), child: { fence: 1 } } ] @@ -397,9 +395,7 @@ describe('deriving what was working at teardown', () => { [ SESSION, { - journal: journal([turnItem('turn-1', 'running')], false, [ - submission('msg-1', 'accepted') - ]), + journal: journal([turnItem('turn-1', 'running')], [submission('msg-1', 'accepted')]), child: { fence: 1 } } ] @@ -455,7 +451,8 @@ describe('the resumable set', () => { getRecord: () => claudeRecord('5aed93d6-advanced-leaf'), supportsRecord: () => true, latestPrompt: () => '', - movedOn: () => false + movedOn: () => false, + savedByNewerOrca: () => false }) expect(candidates).toHaveLength(1) @@ -470,13 +467,33 @@ describe('the resumable set', () => { getRecord: () => claudeRecord(null, 'prov-session-2'), supportsRecord: () => true, latestPrompt: () => '', - movedOn: () => false + movedOn: () => false, + savedByNewerOrca: () => false }) expect(set.candidates).toEqual([]) expect(set.superseded).toEqual([forked]) }) + // Nothing here can continue a chat a newer Orca saved: it is not offered, and its offer is kept + // for the Orca that can act on it. + it("neither offers nor spends a newer Orca's chat, and offers it once this build can open it", () => { + const offer = marker() + expect(resumableSet({ markers: [offer], savedByNewerOrca: [SESSION] })).toEqual({ + candidates: [], + superseded: [] + }) + expect(resumableSet({ markers: [offer] }).candidates).toHaveLength(1) + }) + + // Being a newer Orca's chat does not keep a forked chat's offer alive: the fork still ends it. + it('still withdraws a forked chat a newer Orca saved', () => { + const forked = marker({ providerHandleRoot: 'codex:"other-thread"' }) + expect(resumableSet({ markers: [forked], savedByNewerOrca: [SESSION] }).superseded).toEqual([ + forked + ]) + }) + // An offer has no expiry, however old it is. it('still offers a months-old marker', () => { const monthsAgo = NOW - 90 * 24 * 60 * 60 * 1000 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 3046aa971f6..c95d8f6d7ba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -12,6 +12,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' @@ -42,9 +43,9 @@ export async function revealStructuredAgentSession( // Lease state is not consulted on purpose: this neither claims the lease nor spawns a child, so a // contested or reconciling chat still reveals and the send that follows adjudicates it. Refusing // here would hide the one view of a session a user needs when its ownership is in doubt. - const readable = await openConversation(sessionId).then( - () => true, - () => false + const openRefusal = await openConversation(sessionId).then( + () => null, + (error: unknown) => journalOpenRefusal(error) ) return { sessionId, @@ -52,7 +53,8 @@ export async function revealStructuredAgentSession( // to aim the tab publication at another workspace. workspaceId: record.location.workspaceId, agent: record.provider, - readable + readable: openRefusal === null, + ...(openRefusal ? { openRefusal } : {}) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index dc8eb111700..5a359deebad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -1,4 +1,7 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -478,6 +481,48 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) + it("keeps a newer Orca's item of an unknown kind, in its place, through a Codex provider hydration", async () => { + expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const message = (turnId: string) => ({ + provider: 'codex' as const, + threadId: HOST_TEST_THREAD, + turnId, + ordinal: 0 + }) + const newerRow = { provider: 'orca' as const, clientMessageId: 'newer-card' } + // A kind this build does not know; no provider's history holds it. + const newerBody: AgentJournalItemBody = JSON.parse( + JSON.stringify({ kind: 'plan-card', steps: [{ text: 'by a newer build' }] }) + ) + sink.appendItem(message('kept'), hostTestMessage('kept'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + sink.appendItem(newerRow, newerBody, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + sink.appendItem(message('drop'), hostTestMessage('drop'), { + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await host.flushStreamedEvents(HOST_TEST_SESSION) + const items = [{ identity: message('kept'), body: hostTestMessage('kept from provider') }] + rewind.mockImplementationOnce(async (input) => { + await input.onPrepared?.(items) + return { ok: true, items } + }) + + expect( + await host.rewind(caller, await params(agentJournalItemKey(message('drop')))) + ).toMatchObject({ ok: true }) + + expect( + (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ + itemId, + body + })) + ).toEqual([ + { itemId: agentJournalItemKey(message('kept')), body: hostTestMessage('kept from provider') }, + { itemId: agentJournalItemKey(newerRow), body: newerBody } + ]) + }) + it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const message = (turnId: string, ordinal = 0) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts index f95579d7750..ee9c69bf38c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.ts @@ -77,9 +77,6 @@ export async function rewindStructuredAgentSession( if (conversationCommandBlocked(ctx, record, context.readChildWork(sessionId))) { return rewindRefusal('busy') } - if (ctx.journal.isReadOnly) { - return rewindRefusal('unsupported') - } const snapshot = ctx.journal.snapshot() const providerKeys = new Map( snapshot.submissions.flatMap((submission) => 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 index f14393df874..429cb803794 100644 --- 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 @@ -131,15 +131,16 @@ function startAgent(): Promise { 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) + const journalDatabase = openTestJournalHostDatabase(root) + // The journal's open asks where the chat's per-chat file lives before it reads anything. + vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { + throw new Error('journal path unavailable') + }) host = new StructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), store, - adapter: { ...adapter(), historyFilePath }, - journalDatabase: openTestJournalHostDatabase(root), + adapter: adapter(), + journalDatabase, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index b71022b92c7..caabf348133 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -189,14 +189,14 @@ it('refuses a send over records a newer Orca wrote with the update words', async }) }) -it('restores a chat for reading from records a newer Orca wrote', async () => { +it('opens no chat from records a newer Orca wrote, and writes nothing trying', async () => { const { host, stateDirectory, leaseReconcileLogged } = await relaunch(true) const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() - expect(host.hasSession(SESSION)).toBe(true) + expect(host.hasSession(SESSION)).toBe(false) expect(leaseReconcileLogged).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 743a8e2287c..6409c544129 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -666,7 +666,7 @@ describe('StructuredAgentSessionStatusFeed', () => { expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) }) - it('invalidates cached status on unreadability and keeps record metadata live', async () => { + it('keeps record metadata live', async () => { const journal = await openJournal() await journal.appendItem( USER_IDENTITY, @@ -681,12 +681,6 @@ describe('StructuredAgentSessionStatusFeed', () => { type: 'status', session: { status: 'idle', model: 'second-model' } }) - const readOnly = vi.spyOn(journal, 'isReadOnly', 'get').mockReturnValue(true) - feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: null } }) - readOnly.mockRestore() - feed.publish(SESSION) - expect(events.at(-1)).toMatchObject({ type: 'status', session: { status: 'idle' } }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 04be126d418..24581c8fca2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -241,12 +241,8 @@ export class StructuredAgentSessionStatusFeed { private retireSettledChildrenOnNewTurn( sessionId: string, session: StatusFeedSession, - acceptedSendKey: string | null + acceptedSendKey: string ): void { - // An unreadable journal says nothing about the user's turns: the last send read stands. - if (acceptedSendKey === null) { - return - } const seen = this.acceptedSends.has(sessionId) const previous = this.acceptedSends.get(sessionId) this.acceptedSends.set(sessionId, acceptedSendKey) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts index 04ce86d36bc..87b440ef37a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-journal-projection.ts @@ -13,11 +13,9 @@ export type StructuredAgentSessionStatusState = ReturnType< export type StructuredAgentSessionJournalProjection = { epoch: string sequence: number - readOnly: boolean fence: number | undefined state: StructuredAgentSessionStatusState - /** Null for an unreadable journal, which says nothing about the user's turns. */ - acceptedSendKey: string | null + acceptedSendKey: string } export class StructuredAgentSessionJournalProjections { @@ -31,9 +29,7 @@ export class StructuredAgentSessionJournalProjections { journal: AgentSessionJournal, record: AgentSessionRecord | null ): StructuredAgentSessionJournalProjection { - // An unreadable journal projects as "no turn": the chat itself shows the reset. const cursor = journal.cursor() - const readOnly = journal.isReadOnly // The conversation's fence, which a child's end moves: its unanswered sends stop counting. const fence = record?.lease.runtimeFence let projection = this.byJournal.get(journal) @@ -41,24 +37,20 @@ export class StructuredAgentSessionJournalProjections { !projection || projection.epoch !== cursor.epoch || projection.sequence !== cursor.sequence || - projection.readOnly !== readOnly || projection.fence !== fence ) { // A journalled submission bumps `lastSequence`, so the send-time working // signal reaches the cache; the lease fence does not, hence the extra key. - const snapshot = readOnly ? null : journal.snapshot() + const snapshot = journal.snapshot() projection = { ...cursor, - readOnly, fence, state: projectStructuredAgentSessionStatusState( - snapshot?.items ?? [], - snapshot?.submissions ?? [], + snapshot.items, + snapshot.submissions, fence ), - acceptedSendKey: snapshot - ? newestAcceptedSendKey(cursor.epoch, snapshot.submissions ?? []) - : null + acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions) } this.byJournal.set(journal, projection) } 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 index 3881e8c429f..27ae0cb0608 100644 --- 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 @@ -125,7 +125,7 @@ describe('AgentSessionSubscribers', () => { ) subscribers.publish(SESSION, journal) subscribers.backgroundTasks(SESSION, null, 1) - subscribers.reset(SESSION, journal, 'epoch_changed', 1) + subscribers.snapshot(SESSION, journal, 1) expect(events.map((event) => ('hostNow' in event ? event.hostNow : null))).toEqual([ 1_001, 1_002, @@ -139,8 +139,8 @@ describe('AgentSessionSubscribers', () => { 'batch', 'batch', 'batch', - 'reset', - 'reset' + 'snapshot', + 'snapshot' ]) }) @@ -207,10 +207,9 @@ describe('AgentSessionSubscribers', () => { }) subscribers.publish(SESSION, journal) - subscribers.reset(SESSION, journal, 'epoch_changed', 1) subscribers.snapshot(SESSION, journal, 1) - expect(published).toEqual([SESSION, SESSION, SESSION]) + expect(published).toEqual([SESSION, SESSION]) }) it('settles a session nobody is reading, from running to idle', async () => { 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 index 1b7bd87a9f2..a61d5a57240 100644 --- 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 @@ -4,10 +4,7 @@ // 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 type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { AgentSessionBackgroundTaskState, AgentSessionSlashCommand, @@ -142,39 +139,18 @@ export class AgentSessionSubscribers { } } - /** Force every subscriber back to a bounded tail page — recovery, epoch - * rollover, an unreadable schema. */ - reset( - sessionId: string, - journal: AgentSessionJournal, - reason: AgentJournalResetReason, - fence: number, - backgroundTasks?: AgentSessionBackgroundTaskState | null - ): void { - this.replay(sessionId, journal, fence, backgroundTasks, { type: 'reset', reset: reason }) - } - + /** Every subscriber back to a bounded tail page. */ snapshot( sessionId: string, journal: AgentSessionJournal, fence: number, backgroundTasks?: AgentSessionBackgroundTaskState | null - ): void { - this.replay(sessionId, journal, fence, backgroundTasks, { type: 'snapshot' }) - } - - private replay( - sessionId: string, - journal: AgentSessionJournal, - fence: number, - backgroundTasks: AgentSessionBackgroundTaskState | null | undefined, - frame: { type: 'snapshot' } | { type: 'reset'; reset: AgentJournalResetReason } ): void { const page = readAgentSessionHydrationPage(journal, fence) const hostNow = this.now() for (const subscriber of this.subscribers(sessionId)) { this.emit(subscriber, { - ...frame, + type: 'snapshot', sessionId, page, fence, 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 index 1f8fc80c863..5a1ad69f613 100644 --- 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 @@ -544,6 +544,36 @@ describe('an unexpected provider exit', () => { replaceFailedSink() }) + // TypeScript accepts the body; the persisted reader rejects a call id that is only spaces. + it('stops a provider whose output would not read back, and the next send starts a child', async () => { + await attach() + + sink?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'Bash', input: null, callId: ' ' }] + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await vi.waitFor(() => { + expect(closeSession).toHaveBeenCalledWith(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' }) + }) + expect(hostErrors).toContainEqual(expect.objectContaining({ code: 'journal_row_rejected' })) + replaceFailedSink() + + // Nothing restarts it on its own; the next send does, and the chat still loads. + expect(acquire).toHaveBeenCalledOnce() + await startAgent() + expect(acquire).toHaveBeenCalledTimes(2) + emitTurnLifecycle('running', 2) + await host.flushAllStreamedEvents() + await reboot() + expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) + }) + it('settles a journal sink failure whose stop saw the provider root exit', async () => { await attach() // The lease follows the root, so its seen exit settles like a proven one. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts index a90a65c1c83..84339a7587d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turn-completion-feed.ts @@ -106,7 +106,7 @@ export class StructuredAgentSessionTurnCompletionFeed { return } if (baseline.epoch !== cursor.epoch || cursor.sequence < baseline.sequence) { - // Epoch replacement (rewind, repair, or legacy import) republishes history with a new + // Epoch replacement (rewind or legacy import) republishes history with a new // identity. It is not a provider edge, so re-baseline silently instead of announcing the // newest settled row as a fresh completion. baseline.epoch = cursor.epoch diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts new file mode 100644 index 00000000000..170f58eba2f --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts @@ -0,0 +1,115 @@ +// A restored chat whose open fails keeps its tab, so a phone on a headless host still lists it and +// its read says why, instead of the chat disappearing: damaged, or saved by a newer Orca. + +import { cp, rm } from 'node:fs/promises' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import { + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +const relaunchedRoots: string[] = [] + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A restarted process over the chat's files, its epoch row rewritten by `edit`. */ +async function relaunchWith( + edit: (db: Database.Database) => void +): Promise { + const before = hostTestState() + await attach() + await before.host.flushStreamedEvents(SESSION) + await before.store.renewLeases([]) + const relaunched = `${before.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(before.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + edit(openTestJournalHostDatabase(relaunched).db) + const store = await openTestAgentSessionRecordStore(relaunched) + const host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + now: () => NOW + }) + replaceHostTestState({ store, host }) + return host +} + +const damaged = (db: Database.Database) => updateTestJournalRowJson(db, SESSION, 1, '}{') +const savedByNewerOrca = (db: Database.Database) => { + const epochRow = liveTestJournalRows(db, SESSION).find((row) => row.seq === 1)! + const newer = { ...JSON.parse(epochRow.rowJson), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 } + updateTestJournalRowJson(db, SESSION, 1, JSON.stringify(newer)) +} + +it.each([ + ['damaged', damaged, 'journalCorrupt'], + ['saved by a newer Orca', savedByNewerOrca, 'journalWrittenByNewerOrca'] +] as const)( + 'lists a restored chat that could not be opened (%s), and its read and send are refused with why', + async (_why, edit, reason) => { + const host = await relaunchWith(edit) + const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId + const refusal = { code: 'agent_session_journal_unreadable', details: { reason } } + + await host.restoreReadableSessions([SESSION]) + + expect(host.hasSession(SESSION)).toBe(false) + expect(host.listSessionTabs()).toEqual([ + { sessionId: SESSION, workspaceId, agent: expect.any(String) } + ]) + // Every read is refused with why, and the host logs it once for the chat. + for (let read = 0; read < 3; read += 1) { + await expect(host.history({ sessionId: SESSION, direction: 'tail' })).rejects.toMatchObject({ + refusal + }) + } + const readFailureLogs = vi + .mocked(console.warn) + .mock.calls.filter(([line]) => String(line).includes('open-for-read')) + expect(readFailureLogs).toHaveLength(1) + const body = hostTestMessage('sent to a chat that cannot load') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: false, refusal }) + + await host.setSessionTabVisibility(SESSION, false) + expect(host.listSessionTabs()).toEqual([]) + await host.flushAllStreamedEvents() + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts index 11647f2eefd..217360673fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-working-at-teardown.ts @@ -178,8 +178,7 @@ export function structuredAgentSessionWorkingAtStop(input: { now: number }): AgentSessionResumeMarker | null { const { sessionId, session } = input - // A journal this host cannot read tells us nothing about what the turn was doing. - if (!session?.child || session.journal.isReadOnly) { + if (!session?.child) { return null } const snapshot = session.journal.snapshot() diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts index 9de1925791f..c110a935aea 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.test.ts @@ -24,6 +24,22 @@ describe('rewind recovery of newer durable records', () => { }) }) + // Retained by its place, never by its kind, so an updated Orca still has it after the rewind. + it('carries an item of a kind this build does not know as it was, and records it', () => { + const body = { kind: 'plan-card', title: 'by a newer build' } + expect(restoreRewindJournalBody(body)).toBe(body) + expect( + AgentSessionRewindRecordSchema.safeParse({ + operationId: 'op-1', + callerKey: 'caller-1', + itemId: 'codex:thread-1:turn-1:3', + expectedEpoch: 'epoch-1', + phase: 'prepared', + retained: [{ itemId: 'codex:thread-1:turn-1:1', body, observedAt: 1 }] + }).success + ).toBe(true) + }) + it('preserves background-task blocks across rewind recovery', () => { const body = { kind: 'message' as const, diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts index 99489b18769..f20ed4879c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-journal-body.ts @@ -1,4 +1,7 @@ -import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas' +import { + AGENT_JOURNAL_ITEM_BODY_KINDS, + isAdmissibleAgentJournalItemBody +} from '../../../shared/agent-session-journal-schemas' import { AGENT_JOURNAL_TURN_LIFECYCLE_STATES, AGENT_JOURNAL_TURN_OUTCOMES, @@ -9,9 +12,21 @@ import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types' type StoredBody = AgentSessionRewindRecord['retained'][number]['body'] +function hasKnownKind(body: StoredBody): body is AgentJournalItemBody { + return AGENT_JOURNAL_ITEM_BODY_KINDS.has(body.kind) +} + /** A row this build cannot place stays visible as a row, never invented turn or prompt state — - * and never as its stored JSON, which is Orca's record, not something a person reads. */ -export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody { + * and never as its stored JSON, which is Orca's record, not something a person reads. An item of + * a kind this build does not know is carried as it was: kept by its place, as every other row. */ +export function restoreRewindJournalBody(stored: StoredBody): AgentJournalItemBody { + if (!hasKnownKind(stored)) { + if (!isAdmissibleAgentJournalItemBody(stored)) { + throw new Error('agent_session_rewind:invalid-retained-body') + } + return stored + } + const body = stored let normalized: unknown = body // A placeholder, not a failure anyone can act on, so it carries no fact. const fallback = () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts index 5b47be7f9f8..43a2beee24c 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.test.ts @@ -96,6 +96,30 @@ describe('rewind keeps each retained row attributed', () => { expect(merged.at(-1)?.turnScope).toEqual({ kind: 'turn', turnItemId: commandTurn }) }) + it("keeps a newer Orca's row after a row the provider still holds, whatever that row's kind", () => { + // A kind this build does not know, which the provider now returns in its own words. + const future: AgentJournalItemBody = JSON.parse('{"kind":"future-item"}') + const newer: AgentJournalItemBody = JSON.parse('{"kind":"plan-card","n":4}') + const merged = mergeRetainedHostLifecycleRows( + [ + retained(codexKey('turn-a', 2), prose('ok')), + retained(codexKey('turn-a', 9), future), + retained(orcaKey('u4'), newer) + ], + [ + providerItem(codexKey('turn-a', 2), prose('ok')), + providerItem(codexKey('turn-a', 9), prose('provider now renders it')) + ] + ) + + expect(merged.map((row) => row.itemId)).toEqual([ + codexKey('turn-a', 2), + codexKey('turn-a', 9), + orcaKey('u4') + ]) + expect(merged[2]?.body).toEqual(newer) + }) + it('places a provider item the old epoch never held in the turn record for its provider turn', () => { const commandTurn = orcaKey('command-turn:cmd-1') const reference = [ diff --git a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts index 6496518b50b..f3e72578709 100644 --- a/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts +++ b/src/main/native-chat/agent-session-wire/structured-rewind-retained-host-rows.ts @@ -1,10 +1,12 @@ // Provider preflight returns provider items only. The host's lifecycle rows are its own record, so // a rewind that takes the provider list as the new epoch must splice those rows back beside the -// provider item each one followed. Provider items carry neither turn scope nor producer, so each -// keeps the ones its retained row held. +// provider item each one followed; so is an item of a kind a newer Orca wrote, which no provider +// holds. Provider items carry neither turn scope nor producer, so each keeps the ones its retained +// row held. import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity' import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AGENT_JOURNAL_ITEM_BODY_KINDS } from '../../../shared/agent-session-journal-schemas' import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -28,7 +30,7 @@ export function isRetainedHostLifecycleRow(item: RetainedRow): boolean { ) } -/** `reference` fixes where each host row sits; provider items are the ordered spine. */ +/** `reference` fixes where each carried row sits; provider items are the ordered spine. */ export function mergeRetainedHostLifecycleRows( reference: readonly RetainedRow[], providerItems: readonly RetainedRow[] @@ -38,9 +40,14 @@ export function mergeRetainedHostLifecycleRows( const rowsAfter = new Map() let anchor = -1 for (const item of reference) { - if (!isRetainedHostLifecycleRow(item)) { - anchor = spineIndex.get(item.itemId) ?? anchor - } else if (!spineIndex.has(item.itemId)) { + // Any row the provider still holds moves the anchor, carried or not, so a later row follows it. + const spine = spineIndex.get(item.itemId) + if (spine !== undefined) { + anchor = spine + } else if ( + isRetainedHostLifecycleRow(item) || + !AGENT_JOURNAL_ITEM_BODY_KINDS.has(item.body.kind) + ) { rowsAfter.set(anchor, [...(rowsAfter.get(anchor) ?? []), item]) } } diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts index a5d1d899723..9deb2e20fe6 100644 --- a/src/main/runtime/agent-session-recovery-capsule-entries.ts +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -211,3 +211,20 @@ export function shouldReplaceMarker( // regress a newer witness from another host. return incoming.teardownId === current.teardownId } + +/** Records "dismiss all" leaves as they were: this host does not list them (a newer Orca's chats). */ +export type KeepRecord = (marker: AgentSessionResumeMarker) => boolean + +/** What a "dismiss all" keeps, and how many pending offers it ends. */ +export function splitDismissedAll( + state: Pick, + keep: KeepRecord +): { kept: Pick; dismissedPending: number } { + const entries = state.entries.filter((entry) => keep(entry.marker)) + return { + kept: { entries, failed: state.failed.filter((failure) => keep(failure.marker)) }, + dismissedPending: state.entries.filter( + (entry) => entry.state === 'pending' && !keep(entry.marker) + ).length + } +} diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 80ed0bb726e..54b579a1caf 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -15,8 +15,10 @@ import { normalizeState, parseState, shouldReplaceMarker, + splitDismissedAll, type AgentSessionResumeFailureInput, type AgentSessionResumeFailureRecord, + type KeepRecord, type RecoveryCapsuleState, type RecoveryEntry } from './agent-session-recovery-capsule-entries' @@ -277,23 +279,24 @@ export class AgentSessionRecoveryCapsule { }) } - clearAll(now: number): Promise { + /** `keep` names records this host does not list; they stay for the Orca that can act on them. */ + clearAll(now: number, keep: KeepRecord = () => false): Promise { return withFileTransactionLock(this.filePath, async () => { - let entries: RecoveryEntry[] + let state: Pick try { - entries = normalizeState(await this.readState(), now).entries + state = normalizeState(await this.readState(), now) } catch { // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. await this.publish({ entries: [], failed: [] }, now, now) return 0 } - const pending = entries.filter((entry) => entry.state === 'pending') - // Dismiss is the explicit user request to forget every recovery record. An in-flight - // action may still finish, but its later complete/rollback becomes a no-op and cannot - // resurrect a row the user dismissed. - await this.publish({ entries: [], failed: [] }, now, now) - return pending.length + const { kept, dismissedPending } = splitDismissedAll(state, keep) + // Dismiss is the explicit user request to forget every recovery record it was shown. An + // in-flight action may still finish, but its later complete/rollback becomes a no-op and + // cannot resurrect a row the user dismissed. + await this.publish(kept, now, now) + return dismissedPending }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index d66ef3d2fa1..20abf16a69c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -30,6 +30,7 @@ import { closeStructuredAgentSessionChild } from '../../structured-agent-session import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { + liveTestJournalRows, openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' @@ -312,19 +313,34 @@ describe('the accessor', () => { expect(logged()).toEqual([denied, exhausted]) }) - it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { + it('refuses a damaged journal as unloadable at subscribe and send, and keeps its rows (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() - // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. updateTestJournalRowJson(openTestJournalHostDatabase(rig.root).db, SESSION, 2, '}{') await rig.restart() setStructuredAgentSessionHost(rig.host) const frames = await call('agentSession.subscribe', { sessionId: SESSION }) - expect(frames.some((frame) => !frame.ok)).toBe(false) - expect(frames.find((frame) => frame.ok)).toMatchObject({ result: { type: 'snapshot' } }) + expect(frames.find((frame) => !frame.ok)).toMatchObject({ + error: { + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + } + }) const fence = rig.store.getRecord(SESSION)!.lease.runtimeFence - expect((await rig.host.send(CALLER, restTestSend('after the repair', fence))).ok).toBe(true) + expect(await rig.host.send(CALLER, restTestSend('after the damage', fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } + }) + expect( + liveTestJournalRows(openTestJournalHostDatabase(rig.root).db, SESSION).find( + (row) => row.seq === 2 + )?.rowJson + ).toBe('}{') }) it('subscribes an old mobile client that holds first even when no agent can start (P2-06)', async () => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 903f8d440af..10b5610e353 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -56,7 +56,9 @@ export const STRUCTURED_AGENT_SESSION_REVEAL_METHODS = [ agent: revealed.agent, activate: true }) - return { ok: true as const, ...revealed } + // Named fields only: the host's reasons a journal did not open stay on the host. + const { sessionId, workspaceId, agent, readable } = revealed + return { ok: true as const, sessionId, workspaceId, agent, readable } } }) ] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index a09e82464ba..227654cb022 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -101,11 +101,11 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt() and snapshot() of a journal. journal: { - isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, - snapshot: () => ({ items: STATUS_ITEMS }) + snapshot: () => ({ items: STATUS_ITEMS, submissions: [] }) } as unknown as AgentSessionJournal, params: { location: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 4c7fe2103d7..9fe1f2083bb 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -526,7 +526,7 @@ describe('a create that reserves its tab', () => { }) describe('a chat tab over records a newer Orca wrote', () => { - it('opens a closed chat from history for reading', async () => { + it("opens a closed chat's tab from history, whose chat does not load", async () => { await createChat(HOST_TEST_SESSION) await host.close(HOST_TEST_SESSION, 'user-close') await host.setSessionTabVisibility(HOST_TEST_SESSION, false) @@ -538,7 +538,7 @@ describe('a chat tab over records a newer Orca wrote', () => { expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ ok: true, - result: { ok: true, readable: true } + result: { ok: true, readable: false } }) expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) diff --git a/src/main/runtime/structured-agent-session-failure-logging.test.ts b/src/main/runtime/structured-agent-session-failure-logging.test.ts index 9a8d9edb96f..14efe114f90 100644 --- a/src/main/runtime/structured-agent-session-failure-logging.test.ts +++ b/src/main/runtime/structured-agent-session-failure-logging.test.ts @@ -209,7 +209,7 @@ describe('host collaborators log with their own scope', () => { it('the queued-message drain logs a step that fails', async () => { const log = recordingStructuredAgentSessionLogger() - const journal = { isReadOnly: false, queuedMessages: { settlementOwed: () => true } } + const journal = { queuedMessages: { settlementOwed: () => true } } const drain = new StructuredAgentSessionQueuedMessageDrain( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a schedule past the owed-settlement check reads only these members. { diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index b4d0399d10d..9926aaba2dc 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -7,9 +7,9 @@ // does and pushes the same notifications and blocking requests back. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type * as JournalStoreFactory from '../native-chat/agent-session-journal/journal-store-factory' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -22,6 +22,32 @@ import { type StructuredCodexRpcHarness } from './structured-codex-session-rpc-test-harness' +const { journalOpenHold } = vi.hoisted(() => { + const journalOpenHold: { next: { entered: () => void; released: Promise } | null } = { + next: null + } + return { journalOpenHold } +}) + +// Holds the next journal open midway, so provider events land while the attach binds its journal. +vi.mock('../native-chat/agent-session-journal/journal-store-factory', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + openAgentSessionJournal: async ( + input: Parameters[0] + ) => { + const hold = journalOpenHold.next + journalOpenHold.next = null + if (hold) { + hold.entered() + await hold.released + } + return actual.openAgentSessionJournal(input) + } + } +}) + const journals = createTrackedJournalOpener() const WORKSPACE = 'workspace-1' @@ -72,23 +98,16 @@ describe('a structured codex session over agentSession.*', () => { }) it('joins an acquired attach through journal bind before draining final rows', async () => { - const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) - 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) - }) + await ensureStructuredAgentSessionHost(harness.hostConfig()) + const openEntered = Promise.withResolvers() + const openGate = Promise.withResolvers() + journalOpenHold.next = { entered: openEntered.resolve, released: openGate.promise } const creating = harness.ok<{ fence: number }>( 'agentSession.create', harness.createIntentParams() ) - await historyEntered.promise + await openEntered.promise harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) harness.codex.notify('item/started', { threadId: THREAD, @@ -108,7 +127,7 @@ describe('a structured codex session over agentSession.*', () => { }) await new Promise((resolve) => setImmediate(resolve)) const waitedForJournalBind = !stopped - historyGate.resolve() + openGate.resolve() await creating await stopping expect(waitedForJournalBind).toBe(true) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index ac6df4c4b2b..e0bfa5bbdd3 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -14,7 +14,10 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { + closeTestJournalHostDatabases, + SAVED_BY_NEWER_ORCA +} from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' @@ -269,7 +272,7 @@ describe('restoring the chat tabs open at quit', () => { { store: 'records a newer Orca wrote', newer: true, writesFail: false }, { store: 'a store whose writes keep failing', newer: false, writesFail: true } ])( - 'lists and reads every chat from $store, and writes nothing', + 'lists every chat from $store and writes nothing; it reads them unless a newer Orca saved them', async ({ newer, writesFail }) => { const records = [ chatRecord(CHAT_A), @@ -298,12 +301,17 @@ describe('restoring the chat tabs open at quit', () => { `agent-session:${CHAT_B}` ]) expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) - await expectHistory(CHAT_A) - await expectHistory(CHAT_B) - // A newer Orca's leases are adjudicated in memory, so nothing fails there. + // A newer Orca's leases are adjudicated in memory, so nothing fails there; its chats do not open. if (newer) { - expect(log.entries).toEqual([]) + for (const sessionId of [CHAT_A, CHAT_B]) { + await expect( + getStructuredAgentSessionHost()!.journalSnapshot(sessionId) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + } + expect(log.entries.map((entry) => entry.fields.scope)).not.toContain('lease-reconcile') } else { + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) expect(log.entries).toEqual([ expect.objectContaining({ fields: { diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx index 150b6452921..8e50bce5e9f 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.test.tsx @@ -7,6 +7,77 @@ import { NativeChatApprovalCard } from './NativeChatApprovalCard' afterEach(cleanup) describe('NativeChatApprovalCard', () => { + describe("a subject of a kind this build cannot draw: a newer Orca's", () => { + // A subject kind a newer build wrote; this build draws only plans. + const NEWER_SUBJECT = JSON.parse('{"kind":"diff","path":"a.ts"}') + const NEEDS_NEWER_ORCA = 'This request needs a newer version of Orca.' + + function renderNewer(detail: string | undefined) { + const onChoose = vi.fn() + const onCancel = vi.fn() + render( + + ) + return { onChoose, onCancel } + } + + it('with no detail: says so, answers nothing, and only its cancel reaches the host', () => { + const { onChoose, onCancel } = renderNewer(undefined) + expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy() + for (const label of ['Approve', 'Deny']) { + const option = screen.getByRole('button', { name: label }) + expect(option.hasAttribute('disabled')).toBe(true) + fireEvent.click(option) + } + expect(onChoose).not.toHaveBeenCalled() + const cancel = screen.getByRole('button', { name: 'Cancel' }) + expect(cancel.hasAttribute('disabled')).toBe(false) + fireEvent.click(cancel) + fireEvent.keyDown(screen.getByRole('group'), { key: 'Escape' }) + expect(onCancel).toHaveBeenCalledTimes(2) + }) + + it('with a detail: shows it, and still approves nothing', () => { + const { onChoose } = renderNewer('# Release\n- Run tests') + expect(document.querySelector('[data-native-chat-approval-detail]')?.textContent).toContain( + '# Release' + ) + expect(screen.getByText(NEEDS_NEWER_ORCA)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Approve' })) + expect(screen.getByRole('button', { name: 'Approve' }).hasAttribute('disabled')).toBe(true) + expect(onChoose).not.toHaveBeenCalled() + }) + + it('leaves a plan answerable, with no such line', () => { + const onChoose = vi.fn() + render( + + ) + expect(screen.queryByText(NEEDS_NEWER_ORCA)).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Approve plan' })) + expect(onChoose).toHaveBeenCalledWith('allow') + }) + }) + it('exposes cancellation while it owns the composer region', () => { const onCancel = vi.fn() diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index 99007d666a3..8ab0d6d822c 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -5,6 +5,10 @@ import { translate } from '@/i18n/i18n' import CommentMarkdown, { type CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { + isNewerApprovalSubject, + isPlanApprovalSubject +} from '../../../../shared/agent-session-approval-subject' import { NativeChatCodeBlock } from './NativeChatCodeBlock' import type { ChatApproval } from './native-chat-interactive-prompt' @@ -34,6 +38,8 @@ export function NativeChatApprovalCard({ allowFileUriLinks = false }: NativeChatApprovalCardProps): React.JSX.Element { const cardRef = useRef(null) + // A newer Orca's subject: its detail is shown, and only the card's cancel answers. + const newerSubject = isNewerApprovalSubject(approval.subject) const hasContext = Boolean( approval.description || approval.decisionReason || @@ -121,7 +127,7 @@ export function NativeChatApprovalCard({

) : null} - {approval.subject?.kind === 'plan' ? ( + {isPlanApprovalSubject(approval.subject) ? (
) : null} + {newerSubject ? ( +

+ {translate( + 'components.native-chat.approval.needsNewerOrca', + 'This request needs a newer version of Orca.' + )} +

+ ) : null}
) : null}
@@ -156,9 +170,10 @@ export function NativeChatApprovalCard({
- - {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} - { - composerRef.current?.focus() - }} - /> - - {!prompt && controller.threadGoal?.goal ? ( - void controller.threadGoal?.change(change)} - /> - ) : null} - {/* Prompt cards take the composer's slot, below the background-task dock. */} - {prompt && approval ? ( - void controller.respond(prompt, { kind: 'option', optionId })} - onCancel={cancelPrompt} - shouldFocus={props.isVisible && props.isFocusedGroup} - onLinkClick={onLinkClick} - allowFileUriLinks={onLinkClick !== undefined} - /> - ) : null} - {prompt && questionBody ? ( - ({ - question: question.question, - ...(question.header ? { header: question.header } : {}), - multiSelect: question.multiSelect, - options: question.options.map((option) => ({ - label: option.label, - ...(option.description ? { description: option.description } : {}) - })) - })) - }} - allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} - onAnswer={(answers) => { - const chosen = questions.map((question, questionIndex) => { - const answer = answers[questionIndex] - const other = answer?.other?.trim() - const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { - const optionId = question.options[optionIndex]?.id - return optionId ? [optionId] : [] - }) - return { questionId: question.id, optionIds, ...(other ? { other } : {}) } - }) - if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { - void controller.respond(prompt, { kind: 'answers', answers: chosen }) + {readFailedFinally ? null : ( + <> + + {/* Host-held drafts, never transcript rows. Above the status area, so running shells and agents sit next to the composer. */} + { + composerRef.current?.focus() + }} + /> + - ) : null} - {prompt ? null : ( - void controller.stop()} - steerQueued={controller.queuedMessages.steerNewest} - structuredTransport={structuredTransport} - launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} - /> + reconnecting={viewState.kind !== 'error' && readFailure !== null && !readFailure.named} + composerError={composerError} + isVisible={props.isVisible} + backgroundTasks={controller.backgroundTasks} + stopBackgroundTask={controller.stopBackgroundTask} + /> + {!prompt && controller.threadGoal?.goal ? ( + void controller.threadGoal?.change(change)} + /> + ) : null} + {/* Prompt cards take the composer's slot, below the background-task dock. */} + {prompt && approval ? ( + void controller.respond(prompt, { kind: 'option', optionId })} + onCancel={cancelPrompt} + shouldFocus={!promptsUnanswerable && props.isVisible && props.isFocusedGroup} + onLinkClick={onLinkClick} + allowFileUriLinks={onLinkClick !== undefined} + /> + ) : null} + {prompt && questionBody ? ( + ({ + question: question.question, + ...(question.header ? { header: question.header } : {}), + multiSelect: question.multiSelect, + options: question.options.map((option) => ({ + label: option.label, + ...(option.description ? { description: option.description } : {}) + })) + })) + }} + allowOther={questions.map((question) => Boolean(question.freeTextQuestionId))} + onAnswer={(answers) => { + const chosen = questions.map((question, questionIndex) => { + const answer = answers[questionIndex] + const other = answer?.other?.trim() + const optionIds = (answer?.indices ?? []).flatMap((optionIndex) => { + const optionId = question.options[optionIndex]?.id + return optionId ? [optionId] : [] + }) + return { questionId: question.id, optionIds, ...(other ? { other } : {}) } + }) + if (chosen.every((answer) => answer.optionIds.length > 0 || answer.other)) { + void controller.respond(prompt, { kind: 'answers', answers: chosen }) + } + }} + onCancel={cancelPrompt} + /> + ) : null} + {composerShown ? ( + void controller.stop()} + steerQueued={controller.queuedMessages.steerNewest} + structuredTransport={structuredTransport} + launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft: true }} + /> + ) : null} + )} {paneCommands.menu} diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 462c63182b3..706777fe645 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -58,6 +58,10 @@ const SENTENCES: Record string> = { 'components.native-chat.writeNotice.updateOrcaToKeepUsing', COPY.updateOrcaToKeepUsing ), + chatSavedByNewerOrca: () => + translate('components.native-chat.writeNotice.chatSavedByNewerOrca', COPY.chatSavedByNewerOrca), + updateOrcaToOpenChat: () => + translate('components.native-chat.writeNotice.updateOrcaToOpenChat', COPY.updateOrcaToOpenChat), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), notAvailable: () => translate('components.native-chat.writeNotice.notAvailable', COPY.notAvailable), diff --git a/src/renderer/src/components/native-chat/native-chat-view-state.ts b/src/renderer/src/components/native-chat/native-chat-view-state.ts index 89d8de5d011..98051945865 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-state.ts @@ -20,8 +20,9 @@ export type NativeChatViewState = * Decide which surface to render. Any renderable message wins over loading/empty so optimistic * first sends never get replaced while transcript discovery catches up. Where the read retries on * its own (the structured chat), messages win over an error too: a read that fails after the - * transcript loaded keeps it, and the error reaches the composer's error line. The terminal-backed - * read does not retry, and its only messages on error are local echoes, so its error takes the pane. + * transcript loaded keeps it, and the error reaches the composer's error line. A read that does not + * retry takes the pane: the terminal-backed one, whose only messages on error are local echoes, and + * a structured read that failed for good. */ export function selectNativeChatViewState( session: NativeChatSession, 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 index eaee915ac68..3424b39dc3c 100644 --- 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 @@ -316,45 +316,49 @@ describe('structured agent-session read transport unattached refusals', () => { } }) - it('hands the pane the refusal a read met, and stops reconnecting only past damage', async () => { - vi.useFakeTimers() - try { - const journalRefusal = (reason: string) => ({ - code: 'runtime_error', - message: 'agent_session_journal_unreadable', - data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } - }) - const applyError = vi.fn() - const transport = startWithHydration(async () => undefined, applyError) - await flushPromises() - expect(attempts).toHaveLength(1) + it.each(['journalCorrupt', 'journalWrittenByNewerOrca'])( + 'hands the pane the refusal a read met, and stops reconnecting only past %s', + async (final) => { + vi.useFakeTimers() + try { + const journalRefusal = (reason: string) => ({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details: { reason } } } + }) + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + expect(attempts).toHaveLength(1) - // An open that can clear keeps reconnecting. - attempts[0].onError(journalRefusal('journalUnavailable')) - attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) - await flushPromises() - expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalUnavailable' } - }) - await vi.advanceTimersByTimeAsync(750) - expect(attempts).toHaveLength(2) + // An open that can clear keeps reconnecting. + attempts[0].onError(journalRefusal('journalUnavailable')) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + }) + await vi.advanceTimersByTimeAsync(750) + expect(attempts).toHaveLength(2) - // Damage no retry reads past: decided from the reason, not the message, which is the same. - attempts[1].onError(journalRefusal('journalCorrupt')) - attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) - await flushPromises() - expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalCorrupt' } - }) - await vi.advanceTimersByTimeAsync(60_000) - expect(attempts).toHaveLength(2) - transport.dispose() - } finally { - vi.useRealTimers() + // Damage, or a newer Orca's chat, no retry reads past: decided from the reason, not the + // message, which is the same. + attempts[1].onError(journalRefusal(final)) + attempts[1].closed.resolve({ unsubscribe: attempts[1].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenLastCalledWith('agent_session_journal_unreadable', { + code: 'agent_session_journal_unreadable', + details: { reason: final } + }) + await vi.advanceTimersByTimeAsync(60_000) + expect(attempts).toHaveLength(2) + transport.dispose() + } finally { + vi.useRealTimers() + } } - }) + ) it('reads a thrown hydrate refusal the same way, and a new run reads again', async () => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx index ebfd23060bb..bef56bf7f81 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.queued-gating.test.tsx @@ -158,6 +158,46 @@ describe('against a capable host', () => { }) }) + // The host's queue would hold a send behind a prompt nothing here can settle. + it('sends immediately while every pending prompt is one this build cannot answer', () => { + const approval = (subject: Record): AgentJournalRenderItem => + JSON.parse( + JSON.stringify({ + itemId: `approval-${String(subject.kind)}`, + revision: 1, + sequence: 2, + observedAt: 1, + body: { + kind: 'approval', + title: 'Review', + detail: null, + subject, + options: [{ id: 'allow', label: 'Approve' }], + resolution: { + state: 'pending', + selectedOptionId: null, + resolvedBy: null, + resolvedAt: null + } + } + }) + ) + const newer = approval({ kind: 'diff', path: 'a.ts' }) + items = [newer] + render() + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: false + }) + mocks.outboxArgs.length = 0 + items = [newer, approval({ kind: 'plan', text: 'do it' })] + render() + expect(mocks.outboxArgs.at(-1)?.queueDelivery).toEqual({ + capability: 'supported', + enabled: true + }) + }) + it('Stop is a plain cancel: drafts stay as cards and no text lands in the composer', async () => { queuedMessages = [{ ...draft('draft-1'), paused: true }] const { result } = render(false) 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 index 0f126fe0559..6d73bc3ce05 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.ts @@ -38,6 +38,7 @@ import { useStructuredAgentSessionQueuedMessages } from './use-structured-agent- import { outboxOutsideQueuedCards } from './structured-agent-session-queued-cards' import { structuredAgentSessionStartFailureFacts } from './structured-agent-session-delivery-notices' import { hostStatesTurnScopes } from '../../../../shared/native-chat-turn-membership' +import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-session-approval-subject' import { withNativeChatCutTurnNotices } from '../../../../shared/native-chat-cut-turn-notice' import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' @@ -117,6 +118,14 @@ export function useStructuredAgentSession(args: { () => (transportState.queuedMessages ?? []).map((message) => message.messageId), [transportState.queuedMessages] ) + const prompts = pendingStructuredSessionPrompts(transportState.journalItems) + // A host's queue waits on any pending prompt, and nothing here can settle one this build cannot + // answer: the send must start a turn, after which the card's cancel works. + const queueEnabled = queueFollowUps && !pendingPromptsAllUnanswerableHere(prompts) + const queueDelivery = useMemo( + () => ({ capability: queueCapability, enabled: queueEnabled }), + [queueCapability, queueEnabled] + ) const outboxController = useStructuredAgentSessionOutbox({ sessionId, target, @@ -124,7 +133,7 @@ export function useStructuredAgentSession(args: { submissions: transportState.submissions, journalItems: transportState.journalItems, composerScopeKey, - queueDelivery: { capability: queueCapability, enabled: queueFollowUps }, + queueDelivery, queuedMessageIds }) @@ -145,7 +154,6 @@ export function useStructuredAgentSession(args: { enabled: providerVisible }) - const prompts = pendingStructuredSessionPrompts(transportState.journalItems) const { outbox } = outboxController // A host that takes a Stop naming no turn gets Stop from the send until the work settles; every // Stop before a turn opens needs that form. An older host can stop only a turn it has opened. @@ -159,12 +167,8 @@ export function useStructuredAgentSession(args: { // A queued send is a card, never a transcript bubble. const isWorking = transportState.isWorking const transcriptOutbox = useMemo( - () => - outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, { - capability: queueCapability, - enabled: queueFollowUps - }), - [isWorking, outbox, queueCapability, queueFollowUps, queuedMessageIds] + () => outboxOutsideQueuedCards(outbox, queuedMessageIds, isWorking, queueDelivery), + [isWorking, outbox, queueDelivery, queuedMessageIds] ) // What the transcript reads: the journal plus the one notice a cut turn with no row gets. const transcriptItems = useMemo( diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index cc4ab94202a..3cc1ed29e59 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2863,6 +2863,7 @@ "backgroundTasksRunning": "Background tasks are still running.", "capacity": "Orca has received too many requests in the last day.", "chatNotFound": "Orca can't find this chat.", + "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", "clearUnfinished": "The last /clear didn't finish.", "commandRunning": "A /compact or /clear is still running.", "conversationCleared": "This conversation has been cleared.", @@ -2905,6 +2906,7 @@ "unreachable": "Orca couldn't reach the agent.", "unsupported": "This needs a newer Orca on the computer running this chat. Update Orca there, then try again.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", + "updateOrcaToOpenChat": "Update Orca to open it.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4456d126c93..17a14f15eb1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17787,6 +17787,8 @@ "historyUnavailable": "Orca couldn't open this chat's history right now.", "savedByNewerOrca": "Chats were saved by a newer Orca.", "updateOrcaToKeepUsing": "Update Orca to keep using them.", + "chatSavedByNewerOrca": "This chat was saved by a newer Orca.", + "updateOrcaToOpenChat": "Update Orca to open it.", "unsupported": "This needs a newer Orca on the computer running this chat. Update Orca there, then try again.", "notAvailable": "This isn't available in this chat.", "unreachable": "Orca couldn't reach the agent.", @@ -18106,6 +18108,7 @@ "reason": "Reason", "blockedPath": "Blocked path", "askRule": "Ask rule", + "needsNewerOrca": "This request needs a newer version of Orca.", "plan": { "file": "Plan file" } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4f002551643..3b0588cae3d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -17565,6 +17565,8 @@ "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", + "chatSavedByNewerOrca": "Este chat se guardó con una versión más reciente de Orca.", + "updateOrcaToOpenChat": "Actualice Orca para abrirlo.", "unsupported": "Esto requiere una versión más reciente de Orca en el equipo que ejecuta este chat. Actualice Orca allí y vuelva a intentarlo.", "notAvailable": "Esto no está disponible en este chat.", "unreachable": "Orca no pudo comunicarse con el agente.", @@ -17789,6 +17791,7 @@ "sending": "Enviando…" }, "approval": { + "needsNewerOrca": "Esta solicitud necesita una versión más reciente de Orca.", "title": "¿Permitir {{value0}}?", "allow": "Permitir", "deny": "Denegar", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index d4c92d9fa99..64eff0891e4 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17664,6 +17664,8 @@ "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", + "chatSavedByNewerOrca": "Ce chat a été enregistré par une version plus récente d'Orca.", + "updateOrcaToOpenChat": "Mettez à jour Orca pour l'ouvrir.", "unsupported": "Cela nécessite une version plus récente d'Orca sur l'ordinateur qui exécute ce chat. Mettez Orca à jour sur cet ordinateur, puis réessayez.", "notAvailable": "Ceci n'est pas disponible dans ce chat.", "unreachable": "Orca n'a pas pu joindre l'agent.", @@ -17888,6 +17890,7 @@ "sending": "Envoi…" }, "approval": { + "needsNewerOrca": "Cette demande nécessite une version plus récente d'Orca.", "title": "Autoriser {{value0}} ?", "allow": "Autoriser", "deny": "Refuser", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 2a933088bc5..89b2d018e9e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17600,6 +17600,8 @@ "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", + "chatSavedByNewerOrca": "このチャットは新しいバージョンの Orca で保存されました。", + "updateOrcaToOpenChat": "開くには Orca を更新してください。", "unsupported": "このチャットを実行しているコンピューターに新しいバージョンの Orca が必要です。そのコンピューターで Orca を更新してから、もう一度お試しください。", "notAvailable": "このチャットでは利用できません。", "unreachable": "Orca は Agent に接続できませんでした。", @@ -17824,6 +17826,7 @@ "sending": "送信中…" }, "approval": { + "needsNewerOrca": "このリクエストには新しいバージョンの Orca が必要です。", "title": "{{value0}} を許可しますか?", "allow": "許可する", "deny": "拒否", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 47f09615666..6dc0bbf56fe 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17600,6 +17600,8 @@ "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", + "chatSavedByNewerOrca": "이 채팅은 최신 버전의 Orca에서 저장되었습니다.", + "updateOrcaToOpenChat": "열려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 컴퓨터에 최신 버전의 Orca가 필요합니다. 해당 컴퓨터에서 Orca를 업데이트한 후 다시 시도하세요.", "notAvailable": "이 채팅에서는 사용할 수 없습니다.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", @@ -17824,6 +17826,7 @@ "sending": "전송 중…" }, "approval": { + "needsNewerOrca": "이 요청에는 최신 버전의 Orca가 필요합니다.", "title": "{{value0}}을(를) 허용하시겠습니까?", "allow": "허용", "deny": "거부", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 04c5fa3f5c6..ec2c1c66e98 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17565,6 +17565,8 @@ "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", + "chatSavedByNewerOrca": "此聊天由更新版本的 Orca 保存。", + "updateOrcaToOpenChat": "请更新 Orca 以打开它。", "unsupported": "运行此聊天的计算机需要更新版本的 Orca。请在该计算机上更新 Orca 后重试。", "notAvailable": "此聊天中无法使用此功能。", "unreachable": "Orca 无法连接到智能体。", @@ -17789,6 +17791,7 @@ "sending": "正在发送…" }, "approval": { + "needsNewerOrca": "此请求需要更新版本的 Orca。", "title": "允许 {{value0}}?", "allow": "允许", "deny": "拒绝", diff --git a/src/shared/agent-session-approval-subject.test.ts b/src/shared/agent-session-approval-subject.test.ts new file mode 100644 index 00000000000..2ad176e6932 --- /dev/null +++ b/src/shared/agent-session-approval-subject.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest' +import { + DRAWN_APPROVAL_SUBJECT_KINDS, + isNewerApprovalSubject, + pendingPromptsAllUnanswerableHere +} from './agent-session-approval-subject' +import type { AgentJournalItemBody } from './agent-session-journal-types' +import { AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS } from './agent-session-journal-schemas' + +it('draws exactly the subject kinds the journal schema knows', () => { + expect([...DRAWN_APPROVAL_SUBJECT_KINDS].sort()).toEqual( + [...AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS].sort() + ) +}) + +it("reads only a subject of a kind it does not draw as a newer build's", () => { + expect(isNewerApprovalSubject(undefined)).toBe(false) + expect(isNewerApprovalSubject({ kind: 'plan' })).toBe(false) + expect(isNewerApprovalSubject({ kind: 'diff' })).toBe(true) +}) + +function pending(body: Record): { body: AgentJournalItemBody } { + return JSON.parse( + JSON.stringify({ + body: { + ...body, + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + }) + ) +} +const NEWER = pending({ + kind: 'approval', + title: 'Review', + detail: null, + subject: { kind: 'diff' }, + options: [{ id: 'allow', label: 'Approve' }] +}) +const PLAN = pending({ + kind: 'approval', + title: 'Plan', + detail: null, + subject: { kind: 'plan', text: 'do it' }, + options: [{ id: 'allow', label: 'Approve' }] +}) +const QUESTION = pending({ kind: 'question', title: 'Which?', options: [] }) + +it('holds only while every pending prompt is an approval this build cannot answer', () => { + expect(pendingPromptsAllUnanswerableHere([])).toBe(false) + expect(pendingPromptsAllUnanswerableHere([NEWER])).toBe(true) + expect(pendingPromptsAllUnanswerableHere([NEWER, NEWER])).toBe(true) + expect(pendingPromptsAllUnanswerableHere([NEWER, PLAN])).toBe(false) + expect(pendingPromptsAllUnanswerableHere([QUESTION, NEWER])).toBe(false) + const cancelled = JSON.parse( + JSON.stringify(NEWER).replace('"state":"pending"', '"state":"cancelled"') + ) + expect(pendingPromptsAllUnanswerableHere([cancelled, PLAN])).toBe(false) + expect(pendingPromptsAllUnanswerableHere([cancelled])).toBe(false) +}) diff --git a/src/shared/agent-session-approval-subject.ts b/src/shared/agent-session-approval-subject.ts new file mode 100644 index 00000000000..0c6354f8398 --- /dev/null +++ b/src/shared/agent-session-approval-subject.ts @@ -0,0 +1,45 @@ +// An approval subject of a kind this build cannot draw is a newer Orca's. It is carried as it was, +// its card shows the approval's `detail`, and only the card's cancel answers it: nobody approves +// what this build cannot show. On Claude the cancel denies the request and the turn goes on; on +// Codex it ends the turn. + +import type { + AgentJournalApprovalSubject, + AgentJournalItemBody, + AgentJournalPlanApprovalSubject +} from './agent-session-journal-types' + +/** The subject kinds this build draws: the known arms of the journal schema's approval subject. + * A copy, kept equal by a test, so clients that never load the schema module can read it. */ +export const DRAWN_APPROVAL_SUBJECT_KINDS: ReadonlySet = new Set(['plan']) + +export function isPlanApprovalSubject( + subject: AgentJournalApprovalSubject | undefined +): subject is AgentJournalPlanApprovalSubject { + return subject?.kind === 'plan' +} + +export function isNewerApprovalSubject(subject: { kind: string } | undefined): boolean { + return subject !== undefined && !DRAWN_APPROVAL_SUBJECT_KINDS.has(subject.kind) +} + +/** Every pending prompt is an approval this build cannot answer. Nothing here can settle them, so + * the composer stays open and a send starts a turn now instead of queueing behind them; with a + * turn running, the card's cancel works. */ +export function pendingPromptsAllUnanswerableHere( + items: readonly { body: AgentJournalItemBody }[] +): boolean { + let found = false + for (const { body } of items) { + if ( + (body.kind === 'approval' || body.kind === 'question') && + body.resolution.state === 'pending' + ) { + if (body.kind === 'question' || !isNewerApprovalSubject(body.subject)) { + return false + } + found = true + } + } + return found +} diff --git a/src/shared/agent-session-journal-body-admission.test.ts b/src/shared/agent-session-journal-body-admission.test.ts new file mode 100644 index 00000000000..d5edff6dc85 --- /dev/null +++ b/src/shared/agent-session-journal-body-admission.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { + readAgentJournalItemBody, + readAgentJournalMessageBody +} from './agent-session-journal-body-admission' + +const RESOLUTION = { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } +const QUESTION_ENTRY = { + id: 'q-1', + question: 'Which lane?', + multiSelect: false, + options: [{ id: 'o-1', label: 'First' }], + freeTextQuestionId: 'q-1-other' +} +const QUESTION = { + kind: 'question', + question: 'Which lane?', + options: [{ id: 'o-1', label: 'First' }], + questions: [QUESTION_ENTRY], + resolution: RESOLUTION +} +const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: RESOLUTION, + subject: { kind: 'plan', text: 'Step one' } +} +const TURN_STATUS = { + kind: 'status', + text: 'Turn started', + turnLifecycle: { turnId: 't-1', state: 'running', startedAt: 5 } +} +const GOAL = { + objective: 'Ship it', + status: 'active', + tokenBudget: null, + tokensUsed: 1, + timeUsedSeconds: 1, + createdAt: 1, + updatedAt: 1 +} + +describe('a body this build reads', () => { + it.each([ + ['a question', QUESTION], + ['a plan approval', PLAN_APPROVAL], + ['a turn lifecycle', TURN_STATUS], + ['a goal change', { kind: 'status', text: 'Goal', threadGoal: { state: 'set', goal: GOAL } }], + [ + 'a key it does not know, in a question and an option too', + { + ...QUESTION, + next: 1, + options: [{ id: 'o-1', label: 'First', shortcut: 'f' }], + questions: [{ ...QUESTION_ENTRY, hint: 'Pick one' }] + } + ], + [ + 'a block type it does not know', + { kind: 'message', role: 'assistant', blocks: [{ type: 'chart' }] } + ], + [ + 'a goal state it does not know', + { kind: 'status', text: 'Goal', threadGoal: { state: 'paused' } } + ], + ['a turn state it does not know', { kind: 'turn', turnId: 't', state: 'handed-off' }], + ['a body kind it does not know', { kind: 'plan-card', steps: [{ text: 'by a newer build' }] }], + [ + 'a plan subject kind it does not know', + { ...PLAN_APPROVAL, subject: { kind: 'diff', path: 'a.ts' } } + ] + ])('is readable: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('readable') + }) +}) + +describe('anything else that fails: damage', () => { + it.each([ + [ + "a turn's context usage of a kind it does not know, which the row reader drops first", + { kind: 'turn', turnId: 't', state: 'done', contextUsage: { used: { kind: 'measured' } } } + ], + ['a body that is not an object', null], + ['a body that is a list', [{ kind: 'status' }]], + ['no kind', { text: 'x' }], + ['an empty kind', { kind: '' }], + ['a kind of only whitespace', { kind: ' ' }], + ['a block type of only whitespace', { kind: 'message', role: 'user', blocks: [{ type: ' ' }] }], + [ + 'a plan subject kind of only whitespace', + { ...PLAN_APPROVAL, subject: { kind: ' ', text: 'x' } } + ], + ['a kind that is not a string', { kind: 7 }], + [ + 'a nested literal that is not a string', + { ...PLAN_APPROVAL, subject: { kind: 5, text: 'x' } } + ], + ['questions that are not a list', { ...QUESTION, questions: null }], + [ + 'a multi-select flag that is not a boolean', + { ...QUESTION, questions: [{ ...QUESTION_ENTRY, multiSelect: 'yes' }] } + ], + [ + 'a question option with no id', + { ...QUESTION, questions: [{ ...QUESTION_ENTRY, options: [{ label: 'First' }] }] } + ], + ['a free-text question id that is not a string', { ...QUESTION, freeTextQuestionId: 4 }], + ['a turn lifecycle with no turn', { ...TURN_STATUS, turnLifecycle: { state: 'running' } }], + [ + 'a turn lifecycle whose turn is a number', + { ...TURN_STATUS, turnLifecycle: { turnId: 7, state: 'running' } } + ], + [ + 'a goal with no token count', + { + kind: 'status', + text: 'Goal', + threadGoal: { state: 'set', goal: { ...GOAL, tokensUsed: null } } + } + ], + ['a goal change with no goal', { kind: 'status', text: 'Goal', threadGoal: { state: 'set' } }], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }], + ['question options that are not a list', { ...QUESTION, options: null }], + ['a question without its resolution', { ...QUESTION, resolution: undefined }], + ['a diff whose patch is broken', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + ['a turn with no state', { kind: 'turn', turnId: 't' }], + ['a negative turn duration', { kind: 'turn', turnId: 't', state: 'done', durationMs: -1 }], + ['a failure fact with no kind', { kind: 'status', text: 'Failed', failure: { kind: '' } }], + [ + 'a tool call id that is only spaces', + { kind: 'tool-call', name: 'Read', state: 'done', callId: ' ' } + ], + [ + 'a known block missing its text', + { kind: 'message', role: 'user', blocks: [{ type: 'text' }] } + ], + ['a block type that is not a string', { kind: 'message', role: 'user', blocks: [{ type: 5 }] }] + ])('is malformed: %s', (_name, body) => { + expect(readAgentJournalItemBody(body)).toBe('malformed') + }) +}) + +describe("a submission's body", () => { + it('reads a message, a newer kind as unreadable, and another known kind as damage', () => { + expect(readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [] })).toBe( + 'readable' + ) + expect(readAgentJournalMessageBody({ kind: 'voice-note', clip: 'x' })).toBe('unreadable') + expect(readAgentJournalMessageBody({ kind: ' ' })).toBe('malformed') + expect(readAgentJournalMessageBody({ kind: 'status', text: 'x' })).toBe('malformed') + expect( + readAgentJournalMessageBody({ kind: 'message', role: 'user', blocks: [{ type: 'text' }] }) + ).toBe('malformed') + }) +}) diff --git a/src/shared/agent-session-journal-body-admission.ts b/src/shared/agent-session-journal-body-admission.ts new file mode 100644 index 00000000000..42d0b8f6376 --- /dev/null +++ b/src/shared/agent-session-journal-body-admission.ts @@ -0,0 +1,30 @@ +// Reading a persisted body by the extension rule in agent-session-journal-schemas.ts: it parses (a +// body, block, goal state or approval subject of a kind this build does not know included), or it +// is damage. A sent message is the one closed set: a kind this build does not know is a newer +// build's. + +import { + AGENT_JOURNAL_ITEM_BODY_KINDS, + isAdmissibleAgentJournalItemBody, + isAdmissibleAgentJournalMessageBody +} from './agent-session-journal-schemas' +import { isJournalTag } from './agent-session-journal-open-union' + +/** `unreadable` is never damage: the caller keeps the row and fails the load as a newer Orca's + * chat, as for a newer `v`. */ +export type AgentJournalContentVerdict = 'readable' | 'unreadable' | 'malformed' + +export function readAgentJournalItemBody(body: unknown): AgentJournalContentVerdict { + return isAdmissibleAgentJournalItemBody(body) ? 'readable' : 'malformed' +} + +/** The same for a submission's body, which only a user message may be. A kind this build does not + * know is a newer build's, never sent again here. */ +export function readAgentJournalMessageBody(body: unknown): AgentJournalContentVerdict { + const verdict = readAgentJournalItemBody(body) + if (verdict !== 'readable' || isAdmissibleAgentJournalMessageBody(body)) { + return verdict + } + const kind = typeof body === 'object' && body !== null && 'kind' in body ? body.kind : undefined + return isJournalTag(kind) && !AGENT_JOURNAL_ITEM_BODY_KINDS.has(kind) ? 'unreadable' : 'malformed' +} diff --git a/src/shared/agent-session-journal-open-union.ts b/src/shared/agent-session-journal-open-union.ts new file mode 100644 index 00000000000..60500173838 --- /dev/null +++ b/src/shared/agent-session-journal-open-union.ts @@ -0,0 +1,32 @@ +// A discriminated union that reads a tag this build does not know instead of failing on it: the +// journal's blocks, goal states, approval subjects and item bodies, so a newer build can add one. + +import { z } from 'zod' + +/** A tag a newer build could have written: a string with something other than whitespace in it. */ +export function isJournalTag(value: unknown): value is string { + return typeof value === 'string' && value.trim() !== '' +} + +/** Tags a discriminated union knows, read off it so the two can never disagree. */ +export function knownTags(union: z.ZodDiscriminatedUnion): ReadonlySet { + const key = union.def.discriminator + return new Set( + union.options.flatMap((option) => { + const tag = option instanceof z.ZodObject ? option.shape[key] : undefined + return tag instanceof z.ZodLiteral ? [...tag.values].map(String) : [] + }) + ) +} + +/** A discriminated union a tag this build does not know still reads through: kept as-is, for a + * reader to skip. A tag of only whitespace is damage, as an empty one is. The catch-all aborts, so + * a known arm's own failure still reaches the reader. */ +export function openDiscriminatedUnion(known: T) { + const key = known.def.discriminator + const tags = knownTags(known) + const unknown = z + .object({ [key]: z.string().regex(/\S/) }) + .refine((value) => !tags.has(value[key] ?? ''), { abort: true }) + return z.union([known, unknown]) +} diff --git a/src/shared/agent-session-journal-schemas.ts b/src/shared/agent-session-journal-schemas.ts index 035bb7d1d01..cb5e0e573d7 100644 --- a/src/shared/agent-session-journal-schemas.ts +++ b/src/shared/agent-session-journal-schemas.ts @@ -5,16 +5,29 @@ // 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 the row is -// rejected at replay, where a repair can delete it, instead of throwing +// refused at replay, where the chat fails to load, 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). +// +// A newer writer extends a body only by new keys, new kinds of an open union, or a bumped row `v`, +// never by changing a field's type, a bound or a closed set this build checks (the closed sets are +// pinned in journal-closed-sets.test.ts). Open unions (a body's `kind`, a block's `type`, a goal's +// `state`, an approval subject's `kind`) keep a value this build does not know as-is and nobody +// draws it; the chat stays writable. Unreadable context usage is dropped +// (journal-row-unusable-annotations.ts). Any other failure is damage: the chat fails to load and +// nothing is deleted. A new value of an open string (turn, resolution, tool or goal `state`) is +// read as-is too, except that an older build's rewind replaces an unknown turn, tool or resolution +// state with a placeholder row and turns an unknown block into a text block of its raw JSON +// (structured-rewind-journal-body.ts); an unknown body kind is carried as-is. History pages carry +// no row `v`, so older clients see such a value too. It must be safe for every older build. import { z } from 'zod' import { AgentSessionContextUsageSchema } from './agent-session-context-usage-schema' +import { knownTags, openDiscriminatedUnion } from './agent-session-journal-open-union' import type { AgentJournalItemBody, AgentJournalMessageItem, @@ -43,15 +56,6 @@ const ToolMetadata = { webSearchResults: z.array(z.object({ title: z.string(), url: z.string() })).optional() } -const KNOWN_BLOCK_TYPES = new Set([ - 'text', - 'tool-call', - 'tool-result', - 'image-ref', - 'subagent-group', - 'background-task' -]) - /** Provider IDs are opaque; reject all-whitespace values without rewriting valid IDs. */ const ProviderCallId = z .string() @@ -71,7 +75,7 @@ const SubagentEntry = z.object({ /** 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([ +const Block = openDiscriminatedUnion( z.discriminatedUnion('type', [ z.object({ type: z.literal('text'), @@ -122,28 +126,23 @@ const Block = z.union([ startedAt: z.number().optional(), settledAt: z.number().optional() }) - ]), - z.object({ type: z.string() }).refine((block) => !KNOWN_BLOCK_TYPES.has(block.type)) -]) + ]) +) -const PromptOption = z - .object({ - id: z.string(), - label: z.string(), - description: z.string().optional() - }) - .strict() +const PromptOption = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional() +}) -const Question = z - .object({ - id: z.string(), - question: z.string(), - header: z.string().optional(), - multiSelect: z.boolean(), - options: z.array(PromptOption), - freeTextQuestionId: z.string().optional() - }) - .strict() +const Question = z.object({ + id: z.string(), + question: z.string(), + header: z.string().optional(), + multiSelect: z.boolean(), + options: z.array(PromptOption), + freeTextQuestionId: z.string().optional() +}) const Resolution = z.object({ state: z.string().min(1), @@ -167,11 +166,13 @@ const ApprovalMatchedAskRule = z.object({ ruleContent: z.string().optional() }) -const ApprovalSubject = z.object({ - kind: z.literal('plan'), - text: z.string().min(1), - filePath: z.string().optional() -}) +const KnownApprovalSubject = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('plan'), text: z.string().min(1), filePath: z.string().optional() }) +]) +export const AGENT_JOURNAL_APPROVAL_SUBJECT_KINDS = knownTags(KnownApprovalSubject) +/** Open like blocks; a subject this build cannot draw is never approvable here + * (agent-session-approval-subject.ts). */ +const ApprovalSubject = openDiscriminatedUnion(KnownApprovalSubject) const MessageBody = z.object({ kind: z.literal('message'), @@ -193,13 +194,12 @@ const ThreadGoal = z.object({ }) /** Like blocks: an unknown `state` stays admissible, a known one with a broken payload does not. */ -const ThreadGoalState = z.union([ +const ThreadGoalState = openDiscriminatedUnion( z.discriminatedUnion('state', [ z.object({ state: z.literal('set'), goal: ThreadGoal }), z.object({ state: z.literal('cleared') }) - ]), - z.object({ state: z.string() }).refine((value) => !['set', 'cleared'].includes(value.state)) -]) + ]) +) /** Open like `state`: a kind, audience or refusal detail a newer host writes must not turn the row * malformed; the fact reader is where an unplaceable one is dropped. */ @@ -209,7 +209,7 @@ const FailureFact = z.object({ refusal: z.object({ code: z.string().min(1), details: z.looseObject({}).optional() }).optional() }) -export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ +const KnownItemBody = z.discriminatedUnion('kind', [ MessageBody, z.object({ kind: z.literal('tool-call'), @@ -282,6 +282,10 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [ }) ]) +/** Every body kind this build knows; a body of another kind is kept as-is and drawn by nobody. */ +export const AGENT_JOURNAL_ITEM_BODY_KINDS = knownTags(KnownItemBody) +export const AgentJournalItemBodySchema = openDiscriminatedUnion(KnownItemBody) + /** Producer linkage as it rides a render item across the process boundary. * `producerKind` stays an open string for the reason the header gives: a host * that learns a third kind must not make its rows unreadable to this client. */ diff --git a/src/shared/agent-session-journal-types.ts b/src/shared/agent-session-journal-types.ts index e5f8125a5c0..9613afa8a84 100644 --- a/src/shared/agent-session-journal-types.ts +++ b/src/shared/agent-session-journal-types.ts @@ -21,8 +21,8 @@ export { type AgentType } /** Bump only alongside a read-time upcaster in `journal-row-schema.ts`. */ /** v3 introduced the `turn` item. A row without one is still written at v2 so - * an older host keeps reading it; the first v3 row latches that host read-only - * instead of truncating the epoch. */ + * an older host keeps reading it; the first v3 row stops that host writing the chat + * (a released one keeps it read-only, this build fails its load) instead of truncating. */ export const AGENT_SESSION_JOURNAL_SCHEMA_VERSION = 3 export const AGENT_SESSION_JOURNAL_TURN_ITEM_SCHEMA_VERSION = 3 const AGENT_SESSION_JOURNAL_PRE_TURN_SCHEMA_VERSION = 2 @@ -167,12 +167,25 @@ export type AgentJournalApprovalMatchedAskRule = { ruleContent?: string } -export type AgentJournalApprovalSubject = { +export type AgentJournalPlanApprovalSubject = { kind: 'plan' text: string filePath?: string } +declare const agentJournalUnknownKind: unique symbol +/** A kind tag this build does not know. Branded, so it never stands in for a known tag. */ +export type AgentJournalUnknownKind = string & { readonly [agentJournalUnknownKind]: true } + +/** A subject of a kind a newer Orca wrote: carried as it was, with whatever fields it holds, and + * never drawn or approved here. */ +export type AgentJournalUnknownApprovalSubject = { readonly kind: AgentJournalUnknownKind } + +/** Open, as the journal schema reads it: narrow with `isPlanApprovalSubject` before reading it. */ +export type AgentJournalApprovalSubject = + | AgentJournalPlanApprovalSubject + | AgentJournalUnknownApprovalSubject + export type AgentJournalApprovalItem = { kind: 'approval' title: string diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index b60356965c0..41a9606deca 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -559,7 +559,12 @@ describe('a chat whose history the host could not open', () => { 'send', "Orca couldn't open this chat's history right now. Your message was not sent. Try again." ], - // Only an update gets past it, so it never says to try again. + // Only an update gets past it, so it never says to try again. A read meets it on one chat. + [ + 'journalWrittenByNewerOrca', + 'read-history', + 'This chat was saved by a newer Orca. Update Orca to open it.' + ], [ 'journalWrittenByNewerOrca', 'send', diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 93a1409de03..1cd5a8e9e26 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -10,22 +10,17 @@ // something beside the words retries it. // Surfaces keep the fact and choose the words when they show it, so nothing saved carries copy. -import type { AgentSessionFailureKind } from './agent-session-failure' import { agentSessionFailureSentence, type AgentSessionFailureWordsContext } from './agent-session-failure-words' -import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { AGENT_SESSION_HISTORY_UNREAD_CAUSES, AGENT_SESSION_WRITE_NOTICE_COPY, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' -import type { - AgentSessionWireRefusal, - AgentSessionWireRefusalCode -} from './agent-session-wire-refusals' +import type { AgentSessionWireRefusal } from './agent-session-wire-refusals' import { agentSessionRefusalFailure, parseAgentSessionWriteFailure, @@ -33,6 +28,13 @@ import { type AgentSessionWriteKind, type AgentSessionWriteRefusal } from './agent-session-write-failure' +import { agentSessionRefusalReasonWords } from './agent-session-refusal-reason-words' + +export { + agentSessionRefusalReasonWords, + type AgentSessionRefusalAction, + type AgentSessionRefusalReasonWords +} from './agent-session-refusal-reason-words' const NOT_DONE: Record = { 'read-history': 'notDoneReadHistory', @@ -56,183 +58,6 @@ export function agentSessionWriteNotDoneParts( return write === 'composer-send' ? ['notDoneSend', 'tryAgainComposerSend'] : [NOT_DONE[write]] } -/** What the person can do about a refusal with this reason. */ -export type AgentSessionRefusalAction = - /** Use the control that sent the write again. */ - | 'retry' - /** Wait for what the notice names; its step says what. */ - | 'wait' - /** Take the step the notice names first. */ - | 'actFirst' - /** Continue somewhere else: the current conversation, or a new chat. */ - | 'goElsewhere' - | 'checkChat' - | 'updateOrca' - /** What the write was for is over: the question it answered moved on. */ - | 'nothingLeft' - /** Orca's own state or fault; nothing the person does gets past it. */ - | 'hostFinding' - -export type AgentSessionRefusalReasonWords = - /** The code's own words are the honest ones for this reason. */ - | { words: 'code'; action: AgentSessionRefusalAction } - /** What stopped the write, and the step past it where the person has one to take. */ - | { - cause: AgentSessionWriteNoticeSentence - step?: AgentSessionWriteNoticeSentence - action: AgentSessionRefusalAction - } - /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ - | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } - -function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { - return { words: 'code', action } -} - -function causeWords( - cause: AgentSessionWriteNoticeSentence, - action: AgentSessionRefusalAction, - step?: AgentSessionWriteNoticeSentence -): AgentSessionRefusalReasonWords { - return step ? { cause, step, action } : { cause, action } -} - -const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') -const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') -// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. -const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') - -// Every reason of every code, so a reason the host adds does not compile until it has words. -// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep -// their code's words. -const REASON_WORDS = { - agent_session_operation_invalid: { - requestMalformed: codeWords('hostFinding'), - operationIdInvalid: codeWords('hostFinding'), - messageIdReused: codeWords('hostFinding'), - // Settled under that id, so the control's retry goes out under a new one. - operationRefusedEarlier: codeWords('retry'), - journalWriteFailed: causeWords('recordFailed', 'retry'), - conversationCleared: causeWords( - 'conversationCleared', - 'goElsewhere', - 'openCurrentConversation' - ), - // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. - clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), - // Only an older host sends this, for a /clear it never settled; only that host resolves it. - conversationCommandUnconfirmed: codeWords('hostFinding'), - conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), - // The chat's agent process is being replaced, which a start or restart does. - handoffInFlight: AGENT_STARTING, - turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), - promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), - backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), - messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), - // No chat surface sends a rewind; a replayed one says only that it did not happen. - rewindRefused: codeWords('hostFinding'), - rewindUnconfirmed: codeWords('hostFinding'), - promptGone: causeWords('questionChanged', 'nothingLeft'), - optionRejected: causeWords('optionRejected', 'retry'), - providerStarting: AGENT_STARTING, - goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), - providerRejected: causeWords('agentRefused', 'retry'), - providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, - notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, - historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, - managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, - accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, - managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, - attachFailed: codeWords('retry') - }, - agent_session_ownership_unknown: { - sessionNotAttached: codeWords('retry'), - noLiveOwner: codeWords('retry'), - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - recordMissing: codeWords('retry'), - replaySuperseded: codeWords('retry'), - leaseMoved: codeWords('retry'), - spawnIdentityMismatch: codeWords('hostFinding'), - notResumable: codeWords('retry'), - noProviderChild: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - // Trying again retries the stop that could not prove the exit. - previousExitUnverifiable: causeWords('ownerUnproven', 'retry') - }, - agent_session_conflict: { - chatStarting: AGENT_STARTING, - ownerUnproven: OWNER_UNPROVEN, - claimConflicted: TERMINAL_CLAIM, - ownerAlive: codeWords('retry'), - identityMismatch: codeWords('hostFinding'), - sessionExists: codeWords('retry'), - conversationHeldElsewhere: codeWords('retry'), - tabIdTaken: codeWords('retry') - }, - execution_owner_reconciling: { - hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), - recordUnreadable: causeWords('recordUnreadable', 'hostFinding') - }, - agent_session_checkpoint_stale: { - fenceStale: codeWords('retry'), - leaseMoved: codeWords('retry'), - recordMissing: codeWords('retry') - }, - agent_session_identity_required: { - recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), - transcriptNotFound: codeWords('retry'), - transcriptUnreadable: codeWords('hostFinding') - }, - agent_session_operation_conflict: { - fingerprintMismatch: codeWords('hostFinding'), - operationIdReused: codeWords('hostFinding'), - handoffInFlight: codeWords('retry') - }, - agent_session_operation_expired: { operationExpired: codeWords('retry') }, - agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, - agent_session_operation_unknown: { - outcomeUnknown: codeWords('checkChat'), - resultLost: codeWords('checkChat'), - rewindUnconfirmed: codeWords('checkChat'), - tabUnconfirmed: codeWords('checkChat') - }, - agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, - agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, - agent_session_journal_unreadable: { - // No retry reads past damage, and the words name no step: it only can't load. - journalCorrupt: causeWords('historyUnusable', 'hostFinding'), - // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), - journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') - }, - // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, - // which only a method the host doesn't know proves; no reason here means an older Orca. An - // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing - // the capability words this with its own older copy. - structured_agent_session_unsupported: { - clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), - hostDisabled: causeWords('notAvailable', 'hostFinding'), - hostUnsupported: causeWords('notAvailable', 'hostFinding') - }, - agent_session_owner_restart_failed: {} -} satisfies { - [C in AgentSessionWireRefusalCode]: Record< - AgentSessionRefusalReason, - AgentSessionRefusalReasonWords - > -} - -/** The words and next step a reason gets; undefined when the refusal names none. */ -export function agentSessionRefusalReasonWords( - failure: AgentSessionWriteRefusal -): AgentSessionRefusalReasonWords | undefined { - const reason = failure.details?.reason - const byReason: Partial> | undefined = - REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] -} - /** A cause, and that the request did not happen unless the cause already says so. */ function causeParts( cause: AgentSessionWriteNoticeSentence, @@ -263,10 +88,11 @@ function reasonParts( ? [NOT_DONE[write], { failure: { kind: words.fact }, surface: 'rejection', context }] : undefined } - const said = causeParts(words.cause, write) + const { cause, step } = write === 'read-history' && words.history ? words.history : words + const said = causeParts(cause, write) // A Retry beside the words is the step for a reason whose action is to retry. const retried = context.retryControl && words.action === 'retry' - return words.step && !retried ? [...said, words.step] : said + return step && !retried ? [...said, step] : said } /** What stopped a refused start, for a line that already says the chat did not start and shows its diff --git a/src/shared/agent-session-refusal-reason-words.ts b/src/shared/agent-session-refusal-reason-words.ts new file mode 100644 index 00000000000..2aaaac158ff --- /dev/null +++ b/src/shared/agent-session-refusal-reason-words.ts @@ -0,0 +1,192 @@ +// The words and next step each refusal reason gets, for every code: a reason the host adds does not +// compile until it has words here. + +import type { AgentSessionFailureKind } from './agent-session-failure' +import type { AgentSessionRefusalReason } from './agent-session-refusal-details' +import type { AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' +import type { AgentSessionWireRefusalCode } from './agent-session-wire-refusals' +import type { AgentSessionWriteRefusal } from './agent-session-write-failure' + +/** What the person can do about a refusal with this reason. */ +export type AgentSessionRefusalAction = + /** Use the control that sent the write again. */ + | 'retry' + /** Wait for what the notice names; its step says what. */ + | 'wait' + /** Take the step the notice names first. */ + | 'actFirst' + /** Continue somewhere else: the current conversation, or a new chat. */ + | 'goElsewhere' + | 'checkChat' + | 'updateOrca' + /** What the write was for is over: the question it answered moved on. */ + | 'nothingLeft' + /** Orca's own state or fault; nothing the person does gets past it. */ + | 'hostFinding' + +export type AgentSessionRefusalReasonWords = + /** The code's own words are the honest ones for this reason. */ + | { words: 'code'; action: AgentSessionRefusalAction } + /** What stopped the write, and the step past it where the person has one to take. */ + | { + cause: AgentSessionWriteNoticeSentence + step?: AgentSessionWriteNoticeSentence + action: AgentSessionRefusalAction + /** Its words when what failed is reading the chat's history. */ + history?: { cause: AgentSessionWriteNoticeSentence; step: AgentSessionWriteNoticeSentence } + } + /** A start that failed: the sentence that failure has everywhere, whose next step is a send. */ + | { fact: AgentSessionFailureKind; action: AgentSessionRefusalAction } + +function codeWords(action: AgentSessionRefusalAction): AgentSessionRefusalReasonWords { + return { words: 'code', action } +} + +function causeWords( + cause: AgentSessionWriteNoticeSentence, + action: AgentSessionRefusalAction, + step?: AgentSessionWriteNoticeSentence +): AgentSessionRefusalReasonWords { + return step ? { cause, step, action } : { cause, action } +} + +const AGENT_STARTING = causeWords('agentStarting', 'wait', 'waitForStart') +const OWNER_UNPROVEN = causeWords('ownerUnproven', 'actFirst', 'reopenChat') +// Only a terminal agent an older build recorded holds a claim; quitting it frees the chat. +const TERMINAL_CLAIM = causeWords('terminalAgentHoldsChat', 'actFirst', 'quitTerminalAgent') + +// Every reason of every code, so a reason the host adds does not compile until it has words. +// Reasons only a create, attach, hold or adopted import meets never reach a chat write; they keep +// their code's words. +const REASON_WORDS = { + agent_session_operation_invalid: { + requestMalformed: codeWords('hostFinding'), + operationIdInvalid: codeWords('hostFinding'), + messageIdReused: codeWords('hostFinding'), + // Settled under that id, so the control's retry goes out under a new one. + operationRefusedEarlier: codeWords('retry'), + journalWriteFailed: causeWords('recordFailed', 'retry'), + conversationCleared: causeWords( + 'conversationCleared', + 'goElsewhere', + 'openCurrentConversation' + ), + // Only an older host sends this, and it keeps refusing the chat, so only a new chat continues. + clearUnconfirmed: causeWords('clearUnfinished', 'goElsewhere', 'startNewChat'), + // Only an older host sends this, for a /clear it never settled; only that host resolves it. + conversationCommandUnconfirmed: codeWords('hostFinding'), + conversationCommandInFlight: causeWords('commandRunning', 'wait', 'waitForCommand'), + // The chat's agent process is being replaced, which a start or restart does. + handoffInFlight: AGENT_STARTING, + turnActive: causeWords('turnActive', 'wait', 'waitForTurn'), + promptPending: causeWords('promptPending', 'actFirst', 'answerFirst'), + backgroundTasksRunning: causeWords('backgroundTasksRunning', 'wait', 'waitForBackgroundTasks'), + messagesUnsettled: causeWords('messagesUnsettled', 'actFirst', 'settleEarlierMessage'), + // No chat surface sends a rewind; a replayed one says only that it did not happen. + rewindRefused: codeWords('hostFinding'), + rewindUnconfirmed: codeWords('hostFinding'), + promptGone: causeWords('questionChanged', 'nothingLeft'), + optionRejected: causeWords('optionRejected', 'retry'), + providerStarting: AGENT_STARTING, + goalsUnsupported: causeWords('goalsUnsupported', 'hostFinding'), + providerRejected: causeWords('agentRefused', 'retry'), + providerStartFailed: { fact: 'providerStartFailed', action: 'retry' }, + notSignedIn: { fact: 'notSignedIn', action: 'actFirst' }, + historyTooLarge: { fact: 'historyTooLarge', action: 'goElsewhere' }, + managedAccountEnvOverride: { fact: 'managedAccountEnvOverride', action: 'actFirst' }, + accountSwitchInProgress: { fact: 'accountSwitchInProgress', action: 'wait' }, + managedAccountUnsupported: { fact: 'managedAccountUnsupported', action: 'actFirst' }, + attachFailed: codeWords('retry') + }, + agent_session_ownership_unknown: { + sessionNotAttached: codeWords('retry'), + noLiveOwner: codeWords('retry'), + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + recordMissing: codeWords('retry'), + replaySuperseded: codeWords('retry'), + leaseMoved: codeWords('retry'), + spawnIdentityMismatch: codeWords('hostFinding'), + notResumable: codeWords('retry'), + noProviderChild: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + // Trying again retries the stop that could not prove the exit. + previousExitUnverifiable: causeWords('ownerUnproven', 'retry') + }, + agent_session_conflict: { + chatStarting: AGENT_STARTING, + ownerUnproven: OWNER_UNPROVEN, + claimConflicted: TERMINAL_CLAIM, + ownerAlive: codeWords('retry'), + identityMismatch: codeWords('hostFinding'), + sessionExists: codeWords('retry'), + conversationHeldElsewhere: codeWords('retry'), + tabIdTaken: codeWords('retry') + }, + execution_owner_reconciling: { + hostReconciling: causeWords('hostReconciling', 'wait', 'waitMoment'), + recordUnreadable: causeWords('recordUnreadable', 'hostFinding') + }, + agent_session_checkpoint_stale: { + fenceStale: codeWords('retry'), + leaseMoved: codeWords('retry'), + recordMissing: codeWords('retry') + }, + agent_session_identity_required: { + recordMissing: causeWords('chatNotFound', 'goElsewhere', 'startNewChat'), + transcriptNotFound: codeWords('retry'), + transcriptUnreadable: codeWords('hostFinding') + }, + agent_session_operation_conflict: { + fingerprintMismatch: codeWords('hostFinding'), + operationIdReused: codeWords('hostFinding'), + handoffInFlight: codeWords('retry') + }, + agent_session_operation_expired: { operationExpired: codeWords('retry') }, + agent_session_operation_capacity: { operationCapacity: codeWords('wait') }, + agent_session_operation_unknown: { + outcomeUnknown: codeWords('checkChat'), + resultLost: codeWords('checkChat'), + rewindUnconfirmed: codeWords('checkChat'), + tabUnconfirmed: codeWords('checkChat') + }, + agent_session_item_revision_stale: { promptMoved: codeWords('nothingLeft') }, + agent_session_already_resolved: { promptAlreadyResolved: codeWords('nothingLeft') }, + agent_session_journal_unreadable: { + // No retry reads past damage, and the words name no step: it only can't load. + journalCorrupt: causeWords('historyUnusable', 'hostFinding'), + // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + // A write meets it on a newer Orca's database, or from an older host keeping the chat + // read-only; a read meets it on this one chat, which only an update opens. + journalWrittenByNewerOrca: { + ...causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing'), + history: { cause: 'chatSavedByNewerOrca', step: 'updateOrcaToOpenChat' } + } + }, + // Thrown, so a client meets these only as an RPC error. The code's own words ask for an update, + // which only a method the host doesn't know proves; no reason here means an older Orca. An + // unsupported location or agent, or no chat host, is not fixed by updating, and a client missing + // the capability words this with its own older copy. + structured_agent_session_unsupported: { + clientCapabilityMissing: causeWords('notAvailable', 'hostFinding'), + hostDisabled: causeWords('notAvailable', 'hostFinding'), + hostUnsupported: causeWords('notAvailable', 'hostFinding') + }, + agent_session_owner_restart_failed: {} +} satisfies { + [C in AgentSessionWireRefusalCode]: Record< + AgentSessionRefusalReason, + AgentSessionRefusalReasonWords + > +} + +/** The words and next step a reason gets; undefined when the refusal names none. */ +export function agentSessionRefusalReasonWords( + failure: AgentSessionWriteRefusal +): AgentSessionRefusalReasonWords | undefined { + const reason = failure.details?.reason + const byReason: Partial> | undefined = + REASON_WORDS[failure.code] + return reason === undefined ? undefined : byReason?.[reason] +} diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index 08fd7a90a60..0904ecf8551 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -36,6 +36,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnavailable: "Orca couldn't open this chat's history right now.", savedByNewerOrca: 'Chats were saved by a newer Orca.', updateOrcaToKeepUsing: 'Update Orca to keep using them.', + chatSavedByNewerOrca: 'This chat was saved by a newer Orca.', + updateOrcaToOpenChat: 'Update Orca to open it.', unsupported: 'This needs a newer Orca on the computer running this chat. Update Orca there, then try again.', notAvailable: "This isn't available in this chat.", @@ -88,4 +90,10 @@ export type AgentSessionWriteNoticePart = /** Causes that already say the history can't be read here, so no sentence after them says it * again. */ export const AGENT_SESSION_HISTORY_UNREAD_CAUSES: ReadonlySet = - new Set(['historyUnusable', 'historyUnavailable', 'historyUnreadable', 'savedByNewerOrca']) + new Set([ + 'historyUnusable', + 'historyUnavailable', + 'historyUnreadable', + 'savedByNewerOrca', + 'chatSavedByNewerOrca' + ]) diff --git a/src/shared/structured-agent-session-read-refusal.ts b/src/shared/structured-agent-session-read-refusal.ts index 74279d7811d..9312c3d8e5b 100644 --- a/src/shared/structured-agent-session-read-refusal.ts +++ b/src/shared/structured-agent-session-read-refusal.ts @@ -47,15 +47,17 @@ export function isUnattachedAgentSessionReadRefusal(error: unknown): boolean { } /** - * A read refusal no retry reads past: SQLite reported the chat's journal damaged. Decided from the - * reason, never the message, which is the bare code for every journal refusal; a journal that - * failed to open for any other reason can clear, so its read keeps reconnecting. + * A read refusal no retry reads past: the chat's history is damaged, as SQLite or its rows show, or + * a newer Orca saved it, which only an update gets past. Decided from the reason, never the + * message, which is the bare code for every journal refusal; a journal that failed to open for any + * other reason can clear, so its read keeps reconnecting. */ export function isFinalAgentSessionReadRefusal( refusal: AgentSessionRefusalReference | undefined ): boolean { return ( refusal?.code === 'agent_session_journal_unreadable' && - refusal.details?.reason === 'journalCorrupt' + (refusal.details?.reason === 'journalCorrupt' || + refusal.details?.reason === 'journalWrittenByNewerOrca') ) } diff --git a/src/shared/structured-agent-session-unknown-item.test.ts b/src/shared/structured-agent-session-unknown-item.test.ts new file mode 100644 index 00000000000..92ec054d142 --- /dev/null +++ b/src/shared/structured-agent-session-unknown-item.test.ts @@ -0,0 +1,38 @@ +// An item of a kind this build does not know is kept and drawn by nobody: no client renders it, +// and nothing the host or a client decides from items (a running turn, a pending prompt, status) +// reads it. + +import { expect, it } from 'vitest' +import { isAdmissibleAgentJournalItemBody } from './agent-session-journal-schemas' +import type { AgentJournalRenderItem } from './agent-session-journal-types' +import { activeStructuredAgentSessionTurnId } from './structured-agent-session-live-turn' +import { + projectStructuredAgentSessionStatusSummary, + projectStructuredItemsToNativeChat +} from './structured-agent-session-projection' + +function renderItem(sequence: number, body: unknown): AgentJournalRenderItem { + // Admitted through the catch-all, though the journal's body type names only known kinds. + if (!isAdmissibleAgentJournalItemBody(body)) { + throw new Error('the journal admits this body') + } + return { itemId: `item-${sequence}`, revision: 1, sequence, observedAt: sequence, body } +} + +const ITEMS = [ + renderItem(1, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Ship it' }] }), + renderItem(2, { kind: 'turn', turnId: 'turn-1', state: 'completed' }), + renderItem(3, { kind: 'plan-card', state: 'running', resolution: { state: 'pending' } }) +] + +it('draws nothing for an item of a kind this build does not know', () => { + expect(projectStructuredItemsToNativeChat(ITEMS).map((message) => message.id)).toEqual(['item-1']) +}) + +it('takes no turn, prompt or work from it', () => { + expect(activeStructuredAgentSessionTurnId(ITEMS)).toBeNull() + expect(projectStructuredAgentSessionStatusSummary(ITEMS)).toMatchObject({ + status: 'idle', + latestPrompt: 'Ship it' + }) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts new file mode 100644 index 00000000000..42f5856e35c --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-newer-chat-refusal.unit.test.ts @@ -0,0 +1,236 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentSessionJournalIdentity +} from '../../../src/shared/agent-session-journal-types' +import type { AgentSessionRefusalReference } from '../../../src/shared/agent-session-wire-refusals' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { journalOpenRefusal } from '../../../src/main/native-chat/agent-session-journal/journal-open-failure' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +/** + * A chat a newer Orca saved now fails its load here with the newer-Orca reason, where this host used + * to serve it read-only (Rule 3: an existing read answers differently). An older client gets that + * refusal instead of pages: it must word it with what it has and never call it damage. And this + * build keeps a body of a kind it does not know (or a plan subject of one), writable, instead of + * deleting from it, which an older host meets when it opens a journal holding such a body. + */ +const SUITE_TIMEOUT_MS = 180_000 +// The last release that does not know the newer-Orca reason: its words fall back to the code's. +const BEFORE_NEWER_REASON_REF = 'v1.4.218' +// A main build that shares this one's host database and schema version, so a downgrade to it opens +// the journal writable. No release tag has that database yet; move to the first one that does. +const WRITABLE_BASELINE_REF = '3727100cc9dbcea6201f8a3e506676a3c4b53b18' +const JOURNAL = 'src/main/native-chat/agent-session-journal' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-chat', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +type ReadHistoryParts = (code: string, details?: unknown, context?: unknown) => unknown[] +type NoticeEnglish = (parts: unknown[]) => string +type IsFinal = (refusal: AgentSessionRefusalReference | undefined) => boolean + +let directory: string +const journals = createTrackedJournalOpener() + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) +}) + +afterAll(async () => { + await journals.closeAll() + rmSync(directory, { recursive: true, force: true }) +}) + +/** A function the checked-out build exports, typed as the caller calls it. */ +function releaseExport(module: Record, name: string): T { + const value = module[name] + if (value === undefined) { + throw new Error(`the checked-out build exports no ${name}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: an export the checked-out build defines; each caller names the shape it uses, and a changed one fails the test. + return value as T +} + +const NEWER_BODY_KIND = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } +const NEWER_NESTED_LITERAL = { + kind: 'approval', + title: 'Approve the change?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'diff', path: 'a.ts' } +} + +/** A row of a kind this build does not know: the journal's open is refused as a newer Orca's. */ +const NEWER_ROW_KIND = { kind: 'future-mark' } + +/** This build's journal of two items, then a row as a newer build wrote it: an item holding + * `body`, or `row` itself: closed. */ +async function journalWithNewerRow( + row: Record = NEWER_ROW_KIND +): Promise<{ rows: string[]; newer: string }> { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 2 }, + { kind: 'status', text: 'also before' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const seq = journal.cursor().sequence + 1 + const newer = JSON.stringify({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: journal.epoch, + seq, + fence: 1, + ts: 2_000, + ...row + }) + await journals.closeAll() + insertTestJournalRowJson( + openTestJournalHostDatabase(directory).db, + IDENTITY.sessionId, + seq, + newer, + 2_000 + ) + return { rows: storedRows(), newer } +} + +function storedRows(): string[] { + const rows = liveTestJournalRows(openTestJournalHostDatabase(directory).db, IDENTITY.sessionId) + closeTestJournalHostDatabase(directory) + return rows.map((row) => row.rowJson) +} + +/** This host's refusal of a newer Orca's chat, as `history` and `subscribe` raise it. */ +async function newerChatRefusal(): Promise { + await journalWithNewerRow() + const failure = await journals.open({ identity: IDENTITY, stateDirectory: directory }).then( + () => null, + (error: unknown) => error + ) + expect(failure).toMatchObject(SAVED_BY_NEWER_ORCA) + await journals.closeAll() + return journalOpenRefusal(failure) +} + +/** How a released client words a read refusal, and whether it stops retrying on it. */ +async function releasedReader(ref: string) { + const checkout = await materializeReleaseCheckout(ref) + const notice = await importReleaseCheckoutModule( + checkout, + 'src/shared/agent-session-refusal-notice.ts' + ) + const readRefusal = await importReleaseCheckoutModule( + checkout, + 'src/shared/structured-agent-session-read-refusal.ts' + ) + const parts = releaseExport(notice, 'agentSessionReadHistoryRefusalParts') + const english = releaseExport(notice, 'agentSessionWriteNoticeEnglish') + const isFinal = releaseExport(readRefusal, 'isFinalAgentSessionReadRefusal') + // The pane's own Retry stands beside the words, as on desktop. + const words = (refusal: Pick) => + english(parts(refusal.code, refusal.details, { retryControl: true })) + return { words, isFinal } +} + +describe('a chat a newer Orca saved, across versions', () => { + it( + 'old client against new host: a release before the reason words it as an unreadable history and keeps retrying', + async () => { + const refusal = await newerChatRefusal() + const released = await releasedReader(BEFORE_NEWER_REASON_REF) + const code = { code: refusal.code } + const damage = { code: refusal.code, details: { reason: 'journalCorrupt' } } + expect(released.words(refusal)).toBe(released.words(code)) + expect(released.words(refusal)).not.toBe(released.words(damage)) + expect(released.isFinal(refusal)).toBe(false) + }, + SUITE_TIMEOUT_MS + ) + + it( + 'old client against new host: the newest release words it as its own, never as damage', + async () => { + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) + const refusal = await newerChatRefusal() + const released = await releasedReader(resolveBaselineReleaseRef()) + const damage = { code: refusal.code, details: { reason: 'journalCorrupt' } } + expect(released.words(refusal)).not.toBe('') + expect(released.words(refusal)).not.toBe(released.words(damage)) + }, + SUITE_TIMEOUT_MS + ) + + // Why a new body kind ships its reader first, or rides a bumped `v`: this build keeps one and + // stays writable, but a build from before deletes the journal from it. Move the pinned build to + // the first release with this rule, and the older build keeps the row too. + it.each([ + ['body kind', NEWER_BODY_KIND], + ['plan subject kind', NEWER_NESTED_LITERAL] + ])( + "this build keeps a newer build's %s and stays writable; a build before it deletes it", + async (_value, body) => { + rmSync(directory, { recursive: true, force: true }) + directory = mkdtempSync(join(tmpdir(), 'orca-newer-chat-xv-')) + const { rows, newer } = await journalWithNewerRow({ + kind: 'item', + itemId: 'codex:thread-1:turn-1:1', + revision: 1, + body + }) + const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) + expect(reopened.snapshot().items).toHaveLength(3) + await journals.closeAll() + expect(storedRows()).toEqual(rows) + + const checkout = await materializeReleaseCheckout(WRITABLE_BASELINE_REF) + const support = await importReleaseCheckoutModule( + checkout, + `${JOURNAL}/journal-host-database-test-support.ts` + ) + const older = releaseExport< + () => { + open: (options: { + identity: AgentSessionJournalIdentity + stateDirectory: string + }) => Promise + closeAll: () => Promise + } + >(support, 'createTrackedJournalOpener')() + try { + await older.open({ identity: IDENTITY, stateDirectory: directory }) + } finally { + await older.closeAll() + } + expect(storedRows()).not.toContain(newer) + }, + SUITE_TIMEOUT_MS + ) +}) diff --git a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts index b31c8f008cd..3fc06f9d2dc 100644 --- a/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts @@ -15,7 +15,8 @@ import { createTrackedJournalOpener, insertTestJournalRowJson, liveTestJournalRows, - openTestJournalHostDatabase + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import type { JournalRow } from '../../../src/main/native-chat/agent-session-journal/journal-row-schema' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' @@ -217,7 +218,6 @@ test("an older build opens this build's journal writable and appends to it; the // Upgraded again: the older build's row folds, and the person's Stop still pauses the queue. const upgraded = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(upgraded.isReadOnly).toBe(false) expect(upgraded.cursor().sequence).toBe(wrote.cursor.sequence + 1) expect(itemIds(upgraded)).toEqual([...wrote.items, 'codex:thread-1:turn-1:2']) expect(upgraded.queuedMessages.pauses('host-a').map((pause) => pause.reason)).toEqual([ @@ -230,11 +230,12 @@ test("an older build opens this build's journal writable and appends to it; the }, 120_000) // Why a Stop's event cannot have a row kind of its own yet: this build keeps a kind it does not know -// and goes read-only, but a build from before that deletes the journal from it. So a Stop kind ships +// and refuses the load as a newer Orca's chat, but a build from before that deletes the journal from +// it. So a Stop kind ships // its reader first and is written once no supported build lacks that reader, or is written at a // bumped `v`. Move the baseline to the first release with this rule, and the older build keeps the // row too. -test("this build keeps a newer build's row kind and goes read-only; a build before it deletes it", async () => { +test("this build keeps a newer build's row kind and refuses the load; a build before it deletes it", async () => { const directory = mkdtempSync(join(tmpdir(), 'orca-newer-kind-downgrade-')) const journals = createTrackedJournalOpener() const newerKinds = () => storedRows(directory).filter((row) => row.includes('"future-mark"')) @@ -262,12 +263,9 @@ test("this build keeps a newer build's row kind and goes read-only; a build befo closeTestJournalHostDatabase(directory) const rowsBefore = storedRows(directory) - const reopened = await journals.open({ identity: IDENTITY, stateDirectory: directory }) - expect(reopened.isReadOnly).toBe(true) - expect(reopened.repair).toEqual({ malformedRows: 0 }) await expect( - reopened.appendItem(item(1), { kind: 'status', text: 'after' }, scope) - ).rejects.toMatchObject({ code: 'journal_read_only' }) + journals.open({ identity: IDENTITY, stateDirectory: directory }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) await journals.closeAll() expect(storedRows(directory)).toEqual(rowsBefore) expect(newerKinds()).toEqual([newer]) From a68ee67cf90dff5c452efcba3321f728b7552e77 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 5 Oct 2026 11:06:40 -0700 Subject: [PATCH 6/9] fix(codex): stop prompting a Codex restart when only its home changed (#25421) * fix(codex): stop prompting a Codex restart when only its home changed A Codex terminal that outlived the move to ~/.codex was blocked behind "This Codex session is using an outdated configuration" until the user restarted it, which starts a fresh codex and ends the conversation. That terminal keeps working on Orca's old Codex home, which is refreshed from ~/.codex, so the change did not need the user's answer; the prompt also never appeared for Codex in Git Bash terminals, whose process tree Orca cannot see through. The restart prompt now covers only an account switch, which the user caused. The pane record keeps its home route, which the retained-home refresh and the shared-server check still read; only the prompt and the custom-home downgrade that existed to keep it from guessing are gone. * refactor(codex): drop what the home-route prompt left behind - Record a pane's own CODEX_HOME override as-is. The filter that kept only overrides Orca could re-derive existed for the removed route comparison; its one remaining reader, the shared-server check, returns null for the shared-home route those panes record either way. - Treat custom-home like shared-home in resolveCodexPaneHome: the removed downgrade only wrote it without an override, so it never named a home. - Inline the restart notice key; its route/account prefix was its only job. - Delete the two pane-local override tests, including a POSIX-only one that still expected custom-home and would have failed on Linux and macOS CI. - Cover the account recheck branches the deleted route-recheck file was the last to exercise: main reporting a pane current, and main not answering. * refactor(codex): retire custom-home and the last re-check helpers - Read an older build's custom-home record as shared-home, which it always was, and drop custom-home from the route type and every check. - Delete shellStartupCodexHomeOverrideMatches and its comparison helper; nothing re-checks a recorded override any more. - Build the pane launch record in one return: the route is always set, and only a resumed launch differs, in how it picks the account. - Drop the restart dialog's notice key; its focus effect now depends on the pane and both account labels directly. - Give the account recheck test a typed window stub, so CI's type-assertion gate passes, and remove timing entries for deleted test files. * fix(codex): drop the removed dialog strings main added to es.json * refactor(codex): stop recording a pane's custom CODEX_HOME The pane record kept a pane's CODEX_HOME override so the removed route prompt could re-check it later. Its one other reader, the shared-server check, only looked at it for a real-home pane, and a custom CODEX_HOME always routes a pane to Orca's mirror, so it was never used. Drop both record fields, their validators, equality checks and spawn plumbing. getCustomCodexHomeOverrideForLaunch folds into the existing hasCustomCodexHomeOverrideForLaunch, and real-home resolves to ~/.codex. Records from older builds still parse; the parser keeps only known keys. The fish test's decoy no longer sets CODEX_HOME, so the boolean check still fails if the launch env XDG_CONFIG_HOME is ignored. * test(codex): drop setup the yes/no CODEX_HOME checks no longer need --------- Co-authored-by: Orca Worker --- config/scripts/ci-shard-timings.json | 2 - ...untime-home-real-home-lane-routing.test.ts | 3 +- .../runtime-home-service-home-routing.ts | 16 +- .../runtime-home-service-state.ts | 2 - .../codex-pane-account-registry-mutations.ts | 7 +- .../codex-pane-account-registry-types.ts | 16 +- src/main/codex/codex-pane-account-registry.ts | 55 ++---- .../codex/codex-pane-launch-account.test.ts | 28 --- src/main/codex/codex-pane-launch-account.ts | 49 ++---- src/main/codex/codex-real-home-path.test.ts | 57 +----- src/main/codex/codex-real-home-path.ts | 121 +++---------- .../codex/codex-shared-server-pane.test.ts | 19 -- src/main/codex/codex-shared-server-pane.ts | 7 +- .../codex/codex-stale-pane-accounts.test.ts | 138 +++------------ src/main/codex/codex-stale-pane-accounts.ts | 22 +-- src/main/ipc/codex-accounts.ts | 3 +- .../ipc/pty-codex-account-attribution.test.ts | 32 +--- src/main/ipc/pty-write-ipc-validation.test.ts | 46 +---- src/main/ipc/pty/host-env/codex-home.ts | 36 ---- src/main/ipc/pty/ipc/spawn-commit-persist.ts | 1 - src/main/ipc/pty/runtime/spawn-commit.ts | 1 - src/preload/api/agent-account-api.ts | 2 - src/preload/api/codex-accounts-bridge.ts | 1 - .../src/components/CodexRestartChip.tsx | 54 +++--- .../components/codex-restart-chip.test.tsx | 24 --- .../codex-restart-notice-key.test.ts | 23 --- .../components/codex-restart-notice-key.ts | 7 - src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/fr.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- ...ex-session-restart-account-recheck.test.ts | 96 ++++++++++ ...odex-session-restart-route-recheck.test.ts | 164 ------------------ .../src/lib/codex-session-restart.test.ts | 27 +-- src/renderer/src/lib/codex-session-restart.ts | 6 +- .../codex-restart-notice-lifecycle.test.ts | 21 --- .../src/store/terminals/terminal-actions.ts | 1 - .../src/store/terminals/terminal-contracts.ts | 2 - .../store/terminals/terminal-restart-state.ts | 6 - 41 files changed, 217 insertions(+), 914 deletions(-) delete mode 100644 src/renderer/src/components/codex-restart-notice-key.test.ts delete mode 100644 src/renderer/src/components/codex-restart-notice-key.ts create mode 100644 src/renderer/src/lib/codex-session-restart-account-recheck.test.ts delete mode 100644 src/renderer/src/lib/codex-session-restart-route-recheck.test.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 34ba98f2566..788a5185523 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5216,7 +5216,6 @@ "src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts": 104, "src/renderer/src/components/cmd-j/worktree-palette-cache-inputs.test.ts": 41, "src/renderer/src/components/codex-restart-chip.test.tsx": 2063, - "src/renderer/src/components/codex-restart-notice-key.test.ts": 63, "src/renderer/src/components/comment-code-context-state.test.ts": 34, "src/renderer/src/components/comment-reply-target-state.test.ts": 51, "src/renderer/src/components/confirmation-dialog-refresh-boundary.test.ts": 1586, @@ -7801,7 +7800,6 @@ "src/renderer/src/lib/codex-account-display-label.test.ts": 48, "src/renderer/src/lib/codex-pane-restart-eligibility.test.ts": 82, "src/renderer/src/lib/codex-pane-selection-lane.test.ts": 249, - "src/renderer/src/lib/codex-session-restart-route-recheck.test.ts": 1547, "src/renderer/src/lib/codex-session-restart-shell-flap.test.ts": 1603, "src/renderer/src/lib/codex-session-restart.test.ts": 6689, "src/renderer/src/lib/codex-stale-pane-account-identity.test.ts": 1587, diff --git a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts index 596b70f2c1e..5ea98007cc7 100644 --- a/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts +++ b/src/main/codex-accounts/runtime-home-real-home-lane-routing.test.ts @@ -145,14 +145,13 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.isHostSystemDefaultRealHome()).toBe(true) - expect(service.getSelectedHostCodexHomeRoute()).toBe('real-home') expect(service.prepareForCodexLaunch()).toBeNull() expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([ getRuntimeCodexHomePath(), getSystemCodexHomePath() ]) service.setRealHomeLaneGate(() => false) - expect(service.getSelectedHostCodexHomeRoute()).toBe('shared-home') + expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) const markerPath = join( testState.userDataDir, diff --git a/src/main/codex-accounts/runtime-home-service-home-routing.ts b/src/main/codex-accounts/runtime-home-service-home-routing.ts index 18d7549d3f5..04ea9383f2a 100644 --- a/src/main/codex-accounts/runtime-home-service-home-routing.ts +++ b/src/main/codex-accounts/runtime-home-service-home-routing.ts @@ -16,8 +16,7 @@ import { import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { hasRecordedLegacySharedCodexPane, - getCodexPaneAccount, - type CodexPaneHomeRoute + getCodexPaneAccount } from '../codex/codex-pane-account-registry' import { ManagedCodexHomeTemporarilyUnavailableError } from './host-codex-managed-home-ownership' import { syncLegacySharedCodexConfigForRetainedPanes } from './legacy-shared-config-compatibility' @@ -92,13 +91,6 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom : { kind: 'skip' } } - getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute { - if (this.getSelfContainedManagedHostAccount()) { - return 'account-home' - } - return this.isHostSystemDefaultRealHome() ? 'real-home' : 'shared-home' - } - getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] { const settings = this.store.getSettings() const homes = new Map() @@ -107,11 +99,7 @@ export abstract class CodexRuntimeHomeRouting extends CodexRuntimeHomeManagedHom if (!record || record.selectionKey !== 'host') { continue } - if ( - record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' - ) { + if (record.homeRoute === undefined || record.homeRoute === 'shared-home') { const homePath = this.getRuntimeHomePath() homes.set(normalizeRuntimePathForComparison(homePath), homePath) continue diff --git a/src/main/codex-accounts/runtime-home-service-state.ts b/src/main/codex-accounts/runtime-home-service-state.ts index 472bb28c72c..66c6c229f27 100644 --- a/src/main/codex-accounts/runtime-home-service-state.ts +++ b/src/main/codex-accounts/runtime-home-service-state.ts @@ -15,7 +15,6 @@ import type { CodexSystemDefaultSnapshot } from './runtime-home-service-types' import type { CodexSessionBackfillDate } from '../codex/codex-session-backfill-types' -import type { CodexPaneHomeRoute } from '../codex/codex-pane-account-registry' import type { CodexAccountSelectionTarget } from './runtime-selection' import type { LegacyWslRuntimeAuthDestination } from './legacy-wsl-runtime-auth-drain' import type { WslCodexAuthRead } from './wsl-codex-auth-batch-reader' @@ -92,7 +91,6 @@ export abstract class CodexRuntimeHomeState { abstract resolveCodexManagedAccountHomeForInactiveFetch( account: CodexManagedAccount ): { kind: 'ready'; homePath: string } | { kind: 'skip' } - abstract getSelectedHostCodexHomeRoute(): CodexPaneHomeRoute abstract getRetainedHostCodexHookHomePaths(ptyIds: readonly string[]): string[] abstract setRealHomeLaneGate(gate: () => boolean): void abstract isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean diff --git a/src/main/codex/codex-pane-account-registry-mutations.ts b/src/main/codex/codex-pane-account-registry-mutations.ts index f946453c0bf..61de741d45f 100644 --- a/src/main/codex/codex-pane-account-registry-mutations.ts +++ b/src/main/codex/codex-pane-account-registry-mutations.ts @@ -137,12 +137,7 @@ function recordsEqual( return ( left?.selectionKey === right.selectionKey && left.accountId === right.accountId && - left.homeRoute === right.homeRoute && - left.shellStartupHomeOverride?.home === right.shellStartupHomeOverride?.home && - left.shellStartupHomeOverride?.shell === right.shellStartupHomeOverride?.shell && - left.shellStartupHomeOverride?.configHome === right.shellStartupHomeOverride?.configHome && - left.shellStartupHomeOverride?.codexHome === right.shellStartupHomeOverride?.codexHome && - left.environmentHomeOverride?.codexHome === right.environmentHomeOverride?.codexHome + left.homeRoute === right.homeRoute ) } diff --git a/src/main/codex/codex-pane-account-registry-types.ts b/src/main/codex/codex-pane-account-registry-types.ts index 446ca93e051..65666532a87 100644 --- a/src/main/codex/codex-pane-account-registry-types.ts +++ b/src/main/codex/codex-pane-account-registry-types.ts @@ -1,14 +1,4 @@ -import type { - CodexEnvironmentHomeOverride, - CodexShellStartupHomeOverride -} from './codex-real-home-path' - -export type CodexPaneHomeRoute = - | 'real-home' - | 'shared-home' - | 'account-home' - | 'custom-home' - | 'wsl-home' +export type CodexPaneHomeRoute = 'real-home' | 'shared-home' | 'account-home' | 'wsl-home' export type CodexPaneAccountRecord = { /** 'host' or 'wsl:' — the selection lane this pane launched from. */ @@ -17,10 +7,6 @@ export type CodexPaneAccountRecord = { accountId: string | null /** Absent only on records written before route provenance was introduced. */ homeRoute?: CodexPaneHomeRoute - /** Rechecked when CODEX_HOME came from process-global shell startup. */ - shellStartupHomeOverride?: CodexShellStartupHomeOverride - /** Rechecked after restart when CODEX_HOME came from the process environment. */ - environmentHomeOverride?: CodexEnvironmentHomeOverride } export type CodexPaneAccountRegistryFile = { diff --git a/src/main/codex/codex-pane-account-registry.ts b/src/main/codex/codex-pane-account-registry.ts index ebad8632676..55eac65e21c 100644 --- a/src/main/codex/codex-pane-account-registry.ts +++ b/src/main/codex/codex-pane-account-registry.ts @@ -8,10 +8,6 @@ import type { CodexPaneAccountRegistryFile, CodexPaneHomeRoute } from './codex-pane-account-registry-types' -import type { - CodexEnvironmentHomeOverride, - CodexShellStartupHomeOverride -} from './codex-real-home-path' export type { CodexPaneAccountRecord, @@ -129,45 +125,17 @@ function parseRegistry(parsed: unknown): CodexPaneAccountRegistryFile { } for (const [ptyId, record] of Object.entries(panes)) { if (isPaneAccountRecord(record)) { + const homeRoute = readPaneHomeRoute(record.homeRoute) empty.panes[ptyId] = { selectionKey: record.selectionKey, accountId: record.accountId, - ...(isPaneHomeRoute(record.homeRoute) ? { homeRoute: record.homeRoute } : {}), - ...(isShellStartupHomeOverride(record.shellStartupHomeOverride) - ? { shellStartupHomeOverride: record.shellStartupHomeOverride } - : {}), - ...(isEnvironmentHomeOverride(record.environmentHomeOverride) - ? { environmentHomeOverride: record.environmentHomeOverride } - : {}) + ...(homeRoute ? { homeRoute } : {}) } } } return empty } -function isEnvironmentHomeOverride(value: unknown): value is CodexEnvironmentHomeOverride { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const context = value as Partial - return typeof context.codexHome === 'string' && context.codexHome.length > 0 -} - -function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupHomeOverride { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return false - } - const context = value as Partial - return ( - typeof context.home === 'string' && - context.home.length > 0 && - (context.shell === undefined || typeof context.shell === 'string') && - (context.configHome === undefined || typeof context.configHome === 'string') && - typeof context.codexHome === 'string' && - context.codexHome.length > 0 - ) -} - function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false @@ -180,14 +148,17 @@ function isPaneAccountRecord(value: unknown): value is CodexPaneAccountRecord { ) } -function isPaneHomeRoute(value: unknown): value is CodexPaneHomeRoute { - return ( - value === 'real-home' || +// Why: older builds wrote 'custom-home' for a shared-home pane with a pane-local CODEX_HOME. +function readPaneHomeRoute(value: unknown): CodexPaneHomeRoute | undefined { + if (value === 'custom-home') { + return 'shared-home' + } + return value === 'real-home' || value === 'shared-home' || value === 'account-home' || - value === 'custom-home' || value === 'wsl-home' - ) + ? value + : undefined } function writeRegistry(registry: CodexPaneAccountRegistryFile): boolean { @@ -242,7 +213,6 @@ export function getCodexPaneAccount(ptyId: string): CodexPaneAccountRecord | nul return readRegistry().panes[ptyId] ?? null } -/** `custom-home` stays conservative because it can mask a non-comparable shared-home route. */ export function isCodexPaneHomeRouteProvenAwayFromSharedHome( route: CodexPaneHomeRoute | undefined ): boolean { @@ -289,9 +259,7 @@ export function hasRecordedLegacySharedCodexPane(): boolean { return Object.values(readRegistry().panes).some( (record) => record.selectionKey === 'host' && - (record.homeRoute === undefined || - record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home') + (record.homeRoute === undefined || record.homeRoute === 'shared-home') ) } @@ -337,7 +305,6 @@ export function hasRecordedManagedHostCodexPane(): boolean { record.selectionKey === 'host' && (record.homeRoute === undefined || record.homeRoute === 'shared-home' || - record.homeRoute === 'custom-home' || (record.homeRoute === 'account-home' && record.accountId !== null)) ) } diff --git a/src/main/codex/codex-pane-launch-account.test.ts b/src/main/codex/codex-pane-launch-account.test.ts index dcf8ab00512..10d025876ca 100644 --- a/src/main/codex/codex-pane-launch-account.test.ts +++ b/src/main/codex/codex-pane-launch-account.test.ts @@ -75,34 +75,6 @@ describe('resolveCodexPaneLaunchAccount', () => { }) }) - it('records a non-comparable route for a pane-local custom home', () => { - expect( - resolveCodexPaneLaunchAccount({ - pinnedByResume: false, - launchCodexHomePath: '/data/codex-runtime-home/home', - recordComparableHomeRoute: false, - systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: null }), - target: { runtime: 'host' } - }) - ).toEqual({ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' }) - }) - - it('keeps an account-owned route comparable when a pane override is ignored', () => { - const accounts = [managedAccount({ id: 'account-a' })] - - expect( - resolveCodexPaneLaunchAccount({ - pinnedByResume: false, - launchCodexHomePath: '/data/codex-accounts/account-a/home', - recordComparableHomeRoute: false, - systemCodexHomePath: SYSTEM_HOME, - settings: settings({ host: 'account-a', accounts }), - target: { runtime: 'host' } - }) - ).toEqual({ selectionKey: 'host', accountId: 'account-a', homeRoute: 'account-home' }) - }) - it('maps a resume redirected to the real system home to the system-default account', () => { expect( resolveCodexPaneLaunchAccount({ diff --git a/src/main/codex/codex-pane-launch-account.ts b/src/main/codex/codex-pane-launch-account.ts index 2ceac661e4e..bef05d044a1 100644 --- a/src/main/codex/codex-pane-launch-account.ts +++ b/src/main/codex/codex-pane-launch-account.ts @@ -8,10 +8,6 @@ import { type CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import type { CodexPaneAccountRecord, CodexPaneHomeRoute } from './codex-pane-account-registry' -import type { - CodexEnvironmentHomeOverride, - CodexShellStartupHomeOverride -} from './codex-real-home-path' type CodexPaneLaunchAccountSettings = Pick< GlobalSettings, @@ -33,46 +29,21 @@ type CodexPaneLaunchAccountSettings = Pick< export function resolveCodexPaneLaunchAccount(args: { pinnedByResume: boolean launchCodexHomePath: string | null - recordComparableHomeRoute?: boolean - shellStartupHomeOverride?: CodexShellStartupHomeOverride - environmentHomeOverride?: CodexEnvironmentHomeOverride systemCodexHomePath: string settings: CodexPaneLaunchAccountSettings target: CodexAccountSelectionTarget }): CodexPaneAccountRecord | null { - const selectionKey = getCodexSelectionLaneKey(args.target) - const resolvedHomeRoute = resolveCodexPaneHomeRoute(args) - const homeRoute = - args.recordComparableHomeRoute === false && resolvedHomeRoute === 'shared-home' - ? 'custom-home' - : resolvedHomeRoute - if (!args.pinnedByResume) { - return { - selectionKey, - accountId: getSelectedCodexAccountIdForTarget(args.settings, args.target), - ...(homeRoute ? { homeRoute } : {}), - ...(args.shellStartupHomeOverride - ? { shellStartupHomeOverride: args.shellStartupHomeOverride } - : {}), - ...(args.environmentHomeOverride - ? { environmentHomeOverride: args.environmentHomeOverride } - : {}) - } + const accountId = args.pinnedByResume + ? resolveCodexHomeOwnerAccountId(args) + : getSelectedCodexAccountIdForTarget(args.settings, args.target) + if (accountId === undefined) { + return null + } + return { + selectionKey: getCodexSelectionLaneKey(args.target), + accountId, + homeRoute: resolveCodexPaneHomeRoute(args) } - const accountId = resolveCodexHomeOwnerAccountId(args) - return accountId === undefined - ? null - : { - selectionKey, - accountId, - ...(homeRoute ? { homeRoute } : {}), - ...(args.shellStartupHomeOverride - ? { shellStartupHomeOverride: args.shellStartupHomeOverride } - : {}), - ...(args.environmentHomeOverride - ? { environmentHomeOverride: args.environmentHomeOverride } - : {}) - } } function resolveCodexPaneHomeRoute(args: { diff --git a/src/main/codex/codex-real-home-path.test.ts b/src/main/codex/codex-real-home-path.test.ts index 25d4f2b5bba..7def880f767 100644 --- a/src/main/codex/codex-real-home-path.test.ts +++ b/src/main/codex/codex-real-home-path.test.ts @@ -3,10 +3,8 @@ import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { - getCustomCodexHomeOverrideForLaunch, hasCustomCodexHomeOverride, - hasCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideMatches + hasCustomCodexHomeOverrideForLaunch } from './codex-real-home-path' import { __resetShellStartupEnvCache } from '../pty/shell-startup-env' @@ -46,13 +44,10 @@ describe('hasCustomCodexHomeOverride', () => { ).toBe(true) }) - it('captures explicit environment provenance for restart comparison', () => { - const codexHome = join(process.cwd(), 'custom-codex-home') - - expect(getCustomCodexHomeOverrideForLaunch({ CODEX_HOME: codexHome })).toEqual({ - source: 'environment', - context: { codexHome } - }) + it('detects an explicit launch env CODEX_HOME', () => { + expect( + hasCustomCodexHomeOverrideForLaunch({ CODEX_HOME: join(process.cwd(), 'custom-codex-home') }) + ).toBe(true) }) it.skipIf(process.platform === 'win32')( @@ -62,26 +57,7 @@ describe('hasCustomCodexHomeOverride', () => { temporaryHomes.push(paneHome) writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n') - // Why cleared: the context records XDG_CONFIG_HOME, so a developer machine - // that sets one would otherwise change the recorded shape. - delete process.env.XDG_CONFIG_HOME expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true) - const override = getCustomCodexHomeOverrideForLaunch({ - HOME: paneHome, - SHELL: '/bin/zsh' - }) - expect(override).toEqual({ - source: 'shell-startup', - context: { - home: paneHome, - shell: '/bin/zsh', - codexHome: join(paneHome, 'custom-codex-home') - } - }) - expect( - override?.source === 'shell-startup' && - shellStartupCodexHomeOverrideMatches(override.context) - ).toBe(true) } ) @@ -99,12 +75,6 @@ describe('hasCustomCodexHomeOverride', () => { join(configHome, 'fish', 'config.fish'), 'set -gx CODEX_HOME "$HOME/custom-codex-home"\n' ) - // The decoy fish reads only if XDG_CONFIG_HOME is ignored. - mkdirSync(join(paneHome, '.config', 'fish'), { recursive: true }) - writeFileSync( - join(paneHome, '.config', 'fish', 'config.fish'), - 'set -gx CODEX_HOME /wrong-default-config-home\n' - ) delete process.env.XDG_CONFIG_HOME const launchEnv = { @@ -112,22 +82,7 @@ describe('hasCustomCodexHomeOverride', () => { SHELL: '/opt/homebrew/bin/fish', XDG_CONFIG_HOME: configHome } - const override = getCustomCodexHomeOverrideForLaunch(launchEnv) - - expect(override).toEqual({ - source: 'shell-startup', - context: { - home: paneHome, - shell: '/opt/homebrew/bin/fish', - configHome, - codexHome: join(paneHome, 'custom-codex-home') - } - }) - // And the recorded configHome is what a later re-check resolves against. - expect( - override?.source === 'shell-startup' && - shellStartupCodexHomeOverrideMatches(override.context) - ).toBe(true) + expect(hasCustomCodexHomeOverrideForLaunch(launchEnv)).toBe(true) } ) }) diff --git a/src/main/codex/codex-real-home-path.ts b/src/main/codex/codex-real-home-path.ts index bdc4ca3425c..fcb29644341 100644 --- a/src/main/codex/codex-real-home-path.ts +++ b/src/main/codex/codex-real-home-path.ts @@ -3,22 +3,6 @@ import { getSystemCodexHomePath } from './codex-home-paths' import { readBashStartupEnvVar, readShellStartupEnvVar } from '../pty/shell-startup-env' import { readPowerShellProfileEnvValues } from '../pty/powershell-profile-env' -export type CodexShellStartupHomeOverride = { - home: string - shell?: string - /** Why recorded: fish reads config under it, so re-reads must use the same root. */ - configHome?: string - codexHome: string -} - -export type CodexEnvironmentHomeOverride = { - codexHome: string -} - -export type CustomCodexHomeOverrideForLaunch = - | { source: 'environment'; context: CodexEnvironmentHomeOverride } - | { source: 'shell-startup'; context: CodexShellStartupHomeOverride } - /** True when the user points Codex outside its standard native home. */ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env): boolean { const codexHome = env.CODEX_HOME?.trim() @@ -36,100 +20,39 @@ export function hasCustomCodexHomeOverride(env: NodeJS.ProcessEnv = process.env) ) } +/** True when the launch env, or a shell startup file it reads, points Codex at a custom home. */ export function hasCustomCodexHomeOverrideForLaunch(launchEnv?: NodeJS.ProcessEnv): boolean { - return getCustomCodexHomeOverrideForLaunch(launchEnv) !== null -} - -export function getCustomCodexHomeOverrideForLaunch( - launchEnv?: NodeJS.ProcessEnv -): CustomCodexHomeOverrideForLaunch | null { - const effectiveEnv = { - CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'), - ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') - } - if (hasCustomCodexHomeOverride(effectiveEnv)) { - return { - source: 'environment', - context: { codexHome: effectiveEnv.CODEX_HOME!.trim() } - } + if ( + hasCustomCodexHomeOverride({ + CODEX_HOME: getLaunchEnvValue(launchEnv, 'CODEX_HOME'), + ORCA_CODEX_HOME: getLaunchEnvValue(launchEnv, 'ORCA_CODEX_HOME') + }) + ) { + return true } // Why USERPROFILE: Windows has no HOME, and PowerShell profiles hang off it. const home = getLaunchEnvValue(launchEnv, process.platform === 'win32' ? 'USERPROFILE' : 'HOME') - const shell = getLaunchEnvValue(launchEnv, 'SHELL') - const configHome = getLaunchEnvValue(launchEnv, 'XDG_CONFIG_HOME') - const [shellCodexHome] = readCustomShellStartupCodexHomes(home, shell, configHome) - if (!home || !shellCodexHome) { - return null - } - return { - source: 'shell-startup', - context: { - home, - ...(shell ? { shell } : {}), - ...(configHome ? { configHome } : {}), - codexHome: shellCodexHome - } - } -} - -export function environmentCodexHomeOverrideContextsEqual( - left: CodexEnvironmentHomeOverride, - right: CodexEnvironmentHomeOverride -): boolean { - return normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) -} - -export function shellStartupCodexHomeOverrideMatches( - context: CodexShellStartupHomeOverride, - currentContext: CodexShellStartupHomeOverride = context -): boolean { - if (!shellStartupCodexHomeOverrideContextsEqual(context, currentContext)) { + if (!home) { return false } - return readCustomShellStartupCodexHomes( - currentContext.home, - currentContext.shell, - currentContext.configHome - ).some( - (codexHome) => - normalizePathForComparison(codexHome) === normalizePathForComparison(context.codexHome) - ) -} - -export function shellStartupCodexHomeOverrideContextsEqual( - left: CodexShellStartupHomeOverride, - right: CodexShellStartupHomeOverride -): boolean { - return ( - normalizePathForComparison(left.home) === normalizePathForComparison(right.home) && - left.shell === right.shell && - left.configHome === right.configHome && - normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome) - ) -} - -/** - * Custom CODEX_HOMEs the pane's shell startup may set. A Windows pane may run - * either PowerShell edition or Git Bash, so any of their startup files counts. - */ -function readCustomShellStartupCodexHomes( - home: string | undefined, - shell: string | undefined, - configHome: string | undefined -): string[] { - if (!home) { - return [] - } - const candidates = + // Why: a Windows pane may run either PowerShell edition or Git Bash, so any of their + // startup files counts. + const shellCodexHomes = process.platform === 'win32' ? [ ...readPowerShellProfileEnvValues('CODEX_HOME', home), readBashStartupEnvVar('CODEX_HOME', home) ] - : [readShellStartupEnvVar('CODEX_HOME', home, shell, configHome)] - return candidates.filter( - (codexHome): codexHome is string => - codexHome !== undefined && hasCustomCodexHomeOverride({ CODEX_HOME: codexHome }) + : [ + readShellStartupEnvVar( + 'CODEX_HOME', + home, + getLaunchEnvValue(launchEnv, 'SHELL'), + getLaunchEnvValue(launchEnv, 'XDG_CONFIG_HOME') + ) + ] + return shellCodexHomes.some( + (codexHome) => codexHome !== undefined && hasCustomCodexHomeOverride({ CODEX_HOME: codexHome }) ) } diff --git a/src/main/codex/codex-shared-server-pane.test.ts b/src/main/codex/codex-shared-server-pane.test.ts index c378a6763b3..5998226e745 100644 --- a/src/main/codex/codex-shared-server-pane.test.ts +++ b/src/main/codex/codex-shared-server-pane.test.ts @@ -88,25 +88,6 @@ describe('findPaneCodex', () => { describe('resolveCodexPaneHome', () => { it.each([ [{ selectionKey: 'host', accountId: null, homeRoute: 'real-home' }, '/home/me/.codex'], - [ - { - selectionKey: 'host', - accountId: null, - homeRoute: 'real-home', - environmentHomeOverride: { codexHome: '/custom/codex' } - }, - '/custom/codex' - ], - [ - { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home', - shellStartupHomeOverride: { home: '/home/me', codexHome: '/rc/codex' } - }, - '/rc/codex' - ], - [{ selectionKey: 'host', accountId: null, homeRoute: 'custom-home' }, null], // Orca's mirror: a fallback lane or a pre-upgrade pane's retired home. [{ selectionKey: 'host', accountId: null, homeRoute: 'shared-home' }, null], [{ selectionKey: 'host', accountId: 'acct', homeRoute: 'account-home' }, null], diff --git a/src/main/codex/codex-shared-server-pane.ts b/src/main/codex/codex-shared-server-pane.ts index 1f9bc68f681..ca246484131 100644 --- a/src/main/codex/codex-shared-server-pane.ts +++ b/src/main/codex/codex-shared-server-pane.ts @@ -71,13 +71,10 @@ export function resolveCodexPaneHome(ptyId: string): string | null { if (record?.selectionKey !== 'host') { return null } - const customHome = - record.environmentHomeOverride?.codexHome ?? record.shellStartupHomeOverride?.codexHome switch (record.homeRoute) { + // Why: a custom CODEX_HOME routes a pane to Orca's mirror, so real-home is always ~/.codex. case 'real-home': - return customHome ?? getSystemCodexHomePath() - case 'custom-home': - return customHome ?? null + return getSystemCodexHomePath() // Why: an unnamed home (managed account, WSL, pre-route record) skips the // warning rather than probing the wrong server. Orca's mirror (shared-home) // gets none either, as macOS and Linux already did: it is a fallback lane diff --git a/src/main/codex/codex-stale-pane-accounts.test.ts b/src/main/codex/codex-stale-pane-accounts.test.ts index 7a87fee713c..ac21c3d5604 100644 --- a/src/main/codex/codex-stale-pane-accounts.test.ts +++ b/src/main/codex/codex-stale-pane-accounts.test.ts @@ -53,7 +53,6 @@ describe('codex pane account registry', () => { ['account-home', true], ['wsl-home', true], ['shared-home', false], - ['custom-home', false], [undefined, false] ] as const)('classifies whether %s proves a pane avoided the shared home', (route, expected) => { expect(isCodexPaneHomeRouteProvenAwayFromSharedHome(route)).toBe(expected) @@ -63,12 +62,7 @@ describe('codex pane account registry', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: 'account-a', - homeRoute: 'account-home', - shellStartupHomeOverride: { - home: '/pane-home', - shell: '/bin/zsh', - codexHome: '/pane-home/custom-codex-home' - } + homeRoute: 'account-home' }) _internals.resetCache() @@ -76,12 +70,7 @@ describe('codex pane account registry', () => { expect(getCodexPaneAccount('pty-1')).toEqual({ selectionKey: 'host', accountId: 'account-a', - homeRoute: 'account-home', - shellStartupHomeOverride: { - home: '/pane-home', - shell: '/bin/zsh', - codexHome: '/pane-home/custom-codex-home' - } + homeRoute: 'account-home' }) }) @@ -99,6 +88,23 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(true) }) + it('reads a custom-home record from an older build as the shared home it was', () => { + writeFileSync( + join(userDataPath, 'codex-pane-accounts.json'), + JSON.stringify({ + version: 2, + panes: { 'pty-1': { selectionKey: 'host', accountId: null, homeRoute: 'custom-home' } } + }) + ) + _internals.resetCache() + + expect(getCodexPaneAccount('pty-1')).toEqual({ + selectionKey: 'host', + accountId: null, + homeRoute: 'shared-home' + }) + }) + it('runs legacy reconciliation only for host panes that may use the shared home', () => { recordCodexPaneAccount('pty-real', { selectionKey: 'host', @@ -118,17 +124,6 @@ describe('codex pane account registry', () => { expect(hasRecordedLegacySharedCodexPane()).toBe(false) - recordCodexPaneAccount('pty-custom', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - - expect(hasRecordedLegacySharedCodexPane()).toBe(true) - - forgetCodexPaneAccount('pty-custom') - expect(hasRecordedLegacySharedCodexPane()).toBe(false) - recordCodexPaneAccount('pty-shared', { selectionKey: 'host', accountId: null, @@ -359,8 +354,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-1', launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' + activeAccountId: 'account-b' } ]) }) @@ -374,8 +368,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-1', launchAccountId: 'account-a', - activeAccountId: null, - reason: 'account-change' + activeAccountId: null } ]) }) @@ -388,7 +381,7 @@ describe('listStaleCodexPanes', () => { ).toEqual([]) }) - it('reports a system-default pane after its home route changes', () => { + it('leaves a pane on an older Codex home alone while its account is unchanged', () => { recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null, @@ -396,86 +389,7 @@ describe('listStaleCodexPanes', () => { }) expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: null, - reason: 'home-route-change' - } - ]) - }) - - it('keeps account-switch copy when the account and home route both change', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'real-home' - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection('account-a'), - activeHostHomeRoute: 'account-home' - }) - ).toEqual([ - { - ptyId: 'pty-1', - launchAccountId: null, - activeAccountId: 'account-a', - reason: 'account-change' - } - ]) - }) - - it('does not guess a route for panes recorded before route provenance', () => { - recordCodexPaneAccount('pty-1', { selectionKey: 'host', accountId: null }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([]) - }) - - it('does not compare a pane-local custom home with the selected host route', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'real-home' - }) - ).toEqual([]) - }) - - it('leaves a retained pane alone while its process CODEX_HOME is unchanged', () => { - recordCodexPaneAccount('pty-1', { - selectionKey: 'host', - accountId: null, - homeRoute: 'shared-home', - environmentHomeOverride: { codexHome: '/custom/codex-home' } - }) - - expect( - listStaleCodexPanes({ - ptyIds: ['pty-1'], - settings: settingsWithSelection(null), - activeHostHomeRoute: 'shared-home' - }) + listStaleCodexPanes({ ptyIds: ['pty-1'], settings: settingsWithSelection(null) }) ).toEqual([]) }) @@ -502,8 +416,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-2', launchAccountId: 'account-a', - activeAccountId: 'account-b', - reason: 'account-change' + activeAccountId: 'account-b' } ]) }) @@ -523,8 +436,7 @@ describe('listStaleCodexPanes', () => { { ptyId: 'pty-2', launchAccountId: 'account-c', - activeAccountId: 'account-d', - reason: 'account-change' + activeAccountId: 'account-d' } ]) }) diff --git a/src/main/codex/codex-stale-pane-accounts.ts b/src/main/codex/codex-stale-pane-accounts.ts index c7e789b3b9b..99a9cdf422a 100644 --- a/src/main/codex/codex-stale-pane-accounts.ts +++ b/src/main/codex/codex-stale-pane-accounts.ts @@ -2,25 +2,23 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { getSelectedCodexAccountIdForTarget } from '../codex-accounts/runtime-selection' import { forgetCodexPaneAccount, - listRecordedCodexPaneAccounts, - type CodexPaneHomeRoute + listRecordedCodexPaneAccounts } from './codex-pane-account-registry' export type StaleCodexPane = { ptyId: string launchAccountId: string | null activeAccountId: string | null - reason: 'account-change' | 'home-route-change' } /** * Reports which of the given PTYs still launch Codex as a previously selected * account, so the restart prompt survives an app restart the shells outlive. + * Why only the account: a pane on an older Codex home keeps working, refreshed from ~/.codex. */ export function listStaleCodexPanes(args: { ptyIds: readonly string[] settings: GlobalSettings - activeHostHomeRoute?: CodexPaneHomeRoute }): StaleCodexPane[] { const stalePanes: StaleCodexPane[] = [] const records = listRecordedCodexPaneAccounts(args.ptyIds) @@ -33,20 +31,8 @@ export function listStaleCodexPanes(args: { args.settings, parseSelectionLaneKey(record.selectionKey) ) - const homeRouteChanged = - record.selectionKey === 'host' && - record.homeRoute !== undefined && - record.homeRoute !== 'custom-home' && - args.activeHostHomeRoute !== undefined && - record.homeRoute !== args.activeHostHomeRoute - const accountChanged = record.accountId !== activeAccountId - if (accountChanged || homeRouteChanged) { - stalePanes.push({ - ptyId, - launchAccountId: record.accountId, - activeAccountId, - reason: accountChanged ? 'account-change' : 'home-route-change' - }) + if (record.accountId !== activeAccountId) { + stalePanes.push({ ptyId, launchAccountId: record.accountId, activeAccountId }) } } return stalePanes diff --git a/src/main/ipc/codex-accounts.ts b/src/main/ipc/codex-accounts.ts index 4d828ecf82b..fc1ec0bcb70 100644 --- a/src/main/ipc/codex-accounts.ts +++ b/src/main/ipc/codex-accounts.ts @@ -17,8 +17,7 @@ export function registerCodexAccountHandlers( } return listStaleCodexPanes({ ptyIds: args.ptyIds.filter((ptyId): ptyId is string => typeof ptyId === 'string'), - settings, - activeHostHomeRoute: codexAccounts.runtimeHomeService.getSelectedHostCodexHomeRoute() + settings }) }) ipcMain.handle('codexAccounts:listRecordedPaneLanes', (_event, args: { ptyIds?: unknown }) => { diff --git a/src/main/ipc/pty-codex-account-attribution.test.ts b/src/main/ipc/pty-codex-account-attribution.test.ts index 353be0f5909..0e109a468e1 100644 --- a/src/main/ipc/pty-codex-account-attribution.test.ts +++ b/src/main/ipc/pty-codex-account-attribution.test.ts @@ -62,7 +62,7 @@ const SHARED_ROLLOUT = join(MANAGED_SHARED_HOME, 'sessions', '2026', '07', '20', describe('registerPtyHandlers', () => { const { handlers, mainWindow } = setupPtyIpcSuite() - it('records route provenance for a process-wide CODEX_HOME', async () => { + it('records a launch env CODEX_HOME pane under the shared home', async () => { const previousCodexHome = process.env.CODEX_HOME process.env.CODEX_HOME = '/process/custom-codex-home' try { @@ -94,8 +94,7 @@ describe('registerPtyHandlers', () => { expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-process-home', { selectionKey: 'host', accountId: null, - homeRoute: 'shared-home', - environmentHomeOverride: { codexHome: '/process/custom-codex-home' } + homeRoute: 'shared-home' }) } finally { if (previousCodexHome === undefined) { @@ -105,33 +104,6 @@ describe('registerPtyHandlers', () => { } } }) - it('does not guess route provenance for a pane-local environment CODEX_HOME', async () => { - setLocalPtyProvider({ - spawn: vi.fn(async () => ({ id: 'pty-pane-env-home' })), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - shutdown: vi.fn(), - onData: vi.fn(() => vi.fn()), - onExit: vi.fn(() => vi.fn()), - listProcesses: vi.fn(async () => []), - getForegroundProcess: vi.fn(async () => null) - } as never) - const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) - registerPtyHandlers(mainWindow as never, undefined, () => TEST_CODEX_HOME, getSettings as never) - - await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - env: { CODEX_HOME: '/pane/custom-codex-home' } - }) - - expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-pane-env-home', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - }) it('does not resume under another account when the origin auth stays unavailable', async () => { vi.useFakeTimers() const spawn = vi.fn(async () => ({ id: 'pty-must-not-spawn' })) diff --git a/src/main/ipc/pty-write-ipc-validation.test.ts b/src/main/ipc/pty-write-ipc-validation.test.ts index ee05fc62c60..4217530c464 100644 --- a/src/main/ipc/pty-write-ipc-validation.test.ts +++ b/src/main/ipc/pty-write-ipc-validation.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { readFileSyncMock, spawnMock, recordCodexPaneAccountMock } from './pty-ipc-mock-registry' -import { posixOnlyIt, TEST_CODEX_HOME } from './pty-ipc-test-constants' +import { spawnMock, recordCodexPaneAccountMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -394,47 +393,4 @@ describe('registerPtyHandlers', () => { ensureForDistro.mockRestore() } }) - posixOnlyIt( - 'does not guess route provenance for a pane-local shell startup CODEX_HOME', - async () => { - setLocalPtyProvider({ - spawn: vi.fn(async () => ({ id: 'pty-custom-home' })), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - shutdown: vi.fn(), - onData: vi.fn(() => vi.fn()), - onExit: vi.fn(() => vi.fn()), - listProcesses: vi.fn(async () => []), - getForegroundProcess: vi.fn(async () => null) - } as never) - readFileSyncMock.mockImplementation((path: string) => - path === '/pane-home/.zshrc' ? 'export CODEX_HOME="$HOME/custom-codex-home"\n' : '' - ) - const getSettings = vi.fn().mockReturnValue({ activeCodexManagedAccountId: null }) - registerPtyHandlers( - mainWindow as never, - undefined, - () => TEST_CODEX_HOME, - getSettings as never - ) - - await handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - env: { - CODEX_HOME: '', - ORCA_CODEX_HOME: '', - HOME: '/pane-home', - SHELL: '/bin/zsh' - } - }) - - expect(recordCodexPaneAccountMock).toHaveBeenCalledWith('pty-custom-home', { - selectionKey: 'host', - accountId: null, - homeRoute: 'custom-home' - }) - } - ) }) diff --git a/src/main/ipc/pty/host-env/codex-home.ts b/src/main/ipc/pty/host-env/codex-home.ts index fbc0db2a04d..3b79c0e7876 100644 --- a/src/main/ipc/pty/host-env/codex-home.ts +++ b/src/main/ipc/pty/host-env/codex-home.ts @@ -13,11 +13,6 @@ import { } from '../../../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../../../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../../../codex/codex-home-paths' -import { - environmentCodexHomeOverrideContextsEqual, - getCustomCodexHomeOverrideForLaunch, - shellStartupCodexHomeOverrideContextsEqual -} from '../../../codex/codex-real-home-path' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../../../pty/codex-home-wsl-env' import { isWslShellName } from '../../../../shared/local-windows-terminal-runtime' import { parseWslPath } from '../../../wsl' @@ -217,47 +212,16 @@ export function recordCodexPaneAccountForSpawn(args: { isReattach: boolean pinnedByResume: boolean launchCodexHomePath: string | null - launchEnv?: NodeJS.ProcessEnv target: CodexAccountSelectionTarget settings: GlobalSettings | undefined }): void { if (!args.ptyId || !args.isDaemonHostSpawn || args.isReattach) { return } - const customHomeOverride = getCustomCodexHomeOverrideForLaunch(args.launchEnv) - const processHomeOverride = customHomeOverride ? getCustomCodexHomeOverrideForLaunch() : null - const recheckableEnvironmentOverride = - customHomeOverride?.source === 'environment' && - processHomeOverride?.source === 'environment' && - environmentCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined - const recheckableShellStartupOverride = - customHomeOverride?.source === 'shell-startup' && - processHomeOverride?.source === 'shell-startup' && - shellStartupCodexHomeOverrideContextsEqual( - customHomeOverride.context, - processHomeOverride.context - ) - ? customHomeOverride.context - : undefined const record = args.settings ? resolveCodexPaneLaunchAccount({ pinnedByResume: args.pinnedByResume, launchCodexHomePath: args.launchCodexHomePath, - // Why: pane-local overrides cannot be re-derived when a restart builds - // a fresh launch env, so route prompts would guess and block valid input. - recordComparableHomeRoute: - args.pinnedByResume || - ((customHomeOverride?.source !== 'environment' || - recheckableEnvironmentOverride !== undefined) && - (customHomeOverride?.source !== 'shell-startup' || - recheckableShellStartupOverride !== undefined)), - shellStartupHomeOverride: args.pinnedByResume ? undefined : recheckableShellStartupOverride, - environmentHomeOverride: args.pinnedByResume ? undefined : recheckableEnvironmentOverride, systemCodexHomePath: getSystemCodexHomePath(), settings: args.settings, target: args.target diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 41752886ab9..bde05540e27 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -103,7 +103,6 @@ export function publishPtyIpcSpawnCommit(ctx: PtyIpcSpawnState, committedSize: P isReattach: ctx.result.isReattach === true, pinnedByResume: ctx.codexResumeHomeSelected, launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: ctx.baseEnv, target: ctx.codexSelectionTarget, settings: ctx.deps.getSettings?.() }) diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 293166d8163..e7a6de5e7bc 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -229,7 +229,6 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) { isReattach: ctx.result.isReattach === true, pinnedByResume: ctx.codexResumeHomeSelected, launchCodexHomePath: ctx.selectedCodexHomePath, - launchEnv: args.env, target: ctx.codexSelectionTarget, settings: ctx.deps.getSettings?.() }) diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index 052fcdf2edc..749c6a23f22 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -34,8 +34,6 @@ export type CodexAccountsApi = { ptyId: string launchAccountId: string | null activeAccountId: string | null - /** Optional for compatibility with a pre-reason main process. */ - reason?: 'account-change' | 'home-route-change' }[] > /** The selection lane each PTY launched from, keyed by pty id; unrecorded panes are absent. */ diff --git a/src/preload/api/codex-accounts-bridge.ts b/src/preload/api/codex-accounts-bridge.ts index d5ca98cdf76..e94b5f35b87 100644 --- a/src/preload/api/codex-accounts-bridge.ts +++ b/src/preload/api/codex-accounts-bridge.ts @@ -30,7 +30,6 @@ export const codexAccountsApi = { ptyId: string launchAccountId: string | null activeAccountId: string | null - reason?: 'account-change' | 'home-route-change' }[] > => ipcRenderer.invoke('codexAccounts:listStalePanes', args), listRecordedPaneLanes: (args: { ptyIds: string[] }): Promise> => diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index cc90244579e..e4109551bf3 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button' import { useAppStore } from '../store' import { translate } from '@/i18n/i18n' import { shouldFocusMobileDriverAction } from './terminal-pane/mobile-driver-overlay-focus' -import { buildCodexRestartNoticeKey } from './codex-restart-notice-key' import { awaitsCodexRestartAnswer } from './codex-restart-notice-state' function isInsideHiddenTree(element: HTMLElement): boolean { @@ -14,7 +13,6 @@ function isInsideHiddenTree(element: HTMLElement): boolean { type RestartNotice = { previousAccountLabel: string nextAccountLabel: string - homeRouteChanged?: true } export default function CodexRestartChip({ @@ -53,7 +51,7 @@ export default function CodexRestartChip({ return ( void @@ -98,7 +96,13 @@ function LoudRestartOverlay({ if (shouldFocusMobileDriverAction(document.activeElement, document.body, paneScope)) { root.focus() } - }, [isVisible, noticeKey, shouldFocus]) + }, [ + isVisible, + ptyId, + restartNotice.previousAccountLabel, + restartNotice.nextAccountLabel, + shouldFocus + ]) return (
- {restartNotice.homeRouteChanged - ? translate('auto.components.CodexRestartChip.8f0d5c92a1', 'Codex setup changed') - : translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')} + {translate('auto.components.CodexRestartChip.d3e8a1f4b2', 'Account switched')}
- {restartNotice.homeRouteChanged - ? translate( - 'auto.components.CodexRestartChip.3ea91b5c07', - 'This Codex session is using an outdated configuration' - ) - : translate( - 'auto.components.CodexRestartChip.a4c8e1b2f7', - 'Codex is still signed in as {{value0}}', - { value0: restartNotice.previousAccountLabel } - )} + {translate( + 'auto.components.CodexRestartChip.a4c8e1b2f7', + 'Codex is still signed in as {{value0}}', + { value0: restartNotice.previousAccountLabel } + )}
- {restartNotice.homeRouteChanged - ? translate( - 'auto.components.CodexRestartChip.e6b7139d2a', - 'Restart this session to load your current Codex configuration.' - ) - : translate( - 'auto.components.CodexRestartChip.9375620cc3', - 'Restart this session to use {{value0}}. It stays on the previous account until you do.', - { value0: restartNotice.nextAccountLabel } - )} + {translate( + 'auto.components.CodexRestartChip.9375620cc3', + 'Restart this session to use {{value0}}. It stays on the previous account until you do.', + { value0: restartNotice.nextAccountLabel } + )}