Files
PaperClipAI/scripts/ci/pr-run-durations.mjs
T
DottaandClaude Opus 5.5 bed5f70278 perf(ci): rebalance server test shards and repair the weekly weight refresh (#21016)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A Core image is published only after the release verification of the
same commit passes (`cloud-readiness.yml` calls `release-verify.yml`)
> - The image is ready about 3 minutes before that gate opens, so the
slowest verification job sets the staging release time
> - The slowest job is a general-server test shard: 11.8-12.7 min, while
other server shards take 8-9 min
> - The shards are balanced by a duration manifest, but the manifest has
not changed since 2026-10-31. The weekly refresh that updates it failed
on 2026-11-02 and 2026-11-09
> - About 40 newer suites per shard use the 1 s median fallback weight,
but several of them run for 90-200 s, so they pile up on one shard
> - This pull request repairs the weekly refresh and refreshes the
server weights from three current master verifications
> - The benefit is a shorter slowest server shard (predicted 10.3 -> 8.8
min of test time) on every master verification and every PR run

## Linked Issues or Issue Description

**What happened?**
The Core image publication waits about 13.5 min for source verification.
The critical path is one general-server shard. In three master
verifications (runs 43133035381, 43134251278, 43145252393) the slowest
server shard took 12.1, 11.0 and 12.7 min. The fastest took 8.0, 8.9 and
8.4 min.

**Root cause**
`scripts/general-server-shard-durations.json` came from one run on
2026-10-31. The weekly `ci-test-durations.yml` refresh failed twice:
- 2026-11-02 (run 41408033686): `gh api .../jobs/<id>/logs` failed. A
server shard log is 30-65 MB. One download took 59 s locally. Three
parallel downloads overran the 120 s `execFile` timeout.
- 2026-11-09 (run 42580964686): the refresh's own check failed.
Merged-PR sources for the Runner lane have a leading `pull` key, and the
test accepted only `run, attempt, sha, url`.

**Expected behavior**
The weekly refresh finishes and keeps the shards balanced as suites are
added.

## What Changed

- `scripts/ci/gh-api.mjs`: job-log downloads get a 15 min timeout and a
512 MB buffer. JSON calls keep 120 s. A failed call now gives a short
error and does not print the partial 60 MB log.
- `scripts/ci/pr-run-durations.mjs`: new shared `assertManifestSource`.
It accepts the optional leading `pull` key that `collectMergedPrSamples`
writes. The checked-in Runner manifest test and the collector test both
use it, so the collector format stays covered.
- `.github/workflows/ci-test-durations.yml`: refresh job timeout 20 ->
45 min (the server lanes alone fetch about 30 large logs).
- `scripts/general-server-shard-durations.json` and
`scripts/serialized-shard-durations.json`: refreshed with `node
scripts/ci/refresh-test-durations.mjs --lanes=server 43133035381
43134251278 43145252393` (median of three successful protected-master
Cloud readiness runs; 1757/1815 general files and 173/173 serialized
files measured).
- No test is added, removed, skipped or reassigned to a different lane.
No shard count changes. `docker.yml`, the Dockerfile and the PID-1 check
are not touched.

## Verification

Measured job timings (minutes from the first job start; three master
verifications):

| Job | 43133035381 | 43134251278 | 43145252393 |
| --- | --- | --- | --- |
| Slowest server shard (duration) | 7/10: 12.1 | 6/10: 11.0 | 8/10: 12.7
|
| Fastest server shard | 2/10: 8.0 | 2/10: 8.9 | 5/10: 8.4 |
| Runner chaos evals (restart/replay) | 10.2 | 11.9 | 11.0 |
| Verify Paperclip Runner (protocol) | 8.9 | 8.9 | 9.6 |
| Chat (slowest of 3) | 8.8 | 10.5 | 9.4 |
| Serialized (slowest of 5) | 9.3 | 9.5 | 9.2 |
| Build / Typecheck | 4.5 / 2.4 | 4.5 / 3.1 | 4.8 / 2.5 |
| Gate "Cloud source verified" ends | +13.5 | +13.2 | +13.9 |

- Queue to runner start is 0.5-1.1 min for every job. In a server shard,
setup (checkout, pnpm, Node, install) is about 50 s and the preflight
plus plugin-sdk build is about 8 s. The test step is the rest (9-11.8
min). Setup is not worth caching.
- Shard prediction, using the refreshed medians as the cost of each file
and the 3 fork workers per shard:
- general-server, 10 shards: old weights max 10.3 / min 8.1 min; new
weights 8.8 / 8.8 min.
- serialized, 5 shards: old weights max 8.2 / min 4.6 min; new weights
6.6 / 6.6 min.
- The old-weight prediction matches what CI saw: predicted 10.3 and 9.9
min for shards 7/10 and 6/10, measured test steps 11.25 and 10.1 min in
run 43133035381.
- Red/green:
- `node --test --test-name-pattern="gh api"
scripts/ci/test-selection-pr-timings.test.mjs` failed before the fix
(`log timeout 120000`) and passes after it.
- With a `pull` key in the Runner manifest sources, the old "package
lane manifest" test failed with the same assertion as run 42580964686.
The new test passes.
- The refresh script, which died before the fix, completed locally in 8
min after it.
- `node --test scripts/ci/test-selection-timings.test.mjs
scripts/ci/test-selection-pr-timings.test.mjs
scripts/__tests__/run-vitest-stable-shard.test.mjs
scripts/__tests__/e2e-shard.test.mjs`: 76/76 pass. This includes the
complete, non-overlapping shard cover checks.
- `actionlint .github/workflows/ci-test-durations.yml`: clean.
- Not run: the release-verify workflow itself, the scheduled refresh
workflow, and the Runner/e2e lane refresh. The shard times above are
predictions from logged per-file timings, not a measured CI run on this
branch.

## Risks

- Low risk. Weights only move files between shards of the same lane. The
shard tests prove every file still runs exactly once.
- The predicted gain is about 1.5 min on the slowest server shard
(12-12.7 -> about 10.5 min job time). The gate then waits for
"Pre-release Runner chaos evals / Restart, replay, trace, and recovery
faults" (10.2-11.9 min). So the expected gate saving is about 1 min
(13.5 -> about 12.5 min), not the full 1.5 min. Run-to-run noise on a
shard is about ±1 min.
- A larger log timeout can make a stuck download wait up to 15 min. The
45 min job timeout still bounds the run.

Candidates for the owner (not changed here):
- The chaos-eval job is now the bound. Its "server finalization" step
takes 7-9 min. Decide if it must gate image publication, or split it.
- Server shard logs are up to 811k lines, mostly repeated stack traces
from `server/src/services/execution-preparation-timing.ts`. That slows
log upload and this refresh.
- 9 test files run over 180 s (largest:
`chat-channels.integration.test.ts` 429 s,
`heartbeat-process-recovery.test.ts` 344 s). One file cannot be split
across shards.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use
(shell, GitHub CLI read-only). Standard reasoning mode.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge

Related: #19650 (earlier repair of the weekly refresh), #21013 (Core
image publish time; separate files).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 05:29:27 -06:00

258 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import path from "node:path";
import { stripVTControlCharacters } from "node:util";
// e2e and the Runner package lane never run on master. Their weights come from
// PR-workflow runs of PRs that merged into master, pinned to the final head SHA.
// Logs are parsed as data; nothing from a run is executed.
export const repository = "paperclipai/paperclip";
export const repositoryId = 1170821064;
const api = `/repos/${repository}`;
export const runnerPackageRoot = "packages/paperclip-runner";
export const e2eManifest = "scripts/e2e-shard-durations.json";
export const runnerManifest = `${runnerPackageRoot}/scripts/vitest-lane-durations.json`;
// The source record shape every timing manifest may carry. Protected-master
// sources have run, attempt, sha and url; merged-PR sources (written by
// collectMergedPrSamples below) add a leading pull number. Rejecting that key
// failed the 2026-11-09 weekly refresh and froze the shard weights.
export function assertManifestSource(source) {
const keys = Object.keys(source);
assert.deepEqual(keys[0] === "pull" ? keys.slice(1) : keys, ["run", "attempt", "sha", "url"]);
if (keys[0] === "pull") assert.ok(Number.isSafeInteger(source.pull) && source.pull > 0);
assert.ok(Number.isSafeInteger(source.run) && source.run > 0);
assert.ok(Number.isSafeInteger(source.attempt) && source.attempt > 0);
assert.match(source.sha, /^[a-f0-9]{40}$/);
assert.equal(typeof source.url, "string");
}
export function validatePull(pull) {
assert.equal(pull.base?.ref, "master");
assert.equal(pull.base?.repo?.id, repositoryId);
assert.ok(pull.merged_at, `PR #${pull.number} is not merged`);
assert.match(pull.head?.sha ?? "", /^[a-f0-9]{40}$/);
assert.match(pull.merge_commit_sha ?? "", /^[a-f0-9]{40}$/);
}
export function validatePrRun(run, pull) {
assert.equal(run.repository?.full_name, repository);
assert.equal(run.repository?.id, repositoryId);
assert.equal(run.event, "pull_request");
assert.equal(run.path, ".github/workflows/pr.yml");
assert.equal(run.status, "completed");
assert.equal(run.conclusion, "success");
// A run of an earlier push measures code that never merged.
assert.equal(run.head_sha, pull.head.sha);
assert.ok(Number.isSafeInteger(run.id) && run.id > 0);
assert.ok(Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0);
}
export function e2eShardJob(name) {
const match = /^ci \/ e2e shard \((\d+)\/(\d+)\)$/.exec(name);
return match ? { index: Number(match[1]), count: Number(match[2]) } : null;
}
// Before #19646: "ci / Verify Paperclip Runner (vitest package)", one lane.
// Since #19646: "ci / Verify Paperclip Runner (vitest package (K/N))".
export function runnerPackageJob(name) {
if (name === "ci / Verify Paperclip Runner (vitest package)") return { index: 1, count: 1 };
const match = /^ci \/ Verify Paperclip Runner \((?:vitest )?package \((\d+)\/(\d+)\)\)$/.exec(name);
return match ? { index: Number(match[1]), count: Number(match[2]) } : null;
}
// Returns the jobs only when they form one complete, successful set of shards.
export function completeShardSet(jobs, classify) {
const selected = jobs.map(job => ({ job, shard: classify(job.name) })).filter(item => item.shard);
if (!selected.length) return null;
const count = selected[0].shard.count;
const indexes = new Set(selected.map(item => item.shard.index));
if (selected.some(item => item.shard.count !== count || item.job.conclusion !== "success")) return null;
if (indexes.size !== selected.length || indexes.size !== count) return null;
for (let index = 1; index <= count; index++) if (!indexes.has(index)) return null;
return selected.map(item => item.job);
}
function splitLogLine(raw) {
const line = stripVTControlCharacters(raw).replace(/\r$/, "");
const match = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.(\d+))?Z ?(.*)$/.exec(line);
if (!match) return { time: null, text: line };
const fraction = Number(`0.${match[2] ?? "0"}`);
return { time: Date.parse(`${match[1]}Z`) + fraction * 1000, text: match[3] };
}
// A location outside a spec (e.g. smoke-lab.shared.ts) belongs to the shard's
// spec named after it. Several candidates are told apart by the suffix that the
// describe title repeats, e.g. "(P1-P4)" for smoke-lab-p1-p4.spec.ts.
export function attributeSpec(location, title, shardSpecs) {
if (location.endsWith(".spec.ts")) {
assert.ok(!shardSpecs || shardSpecs.includes(location), `${location} is not in this shard`);
return location;
}
assert.ok(shardSpecs, `No shard spec list to attribute ${location}`);
const directory = path.posix.dirname(location);
const stem = path.posix.basename(location).replace(/(\.shared)?\.[cm]?[jt]sx?$/, "");
const candidates = shardSpecs.filter(spec => path.posix.dirname(spec) === directory
&& path.posix.basename(spec).startsWith(`${stem}-`));
if (candidates.length === 1) return candidates[0];
const lower = title.toLowerCase();
const named = candidates.filter(spec => lower.includes(path.posix.basename(spec).slice(stem.length + 1, -".spec.ts".length)));
assert.equal(named.length, 1, `Cannot attribute ${location} (${title}) to one spec`);
return named[0];
}
// The e2e lane runs one Playwright worker, so each gap between consecutive
// result lines is the finishing test's cost including fixtures and navigation.
// The first gap starts when Playwright reports "Running N tests".
export function parseE2eLog(log, inventory) {
let shardSpecs = null, start = null, previous = null, expected = null, seen = 0;
const totals = new Map();
for (const raw of log.split("\n")) {
const { time, text } = splitLogLine(raw);
if (time === null || text.startsWith("[WebServer]")) continue;
const specs = /^shard \d+\/\d+ specs: (.+)$/.exec(text);
if (specs) {
shardSpecs = specs[1].trim().split(/\s+/);
continue;
}
const running = /^Running (\d+) tests? using (\d+) workers?\b/.exec(text);
if (running) {
assert.equal(start, null, "Expected one Playwright run per job");
assert.equal(Number(running[2]), 1, "Gap attribution requires a single Playwright worker");
expected = Number(running[1]);
start = previous = time;
continue;
}
if (start === null) continue;
const result = /^\s*\S+\s+\d+\s+\[[^\]]+\]\s+›\s+(tests\/e2e\/[^\s:]+):\d+:\d+\s+›\s+(.*)$/.exec(text);
if (!result) continue;
assert.ok(!result[1].split("/").some(part => part === ".." || part === "."), result[1]);
seen++;
const spec = attributeSpec(result[1], result[2], shardSpecs);
const gap = Math.max(0, time - previous);
previous = time;
totals.set(spec, (totals.get(spec) ?? 0) + gap);
}
assert.notEqual(start, null, "No Playwright run found");
assert.equal(seen, expected, `Parsed ${seen} of ${expected} Playwright results`);
const parsed = new Map();
for (const [spec, ms] of totals) {
assert.ok(ms <= 3_600_000, `${spec}: implausible ${ms}ms`);
// Deleted or renamed specs still appear in older trusted runs.
if (inventory.has(spec)) parsed.set(spec, { ms: Math.round(ms), complete: true });
}
return parsed;
}
// Vitest prints one line per file, relative to the package:
// ✓ src/live/runnerd-codex-transport.test.ts (257 tests) 126679ms
// The value covers tests and hooks, not collection or setup.
export function parseRunnerLog(log, inventory) {
const parsed = new Map();
let expected = 0, seen = 0, summaries = 0;
for (const raw of log.split("\n")) {
const { text } = splitLogLine(raw);
const summary = /^\s*Test Files\s+(?:\d+ failed \| )?(\d+) passed\b/.exec(text);
if (summary) {
summaries++;
expected += Number(summary[1]);
continue;
}
const match = /^\s*✓\s+(\S+\.test\.[cm]?[jt]sx?)\s+\(\d+ tests?[^)]*\)\s+(\d+)ms\s*$/.exec(text);
if (!match) continue;
assert.ok(!match[1].split("/").some(part => part === ".." || part === "." || part === ""), match[1]);
assert.ok(!match[1].includes("\\"));
seen++;
const file = `${runnerPackageRoot}/${match[1]}`, ms = Number(match[2]);
assert.ok(ms <= 3_600_000, `${file}: implausible ${ms}ms`);
if (inventory.has(file)) parsed.set(file, { ms: Math.max(parsed.get(file)?.ms ?? 0, ms), complete: false });
}
assert.ok(summaries > 0, "No Vitest summary found");
assert.equal(seen, expected, `Parsed ${seen} of ${expected} passed Vitest files`);
return parsed;
}
// The Runner include globs, read as text so the refresh needs no installed
// dependencies. Tests assert the list stays non-empty and matches real files.
export function runnerIncludeGlobs(repoRoot = process.cwd()) {
const config = readFileSync(path.join(repoRoot, runnerPackageRoot, "vitest.config.ts"), "utf8");
const block = /\binclude:\s*\[([\s\S]*?)\]/.exec(config);
assert.ok(block, "Runner vitest.config.ts has no include list");
const globs = [...block[1].matchAll(/"([^"]+)"/g)].map(match => match[1]);
assert.ok(globs.length > 0, "Runner include list is empty");
return globs;
}
export function runnerInventory(repoRoot = process.cwd()) {
const globs = runnerIncludeGlobs(repoRoot);
const tracked = execFileSync("git", ["ls-files", "-z", "--", runnerPackageRoot], { cwd: repoRoot, encoding: "utf8" })
.split("\0").filter(Boolean);
return new Set(tracked.filter(file => {
const relative = file.slice(runnerPackageRoot.length + 1);
return globs.some(glob => path.matchesGlob(relative, glob));
}));
}
export function isMergedIntoHead(sha, repoRoot = process.cwd()) {
try {
execFileSync("git", ["merge-base", "--is-ancestor", sha, "HEAD"], { cwd: repoRoot, stdio: "ignore" });
return true;
} catch {
return false;
}
}
function keepLargest(target, parsed) {
for (const [file, value] of parsed) {
const previous = target.get(file);
if (!previous || value.ms > previous.ms) target.set(file, value);
}
}
// lanes: [{ name, classify(jobName) -> {index,count}|null, parse(log, inventory), inventory }]
// Returns { [lane]: { samples: Map[], sources: [] } } with at most maxRuns samples each.
export async function collectMergedPrSamples({ gh, lanes, maxRuns = 3, pullLimit = 40,
isMerged = isMergedIntoHead, log = console.log } = {}) {
const result = Object.fromEntries(lanes.map(lane => [lane.name, { samples: [], sources: [] }]));
const pulls = await gh(`${api}/pulls?state=closed&base=master&sort=updated&direction=desc&per_page=${pullLimit}`);
const merged = pulls.filter(pull => pull.merged_at).sort((a, b) => b.merged_at.localeCompare(a.merged_at));
for (const pull of merged) {
const pending = lanes.filter(lane => result[lane.name].samples.length < maxRuns);
if (!pending.length) break;
validatePull(pull);
// Only measure code present in this checkout.
if (!isMerged(pull.merge_commit_sha)) continue;
const listed = await gh(`${api}/actions/workflows/pr.yml/runs?head_sha=${pull.head.sha}&event=pull_request&status=success&per_page=20`);
const runs = listed.workflow_runs.filter(run => {
try { validatePrRun(run, pull); return true; } catch { return false; }
}).sort((a, b) => b.id - a.id);
if (!runs.length) continue;
const run = runs[0];
const jobs = [];
for (let page = 1; ; page++) {
const response = await gh(`${api}/actions/runs/${run.id}/jobs?filter=latest&per_page=100&page=${page}`);
jobs.push(...response.jobs);
if (response.jobs.length < 100) break;
}
for (const lane of pending) {
const selected = completeShardSet(jobs, lane.classify);
if (!selected) continue;
const sample = new Map();
try {
for (const job of selected) {
assert.equal(job.run_id, run.id);
keepLargest(sample, lane.parse(await gh(`${api}/actions/jobs/${job.id}/logs`, false), lane.inventory));
}
} catch (error) {
log(`::warning::${lane.name}: skipped run ${run.id} for PR #${pull.number}: ${error.message.split("\n")[0]}`);
continue;
}
result[lane.name].samples.push(sample);
result[lane.name].sources.push({ pull: pull.number, run: run.id, attempt: run.run_attempt, sha: run.head_sha, url: run.html_url });
}
}
return result;
}