From 55dd7fdea13bc7f92edb584d6691560616409cb5 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 9 Aug 2026 15:53:58 +0300 Subject: [PATCH 1/4] bump dev Pi packages to 0.84.1/TypeBox 1.3.7 --- package-lock.json | 141 +++++++++++++++------ package.json | 9 +- tests/unit/config-invariants.test.ts | 14 +- tests/unit/helpers.ts | 1 + tests/unit/module-evaluation-order.test.ts | 21 ++- 5 files changed, 129 insertions(+), 57 deletions(-) diff --git a/package-lock.json b/package-lock.json index 818a355..13ffdd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,14 @@ "version": "0.4.0", "license": "MIT", "devDependencies": { - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", + "@earendil-works/pi-ai": "0.84.1", + "@earendil-works/pi-coding-agent": "0.84.1", + "@earendil-works/pi-tui": "0.84.1", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", "husky": "^9.1.7", - "typebox": "1.1.38", + "typebox": "1.3.7", "typescript": "^6.0.3" }, "engines": { @@ -538,14 +538,15 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.0.tgz", - "integrity": "sha512-8MvW9+zno13sXDuT2kFMnWeTNUufUhPeZDRVO+igGoBRCDWgn7Xh2FkRQI1mRuet6QhF4ENQuLYdIAOyG6BhNw==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", + "integrity": "sha512-wMsAdJMxuNri08vLqTyYVI201DQQezGhPSTkzYsHdw5dYX3rCNwEmSvpaAwhi7ELKI/2tE/CEgSWg/6iRxSgdQ==", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -554,7 +555,7 @@ "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", - "typebox": "1.1.38" + "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" @@ -564,21 +565,24 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.82.0.tgz", - "integrity": "sha512-Qnqgn9zhJFQ2HZ8R4iNuGhyCk93XX6+eUw9i+TjTuo47amzCy93ft3bB6yaUCleCrNO58dJDHYSGNHv/GAPWKg==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", + "integrity": "sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==", "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.82.0", - "@earendil-works/pi-ai": "^0.82.0", - "@earendil-works/pi-tui": "^0.82.0", + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", + "grok-mermaid": "0.2.2", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", @@ -586,8 +590,8 @@ "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", - "typebox": "1.1.38", - "undici": "8.5.0", + "typebox": "1.3.7", + "undici": "8.9.0", "yaml": "2.9.0" }, "bin": { @@ -1063,15 +1067,16 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.82.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", "dev": true, "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.82.0", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", "diff": "8.0.4", "ignore": "7.0.5", - "typebox": "1.1.38", + "typebox": "1.3.7", "yaml": "2.9.0" }, "engines": { @@ -1079,13 +1084,14 @@ } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.1", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", @@ -1094,7 +1100,7 @@ "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", - "typebox": "1.1.38" + "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" @@ -1103,9 +1109,42 @@ "node": ">=22.19.0" } }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-client": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-protocol": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.0.tgz", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", "dev": true, "license": "MIT", "dependencies": { @@ -1656,16 +1695,16 @@ "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { @@ -1930,6 +1969,16 @@ "dev": true, "license": "ISC" }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -2399,16 +2448,16 @@ "license": "0BSD" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", "dev": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { - "version": "8.5.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", - "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "dev": true, "license": "MIT", "engines": { @@ -2522,10 +2571,20 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "integrity": "sha512-180/xGJtsq7IoR3p9EKWjRd0e9M4DkxInhlo9xyD7prDC7Qrhqq+nhvwrW0lFjPfXcEI2FSHmGCSyvSJE9GsaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@earendil-works/pi-tui": { - "version": "0.82.0", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.82.0.tgz", - "integrity": "sha512-9IDjQOXne7t9l2s2YcjnIBxsVNVPE7qScVSB3YmFlXsBW4pfo2gOElTxggV84KrRiGqABnlFPBWbf0k54hszHQ==", + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", + "integrity": "sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==", "dev": true, "license": "MIT", "dependencies": { @@ -3822,9 +3881,9 @@ "license": "0BSD" }, "node_modules/typebox": { - "version": "1.1.38", - "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", - "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index 0f3ccfc..409c42d 100644 --- a/package.json +++ b/package.json @@ -27,20 +27,19 @@ "test:all": "npm run test && npm run test:e2e", "test:snapshots:check": "node ./scripts/run-node-test.mjs tests/unit/render-snapshots.test.ts", "test:snapshots:update": "node ./scripts/run-node-test.mjs --update-snapshots tests/unit/render-snapshots.test.ts", - "test:compat:floor": "node ./scripts/test-compat-floor.mjs", "test:compat:current": "node ./scripts/test-compat-current.mjs", "test:package-host": "node ./scripts/test-package-host.mjs", "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", + "@earendil-works/pi-ai": "0.84.1", + "@earendil-works/pi-coding-agent": "0.84.1", + "@earendil-works/pi-tui": "0.84.1", "@types/node": "^25.9.3", "audit-ci": "^7.1.0", "fast-check": "^4.8.0", "husky": "^9.1.7", - "typebox": "1.1.38", + "typebox": "1.3.7", "typescript": "^6.0.3" }, "pi": { diff --git a/tests/unit/config-invariants.test.ts b/tests/unit/config-invariants.test.ts index 3ac3b36..85133ce 100644 --- a/tests/unit/config-invariants.test.ts +++ b/tests/unit/config-invariants.test.ts @@ -39,6 +39,10 @@ const WORKFLOW_PATH = new URL(".github/workflows/test.yml", REPO_ROOT_URL); const LOCK_PATH = new URL("package-lock.json", REPO_ROOT_URL); const SPAWN_SOURCE_PATH = new URL("spawn/index.ts", REPO_ROOT_URL); const RENDERER_SOURCE_PATH = new URL("spawn/renderer.ts", REPO_ROOT_URL); +// Pinned versions verified against package.json + lockfile. +// Update when Pi devDependencies are bumped. +const EXPECTED_PI_VERSION = "0.84.1"; +const EXPECTED_TYPEBOX_VERSION = "1.3.7"; const EXPECTED_MATRIX = new Set([ "ubuntu-latest@22.19.0", "ubuntu-latest@24", @@ -149,7 +153,7 @@ function parseLockfileVulnerablePaths(lockfilePath: string, packageName: string, return paths; } -test("Pi 0.82.0 compatibility metadata and source boundaries stay exact", () => { +test("pinned Pi compatibility metadata and source boundaries stay exact", () => { const packageJson = parsePackageJson(); const lock = JSON.parse(readText(LOCK_PATH)) as { packages: Record }; assert.equal(packageJson.engines.node, ">=22.19.0"); @@ -157,11 +161,11 @@ test("Pi 0.82.0 compatibility metadata and source boundaries stay exact", () => assert.equal(packageJson.peerDependencies[name], "*", `${name} peer must remain host-provided`); } for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]) { - assert.equal(packageJson.devDependencies[name], "0.82.0"); - assert.equal(lock.packages[`node_modules/${name}`]?.version, "0.82.0"); + assert.equal(packageJson.devDependencies[name], EXPECTED_PI_VERSION); + assert.equal(lock.packages[`node_modules/${name}`]?.version, EXPECTED_PI_VERSION); } - assert.equal(packageJson.devDependencies.typebox, "1.1.38"); - assert.equal(lock.packages["node_modules/typebox"]?.version, "1.1.38"); + assert.equal(packageJson.devDependencies.typebox, EXPECTED_TYPEBOX_VERSION); + assert.equal(lock.packages["node_modules/typebox"]?.version, EXPECTED_TYPEBOX_VERSION); const spawnSource = readText(SPAWN_SOURCE_PATH); assert.doesNotMatch(spawnSource, /\bAuthStorage\b|\bModelRegistry\b/); diff --git a/tests/unit/helpers.ts b/tests/unit/helpers.ts index 18068b7..78751bc 100644 --- a/tests/unit/helpers.ts +++ b/tests/unit/helpers.ts @@ -181,6 +181,7 @@ export function createTestPI() { }, getFlag: (name: string) => _flags.get(name), registerMessageRenderer: () => {}, + registerMarkdownTransformer: () => {}, registerEntryRenderer: () => {}, setLabel: () => {}, unregisterProvider: () => {}, diff --git a/tests/unit/module-evaluation-order.test.ts b/tests/unit/module-evaluation-order.test.ts index 87a911c..9c5967e 100644 --- a/tests/unit/module-evaluation-order.test.ts +++ b/tests/unit/module-evaluation-order.test.ts @@ -7,6 +7,9 @@ import { spawnSync } from "node:child_process"; import { strict as assert } from "node:assert"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, it } from "node:test"; @@ -14,11 +17,17 @@ const root = fileURLToPath(new URL("../../", import.meta.url)); const loader = new URL("../../register-loader.mjs", import.meta.url).href; function evaluate(code: string): void { - const result = spawnSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", code], { - cwd: root, - encoding: "utf8", - }); - assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + const agentDir = mkdtempSync(join(tmpdir(), "pi-agenticoding-module-order-")); + try { + const result = spawnSync(process.execPath, ["--import", loader, "--input-type=module", "--eval", code], { + cwd: root, + encoding: "utf8", + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir }, + }); + assert.equal(result.status, 0, [result.stdout, result.stderr].filter(Boolean).join("\n")); + } finally { + rmSync(agentDir, { recursive: true, force: true }); + } } describe("module evaluation order", () => { @@ -92,7 +101,7 @@ describe("module evaluation order", () => { const model = modelRuntime.getModel('module-order-test', 'deterministic'); if (!model) throw new Error('deterministic model missing'); const { session } = await sdk.createAgentSession({ - model, modelRuntime, cwd: process.cwd(), + model, modelRuntime, cwd: process.cwd(), agentDir: process.env.PI_CODING_AGENT_DIR, sessionManager: sdk.SessionManager.inMemory(), settingsManager: sdk.SettingsManager.inMemory(), }); From 13437f6ec1edd6e1f62699d5b82ab7685e1de0e3 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 9 Aug 2026 15:54:08 +0300 Subject: [PATCH 2/4] replace 0.82.0 floor-compat lane with latest-Pi packed-host CI + diagnostics --- .github/renovate.json | 16 +++ .github/workflows/test.yml | 45 ++++++-- scripts/compat-fixture.mjs | 58 ++++++++++ scripts/compat-process.mjs | 32 +++++- scripts/dependency-graph-assertions.mjs | 15 --- scripts/test-compat-current.mjs | 106 +++++++++--------- scripts/test-compat-floor.mjs | 33 ------ scripts/test-package-host.mjs | 91 +++++++++------ tests/unit/compat-process.test.ts | 79 ++++++++++++- .../unit/dependency-graph-assertions.test.ts | 86 +++----------- .../unit/spawn-runtime-compatibility.test.ts | 4 +- 11 files changed, 344 insertions(+), 221 deletions(-) create mode 100644 .github/renovate.json create mode 100644 scripts/compat-fixture.mjs delete mode 100644 scripts/test-compat-floor.mjs diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..32d5f9b --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + "packageRules": [ + { + "matchPackageNames": [ + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", + "typebox" + ], + "groupName": "Pi compatibility dependencies", + "rangeStrategy": "pin" + } + ] +} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0a5d5bf..ca85675 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,6 +20,7 @@ on: pull_request: branches: [main] paths-ignore: ['*.md', '**/docs/**'] + workflow_dispatch: jobs: # ── Cross-platform test matrix ────────────────────────────────────── @@ -71,22 +72,41 @@ jobs: - name: E2E tests run: npm run test:e2e - # Compatibility contracts run from a foreign cwd on the floor and Windows lanes. - - name: Exact-floor compatibility contract - if: (matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest' - working-directory: ${{ runner.temp }} - run: node "${{ github.workspace }}/scripts/test-compat-floor.mjs" - - - name: Packed Pi 0.82.0 host contract + # The packed extension is exercised from a foreign cwd with the latest host. + - name: Packed latest Pi host contract if: (matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest' working-directory: ${{ runner.temp }} + env: + COMPAT_ARTIFACT_DIR: ${{ runner.temp }}/packed-host-diagnostics-${{ matrix.os }}-node-${{ matrix.node-version }} run: node "${{ github.workspace }}/scripts/test-package-host.mjs" + - name: Upload packed host diagnostics + if: always() && ((matrix.os == 'ubuntu-latest' && matrix.node-version == '22.19.0') || matrix.os == 'windows-latest') + uses: actions/upload-artifact@v4 + with: + name: packed-host-diagnostics-${{ matrix.os }}-node-${{ matrix.node-version }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/packed-host-diagnostics-${{ matrix.os }}-node-${{ matrix.node-version }} + if-no-files-found: warn + overwrite: true + retention-days: 30 + - name: Synchronized current Pi compatibility on Windows if: matrix.os == 'windows-latest' working-directory: ${{ runner.temp }} + env: + COMPAT_ARTIFACT_DIR: ${{ runner.temp }}/current-pi-diagnostics-windows-node-24 run: node "${{ github.workspace }}/scripts/test-compat-current.mjs" + - name: Upload current Pi diagnostics on Windows + if: always() && matrix.os == 'windows-latest' + uses: actions/upload-artifact@v4 + with: + name: current-pi-diagnostics-windows-node-24-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/current-pi-diagnostics-windows-node-24 + if-no-files-found: warn + overwrite: true + retention-days: 30 + # Upload test results for debugging — artifacts available for 30 days. - name: Upload test results if: always() @@ -108,4 +128,15 @@ jobs: - run: npm ci - name: Synchronized current Pi compatibility working-directory: ${{ runner.temp }} + env: + COMPAT_ARTIFACT_DIR: ${{ runner.temp }}/current-pi-diagnostics-ubuntu-node-24 run: node "${{ github.workspace }}/scripts/test-compat-current.mjs" + - name: Upload current Pi diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: current-pi-diagnostics-ubuntu-node-24-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/current-pi-diagnostics-ubuntu-node-24 + if-no-files-found: warn + overwrite: true + retention-days: 30 diff --git a/scripts/compat-fixture.mjs b/scripts/compat-fixture.mjs new file mode 100644 index 0000000..1076cbc --- /dev/null +++ b/scripts/compat-fixture.mjs @@ -0,0 +1,58 @@ +import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { runNpmWithRetry } from "./compat-process.mjs"; + +export const PI_PACKAGES = [ + "@earendil-works/pi-ai", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", +]; + +export function createCompatCopy(root, prefix) { + const temp = mkdtempSync(join(tmpdir(), prefix)); + const copy = join(temp, "source"); + cpSync(root, copy, { + recursive: true, + filter: (source) => ![".git", "node_modules", "openspec"].includes(basename(source)), + }); + return { temp, copy }; +} + +export function prepareCompatCopy(copy) { + rmSync(join(copy, "package-lock.json"), { force: true }); + const packagePath = join(copy, "package.json"); + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); + if (packageJson.scripts) delete packageJson.scripts.prepare; + writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +} + +/** Resolve one current Pi release and the exact TypeBox version it requires. */ +export async function resolveLatestPi(copy) { + const piVersion = JSON.parse((await runNpmWithRetry(copy, [ + "view", "@earendil-works/pi-coding-agent@latest", "version", "--json", + ], { capture: true })).stdout); + const typeboxVersion = JSON.parse((await runNpmWithRetry(copy, [ + "view", `@earendil-works/pi-coding-agent@${piVersion}`, "dependencies.typebox", "--json", + ], { capture: true })).stdout); + if (typeof piVersion !== "string" || !piVersion || typeof typeboxVersion !== "string" || !typeboxVersion) { + throw new Error("Latest Pi coding-agent did not declare usable Pi and TypeBox versions"); + } + return { piVersion, typeboxVersion }; +} + +export function latestPiDependencies({ piVersion, typeboxVersion }) { + return Object.fromEntries([ + ...PI_PACKAGES.map((name) => [name, piVersion]), + ["typebox", typeboxVersion], + ]); +} + +/** Write failure diagnostics to artifact directory if configured. */ +export function writeCompatDiagnostics(artifactDir, sourceDir, error) { + if (!artifactDir) return; + mkdirSync(artifactDir, { recursive: true }); + const packageLock = join(sourceDir, "package-lock.json"); + if (existsSync(packageLock)) copyFileSync(packageLock, join(artifactDir, "package-lock.json")); + writeFileSync(join(artifactDir, "failure.txt"), String(error)); +} diff --git a/scripts/compat-process.mjs b/scripts/compat-process.mjs index e564ebc..3820143 100644 --- a/scripts/compat-process.mjs +++ b/scripts/compat-process.mjs @@ -12,6 +12,11 @@ function formatInvocation(command, args) { return [command, ...args].map((value) => JSON.stringify(value)).join(" "); } +function truncateOutput(value, limit = 8192) { + if (!value) return ""; + return value.length > limit ? `${value.slice(0, limit)}\n… [truncated ${value.length - limit} chars]` : value; +} + /** Run a subprocess and fail with launch/status/signal and captured-output context. */ export function runChecked(command, args, options = {}) { const { cwd, capture = false, env = process.env } = options; @@ -28,8 +33,8 @@ export function runChecked(command, args, options = {}) { `error.stack: ${result.error?.stack ?? "none"}`, `status: ${String(result.status)}`, `signal: ${String(result.signal)}`, - `stdout:\n${result.stdout ?? ""}`, - `stderr:\n${result.stderr ?? ""}`, + `stdout:\n${truncateOutput(result.stdout ?? "")}`, + `stderr:\n${truncateOutput(result.stderr ?? "")}`, ].join("\n"); throw new Error(diagnostics); } @@ -84,3 +89,26 @@ export function runNpm(cwd, args, options = {}) { const invocation = npmInvocation(args, options); return runChecked(invocation.command, invocation.args, { cwd, ...options }); } + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Retry runNpm with exponential backoff for transient registry failures. + * Retries on non-zero status/signal/error; immediate success returns. + */ +export async function runNpmWithRetry(cwd, args, options = {}, retryOptions = {}) { + const { retries = 3, baseMs = 800 } = retryOptions; + let lastError; + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return runNpm(cwd, args, options); + } catch (error) { + lastError = error; + if (attempt === retries) break; + await sleep(baseMs * 2 ** attempt); + } + } + throw lastError; +} diff --git a/scripts/dependency-graph-assertions.mjs b/scripts/dependency-graph-assertions.mjs index 334aa7a..e2b576c 100644 --- a/scripts/dependency-graph-assertions.mjs +++ b/scripts/dependency-graph-assertions.mjs @@ -24,21 +24,6 @@ function formatVersion(version) { return version ?? "missing version"; } -export function assertExactPackageVersions(graph, expectedVersions) { - const entries = Object.entries(expectedVersions); - const occurrences = collectPackageOccurrences(graph, entries.map(([name]) => name)); - - for (const [name, expectedVersion] of entries) { - const packages = occurrences.get(name); - if (packages.length === 0) throw new Error(`Expected ${name}@${expectedVersion}, found no occurrences`); - const mismatches = packages.filter(({ version }) => version !== expectedVersion); - if (mismatches.length > 0) { - const found = mismatches.map(({ path, version }) => `${formatVersion(version)} at ${path}`).join(", "); - throw new Error(`Expected every ${name}@${expectedVersion} occurrence, found ${found}`); - } - } -} - export function assertSynchronizedPackageVersions(graph, packageNames) { if (packageNames.length === 0) throw new Error("Pass at least one package name to synchronize"); const occurrences = collectPackageOccurrences(graph, packageNames); diff --git a/scripts/test-compat-current.mjs b/scripts/test-compat-current.mjs index ec49244..a230476 100644 --- a/scripts/test-compat-current.mjs +++ b/scripts/test-compat-current.mjs @@ -1,62 +1,60 @@ -import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { repoRootFromScript, runChecked, runNpm } from "./compat-process.mjs"; +import { pathToFileURL } from "node:url"; +import { createCompatCopy, latestPiDependencies, prepareCompatCopy, resolveLatestPi, writeCompatDiagnostics } from "./compat-fixture.mjs"; +import { repoRootFromScript, runChecked, runNpmWithRetry } from "./compat-process.mjs"; import { assertSynchronizedPackageVersions } from "./dependency-graph-assertions.mjs"; -const PI_PACKAGES = [ - "@earendil-works/pi-agent-core", - "@earendil-works/pi-ai", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-tui", -]; +const PI_SYNC_PACKAGES = ["@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui"]; -const root = repoRootFromScript(import.meta.url); -const temp = mkdtempSync(join(tmpdir(), "pi-agenticoding-current-")); -const copy = join(temp, "source"); -try { - cpSync(root, copy, { - recursive: true, - filter: (source) => ![".git", "node_modules", "openspec"].includes(source.split(/[\\/]/).at(-1)), - }); - rmSync(join(copy, "package-lock.json"), { force: true }); - const packagePath = join(copy, "package.json"); - const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); - packageJson.scripts.prepare = ""; - writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`); +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; - const typeboxResult = runNpm(copy, [ - "view", "@earendil-works/pi-coding-agent@latest", "dependencies.typebox", "--json", - ], { capture: true }); - const currentPiTypebox = JSON.parse(typeboxResult.stdout); - if (typeof currentPiTypebox !== "string" || currentPiTypebox.length === 0) { - throw new Error("Current Pi coding-agent did not declare a TypeBox dependency"); - } +async function main() { + const root = repoRootFromScript(import.meta.url); + const artifactDir = process.env.COMPAT_ARTIFACT_DIR; + const { temp, copy } = createCompatCopy(root, "pi-agenticoding-current-"); + + try { + prepareCompatCopy(copy); - runNpm(copy, ["install", "--ignore-scripts", "--save-dev", "--save-exact", - "@earendil-works/pi-ai@latest", - "@earendil-works/pi-coding-agent@latest", - "@earendil-works/pi-tui@latest", - `typebox@${currentPiTypebox}`, - ]); - const graphResult = runNpm(copy, ["ls", "--all", "--json"], { capture: true }); - const graph = JSON.parse(graphResult.stdout); - const piVersion = assertSynchronizedPackageVersions(graph, PI_PACKAGES); - assertSynchronizedPackageVersions(graph, ["typebox"]); + const latestPi = await resolveLatestPi(copy); + await runNpmWithRetry(copy, ["install", "--ignore-scripts", "--save-dev", "--save-exact", + ...Object.entries(latestPiDependencies(latestPi)).map(([name, version]) => `${name}@${version}`), + ]); + const graphResult = await runNpmWithRetry(copy, ["ls", "--all", "--json"], { capture: true }); + const graph = JSON.parse(graphResult.stdout); + if (artifactDir) { + mkdirSync(artifactDir, { recursive: true }); + writeFileSync(join(artifactDir, "dependency-graph.json"), JSON.stringify(graph, null, 2)); + } + const piVersion = assertSynchronizedPackageVersions(graph, PI_SYNC_PACKAGES); + assertSynchronizedPackageVersions(graph, ["typebox"]); - runNpm(copy, ["run", "typecheck"]); - runChecked(process.execPath, ["./scripts/run-node-test.mjs", - "tests/unit/spawn-runtime-compatibility.test.ts", - "tests/unit/spawn-lifecycle.test.ts", - "tests/unit/spawn-event.test.ts", - "tests/unit/spawn-render.test.ts", - "tests/unit/dependency-graph-assertions.test.ts", - "tests/unit/spawn.test.ts", - "tests/unit/readonly-spawn.test.ts", - "tests/unit/compat-process.test.ts", - ], { cwd: copy }); - runNpm(copy, ["run", "test:e2e"]); - process.stdout.write(`Current synchronized Pi compatibility passed at ${piVersion}.\n`); -} finally { - rmSync(temp, { recursive: true, force: true }); + await runNpmWithRetry(copy, ["run", "typecheck"]); + await runNpmWithRetry(copy, ["exec", "audit-ci", "--", "--config", "audit-ci.jsonc"]); + runChecked(process.execPath, ["./scripts/run-node-test.mjs", + "tests/unit/spawn-runtime-compatibility.test.ts", + "tests/unit/spawn-lifecycle.test.ts", + "tests/unit/spawn-event.test.ts", + "tests/unit/spawn-render.test.ts", + "tests/unit/dependency-graph-assertions.test.ts", + "tests/unit/spawn.test.ts", + "tests/unit/readonly-spawn.test.ts", + "tests/unit/compat-process.test.ts", + ], { cwd: copy }); + runChecked(process.execPath, ["./scripts/run-node-test.mjs", "tests/e2e/basic.test.ts"], { cwd: copy }); + if (artifactDir) { + mkdirSync(artifactDir, { recursive: true }); + copyFileSync(join(copy, "package-lock.json"), join(artifactDir, "package-lock.json")); + writeFileSync(join(artifactDir, "versions.txt"), `pi=${piVersion}\ntypebox=${latestPi.typeboxVersion}\nnode=${process.version}\n`); + } + process.stdout.write(`Current synchronized Pi compatibility passed at ${piVersion}.\n`); + } catch (error) { + writeCompatDiagnostics(artifactDir, copy, error); + throw error; + } finally { + rmSync(temp, { recursive: true, force: true }); + } } + +if (isMain) await main(); diff --git a/scripts/test-compat-floor.mjs b/scripts/test-compat-floor.mjs deleted file mode 100644 index 6f351fb..0000000 --- a/scripts/test-compat-floor.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { repoRootFromScript, runChecked, runNpm } from "./compat-process.mjs"; -import { assertExactPackageVersions } from "./dependency-graph-assertions.mjs"; - -const expected = { - "@earendil-works/pi-agent-core": "0.82.0", - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", - typebox: "1.1.38", -}; - -const root = repoRootFromScript(import.meta.url); -const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); -if (packageJson.engines?.node !== ">=22.19.0") throw new Error("Node floor must be >=22.19.0"); -const graphResult = runNpm(root, ["ls", "--all", "--json"], { capture: true }); -assertExactPackageVersions(JSON.parse(graphResult.stdout), expected); - -runNpm(root, ["run", "typecheck"]); -runChecked(process.execPath, [join(root, "scripts", "run-node-test.mjs"), - "tests/unit/spawn-runtime-compatibility.test.ts", - "tests/unit/spawn-lifecycle.test.ts", - "tests/unit/spawn-event.test.ts", - "tests/unit/dependency-graph-assertions.test.ts", - "tests/unit/spawn-render.test.ts", - "tests/unit/spawn.test.ts", - "tests/unit/readonly-spawn.test.ts", - "tests/unit/config-invariants.test.ts", - "tests/unit/compat-process.test.ts", -], { cwd: root }); -runNpm(root, ["run", "test:e2e"]); -process.stdout.write("Exact Pi 0.82.0 compatibility floor passed.\n"); diff --git a/scripts/test-package-host.mjs b/scripts/test-package-host.mjs index 5e04af5..bf65095 100644 --- a/scripts/test-package-host.mjs +++ b/scripts/test-package-host.mjs @@ -1,39 +1,45 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { repoRootFromScript, runChecked, runNpm } from "./compat-process.mjs"; +import { pathToFileURL } from "node:url"; +import { createCompatCopy, latestPiDependencies, prepareCompatCopy, resolveLatestPi, writeCompatDiagnostics } from "./compat-fixture.mjs"; +import { repoRootFromScript, runChecked, runNpmWithRetry } from "./compat-process.mjs"; -const root = repoRootFromScript(import.meta.url); -const temp = mkdtempSync(join(tmpdir(), "pi-agenticoding-host-")); -let tarball; -try { - const packJson = JSON.parse(runNpm(root, ["pack", "--json", "--ignore-scripts"], { capture: true }).stdout); - tarball = join(root, packJson[0].filename); +const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? "").href; + +async function main() { + const root = repoRootFromScript(import.meta.url); + const artifactDir = process.env.COMPAT_ARTIFACT_DIR; + const { temp, copy } = createCompatCopy(root, "pi-agenticoding-host-"); const host = join(temp, "host"); - mkdirSync(host, { recursive: true }); - writeFileSync(join(host, "package.json"), `${JSON.stringify({ - name: "pi-agenticoding-package-host", - private: true, - type: "module", - dependencies: { - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", - typebox: "1.1.38", - "pi-agenticoding": `file:${tarball}`, - }, - }, null, 2)}\n`); - runNpm(host, ["install", "--ignore-scripts"]); - const graph = JSON.parse(runNpm(host, ["ls", "--json", "pi-agenticoding", - "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"], { capture: true }).stdout); - const extension = graph.dependencies?.["pi-agenticoding"]; - if (!extension) throw new Error("Packed extension is missing from host graph"); - for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"]) { - const nested = join(host, "node_modules", "pi-agenticoding", "node_modules", ...name.split("/"), "package.json"); - if (existsSync(nested)) throw new Error(`Packed extension owns nested peer ${name}`); - } + let tarball; + + try { + prepareCompatCopy(copy); - writeFileSync(join(host, "smoke.mjs"), ` + const packJson = JSON.parse((await runNpmWithRetry(copy, ["pack", "--json", "--ignore-scripts"], { capture: true })).stdout); + tarball = join(copy, packJson[0].filename); + const latestPi = await resolveLatestPi(copy); + mkdirSync(host, { recursive: true }); + writeFileSync(join(host, "package.json"), `${JSON.stringify({ + name: "pi-agenticoding-package-host", + private: true, + type: "module", + dependencies: { + ...latestPiDependencies(latestPi), + "pi-agenticoding": `file:${tarball}`, + }, + }, null, 2)}\n`); + await runNpmWithRetry(host, ["install", "--ignore-scripts"]); + const graph = JSON.parse((await runNpmWithRetry(host, ["ls", "--json", "pi-agenticoding", + "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"], { capture: true })).stdout); + const extension = graph.dependencies?.["pi-agenticoding"]; + if (!extension) throw new Error("Packed extension is missing from host graph"); + for (const name of ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "typebox"]) { + const nested = join(host, "node_modules", "pi-agenticoding", "node_modules", ...name.split("/"), "package.json"); + if (existsSync(nested)) throw new Error(`Packed extension owns nested peer ${name}`); + } + + writeFileSync(join(host, "smoke.mjs"), ` import { DefaultResourceLoader } from "@earendil-works/pi-coding-agent"; import { join } from "node:path"; const extensionPath = join(process.cwd(), "node_modules", "pi-agenticoding", "index.ts"); @@ -47,9 +53,20 @@ const loaded = loader.getExtensions(); if (loaded.errors.length > 0) throw new Error(JSON.stringify(loaded.errors)); if (loaded.extensions.length !== 1) throw new Error("packed extension did not load"); `); - runChecked(process.execPath, ["smoke.mjs"], { cwd: host }); - process.stdout.write("Packed Pi 0.82.0 host smoke passed with host-provided peers.\n"); -} finally { - if (tarball) rmSync(tarball, { force: true }); - rmSync(temp, { recursive: true, force: true }); + runChecked(process.execPath, ["smoke.mjs"], { cwd: host }); + if (artifactDir) { + mkdirSync(artifactDir, { recursive: true }); + copyFileSync(join(host, "package-lock.json"), join(artifactDir, "package-lock.json")); + writeFileSync(join(artifactDir, "versions.txt"), `pi=${latestPi.piVersion}\ntypebox=${latestPi.typeboxVersion}\nnode=${process.version}\n`); + } + process.stdout.write(`Packed latest Pi ${latestPi.piVersion} host smoke passed with host-provided peers.\n`); + } catch (error) { + writeCompatDiagnostics(artifactDir, host, error); + throw error; + } finally { + if (tarball) rmSync(tarball, { force: true }); + rmSync(temp, { recursive: true, force: true }); + } } + +if (isMain) await main(); diff --git a/tests/unit/compat-process.test.ts b/tests/unit/compat-process.test.ts index 8c34459..328e394 100644 --- a/tests/unit/compat-process.test.ts +++ b/tests/unit/compat-process.test.ts @@ -10,6 +10,7 @@ const { repoRootFromScript, runChecked, runNpm, + runNpmWithRetry, isValidNpmExecpath, } = await import(new URL("../../scripts/compat-process.mjs", import.meta.url).href); @@ -19,6 +20,38 @@ function readScript(name: string): string { return readFileSync(join(REPO_ROOT, "scripts", name), "utf8"); } +function writeFlakyNpmCli(tmpDir: string, failures: number): { cliPath: string; attemptsPath: string } { + const attemptsPath = join(tmpDir, "attempts"); + const cliPath = join(tmpDir, "npm-cli.js"); + writeFileSync(cliPath, ` +const { existsSync, readFileSync, writeFileSync } = require("node:fs"); +const attemptsPath = ${JSON.stringify(attemptsPath)}; +const attempts = (existsSync(attemptsPath) ? Number(readFileSync(attemptsPath, "utf8")) : 0) + 1; +writeFileSync(attemptsPath, String(attempts)); +if (attempts <= ${failures}) { process.stderr.write("transient failure"); process.exit(17); } +process.stdout.write("fixture npm success"); +`); + return { cliPath, attemptsPath }; +} + +async function withFlakyNpm(failures: number, test: (tmpDir: string, cliPath: string, attemptsPath: string) => Promise): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), "npm-retry-test-")); + const { cliPath, attemptsPath } = writeFlakyNpmCli(tmpDir, failures); + try { + return await test(tmpDir, cliPath, attemptsPath); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +function retryFixtureNpm(tmpDir: string, cliPath: string) { + return runNpmWithRetry(tmpDir, ["--version"], { + capture: true, + env: { npm_execpath: cliPath }, + execPath: process.execPath, + }, { retries: 2, baseMs: 0 }); +} + test("repoRootFromScript decodes native paths containing spaces", () => { const expectedRoot = resolve(tmpdir(), "compat repo with spaces"); const scriptUrl = pathToFileURL(join(expectedRoot, "scripts", "check.mjs")).href; @@ -86,6 +119,32 @@ test("runNpm launches npm portably", () => { assert.match(result.stdout, /^\d+\.\d+\.\d+/); }); +test("runNpmWithRetry returns on its first successful attempt", async () => { + await withFlakyNpm(0, async (tmpDir, cliPath, attemptsPath) => { + const result = await retryFixtureNpm(tmpDir, cliPath); + assert.equal(result.stdout, "fixture npm success"); + assert.equal(readFileSync(attemptsPath, "utf8"), "1"); + }); +}); + +test("runNpmWithRetry retries a transient npm failure", async () => { + await withFlakyNpm(1, async (tmpDir, cliPath, attemptsPath) => { + const result = await retryFixtureNpm(tmpDir, cliPath); + assert.equal(result.stdout, "fixture npm success"); + assert.equal(readFileSync(attemptsPath, "utf8"), "2"); + }); +}); + +test("runNpmWithRetry rethrows after exhausting its retry budget", async () => { + await withFlakyNpm(3, async (tmpDir, cliPath, attemptsPath) => { + await assert.rejects( + retryFixtureNpm(tmpDir, cliPath), + /status: 17/, + ); + assert.equal(readFileSync(attemptsPath, "utf8"), "3"); + }); +}); + test("runChecked reports process launch failures", () => { assert.throws( () => runChecked("pi-agenticoding-command-that-does-not-exist", [], { cwd: REPO_ROOT, capture: true }), @@ -112,12 +171,28 @@ test("runChecked reports nonzero status and captured output", () => { ); }); +test("runChecked truncates oversized diagnostic output", () => { + const payload = "x".repeat(9000); + assert.throws( + () => runChecked(process.execPath, ["-e", "process.stderr.write(process.env.PAYLOAD); process.exit(7)"], { + cwd: REPO_ROOT, + capture: true, + env: { ...process.env, PAYLOAD: payload }, + }), + (error: unknown) => { + assert.match(String(error), /\[truncated 808 chars\]/); + assert.equal(String(error).includes(payload), false); + return true; + }, + ); +}); + test("compatibility scripts use native roots and the shared npm runner", () => { - for (const name of ["test-compat-current.mjs", "test-compat-floor.mjs", "test-package-host.mjs"]) { + for (const name of ["test-compat-current.mjs", "test-package-host.mjs"]) { const source = readScript(name); assert.doesNotMatch(source, /\.pathname\b/); assert.doesNotMatch(source, /spawnSync\(["']npm["']/); - assert.match(source, /runNpm\(/); + assert.match(source, /runNpm/); assert.match(source, /repoRootFromScript\(import\.meta\.url\)/); } }); diff --git a/tests/unit/dependency-graph-assertions.test.ts b/tests/unit/dependency-graph-assertions.test.ts index afb8ecd..8318577 100644 --- a/tests/unit/dependency-graph-assertions.test.ts +++ b/tests/unit/dependency-graph-assertions.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -const { - assertExactPackageVersions, - assertSynchronizedPackageVersions, -} = await import(new URL("../../scripts/dependency-graph-assertions.mjs", import.meta.url).href); + +const { assertSynchronizedPackageVersions } = await import( + new URL("../../scripts/dependency-graph-assertions.mjs", import.meta.url).href, +); const PI_PACKAGES = [ "@earendil-works/pi-agent-core", @@ -13,30 +13,24 @@ const PI_PACKAGES = [ "@earendil-works/pi-tui", ]; -test("floor and current compatibility lanes include installed pi-agent-core", () => { - for (const script of ["test-compat-floor.mjs", "test-compat-current.mjs"]) { - const source = readFileSync(new URL(`../../scripts/${script}`, import.meta.url), "utf8"); - assert.match(source, /["']@earendil-works\/pi-agent-core["']/i, `${script} must guard pi-agent-core`); - } +test("latest compatibility lane guards every installed Pi package", () => { + const source = readFileSync(new URL("../../scripts/test-compat-current.mjs", import.meta.url), "utf8"); + assert.match(source, /["']@earendil-works\/pi-agent-core["']/i); }); function dependency(version: string, dependencies = {}): object { return { version, dependencies }; } -function graph(piVersion = "0.82.0", typeboxVersion = "1.1.38"): object { +function graph(piVersion = "0.99.0", typeboxVersion = "2.0.0"): object { return { name: "synthetic-install", version: "1.0.0", dependencies: { - "@earendil-works/pi-ai": dependency(piVersion, { - typebox: dependency(typeboxVersion), - }), + "@earendil-works/pi-ai": dependency(piVersion, { typebox: dependency(typeboxVersion) }), "@earendil-works/pi-coding-agent": dependency(piVersion, { "@earendil-works/pi-agent-core": dependency(piVersion), - "@earendil-works/pi-ai": dependency(piVersion, { - typebox: dependency(typeboxVersion), - }), + "@earendil-works/pi-ai": dependency(piVersion, { typebox: dependency(typeboxVersion) }), "@earendil-works/pi-tui": dependency(piVersion), }), "@earendil-works/pi-tui": dependency(piVersion), @@ -45,64 +39,18 @@ function graph(piVersion = "0.82.0", typeboxVersion = "1.1.38"): object { }; } -test("exact floor accepts a coherent recursive dependency graph", () => { - assert.doesNotThrow(() => assertExactPackageVersions(graph(), { - "@earendil-works/pi-agent-core": "0.82.0", - "@earendil-works/pi-ai": "0.82.0", - "@earendil-works/pi-coding-agent": "0.82.0", - "@earendil-works/pi-tui": "0.82.0", - typebox: "1.1.38", - })); -}); - -test("exact floor rejects mixed nested Pi and TypeBox versions", () => { - const mixedCore = graph() as any; - mixedCore.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-agent-core"].version = "0.83.0"; - assert.throws( - () => assertExactPackageVersions(mixedCore, { "@earendil-works/pi-agent-core": "0.82.0" }), - /@earendil-works\/pi-agent-core@0\.82\.0.*0\.83\.0/, - ); - - const mixedPi = graph() as any; - mixedPi.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-ai"].version = "0.83.0"; - assert.throws( - () => assertExactPackageVersions(mixedPi, { "@earendil-works/pi-ai": "0.82.0" }), - /@earendil-works\/pi-ai@0\.82\.0.*0\.83\.0/, - ); - - const mixedTypebox = graph() as any; - mixedTypebox.dependencies["@earendil-works/pi-ai"].dependencies.typebox.version = "1.2.0"; - assert.throws( - () => assertExactPackageVersions(mixedTypebox, { typebox: "1.1.38" }), - /typebox@1\.1\.38.*1\.2\.0/, - ); -}); - -test("current assertions accept recursively synchronized Pi and TypeBox versions", () => { - const current = graph("0.99.0", "2.0.0"); +test("latest assertions accept recursively synchronized Pi and TypeBox versions", () => { + const current = graph(); assert.equal(assertSynchronizedPackageVersions(current, PI_PACKAGES), "0.99.0"); assert.equal(assertSynchronizedPackageVersions(current, ["typebox"]), "2.0.0"); }); -test("current assertions reject mixed nested Pi and TypeBox versions", () => { - const mixedCore = graph("0.99.0", "2.0.0") as any; +test("latest assertions reject mixed nested Pi and TypeBox versions", () => { + const mixedCore = graph() as any; mixedCore.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-agent-core"].version = "0.98.0"; - assert.throws( - () => assertSynchronizedPackageVersions(mixedCore, PI_PACKAGES), - /synchronized.*0\.98\.0.*0\.99\.0/i, - ); - - const mixedPi = graph("0.99.0", "2.0.0") as any; - mixedPi.dependencies["@earendil-works/pi-coding-agent"].dependencies["@earendil-works/pi-tui"].version = "0.98.0"; - assert.throws( - () => assertSynchronizedPackageVersions(mixedPi, PI_PACKAGES), - /synchronized.*0\.98\.0.*0\.99\.0/i, - ); + assert.throws(() => assertSynchronizedPackageVersions(mixedCore, PI_PACKAGES), /synchronized.*0\.98\.0.*0\.99\.0/i); - const mixedTypebox = graph("0.99.0", "2.0.0") as any; + const mixedTypebox = graph() as any; mixedTypebox.dependencies["@earendil-works/pi-ai"].dependencies.typebox.version = "1.9.0"; - assert.throws( - () => assertSynchronizedPackageVersions(mixedTypebox, ["typebox"]), - /synchronized.*1\.9\.0.*2\.0\.0/i, - ); + assert.throws(() => assertSynchronizedPackageVersions(mixedTypebox, ["typebox"]), /synchronized.*1\.9\.0.*2\.0\.0/i); }); diff --git a/tests/unit/spawn-runtime-compatibility.test.ts b/tests/unit/spawn-runtime-compatibility.test.ts index 5dac784..c42d45a 100644 --- a/tests/unit/spawn-runtime-compatibility.test.ts +++ b/tests/unit/spawn-runtime-compatibility.test.ts @@ -8,7 +8,7 @@ import { createState } from "../../state.js"; import { executeSpawn, registerSpawnTool } from "../../spawn/index.js"; import { createTestPI, runRealChildInvocation } from "./helpers.js"; -test("exact Pi floor real child completes through inherited/default thinking", async () => { +test("real child completes through inherited/default thinking", async () => { const proof = await runRealChildInvocation({ prompt: "Use the agentic_e2e_probe tool and return AGENTIC_E2E_PROBE_OK." }); assert.equal(proof.result.content[0].text, proof.expectedText); assert.equal(proof.result.details.model, proof.modelId); @@ -20,7 +20,7 @@ test("exact Pi floor real child completes through inherited/default thinking", a assert.deepEqual(proof.outboundFetches, [], "offline real-child fixture attempted an outbound fetch"); }); -test("exact Pi floor real child preserves selected identity and reports effective thinking", async () => { +test("real child preserves selected identity and reports effective thinking", async () => { const proof = await runRealChildInvocation({ prompt: "Use the agentic_e2e_probe tool and return AGENTIC_E2E_PROBE_OK.", thinking: "max", From 695836832c399656e836fc6048629467328a1581 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 9 Aug 2026 15:54:13 +0300 Subject: [PATCH 3/4] remove obsolete audit-ci allowlist entries --- audit-ci.jsonc | 18 +------- tests/unit/config-invariants.test.ts | 64 ++-------------------------- 2 files changed, 5 insertions(+), 77 deletions(-) diff --git a/audit-ci.jsonc b/audit-ci.jsonc index 2e93da3..861713f 100644 --- a/audit-ci.jsonc +++ b/audit-ci.jsonc @@ -1,21 +1,5 @@ { "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", "moderate": true, - "allowlist": [ - // brace-expansion 5.0.7 is pinned by @earendil-works/pi-coding-agent's - // published npm-shrinkwrap.json (the host under test); registry fix 5.0.9 - // exists but cannot be installed without deviating from the host tree — - // remove when a pi-coding-agent release ships brace-expansion ≥5.0.9. - { "GHSA-mh99-v99m-4gvg": { "active": true, "expiry": "2026-10-01", "notes": "brace-expansion <5.0.8 DoS via unbounded expansion; 5.0.7 pinned by @earendil-works/pi-coding-agent's npm-shrinkwrap (host under test) — remove when pi-coding-agent ships brace-expansion ≥5.0.8" } }, - { "GHSA-rgw5-rvv9-x895": { "active": true, "expiry": "2026-10-01", "notes": "brace-expansion <5.0.9 DoS bypassing the CVE-2026-14257 mitigation; 5.0.7 pinned by @earendil-works/pi-coding-agent's npm-shrinkwrap — remove when pi-coding-agent ships brace-expansion ≥5.0.9" } }, - // undici 8.5.0 is pinned exactly by @earendil-works/pi-coding-agent (the host - // under test); no released pi-coding-agent ships undici ≥8.9.0 (upstream - // issue #7049, PR #7225 closed unmerged) — remove when a pi-coding-agent - // release ships undici ≥8.9.0. - { "GHSA-4cwx-7wf7-3272|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0 high: cross-user cache disclosure / parse-time crash; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 (host under test); no upstream fix released — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-8xcm-r25x-g524|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: retry-interceptor response desynchronization; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-jr45-8vmc-qm54|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: Cache-Control whitespace bypass; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-m8rv-5g2x-5cg5|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: CRLF injection via blob-like body type; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } }, - { "GHSA-v3r7-h72x-cjcm|@earendil-works/pi-coding-agent>undici": { "active": true, "expiry": "2026-10-01", "notes": "undici <8.9.0: cookie attribute injection; pinned exact 8.5.0 by @earendil-works/pi-coding-agent 0.82.0 — remove when pi-coding-agent ships undici ≥8.9.0" } } - ] + "allowlist": [] } diff --git a/tests/unit/config-invariants.test.ts b/tests/unit/config-invariants.test.ts index 85133ce..2bbfada 100644 --- a/tests/unit/config-invariants.test.ts +++ b/tests/unit/config-invariants.test.ts @@ -1,9 +1,8 @@ /** * Invariant tests for the audit-ci security audit configuration. * - * Validates that allowlist entries have unexpired expiry dates, that the - * CI workflow ordering (audit → unit → e2e) is preserved, and that the - * allowlist matches the current lockfile's actual vulnerability state. + * Validates the audit policy, that any allowlist entries have unexpired expiry + * dates, and that CI preserves the audit → unit → e2e order. */ import assert from "node:assert/strict"; @@ -49,15 +48,7 @@ const EXPECTED_MATRIX = new Set([ "macos-latest@24", "windows-latest@24", ]); -const EXPECTED_ALLOWLIST_KEYS = new Set([ - "GHSA-mh99-v99m-4gvg", - "GHSA-rgw5-rvv9-x895", - "GHSA-4cwx-7wf7-3272|@earendil-works/pi-coding-agent>undici", - "GHSA-8xcm-r25x-g524|@earendil-works/pi-coding-agent>undici", - "GHSA-jr45-8vmc-qm54|@earendil-works/pi-coding-agent>undici", - "GHSA-m8rv-5g2x-5cg5|@earendil-works/pi-coding-agent>undici", - "GHSA-v3r7-h72x-cjcm|@earendil-works/pi-coding-agent>undici", -]); +const EXPECTED_ALLOWLIST_KEYS = new Set(); function readText(url: URL): string { return readFileSync(url, "utf8"); @@ -123,36 +114,6 @@ function runAuditCi(): void { assert.equal(result.status, 0, diagnostics); } -function compareVersions(a: string, b: string): number { - const parse = (v: string): [number, number, number] => { - const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v); - assert.ok(match, `unexpected version format: ${v}`); - return [Number(match[1]), Number(match[2]), Number(match[3])]; - }; - const [aMajor, aMinor, aPatch] = parse(a); - const [bMajor, bMinor, bPatch] = parse(b); - if (aMajor !== bMajor) return aMajor - bMajor; - if (aMinor !== bMinor) return aMinor - bMinor; - return aPatch - bPatch; -} - -function parseLockfileVulnerablePaths(lockfilePath: string, packageName: string, maxVersion: string): string[] { - const lock = JSON.parse(readFileSync(lockfilePath, "utf8")) as { - packages?: Record; - }; - const prefix = `node_modules/${packageName}`; - const paths: string[] = []; - for (const [path, entry] of Object.entries(lock.packages ?? {})) { - if (!path.endsWith(prefix)) continue; - const version = entry?.version; - assert.ok(typeof version === "string", `missing version for lockfile entry: ${path}`); - if (compareVersions(version, maxVersion) <= 0) { - paths.push(path); - } - } - return paths; -} - test("pinned Pi compatibility metadata and source boundaries stay exact", () => { const packageJson = parsePackageJson(); const lock = JSON.parse(readText(LOCK_PATH)) as { packages: Record }; @@ -179,7 +140,7 @@ test("pinned Pi compatibility metadata and source boundaries stay exact", () => assert.doesNotMatch(rendererSource, /process\.(?:stdout|stderr)\.write\s*\(/); }); -test("audit-ci config keeps only the active expiry-tracked scoped exceptions", () => { +test("audit-ci config enforces the empty allowlist policy", () => { const config = parseAuditConfig(); assert.equal(config.$schema, AUDIT_SCHEMA); assert.equal(config.moderate, true); @@ -196,23 +157,6 @@ test("audit-ci config keeps only the active expiry-tracked scoped exceptions", ( } }); -test("the lockfile contains the sole allowlisted vulnerable brace-expansion path", () => { - const vulnerablePaths = parseLockfileVulnerablePaths(fileURLToPath(LOCK_PATH), "brace-expansion", "5.0.7"); - assert.deepEqual(vulnerablePaths, [ - "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion", - ]); -}); - -test("the lockfile contains the allowlisted undici path at a vulnerable version", () => { - // undici <8.9.0 is allowlisted only while pi-coding-agent pins 8.5.0 exactly; - // once a pi-coding-agent release ships undici ≥8.9.0 this fails and forces - // the allowlist entries to be removed. - const vulnerablePaths = parseLockfileVulnerablePaths(fileURLToPath(LOCK_PATH), "undici", "8.8.0"); - assert.deepEqual(vulnerablePaths, [ - "node_modules/@earendil-works/pi-coding-agent/node_modules/undici", - ]); -}); - test("workflow keeps the expected matrix and audit/test order", () => { const workflow = readText(WORKFLOW_PATH); const packageJson = parsePackageJson(); From 2207a7c37ade1aa144d41569f8a1c9cdd693d694 Mon Sep 17 00:00:00 2001 From: Ofri Wolfus Date: Sun, 9 Aug 2026 15:54:20 +0300 Subject: [PATCH 4/4] harden spawn abort/invalidation race handling --- spawn/index.ts | 36 ++- state.ts | 33 ++- tests/unit/abort-child-session.test.ts | 71 +++++ tests/unit/spawn.test.ts | 362 +++++++++++++++---------- 4 files changed, 337 insertions(+), 165 deletions(-) create mode 100644 tests/unit/abort-child-session.test.ts diff --git a/spawn/index.ts b/spawn/index.ts index 4dc48f3..4c3f749 100644 --- a/spawn/index.ts +++ b/spawn/index.ts @@ -28,7 +28,7 @@ import { SessionManager, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; -import type { AgenticodingState } from "../state.js"; +import { abortChildSession, type AgenticodingState } from "../state.js"; import { formatPageList } from "../notebook/store.js"; import { createNotebookToolDefinitions } from "../notebook/tools.js"; import { resolveSpawnModelRoute } from "../model-groups/router.js"; @@ -373,13 +373,15 @@ export function executeSpawn( const invalidatedError = new Error("Spawn invalidated by reset."); let wasAborted = false; - let abortPromise: Promise | undefined; + let reportedAbortPromise: Promise | undefined; const abortChild = () => { wasAborted = true; - if (!abortPromise) { - abortPromise = session.abort(); - abortPromise.catch(() => {}); + const abortPromise = abortChildSession(state, session); + if (abortPromise !== reportedAbortPromise) { + reportedAbortPromise = abortPromise; + void abortPromise.catch((error) => notifyCleanupFailure(ctx, error)); } + return abortPromise; }; const clearChildSession = () => { if (state.childSessions.get(toolCallId) === session) { @@ -391,7 +393,7 @@ export function executeSpawn( }; const abortAndInvalidate = async () => { clearChildSession(); - await session.abort().catch(() => {}); + await abortChild(); throw invalidatedError; }; @@ -409,8 +411,7 @@ export function executeSpawn( try { if (signal?.aborted) { - abortChild(); - await abortPromise; + await abortChild(); throw signal.reason instanceof Error ? signal.reason : new Error("Spawn aborted before child session started."); @@ -435,8 +436,7 @@ export function executeSpawn( }); if (signal?.aborted) { - abortChild(); - await abortPromise; + await abortChild(); throw signal.reason instanceof Error ? signal.reason : new Error("Spawn aborted before child session started."); @@ -445,7 +445,17 @@ export function executeSpawn( await abortAndInvalidate(); } - await session.prompt(fullPrompt); + try { + await session.prompt(fullPrompt); + } catch (error) { + // Only a signal-aborted AbortError is expected from prompt when wasAborted is true. + // Other errors (real prompt failures) must not be swallowed — they should surface + // even if an abort raced with the rejection. Reset invalidation still takes + // precedence over abort handling, so isStale() always rethrows. + if (isStale()) throw error; + if (!wasAborted) throw error; + if ((error as Error)?.name !== "AbortError") throw error; + } } catch (error) { clearChildSession(); if (isStale()) { @@ -462,12 +472,12 @@ export function executeSpawn( } const resultText = getLastAssistantText(session.messages as AssistantMessageLike[]); - if (!resultText) { + if (!resultText && !wasAborted) { clearChildSession(); throw new Error("Child agent produced no output."); } const outcome = wasAborted ? "aborted" : getLastAssistantOutcome(session.messages as AssistantMessageLike[]); - const { text: finalText, truncated } = truncateResult(resultText); + const { text: finalText, truncated } = truncateResult(resultText ?? ""); // Execution should not retain live children after completion. If the TUI // already rendered the child, it still owns the session object itself. diff --git a/state.ts b/state.ts index f0478b6..efb080c 100644 --- a/state.ts +++ b/state.ts @@ -96,6 +96,9 @@ export interface AgenticodingState { */ childSessionEpoch: number; + /** One abort promise per child session, shared by signal and reset cleanup. */ + childAbortPromises: WeakMap>; + /** Whether readonly mode is active — write/edit blocked; handoff needs explicit /handoff or a human topic boundary; bash writes limited to temp. */ readonlyEnabled: boolean; @@ -160,6 +163,7 @@ export function createState(): AgenticodingState { childSessions, liveChildSessions, childSessionEpoch: 0, + childAbortPromises: new WeakMap(), readonlyEnabled: false, readonlyNudgePending: false, frontmatterSkillCache, @@ -236,15 +240,32 @@ export function invalidateHandoffState(state: AgenticodingState): void { // from work that no longer belongs to the active session tree. } +/** Return the session's single shared abort operation, starting it if necessary. */ +export function abortChildSession(state: AgenticodingState, session: AgentSession): Promise { + const existing = state.childAbortPromises.get(session); + if (existing) return existing; + + let resolve!: () => void; + let reject!: (error: unknown) => void; + const abortPromise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + state.childAbortPromises.set(session, abortPromise); + try { + Promise.resolve(session.abort()).then(resolve, reject); + } catch (error) { + reject(error); + } + return abortPromise; +} + /** Abort all active child sessions and clear both registries. Called on /new (session reset). */ export function abortAndClearChildSessions(state: AgenticodingState): void { - const seen = new Map(); // session → first id (for logging) - for (const [id, session] of [...state.childSessions.entries(), ...state.liveChildSessions.entries()]) { - if (!seen.has(session)) seen.set(session, id); - } + const sessions = new Set([...state.childSessions.values(), ...state.liveChildSessions.values()]); state.childSessions.clear(); state.liveChildSessions.clear(); - for (const [session, id] of seen) { - session.abort().catch(() => {}); + for (const session of sessions) { + void abortChildSession(state, session).catch(() => {}); } } diff --git a/tests/unit/abort-child-session.test.ts b/tests/unit/abort-child-session.test.ts new file mode 100644 index 0000000..a396c30 --- /dev/null +++ b/tests/unit/abort-child-session.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createState, abortChildSession } from "../../state.js"; + +function createMockSession(abortImpl: () => Promise | void) { + return { abort: abortImpl } as any; +} + +test("abortChildSession returns the same promise for the same session", async () => { + const state = createState(); + const session = createMockSession(async () => {}); + + const promise1 = abortChildSession(state, session); + const promise2 = abortChildSession(state, session); + + assert.strictEqual(promise1, promise2, "same session returns same promise"); + + await promise1; + await promise2; +}); + +test("abortChildSession handles synchronous abort() throw", async () => { + const state = createState(); + const syncError = new Error("sync abort failure"); + const session = createMockSession(() => { throw syncError; }); + + const promise = abortChildSession(state, session); + + await assert.rejects(promise, (error: unknown) => { + assert.equal(error, syncError); + return true; + }); +}); + +test("abortChildSession returns different promises for different sessions", async () => { + const state = createState(); + const session1 = createMockSession(async () => {}); + const session2 = createMockSession(async () => {}); + + const promise1 = abortChildSession(state, session1); + const promise2 = abortChildSession(state, session2); + + assert.notStrictEqual(promise1, promise2, "different sessions return different promises"); + + await promise1; + await promise2; +}); + +test("abortChildSession promise resolves when abort completes", async () => { + const state = createState(); + let abortCalled = false; + const session = createMockSession(async () => { abortCalled = true; }); + + const promise = abortChildSession(state, session); + await promise; + + assert.equal(abortCalled, true, "abort was called"); +}); + +test("abortChildSession promise rejects when abort rejects", async () => { + const state = createState(); + const abortError = new Error("abort rejected"); + const session = createMockSession(async () => { throw abortError; }); + + const promise = abortChildSession(state, session); + + await assert.rejects(promise, (error: unknown) => { + assert.equal(error, abortError); + return true; + }); +}); diff --git a/tests/unit/spawn.test.ts b/tests/unit/spawn.test.ts index 1086ee6..4c4536a 100644 --- a/tests/unit/spawn.test.ts +++ b/tests/unit/spawn.test.ts @@ -79,20 +79,10 @@ test("spawn execute passes broad active registered tool formula to child session const requestedCwd = "/tmp"; let seenConfig: any; - const mockFactory = async (config: any) => { + registerSpawnTool(pi as any, state, async (config: any) => { seenConfig = config; - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + return { session: mockSessionFactory({ prompt: async () => {} }), extensionsResult: undefined as any }; + }); await pi.tools.get("spawn").execute( "spawn-1", @@ -126,21 +116,10 @@ test("spawn forwards requested thinking and reports the session effective thinki const state = createState(); const updates: any[] = []; let seenConfig: any; - const session = { - messages: [] as any[], - get thinkingLevel() { return "off" as const; }, - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - dispose: () => {}, - getSessionStats: () => undefined, - }; - - registerSpawnTool(pi as any, state, (async (config: any) => { + registerSpawnTool(pi as any, state, async (config: any) => { seenConfig = config; - return { session: session as any }; - }) as any); + return { session: mockSessionFactory({ thinkingLevel: "off", prompt: async () => {} }), extensionsResult: undefined as any }; + }); const result = await pi.tools.get("spawn").execute( "spawn-effective-thinking", @@ -176,21 +155,12 @@ test("spawn execute composes Model Group routing with readonly child guards", as }; let seenConfig: any; let seenPrompt = ""; - const mockFactory = async (config: any) => { + registerSpawnTool(pi as any, state, async (config: any) => { seenConfig = config; - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - seenPrompt = prompt; - session.messages = [{ role: "assistant", content: [{ type: "text", text: "routed result" }] }]; - }, - abort: async () => {}, - dispose: () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - registerSpawnTool(pi as any, state, mockFactory as any); + return { session: mockSessionFactory({ + prompt: async (p?: string) => { seenPrompt = p ?? ""; }, + }), extensionsResult: undefined as any }; + }); const result = await pi.tools.get("spawn").execute( "spawn-routed", @@ -225,20 +195,9 @@ test("spawn execute builds prompt with notebook pages and task", async () => { state.notebookPages.set("entry-a", "preview line\nfull body"); let seenPrompt = ""; - const mockFactory = async (config: any) => { - const session = { - messages: [] as any[], - prompt: async (prompt: string) => { - seenPrompt = prompt; - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async (p?: string) => { seenPrompt = p ?? ""; }, + })); await pi.tools.get("spawn").execute( "spawn-1", @@ -450,23 +409,14 @@ test("spawn execute returns result and stats", async () => { const state = createState(); const updates: any[] = []; - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => ({ - tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 }, - cost: 0.5, - assistantMessages: 2, - }), - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + getSessionStats: () => ({ + tokens: { input: 11, output: 22, cacheRead: 3, cacheWrite: 4, total: 40 }, + cost: 0.5, + assistantMessages: 2, + }), + })); const result = await pi.tools.get("spawn").execute( "spawn-1", @@ -498,21 +448,10 @@ test("spawn execute marks stats unavailable when stats collection throws", async pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => { - throw new Error("stats failed"); - }, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + getSessionStats: () => { throw new Error("stats failed"); }, + })); const result = await pi.tools.get("spawn").execute( "spawn-1", { prompt: "Do the task" }, @@ -530,17 +469,7 @@ test("spawn execute throws when child produces no output", async () => { pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => {}, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ result: [] })); await assert.rejects( () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), @@ -553,19 +482,9 @@ test("spawn execute clears childSessions when prompt throws", async () => { pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - throw new Error("prompt failed"); - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => { throw new Error("prompt failed"); }, + })); await assert.rejects( () => pi.tools.get("spawn").execute("spawn-1", { prompt: "Do the task" }, undefined, undefined, { model: { id: "mock-model" }, cwd: "/tmp" }), @@ -665,19 +584,9 @@ test("spawn execute clears childSessions after successful completion when unrend pi.setActiveTools(["read", "bash", "spawn"]); const state = createState(); - const mockFactory = async () => { - const session = { - messages: [] as any[], - prompt: async () => { - session.messages = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; - }, - abort: async () => {}, - getSessionStats: () => undefined, - }; - return { session: session as any }; - }; - - registerSpawnTool(pi as any, state, mockFactory as any); + registerSpawnTool(pi as any, state, mockFactoryWith({ + prompt: async () => {}, + })); const result = await pi.tools.get("spawn").execute( "spawn-1", { prompt: "Do the task" }, @@ -872,6 +781,179 @@ test("spawn execute aborts child session when signal fires during execution", as assert.equal(disposeCalls, 1, "mid-prompt abort disposes exactly once"); }); +function mockSessionFactory(opts: { + prompt?: (prompt?: string) => Promise; + abort?: () => Promise; + dispose?: () => void; + result?: any[]; + thinkingLevel?: string; + getSessionStats?: () => any; +} = {}) { + const defaultResult = [{ role: "assistant", content: [{ type: "text", text: "child result" }] }]; + const session: any = { + messages: [] as any[], + get thinkingLevel() { return opts.thinkingLevel; }, + prompt: async (p?: string) => { + if (opts.prompt) await opts.prompt(p); + session.messages = opts.result ?? defaultResult; + }, + abort: opts.abort ?? (async () => {}), + dispose: opts.dispose ?? (() => {}), + getSessionStats: opts.getSessionStats ?? (() => undefined), + }; + return session; +} + +/** Create a session factory for spawn tests — wraps mockSessionFactory with the expected return shape. */ +function mockFactoryWith(opts: Parameters[0] = {}) { + return async () => ({ session: mockSessionFactory(opts), extensionsResult: undefined as any }); +} + +test("spawn execute swallows prompt rejection when signal aborts mid-flight", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + let abortCalled = false; + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (err: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const mockFactory = async () => { + return { session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { + abortCalled = true; + rejectPrompt(Object.assign(new Error("aborted"), { name: "AbortError" })); + }, + dispose: () => { disposeCalls++; }, + }) }; + }; + registerSpawnTool(pi as any, state, mockFactory as any); + + const executePromise = pi.tools.get("spawn").execute( + "spawn-aborted-throw", + { prompt: "Do the task" }, + controller.signal, + undefined, + { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + controller.abort(); + const result = await executePromise; + assert.equal(abortCalled, true); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); + assert.equal(result.details.outcome, "aborted"); + // Outcome and cleanup are the external contracts; text format is secondary. + assert.equal(result.content[0]?.text ?? "", ""); + assert.equal(disposeCalls, 1, "mid-prompt abort disposes exactly once"); +}); + +test("spawn execute preserves a real prompt failure that races with abort", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const promptError = new Error("prompt failed despite abort"); + registerSpawnTool(pi as any, state, async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + dispose: () => { disposeCalls++; }, + }), extensionsResult: undefined as any })); + + const execution = pi.tools.get("spawn").execute( + "spawn-abort-real-error", { prompt: "Do the task" }, controller.signal, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + controller.abort(); + rejectPrompt(promptError); + + await assert.rejects(execution, (error: unknown) => error === promptError); + assert.equal(disposeCalls, 1); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("spawn invalidation wins the abort and prompt-rejection race", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + const controller = new AbortController(); + let abortCalls = 0; + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const mockFactory = async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, + }) }); + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-abort-reset-race", { prompt: "Do the task" }, controller.signal, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + controller.abort(); + resetState(state); + rejectPrompt(new Error("prompt rejected after abort and reset")); + + await assert.rejects(() => execution, /invalidated by reset/i); + assert.equal(abortCalls, 1, "signal cancellation and reset share one abort"); + assert.equal(disposeCalls, 1, "the raced child disposes exactly once"); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + +test("executeSpawn throws invalidatedError even when abort rejects during reset", async () => { + const pi = createTestPI(); + pi.setActiveTools(["read", "bash", "spawn"]); + const state = createState(); + let disposeCalls = 0; + let promptStarted!: () => void; + let rejectPrompt!: (error: Error) => void; + const started = new Promise((resolve) => { promptStarted = resolve; }); + const abortError = new Error("abort failed"); + const mockFactory = async () => ({ session: mockSessionFactory({ + prompt: async () => { + promptStarted(); + await new Promise((_resolve, reject) => { rejectPrompt = reject; }); + }, + abort: async () => { throw abortError; }, + dispose: () => { disposeCalls++; }, + }), extensionsResult: undefined as any }); + registerSpawnTool(pi as any, state, mockFactory as any); + + const execution = pi.tools.get("spawn").execute( + "spawn-abort-rejects", { prompt: "Do the task" }, undefined, + undefined, { model: { id: "mock-model" }, cwd: "/tmp" }, + ); + await started; + resetState(state); + rejectPrompt(new Error("prompt rejected")); + + await assert.rejects(execution, /invalidated by reset/i); + assert.equal(disposeCalls, 1, "the aborted child disposes exactly once"); + assert.equal(state.childSessions.size, 0); + assert.equal(state.liveChildSessions.size, 0); +}); + test("spawn renderCall shows prompt preview and optional routing controls", () => { const state = createState(); @@ -1207,15 +1289,10 @@ test("executeSpawn does not prompt when onUpdate synchronously resets the child undefined, () => { resetState(state); }, "medium", - async () => ({ - extensionsResult: undefined as any, - session: { - messages: [] as any[], - prompt: async () => { promptCalls++; }, - abort: async () => { abortCalls++; }, - dispose: () => { disposeCalls++; }, - getSessionStats: () => undefined, - } as any, + mockFactoryWith({ + prompt: async () => { promptCalls++; }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, }), ); @@ -1246,15 +1323,10 @@ test("executeSpawn does not prompt when onUpdate synchronously aborts the signal controller.signal, () => { controller.abort(reason); }, "medium", - async () => ({ - extensionsResult: undefined as any, - session: { - messages: [] as any[], - prompt: async () => { promptCalls++; }, - abort: async () => { abortCalls++; }, - dispose: () => { disposeCalls++; }, - getSessionStats: () => undefined, - } as any, + mockFactoryWith({ + prompt: async () => { promptCalls++; }, + abort: async () => { abortCalls++; }, + dispose: () => { disposeCalls++; }, }), ); @@ -1308,16 +1380,14 @@ test("executeSpawn aborts stale child when resetState fires during prompt", asyn // Wait for session to be created and prompt to start await promptStartedPromise; - // Reset state triggers abortAndClearChildSessions which calls session.abort() - // abort() rejects the pending prompt, which causes the stale check to fire + // Reset cleanup starts the shared abort, then prompt rejection reaches invalidation. resetState(state); await assert.rejects( () => executePromise, /invalidated by reset/i, ); - // abort is called once by clearChildSession (identity match via liveChildSessions) - assert.equal(abortCalls >= 1, true); + assert.equal(abortCalls, 1, "reset and invalidation share one abort"); assert.equal(state.childSessions.size, 0); assert.equal(state.liveChildSessions.size, 0); assert.equal(disposeCalls, 1, "prompt-reset invalidation disposes exactly once");