Files
PaperClipAI/.github/scripts/runner-loss-rerun.mjs
T
Nicky LeachandClaude Opus 5.5 f2299cb00f feat(ci): re-run failed PR jobs once when every failure was a lost runner (#19640)
## 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>
2026-11-06 15:05:42 -08:00

206 lines
10 KiB
JavaScript

#!/usr/bin/env node
/**
* runner-loss-rerun.mjs
*
* Re-runs the failed jobs of a first PR CI attempt once, but only when every
* failed job failed because its runner was lost. Runs from
* .github/workflows/ci-runner-loss-rerun.yml on workflow_run. It reads GitHub
* API metadata only and never touches the PR's code or artifacts.
*
* Local dry run against a real run (no rerun is requested):
* GITHUB_TOKEN=$(gh auth token) node .github/scripts/runner-loss-rerun.mjs --run-id <id> --dry-run
*/
import { appendFileSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const REPOSITORY = "paperclipai/paperclip";
export const REPOSITORY_ID = 1170821064;
export const PR_WORKFLOW_PATH = ".github/workflows/pr.yml";
// The runner's own messages when an AWS instance disappears mid-job.
export const RUNNER_LOSS_PATTERN = /lost communication with the server|received a shutdown signal/;
// `verify` and `e2e` in pr-trusted.yml only assert their `needs` results. They
// fail whenever a dependency failed and re-run with it.
export const AGGREGATE_JOBS = new Set(["ci / verify", "ci / e2e"]);
// ci-routing.yml checks the attempt's triggering actor. A bot-triggered attempt
// would move the whole run to GitHub-hosted runners, so leave it to a human.
export const GATE_JOB_PREFIX = "ci / gate / ";
// A shutdown signal often reaches the check run only as this annotation; the
// signal itself is logged as an error line in the job log.
const CANCELLED_MESSAGE = "The operation was canceled.";
export function createGithubClient({ token, fetcher = fetch, apiUrl = "https://api.github.com" }) {
if (!token) throw new Error("GITHUB_TOKEN is required");
const origin = new URL(apiUrl).origin;
const request = async (target, { method = "GET" } = {}) => {
const url = new URL(target, apiUrl);
if (url.origin !== origin) throw new Error(`Refusing to call ${url.origin}`);
const response = await fetcher(url.href, {
method,
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, "X-GitHub-Api-Version": "2022-11-28" },
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) throw new Error(`GitHub API ${method} ${url.pathname} failed (HTTP ${response.status})`);
return response;
};
return {
json: async (target) => (await request(target)).json(),
// Follows Link rel="next" until the last page. `field` selects the array
// in wrapped responses such as { total_count, jobs }.
paginate: async (target, field) => {
const items = [];
let totalCount;
for (let next = target, pages = 0; next; pages += 1) {
if (pages >= 100) throw new Error(`Too many pages for ${target}`);
const response = await request(next);
const body = await response.json();
const page = field ? body[field] : body;
if (!Array.isArray(page)) throw new Error(`Unexpected page shape for ${target}`);
items.push(...page);
if (field && Number.isInteger(body.total_count)) totalCount = body.total_count;
next = /<([^>]+)>;\s*rel="next"/.exec(response.headers.get("link") ?? "")?.[1];
}
if (totalCount !== undefined && items.length !== totalCount) {
throw new Error(`Incomplete listing for ${target}: ${items.length} of ${totalCount}`);
}
return items;
},
// Job logs answer with a redirect to a signed download URL.
text: async (target) => (await request(target)).text(),
post: async (target) => { await request(target, { method: "POST" }); },
};
}
export function logShowsRunnerLoss(log) {
return log.split("\n").some((line) => line.includes("##[error]") && RUNNER_LOSS_PATTERN.test(line));
}
/** Classifies one failed job. Logs are read only when annotations are inconclusive. */
export async function classifyJob(job, { annotations, readLog }) {
const failures = annotations.filter((annotation) => annotation.annotation_level === "failure");
const isRunnerLoss = (annotation) => RUNNER_LOSS_PATTERN.test(annotation.message ?? "");
// A step can fail for a real reason before the runner is lost (for example a
// test failure, then a loss during an always() upload step). Any failure
// annotation other than runner loss or cancellation marks a real failure.
const real = failures.find((annotation) => !isRunnerLoss(annotation) && annotation.message !== CANCELLED_MESSAGE);
if (real) return { lost: false, evidence: firstLine(real.message) };
const match = failures.find(isRunnerLoss);
if (match) return { lost: true, evidence: firstLine(match.message) };
// Only cancellations are left: the shutdown signal, if any, is in the log.
if (failures.length > 0) {
if (logShowsRunnerLoss(await readLog(job))) {
return { lost: true, evidence: "job log: runner received a shutdown signal or lost communication" };
}
return { lost: false, evidence: `${CANCELLED_MESSAGE} (no runner-loss error in the job log)` };
}
return { lost: false, evidence: "no failure annotation" };
}
function firstLine(message) {
const line = String(message).split("\n")[0];
return line.length > 160 ? `${line.slice(0, 157)}...` : line;
}
export function eligibleRun(run) {
if (run.repository?.full_name !== REPOSITORY || run.repository?.id !== REPOSITORY_ID) return "run is not in the canonical repository";
if (run.path !== PR_WORKFLOW_PATH) return `workflow is ${run.path}, not ${PR_WORKFLOW_PATH}`;
if (run.event !== "pull_request") return `event is ${run.event}, not pull_request`;
if (run.status !== "completed" || run.conclusion !== "failure") return `run is ${run.status}/${run.conclusion}, not completed/failure`;
// One automatic re-run only. A higher attempt also means someone re-ran it.
if (run.run_attempt !== 1) return `run is on attempt ${run.run_attempt}; only attempt 1 is re-run automatically`;
return undefined;
}
/**
* Re-running an older run joins the PR's `cancel-in-progress` concurrency
* group and would cancel newer CI. Require the run to be the latest CI for the
* PR's current head.
*/
export async function staleReason(client, run) {
const pulls = await client.paginate(`/repos/${REPOSITORY}/commits/${run.head_sha}/pulls?per_page=100`);
const open = pulls.filter((pr) => pr.state === "open" && pr.base?.repo?.id === REPOSITORY_ID
&& pr.head?.repo?.id === run.head_repository?.id && pr.head?.sha === run.head_sha);
if (open.length === 0) return "no open pull request still has this run's head commit";
const runs = await client.paginate(
`/repos/${REPOSITORY}/actions/workflows/pr.yml/runs?event=pull_request&head_sha=${run.head_sha}&per_page=100`,
"workflow_runs",
);
const newer = runs.find((candidate) => candidate.id > run.id && candidate.conclusion !== "skipped");
if (newer) return `a newer PR run (${newer.id}) exists for this head commit`;
return undefined;
}
export async function decide(client, run) {
const decision = { runId: run.id, attempt: run.run_attempt, rerun: false, jobs: [] };
const ineligible = eligibleRun(run);
if (ineligible) return { ...decision, reason: ineligible };
const jobs = await client.paginate(`/repos/${REPOSITORY}/actions/runs/${run.id}/attempts/1/jobs?per_page=100`, "jobs");
const failed = jobs.filter((job) => job.conclusion === "failure" && !AGGREGATE_JOBS.has(job.name));
if (failed.length === 0) return { ...decision, reason: "no failed jobs other than the verify/e2e aggregates" };
for (const job of failed) {
if (job.name.startsWith(GATE_JOB_PREFIX)) {
decision.jobs.push({ id: job.id, name: job.name, lost: false, evidence: "routing gate failed; a bot re-run would lose AWS routing" });
continue;
}
const annotations = await client.paginate(`/repos/${REPOSITORY}/check-runs/${job.id}/annotations?per_page=100`);
const readLog = (entry) => client.text(`/repos/${REPOSITORY}/actions/jobs/${entry.id}/logs`);
decision.jobs.push({ id: job.id, name: job.name, ...(await classifyJob(job, { annotations, readLog })) });
}
const real = decision.jobs.filter((job) => !job.lost);
if (real.length) return { ...decision, reason: `${real.length} of ${failed.length} failed jobs did not lose their runner` };
const stale = await staleReason(client, run);
if (stale) return { ...decision, reason: stale };
return { ...decision, rerun: true, reason: `all ${failed.length} failed jobs lost their runner` };
}
export function renderSummary(decision, { dryRun = false } = {}) {
const action = decision.rerun ? (dryRun ? "Would re-run failed jobs (dry run)" : "Re-ran failed jobs") : "No automatic re-run";
const lines = [
"### CI runner-loss re-run", "",
`**${action}** for run ${decision.runId} attempt ${decision.attempt}: ${decision.reason}.`, "",
];
if (decision.jobs.length) {
lines.push("| Job | Runner lost | Evidence |", "| --- | --- | --- |");
for (const job of decision.jobs) {
lines.push(`| ${escapeCell(job.name)} | ${job.lost ? "yes" : "no"} | ${escapeCell(job.evidence)} |`);
}
lines.push("");
}
return `${lines.join("\n")}\n`;
}
const escapeCell = (value) => String(value).replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
export async function main({ env = process.env, argv = process.argv.slice(2), fetcher = fetch, log = console.log } = {}) {
const dryRun = argv.includes("--dry-run");
const argRunId = argv[argv.indexOf("--run-id") + 1];
const event = env.GITHUB_EVENT_PATH ? JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf8")) : undefined;
const runId = argv.includes("--run-id") ? argRunId : event?.workflow_run?.id;
if (!/^[1-9]\d*$/.test(String(runId))) throw new Error("Provide --run-id or a workflow_run event");
const client = createGithubClient({ token: env.GITHUB_TOKEN, fetcher });
// Read the run's current state rather than the event snapshot: its attempt
// number is above 1 if anyone re-ran it after this event was queued.
const run = await client.json(`/repos/${REPOSITORY}/actions/runs/${runId}`);
const decision = await decide(client, run);
if (decision.rerun && !dryRun) {
await client.post(`/repos/${REPOSITORY}/actions/runs/${run.id}/rerun-failed-jobs`);
}
const summary = renderSummary(decision, { dryRun });
log(summary);
if (env.GITHUB_STEP_SUMMARY) appendFileSync(env.GITHUB_STEP_SUMMARY, summary);
return decision;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}