Skip to content

ci: add a report-only merged-branch reaper sweep - #13500

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper
Aug 30, 2026
Merged

os-project-manager merged 2 commits into
mainfrom
claude/issue-12771-merged-branch-reaper

Conversation

@claude

@claude claude Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

Part of #12771

Adds .github/workflows/merged-branch-reaper.yml — a report-only sweep that classifies every claude/* remote branch by the state of the PR whose head ref it is, and prints the would-delete list. It deletes nothing.

Part of, not Fixes, on purpose: this delivers the dry-run half the ruling asked for. Enabling deletion is a separate, reviewed diff and the maintainer's call, and the measurement below raises a question the card should stay open for.

The two fences

  1. ⛔⛔ Criterion is PR state MERGED. The is-ancestor probe appears nowhere — not as the criterion, not as a secondary check. The only mentions of the phrase in the diff are prose forbidding it. Verified: grep -n "is-ancestor\|merge-base" returns 5 hits, all comments or the rendered report caption; reverse-checked with a term known present (merged_at, 10 hits).
  2. ⛔⛔ Dry-run only. And it is enforced by the token, not just the code: the job runs with permissions: contents: read, which is structurally incapable of deleting a ref. A defect in the classification still cannot delete a branch.

Measurements — these changed the design

All figures measured 2026-08-30 against the live API, not relayed.

The population, re-derived: 335 claude/* branches

Also measured, and worth a separate look: copilot/ carries 678 branches — twice the claude/ accumulation. Out of scope here (the ruling names claude/*), reported so it is not mistaken for covered.

⭐ delete_branch_on_merge is already enabled — so the event-driven shape is the wrong one

repo setting delete_branch_on_merge true
claude/* PRs merged 2026-08-20T13:11Z .. 2026-08-30 1386
...whose head branch is still on the remote 1
native leak rate 0.07%

An on: pull_request reaper would fire ~140x/day to find nothing 99.93% of the time, racing the platform setting on the same ref. So this is a weekly scheduled sweep + workflow_dispatch instead — one mechanism that covers both the legacy backlog and the rare leak. The 111 merged survivors are a one-time debt, not a flow: 62 merged in 2026-04, 33 in 2026-06, 8 in 2026-08 (newest 2026-08-20).

⭐ H3 confirmed, and it dominates: half the population has no PR at all

bucket count share reaped?
MERGED 111 33.1% ✅ would delete
NO PR at all 170 50.7% ⛔ unreachable by construction
CLOSED, unmerged 43 12.8% ⛔ excluded — MERGED-only default
OPEN 11 3.3% ⛔ excluded — in use

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, 123 within the trailing 14 days. Branches pushed by a session that died or never opened a PR.

⇒ This workflow is correct as ruled and clears a real 111-branch debt, but it reaches ~a third of the population and close to none of the ongoing accumulation. Widening the criterion to cover abandoned no-PR branches is a new ruling (what proves such a branch is abandoned rather than in flight?), deliberately not taken here.

Option B — granting seats delete rights — is not touched here; it stays with the #12728 credential pass.

Proving the filter, not just running it

Every bucket above is a demonstrated exclusion on real data, and the report renders each with named examples:

  • OPEN excluded — e.g. a branch whose PR is still open; also excluded when a branch has both a merged and an open PR (in use regardless of the merge).
  • CLOSED-unmerged excluded — 43 branches whose work never landed; MERGED-only is the default policy and reaping them would discard unmerged work.
  • NO PR excluded — 170 branches the criterion cannot see.
  • MERGED within grace — a 7-day grace window since merged_at keeps the sweep from racing delete_branch_on_merge. On today's data this bucket is 0 (newest survivor merged 2026-08-20), so the first real run should show 111 would-delete / 0 held.

One trap found and guarded in the code: commits/{sha}/pulls looks like it answers "does this branch have a PR" and does not — it returns PRs that merely contain the commit. Measured, it claimed a PR for 4 of 5 branches that had none of their own. The workflow therefore filters on pr.head.ref === branch.name rather than trusting the endpoint shape.

The pull_request trigger on this file means this PR's own CI run produces the first real would-delete list on a real runner — the human look the ruling asks for, rather than a claim about one.

Verification

Gate union re-run at final commit c014a236 — 17 derived families, all exit 0, exit codes captured before any pipe:

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  docs-audit/check-drift-comment  pm/ci-failure --self-test

Gate list derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, not recalled. The workflow-scanning gates report 29 workflow files where 28 existed before, which is how I know they actually read the new file rather than passing vacuously.

  • YAML parses; embedded github-script body extracted and node --check clean.
  • check-test-completeness.mjs exits 3 / PREREQUISITE NOT MET by design — recorded NOT MEASURED, neither red nor green.
  • No control bytes: grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' clean.
  • No changeset: the diff is one .github/workflows/ file and publishes nothing from any package, so skip-changeset applies.

Patch round — Lint & Repo Gates red at c014a236, green at e1c856b7

dispatch-gates' own self-test refused this file:

✗ every real paths-filtered workflow discovers a check family or declares why not (gaps: merged-branch-reaper.yml)
✗ dispatch-gates self-test: 1 of 944 case(s) failed.

Reproduced locally at c014a236 (exit 1, same assertion, line 636) before changing anything.

The assertion names two legitimate exits: discover a check:* family, or declare why there is none. This workflow genuinely has none — its single step is an API sweep through actions/github-script, not a named local verification — so it now carries 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

That is the mechanism's intended use, not a way of making the gate stop asking: 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.

The pull_request filter was kept, and that was measured rather than assumed. The alternative fix — drop the paths filter so the gate skips the file — would have been smaller but wrong: that trigger is what produced this workflow's first real dry-run list, on a real runner, which is the one human look the ruling requires. Run 33318728567 completed success with the annotation:

Dry run: 111 of 335 claude/ branches would be deleted. 168 have no PR and are unreachable by this criterion. Nothing was deleted.

That is the deliverable, generated by the workflow itself rather than predicted: 111 would-delete matches the local classification exactly, and the no-PR share (168 live vs 170 measured ~40 min earlier) drifts because this repo merges ~140 PRs/day and branches acquire PRs continuously.

After the fix, at e1c856b7:

✓ every real paths-filtered workflow discovers a check family or declares why not (gaps: none)
✓ dispatch-gates self-test: 944 cases pass.

Both legs run: the self-test leg (pnpm check:pm-dispatch-gates, exit 0) and the work leg (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, exit 0, tree clean). Derived family union re-run at e1c856b7 — all 17 exit 0.

⚠️ One gap noticed and not fixed here: check:pm-dispatch-gates is not in the family list that dispatch-gates derives for a .github/workflows/** surface, even though its live assertion reads every workflow file in the tree. That is exactly why the local run passed and CI did not. Reported rather than patched — it is outside this PR's declared surface.

Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC


Generated by Claude Code

Adds .github/workflows/merged-branch-reaper.yml: a weekly (plus
workflow_dispatch) sweep that classifies every claude/* remote branch by
the state of the pull request whose head ref it is, and reports the
would-delete list. It deletes nothing.

The criterion is PR state MERGED. The is-ancestor probe is not used
anywhere, including as a secondary check: this repo squash-merges through
a queue that rewrites commits, so a fully merged branch's tip is normally
not an ancestor of main.

Report-only is enforced by the token, not only by the code -- the job runs
with contents: read, which cannot delete a ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
The reaper is paths-filtered on itself, and dispatch-gates requires every
paths-filtered workflow to either discover a check:* family or declare why
it has none. This one genuinely has none: its single step is an API sweep
run through actions/github-script, not a named local verification.

Uses the marker the gate itself provides rather than an exemption list, per
that mechanism's own docblock. The pull_request filter is kept because it
is load-bearing: it produced the first real dry-run list (run 33318728567,
"111 of 335 ... would be deleted"), which is the human look the ruling
requires before deletion is enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@os-project-manager
os-project-manager marked this pull request as ready for review August 30, 2026 15:44
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 30, 2026
Merged via the queue into main with commit 4a24b96 Aug 30, 2026
33 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-12771-merged-branch-reaper branch August 30, 2026 16:07
os-tesla pushed a commit that referenced this pull request Oct 25, 2026
…er (objectui#13492) (#13500)

<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9D1ujqNbMkVbXgPHgdDMe18r)_

Fixes #13492

Clause-②: no
Metadata-protocol: none

## What changed

An assistant answer now renders its text and its charts in the order the
stream produced them. A chart the model drew before writing its analysis
shows first, so an open-ended analysis in the console's assistant panel
puts its chart on the first screen (the acceptance
objectstack-ai/cloud#3072 sets). Before, `mapMessages` joined every text
part into one `content` and lifted every `data-chart` part into one
trailing `charts[]`, and `ChatbotEnhanced` rendered the whole text
first, so the chart always landed below every paragraph.

- **`mapMessages.ts`:** `extractCharts` now records, on each chart,
where it arrived among the text parts: `textOffset` = the length of the
text that `extractText` joined before it. Tool, step and reasoning parts
add nothing. Only when `content` IS that join; a legacy string `content`
is not built from the parts, so its charts carry no offset.
- **`ChatbotEnhanced.tsx`:** a new optional `ChatChart.textOffset`.
`layoutAnswer` splits `content` at the offsets into text runs and chart
runs; each text run renders through its own `MessageResponse` (so
long-answer folding and record links work in every run), and each chart
run through the same chart markup as before, now in `AnswerCharts`. The
streaming caret rides the last text run, where it sat before. Charts
render in the same tree position whether or not prose has arrived, so a
chart drawn before the first word keeps its mounted node when the text
streams in.
- **Backward-compatible:** `content` and `charts` keep their shape. A
chart with no `textOffset` (a host-built message, the legacy
string-content shape) or one past the end of the text trails all of it,
which is exactly the old layout; a message with no charts renders the
same DOM as before. `textOffset` is a placement key and never reaches
the chart schema handed to `SchemaRenderer`.

## The card's acceptance, item by item

| Acceptance | Pinned by |
|---|---|
| parts `data-chart`, `text`: the chart renders before the text |
`data-chart then text: the chart renders before the analysis` |
| parts `text`, `data-chart`, `text`: the three in that order | `text,
data-chart, text: the three render in that order` |
| a re-hydrated stored message keeps the order |
`useObjectChat.chartOrderRehydrate-13492.test.tsx` (API mode, stored
message with `parts` through the hook and into the bubble) and
`re-mapping a restored message (same parts) yields the same placement` |
| long-answer folding (objectui#13032) does not regress |
`objectui#13032 does not regress: the long analysis after a chart still
folds once the stream ends`, plus the unchanged
`chatAnswerLayout-13032.test.tsx` |
| record links (objectui#13037) do not regress | `objectui#13037 does
not regress: a record link in the text after a chart opens through the
host opener`, plus the unchanged `chatRecordLink-13037.test.tsx` |
| staging re-test with 「帮我分析一下客户」 | **NOT MEASURED**: staging reaches
this through cloud's objectui pin; re-run after the pin moves past this
merge |

New files: `mapMessages.chartOrder-13492.test.ts` (6 tests),
`ChatbotEnhanced.chartOrder-13492.test.tsx` (7, one a CONTROL for the
old layout), `useObjectChat.chartOrderRehydrate-13492.test.tsx` (2).
`mapMessages.test.ts` gains the `textOffset` in the one test that pins a
chart's exact shape.

## Verification (local, head `cb0af96`)

All runs from the repo root under the shared verify lock.

- `pnpm exec vitest run --maxWorkers=2 packages/plugin-chatbot/`: **Test
Files 82 passed (82), Tests 787 passed (787)**.
- The 54 test files in `packages/app-shell/src` and `apps/console/src`
that import `@object-ui/plugin-chatbot` (resolved to its `src` by the
root config's alias): **Test Files 54 passed (54), Tests 582 passed
(582)**.
- `pnpm turbo run type-check --filter=@object-ui/plugin-chatbot` (builds
the dependency closure, then `tsc --noEmit && tsc -p
tsconfig.test.json`): exit 0 after one fix to the new re-hydrate test
(it now hands the hook's messages through `toRuntimeMessages`, as hosts
do).
- `eslint --no-inline-config` on the six touched source and test files:
0 errors; the 11 warnings are all on lines this PR does not touch.
- `node scripts/check-changeset-presence.mjs` and `node
scripts/check-changeset-no-major.mjs`: pass. `pnpm
check:new-line-citations`: 0 new citations. `check:vi-mock-specifiers`,
`check:vi-mock-inherit`, `check:vi-mock-override-shape`,
`check:test-path-roots`, `check:control-bytes`, `check:ui-root-lookups`,
`check:unreferenced-sources`: all exit 0.
- **Reverse verification**, both through `ablation-replace.mjs` with the
fix committed and restore proven (blob equals HEAD, `git diff HEAD`
empty):
- renderer: `layoutAnswer` ignores `textOffset` (every chart trails the
text) ⇒ 6 of the 9 render and re-hydrate tests turn red; the record-link
test, the old-layout CONTROL and the mapper tests stay green, as they
should.
- mapper: the `textOffset` spread deleted ⇒ 13 tests red across the four
files, including the existing exact-shape test; the legacy no-offset
test stays green.
- NOT MEASURED locally, left to CI: the sharded full `pnpm test`,
repo-wide `pnpm lint`, builds and the remaining `check:*` gates.

## Acceptance notes

- **No reload path in this repository restores a chart today, so on
reload the chart is missing, not misplaced.** The server keeps a
conversation as Vercel `ModelMessage`s
(`IAIConversationService.addMessage` in `@objectstack/spec`'s
`contracts/ai-service.ts`), which have no data parts. On the way back,
app-shell's `hydratedMessagesToChatMessages` builds `content` and tool
invocations only, and `sanitizeChatMessagesForCache` writes text and
tool parts only. Read from code, not measured on staging; outside this
card's file surface (`packages/plugin-chatbot/src/**`). A stored message
that does carry its `data-chart` parts keeps its order (pinned above).
Whether cloud's `visualize_data` result carries enough to re-derive the
chart on reload is a cloud-side reading this session cannot reach.
- In an answer that interleaves text and charts, each text run folds on
its own (a run taller than two viewport heights folds; a short intro
above the chart does not). An answer with no chart, or with its charts
after all the text, is one run and folds exactly as before.
- Docs: neither `packages/plugin-chatbot/README.md` nor
`content/docs/plugins/plugin-chatbot.mdx` describes where charts render,
so nothing there went stale; `textOffset` is documented on the
`ChatChart` type.

- [x] Adds no product-specific widget, dialog, hook or string (see
AGENTS.md › Product-neutral renderer)

Card thread session: `session_01VWbYRWtBm3f8J2T8HAt97o`, claimed by the
`domain:ui` seat 4 (claim comment 6393183623).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01VWbYRWtBm3f8J2T8HAt97o

---
_Generated by [Claude
Code](https://claude.ai/code/session_01VWbYRWtBm3f8J2T8HAt97o)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants