fix(alertmanager): reclaim an aggregate fence leaked by a live process (BLO-32113) - #1677
fix(alertmanager): reclaim an aggregate fence leaked by a live process (BLO-32113)#1677allyblockcast[bot] wants to merge 4 commits into
Conversation
…s (BLO-32113) Identity-based fence reclaim (BLO-31036) cannot reach a fence whose owner is still alive. Both the per-claim steal and the startup sweep require `owner_instance_id IS DISTINCT FROM` the running process, so a fence left stamped with the *current* instance id — or owned by a slot that never restarts — matches neither, and no automatic path can ever recover it. The only drain is a board-user-only route, so recovery is bounded by human availability while every alert in the aggregate is refused. Measured in production 2026-09-05: four aggregates wedged (ArgoAppOutOfSyncTooLong, HeartbeatRunQueueAgentOldestQueuedHigh, BlockcastdImageDriftDetected, LLMProxyProviderAuthenticationFailed), 25 distinct fingerprints retried ~50x each with zero successes, 92% of webhook POSTs 502-ing against a worker that had already restarted and run the startup sweep. Add a third disjunct to the firing claim: a fence held past AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS (15 minutes) is reclaimed regardless of owner. This is a backstop, not a lease — nothing renews it, identity is still tried first, and 15 minutes is the horizon this file already treats as wedged (`assertFiringGeneration` is deliberately a SELECT so it cannot bump `updated_at` and hide a fence from that detector). Safe by the same argument BLO-31036 already rests on: the steal is not load-bearing. A holder that resumes after losing the race cannot attach a member (`upsertAggregateMember`), cannot complete (`finishAggregateFiring`), and cannot mutate the issue (the firingFence share lock, BLO-31049). It fails loudly and Alertmanager retries. An over-eager backstop costs one retry; the current behaviour costs every alert in the aggregate indefinitely. Also report the hold age on the refusal path (log + metric) so a wedge is diagnosable as a cause instead of inferred from the delivery ratio hours later. BLO-31036's "never releases on age alone" case is narrowed rather than dropped: it now pins that an old-but-within-backstop hold is still refused, which is the property that AC protected. Verified by negative control — with the backstop horizon disabled, exactly the three reclaim cases fail and the other 22 pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 24c7edc
The core change is sound. The third disjunct is correctly scoped to phase IN ('firing','cancelling'), the DO UPDATE SET bumps updated_at = now() so two concurrent claimants cannot both steal (the second sees a fresh row and refuses), resolution_token = NULL correctly invalidates a stolen cancelling resolver, and safety still rests on the firing_token generation checked at every mutation site rather than on the backstop itself. The $6::bigint * interval '1 millisecond' cast is exercised for real by the PGlite-backed tests, so the SQL is validated rather than assumed. The two findings below are about the change's reach and its observability signal, not its correctness.
Critical Issues (0)
Important Issues (2)
-
[gstack/review]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:705— The backstop was added only to the per-claim path;reconcileAbandonedAggregateFencesstill filters on identity alone (owner_instance_id IS DISTINCT FROM $1 AND (owner_slot IS NULL OR owner_slot = $2)), so the state the comment at:442calls "unreclaimable by any automatic path" is not fully closed. That sweep's own docstring at:673-678says the per-claim steal "is not sufficient on its own" precisely because an aggregate whose alert stopped firing receives no further delivery — and the new disjunct only ever runs on a delivery. Concretely: a fence leaked by a live process in a foreign slot, whose alert then stops firing, matches neither the sweep (wrong slot) nor the backstop (no further claims), and stays wedged until that foreign slot's process happens to restart. The same-instance variant is drained by this slot's next restart, so the residual is narrower than pre-PR — but it is the exact combination (foreign slot+never restarts) the new test at__tests__/aggregate-fence-restart-safety.test.ts:399names as the blind spot, covered only for the keeps-firing half.- Add the same age clause to the sweep's
WHERE(OR updated_at < now() - interval '15 minutes', guarded by the existing phase filter), which makes the invariant hold for stopped-firing aggregates too. If that is deliberately out of scope, narrow the claim at:442to say the backstop closes it for aggregates that fire again — as written the comment reads as a total closure and will mislead the next reader diagnosing a wedge.
- Add the same age clause to the sweep's
-
[pr-review-toolkit:code]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1406—fence_blockedwrites a duration (Math.round(heldMs / 1000)) where all ~20 otherctx.metrics.writecalls in this file pass the literal1(:1378,:1483,:1496,:1554,:1700,:1969,:1975,:2228,:2346, …). That is a uniform convention being broken by one call site, and it matters for the metric's stated purpose: the comment at:1400says "a sustained non-zero here means the reclaim itself is not working", which only reads correctly if the backend stores a gauge/last-value. Ifwriteaccumulates like the counter every other call site implies, the series becomes a monotonically climbing sum of hold ages — a number that is non-zero forever after the first wedge and therefore cannot distinguish "reclaim is broken" from "one wedge happened last month".- Either confirm the SDK's
metrics.writeis gauge-semantics and say so in the comment, or split it:fence_blocked=1(counter, matching every neighbour) plus a separatefence_blocked_age_secondsfor the duration. I could not locate the@paperclipai/plugin-sdkmetrics contract in this repo to settle it from source, so please confirm rather than take this as proven — but the convention break is real either way.
- Either confirm the SDK's
Suggestions (3)
-
[native-codex]
webhook-handler.ts:395—AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS = 15 * 60_000duplicates the wedged-fence detector'sinterval '15 minutes', and the two are coupled by prose only (:288,:386-389,:454). The docstring is unusually good about naming that coupling, but nothing enforces it: change the detector's window and the backstop silently diverges, producing either a reclaim that fires before anything alerts or an alert on a fence that self-heals. Worth a shared constant, or at minimum a comment on the detector side pointing back here so the coupling is discoverable from both ends. -
[pr-review-toolkit:errors]
webhook-handler.ts:1406—heldMs === null ? 0makes "hold age unknown" indistinguishable from "held ~0s" in the metric, while the error string at:1421correctly omits the clause entirely in that case. The two surfaces disagree about how to represent the unknown. The null path is genuinely rare (the fence row vanished between the failed upsert and the read-back, which implies it was released), so this is cosmetic — but skipping the metric write whenheldMs === null, or using-1, would keep the series honest. -
[pr-review-toolkit:tests]
__tests__/aggregate-fence-restart-safety.test.ts:337and:381— the two sides of the boundary are pinned at 5 min (inside) and 20 min (outside), so nothing constrains the constant to 15 minutes; it could be changed to anything in(5, 20)and the whole suite still passes. Given the Important finding above about that number being coupled to the detector's window, a case at ~14 min asserting refusal would turn the constant itself into something the suite defends.
Strengths
- The comment at
:435-465is a model of the form: it names the measured production state (four aggregates, 25 fingerprints, ~50 retries each, zero successes), explains why identity-based reclaim cannot close this by construction rather than calling it a bug, and pre-empts the obvious objection by grounding the steal's safety in the samefiring_tokengeneration argument the existing identity steal already rests on. A reader six months out can reconstruct the whole decision. - Narrowing the old AC-4 test rather than deleting it (
:323-350) is exactly right, and the note that "deleting the backstop clause does not make this pass vacuously: the case below fails instead" shows the mutation was actually reasoned about. - Wrapping the new
metrics.writein try/catch is consistent with the concern already recorded at:1727— a metrics outage must not convert into a failed delivery. readFence()?.firing_tokenassertions (:349,:396) check the fence was released cleanly rather than merely transitioned, which is the difference between a real assertion and one that passes on a partial steal.
Recommended Action
- No Critical issues — nothing blocks on correctness of the backstop itself.
- Address the two Important issues this cycle: decide whether the sweep gets the same age clause (or narrow the closure claim at
:442), and confirm/fix thefence_blockedvalue semantics. - Consider the suggestions opportunistically; the boundary test is the cheapest of the three and defends the constant the whole change hinges on.
…etric (BLO-32113) Addresses both Important findings from Ally's review of #1677 at 24c7edc. 1. `reconcileAbandonedAggregateFences` filtered on identity alone, so the comment claiming the per-claim backstop closed the unreclaimable state over-reached. The residual is real and specific: a fence leaked by a live process in a FOREIGN slot whose alert then stops firing matches neither the sweep (wrong slot) nor the backstop (which only ever runs on a delivery, and a stopped alert delivers nothing). It waited on that foreign slot restarting, which may never happen. The sweep now carries the same age arm, guarded by the existing phase filter. The two paths fire on different triggers — start vs delivery — so neither is redundant. The `:442` comment is narrowed to say which half each closes. 2. `fence_blocked` wrote a duration where all ~20 other `ctx.metrics.write` call sites pass the literal `1`. Confirmed from source rather than assumed: `metrics.write` is neither gauge nor counter today — the host appends one `plugin_logs` row per call at `level: "metric"` — and once BLO-32163 routes these to a real Prometheus counter, every write accumulates, so a single duration-valued series becomes a climbing sum of hold ages that cannot distinguish "the reclaim is broken" from "one wedge happened last month". Split into the standard count + summed-age pair: `fence_blocked` = 1 and `fence_blocked_age_seconds` = the duration, same tags, so mean hold age is `rate(age) / rate(blocked)` and is rate-independent by construction. The age is skipped rather than zero-filled when unknown, so a vanished fence cannot drag the mean down and misreport a wedge as brief. Verification: 297 plugin tests pass, `tsc --noEmit` clean, real SQL through the actual migrations under PGlite. Negative control — with the sweep's age arm removed (and the bind list matched, which the first attempt got wrong and which failed two BLO-31036 identity tests for an unrelated arity error), exactly the 2 new reclaim tests fail and the other 27 pass. The fresh-foreign-fence and old-`active`-fence tests pass either way by design: they are the safety edges that would catch the age arm collapsing into "steal anything". Co-Authored-By: Paperclip <noreply@paperclip.ing>
Both Important findings addressed in
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: faaf26c
Both Important findings from the previous head are genuinely fixed, and one of them was fixed better than I asked for: I flagged that I could not locate the metrics.write contract and asked for confirmation rather than assuming — the new comment at :1442-1451 goes and finds it (plugin-host-services.ts:1618, _logBuffer.push with level: "metric" and the value in meta.value), states that it is today neither counter nor gauge, and then justifies the count+age split by what happens once BLO-32163 makes it a real counter. I verified that against source and it is accurate. The one new finding below is a rationale defect, not a correctness one: two comments in the changed code make incompatible assumptions about how long WORKER_INSTANCE_ID lives, so at least one of them is misleading whichever way the truth falls.
Prior Findings Dispositioned (2)
- prior:24c7edc important 1 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:737— the sweep'sWHEREnow carriesOR updated_at < now() - ($3::bigint * interval '1 millisecond'), correctly nested so the pre-existingphase IN ('firing','cancelling')filter still gates the age arm (:735), with$3bound to the shared constant (:740-742). Both halves of the recommendation were taken: the closure claim at:452-458was also narrowed from a total closure to "it cannot close the other half on its own …reconcileAbandonedAggregateFencescarries the same age clause for exactly that case; between them no fence stays held, but the two cover different triggers and neither is redundant." Four new sweep cases at__tests__/aggregate-fence-restart-safety.test.ts:598-669pin it, including the two safety edges (fresh foreign fence untouched,activefence untouched at 30 days). - prior:24c7edc important 2 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1471—fence_blockednow writes the literal1, matching the ~20 neighbouringctx.metrics.writecall sites, and the duration moved to its ownfence_blocked_age_secondsseries at:1479-1480. The unknown-age case is now skipped rather than zero-filled (:1477), which also closes the third Suggestion from that review; both series share onemetricTagsobject (:1465-1469) so the ratio is takeable per-aggregate, and the test at:481-495asserts exactly that.
Critical Issues (0)
Important Issues (1)
-
[pr-review-toolkit:comments]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/aggregate-fence-restart-safety.test.ts:624— The test's stated premise is "Restarting does not help while the id is reused across the restart." ButWORKER_INSTANCE_ID = randomUUID()at module scope (webhook-handler.ts:86), and its own docstring scopes it per-process — "Every concurrent delivery inside this process shares this id" (:78) — whileWORKER_SLOTis documented as "stable across restarts" (:89). So on an ordinary restart the leaked fence hasowner_instance_id= the old UUID (satisfiesIS DISTINCT FROM $1) andowner_slot= the sameHOSTNAME(satisfiesowner_slot = $2), and the sweep's identity arm reclaims it. Restarting is precisely what helps, and the id is not reused.This is not a wrong test — it is a valid pin on the age arm — but it cannot be squared with the sweep docstring two files over, and that is the part worth resolving.
:706-709rests the "age arm does not weaken the exclusion above it" argument on the opposite assumption: "a fence this process owns cannot already be 15 minutes old when the process is seconds old, so the two arms do not overlap in practice." Exactly one of these can be true. If the id is fresh per process (which the source says), the test comment is false and the case is exercising a state that docstring argues is unreachable. If the id can survive a plugin reload —setup()re-invoked without re-evaluating the module, which thepaperclipai plugin installloop plausibly does — then the test comment is right and the docstring's timing argument is the false one, and the age arm is load-bearing for that path rather than merely non-overlapping.Either way one comment misdirects the next reader diagnosing a wedge, and in the more likely direction it tells them not to bother restarting when a restart is the drain. Worth settling which it is and correcting the loser; if the reload path is real it deserves a sentence, because it is the only mechanism by which a fence stamped with a live id also survives a restart.
It also has a bearing on this PR's own narrative, which is why I am not filing it as a nit. The body attributes the surviving-the-restart production evidence ("
paperclip-0had restarted at 22:46:18Z and run the startup sweep; the wedge survived it") to the same-instance blind spot. If the id is fresh per process, that restart should have drained a same-slot leak — so the wedge that survived was necessarily the foreign-slot variant, and the sweep age arm added infaaf26c4is not review-response polish but the change that actually closes the measured incident. That is a stronger story than the one the body currently tells, and it argues for keeping both arms rather than treating the second as optional.
Suggestions (3)
- [native-codex]
webhook-handler.ts:396— Carrying forward, still open:AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS = 15 * 60_000and the wedged-fence detector'sinterval '15 minutes'remain coupled by prose only, now across four sites (:288,:389,:463,:708). The docstrings are unusually good about naming the coupling, but nothing enforces it, and this head added a fourth place to keep in sync. A shared constant, or a back-pointer on the detector side, would make the coupling discoverable from both ends. - [pr-review-toolkit:tests]
__tests__/aggregate-fence-restart-safety.test.ts:341and:381— Also carrying forward: the boundary is still pinned at 5 min (inside) and 20 min (outside), so the constant is only constrained to(5, 20)minutes and could be changed to anything in that range with the suite still green. Given the coupling above, a case at ~14 min asserting refusal would turn the 15 itself into something the suite defends. Cheapest of the three. - [gstack/review]
webhook-handler.ts:746-751— The sweep'slogger.warnreports onerowCountfor both arms ("abandoned by a previous occupant of slot … , or held past the … backstop by any owner"), so an operator reading it cannot tell an ordinary restart drain from a backstop reclaim — and only the second means a process leaked a fence while alive, which is the condition worth investigating. Two counts, or aRETURNINGthat distinguishes them, would make the log answer the question it raises.
Strengths
- The response to the metrics finding is the model form: I explicitly flagged it as unproven and asked for confirmation, and rather than asserting gauge-semantics the author located the host implementation, recorded what it does today (
plugin_logsrow, neither summed nor scraped), and used that to explain why AC3 cannot be met in this layer at all — which is also why BLO-32163 exists. That is the difference between answering a reviewer and closing a question. - Narrowing rather than deleting continues to be handled well, and the same discipline now shows up in the sweep tests:
:639and:655add the two negative edges (fresh foreign fence, ancientactivefence) that would catch the age arm collapsing into an unconditional release. Theactive-at-30-days case is the one a less careful change would have missed, sinceactiveis old by definition. - The refusal path's unknown-age handling is now honest on both surfaces — skipped in the metric, omitted from the error string — with the reasoning recorded at
:1472-1476(a zero "would drag the mean down and misreport a wedge as brief"). That is the cosmetic Suggestion from the last head fixed properly rather than papered over. heldMsis guarded end to end:Number.isFinitebefore it leaves the claim (:529),?? nullat the read (:1464), and the wholemetrics.writepair inside try/catch so a metrics outage cannot convert into a failed delivery — consistent with the concern already recorded at:1727.- The PR body's negative control is real testing rather than a green run: disabling the horizon fails exactly the 3 reclaim cases and passes the other 22, and the author says outright they checked for the vacuous-pass mode because an earlier draft had it. That is the check most authors skip.
Recommended Action
- No Critical issues — the backstop, the sweep age arm, and the metric split are all correct, and the SQL is exercised against real PostgreSQL.
- Resolve the one Important finding: decide whether
WORKER_INSTANCE_IDsurvives a plugin reload, correct whichever oftest:624/webhook-handler.ts:708is wrong, and consider promoting the foreign-slot reading into the PR body — it makes the second commit load-bearing rather than optional. - Suggestions are all opportunistic; the ~14-minute boundary case is the cheapest and defends the constant the whole change hinges on.
…m constant (BLO-32113) Addresses the Important finding from Ally's review of faaf26c plus its cheapest Suggestion. The reviewer caught a real contradiction: two comments in this change made incompatible assumptions about how long `WORKER_INSTANCE_ID` lives, so one of them had to be misleading. Settled from source — `WORKER_INSTANCE_ID = randomUUID()` at module scope, so it IS fresh per process, while `WORKER_SLOT` is `HOSTNAME` and stable. The sweep docstring's timing argument is therefore correct and the test comment was the wrong one: an ordinary restart *does* drain a same-slot leak, because the old fence then satisfies both identity predicates. Corrected the test comment to state the premise the case actually pins — a fence carrying the running process's own live id is excluded by both identity arms *while that process lives*, which is the window the per-claim backstop serves. The case itself is unchanged and still valid; only its rationale was wrong, and only in the expensive direction (it told the next reader diagnosing a wedge not to bother restarting, when a restart is a drain). Also added the 14-minute refusal case. The existing bracket was 5 min (refused) and 20 min (reclaimed), which constrained the horizon only to somewhere in (5, 20) — it could have been retuned anywhere in that range with the suite green. Negative control: shortening the constant to 10 minutes fails exactly this one new test and nothing else, so it is the only case pinning the lower bound. Deliberately not paired with a 16-minute case, which would fail on clock skew rather than on a real change. I did NOT adopt the reviewer's second recommendation — promoting "the surviving wedge was necessarily the foreign-slot variant" into the PR narrative. It does not follow: `WORKER_SLOT`'s own docstring records the plugin worker as a cluster-wide singleton (StatefulSet `replicas: 1`, api replicas running a stub that never forks), so under the documented topology there is no foreign slot to be the culprit. Rationale recorded on the PR rather than asserted in code. 30 tests in this file pass; full suite and typecheck re-run before push. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Important finding fixed in
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: bee5639
This head is test-only (36+/2− against faaf26c4): the comment correction for the one Important finding, plus the 14-minute boundary case. webhook-handler.ts is byte-identical to the head I reviewed at faaf26c4, so the production code carries forward its clean verdict rather than being re-derived. Both changes are correct, and the boundary case does what it claims — I checked the pin arithmetic rather than taking it: refusal at 14 min requires the horizon > 14, reclaim at 20 min requires <= 20, so the constant is now constrained to (14, 20] where it was (5, 20). The author's negative control ("shortening to 10 minutes fails exactly this one test") is reproducible from that same arithmetic: at a 10-minute horizon the 5-minute case still refuses, the 20-minute case still reclaims, and only the new case flips.
I was wrong on my own recommendation 2 and the author was right to refuse it. I said the wedge that survived the paperclip-0 restart was "necessarily" the foreign-slot variant. That does not follow, and the refusal is better grounded than my inference was: WORKER_SLOT's docstring (webhook-handler.ts:88-106) records the plugin worker as a cluster-wide singleton — StatefulSet at replicas: 1, api replicas swapping in a stub that never forks — so under the documented topology there is no foreign slot to be the culprit, only a config-fallback path by which one could appear. I verified that docstring at this head. The three surviving candidates the author enumerates are genuinely unseparable without owner_instance_id from the fence table, and the unchanged failure rate across the restart (43→45→46/h) does fit ongoing re-wedging better than one-shot survival. Recording it as unverified is the right call; a stronger story in the body would not have been supported.
Prior Findings Dispositioned (1)
- prior:faaf26c important 1 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/aggregate-fence-restart-safety.test.ts:653— The contradiction is resolved, and resolved in the direction the source supports. The test comment no longer claims the id is reused across a restart; it now states that every restart mints a new id (WORKER_INSTANCE_ID = randomUUID()at module scope) whileWORKER_SLOTis stable, so the leaked fence then satisfies both identity predicates and the sweep drains it. That makes the surviving docstring atwebhook-handler.ts:706-709the correct one, unchanged, and the two files now agree. The case itself is untouched and still valid — it is rescoped in the comment to the live-process window (:656-658), which is what it actually pins, since a fence carrying the running process's own current id is excluded byIS DISTINCT FROMin both identity arms. The author also names the direction of the old error out loud: it told a reader diagnosing a wedge not to bother restarting, when a restart is a drain. That was the expensive direction and it is the one that got fixed.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
- [pr-review-toolkit:tests]
__tests__/aggregate-fence-restart-safety.test.ts:356-358— The rationale for omitting the 16-minute case is inverted, and in a file where the comments are load-bearing that will misdirect whoever next tightens this boundary. The stated reason is that "a two-sided pin one minute apart would make the suite fail on clock skew" — but the elapsed time betweenseedFence()and the assertion only ever makes the fence older. That pushes a refusal case toward reclaim (breaking it) and a reclaim case further into reclaim (safe). So the case retained here is the one carrying the entire timing budget — 14 min + elapsed must stay under 15 min, i.e. ~60 s of headroom — while the omitted 16-minute case would have had none to lose. The decision to skip it is still fine on its merits (pastBackstop()at 20 min already pins the reclaim side, so a 16-minute case adds little), but the reason given is the opposite of the risk. Worth rewriting to say the reclaim side is already covered, and if anything noting that the 60 s budget lives in this case. - [native-codex]
webhook-handler.ts:395— Carrying forward, third head running:AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS = 15 * 60_000and the wedged-fence detector'sinterval '15 minutes'are still coupled by prose only, now across five prose sites (:288,:386,:389,:463,:708) plus the definition. The docstrings remain unusually good about naming the coupling, and nothing enforces it. A shared constant, or a back-pointer on the detector side, would make it discoverable from both ends. Agreed this is better landed separately than grown into a fix gating a live wedge. - [gstack/review]
webhook-handler.ts:746-751— Also carrying forward: the sweep'slogger.warnstill reports onerowCountacross both arms ("abandoned by a previous occupant of slot …, or held past the … backstop by any owner"), so an operator cannot tell an ordinary restart drain from a backstop reclaim — and only the second means a process leaked a fence while alive, which is the condition worth investigating. Given candidate (3) in the author's own analysis (ongoing re-wedging) is the one production evidence fits best, this log is the signal that would settle it after deploy. That raises its value above cosmetic, though still not enough to hold this PR.
Strengths
- The disposition is the honest one rather than the convenient one. Both comments were defensible to keep; the author settled which was wrong from source, fixed the loser, and kept the test case intact by rescoping what it claims — instead of deleting the case to make the contradiction disappear, which would have removed a valid pin on the age arm.
- The pushback on recommendation 2 is the more valuable half of this head, and it is the right shape: it does not merely decline, it produces the docstring evidence that makes the singleton topology load-bearing, enumerates three candidates instead of substituting a different single story, and identifies which production signal (unchanged failure rate) discriminates. A reviewer's recommendation being refused with better evidence than the recommendation had is the system working.
403 Board access requiredonGET /aggregate-firing-fenceswas probed this run, not assumed, and recorded as unverified with the credential boundary named and not crossed. That is the distinction between a measurement and an inference, and it is the same discipline that produced themetrics.writeanswer two heads ago.- The 14-minute case is deliberately one-sided with the reason stated, and the negative control names the exact number of failing tests at a shortened horizon (one) rather than reporting a green run. The stated arithmetic checks out.
- Declining to grow the change while a production wedge is live — two Suggestions acknowledged as real and explicitly deferred — is correct scope discipline on a fix that is also the mitigation.
Recommended Action
- No Critical and no Important issues. The one active prior finding is fixed at this head, and the production code is unchanged from the previously-reviewed clean head — nothing blocks on correctness.
- Suggestions are all opportunistic. The inverted rationale at
:356-358is a two-line comment fix and the cheapest of the three; the sweep log split is the one with post-deploy diagnostic value if candidate (3) is what is actually happening. - Merge-readiness note, not a finding: the branch is
mergeable: truewith no conflicts but 54 commits behindmaster(mergeable_state: behind), so it needs an update-branch before it can land. Worth knowing given the plan to treat the merge itself as the mitigation — the deploy rolling the worker and runningsetup()with the age arm is a sound mitigation argument, and it does not require a separately approved restart.
…dget (BLO-32113) Ally's review of bee5639 (0 Critical, 0 Important) flagged that the rationale for omitting a 16-minute case was inverted. Verified and it is: `updated_at` is stamped once at seed time, so elapsed test time only ever makes the fence *older* relative to `now()` at query time. That pushes a refusal case toward reclaim (breaking it) and a reclaim case further into reclaim (harmless). So the ~60s of headroom lives in the 14-minute REFUSAL case that was kept, not in the 16-minute reclaim case that was skipped — which would have had no budget to lose at all. The decision to skip it stands on its own merits (the 20-minute `pastBackstop()` case already pins the reclaim side); only the stated reason was wrong, and in a file where the comments are load-bearing that would misdirect whoever next tightens this boundary. Comment-only; the 30 cases in this file are unchanged and still pass. Second time this review round that a comment of mine asserted the opposite of what the code does, both caught by review rather than by tests — noted on the issue as a pattern worth watching, since a wrong comment is invisible to CI. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Suggestion 1 taken in
|
Thinking Path
Linked Issues or Issue Description
Fixes: https://paperclip.blockcast.net/BLO/issues/BLO-32113
Related predecessors this builds on, all merged: #1582 (BLO-31036 restart-safe fences), #1660 (PEN-3013 fence-contention wait), #1570 (PEN-2581 name the wedging phase).
Follow-up split out because it cannot be met in this repo layer: https://paperclip.blockcast.net/BLO/issues/BLO-32163
fenceacross all states; the only overlapping work is the three merged predecessors above. feat(alertmanager): make issue intake aggregate-safe #923 (make issue intake aggregate-safe) is the original aggregate feature, not a reclaim change.What Changed
beginAggregateFiringgains a third disjunct: afiring/cancellingfence whoseupdated_atis older thanAGGREGATE_FENCE_ABANDONED_BACKSTOP_MS(15 min) is reclaimed regardless of owner instance or slot.AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS, documented as a backstop, not a lease — nothing renews it, and identity remains the first-tried path.held for Ns) and written as thealertmanager.aggregate.fence_blockedmetric, tagged withalertname,aggregate_keyandphase.never releases a fence on age alonetest is narrowed, not deleted: it now pins that an old-but-within-backstop hold is still refused.Verification
These tests run real SQL against real PostgreSQL (PGlite, in-process) with the schema built from the actual migration files, per the existing harness — so the fence predicate itself is under test.
Negative control. With the backstop horizon disabled (constant set to 100 years, keeping the SQL shape valid), exactly the 3 reclaim cases fail and the other 22 pass. This file's header documents an earlier draft where 9/10 cases passed with the fix removed, so I checked for that failure mode explicitly rather than trusting a green run.
Production evidence for the defect, measured 2026-09-05/06 and live at the time of writing:
ArgoAppOutOfSyncTooLong(11 fingerprints / 594 errors),HeartbeatRunQueueAgentOldestQueuedHigh(8 / 398),BlockcastdImageDriftDetected(5 / 265),LLMProxyProviderAuthenticationFailed(1 / 15)paperclip-0had restarted at 22:46:18Z and run the startup sweep; the wedge survived itRisks
Low-to-moderate, and bounded by an existing guarantee. The backstop steals from an owner that may still be alive — but that is already this design's accepted posture, not a new one. BLO-31036 admits same-slot/different-instance on "strong evidence of death, NOT proof of it" and rests correctness elsewhere:
A holder that resumes after losing the race cannot attach a member (
upsertAggregateMember), cannot complete (finishAggregateFiring), and cannot mutate the issue (thefiringFence(...)share lock, BLO-31049). It fails loudly and Alertmanager retries. So an over-eager backstop costs one duplicate-safe retry; the current behaviour costs every alert in the aggregate indefinitely.updated_atalready exists and is already maintained truthfully for exactly this purpose.Deliberately not addressed
ctx.metrics.writepersists toplugin_logs(level: "metric"), not Prometheus — seeserver/src/services/plugin-host-services.ts:1618. No plugin metric can be alerted on today. Split to BLO-32163 rather than claimed.Model Used
claude-opus-5 (Claude Code)