Skip to content

Commit 4e5285f

Browse files
feat(pm/fleet-write): sub_issue_add and sub_issue_remove — an epic PM links a parent's children as objectstack-fleet[bot], read back from the parent's sub_issues list (#27360)
Fixes #27301 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:舰队写中继的闭合 op 表新增 `sub_issue_add` / `sub_issue_remove` 两个 op,云席位以 `objectstack-fleet[bot]` 身份把子卡挂到父卡下,或从父卡摘下。 - 为什么改:中继此前没有挂子卡的通道,epic PM 只能用个人账号直连挂子卡,破坏「写侧恒为 fleet bot」;子树挂不上,epic 队列读数与半态巡查就看不到子卡。 - 风险与回滚:只动 `scripts/pm/fleet-write/**` 四个文件与一份 PM 参考文档,不发布任何包、不改 workflow;回滚即 revert 本 PR。写前核对两张卡,写后回读父卡的子卡列表才算落地,任何不符都是零写入或明确失败。 - 先看:`scripts/pm/fleet-write/execute.mjs`(`subIssuePreflight` 与 `confirmSubIssue`,风险在写前围栏与回读窗口)、`scripts/pm/fleet-write/ops.mjs`(两行 op 与 `SUB_ISSUE_FENCES`)、`.claude/skills/pm-dispatch/references/rest-channel.md`(新增一行、删去一行)。 - 两分钟验证:`node scripts/pm/fleet-write/execute.mjs --self-test`,预期末行 `208 cases pass across 20 batteries`。 - 需要你做的:无,席位落地(Tier S;首次真实挂卡由席位在合入后执行)。 ## What changes The fleet-write relay's closed op table gains the pair the card asks for, each with fences and a measured read-back. A cloud seat can now write an issue-hierarchy link as `objectstack-fleet[bot]` instead of under a person's login. - `scripts/pm/fleet-write/ops.mjs` - Adds a header section, "The two rows that write an issue's hierarchy". - Adds the field `sub_issue` (an integer; the child's issue NUMBER). - Adds the rows `sub_issue_add` (`POST .../issues/{parent}/sub_issues`) and `sub_issue_remove` (`DELETE .../issues/{parent}/sub_issue`, the platform's singular path). Both require `issue` and `sub_issue` and spend `issues` with no `alsoSpends`. Neither is `alone`. Each carries a new row property, `distinct: ['issue', 'sub_issue']`. - The descriptor carries `subIssue: { mode, parent, child }` and no body. The executor builds `{ sub_issue_id }` from the child's own answer and never sends `replace_parent`. - Adds the pure parts both other files read: `SUB_ISSUE_FENCES` (parent, child, free for add, listed for remove), `subIssueFenceFailures`, `subIssueFenceText`, `subIssueCardFailure`, `subIssueAlreadyHere`, `subIssueReadBack` and `subIssueLandedText`. - `scripts/pm/fleet-write/execute.mjs` - Adds a header section, "The hierarchy link". - `subIssuePreflight` reads the parent's and the child's own `GET .../issues/{n}`. Only when both pass does it read the child's `GET .../issues/{child}/parent` (add) or the parent's whole `GET .../issues/{parent}/sub_issues` list (remove: every page at 100, capped at 10 pages, and a truncated list is a SUBSET, never read as absent). It then judges every fence, so a failing fence means the write is NOT SENT and zero writes happen. - An add whose child already answers to THIS parent is a measured no-op: nothing is sent, and it lands only when the list reads the child back. - `confirmSubIssue` lands a write only when the parent's list lists the child's id (add) or no longer does (remove). It re-reads every 3 s for up to 30 s. A non-2xx answer is FAILED with nothing read back. A list that never settles is FAILED UNCONFIRMED, ⛔ never re-sent. - `scripts/pm/fleet-write/validate.mjs` - Adds a header bullet and the `distinct` rule: an action naming one issue as both parent and child is refused by name, judged only on values the field kinds admitted. - Adds the two rows to the three minimal-shape ledgers and the refused-family sample. - `scripts/pm/fleet-write/dispatch.mjs` - Adds a header bullet: neither row carries a body, so the seat reads nothing back. A `success` run is the executor's confirmation, as for `pr_update_branch`. Neither row is content-creating, so an epic's links are paced at rule ①'s gap. - `.claude/skills/pm-dispatch/references/rest-channel.md` (80 → 80, ceiling 80) - Adds a write-side row after the `pr_update_branch` row (112 B, no ✓ because the op has not been called live): ```text - 挂子卡走中继 op `sub_issue_add`/`sub_issue_remove`(`issue` 父号,`sub_issue` 子号),回读父列表。 ``` - The row is paid for by deleting this line from the 第三桶 section: ```text - 本表只指路,⛔ 不在两处各存一份;先问 git,再问 REST。 ``` That content already has a home: the git-before-REST read order is `references/reading-discipline.md` L35 (`读序 git、payload、REST、MCP`), and the pointer to `platform-readings.md` stays on the line above. No `.github/workflows/**` change is needed. GitHub's server-to-server permission ledger lists `add-sub-issue` and `remove-sub-issue` under `issues: write` with no additional permission, and `fleet-write.yml` already mints `permission-issues: write` literally on every stroke. ## Tests | self-test | before (origin/main 74801d1) | after (29c86ca) | new battery | floor | |---|---|---|---|---| | `validate.mjs --self-test` | 193 cases / 12 batteries | 216 / 13 | the hierarchy rows, 23 | 12 → 13 | | `execute.mjs --self-test` | 188 / 19 | 208 / 20 | the hierarchy link, 20 | 19 → 20 | | `dispatch.mjs --self-test` | 260 / 23 | 265 / 24 | the hierarchy link at the seat, 5 | 23 → 24 | Each new battery is pinned at its measured count. Each count was read off the floor's own refusal text: "registered 23 / 20 / 5 case(s), below its pinned floor". Two ablations, one per op, ran through `node scripts/ablation-replace.mjs` in wrap mode with `trap` restore. Each was committed first, its anchor hit exactly once, and its mutation was proven on disk by blob change. <details><summary>Ablation A — sub_issue_add verb POST → PUT in ops.mjs: both batteries RED, restored</summary> ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob a867648 -> c8504caada46 execute-exit=1 validate-exit=1 ✗ fleet-write/execute self-test: 12 of 208 case(s) failed. ✗ fleet-write/validate self-test: 2 of 216 case(s) failed. ablation-replace: ok restored: blob == HEAD (a867648) and `git diff HEAD` is empty ``` </details> <details><summary>Ablation B — sub_issue_remove path /sub_issue → /sub_issues in ops.mjs: both batteries RED, restored</summary> ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob a867648 -> 47049fe46b37 execute-exit=1 validate-exit=1 ✗ fleet-write/execute self-test: 5 of 208 case(s) failed. ✗ fleet-write/validate self-test: 1 of 216 case(s) failed. ablation-replace: ok restored: blob == HEAD (a867648) and `git diff HEAD` is empty ``` </details> ## Acceptance notes - Mechanism readings, all measured: - Paths and bodies come from GitHub's REST OpenAPI description (`issues/add-sub-issue`, `issues/remove-sub-issue`, `issues/list-sub-issues`, `issues/get-parent`), read 2026-10-29. The add returns 201 and the remove 200, each answering an issue object. - The permission rows come from `github/docs` `server-to-server-permissions.json`: `issues` write, server-to-server, `additional-permissions: false`. - Read-only REST GETs against this repository made zero writes: - `#27293/sub_issues` listed 10 children by REST id. - `#26709` carries `parent_issue_url`, but `#27301`, which has no parent, omits the field entirely. - `#27301/parent` answered 404 `No parent issue found`. - `#26709/parent` answered 200 with #27293. - Because a missing field is not evidence, the add's `free` fence reads the `/parent` endpoint and does not infer absence from a missing field. - Design choices the seat may want to re-rule: - The payload carries the child's NUMBER and never `sub_issue_id`, which the card allowed as an alternative. Resolving the id from the number keeps the payload in the vocabulary every other row uses. - An add whose child is already under this same parent is a measured no-op that lands with zero writes. A remove of a child the parent does not list is refused, as the order spells it. - The read-back window is 30 s (`SUB_ISSUE_WINDOW_MS`). The first failure stops a stroke, so at most one window is paid. - ⛔ The new op was never exercised against the live repository. The first live stroke is the seat's, after landing. - Not touched: `scripts/pm/check-half-states.mjs` H45 and `seat-lifecycle.md` 〈Epic 子树车道〉. They already read `sub_issues_summary`; this PR only gives them a writer. ## Verification All local gates were run on head `29c86ca4`, the final commit. That commit merges `origin/main` `73725c20`; the incoming change touched only `docs/adr/**` and does not overlap. <details><summary>Derived gate families: 46 of 46 exit 0, reconciled with --ran</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 46 commands over 5 paths, 564 changed lines. - All 46 recorded `exit 0`. That includes `check:pm-fleet-write-validate`, `-execute` and `-dispatch` (the last also runs `rerun-failed.mjs --self-test`), `check:pm-skill-ratchet`, `check:pm-skill-id-lint`, `check:pm-governed-merges`, `check:self-test-wired`, `check:nul-bytes` and `check:pm-dispatch-gates` (fast tier: 1935 cases, 7 slow sections deferred). - `check:doc-formula-expressions` first answered exit 3, PREREQUISITE NOT MET, because `@objectstack/formula` was not built. After a locked `turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` it answered 0. - `dispatch-gates --ran`: `46 derived famil(ies) accounted for — 46 run, 0 NOT-MEASURED (a DERIVED zero — all 46 recorded an exit code and none of them is 3)`. - The relay's other importers also exited 0: the self-tests of `check-governed-queue-guard`, `check-half-states`, `fleet-roster` and `mailbox-open`, plus `check:pm-with-fleet`. - NOT MEASURED locally, left to CI: the path-scheduled step `ci.yml · Plan Test Core shards`, the 11 wide-population families, the 52 artifact-roster families and the 11 pending-changeset families. There is no changeset; `scripts/pm/**` and `.claude/**` publish nothing, hence `skip-changeset`. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYmhU5KEj1nukYhp1Tg)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent efdf11a commit 4e5285f

5 files changed

Lines changed: 549 additions & 15 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
- 请求体走文件(`-d @file`)或引号定界 heredoc(`<<'EOF'`),⛔ 永不内联双引号串。
4747
- ✓ 请审 `POST .../pulls/{n}/requested_reviewers`(Tier H 归中继 op)· 开 PR `POST .../pulls` 带 `draft=true`。
4848
- ✓ `origin/main` 合进 PR head:中继 op `pr_update_branch`(`pull` + 40 位 `expected_head_sha`),真合并提交。
49+
- 挂子卡走中继 op `sub_issue_add`/`sub_issue_remove`(`issue` 父号,`sub_issue` 子号),回读父列表。
4950
- ✓ 落地经中继 `node scripts/pm/fleet-write/dispatch.mjs --repo O/N --actions-file F`;`--via dispatch` 同载。
5051
- ✓ 启动 run 走 op `workflow_dispatch`:解锁物是白名单 run 才启;入表只经 workflow 自己的 PR。
5152
- F = `[{"op":"workflow_dispatch","workflow":"release.yml","ref":"main","inputs":{"refresh_version_pr":true}}]`。
@@ -65,7 +66,6 @@
6566
## 第三桶 —— git 零配额等价物
6667

6768
- 分支存在性、队列分支、判落地、squash 验证四条的拼写与边界住 `platform-readings.md`。
68-
- 本表只指路,⛔ 不在两处各存一份;先问 git,再问 REST。
6969

7070
## 队列路由的读法
7171

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,15 @@
251251
* (`scripts/pm/rerun-failed.mjs`) reads the new attempt's job ids after,
252252
* as a reading for its note. A run that failed (a fence refused it, or
253253
* the re-run did not read back) is exit 5 like any failed run;
254+
* - `sub_issue_add` and `sub_issue_remove` carry no body and are confirmed
255+
* INSIDE the run too: the executor judges the fences on the two cards
256+
* before the write and lands the action only when the parent's own
257+
* sub-issue list reads the child back, or no longer does
258+
* (`execute.mjs`, `confirmSubIssue`). So a `success` run IS that
259+
* confirmation and this file reads nothing back; a run that failed (a
260+
* fence refused it, or the list did not settle) is exit 5 like any
261+
* failed run. Neither creates content, so a stroke linking an epic's
262+
* children is paced at rule ①'s gap, never batched;
254263
* - a `pr_create` carrying `assignees` is read back at its body's locator
255264
* as before, and its row ALSO carries the pull's `assignees` as the
256265
* platform lists them (`assigneesReading`; `--json` carries them too) —
@@ -1509,9 +1518,11 @@ const SELF_TEST_BATTERIES = Object.freeze({
15091518
"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,
15101519
"the signature at the seat's door: a comment_edit or issue_patch body opens with the seat's signature line or the file is usage (2), nothing packed; generated, body-less and the other doors' ops are left alone": 13,
15111520
"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,
1521+
"the hierarchy link at the seat: an epic's children pack as sub_issue_add actions carrying the parent and the child by number alone, beside other issue ops in ONE dispatch; a self-link, an id or a replace_parent is refused before anything is sent; neither row creates content, carries a body or names a workflow, so the stroke is paced at rule ①'s gap and 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": 5,
15121522
});
1513-
// Raised 21 → 22 with the run_rerun_failed seat battery, 22 → 23 with the signature battery: one battery, one row.
1514-
const SELF_TEST_BATTERY_FLOOR = 23;
1523+
// Raised 21 → 22 with the run_rerun_failed seat battery, 22 → 23 with the signature battery, 23 → 24 with the
1524+
// hierarchy link seat battery: one battery, one row.
1525+
const SELF_TEST_BATTERY_FLOOR = 24;
15151526
const UNATTRIBUTED_BATTERY = '(unattributed)';
15161527

15171528
const batteryCases = new Map();
@@ -2295,6 +2306,32 @@ export async function selfTest() {
22952306
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]);
22962307
}
22972308

2309+
// ── the hierarchy link at the seat ──────────────────────────────────────
2310+
battery("the hierarchy link at the seat: an epic's children pack as sub_issue_add actions carrying the parent and the child by number alone, beside other issue ops in ONE dispatch; a self-link, an id or a replace_parent is refused before anything is sent; neither row creates content, carries a body or names a workflow, so the stroke is paced at rule ①'s gap and 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");
2311+
{
2312+
const REPO = 'objectstack-ai/objectstack';
2313+
const PARENT = 27293;
2314+
const children = [26702, 26709, 26710];
2315+
const links = children.map((n) => ({ op: 'sub_issue_add', issue: PARENT, sub_issue: n }));
2316+
const pack = (actions) => packRequest({ repo: REPO, session: SESSION, actions, requestId: 'fw-test-1' });
2317+
const packed = pack([...links, { op: 'sub_issue_remove', issue: PARENT, sub_issue: 26711 }, { op: 'comment', issue: PARENT, body: 'linked' }]);
2318+
t("an epic's links pack in ONE stroke beside a remove and a comment — no alone — each action op, issue and sub_issue alone", [packed.ok, packed.payload?.actions.slice(0, 4).map((a) => Object.keys(a)), packed.payload?.actions.length], [true, [['op', 'issue', 'sub_issue'], ['op', 'issue', 'sub_issue'], ['op', 'issue', 'sub_issue'], ['op', 'issue', 'sub_issue']], 5], packed.errors.join(' | '));
2319+
const refusedFor = (actions, needle) => {
2320+
const p = pack(actions);
2321+
return !p.ok && p.payload === null && p.errors.some((e) => e.includes(needle));
2322+
};
2323+
t('⛔ refused at the packer, before anything is sent: a parent named as its own child, a sub_issue_id beside the numbers, and the replace_parent flag', [refusedFor([{ op: 'sub_issue_add', issue: PARENT, sub_issue: PARENT }], 'the same as issue'), refusedFor([{ ...links[0], sub_issue_id: 6060051871 }], 'actions[0].sub_issue_id is not a key `sub_issue_add` takes'), refusedFor([{ ...links[0], replace_parent: true }], 'actions[0].replace_parent is not a key `sub_issue_add` takes')], [true, true, true]);
2324+
const pacing = strokePacing(pack(links).payload);
2325+
t("derived from the table: neither row is a body op, a run op, a content-creating op or an annotated op, and three links are three writes at rule ①'s gap, never batched", [['sub_issue_add', 'sub_issue_remove'].map((op) => [BODY_OPS.includes(op), RUN_OPS.includes(op), CONTENT_CREATING_OPS.includes(op), Object.hasOwn(ANNOTATED_OPS, op), op in READ_BACK_LOCATORS]), pacing.writes, pacing.batched, pacing.pacingMs], [[[false, false, false, false, false], [false, false, false, false, false]], 3, false, 0]);
2326+
const linkStroke = pack(links).payload;
2327+
const landed = await drive({ runs: () => [RUN('completed', 'success')] }, { stroke: linkStroke });
2328+
const sent = landed.seen.filter((s) => s.call === DISPATCH);
2329+
const beyondTheRelayRun = landed.seen.filter((s) => s.call !== DISPATCH && s.call !== RUNS && s.call !== `GET /repos/${RELAY_REPO}/actions/runs/42`);
2330+
t("a success run: ONE dispatch carrying the links as judged, and no read beyond the relay run's own — the executor's list read-back landed them — exit 0", [sent.length, sent[0]?.body?.client_payload?.actions, landed.state, landed.readBack?.state, beyondTheRelayRun.map((s) => s.call), exitForResult(landed)], [1, links, 'success', 'none', [], EXIT_OK], landed.logs.join(' | '));
2331+
const fenced = await drive({ runs: () => [RUN('completed', 'failure')] }, { stroke: linkStroke });
2332+
t('⛔ a run that failed — a fence refused a card, or the list did not settle — 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]);
2333+
}
2334+
22982335
// ── the issue_create re-list ────────────────────────────────────────────
22992336
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');
23002337
{

0 commit comments

Comments
 (0)