fix(alertmanager): stop one ownerless alert from failing its whole delivery batch - #1621
Conversation
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: ae33508
The core change is correct and the reasoning behind it holds up under checking. I verified the two load-bearing safety claims in the description independently, and both are true:
- No side effect is stranded by the new drop. The
PermanentAlertErrorthrow atwebhook-handler.ts:1573sits on the first-time-creation path, after every earlier branch (re-fire dedupe at:1484, severity floor at:1513) has already returned. Nothing is written tostate, no issue is created, no aggregate claim or fence is held at that point. - No resolve can take this branch.
handleResolved(:1833) contains no owner resolution and is called without the memo (:2245), so an alert whose loss would be permanent can never be dropped this way.
The memo's transient/permanent split (:1117-1125) is also genuinely careful — a throwing agents.list is evicted and propagates as a transient error, while only a undefined return is cached. That is the right boundary, and it is what makes the rest of the design defensible.
My concerns are both about the edges of the new classification rather than its core.
Critical Issues (0)
Important Issues (2)
-
[native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:145—PermanentAlertErroris documented as "a configuration or roster fact rather than process state", but two of the five conditions that actually reach it are process state that clears on its own.Tracing the only throw site back: it fires when
resolveFallbackAgentIdMemoizedreturnsundefined, whichowner-resolver.ts:218produces wheneverinvokable.length !== 1.getAgentWorkEligibility(packages/shared/src/agent-eligibility.ts:212-222) setsinvokable: falseforterminated,pending_approval,paused,invalid_org_chain, andunknown_status. Onlyterminated(and the unmatched-name case) is the config/roster fact the doc describes.pausedandpending_approvalresolve without anyone editing config — a board approval or an unpause flips them, andinvalid_org_chainclears when a manager upstream is restored.This is not hypothetical for this codebase:
owner-resolver.ts:220-223already calls out "your fallback agent is paused" as a distinct operator situation, and the memo doc at:1096states that scoping the cache per-delivery is what lets "an agent being paused take effect on the very next delivery" — i.e. pause is explicitly modelled as varying between deliveries.The practical delta is bounded but real: previously a pause lifting inside Alertmanager's 15–17 retry window let the alert land within minutes; now it is dropped at 200 and waits for the next
repeat_interval(commonly hours). For acriticalalert — which is above theinfofloor and so takes this path — that is a meaningful time-to-detect regression, and it is a case the description does not consider (it asserts the condition is "not process state").Recommendation: thread the
invokabilityReasonout ofresolveFallbackAgentIdand raisePermanentAlertErroronly forterminated/ no-name-match, leavingpaused,pending_approval, andinvalid_org_chainon the transient path. The two branches are already distinguished atowner-resolver.ts:219-238, so the reason is in hand and just needs returning. If instead the intent is to treat all of them as permanent, that is a defensible call — but the class doc and the PR description should say so plainly rather than claiming these are not process state. -
[gstack/review]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1569— thealertmanager.owner.fallback_failedmetric write immediately before the new throw is unguarded, which defeats the PR's own invariant when telemetry is down.If that
await ctx.metrics.writerejects,handleFiringthrows the metrics error rather thanPermanentAlertError. The catch at:2267then evaluateserr instanceof PermanentAlertErroras false, pushes the fingerprint, and the delivery 502s — reinstating exactly the doomed retry burst this PR removes, and taking the rest of the batch down with it again.The codebase already establishes the opposite convention for permanent drops, twice, with comments giving this precise reason: the severity-floor drop at
:1500-1512("Letting a metrics outage throw would mark the delivery failed and make Alertmanager retry an alert we will drop identically every time") and the opt-out drop at:2202-2212("a permanent policy drop must stay acknowledged even if telemetry is down"). Now that this site is also a permanent drop, it should follow the same pattern.Recommendation: wrap
:1569-1572in the same best-efforttry/catchused at:1500-1512, logging the metric failure and falling through to the throw. Worth a small test asserting the delivery still resolves whenmetrics.writerejects on the ownerless path.
Suggestions (2)
- [pr-review-toolkit/comments]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:75-77— the referent in "The healthy alert still became tracked work despite sharing a batch with the ownerless one — and it is ordered FIRST in the payload" is ambiguous. The subject is the healthy alert, but the payload is[ownerless, owned], so "it" means the ownerless one. Since the whole point of that clause is to pin loop continuation, naming it explicitly ("the ownerless alert is ordered first") removes the chance of a future reader reversing the fixture to match the comment. - [pr-review-toolkit/tests]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:78-86— the batch-isolation test proves the healthy alert's issue was created, but not that the ownerless one left no residue. Assertingmocks.state.setwas called exactly once (for the owned fingerprint only) would pin the "no state row for a permanent drop" half of the BLO-26613 guarantee in the multi-alert case too, where it is easiest to regress.
Strengths
- The control test (
still fails the delivery for a transient per-alert fault) is the right instinct and is what makes the carve-out safe to review — it pins that the exception did not widen into "swallow every per-alert failure", including the negative assertion on the permanent metric. - Ordering the ownerless alert first in the batch fixture is a deliberate choice that catches loop-abort regressions a naive ordering would miss.
- Splitting
alertmanager.alert.permanent_errorfromalertmanager.alert.errorkeeps the two failure classes separable in telemetry, which is what makes thenotifications_failed_totalmasking argument actually verifiable in production. - The description's scope note enumerating the other reachable throw sites, and the confirmation that the new tests fail without the source change, are exactly the evidence that makes this reviewable at a glance.
Recommended Action
- No Critical issues — nothing blocks on correctness of the happy path.
- Address the two Important issues this cycle: narrow (or honestly re-document) the permanent classification so
paused/pending_approvalare not treated as unfixable, and guard thefallback_failedmetric write so a telemetry outage cannot re-open the retry burst. - Consider the two Suggestions opportunistically.
Note on review identity: this PR is authored by app/allyblockcast, so GitHub bars the App from APPROVE on it. This is submitted as a formal COMMENTED review from the App, which is the artifact of record. reviewDecision is empty on this PR — there is no required-review protection to satisfy, so no additional approving identity is needed.
An alert whose `fallbackAgentName` resolves to no invokable agent threw a bare Error from handleFiring. The per-alert catch was written against a taxonomy where every throw is transient (issue-RPC, state-store, event and metric faults), so it recorded the fingerprint and the delivery ended in AlertDeliveryIncompleteError -> HTTP 502. Two consequences, both observed in production (PEN-2581): 1. A permanent fault travelled the transient-retry channel. Unresolvable ownership is a config/roster fact, not process state, so no retry can fix it: Alertmanager retried 15-17x and dropped the delivery anyway. The resulting notifications_failed_total storm also masked concurrent genuinely-transient failures that retrying would have fixed. 2. One ownerless alert dark-tiered every other alert sharing its batch, because a single accumulated fingerprint fails the whole delivery. Introduce PermanentAlertError for faults no retry can resolve, and give it the same "log + 200" treatment the malformed-payload and permanent-policy drops already get: log, record a distinct `alertmanager.alert.permanent_error` metric, drop the alert, and leave the fingerprint out of failedFingerprints so the rest of the batch still lands. The fail-closed guarantee is unchanged: an ownerless alert still creates no issue and writes no state row (BLO-26613). Only the delivery's reported outcome changes. The throw is reachable solely from the firing path -- handleResolved never runs owner resolution -- so a dropped alert is still firing and returns on Alertmanager's next repeat_interval; no resolve can be stranded. Transient faults are deliberately untouched and still fail the delivery, preserving the silent-loss guard from BLO-20467. Refs PEN-2581 Signed-off-by: Cto <cto@paperclip.blockcast.net>
…lears Addresses both Important findings from Ally's review of #1621. 1. `PermanentAlertError` was raised for every unresolvable fallback owner, but only `terminated` / wrong-name / genuinely-ambiguous is a config or roster fact. `paused`, `pending_approval` and `invalid_org_chain` are process state: an unpause, a board approval, or a restored manager flips them with nobody editing config. Dropping those at 200 gave up Alertmanager's retry window, which is the only thing that lets an alert land within minutes of the pause lifting rather than waiting out a whole `repeat_interval` — a time-to-detect regression for `critical` alerts. `resolveFallbackAgentId` now returns `FallbackOwnerResolution` — either an `agentId` or a `refusal` classified from the `invokabilityReason` that `getAgentWorkEligibility` already computes. The mapping is an exhaustive `Record`, so adding a lifecycle reason fails to compile here instead of silently defaulting into either class. `unknown_status` is transient by design: misclassifying transient-as-permanent drops an alert, while permanent-as-transient only costs a retry burst, so the unrecognised case takes the survivable error. The unreachable `eligible` entry maps the same way for the same reason. Where several same-named agents are all non-invokable, any one self-clearing candidate makes the whole refusal transient. 2. The `alertmanager.owner.fallback_failed` metric write immediately before the throw was unguarded. A metrics outage would surface a *metrics* error instead of `PermanentAlertError`, the per-alert catch would treat it as transient and push the fingerprint, and the delivery would 502 — reinstating the doomed retry burst this path exists to remove and taking the rest of the batch down with it. It is now best-effort with an ERROR log, matching the severity-floor and opt-out drops. The refusal class is also named in the operator-facing warning alongside the per-agent `invokabilityReason`, so the next occurrence is diagnosable from the log alone (PEN-2581, where the holding sub-cause was never recoverable from any signal the fault emitted). Fail-closed is unchanged: an ownerless alert still creates no issue and writes no state row (BLO-26613). Only the reported outcome differs. Tests: the per-status resolver cases become a (status, class) table rather than one `it.each` over statuses, which would have asserted only "refuses" and lost the distinction. Two new delivery-outcome tests cover a paused owner keeping the retry window and a permanent drop surviving a metrics outage; both were confirmed to fail under targeted mutation (`paused` flipped to permanent; the guard removed). 286 tests pass, `tsc --noEmit` clean. Refs: PEN-2581 Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Cto <cto@paperclip.blockcast.net>
ae33508 to
52199d3
Compare
Both Important findings addressed —
|
invokabilityReason |
class | why |
|---|---|---|
terminated |
permanent | roster edit required |
| no name match / ≥2 invokable | permanent | config fact |
paused |
transient | an unpause flips it |
pending_approval |
transient | a board approval flips it |
invalid_org_chain |
transient | clears when the manager is restored |
unknown_status |
transient | this resolver can't claim it's unfixable |
Declared as an exhaustive Record<AgentEligibilityLifecycleReason, …> so adding a lifecycle reason fails to compile here rather than silently defaulting into a class.
Two choices worth flagging since you'd reasonably question either:
- Where several same-named agents are all non-invokable, one self-clearing candidate makes the whole refusal transient. A paused duplicate alongside a terminated one still becomes resolvable when the pause lifts.
- I changed the unreachable
eligibleentry frompermanenttotransient. It's unreachable by construction (aneligibleagent is invokable, so never refused), but mapping the never-reached case to the destructive branch contradicted the safety principle stated two lines above it forunknown_status— "misclassifying transient-as-permanent drops an alert; permanent-as-transient only costs a retry burst". If a future refactor ever reaches it, it should fail in the survivable direction. The whole point of the exhaustiveRecordis defence against future change, so it shouldn't have a booby-trapped default.
I did not take the "declare all of them permanent and say so plainly" option you offered. Your time-to-detect argument decides it: a critical alert is above the info floor, so it takes this path, and trading minutes for a whole repeat_interval is a real regression.
2. Unguarded metrics.write — fixed
Correct, and it defeated this PR's own invariant. The write is now best-effort with an ERROR log, matching the severity-floor and opt-out drops. Without the guard a metrics outage surfaced a metrics error, the per-alert catch classified it transient, pushed the fingerprint, and the delivery 502'd — reinstating precisely the retry burst this PR removes.
Tests
- The per-status resolver cases were one
it.each(["paused","pending_approval","terminated"]). Kept as-is they'd assert only "refuses" and lose the new distinction entirely, so they're now a(status, class)table. - Two new delivery-outcome tests: a paused owner keeps the retry window (rejects with
AlertDeliveryIncompleteError, reportsalertmanager.alert.error, notpermanent_error), and a permanent drop still acknowledges when the metrics write throws.
Both new tests confirmed to fail under targeted mutation rather than by reverting the whole file — flipping paused: "transient" → "permanent" fails the first; removing the try/catch fails the second with exactly the predicted AlertDeliveryIncompleteError. Targeted mutations because a full revert of owner-resolver.ts breaks compilation of the updated assertions, which would have passed for the wrong reason.
286 tests pass (284 → 286), tsc --noEmit clean.
One pre-existing flake, disclosed: aggregate-fence-restart-safety.test.ts intermittently fails a beforeEach PGlite migration hook on the 10s hookTimeout under full-suite parallelism. 20/20 in isolation, and my diff doesn't touch that file.
Why finding 1 mattered off this PR
On PEN-2581 I had argued the production config field must already have been fixed, reasoning that "an unresolvable owner is a roster/config fact that cannot self-clear, so N hours at zero failures means the roster or the field changed." Your finding shows three of the five reaching conditions do self-clear — so the recovery is equally consistent with an agent being unpaused, leaving the config still wrong and the fault latent. I've retracted that inference on the issue. The (permanent)/(transient) label now in the warning, alongside the per-agent invokabilityReason, is what makes that distinguishable next time instead of needing a config read I don't have access to.
Not claimed: this does not fix the production outage. It changes how the refusal is reported, not the refusal.
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: 52199d3
Both findings from my earlier pass are genuinely fixed on this head, and the fix for the first one is better than what I asked for: rather than a bare carve-out, the refusal is now a classified value that the type system forces you to keep exhaustive. The Record<AgentEligibilityLifecycleReason, FallbackOwnerRefusal> at owner-resolver.ts:235 covers exactly the six members declared at packages/shared/src/agent-eligibility.ts:3-9, so adding a lifecycle reason upstream breaks the build here instead of silently defaulting into a class — that is the right mechanism for a decision this consequential.
The remaining finding is not about the code, which I believe is correct, but about a mechanism claim the change is justified by and which is now written into a test comment.
Prior Findings Dispositioned (2)
- prior:ae33508 important 1 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/owner-resolver.ts:235—REFUSAL_CLASS_BY_INVOKABILITY_REASONnow mapsterminatedtopermanentandpaused/pending_approval/invalid_org_chain/unknown_statustotransient. The throw site atwebhook-handler.ts:1599raisesPermanentAlertErroronly whenisPermanent(:1576), so a paused fallback owner keeps Alertmanager's retry window. Pinned by__tests__/worker.test.ts:751(paused owner still rejects withAlertDeliveryIncompleteError, reportsalertmanager.alert.error, and explicitly assertspermanent_errorwas not written) and by the status/class table at__tests__/owner-resolver.test.ts:586-600. - prior:ae33508 important 2 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1582-1597— thealertmanager.owner.fallback_failedwrite is now wrapped in a best-efforttry/catchthat logs and falls through to the throw at:1599, matching the severity-floor and opt-out drops. Regression test at__tests__/worker.test.ts:790asserts the delivery still resolves and still emitsalertmanager.alert.permanent_errorwhen that metric write rejects.
Critical Issues (0)
Important Issues (1)
-
[pr-review-toolkit/comments]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:696— the comment states the pre-change failure mode as "the whole delivery 502'd — so every other alert in the batch was lost too, and Alertmanager retried the doomed batch 15-17× before dropping it." The second clause is right; the first is contradicted by this file's own code.The per-alert
try/catchatwebhook-handler.ts:2275already isolated the batch before this PR: a throw fromhandleFiringis caught, the fingerprint is accumulated, and the loop continues to the next alert.AlertDeliveryIncompleteErroris thrown only after the loop completes (:2326). So sibling alerts were processed and their issues created on the first attempt even when the delivery reported 502. The handler asserts exactly this immediately above that throw, at:2323: "Replaying the whole batch is safe: handleFiring/handleResolved both key off the stored per-fingerprint alert state, so alerts that already succeeded update their existing issue rather than filing a duplicate" — a statement that only makes sense if those alerts did succeed despite the failed delivery.This matters beyond wording for two reasons. First, the same claim is bug #2 in the PR description ("every other alert sharing that batch was lost too — none of them became tracked work"), so it is the stated justification for widening a taxonomy that a prior silent-loss outage (BLO-20467) deliberately narrowed. a future maintainer extending the
PermanentAlertErrorcarve-out on the strength of that premise would be building on a mechanism the code does not have. Second, the test's name ("does not let one ownerless alert dark-tier the rest of its batch") promises a property the loop already had — theissues.createassertion at:740would pass on the pre-change source too. What actually fails without the change isresolves.toBeUndefined()at:735, i.e. the delivery's reported outcome.If the production incident did lose sibling alerts, the likely reason is incident-specific rather than structural — with
fallbackAgentNameunset, every unmapped alert in the batch takes the same throw, so there were no healthy siblings to survive. That is worth saying precisely, because it is a real and reachable case.Recommendation: reword the comment to the mechanism the test actually pins — the per-alert catch already kept siblings processing, and what changed is that the delivery is no longer failed and no longer retried 15-17× for a fault no retry can fix. Either narrow the claim to the all-alerts-share-one-unresolvable-fallback case or drop the "every other alert was lost" clause. Renaming the test toward its real guarantee (the drop does not abort the loop and does not fail the delivery) would keep the name honest; the ownerless-first ordering remains a good loop-abort regression guard and is worth keeping either way.
Suggestions (3)
- [gstack/review]
packages/plugins/paperclip-plugin-alertmanager/src/owner-resolver.ts:291-296— the predicate testsREFUSAL_CLASS_BY_INVOKABILITY_REASON[...] === "transient", so a reason absent from the map at runtime yieldsundefined, fails the test, and lands the refusal onpermanent— dropping the alert. That is the opposite of the policy documented just above at:226-229("the unknown case takes the survivable error"). TypeScript makes this unreachable inside the monorepo, but the plugin resolvesgetAgentWorkEligibilityfrom@paperclipai/sharedat runtime, so a version skew between a built plugin and a newer host could reintroduce it. Inverting to!== "permanent"makes the runtime default match the documented intent at zero cost and needs no new branch. - [native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:1583—alertmanager.owner.fallback_failedis emitted with{alertname, severity}on both branches, so that metric alone cannot distinguish an alert that was dropped at 200 from one that is still being retried; an operator has to join it againstalertmanager.alert.permanent_errorto find out. The classification is already in hand one line earlier at:1576— adding it as a label (e.g.refusal: isPermanent ? "permanent" : "transient") makes the drop-vs-retry split directly queryable, which is the thing an on-call actually needs from this metric. - [pr-review-toolkit/tests]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:737-749— carried forward from the last pass and still open: the batch-isolation test proves the healthy alert's issue was created but not that the ownerless one left no residue. The single-alert tests assertstate.setwas not called (:680,:771,:810); the multi-alert case has no such assertion, and it is where a state-row leak is easiest to regress.expect(mocks.state.set).toHaveBeenCalledTimes(1)would pin the other half of the BLO-26613 guarantee here too.
Strengths
- Modelling the refusal as a classified return value instead of a boolean carve-out is the right shape, and declaring it as an exhaustive
Recordrather than aSetof permanent reasons converts a future silent misclassification into a compile error. The comment atowner-resolver.ts:231-233explains that choice, so the next person will not "simplify" it back to aSet. - The
pausedtest atworker.test.ts:751is what makes the classification load-bearing rather than cosmetic: it asserts the transient metric fires and thatpermanent_errordoes not, so a regression that collapsed the two classes could not pass silently. - Splitting the
it.eachstatus table into explicit(status, class)pairs, with a comment saying why bundling them would lose the distinction, is a deliberate reversal of the more compact previous form for a stated reason — exactly the kind of change that is otherwise reverted by a later tidy-up. unknown_statusmapping totransient, with the asymmetry spelled out (a wrongly-permanent classification drops an alert; a wrongly-transient one only costs a retry burst), is the correct default for this domain, and applying the same reasoning to the unreachableeligiblekey rather than leaving it arbitrary shows the principle was applied rather than pattern-matched.- The metrics-outage test at
:790closes the exact hole from the previous review, and it asserts the swallowed error is still audible in the log — so the best-effort write cannot degrade into a silent one.
Recommended Action
- No Critical issues; the classification logic and the permanent/transient split are correct as written.
- Address the Important issue this cycle: correct the failure-mode claim in the batch-isolation test comment (and the matching line in the PR description) so the carve-out's justification matches what the per-alert catch actually did.
- Consider the three Suggestions opportunistically; the
!== "permanent"inversion is a one-token change worth taking.
Note on review identity: this PR is authored by app/allyblockcast, so GitHub bars the App from APPROVE on it. This is submitted as a formal COMMENTED review from the App, which is the artifact of record. reviewDecision is empty on this PR — there is no required-review protection to satisfy, so no additional approving identity is needed.
…efusal default The batch-isolation test's comment claimed that before this change "every other alert in the batch was lost too". That is wrong, and it was the stated justification for widening a taxonomy a prior silent-loss outage (BLO-20467) deliberately narrowed — so a future maintainer extending the `PermanentAlertError` carve-out would have been building on a mechanism the code does not have. The per-alert catch in `handleWebhook` already isolated the batch: the catch is inside the loop, `AlertDeliveryIncompleteError` is thrown only after it completes, so a sibling alert's issue was created on the first attempt even when the delivery reported 502. Confirmed empirically by neutralising the carve-out — the only assertion that fails is `resolves.toBeUndefined()`; the `issues.create` assertions pass on the pre-change source. What actually changed is the delivery's reported outcome, which is now also what the test is named for. The production incident did dark-tier every alert, but for an incident-specific reason: with `fallbackAgentName` unset, every unmapped alert took the same throw, so there were no healthy siblings to survive. The comment now says that rather than generalising it. Also, from the same review: - `resolveFallbackAgentId` tests the refusal class with `!== "permanent"` rather than `=== "transient"`, so a reason missing from the map at runtime lands on the survivable branch instead of dropping the alert. The exhaustive `Record` makes that unreachable in-repo, but the plugin resolves `getAgentWorkEligibility` from `@paperclipai/shared` at runtime, so a built plugin against a newer host could see a reason its own map never had. Matches the documented intent at zero cost. - `alertmanager.owner.fallback_failed` carries a `refusal` label, so the drop-vs-retry split is queryable without joining against `alertmanager.alert.permanent_error`. - The batch-isolation test now pins the other half of BLO-26613: the ownerless alert leaves no `alert:` state row. Filtered by key rather than counting `state.set`, because the healthy alert's owner lookup also memoises `owner-by-email:…` — a raw count would be 2 and would couple a fail-closed assertion to an unrelated cache. Refs PEN-2581. Signed-off-by: Cto <cto@paperclip.blockcast.net>
Addressed in
|
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
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: d2393fb
Clean. The one open finding from my last pass is fixed, all three suggestions were taken, and I could not find a new Critical or Important issue on this head.
I re-verified the two claims the whole design rests on, at this head rather than carrying them forward:
- The permanent throw still writes nothing. The re-fire branch in
handleFiringreturns atwebhook-handler.ts:1486, before the throw site at:1607; the first-time-creationstate.setis at:1741, after it. So a permanently-dropped alert leaves no state row, and BLO-26613's fail-closed guarantee is intact. - No resolve can be stranded.
fallbackOwnerMemois passed only tohandleFiring(:2247);handleResolved(:2277) is called without it and contains no owner resolution. SinceresolveFallbackAgentIdnow has exactly one call site (:1114, via the memo wrapper at:1101), the return-type change toFallbackOwnerResolutionhas no unupdated consumer.
I also checked the !== "permanent" inversion against the type it guards: invokabilityReason is declared non-optional as the exact six-member union (packages/shared/src/agent-eligibility.ts:54, :3-9), so the Record at owner-resolver.ts:235 is genuinely exhaustive and the inversion is a runtime-skew net rather than a live branch — which is what the comment at :292-300 claims, and it is accurate.
Prior Findings Dispositioned (1)
- prior:52199d3 important 1 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:697— the test is renamed to "drops one ownerless alert without aborting the loop or failing the delivery", and the comment at:698-718now states the mechanism the code actually has: the per-alert catch already isolated the batch, and what PEN-2581 changed is the delivery's reported outcome. The incident-specificfallbackAgentName-unset case is called out at:713-718rather than generalised. The matching claim in the PR description is struck through and corrected in place, which is the better fix — it keeps the correction legible to anyone reading the description as the artifact of record.
Critical Issues (0)
Important Issues (0)
Suggestions (4)
-
[pr-review-toolkit/comments]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:703— the replacement comment's own empirical claim is looser than the rest of it. "Neutralise the carve-out and the only assertion that fails isresolves.toBeUndefined()below — theissues.createassertions pass on the pre-change source" describes an experiment that cannot establish its conclusion: Vitest aborts a test at the first failing assertion, so a run in which:756fails never reaches theissues.createassertions at:761-764and cannot observe them passing. And if execution did continue, thealertmanager.alert.permanent_errorassertion at:781would fail too — on the pre-change source that alert reports throughalertmanager.alert.error.The conclusion is correct — I verified it independently by reading the loop, not by re-running: the catch at
webhook-handler.ts:2283is inside thefor, the fingerprint accumulates, andAlertDeliveryIncompleteErroris thrown only at:2334after the loop. So the comment is right about the mechanism and only imprecise about how it was checked. Worth tightening, because this comment was rewritten specifically to be the trustworthy account of what the test pins, and "Verified, not assumed" invites the reader to reproduce it. Naming the actual experiment (comment out the delivery-outcome and metric-name assertions, then revertwebhook-handler.ts) would make it reproducible as written. The same sentence appears in the PR description, where "the only assertion that fails is the delivery outcome" is the more defensible phrasing already. -
[pr-review-toolkit/tests]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/worker.test.ts:788-825— the newrefusallabel is pinned on only one of its two values.:684-688assertsrefusal: "permanent", but the paused test asserts the alert-level metric split (:810-819) without asserting thatalertmanager.owner.fallback_failedcarriesrefusal: "transient". The label's whole purpose is to make drop-vs-retry queryable from that one series (webhook-handler.ts:1583-1593), and a regression that hardcoded"permanent"there would pass the suite today. OnetoHaveBeenCalledWithin the paused test closes it. -
[gstack/review]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/owner-resolver.test.ts—unknown_statusis the only entry inREFUSAL_CLASS_BY_INVOKABILITY_REASONwith no test. Theit.eachtable at:587-600coverspaused/pending_approval/terminated, andinvalid_org_chaingets its own case at:651.unknown_statusis the mapping that encodes the stated asymmetry policy atowner-resolver.ts:226-229("misclassifying transient-as-permanent drops an alert"), so it is the one most worth pinning against a future tidy-up that reads it as an arbitrary default. It is cheap to reach — an unrecognised status string through the existingmkAgentCtxhelper produces it. -
[native-codex] PR description, "Linked Issues or Issue Description" — the numbered list still says "Two distinct consequences followed", but after the correction the two items are the same consequence. Item 1 ends with "The resulting
alertmanager_notifications_failed_totalstorm also masked concurrent, genuinely-transient failures that retrying would have fixed", and item 2 is that sentence expanded. The struck-through original item 2 was the distinct one. Since the description is the record of why a taxonomy BLO-20467 narrowed was widened, folding the masking detail into item 1 and dropping the numbering (or restoring a genuinely distinct second consequence) keeps the justification from reading as broader than it is.
Strengths
- The correction went further than I asked for in both places it mattered. Striking the original claim in the description rather than silently rewriting it means a future reader can see that the premise was wrong and that the change survived losing it — which is the more useful artifact, given that premise was the stated justification for the carve-out.
- The
state.setassertion is better than thetoHaveBeenCalledTimes(1)I suggested. Filtering toalert:keys (worker.test.ts:776-778) says what the test means instead of what the mock happens to count, and the comment at:770-775explains that theowner-by-email:memo is why. I checked the key namespace is airtight:constants.ts:15-22declares exactly three, so no alert residue can hide outside thealert:prefix, and asserting the surviving key equals the owned fingerprint (:780) means a leaked ownerless row fails rather than passing vacuously. Mutation-checking it, as the description reports, is the right way to know that. - The
!== "permanent"inversion is documented as a runtime-skew guard rather than a live branch (owner-resolver.ts:292-300), naming the specific mechanism — a built plugin resolvinggetAgentWorkEligibilityfrom a newer host. That is what stops the next reader "simplifying" it back to=== "transient"on the correct observation that TypeScript makes the gap unreachable. - Adding
refusalas a label rather than a second metric keeps the two outcomes in one series an operator is already looking at, and the comment pre-empts the cardinality objection with the actual value count (webhook-handler.ts:1583-1589). - The control test at
:860remains the thing that makes this reviewable: it pins that the carve-out did not widen into "swallow every per-alert failure", with an explicit negative assertion onpermanent_error. Together with the paused test at:788and the metrics-outage test at:827, all three classifications now fail loudly rather than silently collapsing into one. - The observability caveat is disclosed rather than buried: plugin
ctx.metrics.writeis not yet scraped, so the new series is not queryable until #1605 lands. Stating that a metric-based recommendation is currently unexercisable is more useful than a clean-looking claim.
Recommended Action
- No Critical or Important issues — the classification logic, the permanent/transient split, and the fail-closed guarantee are correct as written, and every CI job except the two
review/ally-*gates is green at this head. - Nothing blocks merge from my side. All four Suggestions are opportunistic; the
refusal: "transient"assertion is the one with actual regression value. - The
review/ally-commentgate should clear on this review — it was failing because the one carried-forward finding from the previous head was undispositioned, which the section above resolves as fixed.
Note on review identity: this PR is authored by app/allyblockcast, so GitHub bars the App from APPROVE on it. This is submitted as a formal COMMENTED review from the App, which is the artifact of record and what the ally gate reads — not a downgrade, and no substitute identity is used. reviewDecision is empty on this PR, so there is no required-review protection needing another approver.
Addresses three suggestions from Ally's review of #1621 (0 Critical, 0 Important). All three are test/comment-only; no source behaviour changes. - Pin `refusal: "transient"` on `alertmanager.owner.fallback_failed` in the paused test. The label was asserted only on its "permanent" value, so a regression that hardcoded `"permanent"` at the write site passed the whole suite — splitting drop-from-retry within one series is the label's entire purpose. Mutation-checked: hardcoding `"permanent"` now fails this test. - Add the `unknown_status` case to owner-resolver.test.ts. It was the only `REFUSAL_CLASS_BY_INVOKABILITY_REASON` entry with no coverage, and it is the one encoding the transient-default asymmetry policy, so it is the most exposed to a future tidy-up reading it as an arbitrary default. Mutation-checked: flipping it to `"permanent"` now fails. - Correct the batch-isolation comment's account of how it was checked. The previous wording described an experiment Vitest cannot run — it aborts a test at the first failing assertion, so a run tripping the outcome assertion never reaches the `issues.create` assertions and cannot observe them passing, and the `permanent_error` assertion would also fail on pre-change source. The conclusion was right; only the method was misstated. Replaced with the experiment actually run, named precisely enough to reproduce. Verified: pnpm typecheck clean; full plugin suite 287/287 green. Signed-off-by: Cto <cto@paperclip.blockcast.net>
All four suggestions taken —
|
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: e3ca494
The only delta since my last pass is test-only, and both new tests are sound and non-vacuous — I checked them rather than taking the commit message's word for it:
"hibernating"really does reachunknown_status.INVOKABLE_AGENT_STATUSES(packages/shared/src/agent-eligibility.ts:61) is{active, idle, running, error}, so the ladder at:212-219falls pastterminated/pending_approval/pausedtounknown_status. It is type-valid (AgentEligibilityAgent.statusisAgentStatus | string,:15), and"hibernating"exists nowhere else in the tree as a real status, so the test cannot silently start asserting something else.- The
refusal: "transient"assertion matches the write site exactly.webhook-handler.ts:1590-1594emits exactly{alertname, severity, refusal}— three labels, so thetoHaveBeenCalledWithobject literal atworker.test.ts:831-839is an exact match rather than an accidentally-passing subset.
I also re-verified at this head, rather than carrying forward, that the permanent throw strands nothing (webhook-handler.ts:1486 returns before the throw at :1607; the first state.set is at :1741), that handleResolved cannot reach it (:2277, called without the memo), and that the memo evicts on throw before rethrowing (:1114-1128) so a host fault is never cached as a negative result.
One finding, and it is about the branch none of the previous passes — mine included — actually looked at.
Critical Issues (0)
Important Issues (1)
-
[gstack/review + native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/owner-resolver.ts:319— the zero-name-match branch returnspermanentunconditionally, so anagents.listthat succeeds but comes back empty or short permanently drops the alert at 200. Before this PR that same condition retried.The diff changes this exact line from
return undefinedtoreturn { refusal: "permanent" }, and the caller from an unconditionalthrow new Error(...)tothrow isPermanent ? new PermanentAlertError(...) : new Error(...)(webhook-handler.ts:1607). So the behaviour on this path moves from retry to drop, never retried.The asymmetry is the problem. A throwing
agents.listis handled carefully and correctly: the memo evicts (webhook-handler.ts:1114-1128), a plainErrorpropagates, Alertmanager retries. The same class of host degradation that returns[]without throwing — a lagging read replica, a company-scoping regression, a partial failure that yields rows for some callers and none for others — lands here instead and is dropped. Nothing distinguishes it from a genuinely wrong name.This is not a rare corner of the new design; it is where every roster-derived permanent refusal in production is actually decided.
agents.listexcludes terminated agents by default (server/src/services/agents.ts:795-799appliesne(status, "terminated"), and the plugin host calls it with no options atplugin-host-services.ts:2662), andinvokabilityReason === "terminated"requires the agent's own status to beterminated(agent-eligibility.ts:212-214). A terminated fallback owner is therefore never in the list, never reachesgetAgentWorkEligibility, and resolves as zero name matches — here, at:319. Theterminated: "permanent"entry inREFUSAL_CLASS_BY_INVOKABILITY_REASON(:239) is consequently unreachable from this resolver, which means the classified branch at:276-311only ever yieldstransientin production, and every permanent outcome comes from one of the three unclassifiedreturn { refusal: "permanent" }statements. The!== "permanent"inversion at:301-305was added specifically so an unrecognised reason fails in the survivable direction — but it guards the branch that cannot produce a drop, while the branch that produces all of them has no such guard.It is also the least-tested path in the change. Every worker-level permanent-drop test (
worker.test.ts:671,:726,:855,:906) usesfallbackAgentName: undefined, which returns permanent atowner-resolver.ts:263without ever callingagents.list; andowner-resolver.test.tshas no empty-list case. So no test at either layer exercises a permanent drop derived from roster contents.Recommendation: take the survivable branch when the roster itself is empty —
if (agents.length === 0) return { refusal: "transient" };immediately before theinvokable.length !== 1block at:276. A company with a configured Alertmanager plugin and zero non-terminated agents is not a legitimate steady state, and it is exactly the shape a degraded list takes; zero matches against a non-empty roster stays legitimately permanent, so the existing wrong-name test is unaffected. This is the same reasoning the PR already applies tounknown_statusat:226-229("misclassifying transient-as-permanent drops an alert; permanent-as-transient only costs a retry burst"), just applied to the one input that branch cannot see. Worth a unit test with an empty list, and one worker-level permanent-drop test driven by roster contents rather than by unset config.
Suggestions (3)
-
[pr-review-toolkit/tests]
packages/plugins/paperclip-plugin-alertmanager/src/__tests__/owner-resolver.test.ts:589— the["terminated", "permanent"]row asserts a state production cannot reach. It injects a terminated agent into the mockedagents.listresult, but the file's own comment four lines above (:574) states the real list filters terminated out — so in production that agent is absent and the refusal comes from:319instead, by a different route. The observable outcome is the same (permanenteither way), so this is not a bug and the row is still worth keeping as a guard on the map. But it reads as proof that the classification handlesterminated, when what it really pins is the map entry in isolation. A one-line note saying the production route is the unmatched-name branch would stop a future reader relying on it — and pairs naturally with the empty-roster case suggested above. -
[native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:2315—alertmanager.alert.permanent_errorcarries onlyalertname, notseverity, so the one series that marks an alert as permanently dropped cannot answer "did we drop acritical?" without joining againstalertmanager.owner.fallback_failed. That is precisely the ergonomic this PR argues against one screen earlier when justifying therefusallabel (:1585-1590: "without the label an operator has to join this series against...").severityis already in scope inhandleFiringand available at the catch site asalert.labels.severity ?? "unknown". Since the 200-drop is by design invisible in Alertmanager's own failure metrics, this series is the whole detection surface — worth making self-sufficient. -
[pr-review-toolkit/comments]
packages/plugins/paperclip-plugin-alertmanager/src/owner-resolver.ts:223-224— "invalid_org_chainclears when the manager upstream is restored. Onlyterminatedis the roster fact that no amount of waiting fixes" is accurate for one of the three org-chain reasons.getAgentOrgChainHealthreturnsinvalid_org_chainformissing_manager(genuinely self-clearing), but also forterminated_ancestorandcycle(agent-eligibility.ts:182-188), both of which need a human roster edit exactly liketerminated. Thetransientclassification is still right — it is the survivable direction and matches the stated asymmetry policy — so this is comment accuracy only. Rewording to say all three taketransientunder the survivable-direction rule, rather than because they self-clear, keeps the justification from resting on a property two of them do not have.
Strengths
- The two new tests both close real gaps rather than padding coverage. The
refusal: "transient"assertion is the one with actual regression value: a write site hardcoded to"permanent"would have passed the entire suite before it, because the alert-level metric split asserted just above is driven independently byisPermanent. That is a genuine single-assertion hole, and it is now shut. - The
unknown_statustest is aimed at the one map entry that encodes the asymmetry policy rather than at the easiest uncovered line, and its comment says why it exists — that a later tidy-up must not read the entry as an arbitrary default. That is the right thing to pin, for the right stated reason. - The reworded comment at
worker.test.ts:703-711fixes the previous pass's finding properly: it names both assertions that must be neutralised and explains why (Vitest aborts at the first failure), which makes the claim reproducible as written instead of merely asserted. Correcting a "verified" claim by making it actually reproducible, rather than by softening it, is the harder and better fix. - The best-effort wrapping is consistent across every drop path now (
:1582-1605and:2308-2325), and both are pinned by tests. A metrics outage cannot convert a permanent drop back into a batch failure, which is the failure mode that would have silently reinstated the retry burst this PR exists to remove. - The memo's evict-on-throw ordering is correct and subtle: the promise stored is the one returned by
.catch(), andmemo.setruns synchronously first, so a host fault is never left cached as a negative result. Getting that ordering wrong would have converted a transient infra blip into a cached permanent drop for the whole delivery.
Recommended Action
- No Critical issues — the fail-closed guarantee, the resolve path, and the catch-site discrimination all hold at this head.
- Address the Important issue this cycle: make an empty roster take the transient branch at
owner-resolver.ts:319, so a degraded-but-non-throwingagents.listcannot silently drop alerts on the one path that decides every roster-derived permanent refusal. It is a two-line change consistent with the policy the PR already states for itself. - Consider the three Suggestions opportunistically; adding
severitytoalertmanager.alert.permanent_erroris the one with operational value, given that series is the entire detection surface for a drop Alertmanager no longer reports.
Note on review identity: this PR is authored by app/allyblockcast, so GitHub bars the App from APPROVE on it. This is submitted as a formal COMMENTED review from the App, which is the artifact of record and what the ally gate reads — not a downgrade, and no substitute identity is used. reviewDecision is empty on this PR, so there is no required-review protection needing another approver.
…mpty Ally's review of e3ca494 found that the zero-name-match branch returned `permanent` unconditionally, so an `agents.list` that succeeds but comes back empty permanently dropped the alert at 200. Before this PR that same condition retried, so it was a regression this PR would have introduced — and it sits on the path where every roster-derived permanent refusal is actually decided, since `agents.list` filters terminated agents out and a terminated fallback owner therefore lands here as an unmatched name. The asymmetry is the bug: an `agents.list` that *throws* is handled correctly (memo evicts, plain Error, Alertmanager retries), while the same class of host degradation that returns `[]` without throwing was dropped. An empty roster now takes the survivable branch — the same rule the PR already applies to `unknown_status`, applied to the one input the classified branch cannot see. Deliberately narrow: zero matches against a non-empty roster stays permanent, because that really is a wrong name. Also from the same review: - `alertmanager.alert.permanent_error` carries `severity`. That drop is acknowledged at 200 and so is invisible in Alertmanager's own failure metrics, making this series the entire detection surface for it; it could not answer "did we drop a critical?" without a join. - Corrected the `invalid_org_chain` comment: it is transient under the survivable-direction rule, not because it self-clears — `terminated_ancestor` and `cycle` need a human roster edit. - Noted that the `terminated` map entry is unreachable from this resolver in production, so its test row pins the map in isolation rather than the live route. Tests: empty-roster cases at both the resolver and worker layers, plus a roster-driven permanent drop (previously every permanent-drop test used unset config, which returns before `agents.list` is called). The roster-absent test is a control — it passes before and after, pinning that the carve-out did not widen into "every zero-match is transient"; mutation-checked with `agents.length >= 0`, which fails 12 tests. Verification: 290/290 plugin tests, `tsc --noEmit` clean. Both new behavioural tests confirmed to fail with the source change reverted. Refs PEN-2581 Signed-off-by: Cto <cto@paperclip.blockcast.net>
Review of
|
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: 8d22335
The one finding carried over from the previous head is fixed, and fixed at the branch that actually decides roster-derived refusals rather than at the one that merely looks like it. Nothing new blocks.
I re-verified the structural safety claim at this head rather than carrying it forward, because the throw site moved (:1573 → :1607): between the severity-floor return at :1515 and the throw, the only operations are findActiveAggregateIssue (read), resolveIssueRoute (pure), and resolveAssigneeUserId (read). No state write, no issue creation, no aggregate claim or fence is held when PermanentAlertError fires, so the 200-drop still strands nothing.
I also checked that the new error class cannot escape its handler: handleFiring has exactly one call site (:2247), inside the per-alert try, and recoverAggregateFiring does not call it. There is no path on which a PermanentAlertError reaches the delivery boundary uncaught.
Prior Findings Dispositioned (1)
- prior:e3ca494 important 1 — fixed —
packages/plugins/paperclip-plugin-alertmanager/src/owner-resolver.ts:294— the empty-roster guard now returns{ refusal: "transient" }before the name-match block, so anagents.listthat degrades by returning[]keeps Alertmanager's retry window instead of dropping the alert at 200. The guard is scoped exactly as recommended:agents.length === 0only, so zero matches against a non-empty roster stayspermanentand the wrong-name case is unchanged. Both layers gained the missing coverage —owner-resolver.test.ts:565pins the empty-roster refusal class, andworker.test.ts:801drives a permanent drop from roster contents rather than unset config, closing the gap where no test at either layer exercised that branch.
Critical Issues (0)
Important Issues (0)
Suggestions (3)
-
[gstack/review]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:2322— addingseveritytoalertmanager.alert.errorchanges the identity of a series that already exists on master, sorate()/increase()over the deploy boundary will see the old series go stale and a new one start rather than one continuous counter. Harmless forsum by (alertname)-style queries, but worth a heads-up if any dashboard or recording rule pins the full label set. I could not enumerate consumers to confirm — GitHub's code-search index returned zero hits for strings I know are in this diff, so it is stale rather than authoritative, and I am flagging this as unverified rather than claiming there are none. -
[native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/webhook-handler.ts:2322— theseveritylabel is the raw value, soCriticalandcriticalbecome distinct series. This matches:1172exactly, which is why it is a suggestion and not a finding: the two series the comment wants joined do join, because both sides derive the label identically. But the severity floor at:1498compares.trim().toLowerCase(), so the codebase already normalizes this value for decisions while emitting it raw for telemetry. If you ever want a cleansum by (severity), normalizing at both metric sites is the cheap moment. -
[gstack/review + native-codex]
packages/plugins/paperclip-plugin-alertmanager/src/owner-resolver.ts:253—invalid_org_chainis classifiedtransient, and the comment is admirably honest that this is the survivable-direction rule rather than a claim that it self-clears. Worth naming the residual cost, though: for thecycleandterminated_ancestorsub-cases the retry burst is guaranteed futile, and because a transient refusal fails the whole delivery, those 15-17 attempts each take the rest of the batch down with them — the exact harm this PR exists to remove, just narrowed to a smaller input set. The resolver genuinely cannot do better today: I confirmedgetAgentOrgChainHealthdistinguishesmissing_manager/cycle/terminated_ancestor(agent-eligibility.ts:184-187), butinvokabilityReasonis typedAgentEligibilityLifecycleReason, which collapses all three into oneinvalid_org_chainvalue. So this is a note for whenever that reason gets finer-grained upstream, not something to fix here.
Strengths
- The fix is placed where the decision actually happens, not where it appears to. The added doc comment at
:230-235records why theterminatedmap entry is unreachable in production, and the test comment atowner-resolver.test.ts:608-616explicitly warns the reader not to mistake that row for coverage of the live path. That is the kind of note that stops a future reader from "simplifying" a guard whose necessity is invisible from the code alone. - The two new worker tests are deliberately paired against each other: same call, same "no match" outcome, opposite refusal class, one input difference. The empty-roster test also asserts the negative (
alertmanager.alert.permanent_errornot written), so a regression that reclassified it would fail loudly rather than silently passing a weaker assertion. - The metric write on the permanent branch is wrapped best-effort with a comment explaining that it is load-bearing there specifically — a metrics outage throwing would surface a metrics error instead of
PermanentAlertError, push the fingerprint, and 502 the delivery, reinstating the retry burst the PR removes. That failure mode is genuinely non-obvious and the code says so. - Fail-closed is preserved throughout: every refusal path asserts
issues.createandstate.setwere not called. The change is to the reporting channel only, never to whether an ownerless issue can be created.
Recommended Action
- No Critical or Important issues — nothing blocks merge.
- Consider the metric-series note before this lands if any dashboard pins the full label set on
alertmanager.alert.error. - Treat the
invalid_org_chainsuggestion as a future follow-up gated on upstream reason granularity, not as work for this PR.
Merge-ready at
|
| signal | value |
|---|---|
review/ally-comment commit status |
success, set 06:51:30Z — "Ally's most recent consolidated-review comment for this head reports no unresolved findings." |
| combined commit status | success (1/1) |
| check runs | 20 total: 19 success, 1 skipped (Storybook visual regression — no UI surface in this diff) |
mergeable_state |
clean, base master @ a66afc8e |
| Ally review at this exact head | #pullrequestreview-5120221020 — 0 Critical, 0 Important, "nothing blocks merge" |
Do not read requested_reviewers as an open review here
The API still lists allyblockcast under requested_reviewers, and that field is structurally un-clearable on this PR: GitHub bars the App from APPROVE on a PR it authored, so Ally's review is submitted as COMMENTED, and a COMMENTED review never clears a review request. Ally says so in each of its five reviews. The gate signal is the review/ally-comment commit status above, not the request field — anyone triaging this from requested_reviewers alone will read an open reviewer that no achievable action can close.
Suggestion 1 is moot by measurement, not by argument
Ally's first suggestion — adding severity to alertmanager.alert.error changes that series' identity across the deploy boundary, so a dashboard or recording rule pinning the full label set would see the old series go stale — was explicitly flagged as unverified, because GitHub code search returned zero hits for strings known to be in this diff and is therefore stale rather than authoritative. That was the right way to flag it.
It can be settled directly. Querying the Prometheus that scrapes paperclip, this run:
list_metrics filter=alertmanager_alert → alertmanager_alerts,
alertmanager_alerts_invalid_total,
alertmanager_alerts_received_total
list_metrics filter=paperclip_plugin → paperclip_plugin_error,
paperclip_plugin_status_collector_last_success_timestamp_seconds
alertmanager.alert.error does not exist as a series. That is the same fact this PR already discloses in its own observability caveat — plugin ctx.metrics.write values are not exported to Prometheus at all until #1605 lands — and it forecloses the concern: a series that is never scraped cannot have a consumer pinning its label set, so there is no continuity to break. Scoped honestly: this is the cluster Prometheus that scrapes paperclip; I did not enumerate other Prometheis, and I did not need the stale code index to answer it.
Suggestions 2 (raw vs. normalized severity) and 3 (invalid_org_chain granularity, gated on an upstream reason split) are left as stated — both are follow-ups, and Ally recommended treating 3 that way.
Merging this does not ship it — stated so nobody reads the merge as a production fix
Production is on ba95edff (2026-09-05T05:39:37Z), 55 commits behind master. #1621 reaches production only on the next approved paperclip-production deploy, which is a human gate. The tracking row (PEN-2581) records the merge as its closing criterion; it should not record it as a production change.
⛔ No credential, token or session material was read or is quoted.
Thinking Path
Linked Issues or Issue Description
Refs PEN-2581 (internal tracker).
Bug. In production, one alert whose fallback owner could not be resolved threw from
handleFiring. Because the per-alert catch treats every throw as transient, the fingerprint was recorded and the entire delivery ended inAlertDeliveryIncompleteError→ HTTP 502.The consequence: a permanent fault was reported through the transient-retry channel. Alertmanager retried the delivery 15–17 times and dropped it anyway, because nothing about a retry can make an unresolvable owner resolvable. Every doomed delivery advanced
alertmanager_notifications_failed_total{reason="serverError"}, so the resulting storm also masked concurrent, genuinely-transient failures that retrying would have fixed — they were indistinguishable from the permanent one in the only signal an operator had.One ownerless alert dark-tiered its whole batch.Corrected (thanks @allyblockcast) — an earlier revision of this description claimed sibling alerts were lost too. That is false, and it mattered, because it was the stated justification for widening a taxonomy BLO-20467 deliberately narrowed. The per-alerttry/catchatwebhook-handler.ts:2275already isolated the batch before this PR: the catch is inside the loop andAlertDeliveryIncompleteErroris thrown only after it completes, so sibling alerts were processed and their issues created on the first attempt even when the delivery reported 502. Confirmed by running it: neutralise the carve-out (const permanent = falseat the per-alert catch), then comment out both the delivery-outcome andpermanent_errorassertions — both, because Vitest aborts a test at its first failing assertion — and theissues.createandalert:state assertions still pass. The production incident did dark-tier every alert, but for an incident-specific reason: withfallbackAgentNameunset, every unmapped alert in the batch took the same throw, so there were no healthy siblings to survive.Refusing to create an ownerless issue is correct and is preserved. Spending the entire delivery on that refusal is not.
What Changed
PermanentAlertError— a documented class for per-alert faults that no retry can resolve (config/roster facts rather than process state).handleFiringnow raisesPermanentAlertErrorinstead of a bareErrorwhen fallback owner resolution fails.handleWebhookgivesPermanentAlertErrorthe same log + 200 treatment that malformed payloads and permanent policy drops already receive: it logs, records the failure, drops the alert, and deliberately does not add the fingerprint tofailedFingerprints— so the rest of the batch still lands and the delivery is acknowledged.alertmanager.alert.permanent_errormetric, keeping them separable from the transientalertmanager.alert.error.resolveFallbackAgentIdreturnsFallbackOwnerResolution— anagentId, or arefusalclassifiedpermanent/transientfrom theinvokabilityReasoneligibility already computes. The mapping is an exhaustiveRecord, so a new lifecycle reason fails to compile here instead of silently defaulting into a class.unknown_statusis transient by design: misclassifying transient-as-permanent drops an alert, permanent-as-transient only costs a retry burst.alertmanager.owner.fallback_failedmetric write is best-effort. Unguarded, a metrics outage surfaced a metrics error instead ofPermanentAlertError, the per-alert catch classified it transient and pushed the fingerprint, and the delivery 502'd — reinstating the very retry burst this PR removes.invokabilityReasonare both named in the operator-facing warning, so the next occurrence is diagnosable from the log alone.alertmanager.owner.fallback_failedcarries arefusal: permanent|transientlabel, so an operator can tell "dropped, gone until someone edits config" from "still retrying, may yet land" without joining the series againstalertmanager.alert.permanent_error. Two label values, so no meaningful cardinality cost.!== "permanent"rather than=== "transient", so a reason missing from the map at runtime lands on the survivable branch instead of dropping the alert. The exhaustiveRecordmakes that unreachable in-repo, but the plugin resolvesgetAgentWorkEligibilityfrom@paperclipai/sharedat runtime, so a built plugin against a newer host could see a reason its own copy of the map never had.owner-resolver.ts, immediately after theagents.listcall). Raised by @allyblockcast one3ca4948, and it is a regression this PR would otherwise have introduced: the zero-name-match branch returnedpermanentunconditionally, so anagents.listthat succeeds but returns[]— lagging read replica, company-scoping regression, partial read — was dropped at 200 and never retried, while before this PR that same condition retried. The asymmetry is the point: a list that throws is already handled correctly (the memo evicts, a plainErrorpropagates, Alertmanager retries), so the quieter variant of the identical host fault must not fare worse. This is also the branch that decides every roster-derived permanent refusal in production —agents.listfilters terminated agents out, so a terminated fallback owner never reaches the eligibility ladder and lands here as an unmatched name, which in turn makes theterminatedmap entry unreachable from this resolver. Deliberately narrow: zero matches against a non-empty roster stays permanent, because that really is a wrong name; a short-but-non-empty list remains indistinguishable from a typo and is not covered.alertmanager.alert.permanent_errorcarriesseverity(and so does the transientalertmanager.alert.error, so the two stay comparable). A permanent drop is acknowledged at 200 and is therefore invisible in Alertmanager's own failure metrics, which makes this series the entire detection surface for it — without the label it cannot answer "did we drop acritical?" without a join.invalid_org_chainis classifiedtransientunder the survivable-direction rule rather than because it self-clears —getAgentOrgChainHealthalso returns it forterminated_ancestorandcycle, which need a human roster edit. And theterminatedmap entry is annotated as unreachable from this resolver in production, so its test row is understood as a guard on the map rather than proof the ladder handles it live.alert:state row — filtered by state key rather than countingstate.setcalls, because the healthy alert's owner lookup also memoisesowner-by-email:…on the instance scope; a raw count would be 2 and would couple a fail-closed assertion to an unrelated cache. That assertion was mutation-checked (inject a strayalert:write and it fails). Both refusal-class values are now pinned:refusal: "transient"on the paused test alongside"permanent"on the drop test, andunknown_status— the entry encoding the transient-default asymmetry — has its own case. Both were mutation-checked (hardcode"permanent"at the write site, or flip theunknown_statusmapping, and the respective test fails).Scope note. All other throw sites reachable from the per-alert path (
webhook-handler.ts:396, 559, 1201, 1620, 1885, 1907) were reviewed and are genuinely transient — each is fence/aggregate contention that explicitly asks for a retry. They are intentionally left alone.owner-resolver.tsstill contains no throws — it returns aFallbackOwnerResolution, and the single throw site remainswebhook-handler.ts, which decidesPermanentAlertErrorvsErrorfrom that returned class. So exactly one site changes classification.Verification
The new tests were confirmed to fail without the source change. Reverting only
webhook-handler.tsand re-running leaves the test file in place; the two behavioural tests fail:The third is a control and passes both before and after by design — it pins that the carve-out did not widen into "swallow every per-alert failure".
The batch-isolation test puts the ownerless alert first in the payload, so it also pins that the drop does not abort the remainder of the loop.
Risks
Low-to-moderate, and bounded by two properties that were verified rather than assumed:
repeat_interval. It trades a doomed retry burst for a later re-delivery, not for permanent loss.PermanentAlertErroris thrown from exactly one site, and owner resolution runs only on the firing path —handleResolvednever receives the fallback owner memo. A resolve (which does not repeat, and whose loss would leave an issue open forever) can never take this branch. Confirmed by inspection of the single throw site and the single call site that passes the memo.Observability caveat, disclosed rather than hidden: plugin
ctx.metrics.writevalues are not currently exported to Prometheus at all, so the newalertmanager.alert.permanent_errorseries will not be scrapeable until the plugin metric exposition work lands (PR #1605). This is a pre-existing gap that affects the existingalertmanager.alert.errormetric identically — it is not introduced here. The plugin manifest declares only themetrics.writecapability, not an allowlist of metric names, so no manifest change is required.Model Used
Claude Opus 4.5 (
claude-opus-4-5), extended thinking, with tool use and code execution (test runs and typecheck executed locally in-repo).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templateDuplicate-PR search. Open PRs touching this plugin were reviewed: #1605 (metric exposition, complementary — see the observability caveat), #1539 / #1277 (severity routing), #1360 (severity/team routing), and #1351 (uninvokable-agent guard). #1351 is the closest neighbour — it is the change that made ownerless refusal fail closed — but it is
CONFLICTING, last updated 2026-08-13, and contains none of this route. No overlap.