mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors wait for the full PR workflow before they can deliver fixes. > - Server shards still execute independent files in series, and a single Runner file serializes two long real-clock waits. > - Faster work outside the critical path does not prove a faster workflow. > - Overlap independent files while preserving isolation, test cases and real production deadlines. > - Compare complete baseline and candidate workflows, separating queue delay from execution cost. ## Linked Issues or Issue Description Refs #16429. Related duration refresh: #12577. **What existing behavior does this improve?** The elapsed time from PR CI creation to the last required check. **Current behavior** General server shards use one worker. One Runner transport file takes about 333 seconds on Linux, including over 100 seconds in two real-clock regressions. These are the longest verification lanes after #16429. **Proposed behavior** Run independent general-server files in at most two isolated forks. Move the two intact Runner deadline regressions into a separate file to overlap their waits in the existing pool. Run two chat partitions per existing job, each with its own temporary home and embedded PostgreSQL. Preserve serial execution within files and every assertion and timing boundary. ## What Changed - Qualify both serial and parallel forks with real worker processes, isolated homes/configuration, timers, globals and environment. - Require both parallel workers to arrive before either exits. - Use two isolated forks for sharded general-server tests; retain one worker with an explicit shared test database. Other test groups keep their existing concurrency. - Extract the lease-expiry and Pi cold-admission regressions verbatim into a separate Runner test file; retain real waits and process/lease assertions. - Split each sharded chat job into two independent invocations; preserve serial shared-database execution. Await both children before failing the parent. Prove disjoint coverage and failure propagation. - Update the paired Linux benchmark to compare a complete general-server shard with identical case inventories. - Document concurrency and deadline-test placement. No production behavior, workflow topology or runner count changes. - Allow 15 seconds for snapshot integration cases that cold-start multiple real TypeScript workers. On the repeat, one-worker cases took 2.5–3 seconds, so queued or sequential starts could exceed the default five seconds. Preserve all cleanup, queue-release and filesystem assertions; these cases do not enforce a production latency deadline. ## Verification All 48 jobs passed on scheduling head `5d718e18` in [attempt 1](https://github.com/paperclipai/paperclip/actions/runs/38763885288/attempts/1). The [bounded repeat](https://github.com/paperclipai/paperclip/actions/runs/38763885288/attempts/2) failed two existing snapshot-worker tests at their default five-second timeout. Its shorter duration is **not** counted as a successful performance sample. The latest head `3c894462` adds a targeted test-budget repair and [passes all 48 CI jobs](https://github.com/paperclipai/paperclip/actions/runs/38768433532) in **8m06s** (workflow creation to final required workflow job). This is **70 seconds / 12.6% shorter** than the 9m16s intermediate run, but the earlier 9m23s result remains part of the evidence: a stable average improvement is not established. Both branch-protection checks (`ci / verify` and `ci / e2e`) pass. The non-required Superagent Security Scan is still running; external PR scans are separate from this workflow duration. | Measurement | Before | After | What it establishes | | --- | ---: | ---: | --- | | General-server shard, paired on one Linux host | 430.94s | 292.99s | 32.0% faster; identical 1,047 case inventory | | Chat job 1 | 460s | 211s | Same full chat inventory spread across isolated partitions | | Chat job 2 | 408s | 274s | Serial cases within each child, separate PostgreSQL instances | | Chat job 3 | 408s | 216s | Total chat runner time fell from 1,276s to 701s | | Full PR workflow, first chat candidate | 9m16s | 9m23s | Not an end-to-end win in this pair: unchanged Build rose from 365s to 465s | | Full PR workflow, final repaired head | 9m16s | 8m06s | All 48 jobs pass; 70 seconds faster observed, not a controlled causal estimate | - Latest [paired benchmark](https://github.com/paperclipai/paperclip/actions/runs/38763885223) passes. Earlier paired server measurements were 33.3% and 37.4% faster. Unchanged fixture timing noise is not attributed to this PR. - All **1,337 chat case identities and outcomes match exactly** across [before-chat](https://github.com/paperclipai/paperclip/actions/runs/38760937988) and [after-chat](https://github.com/paperclipai/paperclip/actions/runs/38763885288/attempts/1), without duplicates or removals. Whole-suite records rose from 44,563 to 44,574: three new launcher checks and eight upstream warm-owner tests. Remaining name differences were generated fixture IDs/timestamps/PIDs, not removed tests. - The final run used `m7i-flex.xlarge` for Build (408s), with all chat jobs completing in 238s / 318s / 280s. It used the same actual merge base `afe5b341` as the first chat candidate. Optional profiling was disabled after the bounded measurement experiment; the full 48-job correctness workflow still ran. - The fleet used `m7i.xlarge` for the earlier Build and `m6a.xlarge` for the first chat candidate Build. Several unchanged compile/install/probe phases slowed together. This does not prove hardware alone caused the difference, and full-run attribution must account for runner/cache variance and the upstream base change. - Original full baseline: [all 48 jobs passed](https://github.com/paperclipai/paperclip/actions/runs/38753547876/attempts/2), 12m07s, including a server job that queued over five minutes. It is not a clean basis for attributing the whole elapsed-time reduction to this PR. An interrupted baseline and two misrouted candidate attempts were excluded and preserved; routing/trust rules were not weakened. - Local: 32 sharding/launcher checks pass, including disjoint coverage, shared-database fallback and a failed child that waits for its sibling. Real paired chat partitions passed 416 cases in 217.63s on independent embedded databases. - Both extracted Runner deadline regressions pass unchanged with real waits. An initial local attempt rejected a Node executable under the controller home; the identical binary in an owned non-home path passed without weakening that guard. - Real worker-isolation proof, actionlint and diff checks pass. Latest-head Greptile is **5/5**, with no review threads or actionable findings. - The eight snapshot-worker cases pass locally with a private `PAPERCLIP_HOME`. The first direct local invocation failed to open the default-home lock database under the sandbox; no production guard was changed. ## Risks Parallel files and paired chat databases may expose shared-state assumptions or increase peak memory. Cases stay serial within each invocation, and explicit shared databases keep the serial path. Complete server coverage and the full 48-job CI workflow pass. Check external required PR scans before merge; the full-workflow measurements are observational and do not establish a steady 12.6% speedup. Queueing, cache state and runner variance must be distinguished from execution improvements. ## Model Used OpenAI GPT-6 through Codex, with repository inspection, code execution and GitHub tools. The exact serving model suffix and context capacity are not exposed to this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green (48 jobs; both required branch-protection checks pass; non-required external security scan pending) - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
589 lines
30 KiB
JavaScript
589 lines
30 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
defaultSuiteWeight,
|
|
loadShardDurations,
|
|
partitionGeneralServerSuites,
|
|
} from "../general-server-shard.mjs";
|
|
|
|
import { assertSelectedTests, partitionTestLines } from "../test-line-shard.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
const script = path.join(repoRoot, "scripts", "run-vitest-stable.mjs");
|
|
const durationsManifest = path.join(repoRoot, "scripts", "general-server-shard-durations.json");
|
|
const serializedDurationsManifest = path.join(
|
|
repoRoot,
|
|
"scripts",
|
|
"serialized-shard-durations.json",
|
|
);
|
|
|
|
// Membership of general-server-without-chat depends on GITHUB_WORKFLOW (see
|
|
// prWorkflowName in run-vitest-stable.mjs), so strip the ambient value and
|
|
// make every test pin the caller it mirrors explicitly — these tests
|
|
// themselves run inside a workflow on CI.
|
|
function workflowEnv(envOverrides = {}) {
|
|
const env = { ...process.env, ...envOverrides };
|
|
if (!("GITHUB_WORKFLOW" in envOverrides)) {
|
|
delete env.GITHUB_WORKFLOW;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function dryRun(args, envOverrides = {}) {
|
|
const result = spawnSync(process.execPath, [script, ...args, "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: workflowEnv(envOverrides),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function dryRunJson(args, envOverrides = {}) {
|
|
const result = dryRun(args, envOverrides);
|
|
assert.equal(result.status, 0, `expected success for ${args.join(" ")}: ${result.stderr}`);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
const SHARD_COUNT = 12;
|
|
const SERIALIZED_SHARD_COUNT = 9;
|
|
|
|
test("the general-server shards form a complete, non-overlapping partition", () => {
|
|
const shards = Array.from({ length: SHARD_COUNT }, (_, index) =>
|
|
dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", String(index), "--shard-count", String(SHARD_COUNT)]),
|
|
);
|
|
|
|
const total = shards[0].generalServerSuiteCount;
|
|
assert.ok(total > 0, "expected a non-empty general-server suite set");
|
|
|
|
const seen = new Set();
|
|
let selectedTotal = 0;
|
|
for (const shard of shards) {
|
|
assert.equal(shard.generalServerSuiteCount, total, "suite count must be stable across shards");
|
|
for (const file of shard.selectedGeneralServerSuites) {
|
|
assert.ok(
|
|
!(file.startsWith("server/src/__tests__/") && /[^/]*(?:route|routes|authz)[^/]*\.test\.ts$/.test(file)),
|
|
`route/authz suite must stay in the serialized lane, not general-server: ${file}`,
|
|
);
|
|
assert.ok(!seen.has(file), `suite assigned to more than one shard: ${file}`);
|
|
seen.add(file);
|
|
selectedTotal += 1;
|
|
}
|
|
}
|
|
|
|
// Every suite runs exactly once: union covers the whole set with no overlap.
|
|
assert.equal(selectedTotal, total, "every suite must be selected exactly once");
|
|
assert.equal(seen.size, total, "union of shards must cover the whole suite set");
|
|
});
|
|
|
|
test("general shards retain scripts and route-named suites outside the serialized directory", () => {
|
|
const general = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
|
|
for (const file of [
|
|
"server/scripts/verify-runner-vendor-dependencies.test.mjs",
|
|
"server/src/services/openrouter-models.test.ts",
|
|
"server/src/routes/setup-token-route.test.ts",
|
|
]) assert.ok(general.selectedGeneralServerSuites.includes(file), `missing configured server suite: ${file}`);
|
|
});
|
|
|
|
for (const group of ["general-workspaces-b", "general-workspaces-c"]) {
|
|
test(`shard flags are rejected for the ${group} group`, () => {
|
|
const result = dryRun(["--mode", "general", "--group", group, "--shard-index", "0", "--shard-count", "3"]);
|
|
assert.notEqual(result.status, 0, `${group} must not accept shard flags`);
|
|
});
|
|
}
|
|
|
|
test("workspace lanes cover every non-server project in the root Vitest configuration", () => {
|
|
const config = readFileSync(path.join(repoRoot, "vitest.config.ts"), "utf8");
|
|
const roots = [...config.matchAll(/^\s+"([^"]+)",?\s*$/gm)].map(match => match[1]);
|
|
assert.ok(roots.includes("server"), "expected the explicit root Vitest project list");
|
|
const expected = roots.filter(root => root !== "server")
|
|
.map(root => JSON.parse(readFileSync(path.join(repoRoot, root, "package.json"), "utf8")).name).sort();
|
|
const actual = ["general-workspaces-a", "general-workspaces-b", "general-workspaces-c"]
|
|
.flatMap(group => dryRunJson(["--mode", "general", "--group", group]).workspaceProjects).sort();
|
|
assert.deepEqual(actual, expected, "no configured project may be silently omitted or run twice");
|
|
});
|
|
|
|
// Collects every `- group:` matrix entry in a workflow with the keys it sets.
|
|
// Comment and blank lines are dropped first, and an entry ends only when the
|
|
// indentation returns to the item level, so a comment between two keys cannot
|
|
// hide a shard setting from the assertions below.
|
|
function matrixEntries(text) {
|
|
const entries = [];
|
|
let current = null;
|
|
for (const line of text.split("\n")) {
|
|
if (/^\s*(#|$)/.test(line)) continue;
|
|
const indent = line.match(/^ */)[0].length;
|
|
const item = line.match(/^ *- group: (\S+)/);
|
|
if (item) {
|
|
current = { group: item[1], indent, keys: ["group"] };
|
|
entries.push(current);
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
if (indent <= current.indent) { current = null; continue; }
|
|
const key = line.match(/^ *([A-Za-z_]+):/);
|
|
if (key) current.keys.push(key[1]);
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
test("matrixEntries sees shard keys that sit behind comments or later in the entry", () => {
|
|
const entries = matrixEntries([
|
|
" include:",
|
|
" - group: general-workspaces-b",
|
|
" group_label: workspaces-b",
|
|
" # a comment between keys must not hide the shard setting",
|
|
" shard_index: 0",
|
|
"",
|
|
" shard_count: 3",
|
|
" - group: general-workspaces-c",
|
|
" group_label: workspaces-c",
|
|
" steps:",
|
|
" - name: unrelated",
|
|
" shard_index: 9",
|
|
].join("\n"));
|
|
assert.deepEqual(entries.map(entry => entry.group), ["general-workspaces-b", "general-workspaces-c"]);
|
|
assert.deepEqual(entries[0].keys, ["group", "group_label", "shard_index", "shard_count"]);
|
|
assert.deepEqual(entries[1].keys, ["group", "group_label"], "keys after the matrix must not leak into the last entry");
|
|
});
|
|
|
|
test("workspaces-c runs the db project alone and workspaces-b no longer carries it", () => {
|
|
// The db project was 168-250s of the 436-513s workspaces-b vitest time
|
|
// (PR runs 38084171761, 38083084276 and 38083020834, 2026-10-10), so it
|
|
// runs in its own lane. The PR and release matrices both carry that lane.
|
|
const workspacesC = dryRunJson(["--mode", "general", "--group", "general-workspaces-c"]);
|
|
assert.deepEqual(workspacesC.workspaceProjects, ["@paperclipai/db"]);
|
|
assert.equal(workspacesC.workspacesVitestShard, null);
|
|
const workspacesB = dryRunJson(["--mode", "general", "--group", "general-workspaces-b"]);
|
|
assert.ok(!workspacesB.workspaceProjects.includes("@paperclipai/db"), "db must not run twice");
|
|
assert.ok(workspacesB.workspaceProjects.length > 0, "workspaces-b must still run its other projects");
|
|
for (const workflow of ["pr-trusted.yml", "release-verify.yml"]) {
|
|
const entries = matrixEntries(readFileSync(path.join(repoRoot, ".github", "workflows", workflow), "utf8"));
|
|
// The parser must demonstrably see shard keys in this workflow, otherwise
|
|
// the unsharded assertions below could pass for the wrong reason.
|
|
const sharded = entries.filter(entry => entry.group === "general-workspaces-a");
|
|
assert.ok(sharded.length > 0 && sharded.every(entry => entry.keys.includes("shard_index") && entry.keys.includes("shard_count")),
|
|
`${workflow} must carry the sharded workspaces-a entries`);
|
|
for (const group of ["general-workspaces-b", "general-workspaces-c"]) {
|
|
const matching = entries.filter(entry => entry.group === group);
|
|
assert.equal(matching.length, 1, `${workflow} must run ${group} exactly once`);
|
|
assert.ok(matching[0].keys.includes("group_label"), `${workflow} must label the ${group} job`);
|
|
assert.deepEqual(matching[0].keys.filter(key => key.startsWith("shard_")), [], `${workflow} must run ${group} unsharded`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => {
|
|
const shards = [0, 1].map((index) =>
|
|
dryRunJson([
|
|
"--mode", "general", "--group", "general-workspaces-a",
|
|
"--shard-index", String(index), "--shard-count", "2",
|
|
]),
|
|
);
|
|
|
|
assert.deepEqual(
|
|
shards.map((shard) => shard.workspacesVitestShard),
|
|
["1/2", "2/2"],
|
|
"each matrix job must pass its own --shard slice to vitest",
|
|
);
|
|
// Vitest's --shard partitions each project's file list deterministically, so
|
|
// an identical project list across jobs is what guarantees complete,
|
|
// non-overlapping coverage of the lane.
|
|
assert.deepEqual(shards[0].workspaceProjects, shards[1].workspaceProjects);
|
|
assert.ok(shards[0].workspaceProjects.length > 0, "workspaces-a must run at least one project");
|
|
|
|
const unsharded = dryRunJson(["--mode", "general", "--group", "general-workspaces-a"]);
|
|
assert.deepEqual(
|
|
unsharded.workspaceProjects,
|
|
shards[0].workspaceProjects,
|
|
"sharding must not change which projects the lane covers",
|
|
);
|
|
assert.equal(unsharded.workspacesVitestShard, null);
|
|
});
|
|
|
|
test("duration-aware partition balances skewed weights better than round-robin", () => {
|
|
// Round-robin puts all three heavy suites on shard 0 (indexes 0, 3, 6).
|
|
const files = ["a", "b", "c", "d", "e", "f", "g", "h", "i"];
|
|
const durations = { a: 30000, d: 30000, g: 30000, b: 100, c: 100, e: 100, f: 100, h: 100, i: 100 };
|
|
|
|
const shards = partitionGeneralServerSuites(files, 3, durations);
|
|
const totals = shards.map((shard) => shard.totalWeight);
|
|
const maxTotal = Math.max(...totals);
|
|
const minTotal = Math.min(...totals);
|
|
assert.ok(
|
|
maxTotal - minTotal <= 200,
|
|
`expected near-even shard weights, got ${totals.join(", ")}`,
|
|
);
|
|
assert.equal(
|
|
shards.flatMap((shard) => shard.files).sort().join(","),
|
|
files.join(","),
|
|
"partition must cover every file exactly once",
|
|
);
|
|
});
|
|
|
|
test("the partition is deterministic for identical inputs", () => {
|
|
const files = Array.from({ length: 50 }, (_, index) => `suite-${index}.test.ts`);
|
|
const durations = Object.fromEntries(files.map((file, index) => [file, (index * 37) % 5000]));
|
|
|
|
const first = partitionGeneralServerSuites(files, 3, durations);
|
|
const second = partitionGeneralServerSuites(files, 3, durations);
|
|
assert.deepEqual(first, second, "same inputs must always produce the same partition");
|
|
});
|
|
|
|
test("suites missing from the manifest get the median weight", () => {
|
|
assert.equal(defaultSuiteWeight({ a: 100, b: 300, c: 900 }), 300);
|
|
assert.equal(defaultSuiteWeight({ a: 100, b: 300, c: 500, d: 900 }), 400);
|
|
assert.equal(defaultSuiteWeight({}), 1000, "empty manifest falls back to a fixed weight");
|
|
});
|
|
|
|
test("a missing or malformed manifest degrades to uniform weights", () => {
|
|
assert.deepEqual(loadShardDurations(path.join(repoRoot, "scripts", "no-such-manifest.json")), {});
|
|
|
|
const files = ["a", "b", "c", "d"];
|
|
const shards = partitionGeneralServerSuites(files, 2, {});
|
|
assert.equal(shards[0].files.length + shards[1].files.length, files.length);
|
|
assert.equal(Math.abs(shards[0].files.length - shards[1].files.length), 0);
|
|
});
|
|
|
|
test("the checked-in manifest loads and covers most of the current suite set", () => {
|
|
const durations = loadShardDurations(durationsManifest);
|
|
assert.ok(Object.keys(durations).length > 0, "manifest must parse to a non-empty duration map");
|
|
|
|
const shard = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"]);
|
|
const currentFiles = shard.selectedGeneralServerSuites;
|
|
const known = currentFiles.filter((file) => durations[file] !== undefined).length;
|
|
assert.ok(
|
|
known / currentFiles.length >= 0.5,
|
|
`manifest is stale: only ${known} of ${currentFiles.length} suites have recorded durations — regenerate it from a recent PR run (see the manifest's $comment)`,
|
|
);
|
|
});
|
|
|
|
test("the chat integration suite keeps a measured duration for duration-aware fallbacks", () => {
|
|
// The PR and release matrices both run the chat suite in dedicated
|
|
// line-sharded lanes, but the plain general-server group (local full runs)
|
|
// still weighs it into the LPT partition; a median-fallback weight there
|
|
// would silently overload whichever shard receives it.
|
|
const chatSuite = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const durations = loadShardDurations(durationsManifest);
|
|
assert.ok(
|
|
Number.isFinite(durations[chatSuite]),
|
|
"the full chat cohort must have a measured duration, not the median fallback",
|
|
);
|
|
});
|
|
|
|
test("the checked-in serialized manifest loads and covers most of the current suite set", () => {
|
|
const durations = loadShardDurations(serializedDurationsManifest);
|
|
assert.ok(Object.keys(durations).length > 0, "manifest must parse to a non-empty duration map");
|
|
|
|
const shard = dryRunJson(["--mode", "serialized", "--shard-index", "0", "--shard-count", "1"]);
|
|
const currentFiles = shard.selectedSerializedSuites;
|
|
const known = currentFiles.filter((file) => durations[file] !== undefined).length;
|
|
assert.ok(
|
|
known / currentFiles.length >= 0.5,
|
|
`manifest is stale: only ${known} of ${currentFiles.length} suites have recorded durations — regenerate it from a recent PR run (see the manifest's $comment)`,
|
|
);
|
|
});
|
|
|
|
test("the real serialized shard partition is complete, non-overlapping and duration-balanced", () => {
|
|
const durations = loadShardDurations(serializedDurationsManifest);
|
|
const fallback = defaultSuiteWeight(durations);
|
|
const shards = Array.from({ length: SERIALIZED_SHARD_COUNT }, (_, index) =>
|
|
dryRunJson(["--mode", "serialized", "--shard-index", String(index), "--shard-count", String(SERIALIZED_SHARD_COUNT)]),
|
|
);
|
|
|
|
const total = shards[0].serializedSuiteCount;
|
|
const selected = shards.flatMap((shard) => shard.selectedSerializedSuites);
|
|
assert.equal(selected.length, total, "every serialized suite must be selected exactly once");
|
|
assert.equal(new Set(selected).size, total, "serialized shards must not overlap");
|
|
|
|
const totals = shards.map((shard) =>
|
|
shard.selectedSerializedSuites.reduce((sum, file) => sum + (durations[file] ?? fallback), 0),
|
|
);
|
|
const maxTotal = Math.max(...totals);
|
|
const minTotal = Math.min(...totals);
|
|
// LPT keeps the spread within the heaviest single suite; use that as the bound.
|
|
const heaviest = Math.max(...Object.values(durations));
|
|
assert.ok(
|
|
maxTotal - minTotal <= heaviest,
|
|
`serialized shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
|
|
);
|
|
});
|
|
|
|
const chatSuitePath = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const nativeRunnerSuitePath =
|
|
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
|
const dotRunnerSuitePath = "server/src/__tests__/dot-runner.test.ts";
|
|
const restartRecoverySuitePath =
|
|
"server/src/services/native-runtime/native-runner-restart-recovery.integration.test.ts";
|
|
|
|
// Mirrors pr-trusted.yml (12 shards, called by pr.yml so GITHUB_WORKFLOW is
|
|
// "PR"): the chat suite runs in its dedicated lanes and the cargo-dependent
|
|
// native-runner suite in the Rust-cached final Verify Paperclip Runner vitest
|
|
// shard, so together the three cover the full server group exactly.
|
|
test("12 PR without-chat shards plus the dedicated chat and native-runner lanes cover the original server group exactly", () => {
|
|
const durations = loadShardDurations(durationsManifest);
|
|
const fallback = defaultSuiteWeight(durations);
|
|
const prEnv = { GITHUB_WORKFLOW: "PR" };
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], prEnv);
|
|
const shards = Array.from({ length: 12 }, (_, index) => dryRunJson([
|
|
"--mode", "general", "--group", "general-server-without-chat",
|
|
"--shard-index", String(index), "--shard-count", "12",
|
|
], prEnv));
|
|
const totals = shards.map((shard) =>
|
|
shard.selectedGeneralServerSuites.reduce((sum, file) => sum + (durations[file] ?? fallback), 0),
|
|
);
|
|
const maxTotal = Math.max(...totals);
|
|
const minTotal = Math.min(...totals);
|
|
// LPT keeps the spread within the heaviest single suite; use that as the
|
|
// bound. The chat and native-runner suites run in their own lanes, so
|
|
// exclude them here.
|
|
const chat = "server/src/__tests__/chat-channels.integration.test.ts";
|
|
const nativeRunner =
|
|
"server/src/services/native-runtime/native-codex-runner.integration.test.ts";
|
|
const heaviest = Math.max(
|
|
...Object.entries(durations)
|
|
.filter(([file]) => file !== chat && file !== nativeRunner)
|
|
.map(([, ms]) => ms),
|
|
);
|
|
assert.ok(
|
|
maxTotal - minTotal <= heaviest,
|
|
`shard weight spread ${maxTotal - minTotal}ms exceeds heaviest suite ${heaviest}ms: ${totals.join(", ")}`,
|
|
);
|
|
|
|
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
|
assert.ok(!files.includes(chatSuitePath));
|
|
assert.ok(!files.includes(nativeRunnerSuitePath));
|
|
assert.ok(!files.includes(dotRunnerSuitePath));
|
|
assert.ok(!files.includes(restartRecoverySuitePath));
|
|
assert.deepEqual([...files, chatSuitePath, nativeRunnerSuitePath, dotRunnerSuitePath, restartRecoverySuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
|
assert.equal(new Set(files).size, files.length);
|
|
const defaultRun = dryRunJson([], prEnv);
|
|
assert.ok(defaultRun.generalServerSuiteCount === full.generalServerSuiteCount);
|
|
});
|
|
|
|
// The legacy group and local default must remain complete when a caller has
|
|
// not selected a separate native lane.
|
|
for (const [caller, envOverrides] of [["Release", { GITHUB_WORKFLOW: "Release" }], ["no ambient workflow", {}]]) {
|
|
test(`10 without-chat shards under ${caller} keep the native-runner suite and cover the server group with chat alone`, () => {
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], envOverrides);
|
|
const shards = Array.from({ length: 10 }, (_, index) => dryRunJson([
|
|
"--mode", "general", "--group", "general-server-without-chat",
|
|
"--shard-index", String(index), "--shard-count", "10",
|
|
], envOverrides));
|
|
const files = shards.flatMap((shard) => shard.selectedGeneralServerSuites);
|
|
assert.ok(!files.includes(chatSuitePath));
|
|
assert.ok(files.includes(nativeRunnerSuitePath));
|
|
assert.ok(files.includes(dotRunnerSuitePath));
|
|
assert.ok(files.includes(restartRecoverySuitePath));
|
|
assert.deepEqual([...files, chatSuitePath].sort(), full.selectedGeneralServerSuites.sort());
|
|
assert.equal(new Set(files).size, files.length);
|
|
});
|
|
}
|
|
|
|
// Release verification selects its dedicated lane explicitly. Do not infer it
|
|
// from the caller name: previews and future workflow callers need the same cover.
|
|
for (const caller of ["Release", "Cloud readiness", "another caller"]) {
|
|
test(`release server, chat, and native lanes cover every server suite once under ${caller}`, () => {
|
|
const env = { GITHUB_WORKFLOW: caller };
|
|
const full = dryRunJson(["--mode", "general", "--group", "general-server", "--shard-index", "0", "--shard-count", "1"], env);
|
|
const files = Array.from({ length: 10 }, (_, index) => dryRunJson([
|
|
"--mode", "general", "--group", "general-server-without-chat-or-native-runner",
|
|
"--shard-index", String(index), "--shard-count", "10",
|
|
], env)).flatMap((shard) => shard.selectedGeneralServerSuites);
|
|
const native = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"], env);
|
|
const combined = [...files, chatSuitePath, ...native.selectedGeneralServerSuites];
|
|
assert.equal(new Set(combined).size, combined.length, "lanes must not overlap");
|
|
assert.deepEqual(combined.sort(), full.selectedGeneralServerSuites.sort());
|
|
});
|
|
}
|
|
|
|
test("the native-runner lane runs exactly the cargo-dependent suites", () => {
|
|
const lane = dryRunJson(["--mode", "general", "--group", "general-server-native-runner"]);
|
|
assert.deepEqual(lane.selectedGeneralServerSuites, [
|
|
"server/src/services/native-runtime/native-codex-runner.integration.test.ts",
|
|
dotRunnerSuitePath,
|
|
restartRecoverySuitePath,
|
|
]);
|
|
});
|
|
|
|
test("shard flags are rejected for the native-runner group", () => {
|
|
const result = dryRun(["--mode", "general", "--group", "general-server-native-runner", "--shard-index", "0", "--shard-count", "2"]);
|
|
assert.notEqual(result.status, 0, "the native-runner lane is a single suite and must not accept shard flags");
|
|
});
|
|
|
|
// The PR-side exclusion above is safe only while the wiring it assumes holds:
|
|
// pr.yml (the caller whose name reusable pr-trusted.yml jobs see as
|
|
// GITHUB_WORKFLOW) is named PR, the sharded vitest lanes partition cleanly
|
|
// with exactly one final shard, and that lane's package script routes through
|
|
// the wrapper that runs the native-runner group.
|
|
test("the PR workflow wiring for the native-runner lane holds", () => {
|
|
const prWorkflow = readFileSync(path.join(repoRoot, ".github/workflows/pr.yml"), "utf8");
|
|
assert.match(prWorkflow, /^name: PR$/m,
|
|
"renaming pr.yml silently moves the native-runner suite back into the uncached server shards");
|
|
|
|
const trustedWorkflow = readFileSync(path.join(repoRoot, ".github/workflows/pr-trusted.yml"), "utf8");
|
|
const lanes = [...trustedWorkflow.matchAll(/command: test:typescript:vitest --shard=(\d+)\/(\d+)/g)]
|
|
.map((match) => [Number(match[1]), Number(match[2])]);
|
|
assert.ok(lanes.length > 0, "expected sharded test:typescript:vitest lanes in pr-trusted.yml");
|
|
assert.equal(new Set(lanes.map(([, count]) => count)).size, 1, "vitest lanes must agree on the shard count");
|
|
const shardCount = lanes[0][1];
|
|
assert.deepEqual(
|
|
lanes.map(([index]) => index).sort((left, right) => left - right),
|
|
Array.from({ length: shardCount }, (_, index) => index + 1),
|
|
"vitest lanes must cover every shard exactly once",
|
|
);
|
|
assert.equal(lanes.filter(([index, count]) => index === count).length, 1,
|
|
"exactly one final vitest shard carries the native-runner group");
|
|
|
|
const runnerPackage = JSON.parse(
|
|
readFileSync(path.join(repoRoot, "packages/paperclip-runner/package.json"), "utf8"),
|
|
);
|
|
assert.equal(runnerPackage.scripts["test:typescript:vitest"], "node ./scripts/run-pr-vitest-lane.mjs");
|
|
});
|
|
|
|
const laneWrapper = path.join(repoRoot, "packages/paperclip-runner/scripts/run-pr-vitest-lane.mjs");
|
|
|
|
function wrapperPlan(args, envOverrides = {}) {
|
|
const result = spawnSync(process.execPath, [laneWrapper, ...args, "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: workflowEnv(envOverrides),
|
|
});
|
|
assert.equal(result.status, 0, `expected wrapper dry run to succeed for ${args.join(" ")}: ${result.stderr}`);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
test("the PR vitest lane wrapper runs the native-runner group exactly on the final PR shard", () => {
|
|
for (const [args, envOverrides, expected] of [
|
|
[["--shard=1/2"], { GITHUB_WORKFLOW: "PR" }, false],
|
|
[["--shard=2/2"], { GITHUB_WORKFLOW: "PR" }, true],
|
|
[[], { GITHUB_WORKFLOW: "PR" }, true],
|
|
[["--shard=2/2"], { GITHUB_WORKFLOW: "Release" }, false],
|
|
[["--shard=2/2"], {}, false],
|
|
[[], {}, false],
|
|
]) {
|
|
const plan = wrapperPlan(args, envOverrides);
|
|
assert.equal(plan.runNativeRunnerGroup, expected,
|
|
`args ${JSON.stringify(args)} env ${JSON.stringify(envOverrides)}`);
|
|
const commands = plan.plannedCommands.map((planned) => planned.args.join(" "));
|
|
assert.ok(commands.some((command) => command.startsWith(`exec vitest run${args.length ? ` ${args.join(" ")}` : ""}`)),
|
|
"the wrapper must pass shard flags through to vitest");
|
|
assert.equal(
|
|
commands.some((command) => command.includes("--group general-server-native-runner")),
|
|
expected,
|
|
);
|
|
}
|
|
|
|
const malformed = spawnSync(process.execPath, [laneWrapper, "--shard=nonsense", "--dry-run"], {
|
|
cwd: repoRoot,
|
|
encoding: "utf8",
|
|
env: workflowEnv({ GITHUB_WORKFLOW: "PR" }),
|
|
});
|
|
assert.notEqual(malformed.status, 0, "a malformed shard flag must fail rather than guess a lane");
|
|
});
|
|
|
|
const lineShardFile = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts");
|
|
const caseAt = (line, name) => ({ name, file: lineShardFile, projectName: "@paperclipai/server", location: { line, column: 3 } });
|
|
|
|
test("test-line shards cover nested and parameterized cases exactly once without splitting a source line", () => {
|
|
const cases = [caseAt(10, "suite > nested > first"), caseAt(10, "suite > nested > second"),
|
|
caseAt(20, "same name"), caseAt(30, "same name"), caseAt(40, "last"), caseAt(50, "new case")];
|
|
const shards = partitionTestLines(cases, 3, lineShardFile);
|
|
assert.deepEqual(shards.map((shard) => shard.tests.length), [2, 2, 2]);
|
|
assert.equal(shards.filter((shard) => shard.lines.includes(10)).length, 1);
|
|
assert.equal(shards.find((shard) => shard.lines.includes(10)).tests.length, 2);
|
|
assert.equal(shards.flatMap((shard) => shard.lines).length, 5);
|
|
assert.deepEqual(shards.flatMap((shard) => shard.tests).sort((a, b) => a.location.line - b.location.line), cases);
|
|
assert.deepEqual(partitionTestLines([...cases].reverse(), 3, lineShardFile).map((shard) => shard.lines), shards.map((shard) => shard.lines));
|
|
});
|
|
|
|
test("line-shard collection rejects empty, foreign, or unlocated tests and invalid shard counts", () => {
|
|
const good = caseAt(10, "valid");
|
|
for (const input of [[], null, [{ ...good, file: "/another.test.ts" }], [{ ...good, projectName: "wrong" }],
|
|
[{ ...good, location: undefined }], [{ ...good, location: { line: 0 } }], [{ ...good, name: "" }]]) {
|
|
assert.throws(() => partitionTestLines(input, 1, lineShardFile));
|
|
}
|
|
for (const count of [0, -1, 1.5, Infinity, 2]) assert.throws(() => partitionTestLines([good], count, lineShardFile));
|
|
});
|
|
|
|
test("filtered collection must match the exact assigned case identities, including duplicates", () => {
|
|
const expected = [caseAt(10, "same"), caseAt(10, "same"), caseAt(20, "nested > case")];
|
|
assertSelectedTests(expected, [...expected].reverse(), lineShardFile);
|
|
for (const actual of [expected.slice(1), [...expected, caseAt(30, "extra")],
|
|
[expected[0], expected[1], caseAt(20, "renamed")],
|
|
[expected[0], expected[1], caseAt(21, "nested > case")]]) {
|
|
assert.throws(() => assertSelectedTests(expected, actual, lineShardFile));
|
|
}
|
|
});
|
|
|
|
|
|
test("parallel chat jobs form a complete disjoint partition", () => {
|
|
for (const count of [2, 3, 4]) {
|
|
const shards = Array.from({ length: count }, (_, index) =>
|
|
dryRunJson(["--mode", "general", "--group", "general-chat", "--shard-index", String(index), "--shard-count", String(count)],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "" }).chatVitestShards,
|
|
);
|
|
assert.ok(shards.every((workers) => workers.length === 2));
|
|
assert.deepEqual(shards.flat().map(({ index }) => index).sort((a, b) => a - b),
|
|
Array.from({ length: count * 2 }, (_, index) => index));
|
|
assert.ok(shards.flat().every((shard) => shard.count === count * 2));
|
|
}
|
|
});
|
|
|
|
test("shared-database and unsharded chat runs retain one serial partition", () => {
|
|
for (let index = 0; index < 3; index++) {
|
|
const result = dryRunJson(["--mode", "general", "--group", "general-chat", "--shard-index", String(index), "--shard-count", "3"],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "postgresql://shared/test" });
|
|
assert.deepEqual(result.chatVitestShards, [{ index, count: 3 }]);
|
|
}
|
|
assert.deepEqual(dryRunJson(["--mode", "general", "--group", "general-chat"],
|
|
{ PAPERCLIP_TEST_DATABASE_URL: "" }).chatVitestShards, [{ index: 0, count: 1 }]);
|
|
});
|
|
|
|
|
|
test("a failed chat child fails the job after its sibling finishes", { skip: process.platform === "win32" }, () => {
|
|
const directory = mkdtempSync(path.join(os.tmpdir(), "chat-launch-proof-"));
|
|
const file = path.join(repoRoot, "server/src/__tests__/chat-channels.integration.test.ts");
|
|
const lines = readFileSync(file, "utf8").split("\n")
|
|
.flatMap((line, index) => /^\s*it\(/.test(line) ? [index + 1] : []).slice(0, 6);
|
|
assert.equal(lines.length, 6);
|
|
const tests = lines.map((line) => ({ file, projectName: "@paperclipai/server",
|
|
name: `launcher fixture ${line}`, location: { line } }));
|
|
const receipt = path.join(directory, "sibling-finished");
|
|
const fakePnpm = path.join(directory, "pnpm");
|
|
try {
|
|
writeFileSync(fakePnpm, `#!${process.execPath}
|
|
const fs = require('node:fs');
|
|
const args = process.argv.slice(2);
|
|
const tests = ${JSON.stringify(tests)};
|
|
const selected = args.flatMap(arg => {
|
|
const match = arg.match(/chat-channels\\.integration\\.test\\.ts:(\\d+)$/);
|
|
return match ? [Number(match[1])] : [];
|
|
});
|
|
if (args.includes('list')) {
|
|
const output = args.find(arg => arg.startsWith('--json=')).slice(7);
|
|
fs.writeFileSync(output, JSON.stringify(selected.length
|
|
? tests.filter(test => selected.includes(test.location.line)) : tests));
|
|
} else if (selected.includes(${lines[0]})) process.exit(7);
|
|
else setTimeout(() => fs.writeFileSync(${JSON.stringify(receipt)}, 'finished'), 200);
|
|
`);
|
|
chmodSync(fakePnpm, 0o755);
|
|
const result = spawnSync(process.execPath, [script, "--mode", "general", "--group", "general-chat",
|
|
"--shard-index", "0", "--shard-count", "3"], {
|
|
cwd: repoRoot, encoding: "utf8", timeout: 30_000,
|
|
env: { ...process.env, PATH: `${directory}${path.delimiter}${process.env.PATH}`,
|
|
PAPERCLIP_TEST_DATABASE_URL: "", PAPERCLIP_CI_PROFILE: "false" },
|
|
});
|
|
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
assert.match(result.stderr, /failed \(7\)/);
|
|
assert.equal(readFileSync(receipt, "utf8"), "finished", "a failed worker must not abandon its sibling");
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|