diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c63e74bea7..987f54b820 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -96,7 +96,13 @@ jobs: uses: actions/upload-artifact@v4 with: name: test-timings-shard-${{ matrix.shard }} - path: .timings/timings-*.json + # Relative outputFile paths mean each package writes its own + # /.timings/ file — glob the whole tree, not just the root. + path: | + .timings/timings-*.json + packages/*/.timings/timings-*.json + plugins/*/.timings/timings-*.json + plugins/examples/*/.timings/timings-*.json if-no-files-found: ignore retention-days: 14 diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts index 1c089ebcfa..74c7223593 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts @@ -203,7 +203,10 @@ describe("plan output bounds (S5)", () => { }); }); - it("a plan-ONLY stream stops emitting once the per-turn cap is crossed", async () => { + // Generous timeout: this test does CPU-bound string flooding (~25 plan + // events x 100 entries x 2k chars) and has timed out at the default 5s + // under loaded CI shards while passing easily in isolation. + it("a plan-ONLY stream stops emitting once the per-turn cap is crossed", { timeout: 20_000 }, async () => { const { createEventBridge, PER_CHUNK_CAP_CHARS, PER_TURN_OUTPUT_CAP_CHARS, MAX_PLAN_ENTRIES } = await import("../event-bridge.js"); const thinking: string[] = []; diff --git a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts index db5f369503..2e52335f5b 100644 --- a/plugins/fusion-plugin-acp-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-acp-runtime/src/process-manager.ts @@ -1,3 +1,4 @@ +// port-4040-allowlist: doc comments below reference the "never kill port 4040" rule; no kill targets it. // Subprocess lifecycle for the ACP runtime. // // Mirrors the hardening conventions in diff --git a/scripts/__tests__/ci-test-shard-timings.test.mjs b/scripts/__tests__/ci-test-shard-timings.test.mjs index e3b8c5241c..df27dcd169 100644 --- a/scripts/__tests__/ci-test-shard-timings.test.mjs +++ b/scripts/__tests__/ci-test-shard-timings.test.mjs @@ -18,6 +18,7 @@ import { buildTimingsSnapshot, writeTimings, TIMINGS_SNAPSHOT_RELATIVE, + discoverWorkspaceTimingFiles, } from "../ci-test-shard.mjs"; const PACKAGES = [ @@ -203,3 +204,23 @@ test("writeTimings warns and does not write when there are no input files", () = rmSync(root, { recursive: true, force: true }); } }); + +test("discoverWorkspaceTimingFiles finds root and per-package .timings files", (t) => { + const root = mkdtempSync(path.join(tmpdir(), "wts-discover-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, ".timings"), { recursive: true }); + mkdirSync(path.join(root, "packages/aaa/.timings"), { recursive: true }); + mkdirSync(path.join(root, "plugins/bbb/.timings"), { recursive: true }); + mkdirSync(path.join(root, "packages/no-timings-here"), { recursive: true }); + writeFileSync(path.join(root, ".timings/timings-shard1-0.json"), "{}"); + writeFileSync(path.join(root, "packages/aaa/.timings/timings-shard1-0.json"), "{}"); + writeFileSync(path.join(root, "plugins/bbb/.timings/timings-shard2-0.json"), "{}"); + writeFileSync(path.join(root, "packages/aaa/.timings/not-a-match.txt"), ""); + + const found = discoverWorkspaceTimingFiles(root).map((f) => path.relative(root, f)); + assert.deepEqual(found.sort(), [ + ".timings/timings-shard1-0.json", + "packages/aaa/.timings/timings-shard1-0.json", + "plugins/bbb/.timings/timings-shard2-0.json", + ]); +}); diff --git a/scripts/ci-test-shard.mjs b/scripts/ci-test-shard.mjs index 729604fd6b..aba7f31500 100644 --- a/scripts/ci-test-shard.mjs +++ b/scripts/ci-test-shard.mjs @@ -964,6 +964,27 @@ export function discoverTimingFiles(dir) { .sort(); } +/** + * Discover timing files across the whole workspace: the root .timings/ dir + * plus every package/plugin's own .timings/ dir (shard runs emit RELATIVE + * outputFile paths, so each package writes under its own directory — see + * the timingFlags comment in main()). + * + * @param {string} projectRoot + * @returns {string[]} Absolute paths, sorted. + */ +export function discoverWorkspaceTimingFiles(projectRoot) { + const dirs = [ + path.join(projectRoot, ".timings"), + ...globSync("{packages,plugins,plugins/examples}/*/.timings", { cwd: projectRoot }).map((d) => + path.join(projectRoot, d), + ), + ]; + const files = new Set(); + for (const dir of dirs) for (const f of discoverTimingFiles(dir)) files.add(f); + return [...files].sort(); +} + /** * Merge per-shard JSON reporter outputs into the committed snapshot. * Refuses to overwrite a snapshot whose capturedAt is newer than this run's. @@ -975,7 +996,9 @@ export function writeTimings(options = {}) { const projectRoot = options.projectRoot ?? process.cwd(); const snapshotPath = options.snapshotPath ?? path.join(projectRoot, TIMINGS_SNAPSHOT_RELATIVE); const inputs = options.inputs - ?? discoverTimingFiles(options.inputDir ?? path.join(projectRoot, ".timings")); + ?? (options.inputDir + ? discoverTimingFiles(options.inputDir) + : discoverWorkspaceTimingFiles(projectRoot)); if (inputs.length === 0) { console.warn("[ci-test-shard] no timing input files found; snapshot unchanged."); @@ -1236,11 +1259,16 @@ export function main(argv = process.argv.slice(2), env = process.env) { // Per-shard timing telemetry (U1 / R4): each test invocation also emits a // vitest JSON reporter file under .timings/. These are uploaded as CI // artifacts and consumed by `--write-timings` to refresh the snapshot. - const timingsDir = path.join(process.cwd(), ".timings"); - mkdirSync(timingsDir, { recursive: true }); + // + // The path MUST be RELATIVE: one pnpm invocation can fan out to several + // packages, each spawning its own vitest with these identical forwarded + // flags. A relative path resolves against each package's cwd, giving every + // package its own /.timings/ file; an absolute path would make all + // packages in the invocation overwrite the same file (last writer wins, + // silently dropping every other package's timings). let invocationIndex = 0; const timingFlags = () => { - const outputFile = path.join(timingsDir, `timings-shard${shard}-${invocationIndex++}.json`); + const outputFile = path.join(".timings", `timings-shard${shard}-${invocationIndex++}.json`); return ["--reporter=json", `--outputFile.json=${outputFile}`]; };