Skip to content

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
os-zhuang merged 7 commits into
mainfrom
claude/issue-25169-relay-rerun-failed
Oct 22, 2026
Merged

os-zhuang merged 7 commits into
mainfrom
claude/issue-25169-relay-rerun-failed

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Refs #25169
Clause-②: no

The fleet-write relay gains one fenced op, run_rerun_failed. It re-runs only the failed jobs of one ci.yml pull-request run, once, and only while that run's head is still its pull's current head, as objectstack-fleet[bot], through a relay-only seat wrapper, scripts/pm/rerun-failed.mjs. .github/workflows/fleet-write.yml is not edited: the existing stroke-scoped permission-actions input 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 is run_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.

fence what it requires authoritative check early, advisory check
workflow the run's path is in RERUN_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 in ops.mjs, never a payload key. relay executor, on the run it reads just before the POST wrapper, before the dispatch
event event is pull_request (push, merge_group, schedule, release, workflow_dispatch and pull_request_target are refused) relay executor, same read wrapper
once run_attempt is 1 relay executor, same read wrapper
failed status is completed and conclusion is failure relay executor, same read wrapper
head the run's head_sha is the CURRENT head of its pull. The pull is the run's pull_requests[0].number, and its head is read with that pull's own GET /repos/{repo}/pulls/{n}, never from the copy the run embeds. A run with no pull_requests entry (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. relay executor, on the pull it reads right after the run, the last request before the POST wrapper
alone the stroke carries exactly that one action (alone: true) validator: at the seat's packer and again in the relay before any token is minted —
grant actions: write is minted for that stroke alone validator's permission_actions output, read by the workflow's mint step —

The five run fences share one set of predicates, RERUN_FAILED_FENCES / rerunFailedFenceFailures in ops.mjs; each takes the run and the pull's own answer. The executor's rerunFailedPreflight reads attempt 1's job list first, as the read-back's baseline. It then reads GET /repos/{repo}/actions/runs/{run_id} and, for the pull the run names, that pull's GET /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 row not 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

tokenPermissionsOf adds a stroke-scoped grant whenever an action's op spends it. run_rerun_failed spends actions, so the validator writes permission_actions=write for its stroke exactly as it does for workflow_dispatch, and an empty value for every other stroke. The workflow's permission-actions: ${{ steps.validate.outputs.permission_actions }} input then mints it unchanged. validate.mjs --self-test proves this with these cases, in the new battery:

  • "⭐ …and the GitHub output spells permission_actions=write for its stroke, exactly as for a workflow_dispatch stroke"
  • "⛔ fence grant, refused side: every op spending no actions grant writes an EMPTY permission_actions — each alone in its stroke, and the stroke of them all that shares one token"
  • "⭐ ruling 3, no workflow edit: fleet-write.yml mints permission-actions from the validator's permission_actions output — the one input this row rides — and names neither this op nor its path"

.github/workflows/fleet-write.yml is therefore untouched, and the PR is not Tier H.

What stays refused

REFUSED_PATH_FAMILIES carves out exactly actions/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-test proves 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:

  • the whole-run runs/{id}/rerun;
  • a job's jobs/{id}/rerun;
  • cancel, force-cancel and delete (runs/{id});
  • logs and attempts;
  • caches, artifacts, secrets and variables;
  • a re-run path with anything around its run 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 shows run_attempt 2, then reads attempt 2's job list. The action lands only on rerunFailedReadBack (ops.mjs): every attempt-1 failed job that attempt 2 lists must be queued or in_progress, and at least one must be. The row then names the new attempt and those job ids, for example attempt 2 · re-run: Test Core (3/6) job 119642047246 queued · not listed yet: Test Core. The read-back fails the action in three cases:

  • it never sees attempt 2 inside the window: a FAILED action, UNCONFIRMED, "do not re-send";
  • a failed job is listed completed in attempt 2;
  • the job list cannot be read.

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. The Test Core aggregate that needs every shard was listed only 16 minutes later, when its need finished. Requiring every attempt-1 failure to be queued or in_progress at once would have failed every real use on ci.yml.

The usage line and where it lives

.claude/skills/pm-dispatch/references/platform-readings.md is the one pm-dispatch reference the ruling's grep finds, and the line sits in its re-run cluster:

经 rerun-failed.mjs 重跑 PR ci.yml 失败 job 仅一次,先在 PR 或卡写明 flake 理由;二败即真红。

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}/rerun 403、MCP rerun_failed_jobs 201 ⇒ 重跑可做,锁 1 未禁。". The same REST 403 / MCP 201 reading stands at the file's earlier line, "读数:POST /actions/runs/{id}/rerun-failed-jobs REST 回 403 而 MCP 回 201。". check:pm-skill-ratchet stays green at 460 of 460 lines. .claude/agents/os-dev.md is not touched: a dev never re-runs. The head rule does not fit as well: the line measures 119 of its 120 bytes, so the head fence alone carries it, in code.

The wrapper, scripts/pm/rerun-failed.mjs

The wrapper follows label-write.mjs's style:

  • it reads the run and then its pull by the pull's own GET, and judges the five fences (advisory, exit 2 naming each failing fence, nothing sent); a pull it cannot read is a prerequisite refusal (exit 3);
  • it names attempt 1's failed jobs;
  • it packs ONE run_rerun_failed action and sends it through sendFleetWrite;
  • after a success run it reads attempt 2's job ids back as a reading, never a verdict;
  • after a failed run it re-reads the run and its pull and says what the fences read now.

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 its relayMissProbe. That conformance learns one named relay-only sender (relayOnly = true), whose control refuses under direct instead 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, so write-pace's write-tool roster is unchanged.

Governed tier

node scripts/pm/check-governed-merges.mjs --pr 25186 reads GOVERNED, Tier S, at head 49804f28cf. The only governed path is .claude/skills/pm-dispatch/references/platform-readings.md, and no Tier H path is in the diff: the root package.json is 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 on f2ee5ad67d: 120ca3ca58 (the head fence) and 49804f28cf (the CI wiring). git merge-tree --write-tree origin/main HEAD against origin/main 2069b81c26 is 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 --commands derived 59 families from the seven changed paths; the root package.json adds 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-closure and check: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). --ran reconciles: "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 the head fence), 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 the head fence), and the floor rose from 18 to 19.
  • pnpm check:pm-fleet-write-dispatch now runs dispatch.mjs --self-test and then rerun-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 the head fence). lint.yml already 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 includes check: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-graph and check:nul-bytes.
  • CI: the PR's check-runs are the record. Ablation: none, because the diff is scripts, one npm script line and a doc line.

Acceptance notes

  • .github/workflows/fleet-write.yml's comments (its header, the validate step and the permission-actions input) still call workflow_dispatch the ONE consumer of actions: 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.
  • Fix round 1 wired scripts/pm/rerun-failed.mjs --self-test into the root check:pm-fleet-write-dispatch script, which lint.yml already runs, so it reaches CI with no workflow edit.
  • .claude/settings.json has no allow rule for node scripts/pm/rerun-failed.mjs or its --use-env-proxy form, while every sibling write tool has both. A seat's first use may meet a permission prompt. Carrier: none.
  • RERUN_FAILED_WORKFLOWS is a list of paths, as the card spells it, so it admits .github/workflows/ci.yml in any fleet repository the envelope names. objectui has one. The sender gate still requires write on that repository.
  • Fix round 1 added the seventh fence, head, by the seat's ruling on this PR. A stale head's re-run would cancel the current head's run, because ci.yml's concurrency group is keyed by the pull number with cancel-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 由此覆盖它,工作流不改。
  • 为什么改:CI 遇到注册表或网络抖动时,席位此前没有合规的重跑手段,只能等维护者手点「Re-run failed jobs」(fix(cli)!: refuse a production os serve that composes no organization object, --server or not #25155 的 run 39713789453 即是一例)。
  • 风险与代价(含回滚):这一笔的令牌多带 actions: write,所以加了七道闸。工作流、事件、仅一次、已失败、head 为该 PR 当前 head 这五道由中继在执行时实判(head 用 PR 自己的 GET 读,不信 run 里嵌的副本);独占一笔与令牌只为这一笔铸造这两道由校验器判。其余 actions/* 路径(整跑重跑、单 job 重跑、取消、删除、缓存、制品、密钥、变量)仍全部拒绝,有自测钉住。工作流文件未改。回滚只需还原本 PR,无数据迁移。
  • 席位意见:
  • 你要做的:审阅并批准、合并本 PR(Tier S,按派发令由维护者合)。合并后,首次真实 flake 由席位按新规则跑一次,并在卡上回读 attempt 2。

Generated by Claude Code

…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>
…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>
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Oct 22, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat review

ACCEPT (seat review of record; not an approval) · domain:devx seat 1 · os-warren · session_01FM29ZqLTrnPNXxijioZwMp · 2026-10-22T08:50Z · head 49804f28cf · card #25169 (claim 6334469056, addendum 6336065284, reports 6335627986, 6336001940 and 6336619662)

  • Form.
    • Base main. The first line is Refs #25169, then Clause-②: no. It is not a closing keyword: the card's last proof is the first real use after the merge.
    • Assignee os-warren; labels documentation, size/xl, tooling, skip-changeset.
    • Seven commits, no rewrite.
    • Seven paths, all inside the claim and its addendum: scripts/pm/fleet-write/{ops,validate,dispatch,execute}.mjs, scripts/pm/rerun-failed.mjs, the root package.json (one script line) and .claude/skills/pm-dispatch/references/platform-readings.md (one line).
    • .github/workflows/fleet-write.yml is untouched: the existing stroke-scoped permission-actions input carries the op.
    • node scripts/pm/check-governed-merges.mjs --pr 25186 → GOVERNED, Tier S (.claude/** ×1).
  • The op, read in the diff by this seat.
    • run_rerun_failed is one bodiless POST /repos/{repo}/actions/runs/{run_id}/rerun-failed-jobs, with run_id as its only key, and it is alone in its stroke.
    • Seven fences. Two are judged by the validator: alone in its stroke, and the stroke-scoped actions: write grant. Five are judged by the executor (rerunFailedPreflight): workflow allowlist, pull_request event, attempt 1, completed failure, and head. The executor makes its last two reads before the POST, the run and then the pull's own GET. A refused fence writes nothing.
    • The wrapper judges the same five earlier, as an advisory check.
    • Read-back. It counts as landed only on attempt 2, and it names the new job ids.
    • What stays refused. The whole-run re-run, jobs/{id}/rerun, cancel, delete, caches, artifacts, secrets and variables, each pinned by a self-test.
  • Fix round 1 (the seat's ruling on the dev's open question 2).
    • The six fences the card named did not compare the run's head with the pull's current head. ci.yml:88–:90 keys its concurrency group on the pull number with cancel-in-progress: true, so re-running a stale head's run would cancel the current head's run. SKILL.md's existing MCP re-run exception already reads 「先核 head」.
    • 120ca3ca58 adds the head fence. The pull is read by its own GET, never from the run's embedded copy, and a run with no pull_requests entry is refused by name.
    • 49804f28cf wires rerun-failed.mjs --self-test into check:pm-fleet-write-dispatch, which lint.yml:1412 runs.
    • Open questions 3 (a dependent failed job that attempt 2 does not list yet is named, not failed) and 4 (each fence's cases live where it is judged) are accepted as built.
  • PR body. This seat rewrote it at this head from the dev's 14 replacement passages. It changed one sentence, so that it states this seat readies the PR and does not approve, queue or arm it. Read-back is identical.
  • Gates.
    • The dev's union at 49804f28cf: 59 derived, 59 run, all exit 0. Five needed their declared builds first, under the verify lock. --ran reconciles.
    • Self-tests: validate 193 cases / 12 batteries; execute 188 / 19; dispatch 247 / 22; rerun-failed 31 / 5.
    • git merge-tree against origin/main is clean.
    • Check-runs at 49804f28cf: 48, 37 success, 11 skipped, 0 failing or pending. node scripts/pm/check-expected-skips.mjs --pr 25186 → OK (11 skipped, every one in the roster).
  • For the maintainer, not decided here.
    1. Once this op's first real use reads back, should SKILL.md :92's MCP rerun_failed_jobs exception retire, so there is one re-run path? The dev recommends yes. That would be a Tier S SKILL.md edit.
    2. .claude/settings.json has no allow rule for node scripts/pm/rerun-failed.mjs, where its sibling write tools have one, so a seat's first use may prompt. That is a config change, and this seat does not make it.
  • Noted, not carded:
    • fleet-write.yml's comments still call workflow_dispatch the one actions: write consumer, a Tier H comment-only drift;
    • RERUN_FAILED_WORKFLOWS is path-only, so it also admits objectui's ci.yml, where the sender gate still requires write;
    • the head fence does not require the pull to be open.

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 22, 2026 08:52
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 22, 2026
Merged via the queue into main with commit 2919ddb Oct 22, 2026
49 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-25169-relay-rerun-failed branch October 22, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants