CI performance analysis and improvement
Recurring analysis of PR CI: measure where wall-clock time goes, track it
over time, and turn findings into concrete fixes. Runs two ways — manually
in an interactive session, and unattended every ~2 days via the scheduled
workflow in meridianlabs-ai/actions (inspect-ai-ci-perf.yml); see
"Scheduled (unattended) mode" below for how the rules differ. Each run is
self-contained.
Objective and ground rules
- Primary metric: PR wall-clock — push to all-checks-green. That is
what contributors feel. Total runner compute-minutes is tracked as a
secondary metric (it drives queue contention and cost).
- Queue time and execution time are separate numbers. Runner-pool
saturation has historically dominated wall clock here (batches of PRs
land together; ~13 jobs fan out per PR). Never propose a test speedup to
fix what is actually queue contention.
- Two output buckets:
- Safe fixes — small, low-risk changes the skill prepares as PRs
(see the fix phase for the category list).
- Structural proposals — anything touching required-check names, job
topology, or policy. Never a PR from this skill: they rank in the
report, and ripe ones are filed as issues for a maintainer decision
(see the fix phase).
- Never propose trimming the Python version matrix (e.g. PRs testing
only 3.11). Explicitly ruled out.
- Interactive runs: always ask before pushing. Prepare branch + diff +
PR body, show the user, push only on their OK. Unattended runs can't ask —
see "Scheduled (unattended) mode" for what they may push.
- One PR per run, not per fix. Every PR costs a full review/approval
ritual, so combine everything a run produces — snapshot, report, prs.md
update, and all of the run's safe fixes — into a single PR, one commit
per logical change. Do not split the run's output across PRs.
Follow AGENTS.md PR rules (CI-only changes need no CHANGELOG entry;
test-content changes are product-adjacent — judge per AGENTS.md).
Phase 1 — Collect
Run the bundled collector (venv active; gh must be authenticated):
python .claude/skills/ci-perf/scripts/collect_ci_data.py \
--out design/ci-perf/history/$(date +%F).json
It snapshots the last ~200 completed PR workflow runs with per-job
timings (execution seconds, wait-from-run-start) and mines recent Build
test-job logs for pytest --durations blocks and the final summary line
(total test counts + pytest wall, for suite-size trends).
- If
pytest_durations comes back empty, CI likely doesn't pass
--durations yet — proposing that one-line workflow change is the
standing first safe fix.
- If a snapshot for today already exists, overwrite it (re-runs same day
are fine); history keeps one file per day.
Repo slow-test policy (context for analysis and fixes)
- Definition: any test that uses docker or hits a real (unmocked)
external service is by definition slow and must carry
@pytest.mark.slow. A fully-mocked test may also be marked slow when
its cost is inherent (e.g. crossing the S3 1000-key page boundary means
1001+ real HTTP PUTs to the in-process moto server) — say why in the
test's docstring.
- Where slow tests run: they are skipped in the PR-gate
test jobs
(no --runslow) and run every ~2 hours by the scheduled suite in
meridianlabs-ai/actions (inspect-ai-scheduled-tests.yml, runner
label slow_test_runner, pytest --runslow --runapi). Marking a test
slow moves its coverage there — it does not delete it. Prefer the PR
gate for regression guards on core logic when the test can be made
cheap; prefer slow for anything matching the definition above.
- The docker trap: docker is preinstalled and running on GitHub
ubuntu-latest, so nothing technically stops a docker test running in
the PR gate — skip_if_no_docker does not skip in CI. The slow mark is
the only enforcement, by convention: @skip_if_no_docker and
@pytest.mark.slow belong together. When durations data shows a
multi-second test, check for this pairing violation first (two found so
far: the 38s sandbox-init test, fixed in #4760, and
test_docker_read_file.py at 42s of fixture setup). Aggregate setup
and teardown phases as well as call — fixture-heavy offenders hide
outside call.
Phase 2 — Analyze
Read the fresh snapshot plus the previous few in design/ci-perf/history/
(trend needs at least one prior; on the first run, report absolute numbers
only). Compute:
- Queue vs execution split.
wait_from_run_start_seconds is true
queue time only for jobs without needs; for dependent jobs, subtract
the predecessor's completed_at first. Get the dependency map by
reading .github/workflows/*.yml (needs: keys) — don't hardcode it;
workflows change.
- Critical path per workflow. Which chain of jobs determines wall
clock, and how much of it is waiting? (Historically:
changes → test
serialization in build.yml put a full queue+start cycle in front of the
longest job.)
- Trends and regressions. Median/p90 wall clock per workflow vs prior
snapshots; tests newly appearing in the slowest list or significantly
slower than before.
- Slowest tests from
pytest_durations (aggregate across runs;
median per test). For top offenders, read the test source and classify:
genuinely heavy, real sleeps/timers that a mock clock or event would
remove, duplicate coverage, or a candidate for @pytest.mark.slow.
- Waste. Cancelled superseded runs and how long they held runners
before dying; jobs whose checkout/setup overhead exceeds their useful
work; unconditional
fetch-depth: 0 where history/tags are unused;
cache effectiveness.
- Step-level breakdown of the heavy jobs — always at p90, not just
median. The snapshot carries per-step timings; sum each step name's
median AND p90 across runs. A step can look fine in one sample and be
the wall-clock lottery across many:
fetch-depth: 0 checkouts fetch
every branch and tag at full history (a ~400MB pack here), taking 30s
or 4min depending on GitHub's server-side pack cache. Rule of thumb:
any always-run step whose p90 exceeds ~2x its median is a variance
problem, not a size problem — hunt for the erratic dependency
(pack cache, registry, external download). For checkouts specifically,
filter: "blob:none" keeps setuptools_scm working (refs + commit
graph) while skipping historical file contents; only jobs that read
old blob contents (e.g. git diff main -- <paths> over sources) need
care, and even those lazy-fetch on demand.
- Suite size — step back from the slow tail.
pytest_summaries in
the snapshot carries total test counts and total pytest wall per job.
Individual slow tests are only half the story: once the outliers are
fixed, sheer test count becomes the long pole (N tests × small median
cost, growing every week). Each run:
- Report total count and total pytest seconds, and the trend vs prior
snapshots (absolute and per-week growth rate).
- Estimate the split: how much of pytest wall is the slow tail
(durations data) vs the body of ordinary tests? When the body
dominates, per-test fixes stop paying and the leverage is fewer or
cheaper tests.
- Hunt duplicate coverage: several tests exercising the same code path
with cosmetic variations (candidates for parametrize or deletion),
new tests added next to older ones that already assert the same
behavior, and whole files whose subject is also covered elsewhere.
Sample a few of the fastest-growing test files rather than trying to
read everything.
- Hunt low-value tests: asserting trivialities (constructors,
passthroughs, framework behavior), tests that can't fail unless an
adjacent test also fails, over-broad matrix legs.
- Deleting or merging tests is coverage-sensitive: exact duplicates are
safe fixes; anything judgement-based is a report proposal for a
maintainer.
Phase 3 — Report
Rewrite design/ci-perf/report.md (full replacement each run; history
lives in the snapshots and git). Structure:
# CI performance report — YYYY-MM-DD
Data: N runs, DATE..DATE. Snapshot: history/YYYY-MM-DD.json
## Summary
2–4 sentences: wall-clock medians per workflow, trend arrow, the one
dominant bottleneck right now.
## Queue vs execution
Table per workflow/job: median exec, median queue, p90 wall.
## Slowest tests
Top ~15 with median seconds and classification. "(no data — --durations
not in CI)" if empty.
## Suite size
Total tests / total pytest seconds per job, trend vs prior snapshots,
slow-tail vs body split, duplicate-coverage and low-value findings
(or what was sampled and came up clean).
## Regressions since last report
Or "none".
## Waste
Cancelled-run runner-minutes, overhead-dominated jobs, etc.
## Proposals (ranked)
Each: what / est. wall-clock impact / disruption (safe-fix vs structural)
/ status (new, PR opened #N, done, declined).
Carry forward prior proposals with updated status — don't drop them.
## PRs opened by this skill
See prs.md.
Also maintain design/ci-perf/prs.md — the permanent ledger of every
change and PR this skill has produced. Unlike the report it is never
rewritten, only appended to and updated in place: one entry per PR (number,
date, one-line description, status open/merged/closed, measured impact once
verified). Each run: add entries for any PRs opened, refresh the status of
open ones (gh pr view), and record verified impact from the impact-check
phase.
Leave report + snapshot + prs.md as uncommitted working-tree changes; ask
the user whether to commit them at the end of the run.
Phase 4 — Fix
From the ranked proposals, prepare the top safe fixes (typically 1–3 per
run, shipped together with the report in the run's single PR):
Safe-fix categories (auto-PR eligible, still ask-first):
- Workflow hygiene: add
--durations=50 to pytest, drop unneeded
fetch-depth: 0 / fetch-tags, cache tuning, merging trivially small
jobs, removing needless needs: serialization.
- Trivial test fixes: real
sleep(...) waits replaced by mock clocks or
events, removal of exact-duplicate tests, marking genuinely slow tests
@pytest.mark.slow where an equivalent fast path exists.
Structural proposals (never a PR from this skill): renaming/merging
required checks (branch protection), moving checks between workflows,
retry/concurrency policy changes, anything a reviewer could reasonably
object to on grounds other than correctness. They rank in the report, and
when one is ripe — a concrete change with measured impact, worth doing
on the evidence, and no open question the next snapshot would answer —
write it up as an issue on meridianlabs-ai/inspect_ai (the org's tracking
repo, same as test-failure triage) so it gets a maintainer decision instead
of scrolling by in successive reports. Before filing, search for an
existing issue (gh issue list --repo meridianlabs-ai/inspect_ai --state all --search "<key phrase>", plus the issue links already in the
report): if one exists, add the new evidence as a comment rather than
filing a duplicate. Record the issue link in the proposal's status line.
Speculative or still-maturing proposals stay report-only.
Procedure: one branch off main for the whole run. Each fix is its own
commit — make the single change, run the relevant local validation
(ruff check, mypy for touched Python; the affected tests for test
fixes) — with the snapshot/report/prs.md commits alongside. Write one PR
body per .github/pull_request_template.md covering everything the run
ships, then show the user the diff and PR body and wait for their OK
before any push. After opening, watch CI per AGENTS.md. Record the PR
number in the report and prs.md.
Verifying impact
Each run, check the proposals marked "PR opened/done" in the previous
report against the new snapshot: did the metric move as predicted? Say so
in the report — honest misses are how estimates get better. Record the
verified (or missed) impact on the PR's entry in prs.md.
Scheduled (unattended) mode
The workflow inspect-ai-ci-perf.yml in meridianlabs-ai/actions runs
this skill every ~2 days with no user present (it sets CI_PERF_SCHEDULED=1
and says so in the prompt). Differences from an interactive run:
- Don't ask — act, within these bounds. Commit the snapshot, report,
prs.md updates, and up to 2 safe fixes (safe-fix categories only, one
commit each, local validation run and passing) on one branch and open
ONE PR — the run's entire output ships as a single PR. Structural
proposals are never shipped as changes; ripe ones are filed as issues
per the fix phase (the fork tracking repo accepts marvin's issue writes
even while upstream PRs are blocked).
- Check the previous run's PR first (
gh pr list --author i-am-marvin
plus the open entries in prs.md). If it is still open, push this run's
commits onto its branch instead of opening a second PR. Never re-ship a
fix that's already sitting in the open PR, and respect the AGENTS.md
open-PR limit (4 per account).
- Contribution-policy compliance: the marvin account is recorded as a
qualified contributor in
.github/qualified.yml, which satisfies the PR
gate; the substantive rules still apply — the PR body must carry the
measured evidence from the snapshot for every fix it ships (AGENTS.md
rule 7).
- Push mechanics: the token is the marvin machine account, which has
write access on the
meridianlabs-ai/inspect_ai fork but not upstream.
Push branches to the fork and open PRs against
UKGovernmentBEIS/inspect_ai following the "Opening an upstream PR from
an org fork" section of AGENTS.md (gh api with head_repo).
- Skip anything doubtful. If local validation fails, the fix touches
more than intended, or the change is only arguably in a safe-fix
category, drop it to a report proposal instead of shipping it. An
unattended run that ships zero fixes is a fine outcome; one that ships
a wrong fix is not.
- Record the opened PR in prs.md before the run ends, and disclose agent
involvement in the PR body per AGENTS.md (including the scheduled-run
context and a link to the workflow run).
1---2name: ci-perf3description: Assess and improve CI performance for pull requests in this repo. Use whenever the user asks about CI speed, slow CI, how long checks take, queue/runner wait, slow or duplicate tests, CI cost, or asks to run the recurring CI performance report (/ci-perf). Also use when asked to make PR feedback faster or to review workflow efficiency.4---56# CI performance analysis and improvement78Recurring analysis of PR CI: measure where wall-clock time goes, track it9over time, and turn findings into concrete fixes. Runs two ways — manually10in an interactive session, and unattended every ~2 days via the scheduled11workflow in `meridianlabs-ai/actions` (`inspect-ai-ci-perf.yml`); see12"Scheduled (unattended) mode" below for how the rules differ. Each run is13self-contained.1415## Objective and ground rules1617- **Primary metric: PR wall-clock** — push to all-checks-green. That is18 what contributors feel. Total runner compute-minutes is tracked as a19 secondary metric (it drives queue contention and cost).20- **Queue time and execution time are separate numbers.** Runner-pool21 saturation has historically dominated wall clock here (batches of PRs22 land together; ~13 jobs fan out per PR). Never propose a test speedup to23 fix what is actually queue contention.24- **Two output buckets:**25 - *Safe fixes* — small, low-risk changes the skill prepares as PRs26 (see the fix phase for the category list).27 - *Structural proposals* — anything touching required-check names, job28 topology, or policy. Never a PR from this skill: they rank in the29 report, and ripe ones are filed as issues for a maintainer decision30 (see the fix phase).31- **Never propose trimming the Python version matrix** (e.g. PRs testing32 only 3.11). Explicitly ruled out.33- **Interactive runs: always ask before pushing.** Prepare branch + diff +34 PR body, show the user, push only on their OK. Unattended runs can't ask —35 see "Scheduled (unattended) mode" for what they may push.36- **One PR per run, not per fix.** Every PR costs a full review/approval37 ritual, so combine everything a run produces — snapshot, report, prs.md38 update, and all of the run's safe fixes — into a single PR, one commit39 per logical change. Do not split the run's output across PRs.40 Follow AGENTS.md PR rules (CI-only changes need no CHANGELOG entry;41 test-content changes are product-adjacent — judge per AGENTS.md).4243## Phase 1 — Collect4445Run the bundled collector (venv active; `gh` must be authenticated):4647```bash48python .claude/skills/ci-perf/scripts/collect_ci_data.py \49 --out design/ci-perf/history/$(date +%F).json50```5152It snapshots the last ~200 completed PR workflow runs with per-job53timings (execution seconds, wait-from-run-start) and mines recent Build54test-job logs for pytest `--durations` blocks and the final summary line55(total test counts + pytest wall, for suite-size trends).5657- If `pytest_durations` comes back empty, CI likely doesn't pass58 `--durations` yet — proposing that one-line workflow change is the59 standing first safe fix.60- If a snapshot for today already exists, overwrite it (re-runs same day61 are fine); history keeps one file per day.6263## Repo slow-test policy (context for analysis and fixes)6465- **Definition:** any test that uses docker or hits a real (unmocked)66 external service is by definition slow and must carry67 `@pytest.mark.slow`. A fully-mocked test may also be marked slow when68 its cost is inherent (e.g. crossing the S3 1000-key page boundary means69 1001+ real HTTP PUTs to the in-process moto server) — say why in the70 test's docstring.71- **Where slow tests run:** they are skipped in the PR-gate `test` jobs72 (no `--runslow`) and run every ~2 hours by the scheduled suite in73 `meridianlabs-ai/actions` (`inspect-ai-scheduled-tests.yml`, runner74 label `slow_test_runner`, `pytest --runslow --runapi`). Marking a test75 slow moves its coverage there — it does not delete it. Prefer the PR76 gate for regression guards on core logic when the test can be made77 cheap; prefer slow for anything matching the definition above.78- **The docker trap:** docker is preinstalled and running on GitHub79 `ubuntu-latest`, so nothing technically stops a docker test running in80 the PR gate — `skip_if_no_docker` does not skip in CI. The slow mark is81 the only enforcement, by convention: `@skip_if_no_docker` and82 `@pytest.mark.slow` belong together. When durations data shows a83 multi-second test, check for this pairing violation first (two found so84 far: the 38s sandbox-init test, fixed in #4760, and85 `test_docker_read_file.py` at 42s of fixture setup). Aggregate `setup`86 and `teardown` phases as well as `call` — fixture-heavy offenders hide87 outside `call`.8889## Phase 2 — Analyze9091Read the fresh snapshot plus the previous few in `design/ci-perf/history/`92(trend needs at least one prior; on the first run, report absolute numbers93only). Compute:94951. **Queue vs execution split.** `wait_from_run_start_seconds` is true96 queue time only for jobs without `needs`; for dependent jobs, subtract97 the predecessor's `completed_at` first. Get the dependency map by98 reading `.github/workflows/*.yml` (`needs:` keys) — don't hardcode it;99 workflows change.1002. **Critical path per workflow.** Which chain of jobs determines wall101 clock, and how much of it is waiting? (Historically: `changes` → `test`102 serialization in build.yml put a full queue+start cycle in front of the103 longest job.)1043. **Trends and regressions.** Median/p90 wall clock per workflow vs prior105 snapshots; tests newly appearing in the slowest list or significantly106 slower than before.1074. **Slowest tests** from `pytest_durations` (aggregate across runs;108 median per test). For top offenders, read the test source and classify:109 genuinely heavy, real sleeps/timers that a mock clock or event would110 remove, duplicate coverage, or a candidate for `@pytest.mark.slow`.1115. **Waste.** Cancelled superseded runs and how long they held runners112 before dying; jobs whose checkout/setup overhead exceeds their useful113 work; unconditional `fetch-depth: 0` where history/tags are unused;114 cache effectiveness.1156. **Step-level breakdown of the heavy jobs — always at p90, not just116 median.** The snapshot carries per-step timings; sum each step name's117 median AND p90 across runs. A step can look fine in one sample and be118 the wall-clock lottery across many: `fetch-depth: 0` checkouts fetch119 every branch and tag at full history (a ~400MB pack here), taking 30s120 or 4min depending on GitHub's server-side pack cache. Rule of thumb:121 any always-run step whose p90 exceeds ~2x its median is a variance122 problem, not a size problem — hunt for the erratic dependency123 (pack cache, registry, external download). For checkouts specifically,124 `filter: "blob:none"` keeps setuptools_scm working (refs + commit125 graph) while skipping historical file contents; only jobs that read126 old blob contents (e.g. `git diff main -- <paths>` over sources) need127 care, and even those lazy-fetch on demand.1287. **Suite size — step back from the slow tail.** `pytest_summaries` in129 the snapshot carries total test counts and total pytest wall per job.130 Individual slow tests are only half the story: once the outliers are131 fixed, sheer test count becomes the long pole (N tests × small median132 cost, growing every week). Each run:133 - Report total count and total pytest seconds, and the trend vs prior134 snapshots (absolute and per-week growth rate).135 - Estimate the split: how much of pytest wall is the slow tail136 (durations data) vs the body of ordinary tests? When the body137 dominates, per-test fixes stop paying and the leverage is fewer or138 cheaper tests.139 - Hunt duplicate coverage: several tests exercising the same code path140 with cosmetic variations (candidates for parametrize or deletion),141 new tests added next to older ones that already assert the same142 behavior, and whole files whose subject is also covered elsewhere.143 Sample a few of the fastest-growing test files rather than trying to144 read everything.145 - Hunt low-value tests: asserting trivialities (constructors,146 passthroughs, framework behavior), tests that can't fail unless an147 adjacent test also fails, over-broad matrix legs.148 - Deleting or merging tests is coverage-sensitive: exact duplicates are149 safe fixes; anything judgement-based is a report proposal for a150 maintainer.151152## Phase 3 — Report153154Rewrite `design/ci-perf/report.md` (full replacement each run; history155lives in the snapshots and git). Structure:156157```markdown158# CI performance report — YYYY-MM-DD159Data: N runs, DATE..DATE. Snapshot: history/YYYY-MM-DD.json160161## Summary1622–4 sentences: wall-clock medians per workflow, trend arrow, the one163dominant bottleneck right now.164165## Queue vs execution166Table per workflow/job: median exec, median queue, p90 wall.167168## Slowest tests169Top ~15 with median seconds and classification. "(no data — --durations170not in CI)" if empty.171172## Suite size173Total tests / total pytest seconds per job, trend vs prior snapshots,174slow-tail vs body split, duplicate-coverage and low-value findings175(or what was sampled and came up clean).176177## Regressions since last report178Or "none".179180## Waste181Cancelled-run runner-minutes, overhead-dominated jobs, etc.182183## Proposals (ranked)184Each: what / est. wall-clock impact / disruption (safe-fix vs structural)185/ status (new, PR opened #N, done, declined).186Carry forward prior proposals with updated status — don't drop them.187188## PRs opened by this skill189See prs.md.190```191192**Also maintain `design/ci-perf/prs.md`** — the permanent ledger of every193change and PR this skill has produced. Unlike the report it is never194rewritten, only appended to and updated in place: one entry per PR (number,195date, one-line description, status open/merged/closed, measured impact once196verified). Each run: add entries for any PRs opened, refresh the status of197open ones (`gh pr view`), and record verified impact from the impact-check198phase.199200Leave report + snapshot + prs.md as uncommitted working-tree changes; ask201the user whether to commit them at the end of the run.202203## Phase 4 — Fix204205From the ranked proposals, prepare the top safe fixes (typically 1–3 per206run, shipped together with the report in the run's single PR):207208**Safe-fix categories** (auto-PR eligible, still ask-first):209- Workflow hygiene: add `--durations=50` to pytest, drop unneeded210 `fetch-depth: 0` / `fetch-tags`, cache tuning, merging trivially small211 jobs, removing needless `needs:` serialization.212- Trivial test fixes: real `sleep(...)` waits replaced by mock clocks or213 events, removal of exact-duplicate tests, marking genuinely slow tests214 `@pytest.mark.slow` where an equivalent fast path exists.215216**Structural proposals** (never a PR from this skill): renaming/merging217required checks (branch protection), moving checks between workflows,218retry/concurrency policy changes, anything a reviewer could reasonably219object to on grounds other than correctness. They rank in the report, and220when one is **ripe** — a concrete change with measured impact, worth doing221on the evidence, and no open question the next snapshot would answer —222write it up as an issue on `meridianlabs-ai/inspect_ai` (the org's tracking223repo, same as test-failure triage) so it gets a maintainer decision instead224of scrolling by in successive reports. Before filing, search for an225existing issue (`gh issue list --repo meridianlabs-ai/inspect_ai226--state all --search "<key phrase>"`, plus the issue links already in the227report): if one exists, add the new evidence as a comment rather than228filing a duplicate. Record the issue link in the proposal's status line.229Speculative or still-maturing proposals stay report-only.230231Procedure: one branch off `main` for the whole run. Each fix is its own232commit — make the single change, run the relevant local validation233(`ruff check`, `mypy` for touched Python; the affected tests for test234fixes) — with the snapshot/report/prs.md commits alongside. Write one PR235body per `.github/pull_request_template.md` covering everything the run236ships, then **show the user the diff and PR body and wait for their OK237before any push**. After opening, watch CI per AGENTS.md. Record the PR238number in the report and prs.md.239240## Verifying impact241242Each run, check the proposals marked "PR opened/done" in the previous243report against the new snapshot: did the metric move as predicted? Say so244in the report — honest misses are how estimates get better. Record the245verified (or missed) impact on the PR's entry in prs.md.246247## Scheduled (unattended) mode248249The workflow `inspect-ai-ci-perf.yml` in `meridianlabs-ai/actions` runs250this skill every ~2 days with no user present (it sets `CI_PERF_SCHEDULED=1`251and says so in the prompt). Differences from an interactive run:252253- **Don't ask — act, within these bounds.** Commit the snapshot, report,254 prs.md updates, and up to 2 safe fixes (safe-fix categories only, one255 commit each, local validation run and passing) on one branch and open256 ONE PR — the run's entire output ships as a single PR. Structural257 proposals are never shipped as changes; ripe ones are filed as issues258 per the fix phase (the fork tracking repo accepts marvin's issue writes259 even while upstream PRs are blocked).260- **Check the previous run's PR first** (`gh pr list --author i-am-marvin`261 plus the open entries in prs.md). If it is still open, push this run's262 commits onto its branch instead of opening a second PR. Never re-ship a263 fix that's already sitting in the open PR, and respect the AGENTS.md264 open-PR limit (4 per account).265- **Contribution-policy compliance:** the marvin account is recorded as a266 qualified contributor in `.github/qualified.yml`, which satisfies the PR267 gate; the substantive rules still apply — the PR body must carry the268 measured evidence from the snapshot for every fix it ships (AGENTS.md269 rule 7).270- **Push mechanics:** the token is the marvin machine account, which has271 write access on the `meridianlabs-ai/inspect_ai` fork but not upstream.272 Push branches to the fork and open PRs against273 `UKGovernmentBEIS/inspect_ai` following the "Opening an upstream PR from274 an org fork" section of AGENTS.md (`gh api` with `head_repo`).275- **Skip anything doubtful.** If local validation fails, the fix touches276 more than intended, or the change is only arguably in a safe-fix277 category, drop it to a report proposal instead of shipping it. An278 unattended run that ships zero fixes is a fine outcome; one that ships279 a wrong fix is not.280- Record the opened PR in prs.md before the run ends, and disclose agent281 involvement in the PR body per AGENTS.md (including the scheduled-run282 context and a link to the workflow run).