Skip to content

Commit 2b7fc52

Browse files
ci(scheduled-full-run): a refresh moves a stale pm:blocked back to pm:queue once every blocker has closed (#24305)
Fixes #24278 Clause-②: no ## What this does When the hourly red card is **refreshed** while it carries `pm:blocked`, the refresh now resolves every `Blocked-by:` target the card names (body **and** comments, line-initial, read by the half-state patrol's own reader). When **every** target is a LOCAL card that has closed, no `Unlock-action:` line names another unlock, and the card has no assignee, it swaps `pm:blocked` for `pm:queue` (one additive `POST .../labels`, one `DELETE .../labels/pm:blocked` by name) and says so in the refreshed body. In every other case no label moves: | card state at refresh | label outcome | body | |---|---|---| | not `pm:blocked` | untouched, and **nothing is read** (zero extra API calls) | refreshed as before | | `pm:blocked`, no `Blocked-by:` line anywhere | untouched (that is the patrol's H4 row) | as before | | `pm:blocked`, any target OPEN | untouched (the hourly reds are the blocked failure itself) | as before | | `pm:blocked`, any target unreadable (e.g. a cross-repo 404) | untouched (unread is not closed) | as before | | `pm:blocked`, any **cross-repo** target CLOSED | untouched: closed upstream means merged there, not consumable here through the `.objectui-sha` pin, so it is not an unlock criterion for this consumer (the run's summary row names it so) | as before | | `pm:blocked`, a line-initial `Unlock-action:` line (body or comments) | untouched: the card names an unlock other than closure | as before | | `pm:blocked`, the comment thread unreadable or over its page bound | untouched (an open blocker could be in it) | as before | | `pm:blocked`, every target a closed LOCAL card, card **assigned** | untouched: moving it is the holder's release act | a note naming the holder | | `pm:blocked`, every target a closed LOCAL card, no `Unlock-action:` line, unassigned | `pm:blocked` → `pm:queue` | a `## State:` note naming each closed target | No grade label (priority, lane, type) is ever written, no other label is touched, the assignee is never touched, and no comment is posted. Not a gate. **Files:** `scripts/ci/scheduled-full-run.mjs` (the judgement, the swap, the note, the self-test battery) and `.github/workflows/scheduled-full-run-card.yml` (four thin callbacks plus `ownerRepo`; header and log line updated). ## ⚠️ Governed surface: Tier H This PR changes `.github/workflows/scheduled-full-run-card.yml`, so it is **Tier H** and lands only after an authorized APPROVED review. The workflow change is unavoidable for any shape that meets the card's "Done when" (the live refresh path must do the same on the next red): the module receives only the callbacks the workflow hands it, and before this PR those were three (open-issue listing, body update, create). Reading the card's comments, reading a target's state and writing a label are API calls only the workflow's `github` client can make. The module has no client, and reaching for the action's token from inside the module would be a hidden coupling, not a fix. ## Why this shape (the card left it to the lane) The card offered A (swap the label) and B (write a body line, labels untouched, let the unlock scan act). I took **A**, narrowed further on three points the protocol requires: 1. **The reader is the patrol's, imported.** `blockerTargetsFor` from `scripts/pm/check-half-states.mjs` (H19's discovery) is imported, not restated, so the refresh and the patrol can never disagree about what a marker is. 2. **Assigned cards are left alone.** `pm:queue` plus an assignee is the patrol's H24 half-state, and clearing an assignee is the release act (clear the field and post `Release:` in one write), which is never a workflow's. 3. **Never released on ambiguity.** An open target in either channel, an unreadable target, a closed **cross-repo** target, an `Unlock-action:` line, or an unreadable or over-long thread all hold the card. A cross-repo target holds even when closed: 「跨仓解锁判据是消费方可安装,⛔ 不是上游已合并」 (pm-dispatch `SKILL.md`, 〈状态模型〉), and objectstack consumes objectui through the `.objectui-sha` pin, so an objectui card closing upstream does not make the fix consumable here. Requiring every target in the union to be closed is stricter than H19's founding rule, and the self-test pins that whenever this refresh swaps, H19 reads the same card as outlived. **Why this is not a reflexive release.** The state model forbids releasing a block just because its upstream closed (「上游已关 ⇒ 先重新推导是否有新阻塞并改写该行,⛔ 不反射式放行」). Here the trigger is not the upstream closing. On #23885 that happened two days earlier and moved nothing. The trigger is a **new red**: an undiagnosed failure that arrived after every named blocker closed is new work, and it cannot carry a recorded blocker yet (a known one would be named, and an open target holds the card). That is the condition the triage seat wrote on #23885 when it parked the card: "If the next hourly run names a second failing job, that job is new work, and this label comes off." That reading holds only for a LOCAL target, whose closure means its fix landed on this `main`. A closed cross-repo target says nothing about this tree, so it holds the card. ### 四维分析 - **实际业务需求**:实测有拉动。#23885 的 06:01Z 红在 06:30:24Z 刷新后仍挂 `pm:blocked`(时间线:该时刻无任何 label 事件),直到 07:02:17Z 分诊手工换成 `pm:queue`;其阻塞 #23908 早在 10-16 已关。hourly 卡是全量电池红的唯一通道(AGENTS.md §7:main-red 是车道的第一件工作)。A 在刷新那一刻把卡放回车道收件箱。B 只多写一行字,而解锁扫描只 grep 正文、正文每次被重写,B 读不到更快的读者,延迟不变。 - **项目长远合理性**:A 让卡的状态与内容在生产者处一致,复用巡查已裁定的解析器(带装饰的行亦认,见 #10102 裁定 C)与已有的写动词(加法 POST + 按名 DELETE),不新增第二套 marker 语法。代价:CI workflow 成为 `pm:blocked → pm:queue` 这一条转换的第二个写者;范围已收至「刷新 + 全部阻塞为本仓卡且已关 + 无 `Unlock-action:` 行 + 无 assignee」(跨仓阻塞即使已关也不放行:上游合并不等于本仓可装),其余一律不动。 - **防 AI 写错**:去掉一个靠席位记得去做的手工步骤(实测漏过);判定在离线 self-test 里被驱动(含实测序列与对照),拒绝方向恒为「不放行」,与巡查「歧义不放行」的姿态一致。B 依赖读者看到一行散文才行动,正是易漏的形状。 - **创业阶段不扩散需求**:不加门禁、不加标签、不加评论、不碰评级;只动卡已点名的两文件。代价是 workflow 属 Tier H,需维护者批准。 Alternative considered and declined: carrying the old body's `Blocked-by:` lines forward into the refreshed body (no workflow change). It makes the body a home again for the unlock scan's body grep, but it does nothing for the measured card, whose line lives in a comment because the body is generated, and it leaves the latency as it is. ## Mechanism hypotheses, measured - **H1 holds.** `fileOrRefreshCard` refreshed with `updateIssue({ number, body })` only. `findExistingCard` scans OPEN issues only, so a closed card is never refreshed. `identity.labels` for the hourly card is `bug`, `domain:devx`, `priority:p1`, with **no `pm:*` state label**. The #23885 timeline shows exactly those three at creation (11:27:28Z, `github-actions[bot]`) and `pm:blocked` added by the fleet at 12:47:18Z. - **H2 holds.** The `Blocked-by: #23908` line is in comment `6215815177`; #23908 is closed; the 2026-10-18 refresh left `pm:blocked` (no label event between 12:47:18Z on 10-16 and 07:02:17Z on 10-18). - **H3 half holds.** The reader exists (`blockedByTargets` and `blockerTargetsFor` in `scripts/pm/check-half-states.mjs`, line-anchored, mid-sentence mentions refused). But it **tolerates decoration** by ruling (#10102, option C), while `state-machine.md` calls the canonical marker undecorated. The measured line is decorated (`- **Blocked-by: #23908.** ...`, a bullet plus bold), so a strict undecorated parser would have **missed the measured case**. Reusing the patrol's reader reads it. The self-test uses that comment verbatim. - **H4 holds.** The job already holds `issues: write`. No permission change; only its comment was reworded. - **H5 holds.** The swap uses `issues.addLabels` (additive POST) and `issues.removeLabel` (DELETE by name). `check-whole-set-label-write` is green (0 in executable content). ## Tests All at `da11d1998` (rework round 1 after the contract review `6249085305`). - `node scripts/ci/scheduled-full-run.mjs --self-test`: exit 0, `scheduled-full-run: self-test OK`. New battery `card refresh: a stale pm:blocked (the measured #23885 sequence)` with a floor of 22 cases; the battery-count floor goes 5 → 6. Cases: - **the measured sequence:** blocked by the verbatim triage comment, #23908 closed, a new red, then the refresh. The result is `pm:queue` with the three grade labels unchanged, the writes are exactly add `pm:queue`, then remove `pm:blocked`, then the body (in that order), only #23908 is read (not the four mid-sentence refs), and the body keeps every line plus a note above the footer. The note itself reads as no marker, H19 agrees the block is outlived, and the next red on the now-`pm:queue` card reads nothing and writes only the body. - **the control:** the same card with #23908 OPEN: held, no label call, body without a note. - **the other no-move cases:** a mid-sentence mention only; two targets with one open; a card that is not `pm:blocked` (zero reads); an unreadable cross-repo target; a **closed cross-repo target**, alone and beside a closed local one (the summary row names it as closed upstream, not an unlock criterion for this consumer); an `Unlock-action:` line in a comment and in the body; an unreadable thread; a thread over its page bound; an assigned card; a self-reference. - **positive case:** a body-borne line. - **write failures and wiring:** a remove that answers 404 is tolerated; an add that fails throws before the body is touched; a missing callback or `ownerRepo` is refused before any call. - **Ablation, on the final head `da11d1998`,** each through `scripts/ablation-replace.mjs` (anchor x1 → x0, HEAD blob `8c4478b70e3d`), one per tested behaviour: - the swap block removed (blob → `d8bb037915a6`): red on the measured case, `measured: the card should hold its three grade labels and pm:queue, holds bug,domain:devx,pm:blocked,priority:p1`; - the cross-repo withhold removed (blob → `10998e45bcce`): red on the cross-repo control, `expected a refresh judged 'held', got refreshed 'expired'`; - the `Unlock-action:` withhold removed (blob → `fd0185d0198b`): red on the `Unlock-action:` case, the same message. - Each restore: blob == HEAD `8c4478b70e3d`, `git diff HEAD` empty, `git status` clean. The self-test re-ran green after. - **Live path, offline:** the workflow's own `script:` block was extracted from the YAML and run against a fake Octokit, `context` and `core`. - Blocker closed: `GET comments p1`, `GET #23908`, `POST labels pm:queue`, `DELETE label pm:blocked`, `PATCH`. The notice line names the move. - Blocker open: comments, target, `PATCH` only, and `pm:blocked` stays. - Cross-repo blocker closed: comments, `GET objectstack-ai/objectui#12666`, `PATCH` only, `pm:blocked` stays, and the log line names it closed upstream. ## Acceptance notes - **The create path has the same symptom by design.** A freshly filed hourly card carries `bug`, `domain:devx`, `priority:p1` and no `pm:*` state label, so it is outside every seat's candidate query until triage's hourly heal (patrol H13). #23885 was in that shape from 11:27Z to 12:47Z on 10-16. The protocol has triage touch new cards first, so this is noted, not filed. Carrier: none. - **`state-machine.md` vs. the ruled reader.** The doc says the canonical `Blocked-by:` marker is undecorated; the reader tolerates decoration by ruling (#10102, option C), and live lines are decorated. This is a wording gap in the doc. Carrier: none. - **Why the patrol's unlock never saw this card.** `platform-readings.md` says the unlock scan greps the BODY only, and the hourly card's body is generated, so a block on it can never be seen there. This PR closes that for the refresh path only. - **The note does not last.** The `## State:` note lives in a body the next red rewrites. The durable record is the label events on the timeline (actor `github-actions[bot]`) and the run's notice line. A comment would be durable, but the card asked for the body and this file's design avoids per-run comments. - **A remove that fails after the add leaves two state labels.** If `removeLabel` fails with something other than 404 (after github-script's retries) once `addLabels` has landed, the card carries both labels. The run fails loud and the patrol's ONE-OF rows name it. Ordering add-first matches `scripts/pm/label-write.mjs`, and the other order would leave a card with no state label. - **Cross-repo targets never release the card.** Unreadable, open or closed, a cross-repo target holds it. When closed, the run's summary row names it as closed upstream and not an unlock criterion for this consumer. Moving such a card on when the fix becomes installable here (a pin bump) stays a seat's act (`pm:on-hold` with a consumer-side `Restart-when:`, per the state model). - **Any `Unlock-action:` value holds the card**, including a spelling the patrol does not recognise. That is stricter than the patrol's silent fallback to the closure predicate, in the one direction that cannot release a card. - `test:repo` was not run locally: the diff touches no repo-project test and the derived list does not name it. CI's `Test Core` runs it. ## Gates (local, at `da11d1998`) Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; the change set came from git: 2 paths, +614/-16, under the 3000-line threshold). 51 commands: all **50 foreground commands exited 0**, each captured before any pipe. `pnpm check:pm-dispatch-gates` was run detached: `VERDICT-EXIT=0`, `✓ dispatch-gates self-test (fast tier): 1913 cases pass`. `--ran`: `51 derived, 51 run, 0 NOT-MEASURED, 0 UNRUN`. The `--ran` reconciliation is in the report comment on #24278. The script's own suite is the self-test above; no `*.test.*` file names this script (`git grep`, 0 hits). 4 families take a value from the workflow and are NOT MEASURED locally (`check-required-contexts --verify-required-set`, three `check-shard-attestation --emit` legs); CI runs them. Changeset: none. `scripts/ci/` and `.github/` ship in no package (the root package is `private`; a `files[]` scan over all packages finds 0 hits, with a positive control of 73 packages matching `dist`). So: `skip-changeset`. ## 维护者速读(草稿) **改了什么**:hourly 全量红卡在被新一次红刷新时,若卡挂着 `pm:blocked`、卡上点名的每一个 `Blocked-by:` 阻塞(正文或评论)都是本仓的卡且已关闭、卡上没有 `Unlock-action:` 行、且无 assignee,刷新会把 `pm:blocked` 换成 `pm:queue`,并在正文里写明原因。其余任何情况(有阻塞未关、读不到、跨仓阻塞(即使已关:上游合并不等于本仓经 `.objectui-sha` pin 可用)、有 `Unlock-action:` 行、没有阻塞行、已被认领)都不动标签。从不动优先级、车道或其它标签,不发评论。 **为什么改**:10-18 06:01Z main 红了,卡在 06:30 被刷新,但仍挂着两天前的 `pm:blocked`(阻塞 #23908 早已关闭),所有席位都把它当「不归我」,直到 07:02 分诊手工放回队列。hourly 卡是全量红的唯一通道,这一小时没人接。 **风险与代价(含回滚)**:改动了 `.github/workflows/scheduled-full-run-card.yml`(Tier H,需您批准)。CI workflow 因此成为 `pm:blocked → pm:queue` 这一条转换的第二个写者,但范围收得很窄,判定在离线 self-test 中驱动(含实测序列、对照与消融)。最坏情况是标签写失败时本次刷新报错、卡保持原样(与今天相同)。回滚:revert 本 PR 即可,无数据迁移。 **席位意见**: **你要做的**:审阅后批准(APPROVE)本 PR,由所属席位落地;不需要其它动作。 --- _Generated by [Claude Code](https://claude.ai/code/session_01VwoC2RxFfgzY2eXuC7Xo6g)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 889bf12 commit 2b7fc52

2 files changed

Lines changed: 614 additions & 16 deletions

File tree

‎.github/workflows/scheduled-full-run-card.yml‎

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,19 @@ name: Scheduled Full Run Card
4242
# REFUSES rather than filing. The oldest match wins — it is the one
4343
# any duplicates were closed against, and the one the devx seat
4444
# already graded.
45-
# REFRESH the body is rewritten in place, never a comment per run. ⛔ Labels
46-
# are applied on CREATE only: grading is the seat's and a refresh
47-
# must not undo it.
45+
# REFRESH the body is rewritten in place, never a comment per run. ⛔ Grade
46+
# labels (priority, lane, type) are applied on CREATE only:
47+
# grading is the seat's and a refresh must not undo it. The one
48+
# label a refresh may move is a STATE: a card still `pm:blocked`
49+
# whose every `Blocked-by:` target (body or comments) is a LOCAL
50+
# card that has closed goes back to `pm:queue` — a new red after
51+
# the last blocker's fix landed on this `main` is new work, and
52+
# `pm:blocked` hides it from every seat. ⛔ A closed CROSS-REPO
53+
# target never releases it: closed there is merged upstream, not
54+
# consumable here through the `.objectui-sha` pin. That, any
55+
# target open or unreadable, no target and an assignee are among
56+
# the cases where no label moves; every case and its reason:
57+
# "A STALE `pm:blocked`" in the module.
4858
# BODY the run link, and the commits between the previous GREEN
4959
# scheduled run of THIS workflow and this one. That range is the
5060
# hour in which `main` broke, and an EMPTY range is a reading of
@@ -113,7 +123,7 @@ jobs:
113123
permissions:
114124
actions: read # list the previous green scheduled run
115125
contents: read # checkout, and the compare API
116-
issues: write # the card, and nothing else
126+
issues: write # the card (body, and the stale-block label swap) and the reads that judge it
117127
steps:
118128
- name: Checkout repository
119129
uses: actions/checkout@v7
@@ -214,11 +224,13 @@ jobs:
214224
215225
// The DECISION is the module's, driven offline by its self-test
216226
// against a mutable board (one red run files one card; the next
217-
// refreshes it and files no second). What is left here is three
218-
// callbacks over the API.
227+
// refreshes it and files no second; a refresh of a card whose
228+
// every blocker is a closed LOCAL card moves `pm:blocked` to
229+
// `pm:queue`). What is left here is thin callbacks over the API.
219230
const outcome = await mod.fileOrRefreshCard({
220231
identity,
221232
body,
233+
ownerRepo: `${owner}/${repo}`,
222234
perPage: 100,
223235
maxPages: 10,
224236
listPage: async (page) => {
@@ -236,10 +248,31 @@ jobs:
236248
const res = await github.rest.issues.create({ owner, repo, title, body: b, labels });
237249
return { number: res.data.number };
238250
},
251+
// Read only for a card carrying `pm:blocked`.
252+
listComments: async ({ number, page, perPage }) => {
253+
const res = await github.rest.issues.listComments({
254+
owner, repo, issue_number: number, per_page: perPage, page,
255+
});
256+
return res.data;
257+
},
258+
getIssue: async ({ repo: target, number }) => {
259+
const [targetOwner, targetRepo] = target.split('/');
260+
const res = await github.rest.issues.get({ owner: targetOwner, repo: targetRepo, issue_number: number });
261+
return { state: res.data.state, closed_at: res.data.closed_at };
262+
},
263+
// The swap: an additive POST and ONE remove by name.
264+
addLabels: async ({ number, labels }) => {
265+
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels });
266+
},
267+
removeLabel: async ({ number, name }) => {
268+
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name });
269+
},
239270
});
240271
241272
core.info(`${outcome.action} ${identity.marker} card #${outcome.number} (${body.length} chars)`);
242-
core.notice(`${run.name} is red on the hourly full run — card #${outcome.number} ${outcome.action}.`);
273+
if (outcome.block) core.info(`block: ${outcome.blockSummary}`);
274+
const moved = outcome.block === 'expired' ? ' (every blocker closed: pm:blocked moved to pm:queue)' : '';
275+
core.notice(`${run.name} is red on the hourly full run — card #${outcome.number} ${outcome.action}${moved}.`);
243276
core.setOutput('action', outcome.action);
244277
core.setOutput('number', String(outcome.number));
245278

0 commit comments

Comments
 (0)