Repository navigation
feat(pm/fleet-write): run_rerun_failed — re-run one ci.yml pull-request run's failed jobs, once, fenced in the relay - #25186
Merged
Conversation
…lidator battery
One relay op re-runs only the failed jobs of one ci.yml pull-request run,
once: run_id alone, alone in its stroke, actions write minted for that
stroke through the existing stroke-scoped output, and four fences on the
run itself (workflow, event, once, failed) that the executor judges.
REFUSED_PATH_FAMILIES carves out exactly runs/{digits}/rerun-failed-jobs.
Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
… and reads it back The four fences on the run are judged in the relay, on the run read immediately before the one bodiless POST; a fence that fails, an unread run or an unread or empty baseline sends nothing. The 201 lands only when the run reads back as attempt 2 with the jobs attempt 1 failed queued or in progress, and the row names their new job ids. Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude <noreply@anthropic.com>
…_failed The wrapper reads the run and judges the four fences as an advisory early refusal, names the jobs attempt 1 failed, sends ONE run_rerun_failed stroke through sendFleetWrite, and reads attempt 2's job ids back as a reading. It has no direct path: a direct route is a prerequisite refusal with zero writes. dispatch.mjs's no-run conformance learns a named relay-only sender, whose control refuses under direct instead of writing, and gains the seat battery for the stroke. Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude <noreply@anthropic.com>
…adings The line replaces the dated reading that a re-run was doable through MCP: a seat re-runs a PR's failed ci.yml jobs once through rerun-failed.mjs, only after writing down why it judges the failure a flake; a second failure is real. The REST 403 / MCP 201 reading stands at its earlier line. Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude <noreply@anthropic.com>
…ad is its pull's current head
A run_rerun_failed is now refused unless the run's head_sha equals the
current head of the pull it names. The pull is the run's
pull_requests[0].number, and its head is read with the pull's own
GET /repos/{repo}/pulls/{n}, never from the copy the run embeds. A run
naming no pull (a fork pull request's run, or the field empty) is refused
by name. ci.yml's concurrency group is keyed by the pull number with
cancel-in-progress true, so a stale head's re-run would cancel the
current head's run.
The executor judges it authoritatively: the run and then the pull are
the last two reads before the POST, and a stale head, a run naming no
pull or an unreadable pull is zero writes with the POST row not sent.
The wrapper judges it earlier as an advisory refusal naming the fence.
RERUN_FAILED_FENCES gains the head fence, every predicate now takes
(run, pull), and the self-test batteries that judge it gain cases on
both sides, with their pins raised: validate 30 to 33, execute 17 to
21, rerun-failed's seat fences 8 to 11.
Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp
Co-authored-by: Claude <noreply@anthropic.com>
…-self-test The wrapper's self-test reaches CI through the script lint.yml already runs, with no workflow edit. Claude-Session: https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp Co-authored-by: Claude <noreply@anthropic.com>
Contributor
Author
Seat reviewACCEPT (seat review of record; not an approval) ·
Governed (Tier S): this seat readies the PR, which requests the maintainer's review. ⛔ It does not approve, queue or enable auto-merge. The maintainer approves and merges. Generated by Claude Code |
os-zhuang
approved these changes
Oct 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #25169
Clause-②: no
The fleet-write relay gains one fenced op,
run_rerun_failed. It re-runs only the failed jobs of oneci.ymlpull-request run, once, and only while that run's head is still its pull's current head, asobjectstack-fleet[bot], through a relay-only seat wrapper,scripts/pm/rerun-failed.mjs..github/workflows/fleet-write.ymlis not edited: the existing stroke-scopedpermission-actionsinput carries the new op (ruling 3, evidence below). The card stays open, because its last proof is the first real use on a genuine flake after this merges. No real use was made here, since the relay runs main's code.The row and its seven fences, with where each is enforced
run_rerun_failed(scripts/pm/fleet-write/ops.mjs) is one request,POST /repos/{repo}/actions/runs/{run_id}/rerun-failed-jobs, with no body. The platform's optional debug-logging flag is never sent. The payload isrun_id, a positive integer, and nothing else. A workflow, attempt, job, event, ref or debug-logging key beside it is refused by name at the validator.pathis inRERUN_FAILED_WORKFLOWS, a closed list matched whole. Today it is exactly.github/workflows/ci.yml(measured spelling on run 39713789453: no ref suffix). It is a constant inops.mjs, never a payload key.eventispull_request(push, merge_group, schedule, release, workflow_dispatch and pull_request_target are refused)run_attemptis 1statusiscompletedandconclusionisfailurehead_shais the CURRENT head of its pull. The pull is the run'spull_requests[0].number, and its head is read with that pull's ownGET /repos/{repo}/pulls/{n}, never from the copy the run embeds. A run with nopull_requestsentry (a fork pull request's run, or the field empty) is refused by name.ci.yml's concurrency group is keyed by the pull number withcancel-in-progress: true, so a stale head's re-run would cancel the current head's run.alone: true)actions: writeis minted for that stroke alonepermission_actionsoutput, read by the workflow's mint stepThe five run fences share one set of predicates,
RERUN_FAILED_FENCES/rerunFailedFenceFailuresinops.mjs; each takes the run and the pull's own answer. The executor'srerunFailedPreflightreads attempt 1's job list first, as the read-back's baseline. It then readsGET /repos/{repo}/actions/runs/{run_id}and, for the pull the run names, that pull'sGET /repos/{repo}/pulls/{n}, the last two requests before the POST, and judges the five fences on those answers. A run that changed between the request and the relay's run, for example one a person re-ran meanwhile, or a pull pushed to meanwhile, is refused there. A fence that fails, an unreadable run or pull, an unreadable baseline or a baseline with no failed job stops the action with its POST rownot sent: zero writes, exit 5, every failing fence named with what was read. The wrapper judges the same five fences earlier and names the one that fails. That check is advisory and never replaces the executor's.The token (ruling 3): the existing input carries it, so no workflow edit
tokenPermissionsOfadds a stroke-scoped grant whenever an action's op spends it.run_rerun_failedspendsactions, so the validator writespermission_actions=writefor its stroke exactly as it does forworkflow_dispatch, and an empty value for every other stroke. The workflow'spermission-actions: ${{ steps.validate.outputs.permission_actions }}input then mints it unchanged.validate.mjs --self-testproves this with these cases, in the new battery:.github/workflows/fleet-write.ymlis therefore untouched, and the PR is not Tier H.What stays refused
REFUSED_PATH_FAMILIEScarves out exactlyactions/runs/{digits}/rerun-failed-jobs. The catch-all reads/actions/(?!workflows/[^/]+/dispatches$|runs/\d+/rerun-failed-jobs$)and the run family reads/actions/runs/(?!\d+/rerun-failed-jobs$).validate.mjs --self-testproves each of the following is caught by a refused family, and that a payload naming any of them as an op is refused with zero writes:runs/{id}/rerun;jobs/{id}/rerun;runs/{id});runs/x/rerun-failed-jobs,runs/1/rerun-failed-jobs/x,runs/1/attempts/1/rerun-failed-jobs).The table now reaches exactly two
actions/*paths: the dispatch call and this re-run.The read-back
The 201 is an acceptance, not a landing.
confirmRerunFailed(execute.mjs) re-reads the run every 3 s for up to 60 s until it showsrun_attempt2, then reads attempt 2's job list. The action lands only onrerunFailedReadBack(ops.mjs): every attempt-1 failed job that attempt 2 lists must bequeuedorin_progress, and at least one must be. The row then names the new attempt and those job ids, for exampleattempt 2 · re-run: Test Core (3/6) job 119642047246 queued · not listed yet: Test Core. The read-back fails the action in three cases:completedin attempt 2;One measured deviation from the ruling's wording: a failed job that attempt 2 does not list YET is named, not failed. On run 39713789453, attempt 2 listed the failed shard at once,
queued, two seconds before it started. TheTest Coreaggregate that needs every shard was listed only 16 minutes later, when its need finished. Requiring every attempt-1 failure to bequeuedorin_progressat once would have failed every real use onci.yml.The usage line and where it lives
.claude/skills/pm-dispatch/references/platform-readings.mdis the one pm-dispatch reference the ruling's grep finds, and the line sits in its re-run cluster:The file's line budget is 120 bytes and its ceiling has zero headroom, so the line names the tool bare, as that file names every tool. It is paid for by deletion: it replaces the dated reading "读数 2026-09-13:席位 REST
/jobs/{id}/rerun403、MCPrerun_failed_jobs201 ⇒ 重跑可做,锁 1 未禁。". The same REST 403 / MCP 201 reading stands at the file's earlier line, "读数:POST /actions/runs/{id}/rerun-failed-jobsREST 回 403 而 MCP 回 201。".check:pm-skill-ratchetstays green at 460 of 460 lines..claude/agents/os-dev.mdis not touched: a dev never re-runs. The head rule does not fit as well: the line measures 119 of its 120 bytes, so theheadfence alone carries it, in code.The wrapper,
scripts/pm/rerun-failed.mjsThe wrapper follows
label-write.mjs's style:run_rerun_failedaction and sends it throughsendFleetWrite;It is RELAY-ONLY. A direct route is a prerequisite refusal (exit 3) with zero writes, and an accepted dispatch with no run is UNCONFIRMED (exit 6), never a direct write.
dispatch.mjs's no-run conformance now discovers it as a sender and drives itsrelayMissProbe. That conformance learns one named relay-only sender (relayOnly = true), whose control refuses underdirectinstead of writing, with its reads recorded. The set of relay-only senders is pinned by name, so no sender can skip the write control unreviewed. The wrapper contains no write verb of its own, sowrite-pace's write-tool roster is unchanged.Governed tier
node scripts/pm/check-governed-merges.mjs --pr 25186reads GOVERNED, Tier S, at head49804f28cf. The only governed path is.claude/skills/pm-dispatch/references/platform-readings.md, and no Tier H path is in the diff: the rootpackage.jsonis not on the register. The size is 1,408 changed lines, under the 3,000 human-merge threshold. As the dispatch directs, the maintainer approves and merges this PR. The seat readies it, which requests the maintainer's review; no seat approves, queues or arms it.Gates
All local readings below were taken at head
49804f28cf, the final commit. Fix round 1 added two commits onf2ee5ad67d:120ca3ca58(theheadfence) and49804f28cf(the CI wiring).git merge-tree --write-tree origin/main HEADagainstorigin/main2069b81c26is clean (exit 0, a tree id alone), and none of main's five newer commits touches a file this PR touches, so no merge was made.node scripts/pm/dispatch-gates.mjs --commandsderived 59 families from the seven changed paths; the rootpackage.jsonadds eight. All 59 ran. On the fresh worktree, five first answered exit 3, PREREQUISITE NOT MET:check:doc-formula-expressions,check:dts-closure,check:dual-build-cjs-loads,check:lean-entry-closureandcheck:sourcemap-no-sources-content. Each passed after the build it declares, run under the verify lock: its two packages, then the whole workspace (76 of 76 tasks).--ranreconciles: "59 derived famil(ies) accounted for — 59 run, 0 NOT-MEASURED (a DERIVED zero — all 59 recorded an exit code and none of them is 3)".pnpm check:pm-fleet-write-validate: 193 cases across 12 batteries. The new battery has 33 cases (30 before theheadfence), and the floor rose from 11 to 12.pnpm check:pm-fleet-write-execute: 188 cases across 19 batteries. The new battery has 21 cases (17 before theheadfence), and the floor rose from 18 to 19.pnpm check:pm-fleet-write-dispatchnow runsdispatch.mjs --self-testand thenrerun-failed.mjs --self-test. The first: 247 cases across 22 batteries. The new seat battery has 6 cases, the conformance adds the new sender and the relay-only roster, and the floor rose from 21 to 22. The second: 31 cases across 5 batteries, its seat-fence battery 11 (8 before theheadfence).lint.ymlalready runs this script, so the wrapper's self-test reaches CI with no workflow edit.pnpm check:pm-dispatch-gates(fast tier) and every other derived family: exit 0. That includescheck:pm-skill-ratchet,check:pm-skill-id-lint,check:pm-write-pace,check:entry-guard,check:self-test-wired,check:merge-driver,check:turbo-task-graphandcheck:nul-bytes.Acceptance notes
.github/workflows/fleet-write.yml's comments (its header, the validate step and thepermission-actionsinput) still callworkflow_dispatchthe ONE consumer ofactions: write, and say the op table has no re-run row. Ruling 3 kept the workflow out of this PR, so those comments are now stale prose. Carrier: none.scripts/pm/rerun-failed.mjs --self-testinto the rootcheck:pm-fleet-write-dispatchscript, whichlint.ymlalready runs, so it reaches CI with no workflow edit..claude/settings.jsonhas no allow rule fornode scripts/pm/rerun-failed.mjsor its--use-env-proxyform, while every sibling write tool has both. A seat's first use may meet a permission prompt. Carrier: none.RERUN_FAILED_WORKFLOWSis a list of paths, as the card spells it, so it admits.github/workflows/ci.ymlin any fleet repository the envelope names. objectui has one. The sender gate still requires write on that repository.head, by the seat's ruling on this PR. A stale head's re-run would cancel the current head's run, becauseci.yml's concurrency group is keyed by the pull number withcancel-in-progress: true. pm-dispatch SKILL.md's MCP exception already requires checking the head first, so the relay path is no weaker than the old one. The fence does not require the pull to be open; that was not ruled. Carrier: none.维护者速读(草稿)
ci.yml的 PR 运行,且只认 head 仍是该 PR 当前 head 的那次运行;只在首次运行失败后重跑一次,并且只重跑失败的 job。席位通过新脚本scripts/pm/rerun-failed.mjs调用,身份是objectstack-fleet[bot]。pm-dispatch 参考文档补了一行使用规则:先写明为何判为 flake,再重跑,二次失败即真红。根package.json的check:pm-fleet-write-dispatch同时跑该脚本的自测,CI 由此覆盖它,工作流不改。actions: write,所以加了七道闸。工作流、事件、仅一次、已失败、head 为该 PR 当前 head 这五道由中继在执行时实判(head 用 PR 自己的 GET 读,不信 run 里嵌的副本);独占一笔与令牌只为这一笔铸造这两道由校验器判。其余actions/*路径(整跑重跑、单 job 重跑、取消、删除、缓存、制品、密钥、变量)仍全部拒绝,有自测钉住。工作流文件未改。回滚只需还原本 PR,无数据迁移。Generated by Claude Code