fix(pm): check-half-states takes the checkout that SERVES the board, and refuses a foreign one - #19288
Conversation
…and refuses a foreign one
The three local git reads — `ls-files` (H17's tracked-file oracle), `rev-parse
--show-toplevel` and `remote get-url origin` (H57's workflow files) — ran with no
`cwd`, so each inherited whatever tree the shell stood in while `PM_SWEEP_REPO`
named another board. Measured 2026-09-19: a sweep of objectui validated its H17
trigger-file index against objectstack's 8888 tracked files (objectui has 7945)
and H57 refused for the whole run, hiding a scheduled lane dead through five
consecutive fires.
`PM_SWEEP_CHECKOUT` now names the tree. Set, the three reads take it as `cwd`
through one `gitRead` helper — the only `execFileSync('git'` site in the file, so
a fourth reader cannot be added without the cwd — and a checkout whose `origin`
is not the swept board refuses with the file's exit-3 PREREQUISITE NOT MET rather
than reading a second repo. `localCheckoutServes` resolves the knob first, so H57
judges when the knob names the right tree. Unset, behaviour is unchanged — plus
the H17 footer now names the tree it read (path + `origin`) beside its oracle
size, because a wrong-tree reading is internally consistent and the tree is the
only thing that distinguishes it from a right one.
Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2
Co-authored-by: Claude <noreply@anthropic.com>
… `cwd` The count pin proves there is exactly ONE `execFileSync` git site in the file; it says nothing about that site passing a `cwd`. Stripping the `cwd` left the whole suite green, i.e. the fix's central wiring had no failing test. This pins it on the source, assembled so the case cannot satisfy itself out of its own text. Claude-Session: https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: In-seat review by the ① Derived judgments
② Semver levelNone — ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19191
Clause-②: no
scripts/pm/check-half-states.mjstook three readings off the LOCAL checkout with plainexecFileSyncgit calls and nocwd, so each inheritedprocess.cwd()while the board it reports onis named by
PM_SWEEP_REPO. The filer measured both directions on the same board with the same script(#19191, comment 5740947564): with
cwdin objectstack, H17's trigger-file index validated objectui'scandidate paths against objectstack's 8888 tracked files (objectui had 7945) and H57 read
NOTHING for the whole run — and the row it never read was hiding a scheduled lane dead through five
consecutive fires (objectstack-ai/objectui#10010). Internally consistent, externally wrong.
维护者速读(草稿)
改了什么 —— 给这个巡检工具加了一个环境变量
PM_SWEEP_CHECKOUT,用来回答「哪个本地检出服务于这块board」。设了它,三处本地 git 读取(H17 的 tracked-file 判据、H57 的 workflow 文件、
origin)就都在那棵树上读;那棵树的
origin不是被扫的仓时,整轮巡检响亮拒绝(退出码 3),⛔ 绝不静默换一棵树读。不设它,行为与今天逐字一致 —— 只多了一句:H17 页脚现在写明它到底在哪棵树上读的(路径 +
origin)。为什么改 —— 这不是「跑错目录」的操作失误,而是一个没有症状的读数缺陷。跨仓巡检是常态(工具住在
objectstack,board 可以是 objectui),而 H17 的页脚正是在告诉派发席位「派发前请拿你的文件面和这张清单求
交集」。清单是拿另一个仓的文件列表验过的,只在另一个仓存在的触发文件被静默丢弃 —— 席位读到的是一份干净
清单,而那份干净是假的。H57 那半反而是诚实的:它明说自己什么都没读,代价是那一轮真有红行没人看见 ——
本 PR 的实测里那是两行,其中一行连卡都没提到。
风险与代价(含回滚) —— 风险面很窄:变量不设时是逐字旧行为(自测里有一条专门盯这件事),巡检
workflow 本身不设它,所以线上那条 lane 的行为不变。新增的唯一失败模式是「变量设错路径」,而那正好是本卡
要的那个响亮拒绝。回滚 = revert 这两个 commit,没有数据迁移、没有已发布面(
scripts/pm/**不随任何包发布,故无 changeset)。
席位意见 ——
你要做的 —— 无需维护者动手。若希望巡检 workflow 把这棵树写明(目前不需要,因为 runner 的检出就是被扫
的仓),那是
.github/workflows/half-state-patrol.yml的一行 env,已写在下面的 Acceptance notes 里,留给单独一个由人合的改动 —— 本 PR ⛔ 不碰 workflow(#19259、#19225 正在改它)。
What changed, mechanically
PM_SWEEP_CHECKOUT— one knob, besidePM_SWEEP_REPOandPM_SWEEP_CLOSED_FLOORin--help,carrying the PATH of the checkout that serves the board.
resolveSweepCheckout(env)resolves andtrims it; whitespace is unset.
readTrackedFiles,readRepoRoot,readOriginUrl) now gothrough a single
gitRead(args, extra)helper that passes that path ascwd. That is the onlyexecFileSyncgit call left in the file, and the self-test pins the count at one — so a fourthreader cannot be added later without the
cwd. That is the card's mechanism assumption turned into ameasured property instead of a belief.
checkoutPrerequisite(sweepRepo, env, originUrl)is a pure verdict in the shapereportPrerequisiteNotMetprints:nullwhen the knob is unset (today's behaviour is not aprerequisite) or when the named tree really serves the board, otherwise the file's own exit-3
PREREQUISITE NOT MET, named. It is answered FIRST in
sweep()— ahead of the transport probe — so aforeign checkout costs zero requests and reads nothing.
localCheckoutServesresolves the same way: the knob leg first (it names the tree, so theoriginread IN that tree is the authority), thenGITHUB_REPOSITORY(which names the RUNNER'stree, i.e. exactly not the knob's), then the checkout's
origin. H57 therefore judges when the knobnames the right tree, instead of refusing for the whole run.
origin— beside its oracle size, in BOTHoracle states (read, and EMPTY BY FAILURE), knob set or not. A wrong-tree reading is internally
consistent; the tree it names is the only thing that tells it from a right one.
Readings
scripts/pm/check-half-states.mjslines--self-testcasesDerived gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat thisbranch → 39 runnable commands, all run locally with the exit code captured BEFORE any pipe, all 0. The
per-command verdicts are in this card's
os-dev-reportcomment. Control-character self-scan over thechanged file: no match. No package contains
scripts/pm/**, so no dependency-closure build and nopackage test suite is owed; repo-wide scans stay CI's.
The firing pair, live
Both legs ran at this branch's head against the objectui board, from the objectstack tool.
Knob = the tree that serves the board —
PM_SWEEP_REPO=objectstack-ai/objectuiwithPM_SWEEP_CHECKOUT=/home/user/objectui, detached under the shared heavy-verify lock (held 398s, waited0s — shared-box seconds, not idle-box figures): exit 0, 502 half-states over 374 open
pm-/p0-labeled issues. The H17 footer:
8142 is objectui's LIVE tracked count today (
git -C /home/user/objectui ls-files | wc -l= 8142); thefiler read 7945 on 2026-09-19 and the board has moved since. This worktree reads 9031, so the two trees
are still 889 apart — the number that never used to appear is now the one printed, with the tree it came
from beside it.
H57 is judged rather than UNRESOLVED:
10 workflow(s) on the swept repo declare a schedule; 8 were judged against their latest event=schedule run and 0 are UNJUDGED rather than clean because that read failed.It filed two red rows, neither reachable by any wrong-cwd sweep:.github/workflows/changeset-release.yml— latest scheduled run35493590744, started2026-09-20T06:11:39Z, concluded
failure. This is the lane the card's re-run traced tofinding(release):
changeset-release.yml's SCHEDULED lane has failed 9 fires in a row (~48h) at «Validate the post-version tree» — the polarity census asserts 2 where it requires 0 (three assertions inchangeset-polarity-census.test.ts), so publish and the version-PR refresh are skipped while every push fire stays green because that job is SKIPPED there objectui#10010, still dead today..github/workflows/check-links.yml— latest scheduled run35489310168, started2026-09-20T04:30:22Z, concluded
failure. Not named anywhere in the card.Knob = a foreign tree — same board,
PM_SWEEP_CHECKOUT=/home/user/objectstack: exit 3, nothingswept, nothing spent:
Reverse verification — both new pins can fail
Each ran from the COMMITTED state, mutated through
scripts/ablation-replace.mjs(which proves thewrite landed on disk and restores byte-exactly), and each turned exactly ONE case red out of 5,081.
The committed blob is
f49d566ed276.cwdfrom the one git-read sitef49d566ed276to994882b2fcfc#19191 knob: …and that one site takes the knob as its cwd— got false, want truef49d566ed276== HEAD,git diff HEADemptyreadRepoRootwith its ownexecFileSyncgit call — a fourth unrouted readerf49d566ed276toecd24ab63593#19191 knob: ONE git read site in the file, so a fourth reader cannot skip the cwd— got 2, want 1The first is the one that mattered most: before that pin existed, stripping the
cwdleft the whole5,080-case suite green, i.e. the fix's central wiring had no test that could fail. The second is what
makes the card's "the three readers are the ONLY local-tree reads" a property the suite enforces.
Acceptance notes
PM_SWEEP_CHECKOUT. It joins the established 12-memberPM_*PM-tooling family(
PM_SWEEP_REPO,PM_SWEEP_CLOSED_FLOOR,PM_STATE_LABELS,PM_TOKEN, …) rather than theproduct's
OS_{DOMAIN}_{NAME}runtime family of Prime Directive [WIP] Create a new release version #9 — the card asked for a knob"beside
PM_SWEEP_REPO", and--helpgroups the three under one heading where a reader looks.Flagged because that directive's wording is categorical; a maintainer who prefers
OS_PM_SWEEP_CHECKOUTgets it for the price of one rename.runner's own checkout IS the swept board (
PM_SWEEP_REPO: ${{ github.repository }}, per-repoinstalls), so the knob would be a no-op there and
GITHUB_REPOSITORYalready answerslocalCheckoutServes. If it should be stated explicitly anyway, the exact line for the sweep step'senv:block in.github/workflows/half-state-patrol.ymlisPM_SWEEP_CHECKOUT: ${{ github.workspace }}— a separate, human-merged change, since ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe #19259 andci(pm): make the half-state patrol callable instead of copied #19225 are open on that file.
origin/mainwas NOT merged into this branch. It moved toe6a03e6while this ran and touchedscripts/pm/check-half-states.mjsin none of those commits (verified against a fetch into a ref thisworktree owns, not against the shared
origin/mainpointer), so the line budget's 36,176 baselinestill holds and no serial writer conflict exists. The merge queue rebuilds the PR onto current
mainand re-runs the required contexts there, which is where a jointly-wrong merge would surface.
sweep()andsweepScheduledWorkflowseach perform their ownreadOriginUrl()/readRepoRoot()— two duplicate local git reads per sweep (microseconds, zerorequests). Threading one reading through
sweepIntowould widen that function's signature, which isthe kind of change this card was told to keep out of. Successor: whoever lands finding(scripts/pm): H59 calls a deliberately REOPENED card a FALSE OPEN —
state_reason: 'reopened'is on the payload it already reads, and H22 in the same file reads it #19177 (H59) or finding(scripts/pm): H52 has no clearing act on apm:on-holdcard — the only three null-exits are close,needs-user-decision(which the row itself forbids here) and a newer dev report a held card can never get #19160(H52) is next on this writer.
state_reason: 'reopened'is on the payload it already reads, and H22 in the same file reads it #19177, finding(scripts/pm): H52 has no clearing act on apm:on-holdcard — the only three null-exits are close,needs-user-decision(which the row itself forbids here) and a newer dev report a held card can never get #19160, finding(scripts/pm):check-half-states.mjsreports its ownMEASURED_MERGES_PER_DAYpin has drifted to a factor of 0.40 (observed ~54.7/day against a pinned 137.5/day, measured 28d ago), so every page-capped window OVER-states the days it covered #19203, [finding] a pm:blocked card whose Blocked-by target is a needs-user-decision card can never be released — the unlock predicate is "closed", and Unlock-action recognizes exactly one PR-shaped spelling #19255 and [finding] a claim whoseSeat:line is not line-initial is silently read as seat 1 — three claims in one round misattributed, producing a false H38 stale row on another seat post #19108 queuebehind this card on the same writer.
scripts/pm/**publishes nothing from any released package, so this diff owes no changeset(
Clause-②: no). No label writes.## Contract reviewrecord, reads--pair, and lands it.Generated by Claude Code