From adccab001a184aa2b238473d94582de1d42b5aed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:29:06 -0700 Subject: [PATCH 1/7] test(cross-version-wire): cover agent status, worktree rows and the relay The gate that decides which PRs run the cross-version wire job listed 23 prefixes and none of them reached the agent-status store, the worktree/mobile row contract, or the relay transport. A change to any of those could break a released peer with nothing in CI to say so. Add those prefixes, and give them something that can fail: two new pairings against the newest release, one over the agent-status snapshot, mutation envelope and status payload, one over the relay handshake and JSON-RPC framing. Reaching a frozen relay daemon needs src/relay in the extracted tree, so the harness now archives it. Also fix the lane itself. The unit shards exclude this directory, so the job's vitest argv was the only thing deciding whether a suite ran, and it was a curated list of seven paths while the directory held nine. The two unlisted suites executed nowhere; one could not have passed if it had, because it extracts a checkout no sibling shares and had no timeout to cover it. The step now names the directory, and a new guard keeps the two in step. --- .github/workflows/pr.yml | 14 +- ...ss-version-wire-lane-registration.test.mjs | 115 +++++++++++ config/scripts/pr-code-change-scope.mjs | 13 ++ config/scripts/pr-code-change-scope.test.mjs | 53 ++++- docs/reference/remote-wire-compatibility.md | 34 +++- ...oss-version-agent-status-wire.unit.test.ts | 166 +++++++++++++++ ...-version-relay-transport-wire.unit.test.ts | 190 ++++++++++++++++++ ...hestration-delivery-downgrade.unit.test.ts | 116 ++++++----- .../release-checkout-tree.ts | 22 +- .../cross-version-wire/release-checkout.ts | 2 +- .../versioned-agent-status-wire.ts | 147 ++++++++++++++ 11 files changed, 801 insertions(+), 71 deletions(-) create mode 100644 config/scripts/cross-version-wire-lane-registration.test.mjs create mode 100644 tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/versioned-agent-status-wire.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4eee03c5632d..081cba4896cc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -748,16 +748,16 @@ jobs: # A path filter that matches nothing exits 1 ("No test files found"), so this # lane cannot report success while running zero tests. + # + # The whole directory, not a curated file list. The unit shards exclude this directory + # wholesale, so this is the only lane that runs it, and a curated list made "in the + # directory" and "runs somewhere" two different facts — two suites had already drifted + # into the gap and executed on no machine. The directory is the same boundary the shard + # exclusion uses, so a file cannot land between them. - name: Old/new client and server compatibility journeys run: >- pnpm exec vitest run --config config/vitest.config.ts - tests/e2e/cross-version-wire/release-checkout.unit.test.ts - tests/e2e/cross-version-wire/cross-version-browser-placement.unit.test.ts - tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts - tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts - tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts - tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts - tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts + tests/e2e/cross-version-wire/ managed_hook_node18: name: managed hooks on Node 18 diff --git a/config/scripts/cross-version-wire-lane-registration.test.mjs b/config/scripts/cross-version-wire-lane-registration.test.mjs new file mode 100644 index 000000000000..fd3e64bb46d2 --- /dev/null +++ b/config/scripts/cross-version-wire-lane-registration.test.mjs @@ -0,0 +1,115 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { classifyPrJobs } from './pr-code-change-scope.mjs' + +/** + * Every cross-version wire suite must run on some machine. + * + * PR CI runs `tests/e2e/**` in the sharded unit job, EXCEPT this directory: the suites here + * skew current code against an extracted release, which needs the full history and tags the + * shard's shallow clone does not have. So the shard excludes the directory wholesale and the + * `cross-version-wire` job is the only lane that runs it. + * + * That made the job's vitest argv the single thing deciding whether a file executes, and for + * a while the argv was a curated list of seven paths while the directory held nine suites. + * `orchestration-delivery-downgrade` and `published-field-shape` ran on no machine and + * reported nothing; the first could not have passed if it had run, because it extracts a + * checkout no sibling shares and had no timeout to cover it. Nobody could find that out. + * + * Both halves matter and being in one is not enough: `CROSS_VERSION_WIRE_PREFIXES` in + * pr-code-change-scope.mjs decides whether the job RUNS for a diff, and the argv decides + * whether the FILE runs once the job started. + */ + +const projectDir = resolve(import.meta.dirname, '../..') +const LANE_JOB = 'cross-version-wire' +const LANE_STEP = 'Old/new client and server compatibility journeys' +const LANE_DIRECTORY = 'tests/e2e/cross-version-wire' +const UNIT_TEST_WORKFLOW = '.github/workflows/unit-tests.yml' + +/** What `config/vitest.config.ts` collects from `tests/e2e/**`. */ +const SUITE_PATTERN = /\.unit\.test\.ts$/ + +/** + * Floor for the suite population, so a broken directory read cannot make every assertion + * below vacuous by finding nothing. Only ever lowered when a suite is genuinely deleted. + */ +const SUITE_FLOOR = 9 + +function readWorkflow(relativePath) { + return parse(readFileSync(join(projectDir, relativePath), 'utf8')) +} + +/** The vitest argv of the lane's one test step. */ +function readLaneArgv() { + const workflow = readWorkflow('.github/workflows/pr.yml') + const job = workflow.jobs?.[LANE_JOB] + if (!job) { + throw new Error( + `No ${LANE_JOB} job in .github/workflows/pr.yml. If it was renamed, update LANE_JOB here -- do not delete this guard.` + ) + } + const step = (job.steps ?? []).find((candidate) => candidate?.name === LANE_STEP) + if (!step) { + throw new Error( + `No "${LANE_STEP}" step in the ${LANE_JOB} job. If it was renamed, update LANE_STEP here -- do not delete this guard.` + ) + } + const run = String(step.run ?? '') + if (!run.includes('vitest run')) { + throw new Error(`The "${LANE_STEP}" step no longer invokes vitest; this guard is stale.`) + } + return run.split(/\s+/).filter((token) => token.startsWith(`${LANE_DIRECTORY}`)) +} + +const laneArgv = readLaneArgv() +const suites = readdirSync(join(projectDir, LANE_DIRECTORY)) + .filter((name) => SUITE_PATTERN.test(name)) + .map((name) => `${LANE_DIRECTORY}/${name}`) + .sort() + +/** A vitest positional selects a file when it names the file or a directory above it. */ +function selectedByLane(path) { + return laneArgv.some((token) => path === token || path.startsWith(token)) +} + +describe('cross-version wire suites run somewhere', () => { + it('reads a plausible number of suites', () => { + expect(suites.length).toBeGreaterThanOrEqual(SUITE_FLOOR) + }) + + it('selects every suite in the directory from the lane argv', () => { + expect(laneArgv.length).toBeGreaterThan(0) + expect(suites.filter((path) => !selectedByLane(path))).toEqual([]) + }) + + it('names no argv path that is not a suite on disk', () => { + // A curated list rots the other way too: a renamed file leaves a filter matching nothing, + // and vitest then fails the whole lane rather than quietly running the rest. + const strays = laneArgv.filter( + (token) => + token !== `${LANE_DIRECTORY}/` && token !== LANE_DIRECTORY && !suites.includes(token) + ) + expect(strays).toEqual([]) + }) + + it('keeps the unit shards excluding the directory, which is what makes this lane the only one', () => { + const workflow = readWorkflow(UNIT_TEST_WORKFLOW) + const shardStep = (workflow.jobs?.test?.steps ?? []).find( + (step) => typeof step?.run === 'string' && step.run.includes('vitest run') + ) + expect(shardStep, `no sharded vitest step in ${UNIT_TEST_WORKFLOW}`).toBeDefined() + // If this exclusion is ever dropped the suites gain a second lane, and the argv stops + // being the only thing that decides whether a file runs. Revisit this guard, do not + // delete it: the shallow shard clone has no tags for an extracted release to skew against. + expect(String(shardStep.run)).toContain(`--exclude=${LANE_DIRECTORY}/**`) + }) + + it('runs the lane for a change to any suite it owns', () => { + for (const path of suites) { + expect(classifyPrJobs([path])[LANE_JOB], path).toBe(true) + } + }) +}) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index a2dfcc0082ff..8aad5cd26fb7 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -131,6 +131,19 @@ function changesMobileWebApp(changedFiles) { const CROSS_VERSION_WIRE_PREFIXES = [ 'tests/e2e/cross-version-wire/', 'src/shared/protocol-version', + // The execution host owns agent status in one store and every reader subscribes to it, so a + // remote host and its client each run a copy that updates on its own schedule. The snapshot + // and mutation decoders refuse a key they do not know, which makes a new field on either one + // a break rather than Rule 1, and the row's status arms reach the worktree listing. + 'src/shared/agent-status', + 'src/shared/runtime-worktree-contracts', + // The daemon keeps running on the remote host across a desktop update, so its framing, + // handshake and version constant meet a bridge from another build as a matter of course. + 'src/main/ssh/relay-protocol', + 'src/shared/relay-frame-decoder', + 'src/relay/protocol', + 'src/relay/relay-frame-decoder', + 'src/relay/relay-handshake', 'src/shared/terminal-stream-protocol', 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index dbf90055c574..72f541c78e4c 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -325,6 +325,57 @@ describe('per-job path classification', () => { ) }) + it('runs cross-version wire checks for the agent-status, worktree-row and relay surfaces', () => { + // Each path is checked against the working tree first. A list of paths that no longer + // exist classifies exactly the same as one that does, so without this the assertions + // below would keep passing after the files they name were renamed away. + const files = [ + // The store a remote host and its client each keep a copy of. Its snapshot and mutation + // decoders refuse an unknown key, so a field added to either breaks the older peer. + 'src/shared/agent-status-store.ts', + 'src/shared/agent-status-store-codec.ts', + 'src/shared/agent-status-store-contract.ts', + 'src/shared/agent-status-transport-envelope.ts', + 'src/shared/agent-status-child-work-codec.ts', + 'src/shared/agent-status-subject.ts', + // The arm set and payload parser behind every row the sidebar, `worktree ps`, mobile + // and the dashboard read. + 'src/shared/agent-status-types.ts', + 'src/shared/runtime-worktree-contracts.ts', + // The desktop half of the relay framing, and the codec both halves share. + 'src/main/ssh/relay-protocol.ts', + 'src/shared/relay-frame-decoder.ts' + ] + // The relay daemon outlives a desktop update, so its framing and handshake meet a bridge + // from another build. Its tree also primes the Node 18 companion smoke. + const relayFiles = [ + 'src/relay/protocol.ts', + 'src/relay/relay-frame-decoder.ts', + 'src/relay/relay-handshake.ts' + ] + expect([...files, ...relayFiles].filter((file) => !existsSync(join(projectDir, file)))).toEqual( + [] + ) + for (const file of files) { + expectClassification([file], { + 'cross-version-wire': true, + package: true, + package_windows: true + }) + } + for (const file of relayFiles) { + expectClassification([file], { + 'cross-version-wire': true, + managed_hook_node18: true, + package: true, + package_windows: true + }) + } + // The relay's RPC handlers are not this job's surface: nothing it runs can fail on them, + // and gating them here would report a pass about a wire it never touched. + expect(classifyPrJobs(['src/relay/git-handler.ts'])['cross-version-wire']).toBe(false) + }) + it('runs workflow-self-change and lockfile diffs as force-all', () => { const result = classifyPrJobs(['.github/workflows/pr.yml']) expect(result.should_run).toBe(true) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 2107120b0927..fce7c2bc8625 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -190,10 +190,36 @@ Run it with: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts ``` -The harness covers the terminal stream and the structured agent-session surface. It does -**not** cover the session-tab sync channel, legacy agent-session publications, file or Git -RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its -own reasoning against the three rules above. +`tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts` pairs the same +two builds over the agent-status store. The execution host owns status in one store and +every reader subscribes to it, so a remote host and its client each keep a copy that updates +on its own schedule. Rule 1 does not hold on this surface: the snapshot and mutation-envelope +decoders refuse a payload carrying a key they do not know, so an added field breaks the older +peer rather than being ignored by it. The suite carries a snapshot, the mutation envelope that +follows it, and an OSC status payload in both directions, and checks that no arm the baseline +can send has been dropped. + +`tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts` pairs the relay. +It is the one wire whose endpoints are not both the app: the daemon lives on the remote host +and keeps running across a desktop update, which is why the handshake carries a version and the +bridge has a dedicated exit code for refusing a mismatched one. The suite skews the handshake +and a desktop JSON-RPC frame in both directions. Reaching the frozen daemon needs `src/relay` +in the extracted tree, so `ARCHIVE_PATHS` in `release-checkout-tree.ts` includes it. + +The harness covers the terminal stream, the structured agent-session surface, the agent-status +store and the relay handshake and framing. It does **not** cover the session-tab sync channel, +legacy agent-session publications, file or Git RPCs, mobile/E2EE framing, or what the relay +*publishes* over its transport — the workspace snapshot, hook envelopes and pty publications are +Rule 3 surfaces with no pairing yet. A change on those paths still needs its own reasoning +against the rules above. + +Every suite in `tests/e2e/cross-version-wire/` runs in the `cross-version-wire` PR job and +nowhere else — the unit shards exclude the directory, because a shallow shard clone has no tags +for an extracted release to skew against. The job's vitest argv is therefore the only thing +deciding whether a file executes, so it names the directory rather than a list of files, and +`config/scripts/cross-version-wire-lane-registration.test.mjs` holds that shut. Which diffs +start the job is a separate list, `CROSS_VERSION_WIRE_PREFIXES` in +`config/scripts/pr-code-change-scope.mjs`; a wire module missing from it runs no pairing at all. ## Worked example: `agentWait` on terminal and worker reads diff --git a/tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts new file mode 100644 index 000000000000..f22305ce7ea2 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts @@ -0,0 +1,166 @@ +// Cross-version coverage for the agent-status wire, paired the way the terminal and +// structured-session harnesses are: current code against a real published release. +// +// The execution host owns agent status in one store and every reader subscribes to it, +// so a remote host and its client each run their own copy of that store. The two update +// independently, which makes three things cross-version surfaces: +// +// - the snapshot a replica hydrates from. Its decoder rejects a payload carrying a key +// it does not know, so an added field is NOT Rule 1 here — it breaks the older peer; +// - the mutation envelope every later change rides on, whose decoder requires an exact +// key set for the same reason; +// - the OSC status payload an agent CLI prints, which the host parses into the row the +// sidebar, `worktree ps`, mobile and the dashboard all read. +// +// `RuntimeWorktreeAgentRow` carries the status arms and working mode into the worktree +// listing, so the arm set is checked here too. The row's remaining members are types with +// no runtime codec; nothing in this file can observe them. + +import { beforeAll, describe, expect, it } from 'vitest' +import { comparePublishedFields } from './published-field-shape' +import { resolveBaselineReleaseRef } from './release-checkout' +import { + loadAgentStatusWireBuild, + WORKING_TREE, + type AgentStatusStoreLike, + type AgentStatusWireBuild +} from './versioned-agent-status-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const SESSION_ID = 'session_11111111-1111-4111-8111-111111111111' +const SCOPE = { + executionHostId: 'ssh:cross-version-host', + wslDistro: null, + workspaceId: 'folder-workspace-cross-version', + workspaceKind: 'folder' +} as const + +let current: AgentStatusWireBuild +let baseline: AgentStatusWireBuild + +beforeAll(async () => { + ;[current, baseline] = await Promise.all([ + loadAgentStatusWireBuild(WORKING_TREE), + loadAgentStatusWireBuild(resolveBaselineReleaseRef()) + ]) +}, SUITE_TIMEOUT_MS) + +type AuthoredStore = { + store: AgentStatusStoreLike + subject: unknown + snapshot: Record + envelope: Record + childWorkId: string +} + +/** + * A host of `build` writing the rows a real session produces, then publishing what a + * replica would receive: the hydration snapshot, and one mutation envelope after it. + */ +function authorOn(build: AgentStatusWireBuild): AuthoredStore { + const store = build.createStore({ epoch: 'cross-version-epoch', mode: 'authority' }) + const subject = build.makeStructuredSubject(SCOPE, SESSION_ID) + expect( + store.applyMutation({ parent: { subject, firstObservedAt: 10 } }), + build.label + ).not.toBeNull() + const childWorkId = 'cross-version-child-1' + expect( + build.announceChild(store, childWorkId, { + parent: subject, + provider: 'claude', + aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], + fence: { invocationId: 'invocation-1', generation: 1 }, + lifetime: 'current', + kind: 'agent', + state: 'working', + membership: 'live', + observedAt: 20, + stoppable: true, + provenance: { source: 'transport', producerId: 'cross-version-fixture' } + }), + build.label + ).toMatchObject({ accepted: true, childWorkId }) + const snapshot = store.getSnapshot() + const envelope = store.applyMutation({ + facts: [{ subject, key: 'cross-version-fact', value: 'published' }] + }) + expect(envelope, `${build.label} published no mutation envelope`).not.toBeNull() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion above fails the test before this runs when the envelope is null. + return { store, subject, snapshot, envelope: envelope as Record, childWorkId } +} + +/** What one build publishes, carried over the wire exactly as a socket would carry it. */ +function overTheWire(frame: Record): unknown { + return JSON.parse(JSON.stringify(frame)) +} + +function describePairing( + name: string, + pair: () => { host: AgentStatusWireBuild; client: AgentStatusWireBuild } +): void { + describe(name, () => { + it('hydrates a replica from the peer snapshot and applies the mutation that follows', () => { + const { host, client } = pair() + const authored = authorOn(host) + const replica = client.createStore({ epoch: 'replica-placeholder', mode: 'replica' }) + expect( + replica.applySnapshot(overTheWire(authored.snapshot)), + `${client.label} refused a snapshot published by ${host.label}` + ).toBe(true) + expect(replica.getParent(authored.subject)).toMatchObject({ firstObservedAt: 10 }) + expect(replica.getChild(authored.childWorkId)).toMatchObject({ + state: 'working', + provider: 'claude' + }) + expect( + replica.applyTransportEnvelope(overTheWire(authored.envelope)), + `${client.label} refused a mutation envelope published by ${host.label}` + ).toBe(true) + }) + + it('publishes no snapshot field the peer decoder drops the frame over', () => { + const { host, client } = pair() + // Both sides author with their own build, so the expectation is what each publishes + // today rather than a list this file keeps about a release. + const skew = comparePublishedFields({ + older: Object.keys(authorOn(client).snapshot).sort(), + newer: Object.keys(authorOn(host).snapshot).sort() + }) + expect( + { ...skew, host: host.label, client: client.label }, + 'the snapshot decoder refuses unknown keys, so neither direction may differ' + ).toMatchObject({ added: [], removed: [] }) + }) + + it('parses the peer status payload an agent CLI prints', () => { + const { host, client } = pair() + const published = host.normalizeStatusPayload({ + state: 'working', + prompt: 'cross-version prompt', + agentType: 'claude', + toolName: 'Read', + timestamp: 1_700_000_000_000 + }) + expect(published, `${host.label} refused its own status payload`).not.toBeNull() + expect( + client.normalizeStatusPayload(overTheWire({ ...published })), + `${client.label} refused a status payload published by ${host.label}` + ).toMatchObject({ state: 'working', agentType: 'claude' }) + }) + }) +} + +describePairing('new host against old client', () => ({ host: current, client: baseline })) +describePairing('old host against new client', () => ({ host: baseline, client: current })) + +describe('agent status arms', () => { + it('keeps every arm the baseline can send readable by current code', () => { + // Derived from the checked-out baseline: an arm it ships and current code dropped + // leaves a row whose state no current reader handles. Additions are current's to make. + expect(baseline.states.length).toBeGreaterThan(0) + expect(current.states).toEqual(expect.arrayContaining([...baseline.states])) + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts new file mode 100644 index 000000000000..2c5c940f9c81 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts @@ -0,0 +1,190 @@ +// Cross-version coverage for the relay transport, paired the way the terminal and +// structured-session harnesses are: current code against a real published release. +// +// The relay is the one wire whose two endpoints are not both the app. The daemon lives +// on the remote host and keeps running across a desktop update, so a new bridge meets an +// old daemon as a matter of course — the handshake carries a version and the bridge has a +// dedicated exit code for refusing a mismatched one, which is what makes that pairing a +// designed state rather than an accident. Above it, the desktop encodes JSON-RPC frames +// with `src/main/ssh/relay-protocol.ts` and the daemon decodes them with its own +// `src/relay/protocol.ts`; the two are independent copies of one framing. +// +// Both frozen sides are read from an extracted release, so no expectation here says what +// a published build does or does not have. + +import { beforeAll, describe, expect, it } from 'vitest' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const WORKING_TREE = 'working-tree' + +type DecodedFrame = { type: number; id: number; ack: number; payload: Buffer } + +type RelayHostProtocol = { + RELAY_VERSION: string + MessageType: { Regular: number; Handshake: number; KeepAlive: number } + FrameDecoder: new ( + onFrame: (frame: DecodedFrame) => void, + onError?: (error: Error) => void + ) => { feed: (chunk: Buffer) => void } + encodeHandshakeFrame: (message: unknown) => Buffer + parseHandshakeMessage: (payload: Buffer) => Record + parseJsonRpcMessage: (payload: Buffer) => Record +} + +type DesktopClientProtocol = { + RELAY_VERSION: string + encodeJsonRpcFrame: (message: unknown, id: number, ack: number) => Buffer +} + +type RelayBuild = { + label: string + host: RelayHostProtocol + client: DesktopClientProtocol +} + +const HOST_MODULE = 'src/relay/protocol.ts' +const CLIENT_MODULE = 'src/main/ssh/relay-protocol.ts' + +/** A module namespace narrowed to the protocol surface, once every member is proven present. */ +function withExports(label: string, module: Record, names: string[]): T { + const missing = names.filter((name) => module[name] === undefined) + if (missing.length > 0) { + throw new Error(`Build ${label} is missing relay protocol exports: ${missing.join(', ')}`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the caller's type declares was just proven present on this namespace. + return module as unknown as T +} + +async function loadBuild(ref: string): Promise { + const [host, client] = + ref === WORKING_TREE + ? await Promise.all([ + import('../../../src/relay/protocol'), + import('../../../src/main/ssh/relay-protocol') + ]) + : await (async () => { + const checkout = await materializeReleaseCheckout(ref) + return Promise.all([ + importReleaseCheckoutModule(checkout, `/${HOST_MODULE}`), + importReleaseCheckoutModule(checkout, `/${CLIENT_MODULE}`) + ]) + })() + return { + label: ref, + host: withExports(ref, host, [ + 'RELAY_VERSION', + 'MessageType', + 'FrameDecoder', + 'encodeHandshakeFrame', + 'parseHandshakeMessage', + 'parseJsonRpcMessage' + ]), + client: withExports(ref, client, ['RELAY_VERSION', 'encodeJsonRpcFrame']) + } +} + +/** Every frame `decoder` recovers from one contiguous byte run, as a socket delivers it. */ +function decodeAll(build: RelayBuild, bytes: Buffer): DecodedFrame[] { + const frames: DecodedFrame[] = [] + const errors: Error[] = [] + const decoder = new build.host.FrameDecoder( + (frame) => frames.push(frame), + (error) => errors.push(error) + ) + decoder.feed(bytes) + expect( + errors.map((error) => error.message), + `${build.label} decoder errored` + ).toEqual([]) + return frames +} + +let current: RelayBuild +let baseline: RelayBuild + +beforeAll(async () => { + ;[current, baseline] = await Promise.all([ + loadBuild(WORKING_TREE), + loadBuild(resolveBaselineReleaseRef()) + ]) +}, SUITE_TIMEOUT_MS) + +function describePairing( + name: string, + pair: () => { bridge: RelayBuild; daemon: RelayBuild } +): void { + describe(name, () => { + it('reads the peer handshake rather than failing to parse it', () => { + const { bridge, daemon } = pair() + const offered = bridge.host.encodeHandshakeFrame({ + type: 'orca-relay-handshake', + version: bridge.host.RELAY_VERSION, + endpointCredential: 'cross-version-credential' + }) + const [frame] = decodeAll(daemon, offered) + expect(frame?.type, `${daemon.label} did not read a handshake frame`).toBe( + daemon.host.MessageType.Handshake + ) + // The daemon must reach a verdict. Refusing the version is a verdict; throwing on the + // frame is the failure this covers, because the bridge then cannot tell skew from a crash. + const parsed = daemon.host.parseHandshakeMessage(frame?.payload ?? Buffer.alloc(0)) + expect(parsed).toMatchObject({ + type: 'orca-relay-handshake', + version: bridge.host.RELAY_VERSION + }) + }) + + it('answers the peer handshake with a verdict the peer can read', () => { + const { bridge, daemon } = pair() + const agreed = daemon.host.RELAY_VERSION === bridge.host.RELAY_VERSION + const reply = daemon.host.encodeHandshakeFrame( + agreed + ? { type: 'orca-relay-handshake-ok', version: daemon.host.RELAY_VERSION } + : { + type: 'orca-relay-handshake-mismatch', + expected: daemon.host.RELAY_VERSION, + got: bridge.host.RELAY_VERSION + } + ) + const [frame] = decodeAll(bridge, reply) + expect( + bridge.host.parseHandshakeMessage(frame?.payload ?? Buffer.alloc(0)), + `${bridge.label} could not read ${daemon.label}'s handshake verdict` + ).toMatchObject({ + type: agreed ? 'orca-relay-handshake-ok' : 'orca-relay-handshake-mismatch' + }) + }) + + it('carries a desktop JSON-RPC request to the peer daemon', () => { + const { bridge, daemon } = pair() + const request = { jsonrpc: '2.0', id: 7, method: 'preflight.probe', params: {} } + const [frame] = decodeAll(daemon, bridge.client.encodeJsonRpcFrame(request, 7, 0)) + expect(frame?.type).toBe(daemon.host.MessageType.Regular) + expect(daemon.host.parseJsonRpcMessage(frame?.payload ?? Buffer.alloc(0))).toMatchObject( + request + ) + }) + }) +} + +describePairing('new bridge against old daemon', () => ({ bridge: current, daemon: baseline })) +describePairing('old bridge against new daemon', () => ({ bridge: baseline, daemon: current })) + +describe('relay version negotiation', () => { + it('keeps the desktop and daemon copies of the version in step within a build', () => { + // The desktop compares its own constant against the daemon's sentinel, so the two + // copies disagreeing inside one build refuses every pairing that build takes part in. + for (const build of [current, baseline]) { + expect(build.client.RELAY_VERSION, `${build.label} desktop/daemon version skew`).toBe( + build.host.RELAY_VERSION + ) + } + }) +}) diff --git a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts index e53b3bc9e6cf..5fb71dce7b40 100644 --- a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts @@ -8,61 +8,69 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // Pin the last pre-v41 implementation: this contract specifically exercises status-only readers. const PRE_V41 = 'aac38d698ff75ac4c8658addab48ef5a83617619' -test('pre-v41 code opens, acknowledges and writes a v41 database, then current code reopens it', async () => { - const checkout = await materializeReleaseCheckout(PRE_V41) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/orchestration/db.ts' - ) - const OldDb = baseline.OrchestrationDb as typeof OrchestrationDb - const directory = mkdtempSync(join(tmpdir(), 'orca-delivery-downgrade-')) - const path = join(directory, 'orchestration.db') - let db: OrchestrationDb | undefined - try { - db = new OldDb(path) - expect(db.db.pragma('user_version', { simple: true })).toBe(40) - const run = db.createRun({ - objective: 'downgrade round trip', - coordinatorHandle: 'coord', - coordinatorPaneKey: 'tab:11111111-1111-4111-8111-111111111111' - }) - const params = { runId: run.id, consumerGeneration: run.consumer_generation } - const insert = (subject: string) => - db!.insertMessage({ - runId: run.id, - from: 'worker', - to: `run:${run.id}`, - subject +// Why the explicit budget: this is the only suite here that pins a commit no sibling shares, so +// it extracts a second checkout of its own and cannot finish inside the 30s default. +const TIMEOUT_MS = 180_000 + +test( + 'pre-v41 code opens, acknowledges and writes a v41 database, then current code reopens it', + async () => { + const checkout = await materializeReleaseCheckout(PRE_V41) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/orchestration/db.ts' + ) + const OldDb = baseline.OrchestrationDb as typeof OrchestrationDb + const directory = mkdtempSync(join(tmpdir(), 'orca-delivery-downgrade-')) + const path = join(directory, 'orchestration.db') + let db: OrchestrationDb | undefined + try { + db = new OldDb(path) + expect(db.db.pragma('user_version', { simple: true })).toBe(40) + const run = db.createRun({ + objective: 'downgrade round trip', + coordinatorHandle: 'coord', + coordinatorPaneKey: 'tab:11111111-1111-4111-8111-111111111111' }) - const oldMessage = insert('obsolete heartbeat') - const oldBatch = db.getOrCreateRunDelivery(params)! - db.close() + const params = { runId: run.id, consumerGeneration: run.consumer_generation } + const insert = (subject: string) => + db!.insertMessage({ + runId: run.id, + from: 'worker', + to: `run:${run.id}`, + subject + }) + const oldMessage = insert('obsolete heartbeat') + const oldBatch = db.getOrCreateRunDelivery(params)! + db.close() - db = new OrchestrationDb(path) - db.markAsRead([oldMessage.id]) - const completion = insert('completion') - const currentBatch = db.getOrCreateRunDelivery(params)! - expect(currentBatch.messages.map((message) => message.id)).toEqual([completion.id]) - expect(currentBatch.delivery.id).not.toBe(oldBatch.delivery.id) - db.close() + db = new OrchestrationDb(path) + db.markAsRead([oldMessage.id]) + const completion = insert('completion') + const currentBatch = db.getOrCreateRunDelivery(params)! + expect(currentBatch.messages.map((message) => message.id)).toEqual([completion.id]) + expect(currentBatch.delivery.id).not.toBe(oldBatch.delivery.id) + db.close() - db = new OldDb(path) - expect(db.db.pragma('user_version', { simple: true })).toBe(41) - // Old readers retain their original replay semantics, but can acknowledge either stored batch. - db.acknowledgeRunDelivery({ ...params, deliveryId: oldBatch.delivery.id }) - expect(db.getOrCreateRunDelivery(params)?.delivery.id).toBe(currentBatch.delivery.id) - db.acknowledgeRunDelivery({ ...params, deliveryId: currentBatch.delivery.id }) - const next = insert('written after downgrade') - const nextBatch = db.getOrCreateRunDelivery(params)! - expect(nextBatch.messages.map((message) => message.id)).toEqual([next.id]) - db.close() + db = new OldDb(path) + expect(db.db.pragma('user_version', { simple: true })).toBe(41) + // Old readers retain their original replay semantics, but can acknowledge either stored batch. + db.acknowledgeRunDelivery({ ...params, deliveryId: oldBatch.delivery.id }) + expect(db.getOrCreateRunDelivery(params)?.delivery.id).toBe(currentBatch.delivery.id) + db.acknowledgeRunDelivery({ ...params, deliveryId: currentBatch.delivery.id }) + const next = insert('written after downgrade') + const nextBatch = db.getOrCreateRunDelivery(params)! + expect(nextBatch.messages.map((message) => message.id)).toEqual([next.id]) + db.close() - db = new OrchestrationDb(path) - expect(db.getOrCreateRunDelivery(params)?.delivery.id).toBe(nextBatch.delivery.id) - db.acknowledgeRunDelivery({ ...params, deliveryId: nextBatch.delivery.id }) - expect(db.getOrCreateRunDelivery(params)).toBeUndefined() - } finally { - db?.close() - rmSync(directory, { recursive: true, force: true }) - } -}) + db = new OrchestrationDb(path) + expect(db.getOrCreateRunDelivery(params)?.delivery.id).toBe(nextBatch.delivery.id) + db.acknowledgeRunDelivery({ ...params, deliveryId: nextBatch.delivery.id }) + expect(db.getOrCreateRunDelivery(params)).toBeUndefined() + } finally { + db?.close() + rmSync(directory, { recursive: true, force: true }) + } + }, + TIMEOUT_MS +) diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index 14674bfeefd3..e7740c69d88d 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -1,12 +1,26 @@ import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { dirname, join, relative } from 'node:path' -const CHECKOUT_PROCESS_TIMEOUT_MS = 45_000 +// Why not 45s: the lane runs every suite in this directory in one vitest invocation, and they do +// not all pin the same commit, so two checkouts can fill at once. Extraction is ~9s of CPU; the +// budget has to cover that CPU competing with a sibling extraction and the suites already running, +// not a solo run on an idle machine. It bounds a cache fill, so a longer wait only delays a +// diagnosis that a hung `git archive` would produce either way. +const CHECKOUT_PROCESS_TIMEOUT_MS = 120_000 const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024 -// Why: the wire endpoints only need the runtime RPC host, the renderer client, and -// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds. -const ARCHIVE_PATHS = ['src/main', 'src/shared', 'src/preload', 'src/renderer', 'src/types'] +// Why: the wire endpoints are the runtime RPC host, the renderer client, the shared +// codec, and the relay daemon — which an auto-update leaves running at its old version +// while the bridge that connects to it is new, so both sides of that pairing must be +// extractable. `src/cli` is still skipped; nothing it owns crosses a version boundary. +const ARCHIVE_PATHS = [ + 'src/main', + 'src/shared', + 'src/preload', + 'src/relay', + 'src/renderer', + 'src/types' +] const ALIAS_SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*)(['"])@(renderer)?\/([^'"]+)\2/g diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index eafee3802be0..b7599cf3a6ba 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -12,7 +12,7 @@ export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') // Bump when extraction or the alias rewrite changes so cached trees are rebuilt. -const CHECKOUT_FORMAT = 3 +const CHECKOUT_FORMAT = 4 const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ diff --git a/tests/e2e/cross-version-wire/versioned-agent-status-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-status-wire.ts new file mode 100644 index 000000000000..7544d2a76c04 --- /dev/null +++ b/tests/e2e/cross-version-wire/versioned-agent-status-wire.ts @@ -0,0 +1,147 @@ +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + type ReleaseCheckout +} from './release-checkout' + +/** + * One build's agent-status wire endpoints: the store that authors snapshots and mutation + * envelopes, the subject maker that names who a row is about, the child-work admission + * that writes rows, and the OSC payload parser that ingests what an agent CLI prints. + * + * Every export is read from the build under test, so nothing here states what a release + * does or does not have. A release that predates an export fails to load and says so. + */ + +export const WORKING_TREE = 'working-tree' as const + +type AnyRecord = Record + +export type AgentStatusStoreLike = { + getParent: (subject: unknown) => AnyRecord | null + getChild: (childWorkId: string) => AnyRecord | null + getSnapshot: () => AnyRecord + applyMutation: (mutation: unknown) => AnyRecord | null + applySnapshot: (snapshot: unknown) => boolean + applyTransportEnvelope: (envelope: unknown) => boolean +} + +export type AgentStatusWireBuild = { + /** Human label used in test names and failure messages. */ + label: string + /** `working-tree` for current code, otherwise the resolved release commit. */ + revision: string + /** Status arms this build can name, read from the build rather than written down. */ + states: readonly string[] + createStore: (options: { epoch: string; mode: 'authority' | 'replica' }) => AgentStatusStoreLike + makeStructuredSubject: (scope: unknown, sessionId: string) => unknown + announceChild: (store: AgentStatusStoreLike, childWorkId: string, request: unknown) => AnyRecord + /** Normalize an OSC status payload the way this build's host ingress does. */ + normalizeStatusPayload: (payload: unknown) => AnyRecord | null +} + +type StoreModule = { + createAgentStatusStore: (options: { epoch: string; mode: string }) => AgentStatusStoreLike +} +type SubjectModule = { + makeStructuredAgentStatusSubject: (scope: unknown, sessionId: string) => unknown +} +type AdmissionModule = { + createAgentChildWorkAdmission: ( + store: AgentStatusStoreLike, + options: { mintChildWorkId: () => string } + ) => { announce: (request: unknown) => AnyRecord } +} +type TypesModule = { + AGENT_STATUS_STATES: readonly string[] + normalizeAgentStatusPayload: (payload: unknown) => AnyRecord | null +} + +type LoadedModules = { + store: StoreModule + subject: SubjectModule + admission: AdmissionModule + types: TypesModule +} + +/** Every export a pairing reads, proven present before any assertion runs. */ +function requireExports(label: string, modules: Record): LoadedModules { + const missing = [ + ['store', 'createAgentStatusStore'], + ['subject', 'makeStructuredAgentStatusSubject'], + ['admission', 'createAgentChildWorkAdmission'], + ['types', 'normalizeAgentStatusPayload'] + ].filter(([module, name]) => typeof modules[module ?? '']?.[name ?? ''] !== 'function') + if (missing.length > 0) { + throw new Error( + `Build ${label} is missing agent-status wire exports: ${missing + .map(([module, name]) => `${name} (${module})`) + .join(', ')}` + ) + } + const states = modules.types?.AGENT_STATUS_STATES + if (!Array.isArray(states) || states.some((state) => typeof state !== 'string')) { + throw new Error(`Build ${label} publishes no AGENT_STATUS_STATES arm set`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every export read below was just proven callable, and the arm set proven to be strings. + return modules as unknown as LoadedModules +} + +function assembleBuild( + label: string, + revision: string, + modules: LoadedModules +): AgentStatusWireBuild { + return { + label, + revision, + states: modules.types.AGENT_STATUS_STATES, + createStore: (options) => modules.store.createAgentStatusStore(options), + makeStructuredSubject: (scope, sessionId) => + modules.subject.makeStructuredAgentStatusSubject(scope, sessionId), + announceChild: (store, childWorkId, request) => + modules.admission + .createAgentChildWorkAdmission(store, { mintChildWorkId: () => childWorkId }) + .announce(request), + normalizeStatusPayload: (payload) => modules.types.normalizeAgentStatusPayload(payload) + } +} + +async function loadWorkingTreeBuild(): Promise { + const [store, subject, admission, types] = await Promise.all([ + import('../../../src/shared/agent-status-store'), + import('../../../src/shared/agent-status-subject'), + import('../../../src/shared/agent-status-child-work-admission'), + import('../../../src/shared/agent-status-types') + ]) + return assembleBuild( + WORKING_TREE, + WORKING_TREE, + requireExports(WORKING_TREE, { store, subject, admission, types }) + ) +} + +async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const [store, subject, admission, types] = await Promise.all([ + importReleaseCheckoutModule(checkout, '/src/shared/agent-status-store.ts'), + importReleaseCheckoutModule(checkout, '/src/shared/agent-status-subject.ts'), + importReleaseCheckoutModule(checkout, '/src/shared/agent-status-child-work-admission.ts'), + importReleaseCheckoutModule(checkout, '/src/shared/agent-status-types.ts') + ]) + return assembleBuild( + checkout.ref, + checkout.commit, + requireExports(checkout.ref, { store, subject, admission, types }) + ) +} + +/** + * Load the agent-status wire surface for one build. `WORKING_TREE` imports current + * source; any other value is a git ref extracted into a cached checkout. + */ +export async function loadAgentStatusWireBuild(ref: string): Promise { + if (ref === WORKING_TREE) { + return loadWorkingTreeBuild() + } + return loadReleaseBuild(await materializeReleaseCheckout(ref)) +} From 2c35b2da97700bf145e9e4a9c3a12543ac8067a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:00:53 -0700 Subject: [PATCH 2/7] docs(wire): record that this gate does not cover the phone Mobile Checks dispatches on its own paths filter, and it does not list the shared types mobile imports verbatim: runtime-types (26 mobile importers), agent-status-types (15) and runtime-worktree-contracts (2). Of 103 host-side modules named *mobile* outside mobile/, 85 are outside that filter, including the projection that builds the tab rows a phone reads. Two of those shared types are gated for cross-version skew by this change. That is a different failure from mobile's typecheck breaking, so the page now says so rather than letting a reader infer the phone is covered. The fix belongs in mobile.yml. --- docs/reference/remote-wire-compatibility.md | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index fce7c2bc8625..71267cea5f20 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -213,6 +213,33 @@ legacy agent-session publications, file or Git RPCs, mobile/E2EE framing, or wha Rule 3 surfaces with no pairing yet. A change on those paths still needs its own reasoning against the rules above. +## The phone is a client, and this gate is not the one that protects it + +The cross-version job answers one question: will a peer from another _version_ still read what +this build publishes. It does not answer whether the phone can still build against what this +build declares, and those are separate gates with separate filters. + +`Mobile Checks` (`.github/workflows/mobile.yml`) runs mobile's typecheck, tests, lint and the +RPC recording guard, and it dispatches on a `paths:` filter of its own. That filter already +explains the mechanism that matters here, in its note on `src/shared/rpc-contract/**`: mobile +imports desktop types verbatim, so a desktop-only edit can break mobile's typecheck with no +other mobile signal. The filter does not apply that reasoning to the rest of what mobile +imports. Measured at `dc8cf30554`: + +- `src/shared/runtime-types.ts` is imported by 26 mobile files and is not in the filter; +- `src/shared/agent-status-types.ts` is imported by 15 and is not in the filter; +- `src/shared/runtime-worktree-contracts.ts` is imported by 2 and is not in the filter; +- of 103 host-side modules named `*mobile*` outside `mobile/`, 18 are in the filter and **85 are + not**, including `src/main/runtime/runtime-mobile-session-projection.ts`, which builds the tab + rows a phone reads. + +So a change to host-side mobile-facing output can merge with Mobile Checks never dispatching. +The first three of those files are gated by `CROSS_VERSION_WIRE_PREFIXES` as of this page's last +revision — **that does not cover them for mobile**, and reading it that way is the mistake this +section exists to prevent. The fix belongs in `mobile.yml`'s `paths:`, not here: adding a host +projection to `CROSS_VERSION_WIRE_PREFIXES` would start a job holding no pairing that can fail +on it. + Every suite in `tests/e2e/cross-version-wire/` runs in the `cross-version-wire` PR job and nowhere else — the unit shards exclude the directory, because a shallow shard clone has no tags for an extracted release to skew against. The job's vitest argv is therefore the only thing From 26321e3db834d39fb2b1213efd06b03d280f2368 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:56:12 -0700 Subject: [PATCH 3/7] test(cross-version-wire): pair only the agent-status surface that crosses versions The agent-status store's snapshot and mutation envelope never leave the process that owns the store, and the SSH relay is always the desktop's own build (content- hashed install directory, per-version socket, exact-version handshake). Pairing them gated changes no older peer can observe. What does cross versions is the hook server's status file: after an update or a downgrade the other build re-admits each persisted row with its own normalizeAgentStatusPayload. Pair that in both directions over every baseline arm, and check current still writes every arm and field the baseline writes. Narrow the lane prefixes to the normalizer's modules, drop the relay prefixes and src/relay from the extracted tree (CHECKOUT_FORMAT back to main's 4), fold the classification cases into main's existing list so the scope test fits max-lines, and correct the wire-compatibility doc. --- ...ss-version-wire-lane-registration.test.mjs | 18 +- config/scripts/pr-code-change-scope.mjs | 17 +- config/scripts/pr-code-change-scope.test.mjs | 56 +----- docs/reference/remote-wire-compatibility.md | 74 ++----- ...sion-death-evidence-downgrade.unit.test.ts | 93 +++++---- ...-version-agent-status-payload.unit.test.ts | 148 ++++++++++++++ ...oss-version-agent-status-wire.unit.test.ts | 166 --------------- ...-version-relay-transport-wire.unit.test.ts | 190 ------------------ .../release-checkout-tree.ts | 14 +- .../cross-version-wire/release-checkout.ts | 2 +- .../versioned-agent-status-wire.ts | 147 -------------- 11 files changed, 236 insertions(+), 689 deletions(-) create mode 100644 tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts delete mode 100644 tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts delete mode 100644 tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts delete mode 100644 tests/e2e/cross-version-wire/versioned-agent-status-wire.ts diff --git a/config/scripts/cross-version-wire-lane-registration.test.mjs b/config/scripts/cross-version-wire-lane-registration.test.mjs index 597e6ffa037d..0de7a90d4932 100644 --- a/config/scripts/cross-version-wire-lane-registration.test.mjs +++ b/config/scripts/cross-version-wire-lane-registration.test.mjs @@ -13,11 +13,9 @@ import { classifyPrJobs } from './pr-code-change-scope.mjs' * shard's shallow clone does not have. So the shard excludes the directory wholesale and the * `cross-version-wire` job is the only lane that runs it. * - * That made the job's vitest argv the single thing deciding whether a file executes, and for - * a while the argv was a curated list of seven paths while the directory held nine suites. - * `orchestration-delivery-downgrade` and `published-field-shape` ran on no machine and - * reported nothing; the first could not have passed if it had run, because it extracts a - * checkout no sibling shares and had no timeout to cover it. Nobody could find that out. + * That made the job's vitest argv the single thing deciding whether a file executes. While it + * was a curated list, `orchestration-delivery-downgrade`, `agent-session-death-evidence-downgrade` + * and `published-field-shape` landed in the directory without joining it and ran on no machine. * * Both halves matter and being in one is not enough: `CROSS_VERSION_WIRE_PREFIXES` in * pr-code-change-scope.mjs decides whether the job RUNS for a diff, and the argv decides @@ -86,16 +84,6 @@ describe('cross-version wire suites run somewhere', () => { expect(suites.filter((path) => !selectedByLane(path))).toEqual([]) }) - it('names no argv path that is not a suite on disk', () => { - // A curated list rots the other way too: a renamed file leaves a filter matching nothing, - // and vitest then fails the whole lane rather than quietly running the rest. - const strays = laneArgv.filter( - (token) => - token !== `${LANE_DIRECTORY}/` && token !== LANE_DIRECTORY && !suites.includes(token) - ) - expect(strays).toEqual([]) - }) - it('keeps the unit shards excluding the directory, which is what makes this lane the only one', () => { const workflow = readWorkflow(UNIT_TEST_WORKFLOW) const shardStep = (workflow.jobs?.test?.steps ?? []).find( diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index e9e8b1394798..6aeb309deedb 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -138,18 +138,11 @@ function changesMobileWebApp(changedFiles) { const CROSS_VERSION_WIRE_PREFIXES = [ 'tests/e2e/cross-version-wire/', 'src/shared/protocol-version', - // The execution host owns agent status in one store and every reader subscribes to it, so a - // remote host and its client each run a copy that updates on its own schedule. The snapshot - // and mutation decoders refuse a key they do not know, which makes a new field on either one - // a break rather than Rule 1, and the row's status arms reach the worktree listing. - 'src/shared/agent-status', - // The daemon keeps running on the remote host across a desktop update, so its framing, - // handshake and version constant meet a bridge from another build as a matter of course. - 'src/main/ssh/relay-protocol', - 'src/shared/relay-frame-decoder', - 'src/relay/protocol', - 'src/relay/relay-frame-decoder', - 'src/relay/relay-handshake', + // A persisted status row is re-admitted by the other build's normalizeAgentStatusPayload after + // an update or a downgrade; these decide which rows and fields it admits. + 'src/shared/agent-status-types', + 'src/shared/agent-status-field-normalization', + 'src/shared/agent-turn-outcome', 'src/shared/terminal-stream-protocol', 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 679fff43626a..6f766499dada 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -353,6 +353,9 @@ describe('per-job path classification', () => { 'src/main/runtime/runtime-worktree-agent-rows.ts', 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', 'src/shared/runtime-worktree-contracts.ts', + 'src/shared/agent-status-types.ts', + 'src/shared/agent-status-field-normalization.ts', + 'src/shared/agent-turn-outcome.ts', 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' ]) { expectClassification([file], { @@ -367,57 +370,6 @@ describe('per-job path classification', () => { ) }) - it('runs cross-version wire checks for the agent-status, worktree-row and relay surfaces', () => { - // Each path is checked against the working tree first. A list of paths that no longer - // exist classifies exactly the same as one that does, so without this the assertions - // below would keep passing after the files they name were renamed away. - const files = [ - // The store a remote host and its client each keep a copy of. Its snapshot and mutation - // decoders refuse an unknown key, so a field added to either breaks the older peer. - 'src/shared/agent-status-store.ts', - 'src/shared/agent-status-store-codec.ts', - 'src/shared/agent-status-store-contract.ts', - 'src/shared/agent-status-transport-envelope.ts', - 'src/shared/agent-status-child-work-codec.ts', - 'src/shared/agent-status-subject.ts', - // The arm set and payload parser behind every row the sidebar, `worktree ps`, mobile - // and the dashboard read. - 'src/shared/agent-status-types.ts', - 'src/shared/runtime-worktree-contracts.ts', - // The desktop half of the relay framing, and the codec both halves share. - 'src/main/ssh/relay-protocol.ts', - 'src/shared/relay-frame-decoder.ts' - ] - // The relay daemon outlives a desktop update, so its framing and handshake meet a bridge - // from another build. Its tree also primes the Node 18 companion smoke. - const relayFiles = [ - 'src/relay/protocol.ts', - 'src/relay/relay-frame-decoder.ts', - 'src/relay/relay-handshake.ts' - ] - expect([...files, ...relayFiles].filter((file) => !existsSync(join(projectDir, file)))).toEqual( - [] - ) - for (const file of files) { - expectClassification([file], { - 'cross-version-wire': true, - package: true, - package_windows: true - }) - } - for (const file of relayFiles) { - expectClassification([file], { - 'cross-version-wire': true, - managed_hook_node18: true, - package: true, - package_windows: true - }) - } - // The relay's RPC handlers are not this job's surface: nothing it runs can fail on them, - // and gating them here would report a pass about a wire it never touched. - expect(classifyPrJobs(['src/relay/git-handler.ts'])['cross-version-wire']).toBe(false) - }) - it('runs workflow-self-change and lockfile diffs as force-all', () => { const result = classifyPrJobs(['.github/workflows/pr.yml']) expect(result.should_run).toBe(true) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 23704fb5c1fb..7b6976f212ce 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -192,62 +192,30 @@ Run it with: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts ``` -`tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts` pairs the same -two builds over the agent-status store. The execution host owns status in one store and -every reader subscribes to it, so a remote host and its client each keep a copy that updates -on its own schedule. Rule 1 does not hold on this surface: the snapshot and mutation-envelope -decoders refuse a payload carrying a key they do not know, so an added field breaks the older -peer rather than being ignored by it. The suite carries a snapshot, the mutation envelope that -follows it, and an OSC status payload in both directions, and checks that no arm the baseline -can send has been dropped. - -`tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts` pairs the relay. -It is the one wire whose endpoints are not both the app: the daemon lives on the remote host -and keeps running across a desktop update, which is why the handshake carries a version and the -bridge has a dedicated exit code for refusing a mismatched one. The suite skews the handshake -and a desktop JSON-RPC frame in both directions. Reaching the frozen daemon needs `src/relay` -in the extracted tree, so `ARCHIVE_PATHS` in `release-checkout-tree.ts` includes it. - -The harness covers the terminal stream, the structured agent-session surface, the agent-status -store and the relay handshake and framing. It does **not** cover the session-tab sync channel, -legacy agent-session publications, file or Git RPCs, mobile/E2EE framing, or what the relay -*publishes* over its transport — the workspace snapshot, hook envelopes and pty publications are -Rule 3 surfaces with no pairing yet. A change on those paths still needs its own reasoning -against the rules above. - -## The phone is a client, and this gate is not the one that protects it - -The cross-version job answers one question: will a peer from another _version_ still read what -this build publishes. It does not answer whether the phone can still build against what this -build declares, and those are separate gates with separate filters. - -`Mobile Checks` (`.github/workflows/mobile.yml`) runs mobile's typecheck, tests, lint and the -RPC recording guard, and it dispatches on a `paths:` filter of its own. That filter already -explains the mechanism that matters here, in its note on `src/shared/rpc-contract/**`: mobile -imports desktop types verbatim, so a desktop-only edit can break mobile's typecheck with no -other mobile signal. The filter does not apply that reasoning to the rest of what mobile -imports. Measured at `dc8cf30554`: - -- `src/shared/runtime-types.ts` is imported by 26 mobile files and is not in the filter; -- `src/shared/agent-status-types.ts` is imported by 15 and is not in the filter; -- `src/shared/runtime-worktree-contracts.ts` is imported by 2 and is not in the filter; -- of 103 host-side modules named `*mobile*` outside `mobile/`, 18 are in the filter and **85 are - not**, including `src/main/runtime/runtime-mobile-session-projection.ts`, which builds the tab - rows a phone reads. - -So a change to host-side mobile-facing output can merge with Mobile Checks never dispatching. -The first three of those files are gated by `CROSS_VERSION_WIRE_PREFIXES` as of this page's last -revision — **that does not cover them for mobile**, and reading it that way is the mistake this -section exists to prevent. The fix belongs in `mobile.yml`'s `paths:`, not here: adding a host -projection to `CROSS_VERSION_WIRE_PREFIXES` would start a job holding no pairing that can fail -on it. +`tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts` pairs the same +two builds over the agent-status payload. Its version boundary is the hook server's status file: +after an update or a downgrade the other build re-admits every persisted row with its own +`normalizeAgentStatusPayload`, which refuses a status arm it does not know. The suite writes a +row in every baseline arm with one build and reads it with the other, in both directions, and +checks that current code still writes every arm and field the baseline writes. + +Two nearby surfaces have no pairing because nothing of another version reads them. The +canonical agent-status store's snapshot and mutation envelope never leave the process that owns +the store, and their decoders refuse unknown keys; a transport that carries them to a replica +must add a version check or lenient decoding, and a pairing, when it ships. The SSH relay is +always the desktop's own build: each build installs it under its own content-hashed directory, +binds its socket there, and the handshake refuses any other build's version (see +`src/main/ssh/ssh-relay-superseded-endpoints.ts`). + +The harness does **not** cover the session-tab sync channel, legacy agent-session +publications, file or Git RPCs, or mobile/E2EE framing. A change on those paths still needs its +own reasoning against the rules above. Every suite in `tests/e2e/cross-version-wire/` runs in the `cross-version-wire` PR job and nowhere else — the unit shards exclude the directory, because a shallow shard clone has no tags -for an extracted release to skew against. The job's vitest argv is therefore the only thing -deciding whether a file executes, so it names the directory rather than a list of files, and -`config/scripts/cross-version-wire-lane-registration.test.mjs` holds that shut. Which diffs -start the job is a separate list, `CROSS_VERSION_WIRE_PREFIXES` in +for an extracted release to skew against. The job therefore names the directory rather than a +list of files, and `config/scripts/cross-version-wire-lane-registration.test.mjs` holds that +shut. Which diffs start the job is a separate list, `CROSS_VERSION_WIRE_PREFIXES` in `config/scripts/pr-code-change-scope.mjs`; a wire module missing from it runs no pairing at all. ## Worked example: `agentWait` on terminal and worker reads diff --git a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts index e85ab02ae1cc..a9da2fba8416 100644 --- a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts @@ -13,51 +13,58 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // The last release before a proof of death named its owner and its last proof of life. const BASELINE_REF = 'v1.4.211' const SESSION = 'session-alpha-1' -// Why the explicit budget: this pins a release no sibling shares, so a cold run extracts its own checkout. +// Why the explicit budget: a cold run may extract the pinned release inside this test. const TIMEOUT_MS = 180_000 -test('an older build loads a proof of death that names its owner and its last proof of life', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) - try { - // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. - writeFileSync( - agentSessionStorePath(directory), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} +test( + 'an older build loads a proof of death that names its owner and its last proof of life', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) + try { + // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. + writeFileSync( + agentSessionStorePath(directory), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reconcileOnRestart({ + probe: async () => ({ outcome: 'pid-absent' }), + now: 90_000 + }) + const evidence = store.getRecord(SESSION)?.lease.deathEvidence + expect(evidence).toEqual({ + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 90_000, + ownerFence: 7, + lastProvenAliveAt: 30_000 }) - ) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) - const evidence = store.getRecord(SESSION)?.lease.deathEvidence - expect(evidence).toEqual({ - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 90_000, - ownerFence: 7, - lastProvenAliveAt: 30_000 - }) - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/agent-session-record-store.ts' - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. - const OldStore = baseline.AgentSessionRecordStore as { - open: (args: { directory: string; hostId: string }) => Promise + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-record-store.ts' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. + const OldStore = baseline.AgentSessionRecordStore as { + open: (args: { directory: string; hostId: string }) => Promise + } + const old = await OldStore.open({ directory, hostId: 'local' }) + expect(old.isSessionUnreadable(SESSION)).toBe(false) + expect(old.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: evidence + }) + } finally { + rmSync(directory, { recursive: true, force: true }) } - const old = await OldStore.open({ directory, hostId: 'local' }) - expect(old.isSessionUnreadable(SESSION)).toBe(false) - expect(old.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: evidence - }) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -}, TIMEOUT_MS) + }, + TIMEOUT_MS +) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts new file mode 100644 index 000000000000..52f89d852fed --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts @@ -0,0 +1,148 @@ +// Cross-version coverage for the agent-status payload: current code against the newest release. +// +// The hook server persists every row's payload to its status file, and after an update or a +// downgrade the other build re-admits it with its own `normalizeAgentStatusPayload` +// (server-persistence-validation.ts). A payload that normalizer refuses loses the row on +// restart; a field it reads differently is a row misread. That is the version boundary this +// surface has. The canonical status store never leaves the process that owns it, and the relay +// that forwards hook envelopes is always the desktop's own build. + +import { beforeAll, describe, expect, it } from 'vitest' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef +} from './release-checkout' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +type Payload = Record + +type StatusBuild = { + label: string + states: readonly string[] + normalize: (payload: unknown) => Payload | null +} + +function toBuild(label: string, module: Record): StatusBuild { + const { AGENT_STATUS_STATES: states, normalizeAgentStatusPayload: normalize } = module + if (typeof normalize !== 'function') { + throw new Error(`Build ${label} exports no normalizeAgentStatusPayload`) + } + if (!Array.isArray(states) || !states.every((state) => typeof state === 'string')) { + throw new Error(`Build ${label} exports no AGENT_STATUS_STATES arm set`) + } + return { + label, + states, + normalize: (payload) => { + const result: unknown = normalize(payload) + return typeof result === 'object' && result !== null + ? Object.fromEntries(Object.entries(result)) + : null + } + } +} + +let current: StatusBuild +let baseline: StatusBuild + +beforeAll(async () => { + const baselineRef = resolveBaselineReleaseRef() + const [working, released] = await Promise.all([ + import('../../../src/shared/agent-status-types'), + materializeReleaseCheckout(baselineRef).then((checkout) => + importReleaseCheckoutModule(checkout, '/src/shared/agent-status-types.ts') + ) + ]) + current = toBuild('working-tree', working) + baseline = toBuild(baselineRef, released) +}, SUITE_TIMEOUT_MS) + +/** Every optional field a row can carry; each build keeps what it knows for the given state. */ +function rowFor(state: string): Payload { + return { + state, + workingMode: 'monitoring', + prompt: 'cross-version prompt', + agentType: 'claude', + model: 'claude-opus', + modelSwitchCommand: 'orca-model', + toolName: 'Read', + toolInput: 'src/index.ts', + interactivePrompt: 'Allow Read?', + lastAssistantMessage: 'first line\nsecond line', + lastAssistantMessageIsToolOutput: true, + interrupted: true, + sessionBoundary: true, + turnCompletedAt: 1_700_000_000_500, + subagents: [ + { + id: 'subagent-1', + agentType: 'explore', + model: 'claude-haiku', + description: 'scan the tree', + state: 'working', + startedAt: 1_700_000_000_000 + } + ], + mainAgent: { state, outcome: 'cancellation', stateStartedAt: 1_700_000_000_100 } + } +} + +function definedKeys(payload: Payload | null): string[] { + return Object.keys(payload ?? {}) + .filter((key) => payload?.[key] !== undefined) + .sort() +} + +function describePairing( + name: string, + pair: () => { writer: StatusBuild; reader: StatusBuild } +): void { + describe(name, () => { + it('re-admits every row the writer persisted and reads each shared field as its own', () => { + const { writer, reader } = pair() + // Every arm the release can write; an arm current drops fails the old-writer direction. + for (const state of baseline.states) { + const raw = rowFor(state) + const persisted = writer.normalize(raw) + expect(persisted, `${writer.label} refused its own ${state} row`).not.toBeNull() + const read = reader.normalize(JSON.parse(JSON.stringify(persisted))) + expect( + read, + `${reader.label} dropped a ${state} row ${writer.label} persisted` + ).not.toBeNull() + // The reader's own normalization of the same row is the expectation, so a field only + // one build knows is neither required nor refused. + const own = reader.normalize(raw) + const shared = definedKeys(persisted).filter((key) => definedKeys(own).includes(key)) + expect( + Object.fromEntries(shared.map((key) => [key, read?.[key]])), + `${reader.label} misread a ${state} row ${writer.label} persisted` + ).toEqual(Object.fromEntries(shared.map((key) => [key, own?.[key]]))) + } + }) + }) +} + +describePairing('new writer against old reader', () => ({ writer: current, reader: baseline })) +describePairing('old writer against new reader', () => ({ writer: baseline, reader: current })) + +describe('what current code still writes', () => { + it('keeps every status arm the baseline can write', () => { + expect(baseline.states.length).toBeGreaterThan(0) + expect(current.states).toEqual(expect.arrayContaining([...baseline.states])) + }) + + it('keeps every payload field the baseline writes', () => { + // A field current stops writing reaches an older reader as `undefined` after a downgrade. + for (const state of baseline.states) { + const raw = rowFor(state) + expect(definedKeys(current.normalize(raw)), state).toEqual( + expect.arrayContaining(definedKeys(baseline.normalize(raw))) + ) + } + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts deleted file mode 100644 index f22305ce7ea2..000000000000 --- a/tests/e2e/cross-version-wire/cross-version-agent-status-wire.unit.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// Cross-version coverage for the agent-status wire, paired the way the terminal and -// structured-session harnesses are: current code against a real published release. -// -// The execution host owns agent status in one store and every reader subscribes to it, -// so a remote host and its client each run their own copy of that store. The two update -// independently, which makes three things cross-version surfaces: -// -// - the snapshot a replica hydrates from. Its decoder rejects a payload carrying a key -// it does not know, so an added field is NOT Rule 1 here — it breaks the older peer; -// - the mutation envelope every later change rides on, whose decoder requires an exact -// key set for the same reason; -// - the OSC status payload an agent CLI prints, which the host parses into the row the -// sidebar, `worktree ps`, mobile and the dashboard all read. -// -// `RuntimeWorktreeAgentRow` carries the status arms and working mode into the worktree -// listing, so the arm set is checked here too. The row's remaining members are types with -// no runtime codec; nothing in this file can observe them. - -import { beforeAll, describe, expect, it } from 'vitest' -import { comparePublishedFields } from './published-field-shape' -import { resolveBaselineReleaseRef } from './release-checkout' -import { - loadAgentStatusWireBuild, - WORKING_TREE, - type AgentStatusStoreLike, - type AgentStatusWireBuild -} from './versioned-agent-status-wire' - -// Why: a cold CI run extracts the baseline checkout before the first pairing. -const SUITE_TIMEOUT_MS = 180_000 - -const SESSION_ID = 'session_11111111-1111-4111-8111-111111111111' -const SCOPE = { - executionHostId: 'ssh:cross-version-host', - wslDistro: null, - workspaceId: 'folder-workspace-cross-version', - workspaceKind: 'folder' -} as const - -let current: AgentStatusWireBuild -let baseline: AgentStatusWireBuild - -beforeAll(async () => { - ;[current, baseline] = await Promise.all([ - loadAgentStatusWireBuild(WORKING_TREE), - loadAgentStatusWireBuild(resolveBaselineReleaseRef()) - ]) -}, SUITE_TIMEOUT_MS) - -type AuthoredStore = { - store: AgentStatusStoreLike - subject: unknown - snapshot: Record - envelope: Record - childWorkId: string -} - -/** - * A host of `build` writing the rows a real session produces, then publishing what a - * replica would receive: the hydration snapshot, and one mutation envelope after it. - */ -function authorOn(build: AgentStatusWireBuild): AuthoredStore { - const store = build.createStore({ epoch: 'cross-version-epoch', mode: 'authority' }) - const subject = build.makeStructuredSubject(SCOPE, SESSION_ID) - expect( - store.applyMutation({ parent: { subject, firstObservedAt: 10 } }), - build.label - ).not.toBeNull() - const childWorkId = 'cross-version-child-1' - expect( - build.announceChild(store, childWorkId, { - parent: subject, - provider: 'claude', - aliases: [{ segmentId: 'segment-1', aliasKind: 'task_id', alias: 'task-1' }], - fence: { invocationId: 'invocation-1', generation: 1 }, - lifetime: 'current', - kind: 'agent', - state: 'working', - membership: 'live', - observedAt: 20, - stoppable: true, - provenance: { source: 'transport', producerId: 'cross-version-fixture' } - }), - build.label - ).toMatchObject({ accepted: true, childWorkId }) - const snapshot = store.getSnapshot() - const envelope = store.applyMutation({ - facts: [{ subject, key: 'cross-version-fact', value: 'published' }] - }) - expect(envelope, `${build.label} published no mutation envelope`).not.toBeNull() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion above fails the test before this runs when the envelope is null. - return { store, subject, snapshot, envelope: envelope as Record, childWorkId } -} - -/** What one build publishes, carried over the wire exactly as a socket would carry it. */ -function overTheWire(frame: Record): unknown { - return JSON.parse(JSON.stringify(frame)) -} - -function describePairing( - name: string, - pair: () => { host: AgentStatusWireBuild; client: AgentStatusWireBuild } -): void { - describe(name, () => { - it('hydrates a replica from the peer snapshot and applies the mutation that follows', () => { - const { host, client } = pair() - const authored = authorOn(host) - const replica = client.createStore({ epoch: 'replica-placeholder', mode: 'replica' }) - expect( - replica.applySnapshot(overTheWire(authored.snapshot)), - `${client.label} refused a snapshot published by ${host.label}` - ).toBe(true) - expect(replica.getParent(authored.subject)).toMatchObject({ firstObservedAt: 10 }) - expect(replica.getChild(authored.childWorkId)).toMatchObject({ - state: 'working', - provider: 'claude' - }) - expect( - replica.applyTransportEnvelope(overTheWire(authored.envelope)), - `${client.label} refused a mutation envelope published by ${host.label}` - ).toBe(true) - }) - - it('publishes no snapshot field the peer decoder drops the frame over', () => { - const { host, client } = pair() - // Both sides author with their own build, so the expectation is what each publishes - // today rather than a list this file keeps about a release. - const skew = comparePublishedFields({ - older: Object.keys(authorOn(client).snapshot).sort(), - newer: Object.keys(authorOn(host).snapshot).sort() - }) - expect( - { ...skew, host: host.label, client: client.label }, - 'the snapshot decoder refuses unknown keys, so neither direction may differ' - ).toMatchObject({ added: [], removed: [] }) - }) - - it('parses the peer status payload an agent CLI prints', () => { - const { host, client } = pair() - const published = host.normalizeStatusPayload({ - state: 'working', - prompt: 'cross-version prompt', - agentType: 'claude', - toolName: 'Read', - timestamp: 1_700_000_000_000 - }) - expect(published, `${host.label} refused its own status payload`).not.toBeNull() - expect( - client.normalizeStatusPayload(overTheWire({ ...published })), - `${client.label} refused a status payload published by ${host.label}` - ).toMatchObject({ state: 'working', agentType: 'claude' }) - }) - }) -} - -describePairing('new host against old client', () => ({ host: current, client: baseline })) -describePairing('old host against new client', () => ({ host: baseline, client: current })) - -describe('agent status arms', () => { - it('keeps every arm the baseline can send readable by current code', () => { - // Derived from the checked-out baseline: an arm it ships and current code dropped - // leaves a row whose state no current reader handles. Additions are current's to make. - expect(baseline.states.length).toBeGreaterThan(0) - expect(current.states).toEqual(expect.arrayContaining([...baseline.states])) - }) -}) diff --git a/tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts deleted file mode 100644 index 2c5c940f9c81..000000000000 --- a/tests/e2e/cross-version-wire/cross-version-relay-transport-wire.unit.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -// Cross-version coverage for the relay transport, paired the way the terminal and -// structured-session harnesses are: current code against a real published release. -// -// The relay is the one wire whose two endpoints are not both the app. The daemon lives -// on the remote host and keeps running across a desktop update, so a new bridge meets an -// old daemon as a matter of course — the handshake carries a version and the bridge has a -// dedicated exit code for refusing a mismatched one, which is what makes that pairing a -// designed state rather than an accident. Above it, the desktop encodes JSON-RPC frames -// with `src/main/ssh/relay-protocol.ts` and the daemon decodes them with its own -// `src/relay/protocol.ts`; the two are independent copies of one framing. -// -// Both frozen sides are read from an extracted release, so no expectation here says what -// a published build does or does not have. - -import { beforeAll, describe, expect, it } from 'vitest' -import { - importReleaseCheckoutModule, - materializeReleaseCheckout, - resolveBaselineReleaseRef -} from './release-checkout' - -// Why: a cold CI run extracts the baseline checkout before the first pairing. -const SUITE_TIMEOUT_MS = 180_000 - -const WORKING_TREE = 'working-tree' - -type DecodedFrame = { type: number; id: number; ack: number; payload: Buffer } - -type RelayHostProtocol = { - RELAY_VERSION: string - MessageType: { Regular: number; Handshake: number; KeepAlive: number } - FrameDecoder: new ( - onFrame: (frame: DecodedFrame) => void, - onError?: (error: Error) => void - ) => { feed: (chunk: Buffer) => void } - encodeHandshakeFrame: (message: unknown) => Buffer - parseHandshakeMessage: (payload: Buffer) => Record - parseJsonRpcMessage: (payload: Buffer) => Record -} - -type DesktopClientProtocol = { - RELAY_VERSION: string - encodeJsonRpcFrame: (message: unknown, id: number, ack: number) => Buffer -} - -type RelayBuild = { - label: string - host: RelayHostProtocol - client: DesktopClientProtocol -} - -const HOST_MODULE = 'src/relay/protocol.ts' -const CLIENT_MODULE = 'src/main/ssh/relay-protocol.ts' - -/** A module namespace narrowed to the protocol surface, once every member is proven present. */ -function withExports(label: string, module: Record, names: string[]): T { - const missing = names.filter((name) => module[name] === undefined) - if (missing.length > 0) { - throw new Error(`Build ${label} is missing relay protocol exports: ${missing.join(', ')}`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every member the caller's type declares was just proven present on this namespace. - return module as unknown as T -} - -async function loadBuild(ref: string): Promise { - const [host, client] = - ref === WORKING_TREE - ? await Promise.all([ - import('../../../src/relay/protocol'), - import('../../../src/main/ssh/relay-protocol') - ]) - : await (async () => { - const checkout = await materializeReleaseCheckout(ref) - return Promise.all([ - importReleaseCheckoutModule(checkout, `/${HOST_MODULE}`), - importReleaseCheckoutModule(checkout, `/${CLIENT_MODULE}`) - ]) - })() - return { - label: ref, - host: withExports(ref, host, [ - 'RELAY_VERSION', - 'MessageType', - 'FrameDecoder', - 'encodeHandshakeFrame', - 'parseHandshakeMessage', - 'parseJsonRpcMessage' - ]), - client: withExports(ref, client, ['RELAY_VERSION', 'encodeJsonRpcFrame']) - } -} - -/** Every frame `decoder` recovers from one contiguous byte run, as a socket delivers it. */ -function decodeAll(build: RelayBuild, bytes: Buffer): DecodedFrame[] { - const frames: DecodedFrame[] = [] - const errors: Error[] = [] - const decoder = new build.host.FrameDecoder( - (frame) => frames.push(frame), - (error) => errors.push(error) - ) - decoder.feed(bytes) - expect( - errors.map((error) => error.message), - `${build.label} decoder errored` - ).toEqual([]) - return frames -} - -let current: RelayBuild -let baseline: RelayBuild - -beforeAll(async () => { - ;[current, baseline] = await Promise.all([ - loadBuild(WORKING_TREE), - loadBuild(resolveBaselineReleaseRef()) - ]) -}, SUITE_TIMEOUT_MS) - -function describePairing( - name: string, - pair: () => { bridge: RelayBuild; daemon: RelayBuild } -): void { - describe(name, () => { - it('reads the peer handshake rather than failing to parse it', () => { - const { bridge, daemon } = pair() - const offered = bridge.host.encodeHandshakeFrame({ - type: 'orca-relay-handshake', - version: bridge.host.RELAY_VERSION, - endpointCredential: 'cross-version-credential' - }) - const [frame] = decodeAll(daemon, offered) - expect(frame?.type, `${daemon.label} did not read a handshake frame`).toBe( - daemon.host.MessageType.Handshake - ) - // The daemon must reach a verdict. Refusing the version is a verdict; throwing on the - // frame is the failure this covers, because the bridge then cannot tell skew from a crash. - const parsed = daemon.host.parseHandshakeMessage(frame?.payload ?? Buffer.alloc(0)) - expect(parsed).toMatchObject({ - type: 'orca-relay-handshake', - version: bridge.host.RELAY_VERSION - }) - }) - - it('answers the peer handshake with a verdict the peer can read', () => { - const { bridge, daemon } = pair() - const agreed = daemon.host.RELAY_VERSION === bridge.host.RELAY_VERSION - const reply = daemon.host.encodeHandshakeFrame( - agreed - ? { type: 'orca-relay-handshake-ok', version: daemon.host.RELAY_VERSION } - : { - type: 'orca-relay-handshake-mismatch', - expected: daemon.host.RELAY_VERSION, - got: bridge.host.RELAY_VERSION - } - ) - const [frame] = decodeAll(bridge, reply) - expect( - bridge.host.parseHandshakeMessage(frame?.payload ?? Buffer.alloc(0)), - `${bridge.label} could not read ${daemon.label}'s handshake verdict` - ).toMatchObject({ - type: agreed ? 'orca-relay-handshake-ok' : 'orca-relay-handshake-mismatch' - }) - }) - - it('carries a desktop JSON-RPC request to the peer daemon', () => { - const { bridge, daemon } = pair() - const request = { jsonrpc: '2.0', id: 7, method: 'preflight.probe', params: {} } - const [frame] = decodeAll(daemon, bridge.client.encodeJsonRpcFrame(request, 7, 0)) - expect(frame?.type).toBe(daemon.host.MessageType.Regular) - expect(daemon.host.parseJsonRpcMessage(frame?.payload ?? Buffer.alloc(0))).toMatchObject( - request - ) - }) - }) -} - -describePairing('new bridge against old daemon', () => ({ bridge: current, daemon: baseline })) -describePairing('old bridge against new daemon', () => ({ bridge: baseline, daemon: current })) - -describe('relay version negotiation', () => { - it('keeps the desktop and daemon copies of the version in step within a build', () => { - // The desktop compares its own constant against the daemon's sentinel, so the two - // copies disagreeing inside one build refuses every pairing that build takes part in. - for (const build of [current, baseline]) { - expect(build.client.RELAY_VERSION, `${build.label} desktop/daemon version skew`).toBe( - build.host.RELAY_VERSION - ) - } - }) -}) diff --git a/tests/e2e/cross-version-wire/release-checkout-tree.ts b/tests/e2e/cross-version-wire/release-checkout-tree.ts index 0ed196108a95..73438b1eceaf 100644 --- a/tests/e2e/cross-version-wire/release-checkout-tree.ts +++ b/tests/e2e/cross-version-wire/release-checkout-tree.ts @@ -1,24 +1,18 @@ import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { dirname, join, relative } from 'node:path' -// Why not 45s: the lane runs every suite in this directory in one vitest invocation, and they do -// not all pin the same commit, so two checkouts can fill at once. Extraction is ~9s of CPU; the -// budget has to cover that CPU competing with a sibling extraction and the suites already running, -// not a solo run on an idle machine. It bounds a cache fill, so a longer wait only delays a -// diagnosis that a hung `git archive` would produce either way. +// Why not 45s: the lane runs the whole directory at once, so several pinned checkouts extract +// concurrently beside running suites; this bounds a hung `git archive`, not a solo cold fill. const CHECKOUT_PROCESS_TIMEOUT_MS = 120_000 const CHECKOUT_MAX_OUTPUT_BYTES = 1024 * 1024 -// Why: the wire endpoints are the runtime RPC host, the renderer client, the shared -// codec, and the relay daemon — which an auto-update leaves running at its old version -// while the bridge that connects to it is new, so both sides of that pairing must be -// extractable. `src/cli` is still skipped; nothing it owns crosses a version boundary. +// Why: the wire endpoints only need the runtime RPC host, the renderer client, and +// the shared codec. Skipping cli/relay keeps a cold CI extraction a few seconds. // The phone's `worktree ps` row reader is one self-contained file, so it rides along alone. const ARCHIVE_PATHS = [ 'src/main', 'src/shared', 'src/preload', - 'src/relay', 'src/renderer', 'src/types', 'mobile/src/worktree/agent-row-display.ts' diff --git a/tests/e2e/cross-version-wire/release-checkout.ts b/tests/e2e/cross-version-wire/release-checkout.ts index 61e0e11ad867..6a2d58312d3b 100644 --- a/tests/e2e/cross-version-wire/release-checkout.ts +++ b/tests/e2e/cross-version-wire/release-checkout.ts @@ -12,7 +12,7 @@ export const REPO_ROOT = resolve(import.meta.dirname, '..', '..', '..') const DEFAULT_CACHE_ROOT = join(REPO_ROOT, 'tests', 'e2e', '.cross-version-checkouts') // Bump when extraction or the alias rewrite changes so cached trees are rebuilt. -const CHECKOUT_FORMAT = 5 +const CHECKOUT_FORMAT = 4 const BASELINE_REF_ENV = 'ORCA_CROSS_VERSION_BASELINE_REF' const STABLE_DESKTOP_RELEASE_TAG = /^v\d+\.\d+\.\d+$/ diff --git a/tests/e2e/cross-version-wire/versioned-agent-status-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-status-wire.ts deleted file mode 100644 index 7544d2a76c04..000000000000 --- a/tests/e2e/cross-version-wire/versioned-agent-status-wire.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - importReleaseCheckoutModule, - materializeReleaseCheckout, - type ReleaseCheckout -} from './release-checkout' - -/** - * One build's agent-status wire endpoints: the store that authors snapshots and mutation - * envelopes, the subject maker that names who a row is about, the child-work admission - * that writes rows, and the OSC payload parser that ingests what an agent CLI prints. - * - * Every export is read from the build under test, so nothing here states what a release - * does or does not have. A release that predates an export fails to load and says so. - */ - -export const WORKING_TREE = 'working-tree' as const - -type AnyRecord = Record - -export type AgentStatusStoreLike = { - getParent: (subject: unknown) => AnyRecord | null - getChild: (childWorkId: string) => AnyRecord | null - getSnapshot: () => AnyRecord - applyMutation: (mutation: unknown) => AnyRecord | null - applySnapshot: (snapshot: unknown) => boolean - applyTransportEnvelope: (envelope: unknown) => boolean -} - -export type AgentStatusWireBuild = { - /** Human label used in test names and failure messages. */ - label: string - /** `working-tree` for current code, otherwise the resolved release commit. */ - revision: string - /** Status arms this build can name, read from the build rather than written down. */ - states: readonly string[] - createStore: (options: { epoch: string; mode: 'authority' | 'replica' }) => AgentStatusStoreLike - makeStructuredSubject: (scope: unknown, sessionId: string) => unknown - announceChild: (store: AgentStatusStoreLike, childWorkId: string, request: unknown) => AnyRecord - /** Normalize an OSC status payload the way this build's host ingress does. */ - normalizeStatusPayload: (payload: unknown) => AnyRecord | null -} - -type StoreModule = { - createAgentStatusStore: (options: { epoch: string; mode: string }) => AgentStatusStoreLike -} -type SubjectModule = { - makeStructuredAgentStatusSubject: (scope: unknown, sessionId: string) => unknown -} -type AdmissionModule = { - createAgentChildWorkAdmission: ( - store: AgentStatusStoreLike, - options: { mintChildWorkId: () => string } - ) => { announce: (request: unknown) => AnyRecord } -} -type TypesModule = { - AGENT_STATUS_STATES: readonly string[] - normalizeAgentStatusPayload: (payload: unknown) => AnyRecord | null -} - -type LoadedModules = { - store: StoreModule - subject: SubjectModule - admission: AdmissionModule - types: TypesModule -} - -/** Every export a pairing reads, proven present before any assertion runs. */ -function requireExports(label: string, modules: Record): LoadedModules { - const missing = [ - ['store', 'createAgentStatusStore'], - ['subject', 'makeStructuredAgentStatusSubject'], - ['admission', 'createAgentChildWorkAdmission'], - ['types', 'normalizeAgentStatusPayload'] - ].filter(([module, name]) => typeof modules[module ?? '']?.[name ?? ''] !== 'function') - if (missing.length > 0) { - throw new Error( - `Build ${label} is missing agent-status wire exports: ${missing - .map(([module, name]) => `${name} (${module})`) - .join(', ')}` - ) - } - const states = modules.types?.AGENT_STATUS_STATES - if (!Array.isArray(states) || states.some((state) => typeof state !== 'string')) { - throw new Error(`Build ${label} publishes no AGENT_STATUS_STATES arm set`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every export read below was just proven callable, and the arm set proven to be strings. - return modules as unknown as LoadedModules -} - -function assembleBuild( - label: string, - revision: string, - modules: LoadedModules -): AgentStatusWireBuild { - return { - label, - revision, - states: modules.types.AGENT_STATUS_STATES, - createStore: (options) => modules.store.createAgentStatusStore(options), - makeStructuredSubject: (scope, sessionId) => - modules.subject.makeStructuredAgentStatusSubject(scope, sessionId), - announceChild: (store, childWorkId, request) => - modules.admission - .createAgentChildWorkAdmission(store, { mintChildWorkId: () => childWorkId }) - .announce(request), - normalizeStatusPayload: (payload) => modules.types.normalizeAgentStatusPayload(payload) - } -} - -async function loadWorkingTreeBuild(): Promise { - const [store, subject, admission, types] = await Promise.all([ - import('../../../src/shared/agent-status-store'), - import('../../../src/shared/agent-status-subject'), - import('../../../src/shared/agent-status-child-work-admission'), - import('../../../src/shared/agent-status-types') - ]) - return assembleBuild( - WORKING_TREE, - WORKING_TREE, - requireExports(WORKING_TREE, { store, subject, admission, types }) - ) -} - -async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { - const [store, subject, admission, types] = await Promise.all([ - importReleaseCheckoutModule(checkout, '/src/shared/agent-status-store.ts'), - importReleaseCheckoutModule(checkout, '/src/shared/agent-status-subject.ts'), - importReleaseCheckoutModule(checkout, '/src/shared/agent-status-child-work-admission.ts'), - importReleaseCheckoutModule(checkout, '/src/shared/agent-status-types.ts') - ]) - return assembleBuild( - checkout.ref, - checkout.commit, - requireExports(checkout.ref, { store, subject, admission, types }) - ) -} - -/** - * Load the agent-status wire surface for one build. `WORKING_TREE` imports current - * source; any other value is a git ref extracted into a cached checkout. - */ -export async function loadAgentStatusWireBuild(ref: string): Promise { - if (ref === WORKING_TREE) { - return loadWorkingTreeBuild() - } - return loadReleaseBuild(await materializeReleaseCheckout(ref)) -} From 27c6e4b1dc87f8373f9aea8438717882a9e6f559 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:58:34 -0700 Subject: [PATCH 4/7] test(cross-version-wire): keep the timeout edits to the two downgrade suites minimal A named timeout after the test body makes the formatter re-indent the whole test; a numeric literal keeps the test-call layout, so each suite diff is two lines. --- ...sion-death-evidence-downgrade.unit.test.ts | 94 +++++++++---------- ...hestration-delivery-downgrade.unit.test.ts | 5 +- 2 files changed, 45 insertions(+), 54 deletions(-) diff --git a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts index a9da2fba8416..001575587bd7 100644 --- a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts @@ -13,58 +13,50 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // The last release before a proof of death named its owner and its last proof of life. const BASELINE_REF = 'v1.4.211' const SESSION = 'session-alpha-1' -// Why the explicit budget: a cold run may extract the pinned release inside this test. -const TIMEOUT_MS = 180_000 -test( - 'an older build loads a proof of death that names its owner and its last proof of life', - async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) - try { - // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. - writeFileSync( - agentSessionStorePath(directory), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: 90_000 - }) - const evidence = store.getRecord(SESSION)?.lease.deathEvidence - expect(evidence).toEqual({ - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 90_000, - ownerFence: 7, - lastProvenAliveAt: 30_000 +// Why the explicit budget: a cold run may extract the pinned release inside this test. +test('an older build loads a proof of death that names its owner and its last proof of life', async () => { + const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) + try { + // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. + writeFileSync( + agentSessionStorePath(directory), + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} }) + ) + const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) + const evidence = store.getRecord(SESSION)?.lease.deathEvidence + expect(evidence).toEqual({ + kind: 'pid-absent', + detail: 'recorded pid absent on host', + observedAt: 90_000, + ownerFence: 7, + lastProvenAliveAt: 30_000 + }) - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/agent-session-record-store.ts' - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. - const OldStore = baseline.AgentSessionRecordStore as { - open: (args: { directory: string; hostId: string }) => Promise - } - const old = await OldStore.open({ directory, hostId: 'local' }) - expect(old.isSessionUnreadable(SESSION)).toBe(false) - expect(old.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: evidence - }) - } finally { - rmSync(directory, { recursive: true, force: true }) + const checkout = await materializeReleaseCheckout(BASELINE_REF) + const baseline = await importReleaseCheckoutModule( + checkout, + 'src/main/runtime/agent-session-record-store.ts' + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. + const OldStore = baseline.AgentSessionRecordStore as { + open: (args: { directory: string; hostId: string }) => Promise } - }, - TIMEOUT_MS -) + const old = await OldStore.open({ directory, hostId: 'local' }) + expect(old.isSessionUnreadable(SESSION)).toBe(false) + expect(old.getRecord(SESSION)?.lease).toMatchObject({ + claimStatus: 'released', + deathEvidence: evidence + }) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 180_000) diff --git a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts index 00732a4eb29b..0398b4085855 100644 --- a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts @@ -8,9 +8,8 @@ import { importReleaseCheckoutModule, materializeReleaseCheckout } from './relea // Pin the last pre-v41 implementation: this contract specifically exercises status-only readers. const PRE_V41 = 'aac38d698ff75ac4c8658addab48ef5a83617619' -// Why the explicit budget: this pins a commit no sibling shares, so a cold run extracts its own checkout. -const TIMEOUT_MS = 180_000 +// Why the explicit budget: this pins a commit no sibling shares, so a cold run extracts its own checkout. test('pre-v41 code opens, acknowledges and writes a current-schema database, then current code reopens it', async () => { const checkout = await materializeReleaseCheckout(PRE_V41) const baseline = await importReleaseCheckoutModule( @@ -69,4 +68,4 @@ test('pre-v41 code opens, acknowledges and writes a current-schema database, the db?.close() rmSync(directory, { recursive: true, force: true }) } -}, TIMEOUT_MS) +}, 180_000) From f4f6f76750b1cec749e5a8b00e70f84a7178470a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:06:29 -0700 Subject: [PATCH 5/7] test(cross-version-wire): stop claiming the status file is agent status's only version boundary Session-tab sync also carries each terminal tab's agent status to paired clients of any version; say so where the suite and the reference name what is and is not paired. --- docs/reference/remote-wire-compatibility.md | 3 ++- .../cross-version-agent-status-payload.unit.test.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 7b6976f212ce..11d864848ff6 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -207,7 +207,8 @@ always the desktop's own build: each build installs it under its own content-has binds its socket there, and the handshake refuses any other build's version (see `src/main/ssh/ssh-relay-superseded-endpoints.ts`). -The harness does **not** cover the session-tab sync channel, legacy agent-session +The harness does **not** cover the session-tab sync channel (which also carries each terminal +tab's agent status to paired clients), legacy agent-session publications, file or Git RPCs, or mobile/E2EE framing. A change on those paths still needs its own reasoning against the rules above. diff --git a/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts index 52f89d852fed..7e27f8492947 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts @@ -3,9 +3,10 @@ // The hook server persists every row's payload to its status file, and after an update or a // downgrade the other build re-admits it with its own `normalizeAgentStatusPayload` // (server-persistence-validation.ts). A payload that normalizer refuses loses the row on -// restart; a field it reads differently is a row misread. That is the version boundary this -// surface has. The canonical status store never leaves the process that owns it, and the relay -// that forwards hook envelopes is always the desktop's own build. +// restart; a field it reads differently is a row misread. That is the boundary this suite pairs. +// The canonical status store never leaves the process that owns it, and the relay that forwards +// hook envelopes is always the desktop's own build. Session-tab sync also carries each tab's +// status to paired clients of any version; it is not paired here. import { beforeAll, describe, expect, it } from 'vitest' import { From e8fdd627738d7cb6439de60f84e31586f1db5240 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:22:57 -0700 Subject: [PATCH 6/7] test(cross-version-wire): start the job for every module a suite pairs The registration test now scans each suite (and the wire helpers suites load through) for the working-tree modules and release-checkout paths it pairs, and fails when one does not start the cross-version-wire job. Nine paired modules did not; they are added as exact paths. The phone's worktree-ps row reader is a mobile-only diff, so the job is lifted outside should_run like mobile_web_app. Drops the agent-status payload pairing: the hook server's status file is a minor path, the suite skipped the new-arm-on-downgrade case it claimed, and it could not see the file's own version gate. Session-tab sync's agentStatus is now listed as unpaired instead. --- ...ss-version-wire-lane-registration.test.mjs | 78 ++++++++- config/scripts/pr-code-change-scope.mjs | 20 ++- config/scripts/pr-code-change-scope.test.mjs | 16 +- docs/reference/remote-wire-compatibility.md | 31 ++-- ...-version-agent-status-payload.unit.test.ts | 149 ------------------ 5 files changed, 111 insertions(+), 183 deletions(-) delete mode 100644 tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts diff --git a/config/scripts/cross-version-wire-lane-registration.test.mjs b/config/scripts/cross-version-wire-lane-registration.test.mjs index 0de7a90d4932..de78f22f2536 100644 --- a/config/scripts/cross-version-wire-lane-registration.test.mjs +++ b/config/scripts/cross-version-wire-lane-registration.test.mjs @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' @@ -19,7 +19,8 @@ import { classifyPrJobs } from './pr-code-change-scope.mjs' * * Both halves matter and being in one is not enough: `CROSS_VERSION_WIRE_PREFIXES` in * pr-code-change-scope.mjs decides whether the job RUNS for a diff, and the argv decides - * whether the FILE runs once the job started. + * whether the FILE runs once the job started. So every module a suite pairs has to start the + * job too, or a change to it breaks the pairing on a PR that never runs the lane. */ const projectDir = resolve(import.meta.dirname, '../..') @@ -37,6 +38,25 @@ const SUITE_PATTERN = /\.unit\.test\.ts$/ */ const SUITE_FLOOR = 9 +/** + * The release-checkout machinery extracts a tree and pairs nothing; `release-checkout.unit.test.ts` + * feeds it made-up paths on purpose. + */ +const CHECKOUT_MACHINERY = new Set([ + 'release-checkout.ts', + 'release-checkout-tree.ts', + 'release-checkout.unit.test.ts' +]) + +/** A working-tree import, static or dynamic, from a suite or a wire helper it loads through. */ +const WORKING_TREE_IMPORT = /['"]\.\.\/\.\.\/\.\.\/((?:src|mobile)\/[^'"]+)['"]/g + +/** A repo path handed to `importReleaseCheckoutModule`, inline or through a constant. */ +const CHECKOUT_MODULE_PATH = /['"]\/?((?:src|mobile)\/[^'"\s]+\.tsx?)['"]/g + +/** Floor for the paired modules found, so a scanner that stops matching cannot pass empty. */ +const PAIRED_MODULE_FLOOR = 25 + function readWorkflow(relativePath) { return parse(readFileSync(join(projectDir, relativePath), 'utf8')) } @@ -69,6 +89,36 @@ const suites = readdirSync(join(projectDir, LANE_DIRECTORY)) .map((name) => `${LANE_DIRECTORY}/${name}`) .sort() +function resolveModule(specifier) { + if (/\.tsx?$/.test(specifier)) { + return specifier + } + const candidates = [`${specifier}.ts`, `${specifier}.tsx`, `${specifier}/index.ts`] + return candidates.find((path) => existsSync(join(projectDir, path))) ?? specifier +} + +/** Each module the suites load for pairing, with the first file that loads it. */ +function readPairedModules() { + const modules = new Map() + for (const name of readdirSync(join(projectDir, LANE_DIRECTORY)).sort()) { + if (!name.endsWith('.ts') || CHECKOUT_MACHINERY.has(name)) { + continue + } + const source = readFileSync(join(projectDir, LANE_DIRECTORY, name), 'utf8') + for (const pattern of [WORKING_TREE_IMPORT, CHECKOUT_MODULE_PATH]) { + for (const [, specifier] of source.matchAll(pattern)) { + const path = resolveModule(specifier) + if (!modules.has(path)) { + modules.set(path, name) + } + } + } + } + return modules +} + +const pairedModules = readPairedModules() + /** A vitest positional selects a file when it names the file or a directory above it. */ function selectedByLane(path) { return laneArgv.some((token) => path === token || path.startsWith(token)) @@ -103,4 +153,28 @@ describe('cross-version wire suites run somewhere', () => { expect(classifyPrJobs([path])[LANE_JOB], path).toBe(true) } }) + + it('finds a plausible number of paired modules', () => { + expect( + pairedModules.size, + `Only ${pairedModules.size} paired modules were recognized; the floor is ` + + `${PAIRED_MODULE_FLOOR}. The scanner has probably stopped matching a load shape.` + ).toBeGreaterThanOrEqual(PAIRED_MODULE_FLOOR) + }) + + it('pairs only modules that exist', () => { + const missing = [...pairedModules] + .filter(([path]) => !existsSync(join(projectDir, path))) + .map(([path, loader]) => `${path} (loaded by ${loader})`) + expect(missing).toEqual([]) + }) + + it('runs the lane for a change to any module a suite pairs', () => { + const ungated = [...pairedModules] + .filter(([path]) => !classifyPrJobs([path])[LANE_JOB]) + .map(([path, loader]) => `${path} (loaded by ${loader})`) + expect(ungated, 'add each to CROSS_VERSION_WIRE_PREFIXES in pr-code-change-scope.mjs').toEqual( + [] + ) + }) }) diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 6aeb309deedb..4f2d293b64f6 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -138,11 +138,6 @@ function changesMobileWebApp(changedFiles) { const CROSS_VERSION_WIRE_PREFIXES = [ 'tests/e2e/cross-version-wire/', 'src/shared/protocol-version', - // A persisted status row is re-admitted by the other build's normalizeAgentStatusPayload after - // an update or a downgrade; these decide which rows and fields it admits. - 'src/shared/agent-status-types', - 'src/shared/agent-status-field-normalization', - 'src/shared/agent-turn-outcome', 'src/shared/terminal-stream-protocol', 'src/shared/browser-client-host-protocol', 'src/shared/browser-network-tunnel-protocol', @@ -169,7 +164,16 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/runtime/runtime-worktree-agent-', 'src/main/runtime/runtime-worktree-pty-agent-sources', 'src/shared/runtime-worktree-contracts', - 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer' + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer', + 'src/main/runtime/rpc/methods/index.ts', + 'src/main/runtime/runtime-subscription-registry.ts', + 'src/shared/terminal-retirement-proof-ledger.ts', + 'src/main/runtime/mobile-session-terminal-retirement.ts', + 'src/main/runtime/mobile-session-terminal-retirement-proof.ts', + 'src/main/persistence/tracking-repos/worktree-identity-migration.ts', + 'src/main/runtime/orchestration/db.ts', + 'src/main/runtime/orchestration/db/contract-constants.ts', + 'mobile/src/worktree/agent-row-display.ts' ] const MANAGED_HOOK_PREFIXES = [ @@ -411,6 +415,10 @@ export function classifyPrJobs(changedFiles) { // that is exactly the diff that changes the page this job builds. Gated on should_run it would // skip on every PR that can break it and run on none. jobs.mobile_web_app = jobs.mobile_web_app || changesMobileWebApp(changedFiles) + // Why outside should_run, for the same reason: the phone's `worktree ps` row reader is paired + // against the host, and a change to it is a mobile-only diff. + jobs['cross-version-wire'] = + jobs['cross-version-wire'] || jobDetector('cross-version-wire')(changedFiles) return { should_run: shouldRun, native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)), diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 6f766499dada..87b32df518ef 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -353,10 +353,15 @@ describe('per-job path classification', () => { 'src/main/runtime/runtime-worktree-agent-rows.ts', 'src/main/runtime/runtime-worktree-pty-agent-sources.ts', 'src/shared/runtime-worktree-contracts.ts', - 'src/shared/agent-status-types.ts', - 'src/shared/agent-status-field-normalization.ts', - 'src/shared/agent-turn-outcome.ts', - 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts' + 'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts', + 'src/main/runtime/rpc/methods/index.ts', + 'src/main/runtime/runtime-subscription-registry.ts', + 'src/shared/terminal-retirement-proof-ledger.ts', + 'src/main/runtime/mobile-session-terminal-retirement.ts', + 'src/main/runtime/mobile-session-terminal-retirement-proof.ts', + 'src/main/persistence/tracking-repos/worktree-identity-migration.ts', + 'src/main/runtime/orchestration/db.ts', + 'src/main/runtime/orchestration/db/contract-constants.ts' ]) { expectClassification([file], { 'cross-version-wire': true, @@ -368,6 +373,9 @@ describe('per-job path classification', () => { ['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'], { 'cross-version-wire': true } ) + const phoneRowReader = classifyPrJobs(['mobile/src/worktree/agent-row-display.ts']) + expect(phoneRowReader.should_run).toBe(false) + expect(phoneRowReader['cross-version-wire']).toBe(true) }) it('runs workflow-self-change and lockfile diffs as force-all', () => { diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 11d864848ff6..912ac9445328 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -192,32 +192,19 @@ Run it with: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts ``` -`tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts` pairs the same -two builds over the agent-status payload. Its version boundary is the hook server's status file: -after an update or a downgrade the other build re-admits every persisted row with its own -`normalizeAgentStatusPayload`, which refuses a status arm it does not know. The suite writes a -row in every baseline arm with one build and reads it with the other, in both directions, and -checks that current code still writes every arm and field the baseline writes. - -Two nearby surfaces have no pairing because nothing of another version reads them. The -canonical agent-status store's snapshot and mutation envelope never leave the process that owns -the store, and their decoders refuse unknown keys; a transport that carries them to a replica -must add a version check or lenient decoding, and a pairing, when it ships. The SSH relay is -always the desktop's own build: each build installs it under its own content-hashed directory, -binds its socket there, and the handshake refuses any other build's version (see -`src/main/ssh/ssh-relay-superseded-endpoints.ts`). - -The harness does **not** cover the session-tab sync channel (which also carries each terminal -tab's agent status to paired clients), legacy agent-session -publications, file or Git RPCs, or mobile/E2EE framing. A change on those paths still needs its -own reasoning against the rules above. +The harness covers the terminal stream and the structured agent-session surface. The one +agent-status path it pairs is the `worktree ps` row (`cross-version-worktree-ps-verdict.unit.test.ts`). +It does **not** cover the session-tab sync channel, including the `agentStatus` each terminal +tab carries to paired desktops and phones, legacy agent-session publications, file or Git RPCs, +mobile/E2EE framing, or the relay transport. A change on those paths still needs its own +reasoning against the three rules above. Every suite in `tests/e2e/cross-version-wire/` runs in the `cross-version-wire` PR job and nowhere else — the unit shards exclude the directory, because a shallow shard clone has no tags for an extracted release to skew against. The job therefore names the directory rather than a -list of files, and `config/scripts/cross-version-wire-lane-registration.test.mjs` holds that -shut. Which diffs start the job is a separate list, `CROSS_VERSION_WIRE_PREFIXES` in -`config/scripts/pr-code-change-scope.mjs`; a wire module missing from it runs no pairing at all. +list of files. Which diffs start the job is a separate list, `CROSS_VERSION_WIRE_PREFIXES` in +`config/scripts/pr-code-change-scope.mjs`, and every module a suite pairs must be on it. +`config/scripts/cross-version-wire-lane-registration.test.mjs` holds both. ## Worked example: `agentWait` on terminal and worker reads diff --git a/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts deleted file mode 100644 index 7e27f8492947..000000000000 --- a/tests/e2e/cross-version-wire/cross-version-agent-status-payload.unit.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// Cross-version coverage for the agent-status payload: current code against the newest release. -// -// The hook server persists every row's payload to its status file, and after an update or a -// downgrade the other build re-admits it with its own `normalizeAgentStatusPayload` -// (server-persistence-validation.ts). A payload that normalizer refuses loses the row on -// restart; a field it reads differently is a row misread. That is the boundary this suite pairs. -// The canonical status store never leaves the process that owns it, and the relay that forwards -// hook envelopes is always the desktop's own build. Session-tab sync also carries each tab's -// status to paired clients of any version; it is not paired here. - -import { beforeAll, describe, expect, it } from 'vitest' -import { - importReleaseCheckoutModule, - materializeReleaseCheckout, - resolveBaselineReleaseRef -} from './release-checkout' - -// Why: a cold CI run extracts the baseline checkout before the first pairing. -const SUITE_TIMEOUT_MS = 180_000 - -type Payload = Record - -type StatusBuild = { - label: string - states: readonly string[] - normalize: (payload: unknown) => Payload | null -} - -function toBuild(label: string, module: Record): StatusBuild { - const { AGENT_STATUS_STATES: states, normalizeAgentStatusPayload: normalize } = module - if (typeof normalize !== 'function') { - throw new Error(`Build ${label} exports no normalizeAgentStatusPayload`) - } - if (!Array.isArray(states) || !states.every((state) => typeof state === 'string')) { - throw new Error(`Build ${label} exports no AGENT_STATUS_STATES arm set`) - } - return { - label, - states, - normalize: (payload) => { - const result: unknown = normalize(payload) - return typeof result === 'object' && result !== null - ? Object.fromEntries(Object.entries(result)) - : null - } - } -} - -let current: StatusBuild -let baseline: StatusBuild - -beforeAll(async () => { - const baselineRef = resolveBaselineReleaseRef() - const [working, released] = await Promise.all([ - import('../../../src/shared/agent-status-types'), - materializeReleaseCheckout(baselineRef).then((checkout) => - importReleaseCheckoutModule(checkout, '/src/shared/agent-status-types.ts') - ) - ]) - current = toBuild('working-tree', working) - baseline = toBuild(baselineRef, released) -}, SUITE_TIMEOUT_MS) - -/** Every optional field a row can carry; each build keeps what it knows for the given state. */ -function rowFor(state: string): Payload { - return { - state, - workingMode: 'monitoring', - prompt: 'cross-version prompt', - agentType: 'claude', - model: 'claude-opus', - modelSwitchCommand: 'orca-model', - toolName: 'Read', - toolInput: 'src/index.ts', - interactivePrompt: 'Allow Read?', - lastAssistantMessage: 'first line\nsecond line', - lastAssistantMessageIsToolOutput: true, - interrupted: true, - sessionBoundary: true, - turnCompletedAt: 1_700_000_000_500, - subagents: [ - { - id: 'subagent-1', - agentType: 'explore', - model: 'claude-haiku', - description: 'scan the tree', - state: 'working', - startedAt: 1_700_000_000_000 - } - ], - mainAgent: { state, outcome: 'cancellation', stateStartedAt: 1_700_000_000_100 } - } -} - -function definedKeys(payload: Payload | null): string[] { - return Object.keys(payload ?? {}) - .filter((key) => payload?.[key] !== undefined) - .sort() -} - -function describePairing( - name: string, - pair: () => { writer: StatusBuild; reader: StatusBuild } -): void { - describe(name, () => { - it('re-admits every row the writer persisted and reads each shared field as its own', () => { - const { writer, reader } = pair() - // Every arm the release can write; an arm current drops fails the old-writer direction. - for (const state of baseline.states) { - const raw = rowFor(state) - const persisted = writer.normalize(raw) - expect(persisted, `${writer.label} refused its own ${state} row`).not.toBeNull() - const read = reader.normalize(JSON.parse(JSON.stringify(persisted))) - expect( - read, - `${reader.label} dropped a ${state} row ${writer.label} persisted` - ).not.toBeNull() - // The reader's own normalization of the same row is the expectation, so a field only - // one build knows is neither required nor refused. - const own = reader.normalize(raw) - const shared = definedKeys(persisted).filter((key) => definedKeys(own).includes(key)) - expect( - Object.fromEntries(shared.map((key) => [key, read?.[key]])), - `${reader.label} misread a ${state} row ${writer.label} persisted` - ).toEqual(Object.fromEntries(shared.map((key) => [key, own?.[key]]))) - } - }) - }) -} - -describePairing('new writer against old reader', () => ({ writer: current, reader: baseline })) -describePairing('old writer against new reader', () => ({ writer: baseline, reader: current })) - -describe('what current code still writes', () => { - it('keeps every status arm the baseline can write', () => { - expect(baseline.states.length).toBeGreaterThan(0) - expect(current.states).toEqual(expect.arrayContaining([...baseline.states])) - }) - - it('keeps every payload field the baseline writes', () => { - // A field current stops writing reaches an older reader as `undefined` after a downgrade. - for (const state of baseline.states) { - const raw = rowFor(state) - expect(definedKeys(current.normalize(raw)), state).toEqual( - expect.arrayContaining(definedKeys(baseline.normalize(raw))) - ) - } - }) -}) From 7a75ff2098ec807e65b47b46bcca673f3fcb1b9f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:56:33 -0700 Subject: [PATCH 7/7] ci(cross-version-wire): start the job for an orchestration schema change The orchestration downgrade suite opens a current-schema database with old code, so a migration under db/schema/ is what it guards. The registration guard only sees modules a suite loads directly and db.ts reaches the schema transitively, so gate it by hand and say so where the guard is described. --- config/scripts/cross-version-wire-lane-registration.test.mjs | 5 ++++- config/scripts/pr-code-change-scope.mjs | 2 ++ config/scripts/pr-code-change-scope.test.mjs | 3 ++- docs/reference/remote-wire-compatibility.md | 4 +++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/config/scripts/cross-version-wire-lane-registration.test.mjs b/config/scripts/cross-version-wire-lane-registration.test.mjs index de78f22f2536..817cf903ce54 100644 --- a/config/scripts/cross-version-wire-lane-registration.test.mjs +++ b/config/scripts/cross-version-wire-lane-registration.test.mjs @@ -97,7 +97,10 @@ function resolveModule(specifier) { return candidates.find((path) => existsSync(join(projectDir, path))) ?? specifier } -/** Each module the suites load for pairing, with the first file that loads it. */ +/** + * Each module the suites load for pairing, with the first file that loads it. Only direct + * loads: a module reached through one (the orchestration schema, via db.ts) is gated by hand. + */ function readPairedModules() { const modules = new Map() for (const name of readdirSync(join(projectDir, LANE_DIRECTORY)).sort()) { diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 4f2d293b64f6..f247d731159e 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -173,6 +173,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [ 'src/main/persistence/tracking-repos/worktree-identity-migration.ts', 'src/main/runtime/orchestration/db.ts', 'src/main/runtime/orchestration/db/contract-constants.ts', + // Why: the orchestration downgrade suite opens a current-schema database with old code; db.ts reaches this transitively. + 'src/main/runtime/orchestration/db/schema/', 'mobile/src/worktree/agent-row-display.ts' ] diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs index 87b32df518ef..23df99865594 100644 --- a/config/scripts/pr-code-change-scope.test.mjs +++ b/config/scripts/pr-code-change-scope.test.mjs @@ -361,7 +361,8 @@ describe('per-job path classification', () => { 'src/main/runtime/mobile-session-terminal-retirement-proof.ts', 'src/main/persistence/tracking-repos/worktree-identity-migration.ts', 'src/main/runtime/orchestration/db.ts', - 'src/main/runtime/orchestration/db/contract-constants.ts' + 'src/main/runtime/orchestration/db/contract-constants.ts', + 'src/main/runtime/orchestration/db/schema/migrate-v42.ts' ]) { expectClassification([file], { 'cross-version-wire': true, diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 912ac9445328..bd6eb5fcf3ae 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -204,7 +204,9 @@ nowhere else — the unit shards exclude the directory, because a shallow shard for an extracted release to skew against. The job therefore names the directory rather than a list of files. Which diffs start the job is a separate list, `CROSS_VERSION_WIRE_PREFIXES` in `config/scripts/pr-code-change-scope.mjs`, and every module a suite pairs must be on it. -`config/scripts/cross-version-wire-lane-registration.test.mjs` holds both. +`config/scripts/cross-version-wire-lane-registration.test.mjs` holds both, but it sees only the +modules a suite loads directly; one reached through them, such as the orchestration schema +migrations behind `db.ts`, has to be added to the list by hand. ## Worked example: `agentWait` on terminal and worker reads