mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## 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>
258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
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;
|
||
}
|