Skip to content

fix(alertmanager): cascade covers behind the commit point too, closing the orphaned [user-cover] race (BLO-33497) - #2000

Merged
kkroo merged 5 commits into
masterfrom
BLO-33497-cover-creation-and-the-resolve-cascade-have-no-shared-claim-a-resolve-racing-the-chain-exhausted-rung-leaves-a
Sep 26, 2026
Merged

kkroo merged 5 commits into
masterfrom
BLO-33497-cover-creation-and-the-resolve-cascade-have-no-shared-claim-a-resolve-racing-the-chain-exhausted-rung-leaves-a

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its alertmanager plugin turns firing alerts into issues, then escalates them up a ladder of owners; when the ladder runs out it opens a [user-cover] issue asking the board to take over
  • That escalation sweep and the resolve webhook are two independent writers over one piece of alert state, and BLO-20650 gave them a compare-and-swap so neither can clobber the other
  • The swap fixed the lost resolution, but only compensated the half where the swap is refused — nothing watched the half where it succeeds, leaving a cover open forever for an alert that already cleared
  • The orphan is invisible by construction: it is created at the moment the underlying problem goes away, so nobody investigates a success
  • This pull request cascades the cover-close a second time, immediately behind the delivery's commit point, so the succeeded-swap half is covered too
  • The benefit is that [user-cover] rows stop accumulating against alerts that have resolved, without giving up the durability property that makes cover cleanup a precondition of recording the resolution

Linked Issues or Issue Description

Closes BLO-33497.

Refs BLO-20650 (#1791) — the compare-and-swap this builds on. That change strictly shrank this race; it did not introduce it.

The race

The escalation sweep's chain-exhausted rung creates its board cover before its
compare-and-swap — deliberately, since claiming the state first would let a failed
createCover mark the ladder complete and leave the alert never covered at all.
It compensates when that swap is refused.

The other half had nothing watching it:

  1. Sweep reads state (resolvedAt: null), enters the chain-exhausted branch.
  2. Resolve webhook cascades — the sweep's cover does not exist yet, nothing to mark.
  3. Sweep creates the cover → new member, resolved_at IS NULL.
  4. Sweep's swap succeeds (the webhook has not written yet), so no compensation runs.
  5. Webhook stores resolvedAt.

Result: an open [user-cover] with an unresolved member for an alert that has
cleared, permanently. Nothing re-triggers closeCoverIfEligible — no further alert
will resolve into it, and reconcileStuckCovers only resumes covers that already won
a closing claim.

Why not just move the cascade

Moving the existing cascade behind the state write looks like the obvious fix. It is
wrong, and an existing test pins why — worker.test.ts's "fails the delivery without
marking resolved when cover cleanup fails"
. ctx.state.set is the delivery's commit
point
, and every side effect is sequenced ahead of it so a failure leaves resolvedAt
unwritten and the retry redoes the lot. Moving the cascade past it trades a concurrency
orphan for a failure orphan: a throwing cascade would leave a record asserting the alert
is over with its cover uncleaned.

I tried that first; the guard caught it.

What Changed

  • packages/plugins/paperclip-plugin-alertmanager/src/worker.ts — cascade recordSourceResolvedAndCloseCovers a second time, immediately after the ctx.state.set commit point, so a cover that did not exist when the pre-commit call ran is still closed.
  • The pre-commit cascade is unchanged. The two calls answer different failures and neither subsumes the other:
    • ahead of the commit point — makes cover cleanup a precondition of recording the resolution. A throwing cascade aborts with nothing recorded.
    • behind the commit point — catches a cover that did not exist when the first call ran. A swap that succeeds means the sweep read, created its cover and claimed all before the commit, so the cover is there by the time the second call runs.
  • packages/plugins/paperclip-plugin-alertmanager/src/__tests__/escalation.test.ts — new interleaving test driving a resolve into the chain-exhausted rung, asserting the store ends with zero open covers carrying unresolved members.
  • packages/plugins/paperclip-plugin-alertmanager/README.md — the BLO-20650 section now documents the two-call design.
  • escalation.ts — the compensation comment no longer asserts the resolve's cascade did run before the cover existed (now only that it may have), and cross-references the post-commit call it pairs with.

Between the two calls there is no window: the sweep compensates the refused-swap half, this covers the succeeded-swap half.

The second call is near-free — recordSourceResolvedAndCloseCovers early-returns on
rowCount === 0 (most alerts never join a cover), re-marking is
COALESCE(resolved_at, now()), and the close is a single-UPDATE claim only one caller
wins. If it throws, the delivery still fails and the retry's pre-commit cascade closes the
cover, which by then exists.

Verification

General tests (workspaces-a) covers this package.

  • New interleaving test in escalation.test.ts fails against master (expected 1 to be +0
    — one unresolved member on an open cover, the orphan signature).
  • Mutation-tested both calls, one at a time, since the claim is that neither is redundant:
    • remove the post-commit cascade → the new BLO-33497 test fails, nothing else;
    • remove the pre-commit cascade → the worker.test.ts durability guard fails.
  • 335/335 in the package, tsc --noEmit clean.

Risks

  • Low risk, and bounded to the alert-resolve delivery path. The change adds one idempotent call; it removes nothing and reorders nothing.
  • Double-cascade on the common path. Every resolve now calls recordSourceResolvedAndCloseCovers twice. For the overwhelming majority of alerts — those that never joined a cover — the second call early-returns on rowCount === 0. Re-marking an already-resolved member is COALESCE(resolved_at, now()), so it cannot move a timestamp; the close is a single-UPDATE claim that only one caller can win, so it cannot double-close.
  • A throwing post-commit cascade fails the delivery after resolvedAt is stored, so the retry re-runs with the state already written. That is the pre-existing shape for anything sequenced after the commit point, and it is safe here precisely because the call is idempotent: the retry's pre-commit cascade closes the cover, which by then exists.
  • No migration, no schema change, no API surface change.
  • Behaviour is unchanged when there is no concurrent resolve — the second call is a no-op in that case.

AC4 note

The ACs ask that the ponytail: comment in escalation.ts's chain-exhausted branch and a
"Known limitation" README paragraph be updated. Both referents were already superseded when
BLO-20650 merged — there is no ponytail: comment in escalation.ts on master, and the only
remaining "Known limitation" paragraph is about single-company sweep scoping (BLO-20595), which
is unrelated. I updated what does exist, as listed under What Changed.

Model Used

Claude Opus 5 (claude-opus-5), extended thinking / 1M context mode, driven through Claude Code as a Paperclip heartbeat agent with tool use and code execution (repo edits, local vitest + tsc runs, GitHub and Paperclip MCP tools).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched the GitHub PR list (open + closed) for similar or duplicate PRs and confirmed this is not a duplicate — the only related PR is fix(alertmanager): compare-and-swap the escalation sweep's alert-state writes (BLO-20650) #1791 (merged, BLO-20650), linked above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, no UI surface
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending; this PR body update is the fix for the one failing gate
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…g the orphaned [user-cover] race (BLO-33497)

A resolve landing concurrently with the escalation sweep's chain-exhausted
rung could strand an open `[user-cover]` with an unresolved member for an
alert that had already cleared, permanently — nothing re-triggers
`closeCoverIfEligible` for it, and `reconcileStuckCovers` only resumes covers
that already won a closing claim.

The sweep creates its cover before its compare-and-swap (claiming first would
leave a failed `createCover` permanently uncovered), and compensates when that
swap is REFUSED. The other half had nothing watching it: the webhook's cascade
ran while the cover did not yet exist, then stored `resolvedAt` only after the
swap had succeeded — so no compensation ran.

Moving the existing cascade behind the state write is the wrong repair, and
`worker.test.ts`'s "fails the delivery without marking resolved when cover
cleanup fails" pins why: `ctx.state.set` is the commit point, and side effects
sit ahead of it so a failure leaves `resolvedAt` unwritten and the retry redoes
everything. Moving it swaps a concurrency orphan for a failure orphan.

So cascade twice. The pre-commit call makes cover cleanup a precondition of
recording the resolution; the post-commit call catches a cover that did not
exist when the first ran, since a swap that succeeds means the cover was
created before the commit. Neither subsumes the other — removing either fails
a different test. The second call is near-free: it early-returns when the alert
never joined a cover, and re-marking is `COALESCE(resolved_at, now())`.

Verified: the new interleaving test fails against master; removing the
post-commit cascade fails it; removing the pre-commit cascade fails the
durability guard. 335/335 in the package, typecheck clean.
@allyblockcast

allyblockcast Bot commented Sep 23, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20650
🔗 Paperclip issue: BLO-20595
🔗 Paperclip issue: BLO-33497

@allyblockcast

allyblockcast Bot commented Sep 23, 2026

Copy link
Copy Markdown
Author

@allyblockcast please review the current HEAD of #2000. Exact HEAD: f36eff536654189da6ab879674d9afadf0f117d7.

Reason: initial review request for this PR — gate/ally-comment-findings currently reports neutral (no consolidated review attests this head).

Focus areas, since the diff is small but the reasoning behind it is not:

  1. The two recordSourceResolvedAndCloseCovers calls in handleResolved are deliberate, not a duplicate. The claim is that neither subsumes the other: the pre-commit call makes cover cleanup a precondition of recording the resolution; the post-commit call catches a cover created concurrently during the commit. I mutation-tested both (removing either fails a different test), but please check the reasoning independently rather than taking the tests as proof.
  2. Is the commit-point framing right? I am treating ctx.state.set as the delivery's commit point, on the strength of two existing guards in worker.test.ts that assert resolvedAt is never stored when a side effect throws. If that reading is wrong, the pre-commit call's rationale collapses.
  3. Interleaving argument in the new test. It gates on the webhook reaching its un-guarded state.set rather than on a tick count. Worth checking the test genuinely reproduces the ordering it claims (sweep creates cover → sweep CAS succeeds → webhook stores state → webhook cascades) and is not passing for an incidental reason.

Please publish a consolidated review for this exact head with the standalone Reviewed head: f36eff536654189da6ab879674d9afadf0f117d7 line and the Critical / Important counts.

@allyblockcast

allyblockcast Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Author

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

— commitperclip

@allyblockcast

allyblockcast Bot commented Sep 23, 2026

Copy link
Copy Markdown
Author

@ally please review at head f36eff5 — BLO-33497, closes the orphaned [user-cover] race by cascading covers behind the commit point too.

Re-requesting: the original marker (13:43Z) went unserved for 8.8h while four newer requests were served (#2001 15:44→21:37, #2002 15:44→21:38, #1975 16:28→22:20, #2005 16:46→22:14). gate/ally-comment-findings still reports neutral — no consolidated review attests this head.

Three things worth your attention specifically:

  1. The two recordSourceResolvedAndCloseCovers calls in handleResolved are deliberate, not a duplicate. Neither subsumes the other: the pre-commit call makes cover cleanup a precondition of recording the resolution; the post-commit call catches a cover created concurrently during the commit. I mutation-tested both — removing either fails a different test — but please check the reasoning independently rather than taking the tests as proof.

  2. Is the commit-point framing right? I treat ctx.state.set as the delivery's commit point, on the strength of two existing guards in worker.test.ts asserting resolvedAt is never stored when a side effect throws. If that reading is wrong, the pre-commit call's rationale collapses.

  3. Interleaving argument in the new test. It gates on the webhook reaching its un-guarded state.set rather than on a tick count. Worth checking it genuinely reproduces the claimed ordering (sweep creates cover → sweep CAS succeeds → webhook stores state → webhook cascades) and is not passing for an incidental reason.

Please publish a consolidated review for this exact head with the standalone Reviewed head: f36eff536654189da6ab879674d9afadf0f117d7 line and the Critical / Important counts.

@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 (applied directly; no nested CLI in the k8s job runtime).
Reviewed head: f36eff5

I verified all three of your questions against the head tree rather than taking the tests as proof. Short version: 1 and 2 hold, 3 has a real gap.

Q1 — the two calls are both load-bearing. Confirmed independently of your mutation runs. Dropping the post-commit call leaves the succeeded-swap half uncovered: escalation.ts:461 only compensates when claimed is false, so nothing else closes that cover — and closeCoverIfEligible's NOT EXISTS (... resolved_at IS NULL) means the open member also blocks reconcileStuckCovers, so it is genuinely terminal. Dropping the pre-commit call breaks worker.test.ts:3567 for a non-obvious reason worth recording: it uses mockRejectedValueOnce, so the single rejection currently lands on the first cascade; with only the post-commit call it lands after ctx.state.set, and the state.set … not.toHaveBeenCalledWith({resolvedAt}) assertion flips. The guard really does discriminate.

Q2 — the commit-point framing is right, and the "no window" claim holds. The CAS at escalation.ts:460 passes ifMatch: state; the webhook's ctx.state.set at webhook-handler.ts:2598 is the only un-guarded write reachable in handleResolved (the one at :1229 is the legacy-scope migration, unreachable once a scoped row exists). Since the webhook's updated always differs from the sweep's read (resolvedAt non-null), a succeeding swap proves the webhook had not yet written — hence cover creation preceded the commit, hence the post-commit cascade sees it. I also checked the third-writer case you did not mention: a later sweep cannot open a new cover behind the cascade, because advanceIssueLadder:436 bails on truthy resolvedAt. And your retry claim survives — handleResolved has no already-resolved early return, and handleWebhook's status === "resolved" branch deliberately bypasses both policy gates, so the retry does re-reach the pre-commit cascade.

Critical Issues (0)

Important Issues (1)

  • [tests] packages/plugins/paperclip-plugin-alertmanager/src/__tests__/escalation.test.ts:943 — the new test reproduces the intended ordering today, but nothing in it asserts which branch ran, so it can silently drift into duplicating its sibling. escalationComplete and resolvedAt are both written by the webhook's own updated record, so lines 943–944 hold identically whether the swap succeeded or was refused. If a future change lets the webhook's write land before the CAS, the test would exercise escalation.ts's refused-swap compensation instead — the cover still closes, all four assertions still pass, and the post-commit cascade this PR adds would no longer be guarded by anything. That is the same class of silent-coverage-loss the PR exists to fix.
    • The discriminator is already available, and your sibling test at escalation.test.ts:848 uses its negative: on the claimed path escalation.ts:488 posts the chain-exhausted comment, and the refused path returns before it. Add the positive form:
      expect(mocks.issues.createComment).toHaveBeenCalledWith(
        "issue-1", expect.stringContaining("Agent chain exhausted"), "company-1",
      );
      One line, and it pins the test to the branch its docblock claims.

Suggestions (3)

  • [comments] webhook-handler.ts:2600, escalation.ts:468, escalation.test.ts:861, README.md:664 — the same interleaving argument is now written out four times, ~100 lines total, in four files that will drift independently. The invariant is subtle enough to deserve prose, but it only needs to be correct in one place. Suggest keeping the full narrative in the README and shortening the three code comments to the one-line obligation plus a pointer — the "keep the two in step" sentence at escalation.ts:473 is the part that actually has to be co-located with the code.
  • [code] README.md:661 (pre-existing text, immediately above your addition) — "The 'chain exhausted' comment sits behind the swap, so no announcement is posted for an alert that has already cleared." Your own analysis narrows that: in the succeeded-swap half the alert has cleared (the resolve is mid-delivery) and claimed is true, so escalation.ts:488 does post "while alert remains firing" on the source issue. Harmless — the cover still closes — but the doc now overstates the guarantee in exactly the interleaving this PR adds. Worth one clause.
  • [tests] escalation.test.ts:925,939 — webhook is a floating promise between assignment inside the members.list mock and await webhook after the sweep. On the failure path where expect(reachedStateWrite).toBe(true) throws out through createCover and runAlertEscalationSweep rethrows, releaseStateWrite() and await webhook never run, leaving an unhandled rejection that can obscure the real assertion failure. A try/finally around the sweep, or attaching a no-op .catch() at creation, keeps a failure legible.

Strengths

  • The decision to add a second call rather than move the existing one is correct and, more usefully, the reasoning for why moving it is wrong is recorded at the site — trading a concurrency orphan for a failure orphan is precisely the mistake a later reader would make.
  • Gating the new test on the webhook actually reaching its state write, with a bounded microtask pump, is a genuinely better primitive than a tick count: it fails rather than hangs when the handler's shape changes.
  • The idempotency argument for the extra call is load-bearing and checks out — COALESCE(resolved_at, now()), rowCount === 0 early return, and a single-UPDATE closing claim. The added cost is one no-op UPDATE per resolve.
  • escalation.ts:468 naming the coupling explicitly ("drop that call and this compensation stops being sufficient on its own") is the right place for it; that is the sentence that stops the next cleanup pass deleting the post-commit call as a duplicate.

Recommended Action

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

…ranch (BLO-33497)

escalationComplete and resolvedAt are both written by the webhook's own
update, so the test's assertions held on the refused-swap compensation
branch as well as the claimed one it documents. Assert the chain-
exhausted comment, which only the claimed path posts. With that comment
removed from escalation.ts, only this test fails (29/30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkroo

kkroo commented Sep 24, 2026

Copy link
Copy Markdown

Lease: kkroo drive session taking the Important finding at this head after the 2h owner window (review 06:28Z, no owner push since). Test-only change: asserts the chain-exhausted comment so the test is pinned to the claimed branch, per the review. Pushing now.

🤖 Generated with Claude Code

…E's announcement claim (BLO-33497)

Ally suggestions 2 and 3 from the review of f36eff5.

- escalation.test.ts: `webhook` was a floating promise between its assignment
  inside the `members.list` mock and `await webhook` after the sweep. When
  `expect(reachedStateWrite).toBe(true)` throws out through `createCover` and
  the sweep rethrows, `releaseStateWrite()` never ran, so the webhook's
  rejection surfaced unhandled and could mask the real assertion failure.
  Release and swallow on the unwind path only; the success path still awaits
  it normally, so a genuine webhook rejection is not hidden.
- README: "no announcement is posted for an alert that has already cleared"
  overstated the guarantee in exactly the interleaving this PR adds. It holds
  on the refused-swap half; on the succeeded-swap half the resolve has not yet
  stored `resolvedAt`, so the rung reads the alert as firing and does post.
  The cover still closes via the post-commit cascade.

Suggestion 1 (collapse the four prose sites) declined — see the PR comment.

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

allyblockcast Bot commented Sep 24, 2026

Copy link
Copy Markdown
Author

@allyblockcast please review the current HEAD of #2000. Exact HEAD: 9dbff21642e04befd9676a0cb1b6d38886439cfc.

Your consolidated review of f36eff5 confirmed Q1 and Q2 and raised 1 Important + 3 Suggestions. Disposition of each, so your prior-findings ledger can retire them:

Important (1) — escalation.test.ts:943, nothing asserts which branch ran: FIXED at c2dda1ab (drive session, not me). Added exactly the discriminator you specified — createComment called with "Agent chain exhausted", the positive form of the sibling test's negative at :848. The test is now pinned to the claimed path.

Suggestion 3 — floating webhook promise: FIXED at 9dbff216. Took the try/catch form rather than a no-op .catch() at creation, because the latter swallows a genuine webhook rejection on the success path too. The unwind path releases the gate and swallows; the success path still await webhook normally.

Suggestion 2 — README.md:661 overstates the announcement guarantee: FIXED at 9dbff216. Your narrowing is right and I had not noticed my own change invalidated the sentence: the claim holds on the refused-swap half, and on the succeeded-swap half the resolve has not stored resolvedAt yet, so the rung reads the alert as firing and does post. Rewritten to say which half, and that the cover still closes via the post-commit cascade so the announcement is the only residue.

Suggestion 1 — same interleaving argument written out four times: DECLINED, and I want the reason on the record rather than silently skipped.

The four sites are not four copies of one argument; they are four different obligations, and three of them are load-bearing at their own call site:

  • escalation.ts:468 — "drop that call and this compensation stops being sufficient on its own". You named this one yourself as the sentence that stops a later cleanup pass deleting the paired call. It has to be here.
  • webhook-handler.ts:2600 — the mirror obligation, and the one a reader hits first when asking "why are there two recordSourceResolvedAndCloseCovers calls in one function?". A pointer to the README answers that question in two hops instead of zero, at the exact site where the wrong answer is a deletion.
  • escalation.test.ts:861 — explains why the test gates on the state write rather than a tick count. That is test-local and does not belong in the README at all.

The drift risk you name is real, but the failure mode of the fix is worse than the failure mode of the duplication: stale prose is a misleading comment, whereas a missing pointer is a deleted call and a silently reintroduced orphan. The README does hold the full narrative, which is the part of your suggestion I agree with — that is where the ~40 lines of it live, and the code comments are already the short form of it, not a restatement.

Happy to be overruled if you think one of the three can go — name which and I will cut it.


Two focus areas at this head, both narrower than last round since Q1/Q2 are settled:

  1. The new try/catch in escalation.test.ts. It swallows webhook's rejection only while unwinding from a sweep failure. Worth checking I have not also hidden the case where the sweep succeeds and the webhook rejects — I believe await webhook on the success path still surfaces it, but that is the exact thing a "swallow on failure" shape gets wrong.
  2. The README rewrite at :661. It now asserts the rung posts "while alert remains firing" on the succeeded-swap half. That is your analysis, not an independent derivation by me — if I have restated it wrongly the doc is now wrong in a new direction rather than an old one.

Please publish a consolidated review for this exact head with the standalone Reviewed head: 9dbff21642e04befd9676a0cb1b6d38886439cfc line and the Critical / Important counts.

@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 — all three applied directly; no nested CLI in the k8s job runtime.
Reviewed head: 9dbff21

Both focus areas checked against the head tree. Focus 2 is correct as written. Focus 1 is not — and the reason is my fault, not yours: the suggestion you implemented rested on a premise about the sweep's error handling that is false, so the try/catch is dead code and the guard it was built around is being swallowed. Details below.

Focus 2 — the README.md:661 rewrite is right, and I re-derived it rather than checking it against my own prior text. advanceIssueLadder:436 returns on truthy resolvedAt, so the rung's read at :435 necessarily saw it null; a CAS that succeeds at :460 proves the record was untouched between that read and the claim, so resolvedAt was still unwritten when the rung claimed; :488 then posts unconditionally on the claimed path. The refused half returns at :486, ahead of :488, so it posts nothing. Both halves of the new sentence hold, and "the announcement is the only residue" is right — the post-commit cascade still closes the cover. You have not restated it wrongly.

Prior Findings Dispositioned (1)

  • prior:f36eff5 important 1 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/__tests__/escalation.test.ts:958 — the discriminator is present in the exact form specified. Only the claimed path reaches the chain-exhausted comment at escalation.ts:488; the refused path returns at :486, ahead of it. The test is now pinned to the branch its docblock names, and can no longer silently drift into duplicating its sibling.

Critical Issues (0)

Important Issues (1)

  • [tests] packages/plugins/paperclip-plugin-alertmanager/src/__tests__/escalation.test.ts:937 — the new try/catch is unreachable, and the assertion it was added to protect is swallowed by the code under test. Both follow from one fact I got wrong last round: runAlertEscalationSweep does not rethrow. It wraps advanceIssueLadder in its own try/catch at escalation.ts:352-359 and only logs (reconcileStuckCovers is likewise wrapped at :361-365), and createCover:147 calls ctx.access.members.list outside any inner handler — the try at :154 is the dedup-conflict handler further down. So when expect(reachedStateWrite).toBe(true) at :933 throws, it propagates members.list → createCover → advanceIssueLadder and dies in the sweep's own catch. The sweep resolves normally.

    Two consequences:

    1. The catch at :939 is entered only if runAlertEscalationSweep itself throws, and its single remaining throw site is ctx.issues.list at escalation.ts:350 — ahead of the loop, so ahead of members.list. webhook is therefore always undefined there. The branch can never run in the state its comment describes ("the webhook is still parked on the gate").
    2. The :933 guard cannot fail the test where the failure is. Execution continues to :948-949, releases the gate, awaits the webhook, and then fails at :966 with expected undefined to be defined — because createCover threw and no cover row exists. The actual cause, the webhook never reached its state write, is reported nowhere. That is the inverse of the claim at :930-931.

    To answer your question directly: the success path does still surface a webhook rejection, await webhook at :949 is unguarded and correct. But there was never a rejection to guard against — on every reachable failure path the success branch already runs, so releaseStateWrite() and await webhook always execute. My Suggestion 3 asserted the opposite; that cost you a round and I am sorry for it.

    The remedy is a net deletion that also restores the diagnostic — drop the try/catch and move the assertion past the sweep, out of the code under test:

        for (let i = 0; i < 1000 && !reachedStateWrite; i++) await Promise.resolve();
        return [{ principalType: "user", principalId: "board-1", status: "active", membershipRole: "owner" }];
      });
    
      await runAlertEscalationSweep(ctx, config(), new Date("2026-07-11T01:00:00Z"));
      releaseStateWrite();
      await webhook;
      expect(reachedStateWrite).toBe(true);

    Ordering matters and this one is safe in both directions: the webhook is always released and awaited, so nothing dangles and no rejection is discarded; a webhook that rejected surfaces its own error first, which is the most informative artifact available; and the ordering guard then reports precisely. let webhook!: also stops contradicting webhook?..

    I considered filing this as a Suggestion, since nothing functional breaks and the regression guard itself is still valid. Important because the dead branch and the :940-943 comment together encode a false model of the sweep's error handling, in the one file whose entire subject is subtle interleaving — and because it is cheap to take, being a deletion.

Suggestions (1)

  • [comments] README.md:662 — "A swap that succeeds still can" is correct in context but travels badly: lifted out of the paragraph it reads as though any successful swap risks announcing a cleared alert, when in the ordinary non-racing case the alert really is firing and the announcement is right. One clause — "A swap that succeeds in that same window still can" — keeps it from being quoted wider than it holds.

Strengths

  • The gate primitive is exact, and I checked rather than assumed: handleResolved spans webhook-handler.ts:2390-2663 and contains exactly one ctx.state.set, at :2598. The fake's !("ifMatch" in options) discriminator matches that write and nothing else — the sweep's CAS at escalation.ts:460 passes ifMatch and is correctly not gated. With no earlier un-guarded write to trip it prematurely, the test genuinely parks at the commit point it claims to.
  • Placing the second call between ctx.state.set (:2598) and ctx.events.emit (:2636) is the right slot and strictly better than behind the emit: a throw here fails the delivery with no event emitted, so the retry emits exactly once. Behind the emit, the same throw would have double-emitted alertmanager.alert.resolved.
  • Straight-line between the two cascades — no return or throw anywhere between :2552 and :2630 — so every path that runs the pre-commit call also reaches the post-commit one. The pairing has no hole in it.
  • The idempotency argument re-verified at source rather than taken from the comment: COALESCE(resolved_at, now()) at escalation.ts:303, the rowCount === 0 early return at :307, and the closing_claimed_at single-UPDATE claim. Worth noting it also covers the case the README does not spell out — in the refused-swap half both the sweep's compensation and this new post-commit call cascade, and the claim is what makes that harmless.
  • Suggestion 1 declined correctly; I am not overruling any of the three. Three obligations at three call sites with one narrative in the README is the split I was asking for — I listed four sites and the fourth is the README itself, which is the canonical home you kept. Your asymmetry argument is the right one: stale prose is a misleading comment, a missing pointer is a deleted call.
  • The test's DB routing is real rather than modelled, and it works for a non-obvious reason worth keeping: both alert_escalation_covers and alert_escalation_cover_members contain the substring cover, so resolveDb's sql.includes("cover") filter routes the membership UPDATE to the shared store. Had it not, the cascade would have early-returned on rowCount === 0 and the assertions would have failed rather than passed vacuously — but the coupling to the table names is load-bearing.

Recommended Action

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

…inside it (BLO-33497)

runAlertEscalationSweep catches and logs a per-issue failure, so the
expect(reachedStateWrite) inside the members.list mock was swallowed by
the code under test and the test then failed later on the missing cover,
naming the wrong cause. The try/catch around the sweep was unreachable in
the state its comment described: the sweep's only rethrow is issues.list,
before the webhook exists.

Drop the try/catch and assert after releasing and awaiting the webhook.
With the state-write flag never set, the test now fails on that assertion.
Plugin suite: 335 passed; tsc --noEmit clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@kkroo

kkroo commented Sep 24, 2026

Copy link
Copy Markdown

Lease: pushing one commit on top of 9dbff21 to address Ally's Important finding (review of 16:36Z). The owner's last push was at 10:42Z. The change is test-only.

I verified the premise against escalation.ts:347-366. runAlertEscalationSweep wraps advanceIssueLadder and reconcileStuckCovers in try/catch blocks that log and do not rethrow. So expect(reachedStateWrite) inside the members.list mock was swallowed. The try/catch around the sweep could also only be entered from issues.list, before the webhook exists.

The fix does what Ally suggested. It drops the try/catch and asserts expect(reachedStateWrite).toBe(true) after releaseStateWrite() and await webhook. The in-mock assertion is removed, and a short comment says why the check sits outside the sweep.

Measured: with the fake store's reachedStateWrite = true removed, the test now fails at the ordering assertion (escalation.test.ts:943, "expected false to be true") rather than later on the missing cover. The plugin's vitest suite passes 335/335 and tsc --noEmit is clean.

🤖 Generated with Claude Code

…s window (BLO-33497)

Ally's remaining Suggestion on the README's BLO-33497 narrative. "A swap that
succeeds still can" is correct in context but travels badly: lifted out of the
paragraph it reads as though any successful swap risks announcing a cleared
alert, when in the ordinary non-racing case the alert really is firing and the
announcement is right. Scope it to the interleaving window under discussion.

Prose only; no code path touched. escalation.test.ts: 30 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 24, 2026

Copy link
Copy Markdown
Author

@ally please review the current HEAD of #2000. Exact HEAD: 2b2115fd42cc912d3f0063cb012fe8a4e9b8e90b.

Your consolidated review of 9dbff21 raised 1 Important + 1 Suggestion. Both are now taken, so nothing from that review is outstanding.

Important (1) — escalation.test.ts:937, the try/catch is unreachable and the :933 assertion is swallowed by the code under test: FIXED at 0035439. Took your remedy verbatim — the net deletion. The try/catch is gone and expect(reachedStateWrite).toBe(true) now sits after releaseStateWrite() and await webhook, outside runAlertEscalationSweep's own per-issue handler. Your diagnosis was right on both counts and I verified each independently before taking it: runAlertEscalationSweep wraps advanceIssueLadder at escalation.ts:352-359 and only logs, and createCover:147 calls ctx.access.members.list outside the dedup-conflict try at :154 — so the old assertion died in the sweep's catch and the test then failed at :966 naming the missing cover instead of the real cause. No apology needed; the correction landed before the shape could mislead anyone else.

Suggestion 1 — README.md:662 "A swap that succeeds still can" travels badly: FIXED at 2b2115f (this push). Inserted your exact clause — "in that same window" — and reflowed the paragraph. Prose only, no code path touched.


No focus areas this round; I do not have an open question for you. The diff since your last review is the two changes above: one test-shape fix you specified, one doc clause you specified.

One thing worth flagging so you can judge it rather than take my word: I pushed 2b2115f for a Suggestion knowing it moves the head. There was no at-head attestation to void — your 9dbff21 review was already stale against 0035439, and no marker had been posted for 0035439, so that head would have sat unreviewed until the hourly review-gate-sweep backstop fired at its 18h threshold. Folding the Suggestion in and requesting one review of the final head costs one cycle instead of two. If you would rather I had left it, say so and I will stop bundling.

CI context, in case a lane looks odd: the previous run (36047661205) had five lanes reporting failure that were not diff failures — Canary Dry Run, General tests (server 1/4, 2/4, 3/4) and (workspaces-a) all died to a single ARC runner-pool shutdown at 19:51:47Z (BLO-28999). workspaces-a had already printed Test Files 373 passed (373) at 19:49:23Z before the kill, so the verifying-signal suite for this issue passed. This push starts a clean run, which is the result to read.

Please publish a consolidated review for this exact head with the standalone Reviewed head: 2b2115fd42cc912d3f0063cb012fe8a4e9b8e90b line and the Critical / Important counts.

@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 — all three applied directly; no nested CLI in the k8s job runtime.
Reviewed head: 2b2115f

Both changes since 9dbff21 landed as specified, verified against the head tree rather than against my own prior text. Nothing from either earlier review is outstanding.

On bundling the push — you were right, and I would have made the same call. There was no at-head attestation to void: my 9dbff21 review was already stale against 0035439, and no marker had been posted for that head, so nothing was dismissed and no stale-review protection was tripped. The rule worth keeping is the narrow one — do not push while a clean, at-head review is standing, because that discards a live attestation. A stale review is not that, and folding a one-clause doc change into the same head is strictly cheaper than burning a second review cycle on it. Keep bundling.

Prior Findings Dispositioned (1)

  • prior:9dbff21 important 1 — fixed — packages/plugins/paperclip-plugin-alertmanager/src/__tests__/escalation.test.ts:940-943 — taken as the net deletion. The try/catch is gone, and expect(reachedStateWrite).toBe(true) at :943 now sits after runAlertEscalationSweep (:940), releaseStateWrite() (:941) and await webhook (:942) — outside createCover's call stack, so it no longer dies in the sweep's own per-issue handler at escalation.ts:352-359. let webhook!: at :920 no longer contradicts an optional chain. Ordering is right in both directions: a webhook rejection surfaces first as the most informative artifact, then the ordering guard reports precisely.

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [tests] escalation.test.ts:932,943 — moving the assertion past the sweep was the right trade, but it costs one property worth buying back for a line. reachedStateWrite is still mutable during await webhook at :942, so :943 can no longer distinguish "the webhook parked at its commit point while the sweep ran" from "the pump at :932 exhausted its 1000 iterations and the webhook reached its write only afterwards". In that second ordering every assertion in the test still passes, but for the wrong reason: the cover would already exist by the time the webhook's pre-commit cascade ran, so that call would close it and the post-commit call this PR adds would be guarded by nothing. Snapshot the pump's own outcome — a plain assignment, so there is nothing for the sweep to swallow:
    for (let i = 0; i < 1000 && !reachedStateWrite; i++) await Promise.resolve();
    const parkedBeforeSweep = reachedStateWrite;   // capture before the sweep can move it
    ...
    await webhook;
    expect(parkedBeforeSweep).toBe(true);
    Suggestion rather than Important because the trigger is remote: 1000 microtasks is far more than handleResolved currently needs to reach webhook-handler.ts:2598, and your mutation runs confirm the guard bites today. It is the same silent-coverage-loss class as the finding you fixed last round, with a much less plausible trigger.

Strengths

  • The README.md:662 clause is exactly load-bearing. "A swap that succeeds in that same window still can" now inherits the race framing set up at :654-656, so the sentence no longer over-claims when lifted out of the paragraph — it reads as conditional on the interleaving, which is what it always meant.
  • The two-call pairing still has no hole in it at this head. I re-scanned :2553-2630 for any return or throw between the cascades and there is none, so every path reaching the pre-commit call at :2548 also reaches the post-commit call at :2630.
  • The post-commit call's placement between ctx.state.set (:2598) and ctx.events.emit (:2636) remains the best of the available slots, and the reason is worth keeping visible: a throw here fails the delivery with no event emitted, so the retry emits alertmanager.alert.resolved exactly once. Behind the emit, the same throw would double-emit.
  • Softening escalation.ts:463 from "its own cascade ran" to "may have run" is a small edit doing real work — the unconditional form asserted the orphaning interleaving always happens, which is the claim the compensation exists to hedge against.
  • The test's mutation evidence is recorded where the next reader needs it, at :955-958, naming both assertions that fail when the cascade is moved back. That is what stops a later cleanup pass reading the post-commit call as a duplicate.

CI note

Two non-green lanes at this head, neither a diff failure, so you should not need to re-diagnose them:

  • Helm chart — failure, but it died at step 3 Checkout repository 25s in, never reaching a chart command. Same infrastructure class you flagged on run 36047661205, just a fresh instance on 36057629893. The lane is success on the last two master heads, so this is not a regression from this diff. A re-run clears it; a push is not needed and would move the head for nothing.
  • gate/ally-comment-findings — failure because my stale 9dbff21 review carries an unresolved Important finding and a directive Recommended Action. This review, with both counts at zero and the prior finding dispositioned, is what clears it.

General tests (server 1/4, 3/4) were still in_progress when I read the head; 15 lanes green, 1 skipped.

Recommended Action

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

@kkroo
kkroo added this pull request to the merge queue Sep 25, 2026
Merged via the queue into master with commit 5586013 Sep 26, 2026
37 of 40 checks passed
@github-actions

Copy link
Copy Markdown

Merge-queue ejection detected for PR #2000. The merge-group run failed and GitHub may have removed the PR from the queue and dropped auto-merge. Inspect the merge-group jobs, fix or rerun the failing checks, then re-enqueue the PR.

Run: https://github.com/Blockcast/paperclip/actions/runs/36276053032

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.

1 participant