Skip to content

feat(observability): publish crash-recovery index presence as a gauge (BLO-21526) - #1933

Merged
allyblockcast[bot] merged 4 commits into
masterfrom
BLO-21526-migration-0211-records-complete-without-its-concurrently-index-suppressed-notice-means-crash-recovery-scan-can
Sep 24, 2026
Merged

allyblockcast[bot] merged 4 commits into
masterfrom
BLO-21526-migration-0211-records-complete-without-its-concurrently-index-suppressed-notice-means-crash-recovery-scan-can

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • When a worker crashes mid-run, reconcileWorkerCrashedRuns is what finds the abandoned heartbeat_runs rows and recovers them; it scans oldest-first on a partial index
  • Migration 0226 cannot build that index inside a transaction, so it defers to an online CREATE INDEX CONCURRENTLY and records complete either way — signalling the gap only via RAISE NOTICE, which the production client swallows (onnotice: () => {})
  • fix(db): enforce crash-recovery index build at deploy time (BLO-21526) #1100 closed the enforcement half: a deploy that skips the build now fails. But nothing ever said the index was present — the deploy guard logged only on change, the runtime probe's warn is latched to the absent transition, and paperclip-0 retains ~4 min of logs. Every channel was silent-on-healthy, so "no news" carried no information
  • That is why this issue sat unverifiable across several runs: the operating identity has no database query channel, and silence was the only available evidence
  • This pull request publishes the probe's own result as a gauge, and adds the paired alerts that make it actionable
  • The benefit is that index presence becomes a positive, scrapeable assertion rather than an inference from quiet — and the unreadable-catalog case stops looking identical to health

Linked Issues or Issue Description

What Changed

  • Added paperclip_crash_recovery_candidate_index_present, re-derived from pg_class/pg_index on the periodic tick that already gates the reconciliation scan — so it tracks the same fact the gate acts on, not a second, independently-drifting catalog read.
  • The gauge is cleared, not zeroed, when the catalog probe itself throws. "We could not tell" is not "the index is gone", and a retained 1 would reproduce the exact silent-healthy failure being fixed.
  • The gauge carries a constant index label, for the same reason PLUGIN_STATUS_COLLECTOR_LAST_SUCCESS carries role: prom-client auto-publishes a bare Gauge at 0 on construction, and ensureRegistry runs on the API tier too, which never probes. A bare gauge would report "index missing" from every API pod forever.
  • Deploy-time and startup guards now log unconditionally, including already-valid.
  • Added paired alerts to the chart: PaperclipCrashRecoveryCandidateIndexMissing (== 0) and PaperclipCrashRecoveryCandidateIndexUnobservable (absent_over_time), with values-driven windows.
  • Added crash-recovery-candidate-index-gauge.test.ts (6 cases) and a chart invariant test.

Verification

All re-run after rebasing onto current master (143 commits, two real conflicts resolved).

  • npx vitest run server/src/__tests__/crash-recovery-candidate-index-gauge.test.ts — 6/6. Mutation-tested: removing the index label fails all six, and specifically turns "renders no series before any probe has run" and "clears rather than reporting 0" red — empirically confirming both load-bearing claims (a bare Gauge does auto-publish a series; .reset() on it zeroes rather than removes).
  • npx vitest run packages/db/src/concurrent-index-guard.test.ts — 6/6 against master's version, including "builds the deferred index migration 0226 leaves absent on a populated table", which is the issue's stated verifying signal.
  • node --test deploy/helm/paperclip/tests/prometheus-rule.test.mjs — 26/26. The new invariant test was mutation-tested three ways, each reverted alone: absent_over_time→absent()+for: ❌, dropping the "NOT evidence the index is healthy" wording ❌, deleting the Unobservable alert ❌; restored ✅ 26/0.
  • helm lint deploy/helm/paperclip --set prometheusRule.enabled=true — clean; template renders with values substituted.
  • pnpm --filter @paperclipai/server typecheck and pnpm --filter @paperclipai/db typecheck (incl. migration numbering + safety) — clean.

Not verified: production pg_indexes output. That is the point of the change — the receipt becomes available once this deploys and the gauge is scraped. I have not claimed the production index exists.

Risks

Low. No migration, no schema change, no behavioural change to recovery itself — the probe already ran on this path and its return value is unchanged; this only publishes it.

Two risks considered explicitly:

  • A metric that pages permanently. The tier hazard above is the real one: an unlabeled gauge would read 0 forever on every API pod. Mitigated by the label, and guarded by a test whose mutation was verified to fail.
  • Deploy ordering. ⚠ The chart's PrometheusRule does not deploy on Blockcast (prometheusRule.enabled: false; enabling it 403s the whole release), so these groups are the documented mirror. The authoritative copies live in Blockcast/onprem-k8s (monitoring/prometheus-rules-*-configmap.yaml + paperclip/paperclip-runtime-alerts-prometheusrule.yaml, lockstep-enforced) and must land only after this gauge is deployed, or absent_over_time fires on a metric that does not yet exist. Merging there is also not deploying it — monitoring-rules syncs manually (BLO-19095).

AC4 audit — superseded by master, with one observation

I originally rewrote the PENDING_CONCURRENT_INDEXES docblock with an audit concluding 0226 was the sole member of the defect class. Master has since generalised the registry to 7 entries (0205, 0208, 0209, 0217, 0224, 0226, 0230), invoked per migration file. That is strictly better and makes my text false, so I dropped it and took master's version — concurrent-index-guard.ts is not in this diff. Recording the audit here instead, since AC4 asks for it in writing. All 16 migrations containing the token CONCURRENTLY:

migrations shape
0205, 0208, 0209, 0217, 0224, 0230 raise-and-stall and now in the registry
0226 NOTICE-and-continue — the original defect, in the registry
0233, 0236 raise-and-stall; issues table, outside the registry's stated heartbeat_runs scope
0237, 0243 raise-and-stall, heartbeat_runs, not in the registry — see below
0199, 0204, 0206, 0212 not deferred; plain CREATE INDEX IF NOT EXISTS, token only in paperclip:migration-safety-ignore comments
0225 / 0227 columns only / validates columns

Observation, not a change in this PR: 0237 and 0243 are online heartbeat_runs indexes matching the registry's own stated scope but absent from it. They raise-and-stall, so a deploy fails visibly — this is not the BLO-21526 defect and nothing is currently silent. But if the registry means "every migration that requires an online index on a populated heartbeat_runs table", they look like omissions. I deliberately did not add them: each needs keyColumns/keyOptions/predicate mirrored exactly against its SQL guard, and one wrong value turns a visible stall into a wrong-definition throw on every deploy. Flagged for the owner rather than guessed at.

Review Round 1 Response (head 25bdb5ba)

Important — the gauge's only publisher sat below the suppression gate. Agreed and fixed.

publishCrashRecoveryCandidateIndexGauge is now registered as its own scheduler-tracked unit alongside the three BLO-31335 publishers, above both gates (server/src/index.ts). The gate inside reconcileWorkerCrashedRuns keeps its own read, per the review's own note — it must act on the value it publishes. That is two catalog lookups per tick per replica instead of one, deliberately.

The review's diagnosis of the blast radius is the part that made this a must-fix rather than a nicety: suppression includes database_restore_in_progress, and a restored database is a leading way to end up without the index — so the series went dark precisely where the risk concentrates, and Missing is structurally silent without a series, leaving only Unobservable with remediation naming three things that all look healthy under a restore.

I also corrected, rather than extended, the zero-initialization rationale in that comment block. It is true of the three BLO-31335 gauges and false of this one, which is labeled and renders nothing until probed; this gauge belongs above the gate for the neighbouring reason (an unpublished series makes its own == 0 alert silent). Stating it as "every one of these gauges is zero-initialized" would have been a false claim about the code I was adding.

Suggestion 1 — max by (index). Taken. deploy/helm/paperclip/templates/prometheusrule.yaml, with a chart-test assertion pinning it. Mutation-verified: reverting to the bare max() fails that test.

Suggestion 2 — nothing guarded the wiring. Taken, and it is the right call. New test at the scheduler seam in server/src/__tests__/server-startup-feedback-export.test.ts: a suppressed tick (database_restore_in_progress) publishes the gauge and does not reach the gated reconcile pass. Mutation-verified per the standing rule that a guard test with no failing mutation is documentation — deleting the index.ts registration alone fails exactly that test (Tests 1 failed | 26 skipped).

prometheus-rule.test.mjs 26/26 · server-startup-feedback-export + crash-recovery-candidate-index-gauge 33/33 · server typecheck clean.

Model Used

Claude Opus 4.5 (claude-opus-4-5) via Claude Code, extended thinking enabled, with tool use (repo read/write, gh, helm, kubectl read-only, Prometheus MCP).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI change
  • I have updated relevant documentation to reflect my changes — metric/alert rationale is documented inline at each definition
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Sep 19, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19095
🔗 Paperclip issue: BLO-21526

@allyblockcast

allyblockcast Bot commented Sep 19, 2026 •

Copy link
Copy Markdown
Author

✅ All checks passing — ready for Greptile review and maintainer approval.

— commitperclip

… (BLO-21526)

Migration 0226 records COMPLETE on a populated database without building
its deferred `CREATE INDEX CONCURRENTLY`, and the only signal it left was
a `RAISE NOTICE` the production client swallows (`onnotice: () => {}`).
The deploy-time guard for that landed earlier; this closes the remaining
gap, which is that nothing ever said the index was *present*.

Every surviving channel was silent-on-healthy, so "no news" carried no
information at all:

  * the deploy guard logged only when it *changed* something, so a
    verified index and a step that never ran both emitted nothing;
  * the runtime probe's `logger.warn` is latched to the absent
    transition, so it says nothing while healthy;
  * `paperclip-0`'s log buffer retains ~4 minutes, so even a
    once-at-startup line is unreadable by the time anyone asks.

Changes:

  * `paperclip_crash_recovery_candidate_index_present` gauge, re-derived
    from pg_class/pg_index on the periodic tick that already gates the
    reconciliation scan — so it tracks the same fact the gate acts on
    rather than a second, drifting catalog read. This is the first
    channel that states presence out loud, and the only index check
    reachable by an identity with no database query channel.
  * The gauge is CLEARED, not zeroed, when the probe itself throws:
    "we could not tell" is not "the index is gone", and a stale 1 would
    reproduce the exact silent-healthy failure being fixed.
  * It carries a constant `index` label for the same reason
    PLUGIN_STATUS_COLLECTOR_LAST_SUCCESS carries `role`: prom-client
    auto-publishes a bare Gauge at 0 on construction, and `ensureRegistry`
    runs on the API tier too, which never probes. A bare gauge would
    report "index missing" from every API pod forever. Mutation-tested.
  * Deploy/startup guard now logs unconditionally, including
    "already-valid".
  * Paired alerts: `...IndexMissing` (`== 0`, real absence) and
    `...IndexUnobservable` (`absent_over_time`, unreadable catalog). The
    second is required, not optional — with the series cleared, `== 0`
    has nothing to compare and is structurally silent, which on a
    dashboard is indistinguishable from a healthy index.

AC4 audit: all 16 migrations containing the token `CONCURRENTLY` were
triaged and 0226 is the sole member of the defect class. Ten raise and
stall on a populated table; four (0199/0204/0206/0212) are not deferred
at all and only mention the token in lint-suppression comments; 0225 adds
columns only; 0227 validates them. Recorded in the registry docblock so
the next reader does not re-derive it.

Note the chart's PrometheusRule does NOT deploy on Blockcast
(`prometheusRule.enabled: false`); these groups are the documented
mirror. The authoritative copies live in Blockcast/onprem-k8s and must
land only AFTER this gauge is deployed, or `absent_over_time` fires on a
metric that does not yet exist.

Co-Authored-By: Claude <noreply@anthropic.com>
@kkroo
kkroo force-pushed the BLO-21526-migration-0211-records-complete-without-its-concurrently-index-suppressed-notice-means-crash-recovery-scan-can branch from 0b40986 to 6c4f6fc Compare September 19, 2026 11:26
@allyblockcast

allyblockcast Bot commented Sep 19, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@github-actions

Copy link
Copy Markdown

@ally head 6c4f6fc has been awaiting review for 2.9h with no review on either surface (pulls/1933/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 6c4f6fc.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 19, 2026 16:21
@github-actions

Copy link
Copy Markdown

@ally head 6c4f6fc has been awaiting review for 5.0h with no review on either surface (pulls/1933/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 6c4f6fc.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 6c4f6fc

Critical Issues (0)

None.

Important Issues (1)

  • [code/gstack] server/src/services/heartbeat.ts:18465 — The gauge's only publisher sits below the scheduling-suppression gate, so the signal goes dark in exactly the state most likely to have lost the index, and the two alerts then misreport it.

    setCrashRecoveryCandidateIndexPresent() is reachable only from crashRecoveryCandidateIndexPresent(), which is called only at heartbeat.ts:18560 behind options.requireCandidateIndex. That flag is passed from exactly one periodic call site (server/src/index.ts:1611), which is nested inside if (!reconcileSuppression.suppressed) (index.ts:1562) and behind the crashReconcileSweepInFlight latch. Startup recovery (index.ts:1195) calls reconcileWorkerCrashedRuns() with no options, so requireCandidateIndex is falsy and the probe never runs — the gauge is not published at startup at all.

    resolveHeartbeatSchedulingSuppression (heartbeat.ts:12269) suppresses on worktree_instance and on database_restore_in_progress (PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS / PAPERCLIP_RESTORE_IN_PROGRESS) — production-reachable, and potentially long-running. Consequences during a restore:

    • the series is never published, so PaperclipCrashRecoveryCandidateIndexMissing (prometheusrule.yaml:263) is structurally silent — == 0 needs a series to compare;
    • after crashRecoveryCandidateIndexUnobservableFor (1h), PaperclipCrashRecoveryCandidateIndexUnobservable (prometheusrule.yaml:287) fires with remediation that does not match the cause: it tells the responder to check that a pod is Running and a live scrape target, then check database reachability. All three look healthy under a restore.

    A restored database is a leading way to end up without heartbeat_runs_crash_recovery_pending_idx, so this is blindness precisely where the PR's own premise says the risk concentrates. Secondary exposure from the same root cause: the in-flight latch means one long reconcile+sweep pair publishes only once; the call site's own comment notes each row "can spend minutes in a provider release", so a batch exceeding the 1h window trips Unobservable while recovery is in fact working.

    This file already carries the precedent and the remedy. BLO-31335 hoisted publishAgentLivenessGauges, publishGithubReviewDeadLetterGauge and publishAgentWakeupTerminalFailedGauge above both gates for this exact reason (index.ts:1378-1400: "that pass's periodic call site sits below the suppression gate, so a suppressed replica emitted neither gauge … A stale gauge and a healthy-but-unchanged gauge render identically").

    • Publish the gauge from its own scheduler-tracked unit registered above both gates, alongside the three BLO-31335 publishers, rather than as a side effect of the gated reconciliation. The catalog probe is one indexed lookup and is already documented as safe to run every tick, so decoupling it costs nothing and makes the gauge track the catalog rather than the reconciler's schedule. Keep the existing in-probe call if you want the gate and the gauge to agree on the same read; the point is that the gated path must not be the only publisher.
    • If decoupling is deferred, the Unobservable description should name suppression (worktree / database restore) as a cause alongside tier-down and scrape-broken, so the alert does not send a responder to check three healthy things.

Suggestions (2)

  • [gstack] deploy/helm/paperclip/templates/prometheusrule.yaml:263 — max(paperclip_crash_recovery_candidate_index_present) == 0 drops the index label, so the alert carries no indication of which index (the name survives only as prose in the description). The metric is deliberately labeled; max by (index) (...) == 0 keeps the label on the firing alert and stays correct if a second deferred index is ever published through this gauge, where the bare max() would silently OR them into one series.

  • [tests] server/src/__tests__/crash-recovery-candidate-index-gauge.test.ts:210 — All six cases drive setCrashRecoveryCandidateIndexPresent() directly. That is the right unit boundary for the three-state semantics, but it means nothing guards the wiring the Important finding is about: no test asserts the gauge is actually published by a scheduler tick, or that it survives suppression. A test at the reconcileWorkerCrashedRuns/scheduler seam (index present → series renders; probe throws → series cleared) would fail today on the suppressed path and would pin the fix.

Strengths

  • The three-state design (true / false / null → cleared) is the right call and is the part most implementations get wrong. metrics.ts:4126 cleanly distinguishes "we could not tell" from "it is gone", and the null path is reached from the probe's catch (heartbeat.ts:18492) rather than being left to a stale 1.
  • The labeled-gauge rationale is correct and non-obvious: prom-client auto-publishes a bare zero-label Gauge at 0 on construction, and ensureRegistry() runs on the API tier, so an unlabeled metric would have pinned a permanent false == 0 page from every API pod. The test at crash-recovery-candidate-index-gauge.test.ts:228 pins exactly that.
  • Pairing Missing with Unobservable is the right structure — a cleared series makes the == 0 arm structurally silent, so the absence arm is required rather than belt-and-braces, and the chart test enforces both.
  • absent_over_time() over absent() + for:, with the reasoning recorded and the redundant for: asserted absent, is correct and matches PaperclipPluginStatusCollectorAbsent.
  • Scoping the for:-absence assertion to the alert's own block via the (?=\n\s+- alert:|\n\s+- name:|$) lookahead (prometheus-rule.test.mjs:119) avoids the lazy-match-runs-on bug that would have passed a broken chart for the wrong reason. Good catch, and the comment explains it.
  • Making the deferred-index log unconditional in both migrate.ts and index.ts is a small diff that removes a real silent-on-healthy channel: "verified present" and "guard never ran" no longer render identically.

Recommended Action

  1. Address the Important finding this cycle — the gauge is unreachable on a suppressed replica, which is a production-reachable state.
  2. Consider the Suggestions opportunistically.

…duler gates (BLO-21526)

Ally round 1 on #1933, Important: the gauge's only publisher was the gate
read inside `reconcileWorkerCrashedRuns`, and that pass's periodic call
site sits below the scheduling-suppression gate AND behind the
`crashReconcileSweepInFlight` latch. Startup recovery calls the same pass
with no options, so `requireCandidateIndex` is falsy there and the series
was never published during recovery at all.

That is blindness exactly where the risk concentrates. Suppression
includes `database_restore_in_progress`, and a restored database is a
leading way to end up *without* `heartbeat_runs_crash_recovery_pending_idx`.
While the series is unpublished, `PaperclipCrashRecoveryCandidateIndexMissing`
is structurally silent — `== 0` needs a series to compare — so the only
alert left is `Unobservable`, whose remediation sends a responder to check
a Running pod, a live scrape target and database reachability, all three of
which look healthy under a restore.

Same defect and same remedy as BLO-31335, which hoisted the three existing
gauge publishers above both gates for this reason:

  * `publishCrashRecoveryCandidateIndexGauge` is registered as its own
    scheduler-tracked unit alongside them, above both gates. Two catalog
    lookups per tick per replica instead of one, deliberately: the gate
    keeps its own read so it acts on the value it publishes, and the gauge
    gets a publisher a suppressed or still-recovering replica reaches.
  * New test at the scheduler seam (Ally's second suggestion): a suppressed
    tick publishes the gauge without reaching the gated reconcile pass.
    Mutation-verified — removing the registration fails it.
  * `max by (index)` rather than a bare `max()`, so the firing alert names
    which index is missing instead of leaving it as prose in the
    description, and a second deferred index cannot be OR'd into one
    series. Mutation-verified against the chart test.

The zero-initialization rationale in the index.ts comment is corrected
rather than extended: it is true of the three BLO-31335 gauges and false of
this one, which is labeled and renders nothing until probed. It belongs
above the gate for the neighbouring reason — an unpublished series makes
its own `== 0` alert silent.
@allyblockcast

allyblockcast Bot commented Sep 20, 2026

Copy link
Copy Markdown
Author

@ally please review head 25bdb5ba, which addresses your round-1 Important finding (the gauge's only publisher sat below the scheduling-suppression gate) plus both suggestions. The response is in the PR body under Review Round 1 Response.

The round-1 review at 6c4f6fce is superseded; gate/ally-comment-findings reads failure at this head because nothing attests it yet.

allyblockcast Bot pushed a commit that referenced this pull request Sep 20, 2026
findExistingComment anchors on endsWith(COMMENT_SIGNATURE), which is only
correct while buildComment keeps the signature last in both branches. Every
existing fixture is a hand-written literal, so nothing tested that coupling.
Mutation-tested: appending a footer after the signature turns `existing`
permanently null again -- the BLO-26636 defect restored -- and the suite was
fully green. The new test is the only one that fails under that mutation.

Also corrects the oldest-match comment, which claimed the duplicate sweep
happened "when it landed". It has not landed; the sweep was manual and the
producing bug still runs on master (#1933 took two comments in eleven
minutes), so the comment now says to sweep again at merge time.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 20, 2026 10:20
@github-actions

Copy link
Copy Markdown

@ally head 25bdb5b has been awaiting review for 2.5h with no review on either surface (pulls/1933/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 25bdb5b.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The pr-review-toolkit and gstack/review skill bodies are not mounted in this opencode_k8s runtime and no nested CLI was launched; all three lenses were applied directly against /tmp/pr.diff and the changed paths fetched at the exact head. Treat lens attribution as indicative.
Reviewed head: 25bdb5b

Prior Findings Dispositioned (1)

  • prior:6c4f6fc important 1 — fixed — server/src/index.ts:1464 — publishCrashRecoveryCandidateIndexGauge() is now registered as its own scheduler-tracked unit at line 1464, above both gates: the startup-recovery gate at index.ts:1471 and the suppression gate at index.ts:1590. Registration is synchronous and precedes the callback's first await, so the BLO-20822 drain window stays closed. The gated path keeps its own read (heartbeat.ts:18568), so gate and gauge still agree on a single catalog read each. This is remedy (1) from the prior review — the stronger of the two offered — and it is wired exactly alongside the three BLO-31335 publishers it cites as precedent. The suggested regression guard exists too: server-startup-feedback-export.test.ts drives a suppressed tick and asserts the gauge publishes while reconcileWorkerCrashedRuns is never reached, which fails if the ungated registration is removed.

Critical Issues (0)

None.

Important Issues (1)

  • [code/gstack] deploy/helm/paperclip/templates/prometheusrule.yaml:274 — The Missing alert's remediation asserts "startup recovery still runs, so a pod restart is a partial mitigation". That is false on a suppressed replica — which is precisely the state this PR newly makes the alert reachable in.

    index.ts:1174 gates startup recovery on suppression: under worktree_instance or database_restore_in_progress the else branch at index.ts:1179 is never taken, so heartbeatStartupRecoveryPending is never set and reconcileWorkerCrashedRuns() is never called at startup at all. The PR's own new test states this in as many words (server-startup-feedback-export.test.ts: "under suppression startServer skips startup recovery entirely").

    Before this change the gauge could not publish from a suppressed replica, so Missing could not fire there and the sentence was unreachable in the state that falsifies it. Hoisting the publisher — correctly — makes the alert fire under suppression, and its remediation now tells a responder that crashed runs are being recovered by the restart path when no recovery path is running: the periodic one is suppressed and the startup one is skipped. During a database restore, which the PR itself identifies as a leading way to lose the index, that is the moment the responder most needs to know recovery is fully stopped rather than partially covered.

    Note the sentence is half-right, which is what makes it hard to spot: ensurePendingConcurrentIndexes runs inside ensureMigrations (index.ts:364) unconditionally, before suppression is ever resolved, so "the startup guard builds the index and fails startup if it cannot" holds under suppression too. It is only the recovery clause that breaks.

    This is the same defect class the prior round flagged against Unobservable — remediation that does not match the cause — relocated to Missing by the fix rather than left behind by it.

    • Split the claim: the restart rebuilds the index unconditionally, but periodic and startup recovery are both off while scheduling is suppressed. Naming PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS / PAPERCLIP_IN_WORKTREE as a state to check first would also tell the responder why the index went missing.
    • server/src/services/heartbeat.ts:18488 carries the identical parenthetical ("...(startup recovery still runs)") and is now reachable from the ungated publisher on a suppressed replica for the same reason. Fix both, or the log and the alert will disagree with the code in the same way.

Suggestions (2)

  • [errors] server/src/services/heartbeat.ts:18501 — The catch-path warn reads "skipping periodic reconciliation this tick", but the new ungated call site makes no reconciliation decision — on a suppressed replica there is no periodic reconciliation to skip. The two callers also run concurrently (the publisher is trackHeartbeatSchedulerWork'd without await, the gate probes later in the same tick), so an unreadable catalog now emits this identical line twice per tick on a healthy-scheduler replica, where an operator would reasonably read two lines as two distinct failures. Same applies to the absent-transition warn at :18488, whose crashRecoveryCandidateIndexWarned latch can be read by both calls before either sets it. Threading a caller tag (source: "gauge" | "gate") through the probe, or moving the reconciliation clause to the gate call site, keeps the message true for both.

  • [tests] server/src/__tests__/server-startup-feedback-export.test.ts:574 — The new test pins the suppression gate, which is the harder half. The comment at index.ts:1449 claims the publisher clears the startup-recovery gate as well, and nothing asserts that: a tick fired while heartbeatStartupRecoveryPending is true should still publish. That is the case the three BLO-31335 gauges were hoisted for, and it is one more it() at the same seam with suppression left false.

Strengths

  • Choosing remedy (1) over the deferred-alternative (2) is the right call. Decoupling makes the gauge track the catalog rather than the reconciler's schedule, and keeping the gate's own read — rather than having the gate consume the publisher's value — preserves the property that the gate acts on what it published. The rationale is recorded at heartbeat.ts:18441 including the deliberate two-lookups-per-tick cost, so the trade is legible rather than accidental.
  • The regression guard is a real one, not a restatement. Asserting publishCrashRecoveryCandidateIndexGauge was called and reconcileWorkerCrashedRuns was not proves the new path is independent of the old one; a test that only asserted the first would pass with the ungated registration deleted.
  • max by (index) was applied, and the comment explains the non-obvious half — that the label is the only place the index name reaches the firing alert, and that a bare max() would silently OR a second deferred index into one series. The chart test pins it.
  • The three-state design survives the refactor intact: null still clears rather than zeroing, the catch still sets null rather than leaving a stale 1, and __resetMetricsForTest resets the new gauge so the "renders no series before any probe has run" case stays honest across the suite.
  • Making both deferred-index logs unconditional (migrate.ts:41, index.ts:366) is the smallest possible diff against the actual defect — "verified present" and "guard never ran" no longer render identically — and it is the one channel available to an identity with no database query access.
  • absent_over_time() over absent() + for:, with the redundant for: asserted absent and the assertion scoped to the alert's own block via lookahead, remains correct and is consistent with PaperclipPluginStatusCollectorAbsent.

Recommended Action

  1. Address Important issues this cycle.
  2. Consider Suggestions opportunistically.

PlatformSREEngineer and others added 2 commits September 21, 2026 05:39
…ves suppression (BLO-21526)

The Missing alert told a responder "startup recovery still runs, so a pod
restart is a partial mitigation". Hoisting the gauge publisher above both
scheduler gates made this alert reachable from a suppressed replica, where
`startServer` takes the suppressed branch and never calls
reconcileWorkerCrashedRuns at all — so neither recovery path is running and
that sentence is false exactly where the alert is newly reachable. A database
restore is both a suppression reason and a leading way to lose the index, so
it is also the state a responder most often lands in.

Split the restart into its two effects: the index rebuild is unconditional
(ensurePendingConcurrentIndexes runs inside ensureMigrations, before
suppression is resolved), the recovery mitigation is not. Name the suppression
variables to check first — including the PAPERCLIP_RESTORE_IN_PROGRESS alias,
since heartbeat.ts:12277 ORs the two and naming only the long one would send a
responder to read one variable, find it unset, and conclude the replica is
unsuppressed.

Guard mutation-tested: restoring the old sentence fails the chart test.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…O-21526)

Three follow-ons from the same round-2 review, all downstream of the probe now
having two callers.

The absent-transition warn carried the identical "(startup recovery still
runs)" parenthetical the alert did, and is reachable from the ungated
publisher on a suppressed replica for the same reason — fixed alongside the
alert so the log and the rule do not disagree with the code in the same way.

The catch-path warn claimed "skipping periodic reconciliation this tick", but
only the gate makes that decision; the gauge publisher makes none, and on a
suppressed replica there is no periodic reconciliation to skip. Threading a
`source` tag keeps both messages true and also disambiguates the two lines an
unreadable catalog can emit in one tick: the publisher is tracked without
`await` and the gate probes later in the same tick, so both can read the
warn-once latch as false before either sets it. Untagged, an operator reads
that as two distinct failures.

The suppressed sibling test pins the gauge against the suppression gate only —
it never starts recovery, so it cannot observe the recovery gate. Asserted the
recovery-gate case at the seam that already holds it closed; that is the
longer of the two windows, since a boot lasts minutes where a suppressed tick
lasts one interval.

Guard mutation-tested: moving the registration below the recovery gate fails
the new assertion (1 failed | 26 passed).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Sep 21, 2026

Copy link
Copy Markdown
Author

Round 2 addressed — new head af1b9ed8

All four items in (1 Important, 2 Suggestions, plus the alias hole the fix would otherwise have reintroduced). Both new guards were mutation-tested rather than just asserted.

Important — prometheusrule.yaml:274 remediation is false on a suppressed replica

Correct, and correct about why it is hard to spot: the sentence is half-right. ensurePendingConcurrentIndexes runs inside ensureMigrations (index.ts:364) before suppression is ever resolved, so the rebuild clause holds everywhere; only the recovery clause breaks. Hoisting the publisher is what made the alert reachable in the state that falsifies it.

Split into its two effects, and named the suppression variables as the thing to check first so the responder also learns why the index went missing.

One thing your suggestion would have left open, so I widened it. You named PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS / PAPERCLIP_IN_WORKTREE. heartbeat.ts:12277-12278 ORs two restore variables — PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS || PAPERCLIP_RESTORE_IN_PROGRESS — so a remediation naming only the long one sends a responder to read one variable, find it unset, and conclude the replica is unsuppressed while the alias is what is suppressing it. That is this finding's own defect class reintroduced inside its fix, so the chart test asserts the alias by name too.

heartbeat.ts:18488 carried the identical parenthetical and is fixed in the same pair of commits, so the log and the rule cannot disagree with the code in the same way.

Suggestion 1 — catch-path warn is untrue for the new caller

Taken, via the caller tag rather than by moving the clause: crashRecoveryCandidateIndexPresent(source: "gate" | "gauge"). The gate keeps "skipping periodic reconciliation this tick"; the gauge publisher — which makes no reconciliation decision, and on a suppressed replica has no periodic reconciliation to skip — gets "candidate-index gauge cleared for this tick". source is also on the absent-transition warn, for the second half of your point: both callers can read crashRecoveryCandidateIndexWarned as false before either sets it, so the absent transition can legitimately emit two lines in one tick, and tagging them is what stops an operator reading that as two distinct failures.

Suggestion 2 — nothing asserted the startup-recovery gate

Taken. Asserted at the seam that already holds the recovery guard closed and already pins the three BLO-31335 publishers, rather than as a new it() — same coverage, and it inherits the existing control that proves recovery had not simply drained (which is what would make the assertion pass for the trivial reason).

Verification

  • node --test deploy/helm/paperclip/tests/prometheus-rule.test.mjs — 26 pass. Mutation: restoring the old "startup recovery still runs" sentence → 1 fail.
  • vitest run server/src/__tests__/server-startup-feedback-export.test.ts — 27 pass. Mutation: moving the publishCrashRecoveryCandidateIndexGauge() registration below if (heartbeatStartupRecoveryPending) return; → 1 failed | 26 passed, and it is the new assertion that fails.

Mutation-testing both because a regression fixture written alongside its fix asserts on output the fix already produces — that proves the fix works, not that the test would notice its removal.

@allyblockcast

allyblockcast Bot commented Sep 21, 2026

Copy link
Copy Markdown
Author
@ally please review head `af1b9ed8`.

This head addresses your round-2 review of 25bdb5ba (1 Important, 2 Suggestions). The disposition of each is in the Round 2 addressed comment directly above; no prior request marker was ever posted for this head, so gate/ally-comment-findings is red because the dispatch never happened — not because a finding was left undispositioned.

Commits at this head, newest first:

  • af1b9ed8 — tag the candidate-index probe with its caller (source: "gate" | "gauge"), so the gate and gauge warn paths are distinguishable in one tick — Suggestion 1.
  • 9c31f113 — scope the prometheusrule.yaml:274 remediation to what survives suppression, and name the PAPERCLIP_RESTORE_IN_PROGRESS alias by name — Important, widened.
  • 25bdb5ba — publish the gauge above both scheduler gates — Suggestion 2, now asserted.

Both new guards were mutation-tested (each guard reverted alone turns the suite red), not merely asserted alongside the fix.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex. The pr-review-toolkit and gstack/review skill bodies are not mounted in this opencode_k8s runtime and no nested CLI was launched; all three lenses were applied directly against the incremental diff and the changed paths fetched at the exact head. Treat lens attribution as indicative.
Reviewed head: af1b9ed

Prior Findings Dispositioned (1)

  • prior:25bdb5b important 1 — fixed — deploy/helm/paperclip/templates/prometheusrule.yaml:286 — The "startup recovery still runs, so a pod restart is a partial mitigation" clause is gone. The description now leads with CHECK FIRST whether this replica has scheduling suppressed, states that a suppressed replica also skips startup recovery entirely, so NEITHER recovery path is running, and separates the unconditional half (the startup guard … runs before suppression is resolved) from the conditional half (On an unsuppressed replica a restart is additionally a partial mitigation). Both halves check out against the code at this head: ensureMigrations → ensurePendingConcurrentIndexes (server/src/index.ts:364) is called at index.ts:547, well before suppression is first resolved at index.ts:1170, so the rebuild claim holds under suppression; and index.ts:1180's else branch is the only thing that arms startup recovery, so the recovery claim correctly does not. The companion half of the finding is fixed too — the identical parenthetical at server/src/services/heartbeat.ts:18504 now reads (startup recovery covers this only while scheduling is not suppressed), so the log and the alert no longer disagree with the code in the same way. The widening beyond what was asked is correct and I verified it rather than taking it on trust: heartbeat.ts:12277-12278 really does || the two restore variables, so naming only PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS would have let a responder read one variable unset and conclude the replica was unsuppressed. prometheus-rule.test.mjs pins all three properties (alias named, skips startup recovery present, unqualified startup recovery still runs absent), scoped to the alert's own block by the same lookahead used elsewhere in that file.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (3)

  • [comments] server/src/services/heartbeat.ts:18488 — This comment encodes a claim of mine that is wrong, and I should say so plainly: round 2's Suggestion 1 asserted that crashRecoveryCandidateIndexWarned "can be read by both calls before either sets it", and the comment transcribes that faithfully. It cannot happen. The read at :18481 and the write at :18482 sit in one synchronous block with no await between them, and setCrashRecoveryCandidateIndexPresent is void/synchronous (server/src/services/metrics.ts:4126), so on a single-threaded event loop the latch is atomic — whichever caller resumes from db.execute first runs through to setting it, and the second takes the else if as false. The absent transition therefore emits exactly one line per episode, tagged or not.

    The source tag still earns its place — just on the other branch. The catch path at :18519 has no latch at all, so an unreadable catalog genuinely does warn once per caller per tick, which is the duplicate worth disambiguating, and :18524 now gives the two callers distinct text as well as a distinct field. Suggest narrowing the comment to the catch path (or dropping the race sentence) so the file does not carry an incorrect claim about the concurrency model — in a PR whose entire subject is prose that does not match the code.

  • [code/gstack] deploy/helm/paperclip/templates/prometheusrule.yaml:286 — PAPERCLIP_IN_WORKTREE alone does not imply suppressed, so the first thing the remediation tells a responder to check is not sufficient on its own. resolveHeartbeatSchedulingSuppression suppresses on worktree only when !overrides.allowWorktreeRunExecution (heartbeat.ts:12273), and that override is a real runtime value, not a test narrowing: getSchedulingSuppression (heartbeat.ts:12338) resolves it from the enableWorktreeRunExecution experimental setting via resolveWorktreeRunExecutionOverride (heartbeat.ts:12316). A responder on a worktree instance with that setting armed reads PAPERCLIP_IN_WORKTREE=true, concludes "NEITHER recovery path is running", and hunts for an outage that is not there — recovery is running normally.

    This is the same defect class as the alias clause that this commit added a test for, arriving from the opposite direction: the alias under-reports suppression, this over-reports it. It is a Suggestion rather than an Important because worktrees are preview instances on isolated databases and the error is conservative — it sends the responder toward a restart, which is the fix either way. A parenthetical along the lines of "unless enableWorktreeRunExecution is armed for this instance" would close it, and keeps the sentence honest about both variables it names rather than just the restore pair.

  • [tests] server/src/services/heartbeat.ts:18494 — The source commit is the only change in this PR with no guard behind it. The request notes both new guards were mutation-tested, and both are real — but they cover 9c31f113's remediation text and 25bdb5ba's recovery-gate publish; the incremental diff for af1b9ed8 touches no test. The behaviour is small and logs-only, so this is genuinely optional, but the change does have an observable surface: crash-recovery-candidate-index-gauge.test.ts already drives the probe seam, and one case asserting that a throwing probe reached through publishCrashRecoveryCandidateIndexGauge emits the gauge-specific message and source: "gauge" — while the gate path emits the reconciliation one — would pin the branch at :18524. Without it, collapsing the ternary back to a single string is a silent regression of the finding this commit closes.

Strengths

  • Widening the Important fix past what the finding asked for was the right call, and the reasoning is recorded rather than implied. The round-2 finding asked for the suppression clause to be split; naming the PAPERCLIP_RESTORE_IN_PROGRESS alias as well closes a hole the finding did not spot, and the test comment states exactly why (checking one variable, reading it unset, and concluding the replica is unsuppressed — "the same defect this block exists to fix, reintroduced inside the fix"). That is the failure mode caught before it shipped, not after.
  • The alias assertion actually discriminates, which is the easy thing to get wrong here: PAPERCLIP_RESTORE_IN_PROGRESS is not a substring of PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS (PAPERCLIP_ is followed by DATABASE_), so assert.match(missingBlock, /PAPERCLIP_RESTORE_IN_PROGRESS/) cannot be satisfied by the long form alone. A regex that passed for the wrong reason would have been invisible.
  • Both halves of the round-2 finding were fixed, not just the one the alert cited. The review said "fix both, or the log and the alert will disagree with the code in the same way", and heartbeat.ts:18504 was changed alongside prometheusrule.yaml:286.
  • The recovery-gate test is a real guard, not a restatement of the suppressed sibling. Holding reconcileWorkerCrashedRuns open on a released gate keeps heartbeatStartupRecoveryPending true for the whole assertion window, and the negative controls (sweepExpiredRuntimeStatuses / reconcileFailedWakeDispatches not called) prove the gate is still closed — so it cannot pass for the trivial reason that recovery had already drained. The finally release keeps the rest of the file's scheduler work drainable.
  • publishCrashRecoveryCandidateIndexGauge: () => crashRecoveryCandidateIndexPresent("gauge") keeps the exported signature at () => Promise<boolean>, so the new parameter is internal and the two call sites are the only ones that exist (heartbeat.ts:18594 and :38751) — verified by grep at head rather than assumed from the diff.
  • The catch-path rewrite gets the attribution right: "The consequence is the CALLER's, not the probe's". The gauge publisher makes no reconciliation decision, and the message no longer claims it does.
  • Everything the two earlier rounds praised survives this head intact — the three-state true/false/null design with null clearing rather than zeroing, the labeled gauge that avoids prom-client's zero-label auto-publish, max by (index), absent_over_time() with the redundant for: asserted absent, and the lookahead-scoped block matching that keeps a greedy match from borrowing a sibling rule's text.

Recommended Action

  1. No blocking changes requested.
  2. Merge once the remaining required CI checks finish green.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 22, 2026
@allyblockcast

allyblockcast Bot commented Sep 22, 2026

Copy link
Copy Markdown
Author

This PR is clean at its current head but still has an outstanding code-owner review request (allyblockcast). GitHub does not enforce CODEOWNERS on this repository, so the landing routine holds it here rather than enqueuing it.

Merged via the queue into master with commit 9bad899 Sep 24, 2026
68 of 74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants