diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c2c3119456b..5ceff36ced4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -200,6 +200,36 @@ jobs: src/main/pty/omp-shell-wrapper.node-pty.test.ts \ src/shared/posix-command-path-lookup.test.ts + daemon_cross_protocol: + name: daemon cross-protocol reattach + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + # Why: the spec builds each previous release's daemon from its own tag, + # so it needs full history and tags rather than the shallow default. + fetch-depth: 0 + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + - name: Restore legacy daemon bundle cache + uses: actions/cache@v5 + with: + path: node_modules/.cache/orca-daemon-release-entries + key: legacy-daemons-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/main/daemon/legacy-daemon-release-refs.ts') }} + restore-keys: | + legacy-daemons-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + + - name: Test current adapter against every previous daemon + run: | + pnpm exec vitest run --bail=1 --config config/vitest.config.ts \ + src/main/daemon/cross-protocol-daemon-reattach.test.ts + test: name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }} runs-on: ubuntu-latest @@ -233,6 +263,7 @@ jobs: --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ --exclude=src/shared/posix-command-path-lookup.test.ts \ + --exclude=src/main/daemon/cross-protocol-daemon-reattach.test.ts \ --shard=${{ matrix.shard }}/${{ matrix.shard_total }} package: @@ -398,6 +429,7 @@ jobs: - typecheck - git_compatibility - shell_contracts + - daemon_cross_protocol - test - package - package_windows @@ -419,6 +451,7 @@ jobs: TYPECHECK: ${{ needs.typecheck.result }} GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} + DAEMON_CROSS_PROTOCOL: ${{ needs.daemon_cross_protocol.result }} TEST: ${{ needs.test.result }} PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} @@ -429,6 +462,7 @@ jobs: "$TYPECHECK" \ "$GIT_COMPATIBILITY" \ "$SHELL_CONTRACTS" \ + "$DAEMON_CROSS_PROTOCOL" \ "$TEST" \ "$PACKAGE" \ "$PACKAGE_WINDOWS"; do diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 585640c7b8d..6584e6c0019 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -8966,6 +8966,95 @@ "The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology." ], "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." + }, + { + "id": "terminal-daemon.cross-protocol-reattach", + "title": "The current adapter adopts a live pane from every still-supported daemon protocol without replacing or orphaning a shell", + "maturity": "experimental", + "protection": "partial", + "owner": "daemon-terminal", + "layer": "daemon-protocol-compatibility", + "surfaces": [ + "pane mount after an app update", + "attach-only spawn against a preserved daemon", + "legacy attach-only emulation", + "daemon session and process lifecycle" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local-daemon"], + "coveredPlatforms": ["macos", "linux"], + "coveredProviders": ["local-daemon"], + "coverageNotes": "Each still-supported protocol is replayed by building its declared historical daemon entry and booting it under plain Node on a private socket, then driving it with the working tree's DaemonPtyAdapter pinned to that protocol. The working-tree build runs as a control case. Windows is skipped because the fixture drives the PTY with a POSIX shell command and reads daemon children through ps; SSH, WSL, and relay providers are out of scope because they do not speak the local daemon protocol.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/11789", + "https://github.com/stablyai/orca/pull/12351" + ], + "invariant": "For every protocol in PREVIOUS_DAEMON_PROTOCOL_VERSIONS plus PROTOCOL_VERSION, an attach-only spawn issued by the current build against a real daemon of that protocol must return the existing session, the same shell pid, and a snapshot still carrying output written before the attach. An attach-only spawn for an unknown session id must fail with a rejection the pane mount classifies as already-gone, must leave the daemon's session list and child-process set exactly as they were, and must not disturb the unrelated live session's liveness or pid. PREVIOUS_DAEMON_PROTOCOL_VERSIONS must end at PROTOCOL_VERSION - 1 and every entry must have a declared historical ref whose own source reports that protocol.", + "oracle": "For each protocol, extract src/main and src/shared at that protocol's shipped ref, bundle daemon-entry.ts against the current node_modules, and boot it on a temporary socket with VITEST cleared. Create a real PTY session through the current adapter, drive the shell until it emits a per-protocol marker, and disconnect. Attach-only through a fresh adapter and require the same session id, isReattach true, the original shell pid, the marker inside the returned snapshot, and an unchanged session list. Then attach-only to an absent id and require a rejection matching the message the mount path keys on, the SessionNotFoundError class on emulated protocols, the orphan session record and any created shell process to clear within a bounded settle, and the surviving session to keep its pid and liveness. Separately assert the manifest and the previous-version list agree with PROTOCOL_VERSION.", + "commands": [ + "pnpm exec vitest run --bail=1 --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts" + ], + "testFiles": ["src/main/daemon/cross-protocol-daemon-reattach.test.ts"], + "assertionRefs": [ + { + "file": "src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "assertions": [ + "declares a release for every still-supported previous protocol", + "requires a protocol bump to extend the previous-version list", + "adopts the live session instead of replacing it", + "rejects a missing session without leaving an orphan behind" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "durationSeconds": 18.6, + "summary": "64 tests passed against 30 historical daemon builds plus the working-tree control, with a warm bundle cache." + }, + { + "date": "2026-08-03", + "runner": "local", + "platform": "linux", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cross-protocol-daemon-reattach.test.ts", + "durationSeconds": 9.9, + "summary": "Reproduction of the daemon_cross_protocol job in a node:24 container from a --shared clone: 64 passed cold, 4.2s warm. The same container reverted PR 12351 and failed 60 of 64, so the gate bites on the CI platform, not only on macOS." + } + ], + "runtimeBudget": { + "p95Seconds": 60, + "scope": "31 sequential daemon builds and boots, each with one real PTY session" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Local macOS and containerized linux sweeps passed. The suite is new, so no CI or soak history exists yet. Two environmental sensitivities are known. Host pty exhaustion: the orphan case needs one pty beyond the live pane, and a saturated host fails it with an explicit posix_openpt error - macos caps ptmx at 511 where linux allows 4096, so this is effectively a developer-machine condition. Ambient SHELL: every daemon build resolves the PTY shell as env.SHELL then process.env.SHELL then /bin/zsh, so an unset SHELL on a zsh-less image failed 20 of 31 protocols; bootDaemon now pins SHELL to the first existing of /bin/bash and /bin/sh instead of inheriting it." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Two mutations of daemon-pty-adapter.ts were run against the suite. Restoring the #11789 hard throw for legacy attach-only failed 60 of 64 tests (all 30 legacy protocols, both cases). Deleting only the kill-and-throw so the emulated create is silently kept failed 30 of 64 - the exact silent-replacement mode the gate exists for. The adapter was restored and the suite returned to 64 passed." + }, + "performanceBudget": { + "required": false, + "evidence": "No product code is touched. The fixture bundles each release's daemon entry with esbuild and caches it under node_modules/.cache keyed by commit, so a warm run pays only for forks and PTY spawns. macOS measured about 32 seconds cold and 19 warm; linux, which is what the CI job runs on, measured 9.9 cold and 4.2 warm." + }, + "promotionCriteria": [ + "Accumulate CI history from the dedicated daemon_cross_protocol job; linux is covered by a containerized reproduction but has never run on a real GitHub runner.", + "Confirm the cold-cache runtime stays inside the runtime budget on CI hardware.", + "Keep the pid-identity, snapshot-marker, and orphan-settle assertions green as new protocols are appended." + ], + "knownGaps": [ + "Windows is skipped entirely: the marker command is POSIX shell and the orphan-process check reads ps, so ConPTY daemons get no coverage.", + "Only one representative ref per protocol is replayed, so other daemon builds within the same protocol are not exercised.", + "The daemon is bundled against the current node_modules rather than the release's own dependency tree, so a native-dependency behavior change between releases is not reproduced.", + "The orphan case needs one pty beyond the live pane; a host at its pty ceiling fails rather than skipping, which is deliberate but makes the gate sensitive to unrelated load." + ], + "demotionRule": "Keep experimental or demote if attach-only against a preserved daemon can return a different pid than the live pane, a failed attach can leave a session record or shell process behind, the previous-protocol list can grow without a matching release ref, or the gate flakes for reasons other than host pty exhaustion." } ] } diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index d1e90fad17a..32f2124cf7a 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -17,6 +17,7 @@ const patchedNodePtyContractFiles = [ 'src/main/daemon/node-pty-fd-leak.test.ts', 'src/main/pty/omp-shell-wrapper.node-pty.test.ts' ] +const crossProtocolDaemonTest = 'src/main/daemon/cross-protocol-daemon-reattach.test.ts' const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles] const testFilePatterns = [ 'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', @@ -53,6 +54,25 @@ describe('PR workflow parallelism', () => { for (const testFile of nativeShellContractFiles) { expect(testStep.run).toContain(`--exclude=${testFile}`) } + expect(testStep.run).toContain(`--exclude=${crossProtocolDaemonTest}`) + }) + + it('runs cross-protocol daemon coverage once with release history and a bundle cache', () => { + const job = workflow.jobs.daemon_cross_protocol + const checkout = job.steps.find((step) => step.name === 'Checkout') + const install = job.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + const cache = job.steps.find((step) => step.name === 'Restore legacy daemon bundle cache') + const testStep = job.steps.find( + (step) => step.name === 'Test current adapter against every previous daemon' + ) + + expect(checkout.with['fetch-depth']).toBe(0) + expect(install.with['native-runtime']).toBe('node') + expect(cache.with.path).toBe('node_modules/.cache/orca-daemon-release-entries') + expect(testStep.run).toContain('--bail=1') + expect(testStep.run).toContain(crossProtocolDaemonTest) }) it('runs real-zsh coverage once outside the general shards', () => { @@ -177,6 +197,7 @@ describe('PR workflow parallelism', () => { 'typecheck', 'git_compatibility', 'shell_contracts', + 'daemon_cross_protocol', 'test', 'package', 'package_windows' diff --git a/src/main/daemon/cross-protocol-daemon-reattach.test.ts b/src/main/daemon/cross-protocol-daemon-reattach.test.ts new file mode 100644 index 00000000000..4e2637db5f4 --- /dev/null +++ b/src/main/daemon/cross-protocol-daemon-reattach.test.ts @@ -0,0 +1,271 @@ +/** + * Runs the CURRENT build's DaemonPtyAdapter against a REAL daemon built from + * every still-supported previous release, plus the working tree as a control. + * + * Daemons survive app updates, so this pairing — new adapter, old daemon — is + * what every pre-existing pane hits after an update, and nothing else in the + * suite exercises it: the sibling adoption tests construct the current + * DaemonServer wearing an old protocol number, which cannot reproduce a + * daemon-side behavior change. #11789 shipped a total reattach failure through + * exactly that gap. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { SessionNotFoundError } from './daemon-errors' +import { + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + PROTOCOL_VERSION, + STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION +} from './daemon-protocol-version' +import { LEGACY_DAEMON_RELEASES } from './legacy-daemon-release-refs' +import { + bootDaemon, + buildDaemonEntryFromRef, + buildDaemonEntryFromWorkingTree +} from './legacy-daemon-release-runtime' +import type { BootedDaemon } from './legacy-daemon-release-runtime' +import type { PtySpawnResult } from '../providers/pty-spawn-result' + +const CASE_TIMEOUT_MS = 120_000 +const MARKER_TIMEOUT_MS = 30_000 +const SETTLE_TIMEOUT_MS = 15_000 +const MAX_CAPTURE_CHARS = 64 * 1024 + +// Why the working tree too: it proves the assertions below can be satisfied at +// all, so a legacy failure reads as a compatibility break rather than a broken +// harness. `ref: null` selects the build under test instead of a git ref. +const REATTACH_CASES = [ + ...LEGACY_DAEMON_RELEASES.map((release) => ({ + protocolVersion: release.protocolVersion, + ref: release.ref + })), + { protocolVersion: PROTOCOL_VERSION, ref: null } +] + +function identity(value: unknown): unknown { + return value +} + +async function waitUntil( + check: () => boolean | Promise, + timeoutMs: number, + failure: () => string +): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + if (await check()) { + return + } + if (Date.now() >= deadline) { + throw new Error(failure()) + } + await new Promise((resolve) => setTimeout(resolve, 50)) + } +} + +describe('cross-protocol daemon reattach coverage', () => { + it('declares a release for every still-supported previous protocol', () => { + expect(LEGACY_DAEMON_RELEASES.map((release) => release.protocolVersion)).toEqual([ + ...PREVIOUS_DAEMON_PROTOCOL_VERSIONS + ]) + }) + + // Why: bumping PROTOCOL_VERSION without appending the outgoing version leaves + // the build users are still running untested — the #11789 shape exactly. + it('requires a protocol bump to extend the previous-version list', () => { + expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS.at(-1)).toBe(PROTOCOL_VERSION - 1) + }) +}) + +// Why POSIX-only: the fixture drives the PTY with a shell command and reads the +// daemon's child processes via ps; neither has a Windows equivalent here. +describe.skipIf(process.platform === 'win32').each(REATTACH_CASES)( + 'current build against a protocol $protocolVersion daemon', + ({ protocolVersion, ref }) => { + const marker = `orca-reattach-${protocolVersion}` + const sessionId = `cross-protocol-pane-${protocolVersion}` + const missingSessionId = `cross-protocol-missing-${protocolVersion}` + let runtimeDir = '' + let daemon: BootedDaemon | null = null + let livePane: PtySpawnResult | null = null + let childPidsWithLivePane: Set | null = null + + const createAdapter = (): DaemonPtyAdapter => { + if (!daemon) { + throw new Error(`protocol ${protocolVersion} daemon is not running`) + } + return new DaemonPtyAdapter({ + socketPath: daemon.socketPath, + tokenPath: daemon.tokenPath, + protocolVersion + }) + } + + /** The pane as it existed before the update: created by whatever app build + * shipped with this daemon, then left running across the restart. It also + * has to exist before anything else, or the daemon retires as unadopted. */ + const createLivePane = async (): Promise => { + const creator = createAdapter() + try { + const created = await creator.spawn({ sessionId, cols: 80, rows: 24, cwd: runtimeDir }) + let output = '' + const stopListening = creator.onData((payload) => { + if (payload.id === sessionId) { + output = `${output}${payload.data}`.slice(-MAX_CAPTURE_CHARS) + } + }) + try { + await waitUntil( + () => { + // Re-sent because a shell still sourcing its rc files can drop + // the first line; extra copies only add ignored output. + creator.write(sessionId, `printf 'ok-%s\\n' ${marker}\r`) + return output.includes(`ok-${marker}`) + }, + MARKER_TIMEOUT_MS, + () => `protocol ${protocolVersion} shell never produced ok-${marker}: ${output}` + ) + } finally { + stopListening() + } + return created + } finally { + creator.dispose() + } + } + + const attachOnlyMissingSession = async (adapter: DaemonPtyAdapter): Promise => { + const failure: unknown = await adapter + .spawn({ + sessionId: missingSessionId, + cols: 120, + rows: 40, + cwd: runtimeDir, + attachOnly: true, + command: 'must-not-run' + }) + .then((result) => { + throw new Error(`attach-only created session ${result.id} instead of failing`) + }, identity) + // Why the message and not just the class: a native v31 rejection crosses + // the wire as a plain DaemonProtocolError, and the mount path classifies + // it by message (isPtyAlreadyGoneError in ipc/pty.ts) to retire the owner + // and respawn. #11789 failed here — terminal_pane_owner_unknown matched + // nothing, so the pane surfaced a raw code instead. + if (!(failure instanceof Error)) { + throw new Error(`attach-only rejected with a non-error value: ${String(failure)}`) + } + expect(failure.message).toMatch(/Session not found/i) + if (protocolVersion < STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION) { + // The emulation raises it in-process, so the class survives too. + expect(failure).toBeInstanceOf(SessionNotFoundError) + } + } + + /** Pre-31 daemons have no attach-only, so the adapter emulates it by creating + * and then killing — and the kill returns before the daemon retires the + * record, hence a bounded settle rather than a single sample. */ + const expectNoResidue = async (adapter: DaemonPtyAdapter): Promise => { + let sessionIds: string[] = [] + await waitUntil( + async () => { + sessionIds = (await adapter.listSessions()).map((session) => session.sessionId) + return !sessionIds.includes(missingSessionId) + }, + SETTLE_TIMEOUT_MS, + () => `protocol ${protocolVersion} kept an orphan session: ${sessionIds.join(', ')}` + ) + expect(sessionIds).toEqual([sessionId]) + + let childPids: Set | null = null + await waitUntil( + () => { + childPids = daemon?.childPids() ?? null + return childPids === null || childPids.size === (childPidsWithLivePane?.size ?? 0) + }, + SETTLE_TIMEOUT_MS, + () => { + const pids: number[] = [...(childPids ?? [])] + return `protocol ${protocolVersion} left an orphan process: ${pids.join(', ')}` + } + ) + expect(childPids).toEqual(childPidsWithLivePane) + } + + beforeAll(async () => { + const build = ref ? await buildDaemonEntryFromRef(ref) : null + // A manifest entry naming the wrong ref would silently retest some other + // protocol, so trust the version read out of that ref's own source. + expect(build?.protocolVersion ?? PROTOCOL_VERSION).toBe(protocolVersion) + runtimeDir = mkdtempSync(join(tmpdir(), `cross-protocol-daemon-${protocolVersion}-`)) + daemon = await bootDaemon({ + entryPath: build ? build.entryPath : await buildDaemonEntryFromWorkingTree(), + runtimeDir + }) + livePane = await createLivePane() + childPidsWithLivePane = daemon.childPids() + }, CASE_TIMEOUT_MS) + + afterAll(async () => { + await daemon?.stop() + daemon = null + if (runtimeDir) { + rmSync(runtimeDir, { recursive: true, force: true }) + } + }) + + it( + 'adopts the live session instead of replacing it', + async () => { + const adapter = createAdapter() + try { + const attached = await adapter.spawn({ + sessionId, + cols: 120, + rows: 40, + attachOnly: true, + command: 'must-not-run' + }) + + expect(attached.id).toBe(sessionId) + expect(attached.isReattach).toBe(true) + // The load-bearing one: a replacement shell would be a different + // process, which is what "looks like success but lost everything" + // means at the OS level. + expect(attached.pid).toBe(livePane?.pid) + expect(attached.snapshot).toContain(`ok-${marker}`) + + const sessions = await adapter.listSessions() + expect(sessions.map((session) => session.sessionId)).toEqual([sessionId]) + } finally { + adapter.dispose() + } + }, + CASE_TIMEOUT_MS + ) + + it( + 'rejects a missing session without leaving an orphan behind', + async () => { + const adapter = createAdapter() + try { + await attachOnlyMissingSession(adapter) + await expectNoResidue(adapter) + + // A kill aimed at the wrong id would satisfy everything above. + const survivor = (await adapter.listSessions()).find( + (session) => session.sessionId === sessionId + ) + expect(survivor?.isAlive).toBe(true) + expect(survivor?.pid).toBe(livePane?.pid) + } finally { + adapter.dispose() + } + }, + CASE_TIMEOUT_MS + ) + } +) diff --git a/src/main/daemon/legacy-daemon-release-refs.ts b/src/main/daemon/legacy-daemon-release-refs.ts new file mode 100644 index 00000000000..26f02fd2a78 --- /dev/null +++ b/src/main/daemon/legacy-daemon-release-refs.ts @@ -0,0 +1,56 @@ +/** + * A representative build for a still-running daemon of each previous protocol. + * + * A daemon survives the app update that replaces it, so after an update every + * pre-existing pane is served by a daemon built from OLD code. Reconstructing + * that daemon by pointing the current `DaemonServer` at an old protocol number + * only tests the current implementation wearing an old label — it cannot catch a + * behavior change on the daemon side (#11789 shipped exactly that blind spot). + * These refs let the cross-protocol reattach gate run the current adapter + * against the real previous build. + * + * Each entry is the newest ref that shipped that protocol when one exists, so + * within-protocol daemon fixes are included. Protocol 27 instead pins its last + * main commit because it was superseded before release. + */ +export type LegacyDaemonRelease = { + protocolVersion: number + /** Newest release tag built at this protocol, or a commit when none shipped. */ + ref: string +} + +// Why refs and not tags-only: protocols 6, 19 and 27 were superseded before a +// stable release cut, so their newest shipped artifact is an rc tag or — for 27, +// which never left main — the commit that carried it. +export const LEGACY_DAEMON_RELEASES: readonly LegacyDaemonRelease[] = [ + { protocolVersion: 1, ref: 'v1.3.16' }, + { protocolVersion: 2, ref: 'v1.3.19' }, + { protocolVersion: 3, ref: 'v1.3.21' }, + { protocolVersion: 4, ref: 'v1.3.47' }, + { protocolVersion: 5, ref: 'v1.4.2' }, + { protocolVersion: 6, ref: 'v1.4.2-rc.8' }, + { protocolVersion: 7, ref: 'v1.4.27' }, + { protocolVersion: 8, ref: 'v1.4.29' }, + { protocolVersion: 9, ref: 'v1.4.32' }, + { protocolVersion: 10, ref: 'v1.4.35' }, + { protocolVersion: 11, ref: 'v1.4.55' }, + { protocolVersion: 12, ref: 'v1.4.65' }, + { protocolVersion: 13, ref: 'v1.4.72' }, + { protocolVersion: 14, ref: 'v1.4.78' }, + { protocolVersion: 15, ref: 'v1.4.79' }, + { protocolVersion: 16, ref: 'v1.4.80' }, + { protocolVersion: 17, ref: 'v1.4.90' }, + { protocolVersion: 18, ref: 'v1.4.134' }, + { protocolVersion: 19, ref: 'v1.4.135-rc.2' }, + { protocolVersion: 20, ref: 'v1.4.135' }, + { protocolVersion: 21, ref: 'v1.4.141' }, + { protocolVersion: 22, ref: 'v1.4.145' }, + { protocolVersion: 23, ref: 'v1.4.146' }, + { protocolVersion: 24, ref: 'v1.4.149' }, + { protocolVersion: 25, ref: 'v1.4.150' }, + { protocolVersion: 26, ref: 'v1.4.155' }, + { protocolVersion: 27, ref: '7ab601487c2e4c05b7067a5157e395300a0bdc0c' }, + { protocolVersion: 28, ref: 'v1.4.159' }, + { protocolVersion: 29, ref: 'v1.4.161' }, + { protocolVersion: 30, ref: 'v1.4.167' } +] diff --git a/src/main/daemon/legacy-daemon-release-runtime.ts b/src/main/daemon/legacy-daemon-release-runtime.ts new file mode 100644 index 00000000000..17948fc7108 --- /dev/null +++ b/src/main/daemon/legacy-daemon-release-runtime.ts @@ -0,0 +1,293 @@ +/** + * Builds and boots a daemon from an arbitrary ref of this repo under plain Node. + * + * Shares its shape with config/scripts/daemon-boot-smoke.mjs — fork the entry as + * a plain-Node child, wait for the `ready` IPC message, stop it on SIGTERM — but + * sources the entry from a git ref instead of out/main so the current build can + * be exercised against the daemon a user is actually still running. + * + * Only the daemon's own source is rebuilt; node-pty and the rest of the runtime + * resolve from the current node_modules, which is what an in-place app update + * leaves behind anyway (the daemon process keeps running, its native deps do + * not get re-resolved). Bundles are cached per commit so a warm PR CI run pays + * for the fork alone. + */ +import { execFileSync, fork } from 'node:child_process' +import type { ChildProcess } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { build } from 'esbuild' + +const CACHE_ROOT = join(process.cwd(), 'node_modules', '.cache', 'orca-daemon-release-entries') +// Why only these trees: the daemon entry's import graph never leaves them, and +// archiving all of src/ costs roughly twice as long per ref. +const DAEMON_SOURCE_TREES = ['src/main', 'src/shared'] +const DAEMON_ENTRY_SOURCE = 'src/main/daemon/daemon-entry.ts' +// Why two: PROTOCOL_VERSION lived in types.ts until it was split out at v31's lineage. +const PROTOCOL_SOURCE_PATHS = [ + 'src/main/daemon/daemon-protocol-version.ts', + 'src/main/daemon/types.ts' +] +const PROTOCOL_VERSION_PATTERN = /^export const PROTOCOL_VERSION = (\d+)/m + +const READY_TIMEOUT_MS = 30_000 +const STOP_TIMEOUT_MS = 10_000 + +// Why pin the shell: every daemon build back to v1.3.16 resolves the PTY shell as +// `env.SHELL || process.env.SHELL || '/bin/zsh'`, and SHELL is unset for a non-bash +// child on CI images that have no zsh. Left ambient, the fixture passes on a dev Mac +// and fails on the runner. +const FIXTURE_SHELL_CANDIDATES = ['/bin/bash', '/bin/sh'] +const KILL_TIMEOUT_MS = 5_000 +const MAX_STDERR_CHARS = 64 * 1024 + +export type DaemonEntryBuild = { + entryPath: string + protocolVersion: number +} + +export type BootedDaemon = { + pid: number + socketPath: string + tokenPath: string + /** PTY shells the daemon owns, or null where the platform has no ps. */ + childPids(): Set | null + stop(): Promise +} + +function git(args: string[]): string { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +} + +function resolveCommit(ref: string): string { + try { + return git(['rev-parse', '--verify', `${ref}^{commit}`]).trim() + } catch { + throw new Error( + `Cannot resolve daemon release ref "${ref}". This gate replays real previous builds, so it ` + + 'needs full history and tags — clone with fetch-depth 0 (or run `git fetch --tags`).' + ) + } +} + +function readProtocolVersionAt(ref: string): number { + for (const path of PROTOCOL_SOURCE_PATHS) { + let source: string + try { + source = git(['show', `${ref}:${path}`]) + } catch { + continue + } + const match = source.match(PROTOCOL_VERSION_PATTERN) + if (match) { + return Number(match[1]) + } + } + throw new Error(`Could not read PROTOCOL_VERSION from ref "${ref}"`) +} + +async function bundleDaemonEntry(entrySourcePath: string, outfile: string): Promise { + await build({ + entryPoints: [entrySourcePath], + outfile, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + // Why external: the bundle lives under node_modules/.cache so Node resolves + // every dependency — including the native node-pty binding — from the + // current install, exactly as a surviving daemon process still holds them. + packages: 'external', + logLevel: 'silent' + }) +} + +function extractSourceTrees(ref: string, destination: string): void { + const archivePath = join(destination, 'source.tar') + mkdirSync(destination, { recursive: true }) + git(['archive', '--format=tar', '-o', archivePath, ref, ...DAEMON_SOURCE_TREES]) + execFileSync('tar', ['-xf', archivePath, '-C', destination], { stdio: 'ignore' }) + rmSync(archivePath, { force: true }) +} + +function readCachedProtocolVersion(stampPath: string, commit: string): number | null { + try { + const stamp: unknown = JSON.parse(readFileSync(stampPath, 'utf8')) + if ( + typeof stamp !== 'object' || + stamp === null || + !('commit' in stamp) || + stamp.commit !== commit || + !('protocolVersion' in stamp) || + !Number.isSafeInteger(stamp.protocolVersion) || + Number(stamp.protocolVersion) < 1 + ) { + return null + } + return Number(stamp.protocolVersion) + } catch { + return null + } +} + +/** Builds the daemon entry as it existed at `ref`, reusing a cached bundle per commit. */ +export async function buildDaemonEntryFromRef(ref: string): Promise { + const commit = resolveCommit(ref) + const cacheDir = join(CACHE_ROOT, commit) + const entryPath = join(cacheDir, 'daemon-entry.js') + const stampPath = join(cacheDir, 'release.json') + if (existsSync(entryPath) && existsSync(stampPath)) { + const protocolVersion = readCachedProtocolVersion(stampPath, commit) + if (protocolVersion !== null) { + return { entryPath, protocolVersion } + } + } + const protocolVersion = readProtocolVersionAt(commit) + const sourceDir = join(cacheDir, 'source') + rmSync(cacheDir, { recursive: true, force: true }) + try { + extractSourceTrees(commit, sourceDir) + await bundleDaemonEntry(join(sourceDir, DAEMON_ENTRY_SOURCE), entryPath) + } finally { + rmSync(sourceDir, { recursive: true, force: true }) + } + writeFileSync(stampPath, `${JSON.stringify({ ref, commit, protocolVersion })}\n`) + return { entryPath, protocolVersion } +} + +/** Builds the daemon entry from the working tree — the build under test. */ +export async function buildDaemonEntryFromWorkingTree(): Promise { + const entryPath = join(CACHE_ROOT, 'working-tree', 'daemon-entry.js') + await bundleDaemonEntry(join(process.cwd(), DAEMON_ENTRY_SOURCE), entryPath) + return entryPath +} + +// Why ps and not a pid file: the assertion is about PTY shells the daemon owns, +// which only exist as its children. Windows has no equivalent one-liner, so the +// caller degrades to the protocol-level session listing there. +export function listChildPids(parentPid: number): Set | null { + if (process.platform === 'win32') { + return null + } + const output = execFileSync('ps', ['-o', 'pid=,ppid=', '-ax'], { encoding: 'utf8' }) + const children = new Set() + for (const line of output.split('\n')) { + const [pid, ppid] = line.trim().split(/\s+/).map(Number) + if (ppid === parentPid && Number.isFinite(pid)) { + children.add(pid) + } + } + return children +} + +function hasExited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null +} + +function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (hasExited(child)) { + return Promise.resolve(true) + } + return new Promise((resolve) => { + const finish = (exited: boolean): void => { + clearTimeout(timer) + child.off('exit', onExit) + resolve(exited) + } + const onExit = (): void => finish(true) + const timer = setTimeout(() => finish(false), timeoutMs) + child.once('exit', onExit) + }) +} + +function resolveFixtureShell(): string { + const shell = FIXTURE_SHELL_CANDIDATES.find((candidate) => existsSync(candidate)) + if (!shell) { + throw new Error( + `No POSIX shell available for the daemon fixture; tried ${FIXTURE_SHELL_CANDIDATES.join(', ')}` + ) + } + return shell +} + +export async function bootDaemon(options: { + entryPath: string + runtimeDir: string +}): Promise { + const socketPath = join(options.runtimeDir, 'daemon.sock') + const tokenPath = join(options.runtimeDir, 'daemon.token') + const env: NodeJS.ProcessEnv = { + ...process.env, + ORCA_USER_DATA_PATH: options.runtimeDir, + SHELL: resolveFixtureShell() + } + // Why: daemon-entry only runs main() when VITEST is unset, so an inherited + // VITEST leaves a live-but-silent process that never binds the socket. + delete env.VITEST + const child = fork(options.entryPath, ['--socket', socketPath, '--token', tokenPath], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + // Why: Vitest's own execArgv would be reapplied to a plain-Node daemon. + execArgv: [], + env + }) + let stderr = '' + child.stderr?.on('data', (chunk: Buffer) => { + stderr = `${stderr}${chunk.toString('utf8')}`.slice(-MAX_STDERR_CHARS) + }) + + const stop = async (): Promise => { + if (hasExited(child)) { + return + } + const gracefulExit = waitForExit(child, STOP_TIMEOUT_MS) + child.kill('SIGTERM') + if (await gracefulExit) { + return + } + const forcedExit = waitForExit(child, KILL_TIMEOUT_MS) + child.kill('SIGKILL') + if (!(await forcedExit)) { + throw new Error(`daemon ${child.pid ?? 'unknown'} did not exit after SIGKILL`) + } + } + + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`daemon did not signal ready in ${READY_TIMEOUT_MS}ms\n${stderr}`)), + READY_TIMEOUT_MS + ) + child.on('message', (message: { type?: string }) => { + if (message?.type === 'ready') { + clearTimeout(timer) + resolve() + } + }) + child.on('error', (error) => { + clearTimeout(timer) + reject(new Error(`daemon fork failed: ${error.message}\n${stderr}`)) + }) + child.on('exit', (code, signal) => { + clearTimeout(timer) + reject(new Error(`daemon exited before ready (code=${code}, signal=${signal})\n${stderr}`)) + }) + }) + } catch (error) { + await stop() + throw error + } + + const pid = child.pid + if (pid === undefined) { + await stop() + throw new Error('daemon signaled ready without a process id') + } + + return { + pid, + socketPath, + tokenPath, + childPids: () => listChildPids(pid), + stop + } +}