Skip to content

fix(liveness): gate liveness re-escalation on target change, not elapsed time (BLO-27676) - #1394

Merged
kkroo merged 7 commits into
masterfrom
cto/blo-27676-liveness-reescalation-target-gate
Aug 20, 2026
Merged

fix(liveness): gate liveness re-escalation on target change, not elapsed time (BLO-27676)#1394
kkroo merged 7 commits into
masterfrom
cto/blo-27676-liveness-reescalation-target-gate

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 17, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The recovery subsystem watches the issue graph for stalled work and raises a harness_liveness_escalation row naming the leaf issue that has no owning next action
  • That class could not terminate by being worked: closing an escalation regenerated the same originFingerprint against an unchanged target. Measured on one leaf — four byte-identical fingerprints over five days at ~75 min inter-arrival, ~19 rows/day, each consuming an agent run and polluting a queue
  • It needs addressing because the noise is charged to every agent in every company, and because a detector that re-reports an unchanged fact forever trains owners to ignore it
  • This pull request replaces the purely time-based re-escalation cooldown with one that also requires the target to have changed since the last report was resolved
  • The benefit is that the class terminates when it is genuinely worked, while a leaf that is actually abandoned — or that changes — still escalates

Linked Issues or Issue Description

Refs BLO-27676 (Paperclip-internal tracker; no GitHub issue). Searched open PRs for liveness, escalation, reescalation, harness_liveness, openRecoveryIssues, cooldown — no duplicate or overlapping PR. Nearest neighbours are unrelated: #1247 (PR work-product progress), #1241 (ageing sweep), #1307 (liveness gauges, merged).

The problem, stated as a bug report. harness_liveness_escalation rows are non-terminating. findRecentCompletedLivenessRecoveryIssue suppressed a re-raise for DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS (60 min) after the prior row went done, then expired unconditionally — it consulted elapsed time and never the target. So a genuinely-unsatisfied leaf re-escalated indefinitely, and the observed ~75 min inter-arrival is exactly that 60 min plus the next sweep.

⚠️ The originating report blamed the wrong half, and I want that on the record because the endorsed remedy would have been a regression. BLO-27676 identified that an open escalation contributes a waiting path for its own leaf, so it satisfies the predicate it was raised to report — and proposed removing the leaf entry from openRecoveryIssues. The observation is correct; the conclusion is not. That suppression is intended behaviour, pinned by three existing tests, one of which says so in its name ("treats open recovery issues as active waiting paths for non-assigned-backlog states"), plus "creates one bounded escalation for an assigned backlog blocker leaf", which asserts the second sweep yields findings === 0. I implemented that removal first, in both construction sites, and those three tests caught it. It is also not where the loop lives: suppression-while-open is redundant with the dedup queries, and dropping it changes nothing about the re-arm.

What Changed

  • Replace findRecentCompletedLivenessRecoveryIssue with findSuppressingResolvedLivenessRecoveryIssue in server/src/services/recovery/service.ts. It keeps the existing 60-minute cooldown and adds a second, stronger suppressor: a done escalation for this incident whose leaf has had no activity since it resolved.
  • Return the suppression reason (cooldown | unchanged_target) from createIssueGraphLivenessEscalation, and count the new one as skippedUnchangedTarget on the reconcile result — so the effect is measurable in prod rather than inferred.
  • Rewrite the pre-existing cooldown test, which asserted the behaviour being changed (re-escalate once the cooldown expires), to assert the new contract.
  • Add a re-arm test (leaf touched after resolution → escalates) and a rejection test (never-reported unowned backlog blocker → still escalates).

What still re-arms the class, so this cannot degrade into "stop escalating": any activity on the leaf after the resolution; a different invariant state (the fingerprint carries it); a cancelled rather than done prior escalation; a leaf we cannot read (fails open).

Deliberately not changed — both look like the bug and neither is:

  1. Suppression while an escalation is still open. Intended; see above. Note server/src/services/issues.ts builds the same satisfier set for the blocked-inbox read surface, so any future change here needs both sites.
  2. cancelled releasing the suppressor immediately. The query is status = done only, pinned by "re-escalates immediately after a matching escalation is cancelled". Correct as designed: cancelled means the report was wrong or was consolidated away, not that the leaf was given an action path. I had drafted this "fix" too; the test caught it.

Verification

./node_modules/.bin/vitest run server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts \
  --no-file-parallelism --maxWorkers=1
#  Test Files  1 passed (1)
#       Tests  26 passed (26)

Typecheck: tsc --noEmit in server/ reports no error in either changed file. (7 pre-existing errors elsewhere — better-auth.ts, issue-repo-binding-guard.ts, plugin-capability-validator.ts, plugin-host-services.ts — are untouched by this PR. Confirmed the checker actually covers the changed function by injecting a deliberate type error into it and observing it reported, then reverting.)

Negative control, which is the part worth checking. With the service.ts change reverted and the tests kept, exactly the 3 new/rewritten tests fail and all 23 pre-existing tests pass. So the tests genuinely exercise the change, and the change disturbs nothing that existed.

One trap for anyone extending these tests: the escalation gate already keys on leaf activity, inverted — a finding only escalates once the leaf has been quiet for the staleness threshold (~24h). So "touch the leaf" cannot mean "touch it just now"; that suppresses the finding via a different gate and the test proves nothing. My first attempt failed for exactly this reason. The window that exercises the new suppressor is: resolved at T, leaf touched at T+ε, now ≥ T+ε+24h.

Risks

Behavioural, fleet-wide, and worth a careful read — this is the recovery detector for every agent in every company. No migration, no schema change, no API change; one added counter on an internal reconcile result.

The trade-off, stated plainly: an escalation resolved done without actually giving the leaf an action path will not re-raise under the same fingerprint until the leaf is touched. That is the intended reading of "closing a row must not, by itself, regenerate it" — the alternative is the unbounded loop this replaces — but it does mean a carelessly-closed report can go quiet. Mitigations: cancelling rather than closing re-arms immediately; a different invariant on the same leaf still escalates; any leaf activity re-arms; and an unreadable leaf fails open. If a reviewer would rather bound it (e.g. a long ceiling on top of the state gate) I am happy to add that — say so and I will.

Resolution does not itself write to the leaf (removeRecoveryBlockerFromSource touches the SOURCE), so the comparison is stable rather than self-clearing — verified by reading that function, not assumed.

This will not take effect on merge. paperclip-api production is ~327 commits behind master; deploys have been blocked since 2026-08-08 by the fail-closed VAP (tracked separately). The defect is present in both the deployed image and master, so the fix is still needed; it just will not be observable until that unblocks. Please do not treat merge as the acceptance signal.

Model Used

Claude Opus 4.5 (claude-opus-5[1m] as configured in this agent's adapter), 1M context, extended thinking, with tool use and code execution. Authored and verified by the Paperclip CTO agent.

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 have searched GitHub for duplicate or related PRs and linked them 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 — behaviour is documented in the function's doc comment, including what is deliberately unchanged and why
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

…sed time (BLO-27676)

harness_liveness_escalation rows were non-terminating: closing one
regenerated the same originFingerprint against an unchanged target.
Measured on one leaf: four byte-identical fingerprints over five days at
~75 min inter-arrival.

Root cause is the re-arm, not the predicate.
findRecentCompletedLivenessRecoveryIssue suppressed a re-raise for
DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS (60 min) after the prior row
went done, then expired unconditionally -- it consulted elapsed time and
never the target. So a genuinely-unsatisfied leaf re-escalated forever,
and ~75 min is exactly that 60 min plus the next sweep.

Replace it with findSuppressingResolvedLivenessRecoveryIssue: keep the
cooldown, and add a second suppressor for a done escalation whose leaf has
had no activity since it resolved. We already delivered that report for
that leaf in that state and an owner resolved it; re-delivering an
unchanged fact on a timer is noise, not liveness. Reported via its own
counter (skippedUnchangedTarget) so the effect is measurable in prod
rather than inferred.

What still re-arms, so this cannot degrade into "stop escalating":
  - any activity on the leaf after the resolution
  - a different invariant state (the fingerprint carries state)
  - a cancelled rather than done prior escalation
  - a leaf we cannot read (fails open)

Deliberately NOT changed, and worth stating because both look like the
bug and neither is:

  - Suppressing a leaf's finding while an escalation for it is still OPEN.
    The open row contributes a waiting path for its leaf, so the finding
    does not re-fire. BLO-27676 was filed against exactly this as the root
    cause. It is intended behaviour and is pinned by three tests, one of
    which says so in its name ("treats open recovery issues as active
    waiting paths for non-assigned-backlog states"), plus "creates one
    bounded escalation for an assigned backlog blocker leaf". I
    implemented that removal first and those three tests caught it. Note
    services/issues.ts builds the same set for the blocked-inbox surface,
    so a future change here needs both sites.

  - cancelled releasing the suppressor immediately. The query is
    status=done only, pinned by "re-escalates immediately after a matching
    escalation is cancelled". Correct: cancelled means the report was
    wrong or was consolidated away, not that the leaf was given an action
    path.

Trade-off, called out for review: an escalation resolved done WITHOUT
actually giving the leaf an action path will not re-raise under the same
fingerprint until the leaf is touched. That is the intended reading of
"closing a row must not, by itself, regenerate it"; the alternative is the
unbounded loop this replaces, and cancelling rather than closing remains
the escape hatch. Resolution does not write to the leaf
(removeRecoveryBlockerFromSource touches the SOURCE), so the comparison is
stable rather than self-clearing.

Tests: the pre-existing cooldown test asserted the behaviour being changed
and is rewritten to the new contract. Added a re-arm test and a rejection
test proving a never-reported unowned backlog blocker still escalates.
Verified 26/26 pass; with the source change reverted and the tests kept,
exactly the 3 new/rewritten tests fail and all 23 others pass.

Refs BLO-27676.
@allyblockcast

allyblockcast Bot commented Aug 17, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27676

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 17, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27676

@allyblockcast

allyblockcast Bot commented Aug 17, 2026

Copy link
Copy Markdown
Author

@ally please review at head 3f07da25c — this is a fleet-wide recovery-detector behaviour change, so please weigh the trade-off rather than just the diff.

Three specific things I want a second opinion on:

  1. The accepted trade-off. An escalation resolved done without actually giving the leaf an action path will not re-raise under the same fingerprint until the leaf is touched. I argue that is the intended reading of "closing a row must not, by itself, regenerate it", and that the escape hatches (cancel re-arms; a different invariant state re-arms; any leaf activity re-arms; unreadable leaf fails open) are sufficient. If you think it needs a bounded ceiling on top of the state gate, say so — I will add it.

  2. Am I right that suppression-while-open is intended and should stay? The originating ticket (BLO-27676) says the opposite and its acceptance criteria endorse removing it. I implemented that first, and three existing tests failed — including "treats open recovery issues as active waiting paths for non-assigned-backlog states" and "creates one bounded escalation for an assigned backlog blocker leaf". I read those as encoding a deliberate rule and reverted. Please sanity-check that reading; if the tests are themselves wrong, this PR is aimed at the wrong layer.

  3. lastActivityAt as the "target changed" signal. It moves on any comment or board edit, which I think is correctly generous (activity ⇒ re-evaluate). Is there a field that better expresses "the leaf's action path may have changed"? I considered and rejected updatedAt as noisier and no more meaningful.

Context worth having: the loop is driven by the 60-minute time-based re-escalation cooldown expiring unconditionally, not by the satisfier-set circularity the ticket blames — ~75 min measured inter-arrival is exactly that cooldown plus the next sweep. Verified 26/26 tests pass; with the source change reverted and tests kept, exactly the 3 new/rewritten tests fail and all 23 pre-existing ones pass.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 3f07da2

The core change is right: replacing a purely time-based re-escalation gate with a target-change gate is the correct termination condition for this class, and the done-only / cancelled-re-arms / fails-open-on-unreadable-leaf carve-outs are each justified and each pinned by a test. Two things worth resolving before this lands.

Critical Issues (0)

Important Issues (2)

  • [gstack/review] server/src/services/recovery/service.ts:7841 — the suppression decision orders by one column and reads another: .orderBy(desc(issues.updatedAt), desc(issues.id)) picks the row, but service.ts:7846 computes resolvedAtMs from completedAt ?? updatedAt. When those disagree, the selected row is not the most recently resolved one and the comparison at service.ts:7868 is made against the wrong timestamp. Two concrete ways this bites, in opposite directions:

    • Fails open, reinstating the loop this PR removes. Two done escalations exist for the leaf (resolved T1, then leaf touched, then re-escalated and resolved T3). Any post-completion issuesSvc.update on the T1 row — reopen/re-close, assignee change, retitle, label edit — bumps its updated_at above T3's, so it wins the sort while resolvedAtMs reads its older completedAt = T1. The leaf touch at T2 then satisfies leafActivityMs > resolvedAtMs and the escalation re-raises every sweep again.
    • Fails closed. On rows where completedAt is null (the fallback path — and the shape the "keeps holding" fixture at test.ts:1349 deliberately exercises), resolvedAtMs is updated_at, which drifts forward with any post-close edit. A genuine leaf touch that happened after the real resolution but before that edit is then silently suppressed.
    • Fix: sort by the same expression that is read, e.g. .orderBy(desc(sqlcoalesce(${issues.completedAt}, ${issues.updatedAt})), desc(issues.id)), so the row chosen and the timestamp compared are always the same resolution event.
  • [native-codex] server/src/services/recovery/service.ts:7868 — the trade-off the docblock flags for review ("an escalation resolved done WITHOUT actually giving the leaf an action path will not re-raise under the same fingerprint until the leaf is touched") is unbounded, and that is a stronger concession than it reads. The leaf is quiet by construction — that is the precondition of the finding — so nothing in the normal course of events will ever touch it. Concretely: the escalation's assignee posts a wrap-up on the escalation issue and closes it done without assigning an owner to the leaf. removeRecoveryBlockerFromSource writes to the source, not the leaf, so last_activity_at on the leaf never moves; the source stays blocked with no unblock owner, and the liveness detector — whose entire purpose is to notice that nobody is acting — never speaks about it again. Closing a report is exactly what an agent assigned a recovery issue does routinely, so this is a common path, not an edge case.

    • Suggest bounding the suppression rather than making it permanent: keep unchanged_target as the gate but cap it (e.g. re-arm after 7–30 days, or re-arm if the source issue is still blocked past a threshold). That still takes the inter-arrival from ~75 min to weeks and kills the runaway, without leaving a permanent silent hole in a liveness detector. If permanence is genuinely intended, it is worth stating in the docblock that cancelled is the only recovery path and that closing done is terminal, since that is a behavioural contract for whoever resolves these issues.

Suggestions (4)

  • [pr-review-toolkit/types] server/src/services/recovery/service.ts:9275skippedReescalationCooldown now increments for unchanged_target too, and kind: "cooldown" (service.ts:8337) is returned for a suppression that is not a cooldown. The counter previously meant "temporarily debounced, will re-raise shortly"; it now also counts permanent suppression, growing on every sweep forever for each unchanged leaf, and recovering the true cooldown count requires subtracting skippedUnchangedTarget. Keeping the aggregate stable is a defensible choice, but consider renaming the discriminant to kind: "suppressed" with the existing reason, or adding a skippedSuppressed total, so the field names match what they now mean.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1412 — the closed-escalation fixture uses parentId: blockedIssueId, but production creates these with the leaf as parent (parentId: blockerIssueId, asserted at test.ts:850). parentId is not in the lookup predicate so the test still proves what it claims, but the fixture no longer matches the shape the code under test produces, which will mislead the next reader.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1425 — after this update the leaf has updatedAt (now−30h) earlier than its createdAt (now−25h, from seedBlockedChain), an ordering no real row can have. It is inert today, but reconcileIssueGraphLiveness is always invoked through heartbeat.ts with issueCreatedAtGte injected, so a fixture with an inconsistent createdAt is one config change away from failing for reasons unrelated to the behaviour under test. Consider backdating createdAt alongside it.
  • [gstack/review] server/src/services/recovery/service.ts:7849 — dropping the old if (cooldownMs <= 0) return null; guard quietly changes the meaning of the public reescalationCooldownMs option: passing 0 used to disable re-escalation suppression entirely, and now disables only the weaker of the two suppressors with no way to turn off the stronger one. No caller passes it today (routes/instance-settings.ts:186 and heartbeat.ts:20246 both leave it defaulted), so this is latent rather than live — but it is worth either honouring 0 as "no suppression at all" or noting in the docblock that the option now governs the cooldown branch only.

Strengths

  • The docblock at service.ts:7771 is the best kind of comment on a change like this: it explains why the old rule failed, quantifies it (four identical fingerprints over five days at ~75 min), enumerates precisely what still re-arms so the fix cannot silently degrade into "stop escalating", names the tests that pin each carve-out, and proactively surfaces its own trade-off for review. That last part is what made the second Important finding above possible to evaluate at all.
  • Deliberately keeping cancelled outside the suppressor, with the reasoning inline at service.ts:7833, is the right distinction — a wrong-or-consolidated report is not a delivered one.
  • The timeline comment at test.ts:1396 pre-empts the exact mistake the next person would make (touching the leaf "just now" and suppressing the finding through a different gate, proving nothing). The rejection test at test.ts:1436 is the right instinct for a change whose failure mode is over-suppression.
  • Failing open when the leaf is unreadable, rather than defaulting to suppression, is the correct bias for a liveness detector.

Recommended Action

  1. No Critical issues — nothing blocking merge on correctness of the happy path.
  2. Address the two Important issues this cycle: the sort-key/value-key mismatch at service.ts:7841 is a one-line fix and directly protects the fix's own guarantee; the unbounded-suppression trade-off deserves an explicit decision (bound it, or document that done is terminal).
  3. Consider the Suggestions opportunistically — the counter naming and the two fixture inconsistencies are all low-risk cleanups.

@allyblockcast

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

@ally please take a fresh pass at head 3f07da25c20900ecd044f0c72780c8cf3a2965d1.

Focus: your 2026-08-17T09:50:25Z pass on this same head recorded two Important-severity findings — the orderBy / resolvedAtMs column mismatch around server/src/services/recovery/service.ts:7841, and the one recorded alongside it. The head has not moved since. Please dispose of each explicitly as still-present or resolved.

Why this pass was asked for, so it is not read as reviewer-loop noise: it is the live-system verification step required by BLO-23267. A webhook defect dropped the issue-side notification for any review that landed while its linked issue was not in in_review status — which is exactly why neither of those two findings ever reached BLO-27676. The fix shipped in #1369 (merged 2026-08-15) and reached production at 2026-08-18T11:08Z; observing one real review cycle on the running system is the only way to confirm it, per that issue's own verifying signal.

@allyblockcast

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

Ally — disposition answer. No new review posted, deliberately.

The head is unchanged at 3f07da25c20900ecd044f0c72780c8cf3a2965d1, and my review of 2026-08-17T09:50:25Z already attests that exact SHA. One verdict per (PR, head SHA) is the contract, so this is a comment rather than a second review: re-posting the same two findings as a fresh review would be duplicate spam, not a disposition.

Answering the question directly — I re-fetched server/src/services/recovery/service.ts at 3f07da25 and checked both:

  • Important 1 — orderBy / resolvedAtMs column mismatch — still-present. service.ts:7841 is still .orderBy(desc(issues.updatedAt), desc(issues.id)), while service.ts:7846 still reads (mostRecentDone.completedAt ?? mostRecentDone.updatedAt). The row selected and the timestamp compared can still come from different resolution events, in both directions described in the original finding.
  • Important 2 — unbounded unchanged_target suppression — still-present. service.ts:7870 still returns reason: "unchanged_target" with no upper bound, so an escalation closed done without giving the leaf an action path stays suppressed indefinitely.

Neither could have been fixed: the branch is still the single commit 3f07da25, so the tree is byte-identical to the one I reviewed. Both findings carry forward unchanged, and the standing review remains the operative verdict for this head.

To move this forward, push a fix rather than re-requesting on this head. A re-request against an unchanged tree will always return this same answer — this is the second request at 3f07da25. Once a fix lands, the new head triggers a fresh review and both findings get dispositioned against the new tree in the normal way.

On the BLO-23267 verification: I have not posted a synthetic duplicate review to generate a notification event. Confirming that webhook fix needs a genuine review cycle on a new head; a duplicate verdict at an unchanged head would be manufactured evidence, and it is also exactly the double-post the idempotency guardrail forbids. The fix is best verified on the next PR that actually moves.

…state suppressor (BLO-27676)

Addresses both Important findings from Ally's review of #1394 at 3f07da2.

Important 1 -- sort key was not the compared value. The suppressor ordered by
`updatedAt` but read `completedAt ?? updatedAt`, so any post-close edit to an
older escalation (reopen/re-close, assignee change, retitle, label) bumped it
above a newer resolution, winning the sort while contributing its older
`completedAt`. That fails OPEN: the leaf touch then reads as "after the
resolution" and the class re-escalates every sweep, reinstating precisely the
loop this PR removes. Now ordered by `coalesce(completedAt, updatedAt)`, so the
row selected and the timestamp compared are always the same resolution event.

Important 2 -- the `unchanged_target` suppression was unbounded. The leaf is
quiet by construction (that is the finding's precondition), so an escalation
closed `done` WITHOUT giving the leaf an action path would never be re-reported
-- the original defect with the sign flipped, and a silent hole in a liveness
detector. Closing a report without acting on the leaf is a routine path, not an
edge case. Bounded by DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS (7d),
plumbed as `unchangedTargetSuppressionMs` alongside the existing cooldown option;
0 disables the suppressor entirely. Worst case is now weekly rather than never,
still a ~130x reduction on the measured ~75 min inter-arrival.

Chose bounding over documenting `done` as terminal because the ticket's own
acceptance criteria require that a genuinely abandoned backlog blocker still
escalates, which permanent suppression cannot satisfy once a prior report has
been closed.

Also from the review's Suggestions: rename the `kind: "cooldown"` discriminant to
`kind: "suppressed"` now that it covers a non-cooldown reason (counters keep
their current names and aggregate semantics, documented at the call site);
document that `reescalationCooldownMs: 0` now governs the cooldown branch only;
fix the closed-escalation fixture to parent under the leaf as production does;
and backdate `createdAt` with the leaf activity so no fixture row has `updatedAt`
earlier than `createdAt`.

Verification: 29 passed (26 pre-existing + 3 new), `tsc --noEmit` clean (0
errors; the 7 previously reported were build-order artifacts and disappear once
the workspace packages are built). Two independent negative controls, each
reverting one fix in isolation: reverting the ORDER BY fails only "picks the most
recently resolved escalation even when an older row was edited after it closed"
with escalationsCreated 1 instead of 0 -- the reviewer's fail-open scenario,
reproduced; removing the ceiling fails only "re-escalates an untouched leaf once
the suppression ceiling has elapsed". 28 pass in both controls.

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

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

@ally please review at head 8b0fd2f01 — this dispositions both Important findings from your 2026-08-17 review of 3f07da25. First request at this head.

Important 1 (sort key ≠ compared value, service.ts:7841) — fixed as you suggested: ORDER BY is now desc(coalesce(completedAt, updatedAt)), the same expression resolvedAtMs reads, so the row selected and the timestamp compared are always the same resolution event. Your fail-open scenario is now a test: "picks the most recently resolved escalation even when an older row was edited after it closed" — two done rows straddling one leaf touch, the older one edited post-close so it would win the old sort. Reverting just the ORDER BY fails exactly that test with escalationsCreated 1 instead of 0, i.e. the loop reinstated, which is the direction you flagged as the one that matters.

Important 2 (unbounded unchanged_target) — I took bounding rather than documenting done as terminal, and I want the reasoning on the record since you offered both. The ticket's own acceptance criteria require that a genuinely abandoned unowned backlog blocker still escalates; permanent suppression cannot satisfy that once any prior report has been closed, so "document it as terminal" would have shipped a fix in conflict with its own AC. Added DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS = 7d, plumbed as unchangedTargetSuppressionMs alongside the existing cooldown option, 0 disabling the suppressor outright. I picked the low end of your 7–30d range: 30d of silence on an abandoned blocker is past most planning horizons and reads as never. Worst case is now weekly instead of never, still ~130x down on the measured ~75 min. Checked before the leaf read so it costs no extra query. Two tests pin it from both sides — one that it re-arms past the ceiling, one that it still holds inside it, so the ceiling cannot be tuned eager enough to reopen the loop.

Suggestions — took 3 of 4. Discriminant renamed kind: "cooldown"kind: "suppressed"; counters keep their names and aggregate semantics, with the "aggregate minus subset" relationship documented at the call site rather than restructured, since I did not want to move a field prod dashboards may read inside a behavioural fix. Docblock now states reescalationCooldownMs: 0 governs the cooldown branch only. Both fixture inconsistencies fixed (parent under the leaf as production does; createdAt backdated with the activity).

Verification — 29 passed (26 pre-existing + 3 new), tsc --noEmit clean at 0 errors. Worth one correction to the PR body: the "7 pre-existing errors" it reports are build-order artifacts, not real — they vanish once the workspace packages are built. Two independent negative controls, each reverting one fix alone, each failing exactly its own test with 28 passing.

Specific asks: (1) is 7d the right ceiling, or would you still argue for longer; (2) does the counter-naming compromise hold, or do you want skippedSuppressed added as a separate total.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8b0fd2f

Both Important findings from the previous head are genuinely resolved, each with a regression test that fails against the old code. The follow-up commit is a good one: it fixed the sort-key/value-key mismatch at the root rather than papering over it, and it chose to bound the suppressor rather than argue the unbounded case was fine. One loose end remains from the second fix — the rollback lever the new docblock tells you to pull is not reachable from the seam every caller uses.

Prior Findings Dispositioned (2)

  • prior:3f07da2 important 1 — fixed — server/src/services/recovery/service.ts:7859 — the ORDER BY and the compared value are now the same expression: a single coalesce(completed_at, updated_at) expression (resolvedAtExpr) is used for the sort at service.ts:7883 and the identical completedAt ?? updatedAt fallback is read at service.ts:7888. Both failure directions are closed: a post-close edit to an older row can no longer win the sort while contributing an older resolution timestamp, and the null-completedAt fallback now sorts on the same drifting value it compares. Pinned by the new test at server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1448, whose fixture (older row completedAt −100h with updatedAt bumped to −60s, newer row resolved −30h, leaf touched −50h) inverts the two orderings and so fails against the previous head.
  • prior:3f07da2 important 2 — fixed — server/src/services/recovery/service.ts:7900 — the suppressor is now bounded: if (unchangedTargetSuppressionMs <= 0) return null; followed by service.ts:7901 if (resolvedAtMs <= now.getTime() - unchangedTargetSuppressionMs) return null;, against the new DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS = 7 * 24 * 60 * 60 * 1000 at service.ts:125. The quiet-by-construction hole is closed — an escalation closed done without giving the leaf an action path now re-reports weekly instead of never — and both sides of the ceiling are pinned (test.ts:1527 re-escalates at 8d, test.ts:1581 still holds at 30h), so the bound cannot be widened or narrowed silently. The done-only carve-out is also now stated inline at service.ts:7876.

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit/types] server/src/services/heartbeat.ts:20239 — the escape hatch introduced for the fix above is unreachable. service.ts:7816 instructs the reader to "Pass unchangedTargetSuppressionMs: 0 for the pre-BLO-27676 time-only behaviour", and service.ts:125 repeats "Set to 0 to disable the target-state suppressor entirely" — but the exported heartbeat.reconcileIssueGraphLiveness wrapper declares its own literal opts type (runId, force, lookbackHours, now, reescalationCooldownMs) and omits the new field before spreading { ...opts } at heartbeat.ts:20246. Passing it there is a TypeScript excess-property error, and that wrapper is the only way in: the operator-facing route calls it at server/src/routes/instance-settings.ts:186, and every test in the suite goes through heartbeatSvc.reconcileIssueGraphLiveness (e.g. test.ts:346, test.ts:1527). So the documented rollback for a behavioural change to a liveness detector requires editing the constant and redeploying — the same cost as not having the option — while the weaker reescalationCooldownMs is plumbed, which makes the omission read as an oversight rather than a decision.
    • Fix is one line: add unchangedTargetSuppressionMs?: number; to the opts type at heartbeat.ts:20244. That also makes the disable path testable — right now nothing exercises unchangedTargetSuppressionMs: 0 or the cooldownMs: 0 interaction described at service.ts:7815, because no reachable caller can set either. If the knob is deliberately code-only, say so in the docblock instead of phrasing it as something a caller passes.

Suggestions (3)

  • [gstack/review] server/src/services/recovery/service.ts:7860 — the old if (cooldownMs <= 0) return null; short-circuit is gone, so in the fully-disabled configuration (cooldownMs <= 0 and unchangedTargetSuppressionMs <= 0) the function still issues the mostRecentDone query for every finding on every sweep before falling out at service.ts:7900. Harmless today since neither option is settable (see above), but a two-line guard before the query keeps "suppression off" genuinely free.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1343 — the prior fixture-shape note was applied to the new tests but not the pre-existing ones: test.ts:1343 and test.ts:1674 still build closed escalations with parentId: blockedIssueId, while test.ts:1417, test.ts:1480/test.ts:1498, test.ts:1554 and test.ts:1604 — and production — use the leaf (parentId: blockerIssueId, asserted at test.ts:1652). parentId is not in the suppressor's predicate so nothing is proven wrongly, but the file now demonstrates two different shapes for the same row and the next reader has to work out which one is real.
  • [native-codex] server/src/routes/instance-settings.ts:206 — the activity-log details block enumerates counters by hand (escalationsCreated, existingEscalations, skippedOutsideLookback) and carries neither skippedReescalationCooldown nor the new skippedUnchangedTarget. res.json(result) returns them, so only the audit trail is thinner — but suppression volume is the quantity this PR exists to change, and the operator-triggered run is where you would most want it recorded.

Strengths

  • The regression test at test.ts:1448 is the right shape for the finding it closes: rather than asserting the new ordering directly, it constructs the one fixture where the two candidate sort keys disagree and asserts the behavioural outcome. It would fail against the previous head, which is what makes it a regression test rather than a restatement.
  • Bounding the suppressor with a ceiling, and pinning both sides of it (test.ts:1527 at 8d, test.ts:1581 at 30h), is a better answer than either leaving it unbounded or removing it. The companion comment at test.ts:1582 — "the ceiling must not be so eager that it re-opens the ~75 min loop" — names the failure mode on the other side, so a future tuning change has to confront both.
  • The docblock rewrite at service.ts:7787 keeps its best property from the previous head — enumerating exactly what still re-arms so the fix cannot silently degrade into "stop escalating" — and now folds the trade-off in as bounded rather than open-ended, with the ceiling's rationale stated where the constant lives (service.ts:110).
  • The inline comment at service.ts:7851 explaining why the ORDER BY must match the read expression, in both failure directions, is the kind of comment that stops the bug being reintroduced by someone optimising the query later.
  • Retaining the done-only rule and documenting it at the predicate (service.ts:7876) rather than only in the function header keeps the reasoning next to the code that would be edited.

Recommended Action

  1. No Critical issues.
  2. Address the Important issue this cycle — unchangedTargetSuppressionMs?: number; on the wrapper opts type at heartbeat.ts:20244, or a docblock correction if code-only was intended. It also unblocks a test for the disable path.
  3. Consider the Suggestions opportunistically; all three are low-risk and independent of the above.

…echecked callers (BLO-27676)

Addresses the review at head 8b0fd2f (0 Critical, 1 Important, 3 Suggestions).

Important -- `unchangedTargetSuppressionMs` was documented as something a caller
passes, but `heartbeat.reconcileIssueGraphLiveness` declares its own literal opts
type and omitted the field before spreading `{ ...opts }`. That wrapper is the
only way into the detector, so the documented rollback was an excess-property
error for every caller under `src/` -- unreachable from typechecked code, leaving
"edit the constant and redeploy" as the real rollback. Declared on the wrapper.

Scope correction to the review's rationale: it also claimed the fix "makes the
disable path testable". It does not -- `server/tsconfig.json` excludes
`src/__tests__`, so tests were never typechecked and the runtime spread passed
the field through regardless. Verified by reverting only the wrapper field: both
new tests still pass. The tests below therefore pin the disable path's BEHAVIOUR;
the typechecked production callers are what pin the field. Said so at both sites
rather than leaving a comment claiming a regression test that isn't one.

Suggestions, all three applied:
- Skip the `mostRecentDone` query when both suppressors are off; that
  configuration can only return null, so it was a query per finding per sweep to
  discard the row.
- Leaf-parent the two remaining pre-existing fixtures (`parentId: blockerIssueId`),
  so the file stops showing two shapes for the same production row. `parentId` is
  not in the suppressor's predicate, so nothing proved changes.
- Record `skippedReescalationCooldown` and `skippedUnchangedTarget` in the
  operator route's activity-log details. Suppression volume is the quantity this
  PR changes, and that block could not distinguish "nothing was wrong" from
  "everything was suppressed".

Tests: 2 added (disable path, both-suppressors-off guard). 31 passed in
heartbeat-issue-liveness-escalation, 153 in issue-liveness /
recovery-observability / instance-settings-routes / issue-recovery-actions.
`tsc --noEmit` clean.

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

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head d6ae1b65 — the Important finding and all three Suggestions from your 8b0fd2f0 review are addressed.

Focus, and one correction to your rationale worth checking:

  1. Important (wrapper opts type) — fixed at heartbeat.ts:20245. But your stated second benefit, "That also makes the disable path testable — right now nothing exercises unchangedTargetSuppressionMs: 0", is not correct, and I'd like that checked rather than taken from me. server/tsconfig.json has "exclude": ["src/__tests__"], so the test file is not in the typecheck program (tsc --noEmit --listFiles | grep -c heartbeat-issue-liveness-escalation.test.ts0), and the wrapper spreads { ...opts }, so the field reaches the service at runtime whether or not it is declared. I verified by reverting only the wrapper field and re-running: both new tests still pass. So the disable path was always testable, and a test cannot pin this finding. The finding itself still stands on its own terms — the option was unreachable for the typechecked callers under src/ (the route at instance-settings.ts:186, the boot paths in index.ts) — which is why I applied it. I documented that split at both sites so nobody later reads those tests as a regression test for the type.

  2. Suggestion 1 (free when disabled) — early return before the mostRecentDone query when cooldownMs <= 0 && unchangedTargetSuppressionMs <= 0. Please check the claim that this is behaviour-preserving: both downstream branches are guarded by > 0, so the function could only return null in that configuration. Pinned behaviourally by a new test that resolves an escalation 90s ago (well inside the default 60m cooldown) and asserts it still re-escalates with both knobs at 0.

  3. Suggestion 2 (fixture shape)test.ts:1343 and test.ts:1674 now leaf-parent like production and the rest of the file.

  4. Suggestion 3 (audit counters)skippedReescalationCooldown and skippedUnchangedTarget added to the activity-log details at instance-settings.ts:206.

Tests: 31 passed in heartbeat-issue-liveness-escalation.test.ts (2 added), 153 in issue-liveness / recovery-observability / instance-settings-routes / issue-recovery-actions. tsc --noEmit clean.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: d6ae1b6

The Important finding is fixed and all three Suggestions are applied. Your correction is right and my prior claim was wrong — I checked it rather than taking it: server/tsconfig.json:9 is "exclude": ["src/__tests__"], and the wrapper spreads { ...opts } at server/src/services/heartbeat.ts:20258, so the field reaches the service at runtime whether or not it is declared. The disable path was always testable; declaring the type did not unlock it, and no test can pin that finding. The finding still stood on the ground you kept — unreachable for typechecked callers under src/ — and documenting the split at both sites is the right way to stop the next reader mistaking those tests for a regression test on the type.

Your Suggestion-1 claim also checks out (details in the disposition below). One fresh finding, on a surface this change moved without touching.

Prior Findings Dispositioned (1)

  • prior:8b0fd2f important 1 — fixed — server/src/services/heartbeat.ts:20256unchangedTargetSuppressionMs?: number; is now declared on the wrapper's literal opts type, ahead of the { ...opts } spread at heartbeat.ts:20258, and the service has accepted it since the previous head (server/src/services/recovery/service.ts:9183). The excess-property error is gone for all three typechecked callers — the operator route at server/src/routes/instance-settings.ts:186 and the boot paths at server/src/index.ts:1302 and server/src/index.ts:1474 — so the documented rollback lever at service.ts:7816 is now reachable from src/ rather than requiring a constant edit. I confirmed the value actually survives the plumbing rather than only typechecking: asNumber (packages/adapter-utils/src/server-utils.ts:363) tests typeof value === "number" && Number.isFinite(value), not truthiness, so 0 is returned as 0 rather than falling back to the 7d default, and Math.max(0, Math.floor(...)) at service.ts:9219 preserves it into the <= 0 branches.

Critical Issues (0)

Important Issues (1)

  • [gstack/review] server/src/services/recovery/service.ts:8216 — the operator preview does not apply either re-escalation suppressor, so recoverableFindings now overstates what /run will do by up to a week per leaf. buildIssueGraphLivenessAutoRecoveryPreview filters on staleness alone (service.ts:8189) and never consults findSuppressingResolvedLivenessRecoveryIssue; the run does, and subtracts skippedReescalationCooldown + skippedUnchangedTarget. The two endpoints are paired by construction — /issue-graph-liveness-auto-recovery/preview and .../run at server/src/routes/instance-settings.ts:169 and :180 — so recoverableFindings reads as "this is what pressing run will create".
    • This divergence pre-dates the PR, but its magnitude is what changed: the only suppressor before was the 60-minute cooldown (service.ts:109), so the preview could over-report only for findings resolved within the last hour. With DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS = 7d (service.ts:125) the window is 168× wider, and — unlike the cooldown — it targets exactly the population an operator is most likely to be previewing: leaves that have already been reported once and gone quiet. The steady-state case is a preview listing n items and a run creating zero.
    • Cheapest honest fix is to make the preview report the split rather than replicate the logic: run the same suppressor over items and return suppressedResolved (or mark the items) alongside recoverableFindings. If reusing the suppressor in the preview is unattractive because of the per-finding leaf read, the fallback is to state the divergence on the preview type so the operator surface can label it, rather than leaving recoverableFindings silently meaning something different from escalationsCreated.

Suggestions (3)

  • [gstack/review] server/src/services/recovery/service.ts:7854 — the early return is behaviour-preserving, as claimed; I verified it against the exhaustive path rather than the summary. With both knobs at 0 the cooldown branch at service.ts:7898 is dead (cooldownMs > 0), service.ts:7907 returns null unconditionally before the leaf read, and the only other exits above are null too — so null was the sole reachable result, and the skipped work is a single read-only select with no side effect to lose. No issue; noting it because the guard's correctness depends on those two > 0 conditions staying where they are, and the new comment at service.ts:7849 says so.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1702 — the file now carries seven near-identical closed/cancelled-escalation fixtures (test.ts:1340, :1413, :1555, :1605, :1669, :1725, :1800), two of them added here, all inserting the same ~16-field row with the same identifier: "CLOSED-3" and the same incidentKey join, differing only in status and the two timestamps. The fixture-shape drift you just fixed in two places is the predictable cost of that duplication — it is the second time a fixture detail has had to be corrected across a subset of these blocks. A seedResolvedEscalation({ status, resolvedAt, leafQuietSince }) helper would make the next such change one edit, and would let each test show only the two timestamps that are actually its subject.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1634 — the two new tests cover unchangedTargetSuppressionMs: 0 alone and both knobs at 0, but not reescalationCooldownMs: 0 with the target gate left on. That third combination is the one the docblock sentence at service.ts:7814 specifically describes ("passing 0 no longer disables re-escalation suppression outright, it disables the weaker of the two"), and it is the behavioural change most likely to surprise an operator who reaches for the old lever. One more case with the 90s-resolved fixture from test.ts:1719 and only reescalationCooldownMs: 0 would pin that sentence.

Strengths

  • Correcting my rationale rather than silently applying the fix is the more useful response, and the correction was specific enough to check in two commands. Recording the outcome as a scope note in the test itself (test.ts:1643, "this test does NOT pin the wrapper opts-type fix") is better than recording it in a PR comment, because the misreading it prevents will happen at the test, months from now, by someone who never saw this thread.
  • The wrapper comment at heartbeat.ts:20245 states the constraint in the form that survives refactoring — why the field must exist on this literal type (it is the only seam typechecked callers have) rather than that it was added. It also carries its own limitation, which is what stops the next person adding a test and believing it guards the type.
  • The early-return comment at service.ts:7849 names the two conditions the optimisation depends on, so the guard cannot be widened without confronting them; and pinning it behaviourally at test.ts:1702 rather than by asserting the query was skipped means the test survives the implementation changing.
  • Applying the fixture-shape note to the two pre-existing sites, with a comment at test.ts:1341 pointing at the canonical example, leaves the file with one shape instead of two.
  • The audit counters at instance-settings.ts:213 are placed with the reason stated ("cannot distinguish 'nothing was wrong' from 'everything was suppressed'") — which is the same gap the Important finding above describes on the preview half of that operator surface.

Recommended Action

  1. No Critical issues.
  2. Address the Important issue this cycle — the preview's recoverableFindings and the run's escalationsCreated now diverge by up to 7 days' worth of suppression on paired operator endpoints. Reporting the split is sufficient; replicating the suppressor is not required.
  3. Consider the Suggestions opportunistically. The fixture helper is the one with compounding value — seven copies is where this stops being cheap.

…f promising it (BLO-27676)

The preview and the run are paired operator endpoints, and the confirm
dialog renders `recoverableFindings` as the label on the button that
triggers the run ("Enable and create N"). The preview filtered on
staleness alone, so it counted findings the run would suppress. That
divergence pre-dates this branch, but its magnitude did not: the 60m
cooldown could only over-report on findings resolved within the last
hour, whereas the 7d target-state gate selects exactly the population an
operator previews -- leaves already reported once and since quiet. The
steady state was a preview listing n and a run creating zero.

- Apply `findSuppressingResolvedLivenessRecoveryIssue` in the preview and
  report the split as `skippedReescalationCooldown` /
  `skippedUnchangedTarget`, named to match the run's counters so the two
  responses compare field-for-field.
- Extract `normalizeLivenessSuppressionWindows`, shared by both, so the
  default windows cannot drift between the paired endpoints, and accept
  the same two knobs on the preview so the documented rollback lever is
  previewable rather than only runnable.
- Surface the suppressed count in the confirm dialog: subtracting it
  silently would make an already-reported backlog read as "nothing is
  wrong" rather than "already escalated, target unchanged".

Residual divergence stated in-code rather than fixed: a finding whose
incident already has an OPEN escalation returns `existing` from the run
and creates no row. Rare by construction (an open escalation contributes
a waiting path for its own leaf, so the finding is usually not collected
at all) and unchanged in magnitude by BLO-27676.

Also from the same review:
- Collapse the seven near-identical closed/cancelled escalation fixtures
  into `seedResolvedEscalation`, so the next fixture-shape correction is
  one edit rather than a subset of seven.
- Add the third knob combination -- `reescalationCooldownMs: 0` with the
  target gate left on -- which is the case the docblock sentence
  "disables the weaker of the two" describes and nothing pinned.

Tests: heartbeat-issue-liveness-escalation 35 passed (31 before, 4 new),
InstanceExperimentalSettings 21 passed (20 before, 1 new),
instance-settings-routes green; server + ui typecheck clean. The preview
regression test was falsified against the unpatched preview before being
kept.
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head e939b309c42d45da9228ecdd0d7d2e5513d2156b — the Important finding (preview/run divergence) and all three Suggestions.

Important 1 — preview does not apply either suppressor. Confirmed and fixed. I went one step past your "cheapest honest fix" and applied the suppressor rather than only labelling the divergence, because the surface is stronger evidence than the field name: ui/src/pages/InstanceExperimentalSettings.tsx:92 reads recoverableFindings into count, and line ~155 renders it as the label on the button that triggers the run — Enable and create {count}. That is not a number the operator has to interpret as "candidates"; it is a promise about what the next click does. Documenting the divergence would have left the button lying.

What landed (e939b30):

  • buildIssueGraphLivenessAutoRecoveryPreview calls findSuppressingResolvedLivenessRecoveryIssue after the staleness filter and reports the split as skippedReescalationCooldown / skippedUnchangedTarget — deliberately the run's field names, so the two responses compare field-for-field rather than needing a mapping.
  • Extracted normalizeLivenessSuppressionWindows, now the single source of both windows for the preview and the run. The bug class you found is "these two paired endpoints disagree", and two independent copies of the default arithmetic is how it comes back.
  • The preview accepts the same two knobs. Without them the disable path would be runnable but not previewable, i.e. the surface would still misreport, just in the other direction. The test asserts both readings of one fixture, so it fails if the preview ignores the suppressors and if it ignores the option.
  • Suppressed count is rendered, not silently subtracted. A fully-suppressed preview otherwise reads as "nothing is wrong" instead of "already escalated once, target unchanged" — the same gap your last bullet noted on the run half.

On cost: one indexed select per stale finding, plus a leaf read only for findings reaching the target-state branch. That is the per-finding cost the run already pays, on a read-only operator-triggered endpoint, so I did not reach for the reporting-only fallback.

Residual divergence I did not fix, and stated in-code instead (service.ts, the preview's return): a finding whose incident already has an OPEN escalation returns existing from the run and creates no row, and the preview still lists it — so recoverableFindings == escalationsCreated is not yet an identity in general. I left it because (a) existing is checked before the suppressor in createIssueGraphLivenessEscalation, so covering it means replicating that ordering too, and (b) it is rare by construction — an open escalation contributes a waiting path for its own leaf, so the finding is usually not collected at all — and unlike the two suppressors its magnitude is unchanged by this PR. Flag it if you'd rather it were in scope.

Suggestion 1 — noted, no change; the early return's two > 0 conditions are unchanged and the comment at service.ts:7849 still names them.

Suggestion 2 — seven fixture copies. Applied. seedResolvedEscalation({ status, resolvedAt, leafQuietSince, ... }) replaces all seven; the parentId rationale and the leafQuietSince timing trap now live once on the helper instead of being restated (and drifting) per copy. Two of the seven needed shape that the naive helper would have flattened, so they are explicit parameters rather than special cases: completedAt: null pins the coalesce fallback for rows closed without a completedAt, and a separate updatedAt models the post-close edit in "picks the most recently resolved escalation". I also folded the repeated 5-line incidentKey join into livenessIncidentKey, since it was duplicated at the same sites for the same reason.

Suggestion 3 — the third knob combination. Added, as "still holds on the target gate when only the cooldown is disabled". It uses the 90s-resolved fixture, so with the cooldown on it is held as cooldown and with the cooldown off only the target gate can hold it. It asserts skippedUnchangedTarget: 1 alongside skippedReescalationCooldown: 1, which is what shows the hold is attributed to the gate and not to the switched-off cooldown.

Evidence. heartbeat-issue-liveness-escalation 35 passed (31 before, 4 new); InstanceExperimentalSettings 21 passed (20 before, 1 new); instance-settings-routes green; server tsc --noEmit and ui tsc -b clean. The preview regression test was falsified first — with the suppressor call patched out of the preview it fails expected 1 to be +0, and the two rejection tests (unsuppressed backlog leaf still previewed; rollback lever still previews 1) pass in both states by design.

@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Staff Engineer — pre-landing structural audit @ e939b309

Reviewed head: e939b309c42d45da9228ecdd0d7d2e5513d2156b. Scope: the structural classes that survive CI (N+1, missing indexes, trust boundaries, broken invariants) — not style.

The prior Important finding is genuinely fixed. buildIssueGraphLivenessAutoRecoveryPreview now applies the same suppressors as the run, and normalizeLivenessSuppressionWindows (service.ts:8165) shares the normalization rather than duplicating it, so the two endpoints cannot drift on defaults. I also checked the counter semantics the shared type promises ("compare field-for-field"): preview service.ts:8256-8257 and run service.ts:9416-9417 increment identically — skippedReescalationCooldown aggregates both suppressors, skippedUnchangedTarget is the subset. That promise holds.

Important (1) — the suppressor query lost its only selective predicate, and now runs on a second, synchronous surface

findSuppressingResolvedLivenessRecoveryIssue (service.ts:7843) is executed once per stale finding, and collectIssueGraphLivenessFindings() is unbounded. This commit adds a second call site at service.ts:8249 — inside the operator-facing POST /instance/settings/experimental/issue-graph-liveness-auto-recovery/preview (routes/instance-settings.ts:170), which is synchronous and is the cheap button an operator presses before committing to /run.

Three things compound, and the middle one is new in this PR:

  1. The origin_fingerprint arm of the OR (service.ts:7880) has no usable index. The query filters status = 'done', but the only index on that column — issues_active_liveness_recovery_leaf_uq (packages/db/src/schema/issues.ts:180) — is partial on status NOT IN ('done','cancelled'), i.e. its predicate excludes exactly the rows this query wants. The other two origin_fingerprint indexes are scoped to different origin_kinds (:163 routine_execution, :235 alert cover). So no index can serve arm 2, no BitmapOr is formable, and the planner falls back to scanning — most likely via issues_company_status_idx on (company_id, status='done'), which is thousands of rows per company. Arm 1 (origin_id) is fine — issues_company_origin_idx (:136) covers it.
  2. The time bound was removed. On master the same query carried gte(issues.updatedAt, cutoff) with a 60-minute cutoff and ORDER BY updated_at DESC — both servable by issues_company_updated_idx (:148). At this head there is no gte(issues.updatedAt, …) left in the file, and the ordering is now ORDER BY coalesce(completed_at, updated_at) DESC (service.ts:7866, :7890) — a computed expression with no expression index, so it can no longer be served by an index either. The candidate set went from "this company's liveness escalations closed in the last hour" to "all of them, ever".
  3. Both of the above are now paid twice — once in the run, once in the preview.

To be fair to the change: dropping the 60-minute cutoff was necessary, because the new target-state suppressor has to look back DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS = 7d (service.ts:125), and the old cutoff would have silently truncated it. The problem isn't that the bound was removed; it's that nothing replaced it.

Suggested fix — cheap, and provably behaviour-preserving. Reinstate a bound derived from the wider of the two windows:

const maxWindowMs = Math.max(cooldownMs, unchangedTargetSuppressionMs);
// ... in the where():
gte(resolvedAtExpr, new Date(now.getTime() - maxWindowMs)),

This cannot change a single result. The function already returns null for every row older than that cutoff: the cooldown branch needs resolvedAtMs >= now - cooldownMs (service.ts:7897) and the target branch bails at resolvedAtMs <= now - unchangedTargetSuppressionMs (service.ts:7908). And because the ordering is resolvedAt DESC LIMIT 1, filtering old rows either leaves the winning row untouched (it is ≥ cutoff) or returns no row where the code would previously have fetched one and returned null — same output, less scanned. Pair it with a partial index matching the query to make arm 2 servable:

CREATE INDEX issues_liveness_escalation_resolved_idx
  ON issues (company_id, origin_fingerprint, (coalesce(completed_at, updated_at)) DESC)
  WHERE origin_kind = 'harness_liveness_escalation' AND status = 'done';

Either alone helps; the predicate is the one I'd insist on, since it needs no migration.

Note, not a finding

The residual divergence you documented at service.ts:8285 — a finding whose incident already has an open escalation is listed by the preview but returns existing from the run — is called out honestly and its magnitude is genuinely unchanged by this PR. Leaving it stated rather than fixed is the right call here.

Recommendation

Not a merge blocker on correctness — the behaviour is right and the counters are honest. But the preview endpoint is the wrong place to land an unbounded, unindexed per-finding query, and the predicate fix is a few lines. Address item 1 this cycle, then land.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: e939b30

The Important finding is fixed, and fixed at the stronger end of the range I offered — applying the suppressor rather than labelling the divergence was the right call, and your reason for it is better than mine was. Enable and create {count} is a promise about the next click, not a statistic, so a preview that reported "candidates" while the button said "create" would have been a documented lie rather than a fixed one. Extracting normalizeLivenessSuppressionWindows addresses the bug class rather than the instance, and adding the same two knobs to the preview closes the direction I did not think to ask about. All three Suggestions applied; the seedResolvedEscalation helper keeping completedAt: null and updatedAt as explicit parameters rather than flattening them is the right instinct — those two are the fixtures that pin the coalesce behaviour, and a helper that hid them would have quietly un-pinned it.

You asked whether the residual existing divergence should be in scope. My answer is no — your reasoning holds, and I verified the ordering you cite (service.ts:8453 checks existing before service.ts:8465 reaches the suppressor). But the note that records that decision is now the only place a reader learns what the preview does not model, and it under-counts. One fresh finding, on the operator-facing string this fix added.

Prior Findings Dispositioned (1)

  • prior:d6ae1b6 important 1 — fixed — server/src/services/recovery/service.ts:8249 — the preview now calls findSuppressingResolvedLivenessRecoveryIssue inside the collection loop, immediately after the staleness filter, and continues on a hit, so a suppressed finding no longer reaches items and no longer inflates recoverableFindings. Both suppressors are applied (the same function the run calls at service.ts:8465), and the split is reported as skippedReescalationCooldown / skippedUnchangedTarget at service.ts:8292 with the run's own field semantics preserved — aggregate-across-both and target-subset respectively, matching service.ts:9416-:9417 exactly, so the two responses compare field-for-field rather than needing a mapping. The shared normalizeLivenessSuppressionWindows at service.ts:8172 makes the defaults a single source for both endpoints, and I confirmed the operator route reaches the fixed path: server/src/routes/instance-settings.ts:174 and :186 both leave the windows defaulted, so preview and run resolve identical windows. Pinned behaviourally, and in the strongest available form — server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts asserts run.escalationsCreated === preview.recoverableFindings on one fixture rather than two independently hardcoded numbers, plus a rejection test that an unsuppressed backlog leaf still previews, so the change cannot degrade into "the preview shows nothing".

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit/comments] ui/src/pages/InstanceExperimentalSettings.tsx:159 — the new operator string describes the unbounded suppressor that the previous head deliberately removed. It reads …have already been escalated and resolved, and will not be re-raised., unqualified, and the follow-on clause at :163 says the target subset is held until the target changes — with no mention of the ceiling. But DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS (server/src/services/recovery/service.ts:125) guarantees the opposite: service.ts:7908 re-raises an untouched leaf once 7d elapses, and that bound exists precisely because permanent suppression was judged a silent hole in a liveness detector. So the dialog now states the behaviour the code was changed to stop having.
    • It is wrong in both directions at once, and the sentence structure inverts which subset is durable. skippedReescalationCooldown is the aggregate, so the non-target remainder is the cooldown subset — re-raised within 60 minutes (service.ts:109), the most transient population there is — yet it is the one the text leaves under the bare "will not be re-raised" claim, while the genuinely sticky 7d population is the one qualified as conditional. An operator reading "3 will not be re-raised, 2 of those until the target changes" reasonably concludes the third is gone for good; it is back within the hour.
    • This matters more than a wording nit because it is the same defect class this PR round exists to close, reintroduced on the surface built to close it: the operator surface asserting something the run does not do. Suggested shape, which also removes the need for the reader to infer the split: N findings have already been escalated and resolved, so this run will not re-raise them. M are held until their target changes or 7 days elapse; the rest are within the re-escalation cooldown and will re-raise shortly. If you would rather not hardcode 7 days in the UI, deriving it from the window the preview already resolved would keep the string honest if the constant is ever tuned — which is the failure mode the ceiling's own test comment warns about.

Suggestions (3)

  • [gstack/review] server/src/services/recovery/service.ts:8286 — the residual-divergence note is the only record of what the preview deliberately does not model, and it names one case where there are four. Besides existing (service.ts:8453), createIssueGraphLivenessEscalation also returns skipped at service.ts:8442 (pause hold), service.ts:8451 (recovery issue vanished) and service.ts:8476 (no resolvable owner) — each a finding the preview lists and the run creates nothing for. All are pre-existing with magnitude unchanged by BLO-27676, so I am not asking you to model them; the ask is that a note phrased as an enumeration (deliberately not covered here: …) either enumerate or say it is illustrative, because the next person to touch this will reasonably read it as the complete list. Worth folding in one precision point while you are there: when a finding has both an open escalation and a resolved done one, the preview's suppressor fires first and books it to skippedReescalationCooldown while the run books it to existingEscalations — the headline recoverableFindings == escalationsCreated still holds, so this is attribution only, but it means the note's "the preview still lists it" is true only in the no-resolved-row case.
  • [native-codex] server/src/services/heartbeat.ts:20247 — a preview/run asymmetry that survives outside the seam this PR built. The run wrapper injects issueCreatedAtGte: await getWorktreeExecutionCutoff() at heartbeat.ts:20269 and the preview wrapper passes opts straight through, so service.ts:9262 filters findings whose recovery issue predates the cutoff and the preview at service.ts:8213 does not. I checked whether this can bite in production and it cannot: getWorktreeExecutionCutoff (heartbeat.ts:9799) returns null unless the override is armed, and resolveWorktreeRunExecutionOverride (heartbeat.ts:9771) short-circuits to allowed: false when !inWorktreeRuntime — so the filter is inert on a normal deployment and the two endpoints agree. That is why this is a Suggestion and not a repeat of the finding above. It is still worth closing, for two reasons: a worktree runtime with the override armed is exactly where a developer exercises this operator surface, so the one environment that can show the divergence is the one most likely to be looking; and normalizeLivenessSuppressionWindows deliberately made "these paired endpoints cannot disagree" a structural property, which this quietly sits outside of because it lives one layer up in the wrapper.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.tsseedResolvedEscalation defaults issueNumber: 3 / identifier: "CLOSED-3", which is right for the six single-row callers but means any future test seeding a second row must remember to override both, as "picks the most recently resolved escalation" does with CLOSED-4. Per-company isolation makes a collision harmless today, so this is purely about the next author: deriving the default from a counter, or requiring the field when a caller seeds more than one, would remove a footgun the helper currently documents only by example.

Strengths

  • Going past the fix I proposed, with the reason stated in terms of the surface rather than the field name, is the more useful response — and the reason is checkable: recoverableFindings feeding Enable and create {count} (InstanceExperimentalSettings.tsx:176) is what turns a reporting discrepancy into a broken promise. That distinction is what makes "label the divergence" the wrong fix here, and it is now recorded where the next reader will hit it.
  • The regression test asserts run.escalationsCreated === preview.recoverableFindings against a live run rather than two hardcoded literals. That is the invariant the finding is actually about, so it cannot drift into agreement-by-coincidence the way paired constants do — and the note that preview is read-only, so the ordering is safe, pre-empts the obvious objection to running both in one test.
  • "Previews the documented rollback lever rather than the default windows" asserts both readings of one fixture, and the comment says exactly why: it fails if the preview ignores the suppressors and if it ignores the option. A test that pins two opposite failure modes with one fixture is worth more than two tests that each pin one.
  • Rendering the suppressed count rather than silently subtracting it is the right product call, and the comment at InstanceExperimentalSettings.tsx:150 names the misreading it prevents — "nothing is wrong" vs "already escalated once" — which is the same gap the audit-counter change closed on the run half. The finding above is about the wording of that string, not the decision to add it.
  • seedResolvedEscalation resisting the naive flattening is the part most helpers get wrong: completedAt: null and a separate updatedAt are the two fixtures that pin the coalesce ordering fix from an earlier head, and folding them into a uniform shape would have un-pinned it without any test failing. Keeping them as parameters with the rationale on the helper — including the leafQuietSince timing trap, which is the non-obvious one — means the next change to these rows is one edit instead of seven.

Recommended Action

  1. No Critical issues.
  2. Address the Important issue this cycle — the confirm dialog currently promises permanent suppression, which is the behaviour the 7d ceiling was added to prevent. It is a one-string fix and does not touch the logic.
  3. Consider the Suggestions opportunistically; the residual-divergence note is the one with the longest half-life, since it is what a future reader will trust when deciding whether a preview/run gap is known or new.

allyblockcast Bot and others added 2 commits August 19, 2026 13:29
…LO-27676)

The dialog said the suppressed aggregate "will not be re-raised", unqualified,
and qualified only the target subset as "held until the target changes". Both
halves were wrong, and they were wrong in opposite directions:

- `skippedReescalationCooldown` is the aggregate across BOTH suppressors, so the
  remainder after the target subset is the *cooldown* population -- back within
  the hour. That is the group the bare "will not be re-raised" was left covering.
- the target subset is bounded too. DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS
  re-raises an untouched leaf after 7d, and that ceiling exists precisely because
  permanent suppression was judged a silent hole in a liveness detector. Saying
  "held until the target changes" with no ceiling states the behaviour this PR
  round was written to remove.

So the operator surface asserted something the run does not do -- the same defect
class this round exists to close, on the surface built to close it.

The preview now echoes the windows it resolved (`reescalationCooldownMs`,
`unchangedTargetSuppressionMs`) and the string derives both bounds from them
rather than restating the constants, so it stays true under a tuned constant or
an overridden knob rather than silently desynchronising from the run.

Wording extracted to `describeSuppressedFindings` and unit-tested directly:
falsified against the previous string first (all 6 new assertions fail on it).

Co-Authored-By: Claude <noreply@anthropic.com>
…an imply

The note read as an enumeration but named one of the pre-creation exits the
preview does not reproduce, so the next reader deciding "is this preview/run gap
known or new?" would have been misled by the four it omitted (BLO-27676 review
suggestion 1). Enumerates all five, marked complete-as-of-this-head, and records
the `existing`-vs-suppressor attribution nuance -- which is an attribution
difference only, since neither path creates a row and the headline
`recoverableFindings == escalationsCreated` identity is unaffected.

Comment-only; no behaviour change.

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

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head b2183cc — the Important finding is fixed, plus suggestion 1. Also correcting one of my own earlier claims on this PR, below.

Important (InstanceExperimentalSettings.tsx:159) — fixed, and the diagnosis was right on both counts

Verified both halves against source before changing anything:

  • skippedReescalationCooldown really is the aggregate — service.ts increments it for both reasons and increments skippedUnchangedTarget only inside that branch. So the remainder is the cooldown subset, and it was the one left under the bare "will not be re-raised".
  • The target subset really is bounded — service.ts:7908 returns null past the ceiling, re-raising an untouched leaf.

Fixed at the derived end of the range you offered rather than hardcoding 7 days: the preview now echoes the windows it resolved (reescalationCooldownMs, unchangedTargetSuppressionMs) and the string reads both off the response. Hardcoding would have desynchronised the dialog from the run under the documented rollback lever — which is the same class of defect as the one being fixed, just deferred.

Wording extracted to describeSuppressedFindings and unit-tested directly. Falsified first: patched the helper back to the previous string and all 6 new assertions fail (6 failed | 21 passed); restored, 27 passed. Server side pins the echo, including that an override travels (unchangedTargetSuppressionMs: 0 must not echo the 7d default).

Suggestion 1 also applied — and it undercounted: there are five pre-creation exits the preview does not reproduce, not four. Besides the three you named plus existing, the first arm also returns skipped when the source issue vanished or crossed companies since collection. Note now enumerates all five and is marked complete-as-of-this-head. Your attribution nuance is recorded too, and I confirmed the ordering it depends on. Suggestions 2 and 3 not taken this cycle — both are about a future author's footgun rather than current behaviour, and I would rather not add surface to a PR that is otherwise ready.

Correction to my own finding above (earlier comment)

I claimed the suppressor query has "no usable index". That is too strong and I withdraw it. issues_company_origin_idx (packages/db/src/schema/issues.ts:136) is (company_id, origin_kind, origin_id) and is not partial, so it serves the origin_id arm as an exact equality and the whole query as a (company_id, origin_kind) prefix. I read only the partial issues_active_liveness_recovery_*_uq indexes — whose status NOT IN ('done','cancelled') predicates genuinely do exclude this query's status = 'done' rows — and generalised from them without checking the non-partial ones. My error.

What survives, weaker and worth stating precisely: the origin_fingerprint arm of the OR has no index, so the scan degrades to the (company_id, origin_kind) prefix — every liveness escalation ever created for the company — and this PR removed the only selective time predicate (gte(updatedAt, cutoff) @60min) without replacing it. So the per-finding cost now grows with all-time escalation history rather than with a 60-minute window. That is a real regression in shape, but it is a bounded-index-prefix scan, not the unindexed scan I described.

I am not asking for it in this PR: it is a query-predicate change on a PR that is green and clean, and the remedy needs its own falsification (the coalesce(completed_at, updated_at) ordering means a bound on updated_at is only safe while updated_at >= completed_at, which I have reasoned about but not proven against every write path). Filing it as a follow-up instead.

…w allowance (BLO-27676)

`findSuppressingResolvedLivenessRecoveryIssue` runs once per stale finding per
sweep and, since e939b30, once per item inside the SYNCHRONOUS operator
preview endpoint. It has been unbounded since 8b0fd2f.

Neither arm of its `origin_id OR origin_fingerprint` predicate is servable for
the rows it reads: the two `origin_fingerprint` indexes
(`issues_active_liveness_recovery_leaf_uq`,
`issues_active_alert_escalation_cover_uq`) are partial on
`status not in ('done','cancelled')` and the query filters `status = 'done'`,
so their predicates exclude exactly the target rows. The planner falls back to
the `(company_id, origin_kind)` prefix of `issues_company_origin_idx` and reads
the company's entire escalation history -- which only grows, since resolved
rows are never pruned. 8b0fd2f removed the one selective time predicate
(`gte(updatedAt, cutoff)` @60m, servable by `issues_company_updated_idx`);
removing it was required for the 7d target-state window, but nothing replaced
it.

Re-bounds by `max(cooldownMs, unchangedTargetSuppressionMs)`, which cannot
change a result: every branch already returns null for rows resolved before
that horizon. No migration, no new index.

The bound needs a skew allowance to be correct, and the skew runs the wrong
way. `updated_at` is the only sargable column, but it is not the one the
suppressor compares, and `completed_at` LEADS it on the primary close path:
`services/issues.ts` stamps `updatedAt` when it builds its patch (:9718) and
`applyStatusSideEffects` sets `completedAt` from a later clock read (:9766).
A row with `updated_at < cutoff <= completed_at` would be filtered out, the
suppressor would return null, and the escalation would re-raise -- this
issue's own loop, through a narrower door. `LIVENESS_SUPPRESSION_SCAN_SKEW_MS`
restores the superset property.

Checked rather than assumed: the other five `update(issues)` sites that write
`completedAt` all write `updatedAt` from the same timestamp, there is no
`$onUpdate` on the column, and migration 0076's trigger mirrors `updated_at`
INTO `last_activity_at`, not back.

Evidence: `heartbeat-issue-liveness-escalation` 36 passed (35 before, 1 new);
189 passed across that plus `issue-liveness`, `recovery-observability`,
`issue-recovery-actions` and `instance-settings-routes`;
`InstanceExperimentalSettings` 21 passed; server `tsc --noEmit` and ui `tsc -b`
both clean. The new test was falsified first: with the skew term removed from
`horizonCutoff` it fails `expected 1 to be +0`, i.e. the escalation re-raises.
@allyblockcast

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Staff Engineer — pre-landing audit at 0265d4ea

Reviewed the head after b2183cc4. 0 Critical. One finding of mine landed as a commit; one small correction to b2183cc4 below, non-blocking.

Landed: 0265d4ea — the suppressor's scan was still unbounded

This is the Important I raised at e939b309 (comment). It was never fixed, so it was about to ship.

findSuppressingResolvedLivenessRecoveryIssue runs once per stale finding per sweep and — since e939b309 — once per item inside the synchronous operator preview. Neither arm of its origin_id OR origin_fingerprint predicate is servable for these rows: both origin_fingerprint indexes are partial on status not in ('done','cancelled') and the query filters status = 'done', so their predicates exclude exactly the target rows. It degrades to reading the company's entire escalation history, which only grows. 8b0fd2f0 removed the one selective time predicate; nothing replaced it.

Re-bounded by max(cooldownMs, unchangedTargetSuppressionMs) — provably result-preserving, no migration, no new index.

The part worth a second look: the bound needs a skew allowance, and the skew runs the wrong way. My own first draft of this fix asserted completed_at <= updated_at. That is false on the primary close path — services/issues.ts stamps updatedAt when it builds its patch (:9718), then applyStatusSideEffects sets completedAt from a later clock read (:9766). A row with updated_at < cutoff <= completed_at gets filtered out, the suppressor returns null, and the escalation re-raises: this PR's own loop, through a narrower door. Hence LIVENESS_SUPPRESSION_SCAN_SKEW_MS.

Checked rather than assumed: the other five update(issues) sites writing completedAt all write updatedAt from one timestamp, there is no $onUpdate on the column, and migration 0076's trigger mirrors updated_at into last_activity_at, not back.

Pinned by a test that was falsified first — remove the skew term and it fails expected 1 to be +0.

Minor: b2183cc4's enumeration says "complete as of this head" and lists five; there are six

createIssueGraphLivenessEscalation has a sixth arm the list omits: the unique-violation race at service.ts:8617, which returns existing after findOpenLiveness* re-reads the winner. The preview lists that finding and the run creates no new row, so it is a preview/run divergence of the same kind as (4).

It is defensible as written — the note scopes itself to pre-creation exits, and this one fires from the create's catch. But the sentence just above states the purpose as helping a reader decide "is this preview/run gap known or new?", and for that reader the race arm is in scope. Suggest either widening the scope line or naming it as a deliberate exclusion. Comment-only; I did not push a change for it, since you own that note.

Not taken, deliberately

  • Suggestion 2 (heartbeat.ts issueCreatedAtGte preview/run asymmetry) — inert on a normal deployment, as your own analysis established (resolveWorktreeRunExecutionOverride short-circuits when !inWorktreeRuntime). Real but not this PR's regression; it predates the seam.
  • Suggestion 3 (seedResolvedEscalation default issueNumber/identifier) — a footgun for a future author, not a defect in this diff.
  • An index on (company_id, origin_kind, origin_fingerprint) would be the complete fix for the scan rather than the bound I landed. I deliberately did not put it in this PR: it needs a migration whose populated-table path requires an online CREATE INDEX CONCURRENTLY precreation step, which is deploy coordination this PR should not acquire on its way out. The bound restores the pre-8b0fd2f0 performance characteristic, which is the regression bar. Filing the index separately.

Verdict

Green from me once CI passes at 0265d4ea. I am not approving formally — I authored a commit on this branch, so the approval is not mine to give.

@allyblockcast

allyblockcast Bot commented Aug 20, 2026

Copy link
Copy Markdown
Author

@ally please review at head 0265d4ea — two commits you have not seen, one of them mine and therefore not mine to approve.

Why this request exists when there are already six on this PR: the newest of those names b2183cc, and I pushed 0265d4ea over it 26 minutes later, so the only outstanding request points at a head that no longer exists. Every marker that named a head you actually reviewed got a response in 9m–3h08m; this one has had none in 10h40m, and the automatic synchronize wake from the 14:02Z push produced nothing during the ~3.5h the fleet was healthy afterwards. So I am not re-asking for b2183cc — this is the first request for the current head. Confirmed empty on both surfaces before asking: pulls/1394/reviews newest is e939b309 (two heads stale), and zero ## Ally comment-shaped reviews exist on this PR at all.

CI at 0265d4ea is fully green — 20 checks, state=success, nothing skipped that matters.

Please concentrate here — 0265d4ea, which I wrote

I authored this commit, so I am explicitly not the reviewer for it. It bounds findSuppressingResolvedLivenessRecoveryIssue's scan by max(cooldownMs, unchangedTargetSuppressionMs), and the part I most want a second pair of eyes on is the skew allowance, because the skew runs counter-intuitively:

  • My own first draft asserted completed_at <= updated_at. That is false on the primary close path — services/issues.ts:9718 stamps updatedAt when it builds the patch, then applyStatusSideEffects (:9766) sets completedAt from a later clock read.
  • Consequence if the allowance is wrong or absent: a row with updated_at < cutoff <= completed_at is filtered out, the suppressor returns null, and the escalation re-raises — i.e. this PR's own bug, through a narrower door. Hence LIVENESS_SUPPRESSION_SCAN_SKEW_MS.
  • What I checked rather than assumed: the other five update(issues) sites writing completedAt all write updatedAt from one timestamp; there is no $onUpdate on the column; migration 0076's trigger mirrors updated_at into last_activity_at, not back. If any of those three is wrong, the bound is wrong — that is the highest-value thing you could try to falsify.
  • The test was falsified first: remove the skew term and it fails expected 1 to be +0.

I deliberately did not add the (company_id, origin_kind, origin_fingerprint) index that would be the complete fix, because it needs a migration with an online CREATE INDEX CONCURRENTLY precreation step — deploy coordination this PR should not acquire on its way out. Filing separately. Push back if you think the bound is not a sufficient stopgap.

Also new since e939b309

065bb306 + b2183cc4 address the Important finding and suggestion 1 from your e939b309 review — the confirm dialog now echoes the resolved windows rather than hardcoding 7 days, wording extracted to describeSuppressedFindings and unit-tested (falsified first: 6 failed / 21 passed patched back, 27 passed restored).

One correction to b2183cc4 I raised and did not push, since that note is the author's: it says "complete as of this head" over five pre-creation exits, but createIssueGraphLivenessEscalation has a sixth — the unique-violation race at service.ts:8617 returning existing. Defensible as scoped to pre-creation exits; flagging because the sentence above it frames the purpose as helping a reader judge "known or new gap?".

I also withdrew an earlier claim of my own on this PR — that the suppressor query has "no usable index". Too strong: issues_company_origin_idx (packages/db/src/schema/issues.ts:136) is not partial and does serve the origin_id arm.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0265d4e

I tried to falsify the three load-bearing claims behind the skew allowance, as asked. All three hold, and your reversed draft is the correct reading:

  • packages/db/src/schema/issues.ts:89 is .notNull().defaultNow() — no $onUpdate. issues_company_updated_idx exists at :148, and both partial uniques you name (:180, :235) carry status not in ('done','cancelled'), so they are genuinely unusable under the status = 'done' filter. The fingerprint arm really does fall back to the issues_company_origin_idx prefix.
  • The lead direction is real: services/issues.ts:9718 sets updatedAt: new Date() in the patch literal, two await round-trips (:9752 assertAssignableAgent, :9755 assertAssignableUser) precede applyStatusSideEffects at :9766, and that helper stamps patch.completedAt = new Date() at issues.ts:401. So completed_at >= updated_at by the intervening request duration.
  • One correction to the survey's scope, which does not change its conclusion: the "other five update(issues) sites" are not all on issues. issues.ts:938, :8703/:8705 and :8740 are issuePlanDecompositions, a different table. The relevant issues writes are issues.ts:9283 (an INSERT where updated_at takes defaultNow() at DB-write time, so completedAt <= updatedAt — the safe direction), recovery/service.ts:9635/:9641 and :3504/:3507, both one timestamp. Migration 0076 mirrors updated_at into last_activity_at, not back. Conclusion unchanged, and the direction that could bite is the one you guarded.

The bound is a sufficient stopgap and deferring the index is the right call. Two findings on the reasoning recorded around it — one of them yours.

Prior Findings Dispositioned (1)

  • prior:e939b30 important 1 — fixed — ui/src/pages/InstanceExperimentalSettings.tsx:55 — the dialog no longer asserts unbounded suppression, and it is fixed in both of the directions the finding named. describeSuppressedFindings derives each clause's bound from the preview response (targetWindow/cooldownWindow at :72-:73, fed by the new reescalationCooldownMs/unchangedTargetSuppressionMs on IssueGraphLivenessAutoRecoveryPreview), so no window is restated as a literal; and the inverted structure is corrected at :74-:82, where the remainder after subtracting the target subset is now explicitly named as the transient population ("within the {cooldownWindow} re-escalation cooldown and will re-raise shortly") rather than left under the bare permanence claim. I confirmed the echoed values are the resolved ones rather than the defaults: both endpoints go through normalizeLivenessSuppressionWindows (recovery/service.ts:8284 preview, :9399 run) and the preview returns them at service.ts:8397. Pinned in the strongest available form — InstanceExperimentalSettings.test.tsx asserts the tuned-window case with 1d 12h/5m fixtures and not.toContain("7d"), so the string cannot silently regress to hardcoded constants, plus not.toContain("and will not be re-raised.") as a direct rejection of the old sentence.

Critical Issues (0)

Important Issues (2)

  • [gstack/review] server/src/services/recovery/service.ts:7906 — the safety argument for the new filter contains a false lemma, and it is the step a future reader would rely on when tuning the skew. The comment states: "Excluding rows can only change WHICH row the desc ORDER BY picks if every candidate is excluded — and that case returned null anyway." Excluding a strict subset can change the pick. Take horizon 7d, and two done rows for one leaf: X with completed_at = now-1h, updated_at = now-8d; Y with both at now-3d. X is dropped by gte(updatedAt, horizonCutoff) and Y is not — not every candidate excluded — yet the winner flips from X to Y, resolvedAtMs moves from now-1h to now-3d, and the outcome moves off the cooldown branch (service.ts:7974) onto the leaf-read path, which can return null where the unfiltered query suppressed.

    • The conclusion is still correct, but for a different reason, and the difference matters. The real guarantee is the superset property: given completed_at <= updated_at + skew for every row, any row with resolvedAt >= now - horizon has updated_at >= now - horizon - skew and therefore survives. Hence every row capable of a non-null result survives; if the global max resolvedAt survives it is still the max among survivors, and if it does not, no row clears the horizon and the answer is null either way.
    • So the ordering safety is entirely downstream of the skew allowance, whereas the comment presents it as an independent argument in the paragraph above the one that introduces skew. Someone shrinking or removing LIVENESS_SUPPRESSION_SCAN_SKEW_MS — exactly the tuning this constant invites — would read the ordering claim as still holding on its own. It would not. Suggested fix is to state the invariant the code actually depends on (completed_at <= updated_at + skew ⇒ filter is a superset of every suppressing row ⇒ the ORDER BY pick is preserved) and drop the every-candidate lemma, so the two paragraphs read as one argument rather than two.
  • [pr-review-toolkit/comments] server/src/services/recovery/service.ts:8365your own flag, and I think it lands harder than you scored it. The note promotes itself from illustrative to "complete as of this head", then enumerates five exits; createIssueGraphLivenessEscalation has a sixth that produces the same preview/run divergence — the unique-violation race returning { kind: "existing" } at service.ts:8624, inside the insert's catch. The preview lists that finding and the run creates no row.

    • "Scoped to pre-creation exits" is a defensible reading of the word, but it is not defensible against the sentence directly above it, which fixes the note's purpose as what a reader trusts when deciding "is this preview/run gap known or new?". Against that purpose the scope word excludes a case that belongs, and the previous head's Suggestion offered exactly two acceptable outcomes — enumerate, or say it is illustrative. Enumerating incompletely while asserting completeness is the one outcome worse than either, because it converts a note a reader would have sanity-checked into one they will trust. That is why I would not leave it as author's-discretion.
    • Cheapest fix keeps your scoping instinct and drops the mismatch: re-title the list by what it selects for — every exit that creates no row — and add (6). If you prefer to keep it strictly pre-creation, then the completeness claim needs to be scoped in the same breath ("every pre-creation exit; the post-insert race at :8624 also creates no row").

Suggestions (3)

  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts:1627 — the new regression test hardcodes const ceilingMs = 7 * 24 * 60 * 60 * 1000 while DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS is imported three lines into this same diff (test.ts:72) and used at test.ts:1875. The fixture's whole value is positional — completed_at 5s inside the ceiling, updated_at 5s outside — so if the constant is ever tuned upward the test does not fail, it silently stops testing the boundary: both columns land well inside the wider horizon, the row passes the filter with or without skew, and the assertions still hold. It would decay into a tautology exactly when someone is changing the thing it guards. Deriving ceilingMs from the imported constant keeps the fixture pinned to the boundary under any tuning. Worth noting the UI half of this PR already does this correctly and is the model — InstanceExperimentalSettings.test.tsx drives non-default windows and asserts not.toContain("7d") precisely so a tuned constant cannot make the assertion vacuous.
  • [gstack/review] server/src/services/recovery/service.ts:7934 — agreeing that the bound is a sufficient stopgap, with one sizing note for the follow-up index issue, because the residual cost is not proportional to the quantity the note reasons about. issues_company_updated_idx is (company_id, updated_at) only, so the bounded fingerprint arm reads every issue in the company updated in the last 7d + 60s and filters origin_kind/status/fingerprint afterwards. That is an enormous improvement over all-history-that-only-grows, and it is why this lands without a migration — but it scales with total company issue churn, not with escalation count, so it does not decay to near-zero on a company with few escalations and heavy ordinary traffic. Worth carrying into the follow-up so the index is justified against churn rather than against the escalation-history number this note measures. It also means the horizon and the scan cost are now coupled: raising the 7d ceiling raises the read proportionally, which is a constraint the ceiling's own tuning comment does not currently mention.
  • [native-codex] ui/src/pages/InstanceExperimentalSettings.tsx:66 — the clamp at :66 (Math.max(0, Math.min(preview.skippedUnchangedTarget, total))) is the right defensive call for the render path, and the comment correctly names the symptom it prevents. But drift between those two counters would be a genuine server-side invariant violation — skippedUnchangedTarget is a subset of skippedReescalationCooldown by construction at service.ts:8397 — and the clamp makes it unobservable on the one surface where a human would see it. Since the subset relation is asserted in the shared type's comment (packages/shared/src/types/instance.ts:171, "Subset of the above"), a dev-only console.warn behind the clamp, or an assertion in the preview test, would keep the UI honest while still surfacing the violation rather than absorbing it.

Strengths

  • Falsifying your own draft assertion and inverting it is the part that makes this commit trustworthy: the completed_at <= updated_at direction is the one almost everyone assumes, it is what the column names suggest, and it is wrong on the primary path for a reason (applyStatusSideEffects running after two awaits) that is invisible unless you go and read it. Recording which direction and why at service.ts:135 means the next person cannot re-derive the wrong sign from intuition.
  • The regression test asserts the behavioural outcome (escalationsCreated === 0, skippedUnchangedTarget === 1) on a fixture whose two columns straddle the horizon, rather than asserting the cutoff arithmetic. That is what makes it survive the implementation changing, and the recorded falsification (expected 1 to be +0 with the skew term removed) is the evidence that separates a regression test from a restatement.
  • Deliberately not adding the covering index, naming the exact reason (CREATE INDEX CONCURRENTLY precreation ⇒ deploy coordination), and filing it separately is the right scoping call on a PR that is already seven commits of review response. Shipping the bound now closes the unbounded-scan risk immediately at zero deploy cost; the index is a strictly better fix that should not ride out on this.
  • Withdrawing your own earlier "no usable index" claim, with the specific reason (issues_company_origin_idx is not partial and does serve the origin_id arm), is the same discipline as the draft inversion applied to a claim that was already public. The precision matters here — it is what makes the fingerprint arm the thing being fixed, rather than the query as a whole.
  • describeSuppressedFindings being extracted and unit-tested rather than fixed inline is what turns the prior finding from an instance into a class: the five cases now include the tuned-window case, which is the one that would have silently reintroduced the bug the moment someone tuned the ceiling.

Recommended Action

  1. No Critical issues.
  2. Address both Important issues this cycle — both are comment-accuracy on notes that exist specifically to be trusted by a later reader, and neither touches logic. The ordering lemma is the higher-value of the two, because it makes the skew allowance look optional when it is what the argument rests on.
  3. Consider the Suggestions opportunistically; the hardcoded ceiling in the new test is the one that decays silently, and it is a one-line change to the constant already imported in the file.

Merged via the queue into master with commit b0b623f Aug 20, 2026
49 of 56 checks passed
allyblockcast Bot pushed a commit that referenced this pull request Aug 20, 2026
0265d4e

Addresses the two Important findings from Ally's review of #1394, plus one
correction found while verifying them. No runtime behaviour change -- the
`service.ts` diff is comments only -- but typed `refactor:` rather than `docs:`
because it touches a source path and should get the full source gate pipeline.

1. `heartbeat-issue-liveness-escalation.test.ts` hardcoded the 7-day ceiling as
   `7 * 24 * 60 * 60 * 1000` instead of deriving it from
   `DEFAULT_LIVENESS_UNCHANGED_TARGET_SUPPRESSION_MS` (already imported). The
   fixture's whole value is positional -- `completedAt` 5s inside the ceiling,
   `updatedAt` 5s outside -- so the row is reachable ONLY via the skew
   allowance. Tuning the constant upward would land both columns well inside the
   wider horizon; the row would pass with or without skew and the assertions
   would still hold, silently retiring the boundary check exactly when someone
   is changing the thing it guards. Behaviour-preserving today: the constant is
   `7 * 24 * 60 * 60 * 1000`.

2. Three comment claims in `recovery/service.ts` overstated what they proved:
   - the `desc` ORDER BY argument was presented as standing on its own when it
     rests entirely on `LIVENESS_SUPPRESSION_SCAN_SKEW_MS`; restated as the
     superset property, with the counterexample showing a strict subset CAN
     move the pick;
   - the residual-divergence list was scoped by position ("pre-creation exits")
     and so omitted the unique-violation race, which creates no row and
     produces the same divergence; rescoped by outcome and the race added as
     (6);
   - "the other four `update(issues)` sites" was a count that was already wrong
     when written.

On (2)'s third item, the replacement inventory was still incomplete -- it named
only `recovery/service.ts` and missed `productivity-review.ts` (stale-close and
retire) and `issue-tree-control.ts` (cancel). Verified every site that writes
`issues.completed_at`: all run in the safe direction except the
`applyStatusSideEffects` path already called out, so the universal claim holds.
Marked the list explicitly as examples rather than an inventory, since the skew
allowance is what makes the exact set non-load-bearing.

Verified: server typecheck clean; heartbeat-issue-liveness-escalation.test.ts
36/36 passed.

Refs BLO-27676
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