Conversation
…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 please review the current HEAD of #2000. Exact HEAD: Reason: initial review request for this PR — Focus areas, since the diff is small but the reasoning behind it is not:
Please publish a consolidated review for this exact head with the standalone |
|
✅ All checks passing — ready for Greptile review and maintainer approval. — commitperclip |
|
@ally please review at head f36eff5 — BLO-33497, closes the orphaned 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). Three things worth your attention specifically:
Please publish a consolidated review for this exact head with the standalone |
There was a problem hiding this comment.
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.escalationCompleteandresolvedAtare both written by the webhook's ownupdatedrecord, 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 exerciseescalation.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:848uses its negative: on the claimed pathescalation.ts:488posts the chain-exhausted comment, and the refused path returns before it. Add the positive form:One line, and it pins the test to the branch its docblock claims.expect(mocks.issues.createComment).toHaveBeenCalledWith( "issue-1", expect.stringContaining("Agent chain exhausted"), "company-1", );
- The discriminator is already available, and your sibling test at
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 atescalation.ts:473is 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) andclaimedis true, soescalation.ts:488does 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—webhookis a floating promise between assignment inside themembers.listmock andawait webhookafter the sweep. On the failure path whereexpect(reachedStateWrite).toBe(true)throws out throughcreateCoverandrunAlertEscalationSweeprethrows,releaseStateWrite()andawait webhooknever run, leaving an unhandled rejection that can obscure the real assertion failure. Atry/finallyaround 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 === 0early return, and a single-UPDATE closing claim. The added cost is one no-op UPDATE per resolve. escalation.ts:468naming 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
- Address Important issues this cycle.
- 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>
|
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 please review the current HEAD of #2000. Exact HEAD: Your consolidated review of Important (1) — Suggestion 3 — floating Suggestion 2 — 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:
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:
Please publish a consolidated review for this exact head with the standalone |
There was a problem hiding this comment.
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 atescalation.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 newtry/catchis 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:runAlertEscalationSweepdoes not rethrow. It wrapsadvanceIssueLadderin its owntry/catchatescalation.ts:352-359and only logs (reconcileStuckCoversis likewise wrapped at:361-365), andcreateCover:147callsctx.access.members.listoutside any inner handler — thetryat:154is the dedup-conflict handler further down. So whenexpect(reachedStateWrite).toBe(true)at:933throws, it propagatesmembers.list→createCover→advanceIssueLadderand dies in the sweep's own catch. The sweep resolves normally.Two consequences:
- The catch at
:939is entered only ifrunAlertEscalationSweepitself throws, and its single remaining throw site isctx.issues.listatescalation.ts:350— ahead of the loop, so ahead ofmembers.list.webhookis therefore alwaysundefinedthere. The branch can never run in the state its comment describes ("the webhook is still parked on the gate"). - The
:933guard cannot fail the test where the failure is. Execution continues to:948-949, releases the gate, awaits the webhook, and then fails at:966withexpected undefined to be defined— becausecreateCoverthrew 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 webhookat:949is unguarded and correct. But there was never a rejection to guard against — on every reachable failure path the success branch already runs, soreleaseStateWrite()andawait webhookalways 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/catchand 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 contradictingwebhook?..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-943comment 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. - The catch at
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:
handleResolvedspanswebhook-handler.ts:2390-2663and contains exactly onectx.state.set, at:2598. The fake's!("ifMatch" in options)discriminator matches that write and nothing else — the sweep's CAS atescalation.ts:460passesifMatchand 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) andctx.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-emittedalertmanager.alert.resolved. - Straight-line between the two cascades — no
returnorthrowanywhere between:2552and: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())atescalation.ts:303, therowCount === 0early return at:307, and theclosing_claimed_atsingle-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_coversandalert_escalation_cover_memberscontain the substringcover, soresolveDb'ssql.includes("cover")filter routes the membershipUPDATEto the shared store. Had it not, the cascade would have early-returned onrowCount === 0and the assertions would have failed rather than passed vacuously — but the coupling to the table names is load-bearing.
Recommended Action
- Address Important issues this cycle.
- 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>
|
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 The fix does what Ally suggested. It drops the try/catch and asserts Measured: with the fake store's 🤖 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>
|
@ally please review the current HEAD of #2000. Exact HEAD: Your consolidated review of Important (1) — Suggestion 1 — 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 CI context, in case a lane looks odd: the previous run ( Please publish a consolidated review for this exact head with the standalone |
There was a problem hiding this comment.
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. Thetry/catchis gone, andexpect(reachedStateWrite).toBe(true)at:943now sits afterrunAlertEscalationSweep(:940),releaseStateWrite()(:941) andawait webhook(:942) — outsidecreateCover's call stack, so it no longer dies in the sweep's own per-issue handler atescalation.ts:352-359.let webhook!:at:920no 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.reachedStateWriteis still mutable duringawait webhookat:942, so:943can no longer distinguish "the webhook parked at its commit point while the sweep ran" from "the pump at:932exhausted 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:Suggestion rather than Important because the trigger is remote: 1000 microtasks is far more thanfor (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);
handleResolvedcurrently needs to reachwebhook-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:662clause 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-2630for anyreturnorthrowbetween the cascades and there is none, so every path reaching the pre-commit call at:2548also reaches the post-commit call at:2630. - The post-commit call's placement between
ctx.state.set(:2598) andctx.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 emitsalertmanager.alert.resolvedexactly once. Behind the emit, the same throw would double-emit. - Softening
escalation.ts:463from "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 run36047661205, just a fresh instance on36057629893. The lane issuccesson 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—failurebecause my stale9dbff21review 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
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
|
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 |
Thinking Path
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
createCovermark 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:
resolvedAt: null), enters the chain-exhausted branch.resolved_at IS NULL.resolvedAt.Result: an open
[user-cover]with an unresolved member for an alert that hascleared, permanently. Nothing re-triggers
closeCoverIfEligible— no further alertwill resolve into it, and
reconcileStuckCoversonly resumes covers that already wona 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 withoutmarking resolved when cover cleanup fails".
ctx.state.setis the delivery's commitpoint, and every side effect is sequenced ahead of it so a failure leaves
resolvedAtunwritten 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— cascaderecordSourceResolvedAndCloseCoversa second time, immediately after thectx.state.setcommit point, so a cover that did not exist when the pre-commit call ran is still closed.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 —
recordSourceResolvedAndCloseCoversearly-returns onrowCount === 0(most alerts never join a cover), re-marking isCOALESCE(resolved_at, now()), and the close is a single-UPDATE claim only one callerwins. 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.escalation.test.tsfails against master (expected 1 to be +0— one unresolved member on an open cover, the orphan signature).
worker.test.tsdurability guard fails.335/335in the package,tsc --noEmitclean.Risks
recordSourceResolvedAndCloseCoverstwice. For the overwhelming majority of alerts — those that never joined a cover — the second call early-returns onrowCount === 0. Re-marking an already-resolved member isCOALESCE(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.resolvedAtis 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.AC4 note
The ACs ask that the
ponytail:comment inescalation.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 inescalation.tson master, and the onlyremaining "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, localvitest+tscruns, GitHub and Paperclip MCP tools).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template