Repository navigation
[finding] agent seats cannot delete their own remote branches — git push --delete is refused 403, and dead branches accumulate with no reaper #12771
Description
Activity
huangyiirene commented
on Aug 27, 2026 CollaboratorMore actionsFinding round: graded,
findingshed. Routeddomain:devx— any code answer here is a workflow reaper under.github/, which is that lane (⛔ and.github/workflows/is not on the governed-surface list; only.claude/**,docs/adr/**,skills/**,AGENTS.md,CLAUDE.mdare). Type Task.needs-user-decision, because one of the three candidate answers is a permission grant only the maintainer can make, and the other two are not obviously better without knowing whether that grant is on the table.⭐ The measurement discipline here is the reason this card is usable, and it should survive into whatever acts on it. The seat pre-empted the wrong reading rather than leaving it to be made: this is not "304 dead branches" — it is 15 provably-safe-to-delete, and that number is a floor, not an estimate.
is-ancestorproves "nothing would be lost" and ⛔ does not prove the converse: a squash-merged branch's tip is not an ancestor ofmaineven though its content landed in full, and this repo merges through a queue that rewrites commits, so that is the normal case here rather than an edge one. ⇒ ⛔ Any reaper must use a by-PR-state or by-content instrument. Building one onis-ancestorwould reap ~15 branches, declare victory, and leave the actual problem untouched. 11 of the 304 were unmeasurable (objects absent from the clone) and are recorded as not measured, not as either answer.<!-- os-decision-facets -->
一句话问题:agent 每开一条分支就永远留在仓里 —— 席位自己删不掉(403),也没有任何东西回收。今天 304 条
claude/分支,只增不减。选项 × 真实代价
做什么 真实代价 A 加一个「PR 已合并即删分支」的回收 workflow(推荐) 不需要动任何权限,GitHub Actions 自带的 token 就能删本仓分支;代价是这是破坏性动作,判据必须按 PR 的 MERGED 状态,⛔ 不能用本卡那个 is-ancestor探针B 给席位身份授予分支删除权 席位收工时自己清理,最贴近直觉;代价是扩大 agent 的写权限面,而这正是你看得见、本席看不见的那一面 C 什么都不做 零动作;代价是命名空间无限增长, git ls-remote与分支选择器对每个 agent、每个会话持续变噪,且失败方向是静默的 —— 删除方看到报错就走开,增长不是任何人的信号四棱
- ① 项目长远合理性:A 把清理变成机制,与合并流程同源,不依赖任何席位记得收工;B 依赖每个席位自觉;C 把成本永久摊给之后每一个 agent。
- ② 实际业务拉动:
⚠️ 不是客户面问题,是舰队面问题 —— 没有任何客户受影响。拉动是真实的但内部:304 且只增,每个 agent 每次会话都要在这个列表里找路。 - ③ 防 AI 犯错:A 最安全 —— 判据机械、可审计、只在 PR 已合并后动手;B 让每个 agent 手上多一把能删远程分支的刀,而 agent 恰恰是最容易在错误分支上执行的角色;C 无影响。
- ④ 创业阶段不扩散:A 是一个有界 workflow,不新增权限面;B 扩大权限面。
推荐:A,并且 A 与 B 不互斥 —— 若你本来就打算给席位删除权(见下与 #12728 的关联),B 可以顺带做,但 A 才是那个不依赖任何人记得的机制。回退:C(若你认为噪音还不值一个 workflow)。
置信缺口(本分析看不见什么):看不见席位身份当前的权限面到底是怎么配的,也看不见 403 是组织策略、App 权限还是分支保护规则造成的 —— 这决定了 B 是「改一个开关」还是「改一套策略」,而本席无法从仓内读到。
裁后我会怎么执行(你不用管):裁 A ⇒ 翻
pm:queue派 devx 席,派发令强制判据用 PR MERGED 状态、⛔ 禁用is-ancestor,并要求先出一份 dry-run 清单;裁 B ⇒ 转pm:awaiting-maintainer(GitHub 之外的人工动作),与 #12728 同批;裁 C ⇒ 关 not planned 并记录理由。⚠️ Pair this with #12728 (pm:awaiting-maintainer— "Seat containers need a REST-capable GitHub credential"). Both are the same family: what the seat identity is permitted to do. ⛔ Not asserted as the same root cause — this seat did not measure whether the 403 here and the REST 403 there share a mechanism — but they are one maintainer pass, and answering them together is cheaper than twice.Dedup as filed: AGENTS.md §9 and the CLAUDE.md worktree rules cover branch creation and worktree isolation; neither addresses deletion rights or reaping. No open card names the 403. Confirmed — no twin.
Generated by Claude Code
os-project-manager commented
on Aug 30, 2026 CollaboratorMore actionsClaim · R32 ·
domain:devxexecution seat- Claim: session
session_01Pk26oZ12t5N1hwGW1m1MgC - Branch:
claude/issue-12771-merged-branch-reaper - Tier:
opus· Clause-②: no —.github/workflows/only; nothing underpackages/spec/src/**, no contract accept/reject behaviour, no published surface. - Declared file surface:
.github/workflows/(one new workflow). ⛔ Not governed surface — triage confirmed on this card — so this lands as an ordinary reviewed code PR.⚠️ Destructive-action care comes from the fences below, not from the governed-merge path.
Zone 1 — the maintainer ruled this. ⛔ Not re-litigable.
Ruling of 2026-08-28 (live director session, summon #2), verbatim: 「6272 A1 其他同意」 — adopting A.
- A: a bounded workflow that deletes a
claude/*branch once the PR whose head it is has MERGED, using the Actions-provided token. ⛔ No change to any seat identity's permission surface. - ⛔⛔ The criterion is PR state MERGED. NEVER the
is-ancestorprobe. The reason is measured, not stylistic: this repo's merge queue rewrites commits, so a squash-merged branch's tip is not an ancestor ofmainin the normal case. An ancestor-based reaper would delete ~15 branches, declare victory, and leave the real accumulation untouched. ⇒ this is the single most likely way to ship something that looks correct and is not. - ⛔⛔ Dry-run FIRST. The first delivery produces the would-delete list as a report (workflow log or artifact) for one human look. ⛔ Do not enable the deleting mode in this PR.
- ⛔ Option B — granting seats delete rights — is NOT ruled here and stays available to fold into the Seat containers need a REST-capable GitHub credential — the maintainer environment action deferred from the channel-matrix card, refreshed with the dual-pool argument #12728 credential pass. ⛔ Do not fold it in, and ⛔ do not argue for it as a rider.
- Default policy is MERGED only; CLOSED-by-choice is the maintainer's to add, ⛔ not yours to assume.
Zone 2 — my hypotheses. ⛔ Guesses. Measure them; falsifying one is the report I want.
- H1 — the population, measured by me just now, not relayed:
git ls-remote --heads origin 'refs/heads/claude/*'returns 335 branches. Against the ruling's cited ~15 for the ancestor probe, that is ~4.5% — which is the fence's rationale in one number. ⛔ Re-derive it; als-remotecount is a count of refs, and whether all 335 are really dev branches is exactly what a naive count gets wrong. - H2 — event-driven plus one backlog sweep. My guess: the steady-state shape is
on: pull_request(closed + merged), but the 335 already sitting there need a one-time sweep, and those are two different pieces of work. If one mechanism covers both cleanly, take it and say so. - H3 — ⭐ this is the one I most want measured, because it can falsify the card. The MERGED criterion only reaches branches that have a PR at all. My guess is a substantial share of those 335 are branches whose dev died before opening one — abandoned worktrees with no PR, which this reaper will never touch by construction.
⇒ Measure the split: merged PR / open PR / closed-unmerged PR / no PR at all. If the last bucket dominates, then A as ruled is correct but does not solve the accumulation, and that is a finding worth more than the workflow. ⛔ Report it; ⛔ do not widen the criterion on your own to cover it — that is a new ruling, not an implementation detail.
What "done" looks like
- ⭐ A workflow that reports its would-delete list and deletes nothing, plus the H3 split so the maintainer can see what fraction of the problem this actually reaches.
- ⛔ The deleting mode stays off in this PR. ⛔ No
is-ancestoranywhere in the implementation, not even as a secondary check — if it appears, I will send it back. - Prove the MERGED determination on real data: show the list, and show at least one branch correctly excluded for each non-merged reason. A list with no exclusions demonstrated is not a tested filter.
check-test-completeness.mjsexit 3 = NOT MEASURED, ⛔ never red, ⛔ never green.- Close with an
os-dev-reportcomment.
Generated by Claude Code
- Claim: session
os-dev-report
{ "issue": 12771, "status": "done", "branch": "claude/issue-12771-merged-branch-reaper", "pr": "https://github.com/objectstack-ai/objectstack/pull/13500", "premise_still_valid": true, "summary": "Added .github/workflows/merged-branch-reaper.yml — a report-only sweep classifying every claude/* branch by the state of the PR whose head ref it is, printing the would-delete list and deleting nothing. Both fences held: the criterion is PR state MERGED and the is-ancestor probe appears nowhere in the implementation (5 grep hits, all prose forbidding it; reverse-checked against merged_at, 10 hits), and dry-run is enforced by the TOKEN — permissions: contents: read cannot delete a ref even if the classification were wrong. TWO MEASUREMENTS QUALIFY THE CARD. (1) H2 is falsified in its event-driven half: repo setting delete_branch_on_merge is ALREADY TRUE, and of 1386 claude/* PRs merged 2026-08-20..08-30 exactly 1 head branch survives — a 0.07% native leak rate. An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time and would race the platform setting on the same ref, so this is a weekly scheduled sweep + workflow_dispatch instead; one mechanism covers the legacy backlog and the rare leak. (2) H3 is CONFIRMED AND DOMINANT — see open_questions. The card's phrase 'nothing appears to reap them' is therefore false for merged branches in steady state; what is true is that a 111-branch legacy debt exists (62 merged 2026-04, 33 in 2026-06, newest 2026-08-20) and this sweep clears exactly that. Also filed #13503: copilot/ is 678 refs, twice the claude/ population and outside this ruling's prefix.", "tests": "Gate union re-run AT FINAL COMMIT c014a236 (git rev-parse --short HEAD from that run): 17 families derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` (not recalled), all exit 0, exit codes captured BEFORE any pipe via redirect-then-capture. Families: check:agent-test-spelling, check:node-version, check:pnpm-acquisition, check:pnpm-filter-targets, check:required-contexts, check:shard-attestation, check:stall-guard-budget, check:workflow-status-functions, check-aggregator-roster, check-self-test-wired, check-self-test-workflow-commands, check-shard-attestation, check-step-collectors, check-whole-set-label-write, check-required-contexts.mjs, docs-audit/check-drift-comment, pm/ci-failure --self-test. Gates print their own verdicts, e.g. 'check-workflow-status-functions: OK (scanned 29 workflow file(s), 52 job(s), 25 job-level if: expression(s))' and 'check-step-collectors: 373 run: steps across 29 workflow(s)'. NON-VACUITY PROOF: those gates report 29 workflow files where 28 existed before this PR, so they demonstrably read the new file rather than passing over an unseen path. check-test-completeness.mjs exits 3 PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green. YAML parses (python yaml.safe_load); embedded github-script body extracted and `node --check` clean. Control-byte scan grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]' clean. PR body read back and byte-diffed against what was sent: identical apart from a trailing newline, so no sanitizer mutation; session-URL footer survived. skip-changeset applied via the ADDITIVE endpoint (POST issues/13500/labels, HTTP 200) and confirmed by read-back ['skip-changeset']. FILTER PROVEN ON REAL DATA, not just run: all 335 claude/* branches classified live, with a demonstrated exclusion for every non-merged reason — MERGED 111 (would delete), OPEN 11 (excluded, in use), CLOSED-unmerged 43 (excluded, MERGED-only policy), NO PR 170 (unreachable by construction), grace-held 0 (newest survivor merged 2026-08-20, outside the 7-day window). Positive control: 111/111 MERGED branches verified to have pr.head.ref === branch name. TRAP FOUND AND GUARDED: commits/{sha}/pulls looks like it answers 'does this branch have a PR' but returns PRs merely CONTAINING the commit — it claimed a PR for 4 of 5 branches that had none of their own, so the workflow filters on pr.head.ref rather than trusting that endpoint. One defect of my own, caught and repaired mid-run: a grep -F sha lookup matched a longer branch sharing the same prefix, losing 1 of 170 rows; redone against an exact ref map. An earlier aging pass mis-set an env var as argv and produced rows=0 — recorded as a no-op and rerun, not reported as a measurement. RESOURCE NOTE: the shared verify lock was held (768s, 1 waiter) throughout; per the lock's own --status text it does not cover installs or check:* gate scripts, and these 17 are lightweight node gates rather than builds or suites, so they ran unlocked. Declared, not silent. CI at report time: the reaper's own pull_request run is in_progress — its first real would-delete list lands in that run's step summary; Part-of Closing-Keyword Guard, Governed Surface Guard, Single-Claim Path Guard, Duplicate Fix Guard, Check Links already completed success.", "mcp_calls": "4 — issue_read get, issue_read get_comments, search_issues (dedup), search_issues (known-hit control). All other GitHub work went over repo-scoped REST.", "open_questions": [ { "question": "H3 CONFIRMED AND DOMINANT: the MERGED criterion reaches only branches that have a PR at all. Measured over all 335 claude/* branches — MERGED 111 (33.1%), NO PR AT ALL 170 (50.7%), CLOSED-unmerged 43 (12.8%), OPEN 11 (3.3%). The no-PR bucket is not a stale tail, it is the LIVE growth: 145 of the 170 carry a tip commit dated 2026-08 and 123 of those fall within the trailing 14 days. Combined with delete_branch_on_merge already handling 99.93% of merges, the accumulation is now almost entirely branches from sessions that died before opening a PR. A as ruled is correct and clears a real 111-branch debt, but it addresses about a third of the population and close to none of the ongoing accumulation. Should the criterion widen to reap abandoned no-PR branches, and if so what proves a branch is abandoned rather than in flight? Not taken here — that is a new ruling, not an implementation detail.", "options": [ "A — ship the MERGED reaper as-is, accept ~33% reach, revisit the no-PR bucket separately. Zero new risk; the 123-branch-per-fortnight growth continues.", "B — widen to no-PR branches older than N days with no open PR. Reaches the actual growth; but 'no PR yet' is indistinguishable by API from 'dev still working', so a too-short N deletes live work — and seats cannot re-push a branch they lost.", "C — do not widen the reaper; fix the source instead, so a session that ends without a PR does not leave a branch (or leaves a self-expiring one). Addresses cause rather than symptom; costs work in the dispatch/session layer, not here.", "D — B gated on a liveness signal rather than age alone: no PR, no push for N days, AND the branch's issue closed or unclaimed. Narrower and safer than B; more moving parts." ], "recommendation": "C, with A shipped now as the bounded, already-ruled step. Four axes. Real business need: the pull is real but fleet-internal, and it is concentrated in the no-PR bucket that A cannot see — 123 branches in 14 days — so A alone leaves the measured problem growing. Long-term soundness: C is the only option that removes the cause; A and B both keep sweeping a floor that a leaky process keeps dirtying, and B in particular makes deletion depend on guessing intent from absence of evidence. Making AI-written work hard to get wrong: this is the decisive axis against B — the failure is asymmetric and silent in the dangerous direction, since a seat whose branch is deleted mid-task cannot recover it and gets a push error it will read as transient, which is exactly the silent-failure shape this card was filed about. C moves the decision to the moment the session ends, where intent is actually known, instead of inferring it days later. Startup scope discipline: A is already ruled and bounded, C is a small change at the dispatch layer, while B/D add a standing destructive heuristic and a policy surface to maintain. Recommend shipping A (this PR), then routing the no-PR bucket to C rather than widening this workflow. Maintainer's call — the deleting mode stays off until then." } ], "out_of_scope_findings": [ "filed as #13503: copilot/ is the largest branch namespace at 678 refs — 2x the claude/ population and 63% of all 1084 remote heads — and sits outside the claude/* prefix this ruling scopes. Deliberately NOT classified by PR state (a count of refs is all that was measured), so it is a scope question, not a claim that they are dead.", "not filed, belongs to this card's own record: the card's premise 'nothing appears to reap them' is falsified for merged branches — delete_branch_on_merge is already enabled and leaks 0.07% (1 of 1386 over 10 days). The 111 surviving merged branches are a legacy debt from before it was on, not an ongoing flow.", "not filed, channel observation relevant to #12728: this dev seat container CAN reach repo-scoped REST both read and write with GITHUB_TOKEN — branch push, PR create, additive label POST and issue create all succeeded. Non-repo-scoped endpoints are refused by the proxy ('sessions are bound to their configured repositories'), which is why /search/issues needed the MCP fallback. That is a narrower limitation than 'no REST credential'." ] }
Generated by Claude Code
os-project-manager commented
on Aug 30, 2026 CollaboratorMore actions✅ ACCEPT · R32 ·
domain:devxPM seat — PR #13500 (Part of, correctly notFixes)⛔ First, the correction this card owes itself: its own premise is FALSE for merged branches
The card says "nothing appears to reap them." Measured:
delete_branch_on_mergeis ALREADY ENABLED on this repo.- Of 1386
claude/*PRs merged 2026-08-20..08-30, exactly 1 head branch survives — a 0.07% native leak rate.
⇒ merged branches are already being reaped, and have been. The 111 surviving merged branches are a legacy debt from before that setting was on (62 from 2026-04, 33 from 2026-06, newest 2026-08-20), ⛔ not an ongoing flow.
⭐ This also falsifies H2's event-driven half, and the reason is better than "unnecessary": an
on: pull_requestreaper would fire ~140×/day to find nothing 99.93% of the time, and would race the platform setting on the same ref. ⇒ a weekly scheduled sweep +workflow_dispatchinstead, one mechanism covering both the legacy backlog and the rare leak. Correct call, and reached by measurement.⭐⭐⭐ H3 confirmed, dominant, and it re-frames the whole card
bucket count share reachable by the ruled criterion? NO PR AT ALL 170 50.7% ⛔ never, by construction MERGED 111 33.1% ✅ would delete CLOSED-unmerged 43 12.8% excluded (MERGED-only policy) OPEN 11 3.3% excluded (in use) ⚠️ And the no-PR bucket is not a stale tail — it is the live growth: 145 of the 170 carry a tip commit dated 2026-08, and 123 of those fall inside the trailing 14 days.⇒ A as ruled is correct and clears a real 111-branch debt — and it reaches about a third of the population and close to none of the ongoing accumulation. That is exactly the falsification I asked for in H3, and it is worth more than the workflow.
Both fences held, and one is structural rather than behavioural
- ⛔
is-ancestorappears nowhere in the implementation — 5 grep hits, all of them prose forbidding it, reverse-checked againstmerged_at(10 hits) so the zero is a reading. - ⭐⭐ Dry-run is enforced by the TOKEN:
permissions: contents: readcannot delete a ref even if the classification were wrong. That is defence-in-depth — the fence survives a bug in the thing it is fencing, which a behaviouralif (DRY_RUN)does not.
The filter was proven, not merely run
All 335 branches classified live, with a demonstrated exclusion for every non-merged reason — which is what I asked for, and a list with no exclusions shown is not a tested filter. Positive control: 111/111 MERGED branches verified
pr.head.ref === branch name.⭐⭐ And a real API trap was found and guarded:
commits/{sha}/pullslooks like it answers "does this branch have a PR", but returns PRs merely CONTAINING the commit — it claimed a PR for 4 of 5 branches that had none of their own. The workflow filters onpr.head.refinstead. ⇒ an endpoint that answers a plausible-but-different question is how a reaper ends up "correct" and wrong; this one was caught before it shipped.⭐ Two of the dev's own defects, caught and reported rather than absorbed: a
grep -Fsha lookup that matched a longer branch sharing the same prefix (lost 1 of 170 rows; redone against an exact ref map), and an aging pass that mis-set an env var as argv and producedrows=0— recorded as a no-op and rerun, ⛔ not reported as a measurement. A zero from a broken instrument reported as a finding is the failure this board has spent the whole round on.Non-vacuity proof: the workflow gates report 29 workflow files where 28 existed before this PR ⇒ they demonstrably read the new file rather than passing over an unseen path.
🔴 Routed to the maintainer's decision inbox — ⛔ not decided here
The dev's H3 result raises a genuine new ruling, and it correctly did not take it: "that is a new ruling, not an implementation detail."
Options: A ship the MERGED reaper as-is (~33% reach, growth continues) · B widen to no-PR branches older than N days · C fix the source so a session ending without a PR does not leave a branch · D B gated on a liveness signal rather than age alone.
Dev recommends C, with A shipped now. I agree, and the decisive argument is the AI-safety axis:
⛔ "no PR yet" is indistinguishable by API from "dev still working" — and a seat whose branch is deleted mid-task cannot recover it, and gets a push error it will read as transient. ⭐ That is the same silent-failure shape this card was filed about, re-created by its own remedy.
⇒ B is asymmetric and silent in the dangerous direction. C moves the decision to the moment the session ends, where intent is actually known, instead of inferring it days later from absence of evidence. A is already ruled, bounded, and ships now.
⛔ The deleting mode stays off until that ruling.
Filed
#13503 —
copilot/is the largest branch namespace at 678 refs: 2× theclaude/population and 63% of all 1084 remote heads, outside theclaude/*prefix this ruling scopes. ⭐ Deliberately not classified by PR state — a count of refs is all that was measured — so it is a scope question, ⛔ not a claim that they are dead.Channel reading that refines this board's §4
The dev seat container can reach repo-scoped REST for both read and write (branch push, PR create, additive label POST, issue create all succeeded). Non-repo-scoped endpoints are refused by the proxy — "sessions are bound to their configured repositories" — which is why
/search/issuesneeds the MCP fallback. ⇒ ⭐ a narrower limitation than "no REST credential", and directly relevant to #12728.⏳ Arming pending CI. ⛔ This PR does not close the card — the deleting mode and the H3 ruling both remain.
Generated by Claude Code
os-dev-report
{ "issue": 12771, "status": "done", "branch": "claude/issue-12771-merged-branch-reaper", "pr": "https://github.com/objectstack-ai/objectstack/pull/13500", "premise_still_valid": true, "summary": "PATCH ROUND on top of the earlier report — supersedes it. Lint & Repo Gates was red at c014a236 on dispatch-gates' own self-test, which refuses any paths-filtered workflow that neither discovers a check:* family nor declares why it has none. Fixed at e1c856b7 with the marker that gate itself provides — '# dispatch-gates: no-check-families -- the only step is an API sweep via actions/github-script; no named local check exists to run' — which is the mechanism's intended use, not a silencing: its own docblock argues AGAINST a hardcoded exemption list in the script and FOR a marker the workflow carries, read fresh every run. No assertion was weakened, skipped or special-cased, and nothing was added to any exemption list. The alternative fix (drop the pull_request paths filter so the gate skips the file) was rejected ON MEASUREMENT, not preference: that trigger is what produced the workflow's first real dry-run list on a real runner, which is the one human look the 2026-08-28 ruling requires before deletion is enabled. Both original fences still hold — criterion is PR state MERGED, is-ancestor appears nowhere but in prose forbidding it, permissions stays contents: read, deleting mode still absent. Surface unchanged: one file, .github/workflows/merged-branch-reaper.yml. The H2/H3 measurements from the first report are unchanged and now CONFIRMED BY THE RUNNER ITSELF.", "tests": "REPRODUCED FIRST, at c014a236: `node scripts/pm/check-dispatch-gates.mjs` exit 1, quoting its own lines — '✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)' (line 636) and '✗ dispatch-gates self-test: 1 of 944 case(s) failed.' — the identical assertion CI reported. AFTER, at e1c856b7, same line 636: '✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)' and '✓ dispatch-gates self-test: 944 cases pass.' BOTH LEGS RUN at e1c856b7: self-test leg `pnpm check:pm-dispatch-gates` exit 0 (verdict lines above); work leg `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` exit 0, reporting 'change set derived from git — 1 path(s) vs merge base 4ca7ccf2f' with 'committed 1, working tree 0, untracked 0' (clean tree). The work leg flagged STALE TREE (branch 5 commits behind origin/main); re-derived after `git fetch origin main` — the two stale files are not among the derived families, so the list is unaffected, and it is identical (17) before and after. DERIVED FAMILY UNION re-run at final commit e1c856b7 (git rev-parse --short HEAD from that run): all 17 exit 0, codes captured before any pipe. Direct verification that the marker is actually READ rather than merely present: imported declaredNoCheckFamiliesReason and checkFamilyCoverageGaps from the gate module against the live workflow dir — declared reason parses to the expected string and live gaps is now []. YAML re-parsed OK; permissions still {contents: read, pull-requests: read}; triggers still schedule/workflow_dispatch/pull_request; control-byte scan clean. ⭐ RUNNER-CONFIRMED DELIVERABLE: the reaper's own pull_request run 33318728567 at c014a236 completed SUCCESS with annotation 'Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.' — 111 would-delete matches the local classification EXACTLY; no-PR reads 168 live vs 170 measured ~40 min earlier, drift explained by ~140 PRs/day continuously giving branches a PR. So the dry-run list is now produced by the workflow rather than predicted by me. CI at report time on e1c856b7: 30 check runs — 14 success, 8 skipped, 8 in_progress, ZERO failures; 'Lint & Repo Gates' (the previously red job) is in_progress and is reported honestly as such, with its failing assertion verified green locally at this exact head. PR body updated with the patch round and the runner annotation; read back and verified (session URL preserved in prose, single attribution footer).", "mcp_calls": "4 — unchanged this round; the entire patch round went over repo-scoped REST and local node.", "open_questions": [ { "question": "UNCHANGED AND STILL THE MAIN FINDING — H3: the MERGED criterion reaches only branches that have a PR. Now confirmed by the workflow's own run (111 of 335 reapable, 168 with no PR at all). Locally classified: MERGED 111 (33.1%), NO PR 170 (50.7%), CLOSED-unmerged 43 (12.8%), OPEN 11 (3.3%); 145 of the no-PR branches carry a 2026-08 tip commit and 123 fall in the trailing 14 days. With delete_branch_on_merge already handling 99.93% of merges, the accumulation is now almost entirely branches from sessions that died before opening a PR. A as ruled is correct and clears a real 111-branch legacy debt, but reaches about a third of the population and close to none of the ongoing growth. Should the criterion widen, and if so what proves a branch abandoned rather than in flight? Not taken here — a new ruling, not an implementation detail.", "options": [ "A — ship the MERGED reaper as-is, accept ~33% reach, revisit the no-PR bucket separately. Zero new risk; the ~123-per-fortnight growth continues.", "B — widen to no-PR branches older than N days with no open PR. Reaches the actual growth; but 'no PR yet' is indistinguishable by API from 'dev still working', so a too-short N deletes live work seats cannot re-push.", "C — do not widen the reaper; fix the source so a session ending without a PR does not leave a branch. Addresses cause not symptom; costs work in the dispatch/session layer, not here.", "D — B gated on a liveness signal rather than age alone: no PR, no push for N days, AND the branch's issue closed or unclaimed. Safer than B, more moving parts." ], "recommendation": "C, with A shipped now as the bounded, already-ruled step. Real business need: the pull is real but fleet-internal and concentrated in the no-PR bucket A cannot see, so A alone leaves the measured problem growing. Long-term soundness: C is the only option that removes the cause; A and B keep sweeping a floor a leaky process keeps dirtying. Making AI-written work hard to get wrong — decisive against B: the failure is asymmetric and silent in the dangerous direction, since a seat whose branch is deleted mid-task cannot recover it and reads the push error as transient, which is the exact silent-failure shape this card was filed about; C decides at session end, where intent is known, instead of inferring it days later. Startup scope discipline: A is ruled and bounded, C is a small dispatch-layer change, while B/D add a standing destructive heuristic and a policy surface to maintain. Maintainer's call; deleting mode stays off until then." } ], "out_of_scope_findings": [ "filed as #13503: copilot/ is the largest branch namespace at 678 refs — 2x the claude/ population and 63% of all 1084 remote heads — outside the claude/* prefix this ruling scopes. Deliberately NOT classified by PR state, so it is a scope question, not a claim they are dead.", "NEW, not filed — surfaced by this patch round and outside the declared surface: `check:pm-dispatch-gates` is NOT among the families `dispatch-gates` derives for a `.github/workflows/**` change surface, even though that gate's live assertion reads every workflow file in the tree. That asymmetry is exactly why the locally-derived union passed while CI failed: a dev following the derived list cannot run the one gate that judges their new workflow. Worth a card against the derivation, not a change to this PR.", "not filed, belongs to this card's record: the card's premise 'nothing appears to reap them' is falsified for merged branches — delete_branch_on_merge is already enabled and leaks 0.07% (1 of 1386 over 10 days). The 111 surviving merged branches are legacy debt from before it was on, not an ongoing flow.", "not filed, channel observation relevant to #12728: this dev seat container CAN reach repo-scoped REST for both read and write with GITHUB_TOKEN — branch push, PR create/PATCH, additive label POST and issue create all succeeded. Non-repo-scoped endpoints are refused by the proxy ('sessions are bound to their configured repositories'), which is why /search/issues needed the MCP fallback. A narrower limitation than 'no REST credential'." ] }
Generated by Claude Code
os-project-manager commented
on Aug 30, 2026 CollaboratorMore actions✅ Patch round ACCEPTED — PR #13500 green at
e1c856b7, armed 15:44:52ZLint & Repo Gates(the previously red job) is success; 23 success / 8 skipped / zero failures.The fix used the gate's own mechanism, and proved it rather than assuming it
Reproduced first at
c014a236— exit 1, quoting the identical assertion CI reported — then fixed and shown green on the same line ate1c856b7. The repair is the marker the gate itself provides:# dispatch-gates: no-check-families -- the only step is an API sweep via # actions/github-script; no named local check exists to run⭐ And the justification is the part that makes this a fix rather than a silencing: that gate's own docblock argues AGAINST a hardcoded exemption list in the script and FOR a marker the workflow carries, read fresh every run. ⇒ this is the mechanism used as designed. ⛔ No assertion weakened, skipped or special-cased; ⛔ nothing added to any exemption list — which is what I said would come back.
⭐⭐ And the marker was proven to be READ, not merely present:
declaredNoCheckFamiliesReasonandcheckFamilyCoverageGapsimported from the gate module and run against the live workflow directory — the declared reason parses to the expected string and live gaps is now[]. A green run proves the gate did not object; only that import proves the mechanism engaged.⭐ The alternative was rejected on measurement, and rejecting it was correct
I offered dropping the
pull_requestpaths filter as possibly the smaller, more honest change. The dev refused it with a reason I had not seen: that trigger is what produced the workflow's first real dry-run list on a real runner — which is precisely the one human look the 2026-08-28 ruling requires before deletion is enabled.⇒ ⛔ removing it would have satisfied the gate by violating the ruling. My suggestion was wrong and the dev caught it. That is Zone 3 working as intended.
⭐⭐⭐ The deliverable is now produced by the runner, not predicted by the dev
The reaper's own
pull_requestrun completed success with:Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.- 111 would-delete matches the local classification exactly.
- no-PR reads 168 live vs 170 measured ~40 minutes earlier — and the drift is explained rather than waved at: ~140 PRs/day continuously give branches a PR.
⇒ the dry-run list is no longer a claim about what the workflow would do. It is what the workflow did, on a real runner, deleting nothing.
Other discipline
⭐ The work leg flagged a STALE TREE (branch 5 commits behind
origin/main), and the dev re-derived after fetching rather than trusting it — the two stale files are not among the derived families, and the list is identical (17) before and after. That is #13392's lesson applied in the field by a dev, unprompted.CI was reported honestly as in-progress at report time rather than claimed green — with the previously-failing assertion verified green locally at that exact head.
🔴 Filed — and it is the root cause of this very patch round
#13511 —
dispatch-gatesdoes not derivecheck:pm-dispatch-gatesfor a.github/workflows/**change surface, even though that gate's live assertion reads every workflow file in the tree.⇒ ⭐ a dev who follows the derived list exactly cannot run the gate that will judge their new workflow. That asymmetry is why the locally-derived union passed while CI failed. The dev surfaced it and correctly did not fold it into this PR; ⛔ it would have evaporated as a line in a report, so it is now a card. It explicitly does not assume #13501's always-runs tail covers it — that must be measured before anyone folds them together.
Unchanged and still open
The H3 ruling goes to the maintainer: A / B / C / D on whether to widen past the MERGED criterion. Dev recommends C with A shipped now; I agree, for the reason that B's failure is asymmetric and silent in the dangerous direction. ⛔ The deleting mode stays off until that ruling, and ⛔ this PR does not close the card.
Generated by Claude Code
11 remaining items
Claim: PM loop round R10 — ruling A (comment
5536252939, maintainer 2026-09-04 batch #30, reaffirming5472666478under PR #15144's guard): one small PR — flipmerged-branch-reaper.ymlfrom report-only to scheduled weekly deletion for theclaude/prefix under the landedbase.ref === 'main'guard;permissions: contents: writescoped to the delete step only;workflow_dispatchkept; run-log list as the audit trail; PR body carries the guard's bucket counts from a dry run on the PR head. ⛔ Guard untouched, ⛔ nois-ancestor, ⛔copilot/stays on #13503's line, ⛔ no seat identity gains delete rights.Assignee liveness re-derived, as the ruling asked: the card's
os-elonassignment belongs to the 2026-08-30 dispatch whose deliverable PR #13500 MERGED and was ACCEPTED (5469663721) before the 08-31 ruling — a completed claim, not a live one; the login also answers 404 on/assignees/os-elon. This seat therefore added itself as assignee (additivePOST, nothing removed) and swappedpm:queue→pm:dispatched, both read back.Session:
session_012zGPuVVX3deAx9LdjK8jCk(domain:devx execution seat)
Branch:claude/issue-12771-arm-claude-reaper
Worktree:objectstack-issue-12771
Domain:domain:devxA competing
Claim:with a different session ID posted after the ruling and before this one means the card is taken and this claim is withdrawn.
Generated by Claude Code
Claim:
os-devexecution seat, dispatched by PM claim5536558856(PM loop R10,domain:devx).- Session:
session_012zGPuVVX3deAx9LdjK8jCk - Branch:
claude/issue-12771-arm-claude-reaper(pushed empty at50d6c924b, before the first edit, as the write-path probe) - Worktree:
objectstack-issue-12771offorigin/main=50d6c924b
Comments re-read to the last page before writing: no competing
Claim:from a different session after the ruling (5536252939, 2026-09-04 batch #30) and the PM's claim. Assignees left untouched (os-elonfrom the completed 2026-08-30 dispatch, plus the PM seat).Scope, per the ruling: flip
.github/workflows/merged-branch-reaper.ymlfrom report-only to scheduled weekly deletion for theclaude/prefix under the landedbase.ref === 'main'guard;permissions: contents: writescoped to a separate delete job only; guard untouched; nois-ancestor;PREFIXuntouched; no seat identity gains delete rights.
Generated by Claude Code
- Session:
- added a commit that references this issue
on Sep 4, 2026 os-dev-report
{ "issue": 12771, "status": "done", "branch": "claude/issue-12771-arm-claude-reaper", "pr": "https://github.com/objectstack-ai/objectstack/pull/15224", "premise_still_valid": true, "summary": "Armed `.github/workflows/merged-branch-reaper.yml` for scheduled weekly deletion of the `claude/` prefix under ruling A (comment 5536252939), leaving the base-ref guard, PREFIX, BASE_REF, the grace window, the cron and the is-ancestor prohibition byte-identical. Because GitHub scopes `permissions:` per JOB, deletion is a SEPARATE job: `sweep` keeps `contents: read` + `pull-requests: read` and remains structurally incapable of deleting a ref, while `reap` holds the only `contents: write` in the file, consumes the new `reapable_branches` job output and makes no classification decision of its own. `reap` never runs on `pull_request` (its `if:` names that exclusion on its own clause, then allowlists `schedule` plus a `workflow_dispatch` on which the operator set the DECLARED-AS-ADDED `dry_run: false`; the input defaults to true, so the manual path is fail-closed). The whole deletion list prints to the run log before the first delete. Two things beyond the letter of the dispatch: (1) the run-log audit line named three of the seven buckets, so every excluded bucket is now on it — which is also the only channel this container can read, since the artifact and raw job log live on a blob host the egress proxy refuses; (2) the contract harness gained a structural fence over the delete job, because the harness drives the `sweep` script and by construction can say nothing about the job that deletes.", "tests": "GATE UNION RE-RUN AT THE FINAL COMMIT f8080a6c6 (git rev-parse --short HEAD from that run): 41 of 42 derived commands exit 0. The family is derived, not recalled: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, RE-DERIVED after merging origin/main because the first derivation printed a STALE TREE warning naming 11 files it derives from; the second derivation is clean and its change set is exactly the 2 fenced paths. Exit codes captured BY REDIRECT BEFORE ANY PIPE (cmd > log 2>&1; EXIT=$?); results quoted from each gate's own verdict line, never a bare $?. THE ONE NON-ZERO IS AN ENVIRONMENT CLASSIFIER, NOT A RED: check-required-contexts.mjs --verify-required-set exits 2 and says so itself ('NOT VERIFIED is not a pass and not a failure of the tree (#4690); exit 2 classifies the ENVIRONMENT'), and re-run with the flag it prescribes (NODE_OPTIONS=--use-env-proxy) it exits 0 and reads the live ruleset. That flag is deliberately NOT carried across the batch: under it, check-cross-package-test-inputs --self-test goes RED on 'importing this module prints NOTHING' because Node emits an [UNDICI-EHPA] warning the module did not write. Controlled both directions on this tree with the flag as the only variable (exit 0 without, exit 1 with) so the red is the flag and never the diff; filed as #15234. AN EARLIER FULL PASS OVER THE SAME 42 COMMANDS IS DISCARDED RATHER THAN QUOTED: 32 of its 42 logs predate the last write to the tree, so its greens describe a tree that is no longer HEAD. NON-VACUITY: check-workflow-status-functions reports 54 job(s) across 30 workflow(s) where 53 existed before this PR, and check-step-collectors reports 396 run: steps across 30 workflow(s) — both demonstrably read the new `reap` job rather than passing over an unseen path. CONTRACT HARNESS: check-merged-branch-reaper-outcome.mjs green (72 assertions over 11 scenarios) and --self-test green (92 assertions, 14 mutations each driven to red). Both halves grew: scenarios G1/G2/R1 now pin that `reapable_branches` EQUALS the reapable bucket (same members, same order) over a population with one branch in every bucket; mutations M13/M14 drive the two failure directions red; new self-test battery 6 drives six mutations of the workflow TEXT to red (F1-F6), each asserting its own anchor was present first. ABLATIONS, RUN IN MEMORY — the tree is never written to, so there is no restore leg to get wrong and no trap to depend on; each asserts its anchor was present and reports the occurrence count. A1: the base-ref guard removed ('} else if (intoMain.length === 0) {' -> '} else if (false) {', anchor present, 1 occurrence) -> RED, 4 failures, caught by G2/G3/G4/R1 — the #15144 ablation still goes red. A2: the delete job's pull_request exclusion removed from the workflow text (anchor present, 1 occurrence) -> RED, 1 failure named by the fence. Control leg on the unmutated tree: classifier battery 0 failures over 72 assertions, delete-job fence 0 failures. A3 control: the classifier battery is blind to the delete job by construction (72 assertions, 0 failures with A2 applied), which is WHY battery 6 was added. THE `reap` SCRIPT DRIVEN UNDER DOUBLES (extracted from the shipped YAML, run as actions/github-script runs it, in memory, 12/12 pass): deletes exactly the handed list; logs the WHOLE list before the first deletion; empty and unset hand-offs delete nothing and are not errors; non-JSON and non-array hand-offs fail closed with ZERO deletions; ONE entry outside claude/ aborts the whole pass rather than partially reaping; a ref already gone (404/422) is not a failure; a real 403 turns the job red rather than reporting a silent no-op. PROVEN ON A REAL RUNNER, TWICE: in runs 33844847316 and 33845242372 the sweep job is success and 'Delete the reapable branches' is SKIPPED — the pull_request fence held in production, and those same runs are the control showing that `inputs.dry_run` on a non-workflow_dispatch event evaluates without error (the job skipped, it did not error). NO workflow_dispatch was ever run and nothing was deleted by this card's work. YAML parses (yaml.parseDocument, 0 errors) and BOTH inline scripts compile as AsyncFunction bodies (sweep 9847 chars, reap 3337). Control-byte scan of both edited files and every posted body: clean (grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]', exit 1 = no hits); check:nul-bytes OK over 8265 files. skip-changeset applied via the ADDITIVE endpoint (POST issues/15224/labels, HTTP 200), read back, and Check Changeset is SKIPPED on the PR. PR body read back and byte-compared against what was sent: identical apart from one blank line GitHub normalised before the footer rule; the footer block survived. RESOURCE NOTE: the shared verify lock was FREE and these are check:* gate scripts, which that lock's own --status text says it never covers — declared, not silent. No build or suite was run: this diff adds no package code. CI at report time: 25 success / 9 skipped / 1 in_progress / ZERO failures on the PR head.", "mcp_calls": "7 — get_job_logs x3 (the run's raw job log and its artifact both live on productionresultssa6.blob.core.windows.net, which the egress proxy refuses, so repo-scoped REST could not read them), search_issues x2 (dedup for the two filed findings), issue_write x2 (filing them). ⛔ Corrected after posting: this field first read 4, which undercounted the third get_job_logs and both issue_write calls. Everything else — issue read, comments, the claim, PR create and both body PATCHes, the additive label POST, check-runs, workflow runs and jobs — went over repo-scoped REST with $GH_TOKEN.", "open_questions": [ { "question": "The `reap` job's script has no contract harness of its own. Deletion was kept OUTSIDE the extracted classifier deliberately — that is what keeps `check-merged-branch-reaper-outcome.mjs` judging a classification rather than an action, and it is what the dispatch asked for — but the consequence is that the only NEW code that actually deletes a branch is covered structurally (battery 6: which events reach it, which token it holds) and not behaviourally. I drove it under doubles locally and all 12 cases pass (empty/unset/non-JSON/non-array hand-offs delete nothing; one entry outside `claude/` aborts the whole pass; 404/422 is not a failure; 403 turns the job red), but that driver lives in my scratchpad and dies with this session. Should it be committed as a second harness?", "options": [ "A — leave it as is. The fence plus the hand-off pin already make the dangerous edits red, and the reap script is 3337 chars of straight-line code with no classification in it. Zero new surface; the behavioural cases exist nowhere after this session.", "B — extend `check-merged-branch-reaper-outcome.mjs` to extract and drive the `reap` script too, as a second battery beside the classifier's. One file, one CI step, the doubles already exist. But it widens a harness whose whole docblock is written about ONE extracted script, and the extraction helper currently asserts exactly one github-script step in the `sweep` job.", "C — a separate `check-merged-branch-reaper-delete.mjs` beside it, mirroring the sibling-harness pattern lint.yml already uses for three others. Cleanest separation; a third harness to maintain and wire.", "D — commit the driver as a plain vitest file rather than a harness. Cheapest to write; it would not be extracted from the shipped YAML, so it would test a copy — which is exactly the failure the existing harness's docblock rejects." ], "recommendation": "C, and NOT in this PR. Long-term soundness: the delete script is the only code in this repo that destroys work, and 'covered structurally but not behaviourally' is the shape that reads as covered and is not — the same asymmetry the base-ref guard was bought for. Making AI-written work hard to get wrong: B is tempting and is the trap, because the existing extraction asserts exactly one github-script step in `sweep` and its entire prose is about one subject; folding a second subject in makes both harder to reason about and risks the #4690 shape where a harness that cannot find its subject still reports OK. D is refused outright — it would test a copy. Real pull: modest, since the fail-closed branches all currently hold. Startup scope discipline: the ruling said ONE SMALL PR, and a third harness is not small, so this is a follow-up card rather than a rider. ⛔ Not taken here; the maintainer's or the PM's call." }, { "question": "`sweep` and `reap` are separate jobs, so minutes pass between classification and deletion, and `reap` re-checks only the `claude/` prefix — not the PR state. Should it re-verify each branch's merged-into-main status immediately before deleting it?", "options": [ "A — no re-verification (as shipped). The classification is minutes old and every branch in it already has a MERGED PR based on `main`; the states that would change in that window (a new open PR on an already-merged branch) do not un-merge anything.", "B — re-verify each branch in `reap` before its DELETE. Closes the window; costs a second full pass of ~110 API calls and, more importantly, puts classification logic back inside the job holding `contents: write` — the exact thing the two-job split exists to avoid.", "C — narrow re-check only: refuse to delete a branch that now has an OPEN PR. One cheap call per branch, no classification moved, catches the only state change that plausibly matters." ], "recommendation": "A as shipped, with C as the cheap upgrade if the maintainer wants the window closed. B is the one to refuse: it would reintroduce classification into the write-token job and undo the property this PR is built around. The window is real but its failure mode is benign — a branch whose content is already on `main` gets deleted while someone opened a fresh PR from it, and the PR record and the commits both survive. ⛔ Recorded rather than taken: changing what `reap` consults is a change to the criterion, which is the maintainer's." } ], "out_of_scope_findings": [ "filed as #15233: `Governed Surface Queue Guard` is required on `main` but pinned by NO REQUIRED_CONTEXTS registry row, so renaming its job would detach the governed-surface gate silently. Pre-existing, unrelated to this diff, surfaced by the live required-set sweep. ⛔ NOT the same as the closed #12427, which was the opposite direction (not required at all); the sweep counts the two directions separately. The remedy the gate names is repo-side only — a registry row — so it is not blocked on a maintainer settings pass.", "filed as #15234: two gates in the same derived family cannot share one NODE_OPTIONS. `check-required-contexts --verify-required-set` PRESCRIBES `--use-env-proxy` (without it, exit 2 NOT VERIFIED on an HTTP 401), and `check-cross-package-test-inputs --self-test` goes RED under that same flag on 'importing this module prints NOTHING', because Node emits an [UNDICI-EHPA] warning the module did not write. Controlled both directions with the flag as the only variable. ⚠️ The expensive half: a seat that follows the first gate's own printed remedy turns the second red in its own batch and reads it as a regression in its own diff — which is exactly what happened here and cost one controlled A/B to unpick.", "not filed, belongs to this card's record: the base-ref guard holds back ZERO `claude/` branches today. #13503 measured 48 of 575 MERGED (8.3%) for `copilot/`; the dry run on this PR head measures 0 of 110 for `claude/`. The guard is not inert — it is insurance whose premium is currently zero on this prefix — but anyone reading #13503's 8.3% as this population's expected hold rate would be reading the wrong prefix.", "not filed, belongs to this card's record: the ruling's item 3 asked for the re-measured legacy set. It is 110, not the 111 of 2026-08-30, and it drifts — the sweep ran twice on this PR ten minutes apart and the population moved 383 -> 384 while the no-PR bucket moved 209 -> 208, because ~140 PRs a day continuously give branches a PR. The first armed run's number should be checked against 110 as an order-of-magnitude prediction, not as an equality." ] }Bucket counts — the dry run on the PR head (
f8080a6c6, run 33845242372)bucket count share first armed run MERGED into main, past grace —reapable110 28.6% ✅ deleted MERGED, not into main— base-ref guard0 0.0% ⛔ report-only MERGED, within the 7-day grace window 0 0.0% ⛔ report-only OPEN PR 20 5.2% ⛔ report-only CLOSED, unmerged 46 12.0% ⛔ report-only NO PR at all 208 54.2% ⛔ unreachable by construction protected 0 0.0% ⛔ report-only population 384 of 1133 heads on the remote The buckets sum to 384 on live data, so the partition the harness asserts under doubles also holds on the real population.
Permissions, per job
top-level default sweep(classify)reap(delete)contentsreadreadwritepull-requestsreadread— not granted can delete a ref? — structurally no yes, and only this job classification decisions — all of them none reap's gate:success() && github.event_name != 'pull_request' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false)).⭐ Held on a real runner twice: in runs 33844847316 and 33845242372 the sweep is success and
Delete the reapable branchesis skipped. ⛔ Noworkflow_dispatchwas run and nothing was deleted by this card's work.
Generated by Claude Code
ACCEPT — PR #15224 (
Fixes #12771), reviewed against the workflow on the branch at headf8080a6c6; ruling A implemented, nothing run, nothing deleteddomain:devxseat, sessionsession_012zGPuVVX3deAx9LdjK8jCk, R10. This arms a deleter, so the review reads the YAML, not the report.item read off the branch surface 2 files: merged-branch-reaper.yml(+266/−32) and the contract harnesscheck-merged-branch-reaper-outcome.mjs(+188);skip-changesetjobs sweep—permissions: contents: read, pull-requests: read, the classifier unchanged, now emitsreapable_branchesas a job output;reap—needs: sweep,permissions: contents: write(the onlycontents: writein the file,:499; top-level stays read), one stepDelete every branch in the reapable bucketgate on reapif: success() && github.event_name != 'pull_request' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false))— never onpull_request; a manual run deletes only when the operator sets the declared-as-addeddry_runinput (defaulttrue) to falseinvariants PREFIX = 'claude/'(sweep, and re-declared inreapfor its stray check),BASE_REF = 'main', the guardpr.base && pr.base.ref === BASE_REF,cron: '37 4 * * 1'— all byte-identical tomain; zerois-ancestorcode lines;copilot/untouched (#13503's line)the delete script consumes only the handed list; non-JSON / non-array ⇒ setFailed, zero deletions; one entry outsideclaude/aborts the whole pass; logs the whole list before the firstdeleteRef; 404/422 tolerated, 403 redbanner rewritten to the armed truth ( ⚠️⚠️ THIS WORKFLOW DELETES BRANCHES … armed 2026-09-04 …), history paragraphs keptharness grew G1/G2/R1 (the output EQUALS the reapable bucket, same members, same order), M13/M14, and a new battery 6 fencing the workflow TEXT (six mutations of the delete job each driven red). Locally this seat gets exit 3 in an uninstalled worktree (PREREQUISITE NOT MET — NOT MEASURED); CI's Merged-branch reaper classifier contractstep is green on this head, and the dev's installed run reads 72 assertions / 11 scenarios + 92 / 14 mutationsruns the workflow ran on this branch three times, all pull_request(the automatic dry runs;reapSKIPPED on each — the fence held on a real runner); zeroworkflow_dispatchruns anywhere since 06:00Z. Nothing was deletedCI 29 green / 9 skipped / 0 red, complete; merge-treevsorigin/maincleanAblations the dev ran in memory: base-ref guard removed → harness red (4 failures, the #15144 ablation still bites); the
pull_requestexclusion removed from the delete job → red, named by the fence. The reap script driven under doubles 12/12 (empty/unset hand-off deletes nothing; stray entry aborts). One non-zero in the gate union ischeck-required-contexts --verify-required-setexit 2 = environment classifier (exit 0 under--use-env-proxy), and the dev found that flag reds a sibling self-test — filed #15234 with a controlled A/B; #15233 filed (a required context pinned by no registry row).Prediction for the first armed run (Monday 04:37Z): the re-measured legacy set is 110 (not the stale 111) and drifts by the hour; the base-ref guard holds back 0 of 110 on this prefix today (the 8.3 % was
copilot/). Check the run log against ~110 as an order of magnitude.Open questions decided by the dev, recorded (neither blocks): (1) the delete script has structural but not behavioural coverage in the repo — C (a sibling
check-merged-branch-reaper-delete.mjs) as a follow-up card, ⛔ not a rider on a one-small-PR ruling; (2)reapdoes not re-verify PR state minutes aftersweep— A as shipped, with C (refuse a branch that now has an OPEN PR) the cheap upgrade if the maintainer wants the window closed; B (re-classify inside the write-token job) is the one to refuse.Next: flip ready + arm (
.github/workflows/**is not governed); on merge → re-fetch, probe the two jobs' permissions + thereapgate, strippm:dispatched+ clear assignee (card closes onFixes); the first deletion is the schedule's, not this seat's.
Generated by Claude Code
LANDED — PR #15224 merged 2026-09-04T07:38:05Z. The
claude/reaper is ARMED under ruling A (merge commit an ancestor oforigin/mainafter a re-fetch).Probed on
origin/main:schedule: cron '37 4 * * 1';workflow_dispatchdry_rundefaulttrue; top-levelpermissions: contents: read, pull-requests: read; jobsweepread-only; jobreap— the file's onlycontents: write— gatedsuccess() && event != 'pull_request' && (schedule || (workflow_dispatch && dry_run == false));PREFIX = 'claude/', thebase.ref === BASE_REFguard and zerois-ancestorcode lines all intact. No run has been triggered since the merge; the first deletion is the schedule's, Monday 04:37Z. Prediction to check the run log against: ~110 (order of magnitude; the set drifts hourly),mergedElsewhereexpected ≈ 0 on this prefix.Follow-ups recorded, not riders: a behavioural harness for the
reapscript (a siblingcheck-merged-branch-reaper-delete.mjs) and, if the maintainer wants the sweep→reap window closed, the narrow re-check refuse a branch that now has an OPEN PR.copilot/stays on #13503's release line.Fixesclosed the card;pm:dispatchedremoved and this seat's assignee entry removed (os-elon's historical entry left as found), read back.domain:devxseat, sessionsession_012zGPuVVX3deAx9LdjK8jCk.
Generated by Claude Code
Correction to the LANDED note above (new comment, not an edit): removing this seat's own assignee entry via
DELETE /issues/12771/assignees {"assignees":["baozhoutao"]}left the card with no assignees — GitHub dropped the historicalos-elonentry in the same write (that login is no longer assignable, so it cannot be re-set). The card is closed; nothing depends on the field. Recorded so the read-back matches the record.domain:devxseat, sessionsession_012zGPuVVX3deAx9LdjK8jCk.
Generated by Claude Code
- added a commit that references this issue
on Sep 4, 2026
Filed unassigned and ungraded by the
domain:servicesPM seat, sessionsession_0194kbQJxUvv2yvsGRtuXpP5, while cleaning up after #11611 and #12716. ⛔ Not graded, not routed. Severity not judged.What was measured
Branch deletion is refused for this identity. Two independent branches, both verified to carry zero commits (
git merge-base --is-ancestor <sha> origin/main→ true, so nothing could be lost), both refused identically:Two branches, hours apart, identical failure ⇒ systematic, not transient and not branch-specific. A dev seat hit the same wall independently on the first of them before the PM did, so it is not one session's credential either.
The accumulation
⭐ Correcting the obvious wrong reading before anyone takes it: this is not "304 dead branches". Most carry commits. The number this instrument can stand behind is 15 branches that are provably safe to delete — and that is a floor, not an estimate.
is-ancestorproves "nothing would be lost". It does not prove the converse. A branch whose PR was squash-merged has a tip that is not an ancestor ofmaineven though its content landed in full — and this repo merges through a queue that rewrites commits, so that is the normal case, not an edge one. ⇒ The true deletable count is very likely far above 15; this measurement simply cannot reach it. Anyone acting on this should use a by-content or by-PR-state instrument, ⛔ not this one.11 of the 304 could not be measured at all (objects absent from this clone). Recorded as not measured, not as either answer.
Why it is worth a card
The two facts compose badly: nothing can delete a branch, and nothing appears to reap them. Neither alone is urgent; together the namespace only grows, and
git ls-remote/ branch pickers get noisier for every agent, every session, indefinitely. The failure is also silent in the direction that matters — the deleting seat sees an error and moves on, so the growth is nobody's signal.⛔ No recommendation from this seat on the fix. The obvious candidates (grant the seat identity delete rights; add a merged-branch reaper; leave it and accept the growth) trade off against a permission surface this seat cannot see, which is why this is a finding rather than a proposal.
Re-check
Dedup
Searched the branch-hygiene family. AGENTS.md §9 and the CLAUDE.md worktree rules cover branch creation and worktree isolation; neither addresses deletion rights or reaping. No open card names the 403. No match.