Skip to content

Commit 2919ddb

Browse files
feat(pm/fleet-write): run_rerun_failed — re-run one ci.yml pull-request run's failed jobs, once, fenced in the relay (#25186)
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」(#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](https://claude.ai/code/session_01FM29ZqLTrnPNXxijioZwMp)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9057fd4 commit 2919ddb

7 files changed

Lines changed: 1353 additions & 55 deletions

File tree

‎.claude/skills/pm-dispatch/references/platform-readings.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@
289289
- 判定跨天翻面 ⇒ ⛔ 一次拒绝不是能力边界;`permissions.allow` 条目才是仓库侧确定性通道。
290290
- `rerun_failed_jobs` 复用原 run 的提交与合并 ref,不拿新 main 重算。
291291
- ⇒ 基上缺已合修复时重跑无效,只能 `git merge origin/main` 推提交;判别看修复合并时间。
292-
- 读数 2026-09-13:席位 REST `/jobs/{id}/rerun` 403、MCP `rerun_failed_jobs` 201 ⇒ 重跑可做,锁 1 未禁。
292+
- 经 `rerun-failed.mjs` 重跑 PR `ci.yml` 失败 job 仅一次,先在 PR 或卡写明 flake 理由;二败即真红。
293293
- 同一 head 上轻量兄弟 workflow `success` 加重量级载体 `cancelled` 是普通取代的预期签名。
294294
- 同名 `failure` 也会被带另一诊断的后一次 `failure` 取代 ⇒ 任何判定前先按名取最新一次。
295295
- cancel-in-progress 窗口只罩得住慢载体 ⇒ 先比对 run `head_sha` 与 PR 当前 head,不开调查。

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
"check:pm-close-cards": "node scripts/pm/close-cards.mjs --self-test",
9797
"check:pm-fleet-write-validate": "node scripts/pm/fleet-write/validate.mjs --self-test",
9898
"check:pm-fleet-write-execute": "node scripts/pm/fleet-write/execute.mjs --self-test",
99-
"check:pm-fleet-write-dispatch": "node scripts/pm/fleet-write/dispatch.mjs --self-test",
99+
"check:pm-fleet-write-dispatch": "node scripts/pm/fleet-write/dispatch.mjs --self-test && node scripts/pm/rerun-failed.mjs --self-test",
100100
"check:pm-board-snapshot": "node scripts/pm/board-snapshot.mjs --self-test",
101101
"check:pm-changeset-deadline-census": "node scripts/pm/changeset-deadline-census.mjs --self-test",
102102
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",

‎scripts/pm/fleet-write/dispatch.mjs‎

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,16 @@
241241
* 422 as a no-op, before it calls the action landed — so a `success` run
242242
* IS that confirmation; the seat reads the new head with one
243243
* `GET /repos/{repo}/pulls/{n}` after, and this file reads nothing back;
244+
* - `run_rerun_failed` carries no body, names no workflow and is
245+
* confirmed INSIDE the run too: the executor judges the five fences on
246+
* the run and its pull's current head before its POST, and lands the
247+
* action only when the run reads
248+
* back as attempt 2 with its failed jobs queued or in progress
249+
* (`execute.mjs`, `confirmRerunFailed`). So a `success` run IS that
250+
* confirmation and this file reads nothing back; the seat's wrapper
251+
* (`scripts/pm/rerun-failed.mjs`) reads the new attempt's job ids after,
252+
* as a reading for its note. A run that failed (a fence refused it, or
253+
* the re-run did not read back) is exit 5 like any failed run;
244254
* - a `pr_create` carrying `assignees` is read back at its body's locator
245255
* as before, and its row ALSO carries the pull's `assignees` as the
246256
* platform lists them (`assigneesReading`; `--json` carries them too) —
@@ -301,7 +311,13 @@
301311
* (any request but a GET), printing the shared `unconfirmedText` sentence;
302312
* - the control drives each under `direct`: at least one direct write and no
303313
* dispatch — so the empty list above is a measurement, never a recorder
304-
* that sees nothing;
314+
* that sees nothing. A RELAY-ONLY sender — one exporting
315+
* `relayOnly = true` because its op has no direct path at all
316+
* (`rerun-failed.mjs`: the relay's executor is where its fences are
317+
* authoritative) — answers the control the other way round: under
318+
* `direct` it refuses with a PREREQUISITE exit, ZERO direct writes and
319+
* no dispatch, and the recorder holds the reads it made first, so its
320+
* empty write list is a measurement too;
305321
* - a sender without the probe fails the pin until it carries one and gives
306322
* the same answer. ⛔ There is no third, quieter answer to an accepted
307323
* dispatch with no run. This file's own CLI (`with-fleet.sh --actions`
@@ -412,6 +428,8 @@ export const RELAY_JOB_TIMEOUT_MS = 10 * 60 * 1000;
412428

413429
/** The export every sender carries for the no-run conformance (the header's section of that name). */
414430
const RELAY_MISS_PROBE = 'relayMissProbe';
431+
/** The export a RELAY-ONLY sender sets to `true`: its op has no direct path, so its control refuses instead of writing (header). */
432+
const RELAY_ONLY = 'relayOnly';
415433

416434
// ---------------------------------------------------------------------------
417435
// Pure core
@@ -1443,8 +1461,10 @@ const SELF_TEST_BATTERIES = Object.freeze({
14431461
"the started run: an action naming a workflow is read back as the newest workflow_dispatch run of that file created since the dispatch — the target repo's run list, read after the relay run completed and re-read within a bounded window; found is success naming id, url and actor; none in the window or an unreadable list is UNCONFIRMED (6) after ONE dispatch; a stroke naming no workflow reads none": 11,
14441462
"the release.yml refresh at the seat: the stroke packs with inputs refresh_version_pr true and travels in ONE dispatch; its started run is read back from release.yml's own run list; force, false, absent or any other input is refused before anything is sent, exit 2": 6,
14451463
"the batch gap at the seat: content creation read off the op table; a stroke at or above write-pace's threshold is batched through its last content-creating write and waited for exactly that much longer; a stroke whose pacing the relay job cannot hold is refused before anything is sent, naming the per-op doors; the job's timeout mirrors the workflow's": 13,
1464+
"the run_rerun_failed stroke at the seat: it packs as ONE action carrying run_id alone and travels in ONE dispatch; beside any other action, or with a key that widens it, it is refused before anything is sent; it creates no content, carries no body and names no workflow, so nothing is read back here — a success run is the executor's confirmation; a run that failed at the relay's fence is exit 5 after ONE dispatch, never re-sent": 6,
14461465
});
1447-
const SELF_TEST_BATTERY_FLOOR = 21;
1466+
// Raised 21 → 22 with the run_rerun_failed seat battery: one battery, one row.
1467+
const SELF_TEST_BATTERY_FLOOR = 22;
14481468
const UNATTRIBUTED_BATTERY = '(unattributed)';
14491469

14501470
const batteryCases = new Map();
@@ -2201,6 +2221,30 @@ export async function selfTest() {
22012221
t('--dry-run prints the relay request carrying the refresh with its inputs and sends nothing, exit 0', [dry.code, dry.seen.length, dry.json?.event_type, dry.json?.client_payload?.actions], [EXIT_OK, 0, RELAY_EVENT_TYPE, [REFRESH]]);
22022222
}
22032223

2224+
// ── the failed-jobs re-run at the seat ──────────────────────────────────
2225+
battery("the run_rerun_failed stroke at the seat: it packs as ONE action carrying run_id alone and travels in ONE dispatch; beside any other action, or with a key that widens it, it is refused before anything is sent; it creates no content, carries no body and names no workflow, so nothing is read back here — a success run is the executor's confirmation; a run that failed at the relay's fence is exit 5 after ONE dispatch, never re-sent");
2226+
{
2227+
const REPO = 'objectstack-ai/objectstack';
2228+
const RERUN = { op: 'run_rerun_failed', run_id: 39713789453 };
2229+
const pack = (actions) => packRequest({ repo: REPO, session: SESSION, actions, requestId: 'fw-test-1' });
2230+
const packed = pack([RERUN]);
2231+
t('the stroke packs: one action, op and run_id alone — no workflow, event or attempt travels, those are the run\'s to answer', [packed.ok, packed.payload?.actions, Object.keys(packed.payload?.actions?.[0] ?? {})], [true, [RERUN], ['op', 'run_id']], packed.errors.join(' | '));
2232+
const refusedFor = (actions, needle) => {
2233+
const p = pack(actions);
2234+
return !p.ok && p.payload === null && p.errors.some((e) => e.includes(needle));
2235+
};
2236+
t('⛔ alone, at the packer: beside a comment or a workflow_dispatch it is refused before anything is sent, naming the action that does not belong', [refusedFor([RERUN, { op: 'comment', issue: 1, body: 'x' }], 'actions[1] is `comment` in a stroke that carries a `run_rerun_failed`'), refusedFor([RERUN, { op: 'workflow_dispatch', workflow: 'shard-timings-refresh.yml', ref: 'main' }], 'actions[1] is `workflow_dispatch` in a stroke that carries a `run_rerun_failed`')], [true, true]);
2237+
t('⛔ nothing widens it, at the packer: a workflow or an attempt beside run_id is refused by name before anything is sent', [refusedFor([{ ...RERUN, workflow: '.github/workflows/ci.yml' }], 'actions[0].workflow is not a key `run_rerun_failed` takes'), refusedFor([{ ...RERUN, run_attempt: 1 }], 'actions[0].run_attempt is not a key `run_rerun_failed` takes')], [true, true]);
2238+
const pacing = strokePacing(packed.payload);
2239+
t('derived from the table: it is no body op, no run op, no content-creating op and no annotated op, and its stroke is ONE write, never batched', [BODY_OPS.includes(RERUN.op), RUN_OPS.includes(RERUN.op), CONTENT_CREATING_OPS.includes(RERUN.op), Object.hasOwn(ANNOTATED_OPS, RERUN.op), pacing.writes, pacing.batched], [false, false, false, false, 1, false]);
2240+
const landed = await drive({ runs: () => [RUN('completed', 'success')] }, { stroke: packed.payload });
2241+
const sent = landed.seen.filter((s) => s.call === DISPATCH);
2242+
const beyondTheRelayRun = landed.seen.filter((s) => s.call !== DISPATCH && s.call !== RUNS && s.call !== `GET /repos/${RELAY_REPO}/actions/runs/42`);
2243+
t('a success run: ONE dispatch carrying the action as judged, and no read beyond the relay run\'s own — nothing read back at the target, the executor\'s read-back landed it — exit 0', [sent.length, sent[0]?.body?.client_payload?.actions, landed.state, landed.readBack?.state, landed.runs?.state, beyondTheRelayRun.map((s) => s.call), exitForResult(landed)], [1, [RERUN], 'success', 'none', 'none', [], EXIT_OK], landed.logs.join(' | '));
2244+
const fenced = await drive({ runs: () => [RUN('completed', 'failure')] }, { stroke: packed.payload });
2245+
t('⛔ a run that failed — the relay refused at a fence, or the re-run did not read back — is exit 5 after ONE dispatch, never re-sent and never a direct write', [fenced.state, exitForResult(fenced), fenced.seen.filter((s) => s.call === DISPATCH).length, fenced.seen.filter((s) => s.call !== DISPATCH && !s.call.startsWith('GET ')).length], ['failure', EXIT_PLATFORM_REFUSAL, 1, 0]);
2246+
}
2247+
22042248
// ── the issue_create re-list ────────────────────────────────────────────
22052249
battery('the issue_create re-list: a list that lags the create reads back IDENTICAL on a bounded re-list; a real miss is still unfound (exit 6) after exactly the declared window; an unreadable re-list is unread; the create is never re-sent');
22062250
{
@@ -2410,8 +2454,13 @@ export async function selfTest() {
24102454
.filter((f) => /\bsendFleetWrite\b/u.test(readReal(join(REPO_ROOT, f), 'utf8')))
24112455
.sort();
24122456
// The discovery's floor, BY NAME: a scan gone blind — a moved root, a renamed import — finds fewer and reds here.
2413-
const KNOWN = ['scripts/pm/close-cards.mjs', 'scripts/pm/issue-create.mjs', 'scripts/pm/issue-transfer.mjs', 'scripts/pm/label-write.mjs', 'scripts/pm/post-stamped.mjs'];
2457+
const KNOWN = ['scripts/pm/close-cards.mjs', 'scripts/pm/issue-create.mjs', 'scripts/pm/issue-transfer.mjs', 'scripts/pm/label-write.mjs', 'scripts/pm/post-stamped.mjs', 'scripts/pm/rerun-failed.mjs'];
24142458
t('the discovery finds every sender known today — the scan is not blind', KNOWN.filter((k) => !senders.includes(k)), []);
2459+
// Declaring `relayOnly` swaps a sender's control from "it writes directly" to "it refuses": named here, so no sender takes that swap unreviewed.
2460+
const RELAY_ONLY_KNOWN = ['scripts/pm/rerun-failed.mjs'];
2461+
const declaredRelayOnly = [];
2462+
for (const sender of senders) if ((await import(pathToFileURL(join(REPO_ROOT, sender)).href))[RELAY_ONLY] === true) declaredRelayOnly.push(sender);
2463+
t('the relay-only senders are exactly the ones named here — a sender declaring itself relay-only to skip the write control reds until it is named', declaredRelayOnly, RELAY_ONLY_KNOWN);
24152464

24162465
const relayRoute = (requested) => ({ requested, transport: 'dispatch', reason: `conformance: ${requested} → dispatch`, error: null, session: SESSION });
24172466
const DIRECT_ROUTE = { requested: 'direct', transport: 'direct', reason: 'conformance: direct', error: null, session: null };
@@ -2448,7 +2497,8 @@ export async function selfTest() {
24482497
}
24492498
};
24502499
for (const sender of senders) {
2451-
const probe = (await import(pathToFileURL(join(REPO_ROOT, sender)).href))[RELAY_MISS_PROBE];
2500+
const mod = await import(pathToFileURL(join(REPO_ROOT, sender)).href);
2501+
const probe = mod[RELAY_MISS_PROBE];
24522502
t(`${sender} sends a relay dispatch and exports ${RELAY_MISS_PROBE} — a sender without it fails here until it answers like the rest`, typeof probe, 'function');
24532503
if (typeof probe !== 'function') continue;
24542504
for (const [requested, state] of [['auto', 'no-run'], ['dispatch', 'no-run'], ['auto', 'timeout']]) {
@@ -2463,6 +2513,7 @@ export async function selfTest() {
24632513
);
24642514
}
24652515
// The control: the same probe under direct WRITES, and the recorder sees it — so the empty lists above are a measurement.
2516+
// A relay-only sender (`relayOnly`, header) has no direct write to see: under direct it REFUSES, and the recorder holds its reads.
24662517
const control = { dispatches: 0 };
24672518
const c = await drive(probe, {
24682519
route: DIRECT_ROUTE,
@@ -2472,7 +2523,11 @@ export async function selfTest() {
24722523
},
24732524
pace: freshPace(),
24742525
});
2475-
t(`…the control: ${sender} under direct issues its write directly — the recorder sees it — and never dispatches`, [control.dispatches, c.calls.filter(isDirectWrite).length > 0], [0, true], `${c.exit} · ${c.calls.join(' | ')}`);
2526+
if (mod[RELAY_ONLY] === true) {
2527+
t(`…the control: ${sender} is relay-only — under direct it refuses (exit ${EXIT_PREREQUISITE}) with ZERO direct writes and no dispatch, and the recorder holds the reads it made first`, [control.dispatches, c.exit, c.calls.filter(isDirectWrite), c.calls.length > 0], [0, EXIT_PREREQUISITE, [], true], `${c.exit} · ${c.calls.join(' | ')}`);
2528+
} else {
2529+
t(`…the control: ${sender} under direct issues its write directly — the recorder sees it — and never dispatches`, [control.dispatches, c.calls.filter(isDirectWrite).length > 0], [0, true], `${c.exit} · ${c.calls.join(' | ')}`);
2530+
}
24762531
}
24772532

24782533
// This file's own CLI, the route `with-fleet.sh --actions` execs: it has no direct path, and it answers the same.

0 commit comments

Comments
 (0)