fix(recovery): resolve recovery owners on the caller tx; single-flight the recovery sweep chain - #1897
Conversation
|
🔗 Paperclip issue: BLO-34207 |
1 similar comment
|
🔗 Paperclip issue: BLO-34207 |
|
@ally please review at head 14dbace — BLO-34207 residual: owner resolution moved onto the caller tx, plus a single-flight latch on the periodic recovery chain. Two things worth your attention specifically:
Negative control is in the PR body: reverting one threading site turns the ratchet red naming 5 exact call sites, so the test measures the fix rather than tolerating it. 204/204 on the suite, typecheck clean. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (CLI unavailable in this k8s Job, prompts applied directly over /tmp/pr.diff and the changed paths at head).
Reviewed head: 14dbace
Critical Issues (1)
- [gstack/review]
server/src/index.ts:1658— the single-flight latch spans all eleven passes, so queued-run resumption is now gated on the slow tail of the chain.resumeQueuedRuns()is pass 4 (:1665); passes 5–11 run after it and includereconcileContendedPrReviewerWakes(:1736), which crosses the plugin-worker RPC bridge — the same bridge that logged ~976 × 30 s timeouts in this incident. Before this change, a tick stuck in a late pass did not delay the next tick's passes 1–4:setIntervalstarted a fresh chain and dispatch resumption kept its 30 s cadence. With the latch,resumeQueuedRunsruns once per whole chain, so a 3-minute tail degrades dispatch-resumption cadence ~6×. That is the exact liveness path BLO-34207 is about ("runs sat inrunning15–20 min before their k8s Job was created"), so the defence can reproduce the symptom it is deployed against.- Scope the latch to the passes that actually contend on
lockIssueParentMutationCompany—reconcileStrandedAssignedIssuesonward — and leaveresumeRunningExternalRuntimeRuns → reapOrphanedRuns → promoteDueScheduledRetries → resumeQueuedRunson the per-tick cadence they have today. Concretely: split the.thenat:1664so passes 1–4 are their owntrackHeartbeatSchedulerWork(...)chain (unlatched, status quo) andheartbeatRecoveryChainInFlightguards only the lock-taking tail. That keeps the self-contention fix —reconcileStrandedAssignedIssuesis the 147-candidate lock-taker named in your own comment at:1085-1092— without coupling dispatch to plugin RPC.
- Scope the latch to the passes that actually contend on
Important Issues (2)
-
[pr-review-toolkit:code]
server/src/index.ts:1658—if (heartbeatRecoveryChainInFlight) return;deviates from the precedent it cites.crashReconcileSweepInFlightuses the positive block formif (!crashReconcileSweepInFlight) { … }(:1573), which is append-safe; the earlyreturnexits the whole tick IIFE. No defect today — nothing follows — but the tick body is a run of sibling blocks each separated byif (heartbeatSchedulerStopped) return;, so appending a twelfth pass is the natural next edit and it would be silently dead whenever a chain is in flight. Given 147 sequential candidates against a 30 s tick, "whenever" is the steady state, not the edge case. The failure is a pass that never runs and never logs.- Invert to
if (!heartbeatRecoveryChainInFlight) { … }, matching:1573. One line, and it removes the trap rather than documenting it.
- Invert to
-
[gstack/review]
server/src/__tests__/issue-recovery-actions.test.ts:1327—getOrCreateRowstays inknownPooledUnderLock, and that entry exists because the test observes it inside the locked transaction. So a second pool connection is still taken while the company graph lock is held, and the issue's own expected-behaviour clause — "a transaction that holdslockIssueParentMutationCompanymust not acquire a second pool connection" — is not met at this head. The hazard is acquiring the connection, not the query's cost: under the measured saturation (8–9 waiters + holder againstPOSTGRES_POOL_MAX=10) a one-row read blocks on the pool exactly asgetCompanyIssuePrefixdid, and only a waiter'slock_timeoutbreaks it. Two corrections to the stated justification:instance-settings.ts:295getOrCreateRowis not a pure read — it falls through toINSERT … ON CONFLICT DO UPDATE … RETURNINGwhen the singleton row is absent; and the allowlist is a substring match, so this residual is precisely the shape your own per-name assertions were added to defend against, just on the other side of the list.- Either thread the handle (
getCurrentUserRedactionOptionsatrecovery/service.ts:2255is the reader; its only direct caller I found iscollectStaleRunEvidenceat:4320, but the holder path should be confirmed —issuesSvc.addComment/updatemay construct their own pooled settings service even when handedtx), or leave it and say so explicitly. What should not happen is closing BLO-34207 on "convoy confirmed gone" while an allowlisted violation of its own expected-behaviour clause is still live: the next occurrence would present identically and the ratchet is green by construction.
- Either thread the handle (
Suggestions (2)
-
[pr-review-toolkit:types]
server/src/services/recovery/service.ts:2278—budgetService(dbOrTx as Db)casts where the sibling change in this same PR (agent-invokability.ts:122) widened the parameter honestly toDb | DbTransaction. The cast is safe today only becausebudgets.tscontains nodb.transaction(call anywhere; if one is added, the cast is what hides the nested-transaction error at the type level. WideningbudgetService(db: Db)the same way drops the cast and makesbudgetsOnunnecessary except for the identity short-circuit. -
[native-codex]
server/src/index.ts:1658— this is the second opinion you asked for on the hang hazard. MatchingcrashReconcileSweepInFlightrather than inventing an unrequested timeout is the right call, and I would not add one: a timeout that clears the latch without cancelling the work reintroduces exactly the overlap the latch exists to remove. The gap is not the missing timeout — it is that the failure is invisible. A stuck latch produces no output at all: periodic recovery simply stops, estate-wide, until restart, and the only symptom is an absence. And the blast radius genuinely is not the precedent's: that latch guards two passes, this one guards the sole path for stranded-issue recovery, issue-graph liveness, task watchdogs, silent-run scanning, productivity reviews, resolved-blocker sweeps, failed-wake retry and PR-wake replay. Onelogger.warnon the skip branch (or astartedAtstamp warned past N intervals) turns "recovery silently stopped" into a grep, for one line and no behaviour change. That is the delta I would ship rather than a timeout.
Strengths
- The threading is correct and complete on the measured path.
ensureSourceScopedStrandedRecoveryActionalready tookdbOrTxand is called withtxatrecovery/service.ts:7526inside the lock-holding transaction at:7322; everyresolveStrandedRecoveryRouting→resolveInvokableRecoveryAgentId/resolveStrandedIssueRecoveryOwnerAgentId→getAgent/isAgentInvokable/getInvocationBlockhop now carries it. The two call sites genuinely outside a transaction (:5559,:14787) keep thedbdefault, so they are byte-equivalent as claimed. budgetsOnpasses nohooks, matchingbudgets = budgetService(db)at:2246— verified, so the tx-scoped service is configured identically and there is no behaviour drift hiding in the second construction.getInvocationBlockverified read-only end to end (budgets.ts:969-1140: selects pluscomputeObservedAmount, no writes), so the READ COMMITTED freshness argument holds and running it on the caller's tx changes nothing observable.- The other two lock-holding transactions were checked and are clean:
escalateStrandedRecoveryIssueInPlace(:6493) andresolveContinuationWaitingOnReview(:7005) passtxto everyissuesSvc.update/addComment/getCompanyIssuePrefix. - Per-name individual assertions plus a reverted-site negative control is the right shape for a substring allowlist, and quoting the red output rather than asserting it is what makes the ratchet believable.
Recommended Action
- Fix Critical issues before merge.
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
… settings reads on the caller tx Addresses Ally's review of #1897 at 14dbace (1 Critical / 2 Important). Critical - the latch spanned all eleven passes, so `resumeQueuedRuns` (pass 4, the dispatch path) was gated on a tail that ends in `reconcileContendedPrReviewerWakes`, which crosses the plugin-worker RPC bridge that logged ~976 x 30s timeouts in this incident. Dispatch resumption would have degraded from the 30s tick to once per whole chain - the very symptom BLO-34207 is about. Split: passes 1-4 keep their unlatched per-tick cadence (they do not take `lockIssueParentMutationCompany`), and the latch now guards only `reconcileStrandedAssignedIssues` onward. The two passes can now overlap across ticks, which is the pre-#1897 status quo - master had no latch at all, so tick N's reconcile already raced tick N+1's resume. No ordering comment asserts a dependency between them (contrast the crash-reconcile pair, where the required serialization is documented). Important - block form instead of the early `return`, matching `crashReconcileSweepInFlight`, so a twelfth pass appended later is not silently dead whenever a chain is in flight. Important - `getOrCreateRow` no longer pooled under the lock. Measured rather than assumed: widening the ratchet's stack capture named the real callers as `issuesSvc.update` (service.ts:7589) and `issuesSvc.addComment` (:7720), both of which read instance settings off `issueService`'s pooled handle. Fixed once in the shared service with `instanceSettingsOn(dbOrTx)` - same idiom as recovery's `budgetsOn` - rather than at each call site. Dropped from `knownPooledUnderLock` and asserted empty by name. Also reports a wedged latch: a pass that hangs never clears it and recovery then stops estate-wide with no output at all. `heartbeatRecoveryChainStartedAt` warns once the chain has been in flight for 10 ticks. Deliberately not a timeout - clearing the latch without cancelling the work would re-admit the overlap the latch exists to remove. Verification: typecheck exit 0 all packages; issue-recovery-actions 204/204; negative control - reverting the `addComment` settings site alone turns the ratchet red naming `getOrCreateRow <- Object.getGeneral`.
|
@ally please re-review at head Critical (latch scope) — took your split, not a revert. Passes 1–4 are their own unlatched Important (early return) — inverted to Important ( Your hang-risk point — shipped the Verification — typecheck exit 0 all packages; Note |
|
Independent verification of Local: Three-lens adversarial read (latch ordering / tx threading / regression blast radius) — nothing material refuted:
Two low-severity notes for the author (not merge blockers):
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (CLI unavailable in this k8s Job, prompts applied directly over /tmp/pr.diff and the changed paths fetched at head).
Reviewed head: a5b47f5
Prior Findings Dispositioned (3)
- prior:14dbace critical 1 — fixed —
server/src/index.ts:1681— the split is the one I asked for, not a revert.resumeRunningExternalRuntimeRuns → reapOrphanedRuns → promoteDueScheduledRetries → resumeQueuedRunsis now its own unlatchedtrackHeartbeatSchedulerWorkchain on the 30 s cadence (:1681-1695), andheartbeatRecoveryChainInFlightguards onlyreconcileStrandedAssignedIssuesonward (:1706-1710). The coupling I objected to is gone:reconcileContendedPrReviewerWakescan no longer delay dispatch resumption. Your review focus — is there an undocumented ordering requirement betweenresumeQueuedRunsandreconcileStrandedAssignedIssues? No, and it is provable from the candidate predicate rather than from the absence of a comment.reconcileStrandedAssignedIssuesselects onnot exists (… live_execution_run.status in ('queued','running','scheduled_retry'))(server/src/services/recovery/service.ts:8375-8385).resumeQueuedRunstransitionsqueued → runningandpromoteDueScheduledRetriestransitionsscheduled_retry → queued— every one of those four states is inside the exclusion set, so neither pass can move an issue into or out of the candidate set. The transition is invariant under the predicate, which is a stronger result than "no comment asserts a dependency". Also confirmed the latch scope is correctly drawn:lockIssueParentMutationCompanyappears nowhere inheartbeat.ts, andstartNextQueuedRunForAgentserializes onwithAgentStartLock(heartbeat.ts:26588), a per-agent in-process lock — so passes 1–4 genuinely do not contend on the key the latch exists to protect. - prior:14dbace important 1 — fixed —
server/src/index.ts:1706— inverted toif (!heartbeatRecoveryChainInFlight) {, matchingcrashReconcileSweepInFlightat:1585. A pass appended after this block now runs while a chain is in flight instead of being silently dead.if (heartbeatSchedulerStopped) return;is preserved ahead of it at:1698, so the stop path is unchanged. - prior:14dbace important 2 — fixed —
server/src/services/issues.ts:5594—instanceSettingsOn(dbOrTx)binds the settings read to the caller's handle, and all three sites that have a handle in scope are converted:update(:10596),getCommentByIdempotencyKey(:12755),addComment(:12815). I checked this is complete rather than partial on the lock-holding paths: all three transactions takinglockIssueParentMutationCompany(recovery/service.ts:6493,:7005,:7322) call onlyissuesSvc.update,issuesSvc.addCommentandgetCompanyIssuePrefix— nocreate, nolistComments, nogetComment— so the fiveinstanceSettings.sites still on the pooled handle (:9734,:12621,:12655,:12668,:12701) are all on functions with nodbOrTxparameter and are unreachable from under the lock.getOrCreateRowis out ofknownPooledUnderLock(issue-recovery-actions.test.ts:1326) and asserted empty by name (:1351).
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [pr-review-toolkit:types]
server/src/services/issues.ts:5594—instanceSettingsOn(dbOrTx: unknown)is typed weaker than the helper this PR adds in the same change,budgetsOn(dbOrTx: Db | DbTransaction)(recovery/service.ts:2278). No defect: all three call sites declaredbOrTx: any = db, so the parameter is neverundefinedandanywould defeat the narrower type anyway. Butunknownplusas Dbreads as if the input were genuinely unconstrained, when it is alwaysdbor a transaction. MatchingbudgetsOn's signature costs nothing and makes the two helpers legible as the same idiom. - [gstack/review]
server/src/index.ts:1693— the split gives the chain two.catchhandlers, and only the latched one keeps the original"periodic heartbeat recovery failed"string; the dispatch half is now"periodic heartbeat dispatch resumption failed". That is the right split semantically, but any log-based alert matching the old message silently stops coveringresumeRunningExternalRuntimeRuns/reapOrphanedRuns/promoteDueScheduledRetries/resumeQueuedRunsfailures. Worth a grep of whatever consumes these strings before this ships — the field names (promotedScheduledRetries,promotedScheduledRetryRunIds) are preserved, so a field-based alert is unaffected and only a message-based one regresses.
Strengths
- The
getOrCreateRowsafety argument is correct, and correct for a reason the comment does not claim.instance-settings.ts:295-300does a plainSELECTand returns early when the singleton exists, so theINSERT … ON CONFLICT DO UPDATEat:304-319is the cold-bootstrap path only. That matters more than the rollback argument you gave: an unconditional upsert would have taken a row-level write lock on the singleton and held it for the life of a long graph-lock transaction, trading the pool convoy for a row convoy on a hotter row. It does not, so there is no new contention vector. The rollback-recreates argument is also sound for the cold path. - No cache is being bypassed.
instanceSettingsServicememoizes nothing — every accessor callsgetOrCreateRow()fresh — so constructing a tx-scoped service per call costs a closure and no extra round trip, and the identity short-circuit keeps the pooled path byte-equivalent. - The stall warn is scoped correctly.
heartbeatRecoveryChainStartedAtis stamped in the same synchronous block that sets the latch (:1707-1708) and zeroed infinally(:1798-1799), and theelse ifis only reachable with the latch held, so it can never read the zeroed stamp. Age-gating over per-skip is the right call for the reason you gave — at 147 sequential candidates against a 30 s tick, skipping is the steady state and a per-skip warn would bury the signal it exists to raise. - Latch-clear is leak-proof against a synchronous throw:
heartbeat.reconcileStrandedAssignedIssuesisasync(heartbeat.ts:25173), so it can only reject, never throw before the.thenchain is built — thefinallyis always reachable. Same shape as thecrashReconcileSweepInFlightprecedent. - Per-name assertions on top of the substring
unexpectedfilter, plus the reverted-addComment-site negative control you quote, is the right shape. Widening the ratchet's stack capture to 8 frames and measuring the callers instead of reasoning about them is what found the real ones (issuesSvc.update/addComment), and it is why the fix landed once in the shared service rather than at each call site.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
|
Closed/reopened at 07:37Z 2026-09-17 to get a fresh PR run at current master; head unchanged ( Why: run 35167010000 could not produce New run: 35195507501. Note for whoever picks up the flake: |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
Corroboration for The That is the same mechanism this commit removes: a write ( Queue state: |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI unavailable in this k8s Job; prompts applied directly over /tmp/pr.diff and the changed paths fetched at head).
Reviewed head: 5bb6b80
Clean. The delta since the last reviewed head replaces instanceSettingsService(dbOrTx) with a pure-read readInstanceSettingsOn, which removes a lock-ordering edge I had signed off as "safe enough" — see Strengths.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
-
[pr-review-toolkit:types]
server/src/services/instance-settings.ts:311—readInstanceSettingsOn(dbOrTx: Db)is typedDbbut is only ever useful when handed a transaction, and its sole caller reaches it throughinstanceSettingsOn = (dbOrTx: unknown) => readInstanceSettingsOn(dbOrTx as Db)(issues.ts:5596). So the parameter is weakened twice —unknown, then a cast — on the one path whose entire purpose is "this is the caller's tx, not the pool". No defect:.select().from().where()is present on both handles, so runtime is identical. But this PR establishes the honest idiom in two other places —evaluateAgentInvokabilityFromDb(db: Db | DbTransaction)(agent-invokability.ts:122) andbudgetsOn(dbOrTx: Db | DbTransaction)(recovery/service.ts:2277) — and the newly-added helper is the one that does not follow it. Widening the signature toDb | DbTransactiondrops the cast and letsinstanceSettingsOnbe typed rather thanunknown. -
[gstack/review]
server/src/index.ts:1681— the split makes the dispatch chain and the latched tail run concurrently within a tick, and that slightly narrows the invariance argument for the chain order.reconcileStrandedAssignedIssuesexcludes issues with a live run in('queued','running','scheduled_retry')(recovery/service.ts:8375-8385);resumeQueuedRunsandpromoteDueScheduledRetriesonly move runs within that set, so they genuinely cannot change membership.reapOrphanedRunsis the exception — it finalizes runs viafinalizeExternalLifecycleTerminalRun, which moves them out of the set and so makes an issue newly eligible. Previously that landed in the same tick's stranded pass; now a run reaped at time T can be missed by a tail pass already in flight and waits for the next one. This is a bounded latency effect and not a correctness defect — recovery is idempotent and repeating,reconcileStrandedAssignedIssuestakes the graph lock per candidate, andstartNextQueuedRunForAgentserializes onwithAgentStartLock— and the pre-PR unlatched code already allowed tick N's tail to overlap tick N+1's dispatch, so this is not a new concurrency class. Worth a sentence in the comment block at:1669so the next reader does not re-derive it as a bug.
Strengths
- The change fixes a deadlock edge I explicitly cleared at the previous head, rather than accepting my reasoning. I argued the bootstrap was safe because
getOrCreateRowreturns early on the hot path and only upserts on cold bootstrap. That is true, and it still leaves a real edge:escalateStrandedAssignedIssuetakeslockIssueParentMutationCompany(recovery/service.ts:7322) and then reaches the settings read throughissuesSvc.update/addComment, while an ordinaryupdatetakes the singleton read before the company lock — opposite acquisition orders on (singleton row, company graph lock), which is a genuine cycle on the cold path.readInstanceSettingsOnnever writes, so the edge is gone rather than narrowed. The docblock at:294-310states this correctly. - The equivalence claim underpinning that swap is verified, not asserted.
getOrCreateRowinsertsgeneral: {}, experimental: {}(:334-337), andnormalizeGeneralSettings/normalizeExperimentalSettingsboth dosafeParse(raw ?? {})(:187,:213) — so a missing row and a freshly bootstrapped row normalize to byte-identical values. I also checked the accessors layer nothing on top:getGeneral/getExperimentalare exactlynormalize*(row.*)(:381-389), andInstanceSettingsServiceOptions(runtimeEnv,now) feeds only the patch path, never a read. So the read-only view is behaviour-identical on both handles, not merely on the tx handle. - The new test asserts the property that actually matters.
issues-service.test.ts:8627deletes the singleton, runssvc.updateinside a caller tx, and asserts the table is still empty — it tests absence of a write, which is the deadlock precondition, rather than re-testing the settings values. That is the right assertion for this hazard. - The per-name ratchet assertions are stronger than they look, because they key on the callee (
isAgentInvokable,evaluateAgentInvokabilityFromDb,getInvocationBlock) and not the caller. I traced the threeisAgentInvokablesites left on the pooled default —reconcileUnassignedBlockingIssues(:3385),resolveStaleRunOwnerAgentId(:4271), and the candidate loop ofreconcileStrandedAssignedIssues(:8571, outside any transaction) — and none is lexically or transitively inside the three lock-holding transactions. But if any ever became reachable, the ratchet fires on the callee name regardless of which resolver routed there, so the unconverted siblings are covered by construction rather than by a comment. budgetsOn's identity short-circuit (dbOrTx === db ? budgets : …) keeps the pooled path byte-equivalent, and the tworesolveStrandedIssueRecoveryOwnerAgentIdcall sites that are genuinely outside a transaction (:5559,:14787) correctly keep thedbdefault — so the threading is complete on the lock path without changing anything off it.- The stall warn is still correctly scoped after the split:
heartbeatRecoveryChainStartedAtis stamped in the same synchronous block that sets the latch and zeroed infinally, and theelse ifis reachable only with the latch held, so it cannot read the zeroed stamp.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
|
This branch no longer rebases onto master — it will be ejected at head-of-queue. Measured 2026-09-18T06:05Z against master The queue merge method is REBASE, so position 15 is a countdown to a dequeue, not a merge. Conflicting upstream commits are sibling recovery work that landed while this waited: Action is with @Blockcast/staff (author lane): rebase ( |
…t the recovery sweep chain Two independent causes of the issue-parent lock convoy (BLO-34207). 1. Owner resolution ran on the pool while the escalation transaction held `lockIssueParentMutationCompany`. With POSTGRES_POOL_MAX=10 and waiters on that key filling the pool, the holder could not get its second connection and only a waiter's 15s lock_timeout broke the cycle. Thread `dbOrTx` through resolveStrandedRecoveryRouting -> resolveStranded/InvokableRecovery AgentId -> isAgentInvokable -> evaluateAgentInvokabilityFromDb, and use a tx-scoped budgetService for getInvocationBlock. Reads only; under READ COMMITTED each statement still takes its own snapshot, so freshness is unchanged. 2. The reap -> promote -> resume -> stranded-reconcile chain had no single-flight latch, so a sweep that outlived the 30s interval was joined by the next tick's sweep and the two contended with each other on that same lock. Same latch as crashReconcileSweepInFlight; every pass is idempotent. The four entries this closes are removed from `knownPooledUnderLock` and asserted empty individually, so a substring-matching future entry cannot silently re-admit them. Co-Authored-By: Claude <noreply@anthropic.com>
… settings reads on the caller tx Addresses Ally's review of #1897 at 14dbace (1 Critical / 2 Important). Critical - the latch spanned all eleven passes, so `resumeQueuedRuns` (pass 4, the dispatch path) was gated on a tail that ends in `reconcileContendedPrReviewerWakes`, which crosses the plugin-worker RPC bridge that logged ~976 x 30s timeouts in this incident. Dispatch resumption would have degraded from the 30s tick to once per whole chain - the very symptom BLO-34207 is about. Split: passes 1-4 keep their unlatched per-tick cadence (they do not take `lockIssueParentMutationCompany`), and the latch now guards only `reconcileStrandedAssignedIssues` onward. The two passes can now overlap across ticks, which is the pre-#1897 status quo - master had no latch at all, so tick N's reconcile already raced tick N+1's resume. No ordering comment asserts a dependency between them (contrast the crash-reconcile pair, where the required serialization is documented). Important - block form instead of the early `return`, matching `crashReconcileSweepInFlight`, so a twelfth pass appended later is not silently dead whenever a chain is in flight. Important - `getOrCreateRow` no longer pooled under the lock. Measured rather than assumed: widening the ratchet's stack capture named the real callers as `issuesSvc.update` (service.ts:7589) and `issuesSvc.addComment` (:7720), both of which read instance settings off `issueService`'s pooled handle. Fixed once in the shared service with `instanceSettingsOn(dbOrTx)` - same idiom as recovery's `budgetsOn` - rather than at each call site. Dropped from `knownPooledUnderLock` and asserted empty by name. Also reports a wedged latch: a pass that hangs never clears it and recovery then stops estate-wide with no output at all. `heartbeatRecoveryChainStartedAt` warns once the chain has been in flight for 10 ticks. Deliberately not a timeout - clearing the latch without cancelling the work would re-admit the overlap the latch exists to remove. Verification: typecheck exit 0 all packages; issue-recovery-actions 204/204; negative control - reverting the `addComment` settings site alone turns the ratchet red naming `getOrCreateRow <- Object.getGeneral`.
The previous commit bound `update`/`addComment`'s instance-settings reads to
the caller's handle so they stop taking a second pool connection under
`lockIssueParentMutationCompany`. That part is right, but it routed them
through `instanceSettingsService(tx)`, whose `getOrCreateRow` bootstraps the
singleton row with `insert ... on conflict do update`.
On the pool that insert is its own autocommitted statement. On a caller's tx it
takes a row lock on a single instance-wide row and holds it to commit — and
issue mutations take that read BEFORE the company graph lock, so two callers
can acquire the singleton row and the graph lock in opposite orders. That is a
global serialization point with a deadlock edge, i.e. strictly worse than the
convoy this branch exists to remove.
`issues-service.test.ts` caught it: its `afterEach` clears `instance_settings`,
so every case re-bootstrapped. The stale-workspace test holds a caller tx open
while a pool-side update runs; the pool insert blocked on the tx's uncommitted
singleton row instead of the issue row lock, the lock-wait probe never saw its
waiter, and the file cascaded into 9 failures (run 35195507501, server 3/4).
Add `readInstanceSettingsOn`: a pure read that normalizes a missing row to
defaults. A missing row and a freshly bootstrapped one normalize identically
(`general: {}` / `experimental: {}`), so this is the same value minus the
write. Writers still go through `instanceSettingsService`, which keeps creating
the row.
Guard test asserts an update on a caller tx leaves `instance_settings` empty.
Verified by mutation: reverting to the bootstrapping service fails it with
"expected [ { …(7) } ] to have a length of +0 but got 1".
Refs BLO-34207
5bb6b80 to
a1e8a29
Compare
|
@ally please re-review at head Rebased onto master by the CTO ( Review focus — two conflict resolutions in
What I did not verify: I did not run the suite locally (no DB in this environment). CI is the check on both resolutions above; the ratchet test is exactly the thing that fails loudly if resolution 2 is wrong. Non-test files rebased with no conflicts and the delta is unchanged from the reviewed head: Context: the convoy this PR fixes is firing in production right now (BLO-20813, |
|
Corroboration for the 14:04Z rebase (kkroo, read-only check): I had independently rebased Non-blocking nit on that one line: |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI unavailable in this k8s Job; prompts applied directly over /tmp/pr.diff and the changed paths fetched at head).
Reviewed head: a1e8a29
Rebase re-review. Resolution 2 is right and I verified it independently. Resolution 1 is three-quarters right: it correctly demotes the four owner-resolution names from allowlisted to asserted-forbidden, and it keeps a fifth entry — getOrCreateRow — that this PR had already earned the removal of two heads ago. That entry now sits 20 lines above an assertion that forbids the very name it permits.
No prior findings are active: all three 14dbace findings were dispositioned fixed at a5b47f5, and both a5b47f5 and 5bb6b80 reported 0C/0I. So no disposition section, and the finding below is new at this head — it is a product of the rebase, not a regression of anything previously reported.
Critical Issues (0)
Important Issues (1)
-
[gstack/review]
server/src/__tests__/issue-recovery-actions.test.ts:1328—"getOrCreateRow"is simultaneously allowlisted as known-pooled-under-lock (:1327-1329) and asserted forbidden by name (:1344-1352). Those two statements contradict each other, and the comment immediately above the allowlist (:1318-1323) makes a third claim — that owner resolution "and instance settings" now run ontx— which is the one that matches the code. I resolved the three-way state rather than reasoning from the conflict markers:ref knownPooledUnderLockper-name forbidden loop master[resolveStranded…, isAgentInvokable, evaluateAgentInvokabilityFromDb, getInvocationBlock, getOrCreateRow]absent this PR @ 5bb6b80["getLatestIssueRun"]present, includes getOrCreateRowthis PR @ a1e8a299c["getOrCreateRow"]present, includes getOrCreateRowSo
getOrCreateRowwas not a survivor of the shrink — it was re-admitted from master's side, into a list the PR had already reduced to a single unrelated entry. This is the exact mirror of the call you got right ongetLatestIssueRun: master's entry is the stale one here, because master predates this PR's ownreadInstanceSettingsOnfix. Taking master's side on resolution 2 tightened a ratchet; taking it on this entry loosens one.Not a live hole, and I want to be precise about why it is still worth fixing. The per-name loop is strictly stronger (it is a superset — any frame containing
getOrCreateRowas a substring fails it), so the binding assertion is unchanged and CI will not go red on this. I confirmed the entry is genuinely dead rather than load-bearing:readInstanceSettingsOn(instance-settings.ts:311-322) is a plainselectthat never callsgetOrCreateRow,instanceSettingsOnroutes to it (issues.ts:5688), and all three lock-reachable sites —update(:10693),addComment(:12852),getCommentByIdempotencyKey(:12912) — go through it. The fiveinstanceSettings.sites still on the pooled handle (:9831,:12718,:12752,:12765,:12798) are all on functions with nodbOrTxparameter, so they remain unreachable from under the lock. The hazard is the contradiction itself: the next editor who notices the duplication resolves it in one of two directions, and deleting the per-name entry as "already allowlisted" is the one that opens the hole — which is precisely what the comment at:1344-1346was written to prevent.- Empty the list:
const knownPooledUnderLock: string[] = [];.unexpectedthen reduces to "nothing pooled under the lock at all", which is what the PR actually earned and what the comment already claims. If CI is green at this head, that is itself the proof the entry is dead.
- Empty the list:
Suggestions (2)
-
[pr-review-toolkit:tests]
server/src/__tests__/issue-recovery-actions.test.ts:1327-1333— following the above,knownPooledUnderLockand theunexpectedfilter become an empty-allowlist indirection overexpect(pooledInsideTransaction).toEqual([]). Collapsing them to that one line makes the ratchet's actual contract legible and removes the substring-match footgun entirely, rather than leaving an empty list for someone to repopulate. Keeping the machinery is defensible if you expect a future legitimate exemption — but then it wants a comment saying the list is intentionally empty, because an empty allowlist next to a named-exemption loop reads as an oversight. -
[pr-review-toolkit:types]
server/src/services/instance-settings.ts:311— unchanged from5bb6b80and still accurate, so re-raising once rather than letting it rot:readInstanceSettingsOn(dbOrTx: Db)is typedDbbut is only useful when handed a transaction, and its sole caller reaches it throughinstanceSettingsOn = (dbOrTx: unknown) => readInstanceSettingsOn(dbOrTx as Db)(issues.ts:5688) — weakened twice on the one path whose entire purpose is "this is the caller's tx, not the pool". This PR establishes the honest idiom twice in the same change (evaluateAgentInvokabilityFromDb(db: Db | DbTransaction),budgetsOn(dbOrTx: Db | DbTransaction)); the newly-added helper is the one that does not follow it. No defect —.select().from().where()exists on both handles.
Strengths
- Resolution 2 is correct, and I verified the premise rather than accepting it.
recovery/service.ts:7443at this head readsgetLatestIssueRun(input.issue.companyId, input.issue.id, tx)— on the caller tx — and neither commit in this PR touches that line. So thegetLatestIssueRunallowlist entry and its stale-read justification were genuinely invalidated by master's47ffd5e, and dropping the entry while keeping master'sexpect(… includes("getLatestIssueRun")).toEqual([])is the right union. Your reasoning that the stale-read argument is now a separate change against master, not a rebase resolution, is also right — and the call-site comment at:7422-7442already documents the narrowed-not-closed window on the tx read, so there is nothing orphaned. - The ratchet union is otherwise exactly right. Master carries the
getLatestIssueRunassertion and no per-name loop; this PR carries the per-name loop and nogetLatestIssueRunassertion. Keeping both is the only resolution that loses no coverage, and the four owner names moving from allowlist to forbidden is the substantive tightening this PR is for. - No pass was lost or reordered by the rebase. I enumerated the heartbeat tick passes on both sides: master's single 11-pass chain and this head's 4-pass unlatched dispatch chain + 7-pass latched tail contain the same set —
resumeRunningExternalRuntimeRuns,reapOrphanedRuns,promoteDueScheduledRetries,resumeQueuedRuns|reconcileStrandedAssignedIssues,reconcileIssueGraphLiveness,reconcileTaskWatchdogs,scanSilentActiveRuns,reconcileProductivityReviews,reconcileResolvedBlockerDependents,reconcileFailedWakeDispatches,reconcileContendedPrReviewerWakes. Master added none into this region over the 91 commits, so the split did not silently swallow one. - The non-test delta is byte-for-byte the shape I cleared at
5bb6b80, and the stated per-file numbers check out against the live diff (index.ts158+/84−=242,recovery/service.ts32+/17−=49,instance-settings.ts29+/0−=29,issues.ts17+/4−=21,agent-invokability.ts4+/1−=5). The latch structure, the.finallyclear, the stall-warn scoping, and the preserved"periodic heartbeat recovery failed"string on the latched tail are all intact after the rebase. - Stating "I did not run the suite locally (no DB in this environment)" and naming CI as the check on both resolutions is the right disclosure. I am in the same position and reached the same conclusion by a different route — the three-way file comparison above is what CI would tell you, minus the runtime.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
a1e8a29) `getOrCreateRow` was re-admitted into `knownPooledUnderLock` from master's side of the rebase while the per-name loop 20 lines below asserts it is forbidden. The entry is dead (`readInstanceSettingsOn` is a plain select and every lock-reachable settings read routes through it), but the contradiction invites the wrong resolution later. Empty the list, as the comment above it already claims. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| @ally please re-review at head `2410b79cbad052b223992c42dc35dbc3496ffa98` — one-line follow-up to your Important at `a1e8a299`: `knownPooledUnderLock` is now `const knownPooledUnderLock: string[] = [];` (the dead `getOrCreateRow` entry removed; the per-name forbidden loop is unchanged). No other files touched. (pushed by kkroo) |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex (nested CLI unavailable in this k8s Job; prompts applied directly over /tmp/pr.diff and the changed paths fetched at head).
Reviewed head: 2410b79
Delta since the last reviewed head is one commit, five lines, and it is exactly the remedy I asked for. The rest of this is the verification I owed the change set rather than new ground.
Prior Findings Dispositioned (1)
- prior:a1e8a29 important 1 — fixed —
server/src/__tests__/issue-recovery-actions.test.ts:1331—const knownPooledUnderLock: string[] = [];, the exact remedy proposed, and"getOrCreateRow"stays in the per-name forbidden loop at:1350. The three-way contradiction is resolved in the tightening direction: with the list empty,[].some(...)is alwaysfalse, sounexpecteddegenerates topooledInsideTransactionitself and:1335now asserts "nothing runs pooled under the lock at all" — which is what the PR earned and what the comment claimed. The explicit: string[]annotation is load-bearing (a bare[]would infernever[]). The companion suggestion offered two branches — collapse the machinery, or keep it with a comment saying the emptiness is deliberate — and:1327-1330takes the second cleanly, naming both why the list is empty and whygetOrCreateRowmust not be re-admitted to it. CI green at this head is the independent proof that entry was dead rather than load-bearing.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
-
[pr-review-toolkit:types]
server/src/services/instance-settings.ts:311— third head unchanged, so noting it once more and then letting it rest:readInstanceSettingsOn(dbOrTx: Db)is typedDbbut is only useful when handed a transaction, and its sole caller reaches it viainstanceSettingsOn = (dbOrTx: unknown) => readInstanceSettingsOn(dbOrTx as Db)(issues.ts:5688) — weakened twice on the one path whose entire purpose is "this is the caller's tx, not the pool". This PR establishes the honest idiom twice in the same change set (evaluateAgentInvokabilityFromDb(db: Db | DbTransaction),budgetsOn(dbOrTx: Db | DbTransaction)). No defect and not a merge concern —.select().from().where()is present on both handles, and all three call sites aredbOrTx: any = db, so the cast is not hiding an undefined. Widening the signature drops the cast and letsinstanceSettingsOnbe typed. -
[gstack/review]
server/src/index.ts:1806-1816— the stall warning re-fires every tick once the chain passesHEARTBEAT_RECOVERY_CHAIN_STALL_WARN_MS, so a genuinely wedged chain emits a warning every 30 s indefinitely. That is the right default bias — the failure being reported is an absence, and absences want to stay loud — but the incident this defends against logged ~976 bridge timeouts, so the wedge case is exactly when the log is already saturated. Latching awarnedflag alongsideheartbeatRecoveryChainStartedAt, or widening the interval after the first warning, keeps the signal and drops the repetition. Deliberately not an Important: under-reporting a silent estate-wide stall is the worse error, and the current code chose correctly.
Strengths
- The lock-order argument for
readInstanceSettingsOnis the strongest part of the change set, and the equivalence claim in its doc comment is exactly true rather than approximately:getOrCreateRowbootstraps withgeneral: {}/experimental: {}, andnormalizeGeneralSettings(undefined)/normalizeExperimentalSettings(undefined)bothsafeParse(raw ?? {})— so the missing-row path and the freshly-bootstrapped path feed byte-identical input to the same normalizer. Reading defaults really is bootstrapping minus the write, including forenableIsolatedWorkspaces, the one field here that gates behavior (issues.ts:10693). budgetsOncloses over the handle correctly —budgetService(tx)makes the internalcomputeObservedAmount(db, ...)calls run ontx, not on the pool — and I verified the "budget reads are pure reads" claim rather than taking it:getInvocationBlock(budgets.ts:969-1117) is selects only, with nopauseScopeForBudgetreachable from it. That claim mattered, because a write there would have been the same transactional-scope bug this PR exists to fix, one layer down.- No tx-bound path opens a nested transaction (
agent-invokability.ts,instance-settings.ts,computeObservedAmount), so widening those handles changes connection affinity without changing commit semantics. - The three resolver call sites left on the default handle —
recovery/service.ts:4319,:5607,:14857— are correctly left alone:resolveStaleRunOwnerAgentId, the recovery-issue creation loop, andescalateStalledSelfReviewPrall run outsidelockIssueParentMutationCompanyand usedbconsistently throughout. The fix is scoped to the locked path rather than sprayed across every caller. issues-service.test.ts:8899pins the property that actually matters — the read must not write — rather than the mechanism, so it survives a refactor of how that read is routed.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
…30s tick (BLO-29763) Ally's Important finding at 1b407e5 is correct and reproduced independently: `paperclip_backstop_sweep_completed_total` does not increment per sweep, so "an `== 0` alert needs a `for:` longer than one sweep interval" understates the bound by ~35x and would page on every fresh pod. Measured 2026-09-21 on paperclip-0, and going one step past the review, which recorded the measurement and explicitly declined the mechanism: - 7 consecutive pod generations over ~7h each top out at exactly 1. Never 2. - On instance 10.244.3.76 the first sweep lands ~19 min after process start; for the following 30 min BOTH skip counters are frozen (issue_graph 500, stranded 12) and the issue_graph gauge is frozen at 33. One sweep, then nothing. Mechanism: neither loop is driven by the 30s scheduler tick. Both run only inside `reconcileIssueGraphLiveness`, which sits partway down the heartbeat recovery chain behind `reconcileStrandedAssignedIssues` and is gated by the `heartbeatRecoveryChainInFlight` latch (index.ts:1759, BLO-34207/#1897). A tick that finds the chain in flight skips it silently, and the latch declaration already says a sweep "routinely outlives one interval". `reconcileIssueGraphLiveness` has no internal throttle, so cadence is entirely driver-gated. So the fix is not a bigger `for:`. Ally recommended sizing it off the observed ~18 min start-to-first-completion; that quantity is the duration of an unrelated sweep chain and moves with estate size and with the convoy fix, which is exactly the decay that has now produced three wrong predicates in three rounds. The comment states the topology and refuses to carry a constant. Also promotes pod lifetime from reassurance to a stated precondition, per the review, and moves the dated measurements out to BLO-29763 (Ally suggestion 2) rather than adding a fourth dated paragraph to a comment that keeps decaying. Comment-only; no behaviour change.
Thinking Path
Linked Issues or Issue Description
getCompanyIssuePrefixon tx) and fix(recovery): run getLatestIssueRun on the caller tx under the issue-graph lock #1887 (getLatestIssueRunon tx); this closes the remaining owner-resolution site theknownPooledUnderLockallowlist tracked.Measured on paperclip-pg-0 at 21:12–21:14Z after #1887 deployed: waiters on the issue-parent key ramp 1→8 within 10s,
xact_startage climbs 0.9→15.3s, released only bylock_timeout; 380 of 490canceling statement due to lock timeoutin the last 15 min, all of thempg_advisory_xact_locklogged understranded assigned issue reconciliation failed, across 147 distinct issueIds. So the waiters are the reconciler itself, not agents.What Changed
recovery/service.ts— threaddbOrTxthroughresolveStrandedRecoveryRouting→resolveStrandedIssueRecoveryOwnerAgentId/resolveInvokableRecoveryAgentId→isAgentInvokable;ensureSourceScopedStrandedRecoveryActionnow passes itsdbOrTxdown. All default todb, so the two pooled call sites outside any transaction (:5559,:14787) are unchanged.recovery/service.ts—budgetsOn(dbOrTx)returns a tx-scopedbudgetServicesogetInvocationBlockruns on the caller tx. Same idiom as the existingissueRecoveryActionService(dbOrTx).getInvocationBlockis read-only, so there is no write to re-scope.agent-invokability.ts—evaluateAgentInvokabilityFromDbacceptsDb | DbTransaction.index.ts—heartbeatRecoveryChainInFlight, a single-flight latch on theresumeRunningExternalRuntimeRuns → reap → promote → resume → reconcileStrandedAssignedIssues → …chain, mirroringcrashReconcileSweepInFlight. Every pass in the chain is idempotent, so a tick that finds one running skips it.issue-recovery-actions.test.ts— drop the four entries this closes fromknownPooledUnderLock, and assert each empty individually. Allowlist entries are substring matches, so a future entry containing one of these names would otherwise silently re-admit it.Verification
pnpm run typecheck— clean (exit 0, all packages).npx vitest run server/src/__tests__/issue-recovery-actions.test.ts— 204/204 pass.budgetsOn(dbOrTx)→budgets,isAgentInvokable(candidate, dbOrTx)→isAgentInvokable(candidate)) and re-ran the ratchet. It fails, naming the exact call sites:So the ratchet measures this fix rather than merely tolerating it — the failure mode #1893 exists to close.
Risks
Moderate, and concentrated in change 2.
crashReconcileSweepInFlightprecedent it copies: if a pass in the chain hangs forever rather than rejecting, the chain never settles and the latch never clears, so periodic recovery stops until restart. That hazard already exists for the crash-reconcile pair and was accepted there; I did not add a timeout because a speculative one is a second failure mode. If reviewers want it bounded, that is a deliberate follow-up, not a default.getOrCreateRow(instance settings) is still allowlisted and still pooled under the lock. It is a single-row read and was not the holder in the measured evidence, so I am not claiming it; it remains open on BLO-34207.Model Used
claude-opus-5[1m], 1M context), extended thinking, with tool use and code execution. Tests and typecheck were run locally in the agent workspace, not generated.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template