fix(pm): board-snapshot walks a bounded delta before the backfill, so the live board is re-read every run - #18054
Conversation
…hot re-reads the live board every run Once the open set completed, `selectWalkPlan` handed every run to the history backfill and `incremental` was unreachable until that backfill finished. The 2026-09-13T14:22Z run measured the result on `board-archive` tip b7c5f57: `walk_phase: history`, `resume.stopped_by: budget`, history cursor at 2026-08-03, `next_since: null`, `board.read_at: null`. The highest archived number was 17460 — #18010, #18020, #18025 and the twelve cards an account suspension destroyed that morning were in no snapshot at all, and at 800 requests a run the backfill was weeks from reaching them. The archive exists to answer one question after a loss; on the day of a loss it answered nothing. Maintainer ruling, verbatim and untranslated, in the skills seat's chat at 2026-09-13T14:58Z: 「现在就派发处理 备份缺口」 A DELTA walk now runs first on every run once the open set is complete: `state=all&sort=updated&direction=asc&since=<the archive's high-water mark over the live board>`, archiving every row it sees, open or closed, issue or pull request. It is bounded to DELTA_REQUEST_SLICE (300) of the 800-request budget and the backfill takes the remaining 500, so a quiet window costs a page and a busy one cannot starve the backfill. A spent slice is not a run stop: the delta writes its cursor, the backfill continues, and the next run resumes the delta there. The delta's first anchor is `walk.open_set.completed_at` minus DELTA_SKEW_MS rather than the previous run's `generated_at` — that stamp names a window this tool never walked, and on the measured manifest it would have skipped three days of board activity with the very walk added to stop skipping it. The manifest carries `walk.delta` beside `walk.history`, `next_since` becomes the delta's high-water mark, and the board's own open count is read right after the delta, so `count_check` stops being permanently `pending` in the history phase. `--self-test` grows an offline harness that drives `snapshot()` against an injected fetch — the walk order and the budget split exist nowhere but inside that function, so they are pinned by running it rather than by reading its source. 89 cases across 8 batteries before; 124 across 10 after. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
…board has The regression case passed on a tree with the delta step ablated: on a four-row board the backfill reaches today inside one page, so the row landed on disk whether or not a delta ran. A 900-row closed backlog with a comment thread each reproduces the pressure the real archive is under, and the case now names something only the delta can do. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
An absent archived card makes `readArchivedCard().issue` undefined, so an ablated tree threw at the first such assertion and the remaining cases never ran. Optional chaining turns each into a named red instead of one stack trace. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① derived judgments — the archiver re-reads the live board every run, bounded, before the backfill:
Seat measurements on the head tree ( ② semver: ③ boundary flags: Implemented-by: Verdict: PASS — the live board is re-read every run, bounded, anchored on the last full enumeration, with the regression pinned by a driven harness; the maintainer's direct order is delivered. Generated by Claude Code |
…bjectstack-ai#18266) Fixes objectstack-ai#18137 Scope is the card's **shape 3 only**, per the skills seat's correction (comment 5673977209): shape 1 (delta-first) landed as objectstack-ai#18054, shape 2 (more runs) and any budget raise are the maintainer's, shape 4 (an objectui port) is a separate card. This PR changes no cron, not the 800-request cap and not the 300-request delta slice. ## The defect, measured rather than argued `count_check: pending` is the tool's own "could not check" — but the exit code is built from the count check's `ok`, and `pending` leaves that `null`. So a run that archived days-old state exited 0, and the workflow step that reacts to `steps.snapshot.outputs.exit_code != '0'` never fired. Read off this repository's own archive — every manifest `board-archive` has ever carried (21 commits touching `board/manifest.json`, `2731e47a6` back to `16259758b`) against this workflow's run history: | manifest `generated_at` | `count_check.verdict` | `walk.delta.cursor` | run conclusion | |---|---|---|---| | 2026-09-10T15:40Z | `ok` (580/580) | — (the run that finished the open set) | success | | 2026-09-10T20:23Z … 2026-09-13T15:30Z (13 runs) | `pending` — "the board's own count was not read this run" | (no delta block) | success ×13 | | 2026-09-13T20:23Z | `pending` — "this run's delta did not catch up" | 2026-09-11T02:47Z | success | | **2026-09-14T02:37Z** | **`pending`** (expected 512 / archived 485) | **2026-09-12T00:40Z, two days behind** | **success** | | 2026-09-14T08:37Z / 14:28Z / 20:24Z | `pending` | 2026-09-12T14:32Z / 2026-09-13T11:19Z / 2026-09-14T04:46Z | success ×3 | | 2026-09-15T02:34Z | `surplus` (expected 519 / archived 536) | 2026-09-15T02:30Z | **failure** (exit 2) | Nineteen consecutive `pending` runs, 2026-09-10T20:23Z through 2026-09-14T20:24Z, every one of them green — including the run on the morning an account suspension destroyed cards the archive was two days too far behind to hold. That is the card. The same manifest replayed through `renderRun`, before and after this change (measured, not transcribed — the "before" leg imports the file as of `cb648cb1`): ```text ========== BEFORE — exit 0 ========== count check PENDING — archived 485 open issue(s); the board reports 512. Why not a verdict: the open set is complete but was enumerated in an earlier run and this run's delta did not catch up with the live board, and the closed history is still walking — cards closed or opened since then are archived in their old state paused the per-run budget of 800 requests is spent — this is a planned stop, not a failure. The next run resumes the history walk at 2026-08-06T13:26:08Z. ========== AFTER — exit 5 ========== (every line above, unchanged, then:) freshness STALE — no run has reached a count-check verdict since 2026-09-10T20:23:11.768Z (78 h ago); the window is 24 h. This archive is behind the live board by an unknown amount, so the run is RED rather than green: a backup that is behind while reporting success is the one state this row exists to end. Whatever this run DID read is committed before the step that reads this exit code — a stale archive is better than none. It clears on the first run whose delta catches up with the live board and whose count check reports a verdict again. ``` ## What the row is, and the two spellings it deliberately does not use A new manifest block, `freshness`, with three verdicts — `building`, `fresh`, `stale` — and a new exit code `EXIT_STALE = 5`, distinct from the count check's 2 (that one says the archive and the board *disagree* about a count both sides were read for; this one says no such reading has happened at all for a day). **The anchor is the streak's start stamp, ⛔ not the delta cursor — and that is a falsification of the literal reading, not a preference.** `walk.delta.cursor` is the `updated_at` of the newest row the delta archived, not the instant the board was last read: an empty page is a short page, `nextWalkStep` ends the walk returning the cursor it was handed, so on a quiet board the delta completes every run, the archive is exactly level, and that cursor sits as far in the past as the last card anyone touched. Ageing it reds a board for being quiet. This is driven in `--self-test` rather than argued (a five-day-quiet board: delta complete, `count_check: ok`, cursor five days old, verdict `fresh`, exit 0) and ablated below. **It is a stamp, ⛔ not a counter of consecutive runs.** The file's own `materialManifest` exists because a value that moves on every run commits a manifest-only diff on every scheduled run and buries the real ones. A streak start is written once when the streak opens, carried forward byte-identical while it lasts, and cleared when a verdict returns: two manifest writes per incident, and the pinned idempotence property survives untouched. **The window: 24 h, measured from the first UNCONFIRMED run** — which is itself one schedule interval after the last confirmed one, so a red lands a day plus one interval after the archive was last known level. On the four-a-day cron that is the 30 h the card asked for, and the script carries no cadence constant at all, so the maintainer's cadence decision touches nothing here. The calibration comes from the same 21 manifests: the one legitimate catch-up in this archive's history — a bounded delta slice closing a three-day gap after objectstack-ai#18045 — ran 2026-09-13T20:23Z to 2026-09-15T02:34Z, 30 h 04 m, and every run inside it held an archive that did not have the newest cards. So the window never reds a single stalled run, never reds a day-long gap, reds the tail of a worst-case catch-up, and reds every run of the four-day streak this card was filed on. **`building` is why a PR run stays green.** The workflow's `pull_request` leg walks into a temp dir with no manifest under a `--limit`, so its open set never completes; without that verdict every PR touching this tool would go red on a tool working perfectly. ## How the red surfaces, and what it costs the archive Nothing. The step order is measured and kept: `Run the snapshot` → `Commit and push the archive` → `Upload the archive to R2` → `Publish the run to the summary` → the step that reads the exit code, which is last in the job. A stale archive is better than none, so whatever a red run managed to read is committed and mirrored before the run is allowed to fail. The workflow's error line now names code 5 alongside 2/3/4, and its step is renamed to say what it now judges. ⛔ The row prescribes no remedy: cadence, budget and slice are named nowhere in the verdict. What it prints is what *clears* it — the first run whose delta catches up and whose count check reports a verdict again. A self-test case pins that: the stale block contains none of "cadence", "budget", "slice" or "more runs". ## Tests `--self-test`: **125 cases / 10 batteries → 145 cases / 11 batteries**, all green; the 125 existing cases keep their outcomes. The new battery ("the freshness row: a stale archive is never a green run") is 18 cases plus 2 added to the driven battery, including the card's own manifest replayed to `stale` + exit 5, the correction's caught-up manifest replayed to `fresh` + exit 2 (the count mismatch keeps its own code), the boundary at exactly the window and one millisecond past it, and two end-to-end driven runs. Reverse verification — the fix committed first, each leg proving its mutation reached the disk (block-occurrence counts, not a `grep -c` over a multi-line block, which is what a first attempt got wrong and the guard caught), restored with `git checkout HEAD -- path` and verified by blob hash against `HEAD`: | ablation | on disk | self-test | |---|---|---| | delete the `EXIT_STALE` leg from `renderRun`'s exit selection | removed-block 1 → 0, marker 1 | **RED** — 2 of 145 fail: the card replay and the driven stale run | | age the delta cursor instead of the streak (the literal reading of the card's condition (a)) | removed-block 1 → 0, marker 1 | **RED** — 2 of 145 fail, one of them the quiet-board case: the false alarm is real, and the case that names it is an instrument and not a decoration | Restore verified both times: blob `1ead53c1…` equals `HEAD:scripts/pm/board-snapshot.mjs` and `git diff HEAD` is empty; the tree re-runs 145/145 green. Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, on the merged head `d53749c2` — 51 families derived, 51 run in the foreground with exit codes captured by redirect (`cmd > log 2>&1; EXIT=$?`), 51 exit 0, reconciled with `--ran`. `check:pm-dispatch-gates` was run the way its own header prescribes (detached, log tailed) because its battery outruns a foreground command cap. ⛔ Not measured here, and named rather than implied: the workflow file cannot be executed locally (no runner, no `GITHUB_TOKEN` with this scope), so its change is validated by the repo's own workflow gates — `check:ci-filter-parity`, `check:workflow-status-functions`, `check:workflow-step-name-quoting`, `check:self-test-workflow-commands`, `check:step-collectors` — and by reading the step order. The 52 artifact-roster families, the 11 wide-population families and the one path-scheduled CI job the derivation names are outside the runnable total and are CI's. ## Acceptance notes - `skip-changeset`: nothing published moves. `scripts/pm/**` and `.github/workflows/**` ship in no package's `files[]`. - Noted, not filed — the workflow header calls this script's self-test "70-case"; it was 125 before this PR and is 145 after. A stale number in a comment is a doc nit, not one of the three filing classes, and fixing it is outside this card's defect class. Carrier: the next PR that edits that paragraph. - Noted, not filed — the latest scheduled run (2026-09-15T02:31Z, run 46) is already **red** at exit 2 with a `surplus` of 17 (board 519, archive 536). That is this tool's destruction signature doing exactly its job, and it is consistent with the 2026-09-14 suspension destroying cards the archive still holds; `--restore=N` prints them back, and a number deliberately left gone belongs in `gone.json`. Acting on it is the seat's and the maintainer's, not this PR's. --- _Generated by [Claude Code](https://claude.ai/code/session_01HZfg2AwVX191qCizp88gQr)_ Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18045
Maintainer ruling, verbatim and untranslated (skills seat chat, 2026-09-13T14:58Z): 「现在就派发处理 备份缺口」
Premises, all four checked against
origin/mainc185d08 before any editselectWalkPlanreachesincrementalonly oncehistorycompletes, so while the backfill runs no walk ever carries a recentsince. Confirmed onboard-archivetipb7c5f578:walk_phase: history,next_since: null,resume.stopped_by: budget, history cursor2026-08-03T11:51:21Z,board.read_at: null,count_check: pending.board/issues/, highest number 17460; 18010, 18020 and 18025 are absent. Read withgit ls-treeover a shallow fetch of the branch into a private ref — the shared checkout was never switched.scripts/pm/board-snapshot.mjs. (feat(pm): two report-only patrol rows — the decision card's four-facet block, andfindingoutliving its grade #18033 touchesscripts/pm/check-half-states.mjs, which this file imports from; no overlap in this diff.).github/workflows/board-snapshot.ymlis unchanged — same cron, same 800-request default, oneGITHUB_TOKEN, no retry loop.The fix, in
scripts/pm/board-snapshot.mjsaloneA bounded delta walk now runs first on every run once the open set is complete:
state=all&sort=updated&direction=asc&since=..., archiving every row it sees — open or closed, issue or pull request. The history backfill then continues from its own cursor with the remaining budget, its resume semantics untouched. A spent slice is not a run stop; only the run budget and a rate-limit refusal stop a run, exactly as before.The budget split: 300 of 800, and 500 to the backfill. A quiet six-hour window moves a few dozen numbers, so a steady-state delta costs one listing page plus a comment read per changed number and the backfill keeps very nearly the whole budget. A catch-up delta buys around 250 numbers a run, closing a multi-day gap in a day of scheduled runs rather than in the weeks the backfill needs to reach the same rows. In the worst case the backfill still gets 500 — five eighths of its former pace, slowed but never starved. An unbounded delta could starve it for good, which is why
DELTA_REQUEST_SLICEexists at all.New manifest keys.
walk.delta = { complete, cursor, since, slice }sits besidewalk.historyand is never merged into it.next_sincebecomes the delta's high-water mark instead of the backfill's February cursor.resume.phasenow names the WALK that stopped (open/delta/history/incremental) rather than the phase.rungainsdelta_ran,delta_completed_hereanddelta_requests— inrun, whichmaterialManifeststrips, so a per-run counter cannot commit a manifest-only diff every run. The board's own open count is bought right after the delta and before the backfill, socount_checkstops being permanentlypendingin the history phase.One declared deviation from the card's fix shape
The card spells the delta anchor as "the previous manifest's
generated_at, minus a small skew". Implemented instead as: the delta's own stored cursor; elsewalk.open_set.completed_atminus the skew; elsenext_since; elsegenerated_atminus the skew. On the measured manifestgenerated_atis 2026-09-13T14:22Z while the board was last enumerated in full at 2026-09-10T15:39Z, so anchoring on the stamp would have declared a window this tool never walked and skipped the exact three-day gap the card was filed about. The skew is 30 minutes, two runs' worth of the workflow's own 15-minute job timeout.Verification
node scripts/pm/board-snapshot.mjs --self-test: 89 cases across 8 batteries before, 125 across 10 after, exit 0 both. Two new batteries; the count-check battery's floor moves 15 to 17 and the roster floor 8 to 10.snapshot()end to end against an injectedfetch— no network, no token, restored in afinally. The card assumed one existed; it did not, so the walk order and the budget split had no instrument at all.f3723530toe0cdbd7b, restored tof3723530,git diff HEADempty). A first attempt stayed green: over a four-row board the backfill reaches "today" inside one page, so the row landed on disk whether or not a delta ran. The harness now carries a 900-row closed backlog, so the backfill provably cannot reach that day inside 800 requests — that fix is its own commit.origin/main(226970b) is merged in;node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderives the same 33 commands on the merged head as before it. Every one runs in the foreground with$?captured before any pipe, and--ranreconciles the list; the verdicts are in theos-dev-reportcomment on the card.skip-changeset: the root package is private and no published package'sfiles[]carriesscripts/, so this diff publishes nothing.Generated by Claude Code