Skip to content

Commit 587bd96

Browse files
feat(pm): fleet-write relay carries the base sync as pr_update_branch, confirmed by measure (#22457)
Fixes #22052 Clause-②: no ## What this lands The sanctioned base sync — merging `origin/main` into a PR head — had no relay op, so a seat could only issue `PUT /repos/{owner}/{repo}/pulls/{n}/update-branch` as bare REST under its own session token, and the platform authored the merge commit under the seat's personal account (the card measured PR 22002's head `fab444b4`). The relay's closed op table now carries ONE new row, `pr_update_branch`, in the PR-ops cluster beside `pr_request_reviewers` / `pr_ready`: the same PUT, issued on the runner with the App installation token, so the merge commit leaves as `objectstack-fleet[bot]`. - `pull` is required; `expected_head_sha` is required and must be a full 40-hex sha (`FIELDS` kind `sha`, `SHA_SHAPE` in `ops.mjs`): a short, uppercase, non-hex or 41-character spelling is refused by `validate.mjs` BEFORE the dispatch, naming the shape — never left to the platform's 422. A missing sha is refused as a missing required key, never defaulted to the current head. - The row spends `pull-requests`: GitHub's server-to-server permission ledger (`github/docs`, `src/github-apps/data/fpt-2022-11-28/server-to-server-permissions.json`, read 2026-10-09) lists `PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch` under `pull_requests` with `access: write`, `server-to-server: true`, `additional-permissions: false`. The mint step in `fleet-write.yml` already grants it, so the workflow is untouched. - The executor (`execute.mjs`, `confirmUpdateBranch`) confirms the write by MEASURE, never by the answer's sentence: - 202 "Updating pull request branch." is an acceptance, not a landing: the pull is polled (`GET /pulls/{n}`) every 3 s for up to 60 s until its head is no longer `expected_head_sha`; then `GET /repos/{repo}/commits/{sha}` is read and the row prints `head OLD → NEW · merge commit author LOGIN (NAME)` — the fleet-identity reading this card exists for, in the log and the step summary of every real sync. A window that expires is a FAILED action whose sentence says UNCONFIRMED (the merge may still land; read the pull; ⛔ never re-sent), and the stroke stops there, so a `pr_ready` queued behind the sync never runs on an unconfirmed head. - 422 is two different things, so the executor re-reads the pull: a head that is no longer the expected sha is FAILED naming both shas (the platform's "expected head sha didn't match"); otherwise `GET /repos/{repo}/compare/{base}...{head}` is read and `behind_by` 0 is a NO-OP landing (the row says "nothing to merge" and quotes the platform's sentence), while a head still behind its base, or a measure that cannot be read, is FAILED with the platform's sentence — never a no-op by default. - Queue-routing reading ③ is unchanged: an enqueued pull's "cannot update" answer now arrives as that FAILED row carrying the platform's sentence — still a read, now through the op's reported status. - The PUT is paced by `write-pace.mjs` like every other write verb (`fleet-write PUT` in the pace log; pinned in the wiring battery). - `dispatch.mjs` reads nothing back for the op (it carries no body); a `success` run IS the confirmation, and the seat reads the new head with one `GET /repos/{repo}/pulls/{n}` after. Pinned in the read-back-targets battery; no new sender, so no new `relayMissProbe`. - Reference files, every edit in place at zero net lines and under the 120-byte line cap: `rest-channel.md` lines 49–50 moved to the relay spelling with bare REST for this verb marked ⛔ (82/82 before and after); line 74 names the op (reading ③ kept); `landing-operations.md` §C names the op (101/101); `platform-readings.md` line 89 names the op (469/469) — that file was not on the claim's surface, but the card's pin (`grep -rn 'update-branch' .claude scripts/pm` returns the op, its tests and the ⛔ line only) could not hold with its old spelling, so it was moved too. Pin, measured on the final tree: `grep -rn 'update-branch' .claude scripts/pm` returns `rest-channel.md:50` (the ⛔ line), `ops.mjs` (the row and its header), `validate.mjs` and `execute.mjs` (their tests and the executor's header) — nothing else. ## The PM's mechanism assumptions, measured 1. **Which ref the relay executes** — CONFIRMED from `fleet-write.yml`: `actions/checkout@v7` with no `ref`, on a `repository_dispatch` trigger, checks out the event's `github.sha`, the default branch's tip. So the relay runs `main`'s `validate.mjs` / `execute.mjs`: a `pr_update_branch` stroke sent before this lands would be refused by `main`'s validator ("not an op the relay executes") with zero writes. The merge commit's author on a real PR is therefore **NOT MEASURED until merge — the seat measures on the first real sync after landing**; that sync's run prints the reading in its row (`head OLD → NEW · merge commit author LOGIN (NAME)`) and its step summary, so the seat quotes it from there. Not measured here by bare REST (that is the defect) and the workflow was not changed to take code from the PR ref. 2. **Token permission** — CONFIRMED sufficient (ledger quoted above): `pull_requests: write`, no additional permission; the mint step is untouched. 3. **Op table position** — the row sits between `pr_request_reviewers` and `pr_ready`, inside the PR cluster; the table's tail (`transfer`) is untouched, so #22369's `workflow_dispatch` row appends behind it without overlap. The executor's confirmation is its own function, not a branch of the GraphQL path. 4. **Senders** — none added: the op travels through `dispatch.mjs --actions-file`; `grep -l sendFleetWrite scripts/pm` is unchanged and the no-run conformance battery still passes (27 cases). 5. **Line ratchet** — 82/82 and 101/101 held with in-place rewrites, no re-wrap, no ceiling change; `pnpm check:pm-skill-ratchet` green; operative text carries no issue number (`pnpm check:pm-skill-id-lint` green, 34 files). ## Verification Self-tests, before → after on this tree (`node scripts/pm/fleet-write/FILE.mjs --self-test`, exit codes captured before any pipe): - `validate.mjs`: 94 → 99 cases across 9 batteries — new rows: the accepted shape; a short sha refused before the dispatch; uppercase / non-hex / 41-char refused; a missing `expected_head_sha` refused; the table's one PUT pinned by op and path, never reaching `/labels` or `/assignees`; the row's permission pinned to `pull-requests` with the sha in its body. - `execute.mjs`: 83 across 12 → 93 across 13 batteries — the new "base sync" battery (10): 202 polled off the expected sha with the author read (3 polls, 6 000 ms of fake clock, the PUT the one write); 422 + `behind_by` 0 as a no-op landing (exit 0, the platform's sentence quoted); 422 + head behind as FAILED naming the count and the platform's sentence (the enqueued "cannot update" reading); 422 + a moved head as FAILED naming both shas with no compare taken; an unreadable pull or compare as FAILED; a window that expires (60 000 ms of fake clock) as FAILED, UNCONFIRMED in its sentence, with the `pr_ready` behind it never attempted; any other status as FAILED; the wiring battery now pins `fleet-write PUT` in the pace log. - `dispatch.mjs`: 200 → 201 cases across 18 batteries — `pr_update_branch` is not in `BODY_OPS`, has no locator and yields no read-back target. - `scripts/pm/write-pace.mjs --self-test`: 113 cases across 12 batteries (execute.mjs is on its roster). - Seat CLI dry-runs through the real packer (`dispatch.mjs --dry-run`, route resolved to `dispatch`): the full-sha stroke packs and prints (exit 0, nothing sent); the 8-character sha and the missing sha are refused at exit 2 naming the reason, nothing sent. - Targeted `eslint` over the four relay files: exit 0 (a targeted run, not the repo-wide `pnpm lint`, which is CI's). Gate sweep: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 47 commands from the 7-path change set, identical before and after the pre-PR merge (derived at `2b3e864b`, re-derived at `c3f353d9` against merge base `2b61f2d9`). All 47 ran serially and detached on `c3f353d9` (1 593 s in all, `check:pm-dispatch-gates` 1 174 s of it), each exit code captured before any pipe: 46 exit 0 on the first pass; `pnpm --filter @objectstack/lint run check:doc-formula-expressions` exit 3 = PREREQUISITE NOT MET (`@objectstack/formula` and `@objectstack/lint` not built; nothing measured), its own fix line run under `scripts/pm/os-verify-lock.sh` (`turbo run build --filter=@objectstack/formula --filter=@objectstack/lint`, VERDICT command-exit 0), then the gate re-run: exit 0. Reconciliation with the exit codes recorded (`--ran`): ✓ dispatch-gates --ran: 47 derived famil(ies) accounted for — 47 run, 0 NOT-MEASURED (a DERIVED zero — all 47 recorded an exit code and none of them is 3). Among them, every gate the dispatch named: `check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:pm-governed-merges`, `check:nul-bytes`, `check:pm-fleet-write-validate` / `-execute` / `-dispatch`, `check:pm-write-pace` — all exit 0. ## Acceptance notes - A 202 whose head never leaves the expected sha inside the window is a FAILED action on the runner (exit 5, the run concludes `failure`), so the seat's `dispatch.mjs` answers 5 "the run FAILED", not 6: a seat cannot read the executor's exit, only the run's conclusion, and the executor has no UNCONFIRMED exit of its own. The row's sentence says UNCONFIRMED and prescribes a read; the prescription for a failed run (never fall back, never re-send) is the same. The PM's suggested route (an UNCONFIRMED-style exit aligned with `exitForResult`) is met in the sentence, not in the exit number. - The 422 sentences in the self-test fixtures ("There are no new commits on the base branch.", "expected head sha didn't match current head ref.", an enqueued "cannot be updated") are FIXTURES, not measured platform text: no file in this repository records them and the docs do not document them, which is exactly why the executor judges a 422 by the compare's `behind_by` and the re-read head, never by the sentence. - The merge commit's author on a real PR: NOT MEASURED until merge (assumption 1 above). - `fleet-write.yml`'s mint-step comment ("the op table has NO contents op — no file, ref or branch write") stays true for the `contents` grant it explains; `ops.mjs`'s header now says where the one branch-moving row's merge comes from (the platform's own update-branch action, under `pull-requests`). The workflow file is untouched on purpose. - Git identity: the container's git author resolved to `objectstack-fleet[bot]` on every commit; the commit trailer pair is the model-free pair AGENTS.md prescribes. - After the pre-PR merge of `origin/main` (2 commits, none touching these files; `pnpm-lock.yaml` moved), `pnpm install --frozen-lockfile` was re-run; no package build is owed — the diff touches no published package and every gate here is a script-level self-test or repo scan. - Out of scope, noted and not filed: `rest-channel.md` line 57 still spells a bare direct merge (`PUT .../pulls/{n}/merge`) as a ✓ for a non-queued repository — the same identity question this card asked about update-branch, for a verb the relay refuses by construction (`/merge$` is a refused family). carrier: the skills seat (this card's lane); noted, not filed. ## 维护者速读(草稿) **改了什么**:舰队写中继多了一个 op `pr_update_branch`:把 `main` 合进某个 PR 的 head,这件事以前只能由席位用自己的账号裸调 GitHub 接口,合并提交就记在席位的个人账号名下;现在走中继,由 `objectstack-fleet[bot]` 发出。同时三份席位参考文件改成新拼写,裸调标为 ⛔。 **为什么改**:仓规定每一笔 GitHub 写都以舰队身份经 `scripts/pm/` 发出;这条动词是唯一还在用个人账号写的口子(实测 PR 22002 的 head `fab444b4` 作者是席位的个人账号)。 **风险与代价(含回滚)**:新增一行 op 表、一个确认函数与自测,不改工作流、不扩权限(GitHub 权限表列此接口为 `pull_requests: write`,已铸)。合并前中继跑的是 `main` 上的代码,所以"真实 PR 上合并提交作者是舰队"这条要等合并后首次真实同步才能实测;执行器会把新 head 与合并提交作者打印在 run 的汇总里。回滚 = revert 本 PR,一次提交。 **席位意见**:(留空) **你要做的**:本 PR 受管面全在 `.claude/**`,Tier S:席内达档契约复核 PASS 后由席位入队,不需要你动手;合并后首次真实同步的作者读数由席位贴回卡上。 --- _Generated by [Claude Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_ Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33ae4bf commit 587bd96

7 files changed

Lines changed: 234 additions & 41 deletions

File tree

‎.claude/skills/pm-dispatch/references/landing-operations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
- 这种 PR 停在 draft,正文写精确的预期红清单:逐条失败测试名 + 报错签名。
8888
- 正文同写解除条件:点名所依赖的那张 PR 合入。
8989
- 每个 CI-failure 事件与清单比对:签名匹配静默跳过,新签名才是真问题。
90-
- 依赖合入后:最后一轮同步 → 红清零 → 转 ready → 入队。
90+
- 依赖合入后:末轮同步走中继 `pr_update_branch`(`rest-channel.md`)→ 红清零 → 转 ready → 入队。
9191

9292
## D. 串行接力:多个已实现 PR 全碰生成物时,一次只放行一个
9393

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
- 摘标签与翻 ready 是状态写,同 head 重起标签敏感检查 ⇒ ③ 在入队前末次状态写之后读。
8787
- 检查全部完成的 PR 挂 auto-merge 即入队,本仓 28–60 秒 ⇒ 挂载与落地之间无窗口。
8888
- `mergeable_state` 未落定时挂上的是经典 auto-merge、不入队,落定后再挂才入队。
89-
- `behind` 的 PR 照常入队:落后于 main 不是入队否决,⛔ 不为它先跑 update-branch。
89+
- `behind` 的 PR 照常入队:落后于 main 不是入队否决,⛔ 不为它先发 `pr_update_branch`。
9090
- `check_suite.completed` 会命名过期 head,check-run 也只属最后一次 push ⇒ 用前先重读当前 head。
9191
- 落地相邻的写侧一则:GitHub 标签描述上限 100 字符,超长写回 422,133 字的原文即不可存。
9292

‎.claude/skills/pm-dispatch/references/rest-channel.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
- ✓ 该 `PATCH` 带 `state` 关卡/重开,`state_reason` 交付 `completed`、撤单 `not_planned`,走裸 REST。
4747
- 请求体走文件(`-d @file`)或引号定界 heredoc(`<<'EOF'`),⛔ 永不内联双引号串。
4848
- ✓ 请求复审 `POST .../pulls/{n}/requested_reviewers` · 开 PR `POST .../pulls` 带 `draft=true`。
49-
- ✓ `origin/main` 合进 PR head:`PUT .../pulls/{n}/update-branch`,PM 席位、零文件写、真合并提交。
50-
- `expected_head_sha` 须完整 40 字符 SHA(短 SHA 回 422);base 未动回 422 = 无事可做,不是失败。
49+
- ✓ `origin/main` 合进 PR head:中继 op `pr_update_branch`(`pull` + 40 位 `expected_head_sha`),真合并提交。
50+
- ⛔ 裸 `PUT .../pulls/{n}/update-branch` 落个人账号;短 SHA 派发前即拒;base 未动 422 = op 报 no-op。
5151
- ✓ 落地经中继 `node scripts/pm/fleet-write/dispatch.mjs --repo O/N --actions-file F`;`--via dispatch` 同载。
5252
- ⛔ 裸 `PATCH /pulls/{n}` 带 `{"draft": false}` 回 200 零改;状态码不作数,`GET /pulls/{n}` 才作数。
5353
- 线程自己建:`POST .../pulls/{n}/comments` 带 `commit_id`·`path`·`line`,回读看 `review_threads`。
@@ -71,7 +71,7 @@
7171

7272
- `merged_by` 是入队者,⛔ 不是绕队证据:队列合并归属给入队的账户,对队列与直合零分辨力。
7373
- 判据 ①:直接合并 `PUT .../pulls/{n}/merge` 在强制队列 ruleset 下回 405。
74-
- ②:PR 上的 `added_to_merge_queue` timeline 事件。③:对已入队 PR 调 update-branch 回不能更新。
74+
- ②:PR 上的 `added_to_merge_queue` timeline 事件。③:对已入队 PR 发 `pr_update_branch` 回不能更新。
7575
- ①② 的拼写与边界住 `platform-readings.md` 队列段,本条只归拢判据。
7676
- 队列 required 集按 job 与 check-run 名匹配,workflow 名从不作为 check context 出现,搜也搜不到。
7777
- 改任一 job 名或 test 分片矩阵的形状,必须同一笔更新队列 required 集,否则队列静默挂起。

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,12 @@
202202
* its default 6. Neither is ever a fall-back to direct — no outcome of an
203203
* accepted dispatch is (the no-run conformance below) — and neither is 0.
204204
* - a stroke carrying no body (labels, assignees, state, a transfer, the
205-
* GraphQL ops) reads nothing back and its outcome is unchanged.
205+
* GraphQL ops) reads nothing back and its outcome is unchanged;
206+
* - `pr_update_branch` carries no body and is confirmed INSIDE the run — the
207+
* executor polls the pull's head off `expected_head_sha`, or measures a
208+
* 422 as a no-op, before it calls the action landed — so a `success` run
209+
* IS that confirmation; the seat reads the new head with one
210+
* `GET /repos/{repo}/pulls/{n}` after, and this file reads nothing back.
206211
*
207212
* ## The no-run conformance — every sender, one answer
208213
*
@@ -1132,7 +1137,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
11321137
'the dispatch: one paced POST to the board repo with event_type and client_payload; 204 is acceptance, anything else a refusal': 7,
11331138
'the run-poller: the run named after the request id, its completion, its conclusion': 6,
11341139
'the ceilings: no run in the start window, no completion in the ceiling — UNCONFIRMED, never retried': 7,
1135-
'the read-back targets: every op the table lets carry a body — derived, each with a locator — and no other op is read back': 6,
1140+
'the read-back targets: every op the table lets carry a body — derived, each with a locator — and no other op is read back': 7,
11361141
"the verdict: post-stamped's own classifier, imported — declared normalisations land, a split, lost or truncated byte is NOT STORED at its first differing byte, an unreadable body is unverified, the PR-create footer forgiven on pr_create alone": 13,
11371142
"the round trip: the card's 41,699 bytes with a multi-byte character across every 16 KiB boundary of every stream, byte for byte through pack, the wire, the runner's env text, the validator and the executor; a per-chunk decode is NOT STORED": 8,
11381143
'the read-back end to end: after a success run each body at its locator — a corrupted read-back exits 4 through the CLI, an unreadable or unfound one 6, a body-less stroke reads nothing, never a retry': 17,
@@ -1526,6 +1531,7 @@ export async function selfTest() {
15261531
const mixed = { repo: 'objectstack-ai/objectstack', actions: [{ op: 'labels_add', issue: 1, labels: ['a'] }, { op: 'issue_patch', issue: 2, state: 'closed' }, { op: 'issue_patch', issue: 3, body: 'B' }, { op: 'pr_create', title: 'T', head: 'h', base: 'main' }, { op: 'pr_create', title: 'T', head: 'h2', base: 'main', body: 'P' }] };
15271532
t('only an action that SENT a body is a target, numbered as the stroke numbers it', readBackTargets(mixed).map((x) => [x.action, x.op]), [[3, 'issue_patch'], [5, 'pr_create']]);
15281533
t('…carrying the keys its locator needs', [readBackTargets(mixed)[0].issue, readBackTargets(mixed)[1].head, readBackTargets(mixed)[1].sent], [3, 'h2', 'P']);
1534+
t("pr_update_branch carries no body — not in BODY_OPS, no locator, no target: its confirmation is the executor's, inside the run", [BODY_OPS.includes('pr_update_branch'), 'pr_update_branch' in READ_BACK_LOCATORS, readBackTargets({ repo: 'objectstack-ai/objectstack', actions: [{ op: 'pr_update_branch', pull: 1, expected_head_sha: '1'.repeat(40) }] })], [false, false, []]);
15291535
let fetched = 0;
15301536
const none = await readBackStroke({ repo: 'objectstack-ai/objectstack', actions: [{ op: 'labels_add', issue: 1, labels: ['a'] }] }, { dispatchedAt: T0 }, {
15311537
judge: JUDGE,

0 commit comments

Comments
 (0)