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>
206 lines
10 KiB
JavaScript
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;
|
|
});
|
|
}
|