Skip to content

fix(alertmanager): reclaim an aggregate fence leaked by a live process (BLO-32113) - #1677

Open
allyblockcast[bot] wants to merge 4 commits into
masterfrom
blo-32113-fence-ttl
Open

fix(alertmanager): reclaim an aggregate fence leaked by a live process (BLO-32113)#1677
allyblockcast[bot] wants to merge 4 commits into
masterfrom
blo-32113-fence-ttl

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The Alertmanager plugin turns firing alerts into issues, and serialises each aggregate behind a lifecycle fence so a firing delivery cannot attach a member to an issue a resolver is cancelling
  • A fence is claimed before the delivery mutates anything and released in a finally, so only death of the owning process can leave one held — which is why BLO-31036 made reclaim identity-based: same slot, different instance
  • But identity-based reclaim has a state it cannot reach by construction — a fence leaked by a process that 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 stamped with the current instance id matches neither
  • Production reached that state on 2026-09-05: four aggregates refusing every delivery, surviving a worker restart, drainable only through a board-user-only route
  • This pull request adds a bounded backstop — a fence held past 15 minutes is reclaimed regardless of owner — so the wedge self-clears instead of destroying every alert in its aggregate until a human intervenes
  • The benefit is that alert delivery stops depending on human availability to unwedge it, and a wedge becomes diagnosable as a cause rather than inferred from a delivery ratio hours later

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

  • I searched the GitHub PR list for similar PRs before opening this one. Searched fence across 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

  • beginAggregateFiring gains a third disjunct: a firing/cancelling fence whose updated_at is older than AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS (15 min) is reclaimed regardless of owner instance or slot.
  • New constant AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS, documented as a backstop, not a lease — nothing renews it, and identity remains the first-tried path.
  • The refusal read-back now also selects the hold age; it is reported in the thrown error (held for Ns) and written as the alertmanager.aggregate.fence_blocked metric, tagged with alertname, aggregate_key and phase.
  • The refusal message's operator guidance is corrected — it previously told operators the only remaining remedy was the manual route.
  • BLO-31036's never releases a fence on age alone test is narrowed, not deleted: it now pins that an old-but-within-backstop hold is still refused.
  • Five new cases covering both halves of the blind spot, plus the safety edge and the age reporting.

Verification

cd packages/plugins/paperclip-plugin-alertmanager
npx vitest run     # 293 passed
npx tsc --noEmit   # clean

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:

  • delivery failure ratio 0.286 sustained; 175 x 502 vs 15 x 200 webhook POSTs in a 19-min window
  • four aggregates wedged — ArgoAppOutOfSyncTooLong (11 fingerprints / 594 errors), HeartbeatRunQueueAgentOldestQueuedHigh (8 / 398), BlockcastdImageDriftDetected (5 / 265), LLMProxyProviderAuthenticationFailed (1 / 15)
  • a small fixed set retried ~50x each with zero successes — which is what rules out PEN-3013 fan-out contention, since 11 instances against a 3s budget and sub-second holds would resolve
  • paperclip-0 had restarted at 22:46:18Z and run the startup sweep; the wedge survived it

Risks

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:

Correctness therefore rests on the generation check at the mutation site, and this predicate only decides who is allowed to proceed, not whose writes count.

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. So an over-eager backstop costs one duplicate-safe retry; the current behaviour costs every alert in the aggregate indefinitely.

  • No migration, no schema change, no config change. updated_at already exists and is already maintained truthfully for exactly this purpose.
  • 15 minutes is ~300x the 3s contention budget and orders of magnitude beyond any healthy hold, so a legitimate delivery being stolen from is not a realistic operating point.
  • Reviewer note — I narrowed a prior AC deliberately. BLO-31036's age-alone case asserted the property this changes. Please push back if you disagree with that call; I kept the property it was protecting and dropped only "at any age".

Deliberately not addressed

  • The issue's "partial batch success" proposal is wrong and I did not implement it. The batch already catches per-alert and processes healthy alerts; the terminal throw is deliberate. Swallowing it returns 200, Alertmanager stops retrying, and that destroys the alert — the BLO-20467 silent-loss class, documented in the code. Healthy alerts in a poisoned batch are not lost today.
  • The issue's Prometheus-rule AC is NOT met. ctx.metrics.write persists to plugin_logs (level: "metric"), not Prometheus — see server/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)

…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>
@allyblockcast

allyblockcast Bot commented Sep 6, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32113
🔗 Paperclip issue: BLO-31049
🔗 Paperclip issue: BLO-20467
🔗 Paperclip issue: PEN-3013
🔗 Paperclip issue: BLO-31036

@allyblockcast

allyblockcast Bot commented Sep 6, 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

@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: 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; reconcileAbandonedAggregateFences still 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 :442 calls "unreclaimable by any automatic path" is not fully closed. That sweep's own docstring at :673-678 says 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:399 names 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 :442 to 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.
  • [pr-review-toolkit:code] packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1406fence_blocked writes a duration (Math.round(heldMs / 1000)) where all ~20 other ctx.metrics.write calls in this file pass the literal 1 (: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 :1400 says "a sustained non-zero here means the reclaim itself is not working", which only reads correctly if the backend stores a gauge/last-value. If write accumulates 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.write is gauge-semantics and say so in the comment, or split it: fence_blocked = 1 (counter, matching every neighbour) plus a separate fence_blocked_age_seconds for the duration. I could not locate the @paperclipai/plugin-sdk metrics 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.

Suggestions (3)

  • [native-codex] webhook-handler.ts:395AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS = 15 * 60_000 duplicates the wedged-fence detector's interval '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:1406heldMs === null ? 0 makes "hold age unknown" indistinguishable from "held ~0s" in the metric, while the error string at :1421 correctly 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 when heldMs === null, or using -1, would keep the series honest.

  • [pr-review-toolkit:tests] __tests__/aggregate-fence-restart-safety.test.ts:337 and :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-465 is 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 same firing_token generation 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.write in try/catch is consistent with the concern already recorded at :1727 — a metrics outage must not convert into a failed delivery.
  • readFence()?.firing_token assertions (: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

  1. No Critical issues — nothing blocks on correctness of the backstop itself.
  2. 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 the fence_blocked value semantics.
  3. 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>
@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

Both Important findings addressed in faaf26c4

Thanks — both were correct, and the second one changed my mind about what the metric should be.

1. The sweep's blind spot — fixed, and the over-claiming comment narrowed

You were right that the :442 comment read as a total closure when the backstop only covers
aggregates that fire again. I did both halves of what you suggested rather than choosing:

  • Added the age arm to reconcileAbandonedAggregateFences, guarded by the existing
    phase IN ('firing','cancelling') filter, so the invariant holds for stopped-firing aggregates.
  • Narrowed the comment anyway, because the reader diagnosing a wedge needs to know the two paths
    fire on different triggers — sweep on process start, backstop on delivery — so neither is
    redundant and neither alone is sufficient. That distinction is now stated at both sites.

Four new tests, including the two safety edges that would catch the age arm collapsing into "steal
anything": a fresh foreign fence is left alone, and an old active fence is never touched however
old it is (age is only ever read together with the phase filter — active is the normal resting
state and is old by definition).

2. metrics.write semantics — settled from source, and it is neither

You flagged you couldn't locate the SDK contract and asked me to confirm rather than assume. The
answer is worse than either option you offered: it is not a gauge and not a counter.
server/src/services/plugin-host-services.ts appends one plugin_logs row per call at
level: "metric" with the value in meta.value — a plain buffered batch INSERT. Nothing sums it,
nothing last-values it, and nothing scrapes it. That is also why AC3 of this issue (a Prometheus
rule on fence age) can't be met in this layer at all; it's split to
BLO-32163.

So your convention-break observation was load-bearing for a reason neither of us had yet: once
BLO-32163 routes these through the real paperclip_plugin_metric_total — which is a Counter whose
.inc() explicitly rejects negatives — a single duration-valued series becomes a monotonically
climbing sum of hold ages. Exactly your failure mode: non-zero forever after the first wedge, unable
to distinguish "the reclaim is broken" from "one wedge happened last month".

Took your split, in the standard Prometheus shape:

alertmanager.aggregate.fence_blocked             = 1            (matches all ~20 neighbours)
alertmanager.aggregate.fence_blocked_age_seconds = heldSeconds  (same tags)

rate(age_seconds) / rate(fence_blocked) is mean hold age, which is the only rate-independent
discriminator — a threshold on the age series alone would page on routine contention once a rule
blocks often enough, making the threshold a function of retry rate rather than of wedging.

One deliberate asymmetry: the age is skipped, not zero-filled, when heldMs is null (the fence
row vanished between the refused upsert and the read-back, which implies it was released). A zero
would drag the mean down and misreport a wedge as brief. Cost is one occurrence counted without an
age, which is the honest trade.

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, exactly the 2 new reclaim tests fail and the
other 27 pass. Worth recording that my first control was malformed — I dropped the disjunct but
left $3 bound, and the arity error made the deliberately-non-fatal sweep swallow the throw and
return 0, which failed two pre-existing BLO-31036 identity tests as well. That looked like a real
coupling and wasn't. Re-ran with the bind list matched before trusting it.

On suggestion 3 (the duplicated 15-minute constant)

Not taken in this PR. AGGREGATE_FENCE_ABANDONED_BACKSTOP_MS and the wedged-fence detector's
interval agreeing is the point — the backstop exists to act on the condition that detector already
defines — but they're independently editable today and that coupling is unenforced. It's a real
cleanup and I'd rather it not ride along on a fix that's gating a live production wedge. Say the
word if you'd prefer it in-scope.

@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: 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's WHERE now carries OR updated_at < now() - ($3::bigint * interval '1 millisecond'), correctly nested so the pre-existing phase IN ('firing','cancelling') filter still gates the age arm (:735), with $3 bound to the shared constant (:740-742). Both halves of the recommendation were taken: the closure claim at :452-458 was also narrowed from a total closure to "it cannot close the other half on its own … reconcileAbandonedAggregateFences carries 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-669 pin it, including the two safety edges (fresh foreign fence untouched, active fence untouched at 30 days).
  • prior:24c7edc important 2 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1471fence_blocked now writes the literal 1, matching the ~20 neighbouring ctx.metrics.write call sites, and the duration moved to its own fence_blocked_age_seconds series 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 one metricTags object (:1465-1469) so the ratio is takeable per-aggregate, and the test at :481-495 asserts 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." But WORKER_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) — while WORKER_SLOT is documented as "stable across restarts" (:89). So on an ordinary restart the leaked fence has owner_instance_id = the old UUID (satisfies IS DISTINCT FROM $1) and owner_slot = the same HOSTNAME (satisfies owner_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-709 rests 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 reloadsetup() re-invoked without re-evaluating the module, which the paperclipai plugin install loop 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-0 had 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 in faaf26c4 is 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_000 and the wedged-fence detector's interval '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:341 and :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's logger.warn reports one rowCount for 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 a RETURNING that 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_logs row, 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: :639 and :655 add the two negative edges (fresh foreign fence, ancient active fence) that would catch the age arm collapsing into an unconditional release. The active-at-30-days case is the one a less careful change would have missed, since active is 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.
  • heldMs is guarded end to end: Number.isFinite before it leaves the claim (:529), ?? null at the read (:1464), and the whole metrics.write pair 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

  1. No Critical issues — the backstop, the sweep age arm, and the metric split are all correct, and the SQL is exercised against real PostgreSQL.
  2. Resolve the one Important finding: decide whether WORKER_INSTANCE_ID survives a plugin reload, correct whichever of test:624 / webhook-handler.ts:708 is wrong, and consider promoting the foreign-slot reading into the PR body — it makes the second commit load-bearing rather than optional.
  3. 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>
@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

Important finding fixed in bee56394 — you were right, and the test comment was the loser

Reviewed head is now bee563941.

The contradiction was real. Settled from source.

WORKER_INSTANCE_ID = randomUUID() at module scope (webhook-handler.ts:86), evaluated once per
module load, so it is fresh per process — your reading. WORKER_SLOT is HOSTNAME and stable.
So on an ordinary restart a leaked fence carries the old UUID (satisfies IS DISTINCT FROM $1) and
the same slot (satisfies owner_slot = $2), and the sweep's identity arm reclaims it. Restarting
is precisely what helps.

That makes the sweep docstring at :706-709 correct and my test comment at :624 wrong. Fixed the
test comment; the case itself is unchanged and still valid — what it actually pins is the
live-process window, where a fence carrying the running process's own current id is excluded by
both identity arms and only the age arm can reclaim it without waiting for a restart.

Worth naming the direction of the error: the old comment told the next reader diagnosing a wedge not
to bother restarting, when a restart is a drain. That is the expensive way to be wrong.

❌ I'm not taking recommendation 2, and I think the evidence doesn't support it

You suggested promoting "the wedge that survived was necessarily the foreign-slot variant" into the
PR body. That doesn't follow, and I'd rather not put a stronger story in the body than the evidence
carries.

WORKER_SLOT's own docstring (:89-104) records the plugin worker as a cluster-wide singleton
StatefulSet paperclip at replicas: 1 with PAPERCLIP_NODE_ROLE=worker, while api replicas swap in
a stub that never forks a child. Under the documented topology there is no foreign slot to be the
culprit.
The docstring does note the singleton "rests partly on chart configuration" (an unknown
PAPERCLIP_NODE_ROLE falls back to "all", so a typo would silently add a second host) — so a
foreign slot is possible, not necessary.

Your fresh-per-process finding actually widens the mystery rather than resolving it. If the id is
fresh and the slot is a singleton, that restart should have drained the leak — and it didn't. Three
candidates survive, and I can't separate them:

  1. A second plugin host exists via the config fallback → genuine foreign slot.
  2. The sweep ran and failed silently. reconcileAbandonedAggregateFences is deliberately
    non-fatal and swallows its error, so a failed sweep is indistinguishable from a sweep that found
    nothing.
  3. The leak is ongoing. The restart drained the old fence and the new process leaked a fresh one.
    This fits the production shape best — the failure rate was unchanged across the restart
    (43/h → 45/h → 46/h), which is what re-wedging looks like and is not what a one-shot survival
    looks like.

If (3) is right then the per-claim backstop is the load-bearing half, since it reclaims on the
next delivery without waiting for a restart. So I'd resist calling either commit the one that "closes
the measured incident" — both arms are justified by mechanism, and which one fires in production is
something I have not measured.

Why I can't just settle it: it needs owner_instance_id / owner_slot from the fence table. The
plugin's read-only listing route is auth: "board", and I probed it this run rather than assuming —
my run-scoped agent JWT gets 403 Board access required on GET /aggregate-firing-fences. I have not
tried a board credential and won't without a grant. Recorded as unverified rather than inferred.

Also took the cheapest Suggestion

Added the 14-minute refusal case. You were right that 5/20 only constrained the horizon to
(5, 20). Negative control: shortening the constant to 10 minutes fails exactly this one new
test and nothing else, so it is genuinely the only case pinning the lower bound. Deliberately not
paired with a 16-minute case — a two-sided pin one minute apart would fail on clock skew rather than
on a real change.

The other two Suggestions (shared constant across the four coupled sites; splitting the sweep's
logger.warn count per arm) are both real and both still open. I'd rather land them separately than
grow a fix that is gating a live production wedge — re-measured at the top of this run, still
0.256 failing against a ~246/h denominator, now ~2 days continuous.

Verification

298 plugin tests pass, tsc --noEmit clean.

One note on the deploy, since it bears on urgency

Merging this is also the mitigation, not just the permanent fix: the deploy rolls the worker, and the
restart runs setup() → the sweep, now carrying the age arm, which reclaims every wedged fence
regardless of owner or slot. No separate approval-gated restart is warranted.

@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: 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) while WORKER_SLOT is stable, so the leaked fence then satisfies both identity predicates and the sweep drains it. That makes the surviving docstring at webhook-handler.ts:706-709 the 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 by IS DISTINCT FROM in 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 between seedFence() 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_000 and the wedged-fence detector's interval '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's logger.warn still reports one rowCount across 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 required on GET /aggregate-firing-fences was 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 the metrics.write answer 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

  1. 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.
  2. Suggestions are all opportunistic. The inverted rationale at :356-358 is 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.
  3. Merge-readiness note, not a finding: the branch is mergeable: true with no conflicts but 54 commits behind master (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 running setup() 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>
@allyblockcast

allyblockcast Bot commented Sep 7, 2026

Copy link
Copy Markdown
Author

Suggestion 1 taken in 65b755f3; the other two split out to BLO-32481

Reviewed head is now 65b755f38.

You were right, and my rationale was exactly backwards

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 I kept
, and the 16-minute case I skipped would have had no budget to lose at all. Comment
rewritten to say that, and to note where the budget actually sits.

The decision to skip the 16-minute case stands on its own merits — pastBackstop() at 20 min already
pins the reclaim side — but you're right that the reason given was the opposite of the risk, and in
this file the comments are load-bearing.

Worth naming the pattern rather than just fixing the instance: this is the second comment in
this review round that asserted the opposite of what the code does — the first being the
WORKER_INSTANCE_ID reuse claim. Both were caught by review, neither by tests, because a wrong
comment is invisible to CI
. The tests were correct both times; only the prose explaining them was
wrong. I've recorded that on the issue.

Suggestions 2 and 3 → BLO-32481

Filed with acceptance criteria and a verifying signal rather than left as review residue. I'm holding
to what I said last round about not growing this PR, and your own framing supports it — "better
landed separately than grown into a fix gating a live wedge."

On the log-split specifically: I agree it has risen above cosmetic, and I've made your reasoning the
load-bearing part of that issue. A per-arm count is the only accessible instrument for settling
which of the three candidates caused the incident, since the fence-listing route is board-gated
(403 to my agent JWT). One implementation note recorded there: RETURNING sees the NEW row and the
reclaim nulls owner_instance_id in the same statement, so distinguishing the arms needs a CTE that
classifies before the update — restructuring the exact SQL that performs the reclaim, which is
precisely what I don't want riding along on the incident fix.

I also flagged there that a both-arms row (an old fence from a dead predecessor) needs a chosen
precedence pinned by a test, or the two counts will double-count.

Merge intent

Once the suite is green and this head carries a clean attestation, I enqueue under the standing grant
for Blockcast/paperclip (gh pr merge --auto, queue not bypass). Re-confirmed there is no
repo-local prohibition in AGENTS.md before relying on that grant. BEHIND is not a gate here — the
queue rebases at merge_method: REBASE.

Still motivating urgency: the wedge measured 0.256 failing against a ~246/h denominator earlier in
this run, ~2 days continuous. The deploy is also the mitigation — rolling the worker runs setup()
the sweep, now carrying the age arm.

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