mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Contributors wait for PR CI before merge, so CI wall-clock time sets the pace of work > - PR CI runs on self-hosted AWS runners, and these runners are sometimes lost mid-job > - On 2026-11-06, 21 of 51 failed jobs on commits that later passed were runner loss. The losses came in fleet-wide bursts, for example 10 jobs on 10 instances in 51 seconds > - After each loss, a person must notice the failure and click "Re-run failed jobs" > - This pull request adds a workflow that re-runs the failed jobs once, only when every failure was a lost runner > - The benefit is fewer manual re-runs and a shorter wait for CI to pass ## Linked Issues or Issue Description **What existing behavior does this improve?** The recovery of PR CI runs that failed only because a self-hosted runner was lost. **Subsystem affected** CI: the trusted PR workflow (`pr.yml` → `pr-trusted.yml`). **Current behavior** When an AWS runner is lost, the job fails with "The self-hosted runner lost communication with the server" or "The runner has received a shutdown signal". The PR stays red until a person re-runs the failed jobs. **Proposed behavior** When the `PR` workflow completes with a failure on attempt 1, a new workflow examines the failed jobs. If every failed job lost its runner, it re-runs the failed jobs one time. If any job failed for another reason, it does nothing. **Reason and benefit** A runner loss is not a code problem. The re-run starts without waiting for a person to notice the failure. **Breaking changes** None. ## What Changed - Add `.github/workflows/ci-runner-loss-rerun.yml`. It triggers on `workflow_run` for `PR`, and it acts only on attempt 1 failures of `pull_request` runs in this repository. It runs on `ubuntu-latest`, so it does not depend on the AWS fleet. - Add `.github/scripts/runner-loss-rerun.mjs`. The script: - lists the attempt-1 jobs and ignores the aggregate jobs `ci / verify` and `ci / e2e`; - finds runner loss in the check-run annotations. If the only annotation is "The operation was canceled.", it reads the `##[error]` lines in the job log; - calls `rerun-failed-jobs` only if every remaining failed job lost its runner; - does not re-run when the gate job failed, the PR head moved, the PR is closed, or a newer run exists for the same commit; - writes the decision and a job table to the step summary. A `--dry-run` mode never posts. - Add 16 tests, with trimmed fixtures from four real runs. - Add a paragraph about the workflow to the "Trusted PR Workflow" section of `doc/DEVELOPING.md`. ## Verification - `node --test .github/scripts/tests/runner-loss-rerun.test.mjs`: 16/16 pass. The tests cover: - re-run when all jobs were lost, including the case where the signal is only in the log; - no re-run for mixed failures, real failures, only aggregate failures, attempt 2, a failed gate, or a stale run; - pagination, incomplete listings, a cross-host link that is refused, and the workflow guards. - `node --test '.github/scripts/tests/*.test.mjs'`: 760/760 pass. - A read-only dry run against the real GitHub API for six recent runs classified all six correctly. - The workflow can run on GitHub only after it is on master. Reviewers can make sure that the first runner-loss failure after the merge triggers one re-run. ## Risks - **Trust:** a re-run of failed jobs uses the outputs of the gate job that succeeded on attempt 1, including its AWS runner admission. A person who clicks "Re-run failed jobs" gets the same result. The only difference is that `github-actions[bot]` triggers attempt 2. The workflow never checks out or runs PR code. - **Failed gate:** if the gate job itself failed, the script does nothing. A re-run by the bot would put all jobs on GitHub-hosted runners. - **Profiling:** `scripts/ci-profile/export.mjs` exports only attempts triggered by authorized users. The bot-triggered attempt 2 is not exported to Honeycomb. Attempt 1 is still exported. - **Events:** GitHub may not create `workflow_run` events for attempts that `GITHUB_TOKEN` started. Then other `workflow_run` consumers do not react to attempt 2. Required checks still update. - **Permissions:** `actions: write`, `checks: read`, `contents: read` and `pull-requests: read`. The last one lets the script confirm that the run still belongs to the open PR's current head. - **Cancelled jobs:** a runner loss where the lost jobs end as `cancelled` and only the aggregate jobs show `failure` is not re-run. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and code execution. A Claude sub-agent wrote the change, and the parent session reviewed it. ## 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 - [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 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
16 KiB
JavaScript
318 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import {
|
|
classifyJob, createGithubClient, decide, logShowsRunnerLoss, main, renderSummary,
|
|
} from "../runner-loss-rerun.mjs";
|
|
|
|
// Trimmed attempt-1 snapshots of real PR runs: jobs, check-run annotations for
|
|
// each failed job, and the error lines of job logs whose only annotation is
|
|
// "The operation was canceled.".
|
|
const fixture = (runId) => JSON.parse(readFileSync(
|
|
new URL(`./fixtures/runner-loss-rerun/run-${runId}.json`, import.meta.url), "utf8",
|
|
));
|
|
const MIXED = 42271918832; // six lost runners, one shutdown in the log, one real test timeout
|
|
const REAL = 42264785415; // a real assertion failure
|
|
const ALL_LOST = 42272083862; // one lost runner
|
|
const LOG_ONLY = 42277969418; // shutdown signal visible only in the job log
|
|
const workflow = readFileSync(new URL("../../workflows/ci-runner-loss-rerun.yml", import.meta.url), "utf8");
|
|
|
|
const json = (body, headers = {}) => new Response(JSON.stringify(body), {
|
|
status: 200, headers: { "content-type": "application/json", ...headers },
|
|
});
|
|
|
|
/**
|
|
* Serves a fixture as the GitHub REST API. By default the run is the latest
|
|
* CI for an open PR's current head.
|
|
*/
|
|
function fakeGithub(data, {
|
|
jobsPageSize = 100, pulls, prRuns, mutateRun = (run) => run,
|
|
} = {}) {
|
|
const calls = [];
|
|
const run = mutateRun(structuredClone(data.run));
|
|
const openPr = {
|
|
number: 1, state: "open",
|
|
base: { repo: { id: data.run.repository.id } },
|
|
head: { sha: data.run.head_sha, repo: { id: data.run.head_repository.id } },
|
|
};
|
|
const fetcher = async (href, init) => {
|
|
const url = new URL(href);
|
|
calls.push({ method: init.method, path: url.pathname, query: url.search, auth: init.headers.Authorization });
|
|
const base = `/repos/paperclipai/paperclip`;
|
|
const route = url.pathname.slice(base.length);
|
|
if (init.method === "POST" && route === `/actions/runs/${run.id}/rerun-failed-jobs`) {
|
|
return new Response(null, { status: 201 });
|
|
}
|
|
if (route === `/actions/runs/${run.id}`) return json(run);
|
|
if (route === `/actions/runs/${run.id}/attempts/1/jobs`) {
|
|
const page = Number(url.searchParams.get("page") ?? 1);
|
|
const jobs = data.jobs.slice((page - 1) * jobsPageSize, page * jobsPageSize);
|
|
const last = page * jobsPageSize >= data.jobs.length;
|
|
const next = new URL(url);
|
|
next.searchParams.set("page", String(page + 1));
|
|
return json({ total_count: data.total_count, jobs }, last ? {} : { link: `<${next.href}>; rel="next"` });
|
|
}
|
|
let match = route.match(/^\/check-runs\/(\d+)\/annotations$/);
|
|
if (match) return json(data.annotations[match[1]] ?? []);
|
|
match = route.match(/^\/actions\/jobs\/(\d+)\/logs$/);
|
|
if (match) return new Response(data.logs[match[1]] ?? "", { status: 200 });
|
|
if (route === `/commits/${run.head_sha}/pulls`) return json(pulls ?? [openPr]);
|
|
if (route === "/actions/workflows/pr.yml/runs") {
|
|
const runs = prRuns ?? [{ id: run.id, conclusion: "failure" }];
|
|
return json({ total_count: runs.length, workflow_runs: runs });
|
|
}
|
|
return new Response("not found", { status: 404 });
|
|
};
|
|
return { calls, fetcher, run, client: createGithubClient({ token: "test-token", fetcher }) };
|
|
}
|
|
|
|
const posted = (calls) => calls.filter((call) => call.method === "POST");
|
|
|
|
async function runMain(data, options = {}, eventRun = {}) {
|
|
const github = fakeGithub(data, options);
|
|
const directory = mkdtempSync(path.join(tmpdir(), "runner-loss-rerun-"));
|
|
try {
|
|
const eventPath = path.join(directory, "event.json");
|
|
const summaryPath = path.join(directory, "summary.md");
|
|
writeFileSync(eventPath, JSON.stringify({ workflow_run: { id: data.run.id, run_attempt: 1, ...eventRun } }));
|
|
writeFileSync(summaryPath, "");
|
|
const decision = await main({
|
|
env: { GITHUB_TOKEN: "test-token", GITHUB_EVENT_PATH: eventPath, GITHUB_STEP_SUMMARY: summaryPath },
|
|
argv: [], fetcher: github.fetcher, log: () => {},
|
|
});
|
|
return { decision, calls: github.calls, summary: readFileSync(summaryPath, "utf8") };
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test("all failed jobs lost their runner: re-runs failed jobs once", async () => {
|
|
const { decision, calls, summary } = await runMain(fixture(ALL_LOST));
|
|
assert.equal(decision.rerun, true);
|
|
assert.deepEqual(decision.jobs.map((job) => [job.name, job.lost]), [["ci / General tests (server (3/12))", true]]);
|
|
assert.deepEqual(posted(calls).map((call) => call.path),
|
|
[`/repos/paperclipai/paperclip/actions/runs/${ALL_LOST}/rerun-failed-jobs`]);
|
|
assert.ok(calls.every((call) => call.auth === "Bearer test-token"));
|
|
// `ci / verify` failed too, but only because its dependency failed.
|
|
assert.equal(calls.some((call) => call.path.endsWith("/check-runs/128088995071/annotations")), false);
|
|
assert.match(summary, /\*\*Re-ran failed jobs\*\*/);
|
|
assert.match(summary, /\| ci \/ General tests \(server \(3\/12\)\) \| yes \| The self-hosted runner lost communication/);
|
|
});
|
|
|
|
test("a shutdown signal found only in the job log counts as runner loss", async () => {
|
|
const data = fixture(LOG_ONLY);
|
|
assert.deepEqual(data.annotations["128101016102"].map((annotation) => annotation.message), ["The operation was canceled."]);
|
|
const { decision, calls } = await runMain(data);
|
|
assert.equal(decision.rerun, true);
|
|
assert.match(decision.jobs[0].evidence, /job log/);
|
|
assert.ok(calls.some((call) => call.path.endsWith("/actions/jobs/128101016102/logs")));
|
|
assert.equal(posted(calls).length, 1);
|
|
});
|
|
|
|
test("mixed runner loss and a real test failure: no re-run", async () => {
|
|
const { decision, calls, summary } = await runMain(fixture(MIXED));
|
|
assert.equal(decision.rerun, false);
|
|
assert.equal(decision.jobs.length, 8);
|
|
assert.deepEqual(decision.jobs.filter((job) => !job.lost).map((job) => job.name), ["ci / General tests (workspaces-a (1/2))"]);
|
|
assert.equal(decision.jobs.find((job) => job.name === "ci / Verify Paperclip Runner (vitest package)").lost, true);
|
|
assert.equal(posted(calls).length, 0);
|
|
assert.match(decision.reason, /1 of 8 failed jobs did not lose their runner/);
|
|
assert.match(summary, /\*\*No automatic re-run\*\*/);
|
|
});
|
|
|
|
test("real test failures: no re-run", async () => {
|
|
const { decision, calls } = await runMain(fixture(REAL));
|
|
assert.equal(decision.rerun, false);
|
|
assert.deepEqual(decision.jobs.map((job) => [job.name, job.lost]), [["ci / General tests (server (4/12))", false]]);
|
|
assert.equal(posted(calls).length, 0);
|
|
});
|
|
|
|
test("only the verify and e2e aggregates failed: no re-run", async () => {
|
|
const data = fixture(ALL_LOST);
|
|
data.jobs = data.jobs.map((job) => job.name.startsWith("ci / General tests") ? { ...job, conclusion: "success" } : job);
|
|
const { decision, calls } = await runMain(data);
|
|
assert.equal(decision.rerun, false);
|
|
assert.match(decision.reason, /no failed jobs other than the verify\/e2e aggregates/);
|
|
assert.equal(calls.some((call) => call.path.includes("/check-runs/")), false);
|
|
assert.equal(posted(calls).length, 0);
|
|
});
|
|
|
|
test("attempt 2 or later is never re-run automatically", async () => {
|
|
// The event was for attempt 1, but someone re-ran the run before this job started.
|
|
const { decision, calls } = await runMain(fixture(ALL_LOST), { mutateRun: (run) => ({ ...run, run_attempt: 2 }) });
|
|
assert.equal(decision.rerun, false);
|
|
assert.match(decision.reason, /attempt 2/);
|
|
assert.deepEqual(calls.map((call) => call.path), [`/repos/paperclipai/paperclip/actions/runs/${ALL_LOST}`]);
|
|
});
|
|
|
|
test("a failed routing gate is left for a human re-run", async () => {
|
|
const data = fixture(ALL_LOST);
|
|
const gate = data.jobs.find((job) => job.name === "ci / gate / Select trusted runner");
|
|
gate.conclusion = "failure";
|
|
data.annotations[gate.id] = [{ annotation_level: "failure", message: "The self-hosted runner lost communication with the server." }];
|
|
const { decision, calls } = await runMain(data);
|
|
assert.equal(decision.rerun, false);
|
|
assert.match(decision.jobs.find((job) => job.id === gate.id).evidence, /routing gate/);
|
|
assert.equal(posted(calls).length, 0);
|
|
});
|
|
|
|
test("runs that are no longer the PR's latest CI are not re-run", async () => {
|
|
const data = fixture(ALL_LOST);
|
|
const moved = await decide(fakeGithub(data, {
|
|
pulls: [{ state: "open", base: { repo: { id: 1170821064 } }, head: { sha: "0".repeat(40), repo: { id: 1170821064 } } }],
|
|
}).client, data.run);
|
|
assert.equal(moved.rerun, false);
|
|
assert.match(moved.reason, /no open pull request/);
|
|
|
|
const merged = await decide(fakeGithub(data, {
|
|
pulls: [{ state: "closed", base: { repo: { id: 1170821064 } }, head: { sha: data.run.head_sha, repo: { id: 1170821064 } } }],
|
|
}).client, data.run);
|
|
assert.equal(merged.rerun, false);
|
|
|
|
// A later full-ci label run on the same head shares the PR concurrency group;
|
|
// re-running the older run would cancel it.
|
|
const newer = await decide(fakeGithub(data, {
|
|
prRuns: [{ id: data.run.id + 1, conclusion: null }, { id: data.run.id, conclusion: "failure" }],
|
|
}).client, data.run);
|
|
assert.equal(newer.rerun, false);
|
|
assert.match(newer.reason, /newer PR run/);
|
|
|
|
// A later label event that did not request CI skips its only job; it is not CI.
|
|
const skipped = await decide(fakeGithub(data, {
|
|
prRuns: [{ id: data.run.id + 1, conclusion: "skipped" }, { id: data.run.id, conclusion: "failure" }],
|
|
}).client, data.run);
|
|
assert.equal(skipped.rerun, true);
|
|
});
|
|
|
|
test("ineligible runs are rejected before any job is read", async () => {
|
|
const data = fixture(ALL_LOST);
|
|
for (const [change, reason] of [
|
|
[{ event: "push" }, /event is push/],
|
|
[{ path: ".github/workflows/other.yml" }, /workflow is/],
|
|
[{ conclusion: "success" }, /completed\/success/],
|
|
[{ repository: { id: 1, full_name: "someone/paperclip" } }, /canonical repository/],
|
|
]) {
|
|
const github = fakeGithub(data);
|
|
const decision = await decide(github.client, { ...data.run, ...change });
|
|
assert.equal(decision.rerun, false);
|
|
assert.match(decision.reason, reason);
|
|
assert.equal(github.calls.length, 0);
|
|
}
|
|
});
|
|
|
|
test("job listing follows Link pagination across pages", async () => {
|
|
const data = fixture(MIXED);
|
|
const github = fakeGithub(data, { jobsPageSize: 20 });
|
|
const decision = await decide(github.client, data.run);
|
|
const pages = github.calls.filter((call) => call.path.endsWith("/attempts/1/jobs"));
|
|
assert.deepEqual(pages.map((call) => new URLSearchParams(call.query).get("page")), [null, "2", "3"]);
|
|
// Failed jobs on pages 2 and 3 are all classified.
|
|
assert.equal(decision.jobs.length, 8);
|
|
assert.equal(decision.rerun, false);
|
|
});
|
|
|
|
test("an incomplete job listing fails instead of deciding on partial data", async () => {
|
|
const data = fixture(ALL_LOST);
|
|
data.total_count += 1;
|
|
const github = fakeGithub(data);
|
|
await assert.rejects(decide(github.client, data.run), /Incomplete listing/);
|
|
assert.equal(posted(github.calls).length, 0);
|
|
});
|
|
|
|
test("the client refuses to follow pagination to another host", async () => {
|
|
const client = createGithubClient({
|
|
token: "test-token",
|
|
fetcher: async () => json([], { link: '<https://example.com/steal?page=2>; rel="next"' }),
|
|
});
|
|
await assert.rejects(client.paginate("/repos/paperclipai/paperclip/commits/x/pulls"), /Refusing to call https:\/\/example.com/);
|
|
});
|
|
|
|
test("classification is conservative about cancelled jobs", async () => {
|
|
const cancelled = [{ annotation_level: "failure", message: "The operation was canceled." }];
|
|
const noSignal = await classifyJob({ id: 1 }, { annotations: cancelled, readLog: async () => "##[error]The operation was canceled.\n" });
|
|
assert.equal(noSignal.lost, false);
|
|
// A job timeout also cancels the step, but adds its own failure annotation.
|
|
const timedOut = await classifyJob({ id: 1 }, {
|
|
annotations: [{ annotation_level: "failure", message: "The job has exceeded the maximum execution time of 30m0s" }, ...cancelled],
|
|
readLog: async () => { throw new Error("log must not be read"); },
|
|
});
|
|
assert.equal(timedOut.lost, false);
|
|
// Notices and warnings are not failure evidence.
|
|
const warning = await classifyJob({ id: 1 }, {
|
|
annotations: [{ annotation_level: "warning", message: "The self-hosted runner lost communication with the server." }],
|
|
readLog: async () => "",
|
|
});
|
|
assert.equal(warning.lost, false);
|
|
// Test output that merely prints the phrase is not a runner error line.
|
|
assert.equal(logShowsRunnerLoss("2026-11-06T00:00:00Z expected 'received a shutdown signal'\n"), false);
|
|
assert.equal(logShowsRunnerLoss(fixture(MIXED).logs["128078674151"]), true);
|
|
});
|
|
|
|
test("a real failure before the runner was lost is not counted as runner loss", async () => {
|
|
const lostMessage = "The self-hosted runner lost communication with the server. Verify the machine is running.";
|
|
const noLog = async () => { throw new Error("log must not be read"); };
|
|
// A test fails, then the runner is lost during an always() upload step.
|
|
const testThenLost = await classifyJob({ id: 1 }, {
|
|
annotations: [
|
|
{ annotation_level: "failure", message: "Error: Test timed out in 15000ms." },
|
|
{ annotation_level: "failure", message: "Process completed with exit code 1." },
|
|
{ annotation_level: "failure", message: lostMessage },
|
|
],
|
|
readLog: noLog,
|
|
});
|
|
assert.equal(testThenLost.lost, false);
|
|
assert.match(testThenLost.evidence, /Test timed out/);
|
|
// The order of the annotations does not matter.
|
|
const lostThenExit = await classifyJob({ id: 1 }, {
|
|
annotations: [
|
|
{ annotation_level: "failure", message: lostMessage },
|
|
{ annotation_level: "failure", message: "Process completed with exit code 1." },
|
|
],
|
|
readLog: noLog,
|
|
});
|
|
assert.equal(lostThenExit.lost, false);
|
|
// Runner loss with a cancellation only is still runner loss.
|
|
const lostAndCancelled = await classifyJob({ id: 1 }, {
|
|
annotations: [
|
|
{ annotation_level: "failure", message: lostMessage },
|
|
{ annotation_level: "failure", message: "The operation was canceled." },
|
|
],
|
|
readLog: noLog,
|
|
});
|
|
assert.equal(lostAndCancelled.lost, true);
|
|
});
|
|
|
|
test("summary cells cannot break the table", () => {
|
|
const summary = renderSummary({
|
|
runId: 1, attempt: 1, rerun: false, reason: "test",
|
|
jobs: [{ name: "ci / a|b", lost: false, evidence: "line one\nline | two" }],
|
|
});
|
|
assert.match(summary, /\| ci \/ a\\\|b \| no \| line one line \\\| two \|/);
|
|
});
|
|
|
|
test("workflow acts only on failed first attempts of the canonical PR workflow", () => {
|
|
const condition = workflow.match(/^ {4}if: >-\n((?: {6}.+\n)+)/m)?.[1].replace(/\s+/g, " ").trim();
|
|
assert.equal(condition, [
|
|
"github.repository == 'paperclipai/paperclip' &&",
|
|
"github.repository_id == '1170821064' &&",
|
|
"github.event.workflow_run.event == 'pull_request' &&",
|
|
"github.event.workflow_run.path == '.github/workflows/pr.yml' &&",
|
|
"github.event.workflow_run.conclusion == 'failure' &&",
|
|
"github.event.workflow_run.run_attempt == 1",
|
|
].join(" "));
|
|
assert.match(workflow, /^on:\n {2}workflow_run:\n {4}workflows: \[PR\]\n {4}types: \[completed\]$/m);
|
|
assert.match(workflow, /^ {2}group: ci-runner-loss-rerun-\$\{\{ github\.event\.workflow_run\.id \}\}$/m);
|
|
const permissions = workflow.match(/^permissions:\n((?: {2}.+\n)+)/m)[1]
|
|
.split("\n").filter((line) => /^ {2}[a-z-]+:/.test(line)).map((line) => line.trim());
|
|
assert.deepEqual(permissions, ["actions: write", "checks: read", "contents: read", "pull-requests: read"]);
|
|
});
|
|
|
|
test("workflow runs only trusted default-branch code", () => {
|
|
assert.match(workflow, /ref: \$\{\{ github\.sha \}\}\n {10}persist-credentials: false\n {10}sparse-checkout: \.github\/scripts\n/);
|
|
assert.doesNotMatch(workflow, /head_sha|head_branch|download-artifact|secrets\.|pull_request_target/);
|
|
assert.equal([...workflow.matchAll(/uses: /g)].length, 1);
|
|
assert.match(workflow, /runs-on: ubuntu-latest/);
|
|
});
|