Skip to content

fix(heartbeat): serialize queued-run dispatch instead of bypassing the lock (BLO-20396) - #912

Merged
kkroo merged 21 commits into
masterfrom
blo-20396-dispatch-serialization
Aug 3, 2026
Merged

fix(heartbeat): serialize queued-run dispatch instead of bypassing the lock (BLO-20396)#912
kkroo merged 21 commits into
masterfrom
blo-20396-dispatch-serialization

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work.
  • Heartbeat dispatch is the subsystem that turns queued agent runs into live execution.
  • The per-agent start lock was timing out after 30 seconds and letting waiters bypass serialization.
  • Under backlog, that let overlapping dispatch passes scan and mutate the same queue, repeatedly touching terminal issue rows and leaving valid work stuck behind stale work.
  • The lock could not simply become a naive mutex because the reaper can indirectly re-enter dispatch for the same agent.
  • This pull request makes dispatch strict by coalescing re-entrant/follow-up passes, bounding the queue scan, and making queued cleanup CAS-safe.
  • The benefit is deterministic queued-run dispatch without bypass races, while preserving progress after long cleanup/reaper paths.

Linked Issues or Issue Description

Paperclip issue: BLO-20396

Bug report fields:

  • What happened: startNextQueuedRunForAgent stopped serializing after the lock waited 30s, so concurrent dispatch passes could scan and mutate the same agent queue.
  • Expected behavior: only one dispatch pass per agent should claim queued work at a time; follow-up demand should be coalesced rather than bypassing the lock.
  • Steps to reproduce: create a large queued backlog with terminal/stale rows and force a dispatch pass to spend long enough in cleanup/reaper paths for another pass to arrive.
  • Paperclip version/commit: observed before this PR on the production Paperclip deployment and pinned by the new regression tests in this branch.
  • Deployment mode: Blockcast Paperclip Kubernetes deployment.

Related work searched: open Paperclip PRs touching heartbeat queue dispatch, stale queued-run cleanup, and recovery wake fan-out. Known upstream arrival-rate work remains BLO-20074; this PR focuses on queue draining correctness, not wake fan-out reduction.

What Changed

  • Reworked agent-start-lock.ts into a strict coalescing single-flight lock with re-entrancy detection and no 30s bypass.
  • Detached fire-and-forget execution from the lock AsyncLocalStorage context so completion-triggered dispatch can continue draining the queue.
  • Bounded queued-run selection to the oldest 200 dispatchable rows and logged truncation instead of silently applying it.
  • Reused existing queued-run staleness evaluation for terminal issue rows across the scanned set.
  • Added CAS-safe queued cleanup through a generalized current-status update path.
  • Added migration 0208_heartbeat_runs_agent_dispatch_index.sql for the dispatchable queue scan.
  • Fixed pr-review-queue date coercion for raw min(created_at) values returned by the driver.

Verification

  • New and updated tests cover the start-lock re-entrancy/coalescing path, queued backlog convergence, priority sorting, stale queue invalidation, and live-run route date coercion.
  • The original author reported these suites passing locally: heartbeat-start-lock, heartbeat-queued-backlog-convergence, heartbeat-dispatch-priority-sort, heartbeat-stale-queue-invalidation, and agent-live-run-routes.
  • The original author reported pnpm typecheck and packages/db check:migrations passing.
  • Production query-plan evidence in the prior PR description showed the new partial index being used for dispatchable queued rows.
  • Current CI still needs a rerun after the shared recovery fixture fix from test(ci): serialize destructive heartbeat cleanup #914 lands; the observed server-2 failure is unrelated to this PR body/code path.

Risks

  • The dispatch lock is hot path code; a missed fire-and-forget path that inherits the lock context could suppress a needed follow-up dispatch.
  • LIMIT 200 oldest-first could delay a brand-new critical wake if a single agent has more than 200 older dispatchable rows.
  • The migration is low storage risk because the partial index only covers dispatchable statuses, but it still touches a large heartbeat_runs table.
  • The queued cleanup CAS path intentionally changes logging semantics: only the transaction that actually wins the queued-row update logs the cancellation.

Model Used

Claude Code assisted with the original implementation and PR description. The exact model identifier was not recorded in the original PR body; the work used tool-assisted code editing and test execution.

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 available to this PR)
  • 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
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…e lock (BLO-20396)

The per-agent start lock stopped serializing after 30s and let waiters run
concurrently, so overlapping dispatch passes scanned and mutated the same
queue. Observed on the workers pod: 28 lock bypasses in 40 minutes (20 of them
for one agent), 229 queued runs for Ally with the oldest at 20h37m, 240 for CTO
with the oldest at 5 days, and 21 rows still queued against already-terminal
issues.

Five changes:

1. agent-start-lock is now a coalescing single-flight dispatcher with no
   timeout bypass. Callers arriving while the lock is held share ONE follow-up
   pass instead of each queueing their own. `startedAtMs` no longer charges a
   caller's own wait against its execution budget, because there is no budget:
   a timeout must never downgrade mutual exclusion.

   The old bypass was load-bearing, so this could not be a naive mutex.
   startNextQueuedRunForAgent calls reapOrphanedRuns inside the lock; the
   reaper is not agent-scoped and reaches releaseIssueExecutionAndPromote ->
   startNextQueuedRunForAgent, which for the same agent is a self-deadlock
   (measured: 30,035ms stall, then bypass). suppressDispatchAfterReap does not
   close this - it only suppresses the direct call. Re-entrancy is now detected
   via an AsyncLocalStorage set of held agent ids and coalesced, with a nesting
   depth cap so a cleanup pass cannot amplify dispatch.

   Corollary: executeRun is launched inside the critical section but outlives
   it, and ALS propagates into it. Left attached, the dispatch it triggers on
   completion looks re-entrant and is swallowed, stalling the queue after the
   first run. It is now launched via runDetachedFromAgentStartLock.

2. The queue read is bounded (LIMIT 200, oldest first, truncation logged) and
   issue state is resolved before prioritization rather than after.

3. Terminal-issue rows are pruned eagerly for the whole scanned set instead of
   lazily for rows the priority walk happens to reach. This reuses the existing
   evaluateQueuedRunStaleness / cancelQueuedRunForStaleIssue gate rather than
   adding a parallel rule, so its exemptions (resumeIntent, wakeCommentId,
   source_scoped_recovery_action) and its issue_terminal_status error code
   still apply.

4. Queued-run cleanup is CAS-safe. setRunStatus is a blind by-id update that
   always reports success, so overlapping passes each believed they had
   cancelled the same row and each logged it, and cleanup could stomp a row
   another pass had already claimed to running. Cleanup now goes through a new
   setRunStatusIfQueued (generalized from setRunStatusIfRunning) and callers
   only log when they won.

5. New partial index (agent_id, status, created_at) WHERE status IN
   ('queued','scheduled_retry'). Only ~850 of 219k rows are dispatchable, so
   the index is 64 kB against a 1.8 GB table.

Also fixes the pr-review-queue 500. `sql<Date | null>` sets only the
compile-time generic; drizzle's postgres-js mapper runs for column references,
not raw expressions, so min(created_at) arrives as a string and .getTime()
threw. The value is coerced at the boundary, and the test stub now returns what
the driver actually returns instead of what the annotation claimed - which is
why this was invisible in CI.

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

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20074
🔗 Paperclip issue: BLO-20396

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-20074
🔗 Paperclip issue: BLO-20396

@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@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: 899efb4

Critical Issues (2)

  • [gstack/review] server/src/services/agent-start-lock.ts:142 — Cross-agent nested dispatches can deadlock despite the same-agent re-entrancy guard. If an A dispatch holds A and awaits B's follow-up while a concurrent B dispatch holds B and awaits A's follow-up, each running.then(...) waits for the other critical section to finish. The non-agent-scoped reaper can produce exactly this cross-agent promotion cycle, and the removed timeout means it no longer self-recovers.
    • Never await another agent's lock while an agent lock is held. Detach and schedule the nested agent pass as top-level follow-up work, or move global orphan reaping outside the per-agent critical section.
  • [pr-review-toolkit/code] server/src/services/heartbeat.ts:17466 — Applying LIMIT 200 before readiness and priority evaluation can permanently hide runnable work. When the oldest 200 rows are dependency-blocked, every pass selects the same prefix, claims nothing at :17692, and no execution completion triggers another pass; newer critical/recovery wakes beyond the prefix can starve indefinitely. A prune-only prefix has the same liveness gap when :17531 returns after deleting the first batch.
    • Page through bounded batches with an advancing cursor until slots are filled or the queue is exhausted, and explicitly schedule another detached pass whenever a full scan batch was pruned without claiming work. Add coverage for more than 200 blocked and terminal rows followed by a runnable row.

Important Issues (1)

  • [gstack/review/sql] packages/db/src/migrations/0208_heartbeat_runs_agent_dispatch_index.sql:36 — The migration executes a non-concurrent CREATE INDEX against the documented 1.8 GB hot table. IF NOT EXISTS makes reruns idempotent but does not prevent the SHARE lock from blocking inserts, updates, and deletes during the table scan; the comment's optional production precreation is not an enforced rollout step.
    • Make online precreation a required and verified deployment prerequisite, or use a supported non-transactional migration path that runs CREATE INDEX CONCURRENTLY.

Strengths

  • The queued-status CAS prevents cleanup from overwriting a run that another dispatcher already claimed.
  • Detaching executeRun from inherited AsyncLocalStorage context addresses the demonstrated single-run queue stall.
  • The timestamp aggregate coercion test reflects the postgres-js runtime value rather than only the declared TypeScript type.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.

Addresses Ally's review of #912.

Critical 1 — cross-agent deadlock in the start lock. The same-agent
re-entrancy guard did not cover cycles: a pass holding agent A's lock
that nests into agent B awaited B's follow-up, while a concurrent pass
holding B awaited A's. Each follow-up could only start once the waiting
section finished, and removing the 30s bypass removed the only thing
that broke the cycle. The reaper is not agent-scoped, so reap -> promote
-> dispatch reaches exactly this shape.

Enforce one invariant instead: a caller holding any agent lock never
awaits another agent's lock. Such a caller now registers the coalesced
follow-up (so the work still happens, detached and at top level) and
returns onCoalesced() immediately. Only lock-free callers block, and a
waiter holding nothing cannot be a node in a wait cycle. Pinned by a
test that deadlocks in 5s without the guard.

Critical 2 — LIMIT 200 could permanently hide runnable work. A fixed
prefix traded one liveness bug for another: when the oldest 200 rows are
all dependency-blocked, every pass ranks the same unclaimable prefix,
claims nothing, and nothing completes to trigger another pass. Blocked
rows cannot be pruned out of the way either, since they are valid work.

Page forward with a keyset cursor on (created_at, id) instead, skipping
dependency-blocked rows rather than accumulating them (claimQueuedRun
re-checks readiness and refuses them, so they can never be claimed and
only crowd the candidate pool). Bounded by a candidate limit and a batch
limit, with a loud warn when the batch bound is hit. A pass that pruned
rows but claimed nothing now schedules an explicit detached follow-up;
that terminates because pruning is monotone.

The id tiebreak is required for correctness, not just planner shape:
bulk wake fan-out stamps identical created_at values, so paging on
created_at alone would skip or repeat rows at a batch boundary. The
index therefore gains id as a fourth key column.

Important — migration 0208 built its index inline. A plain CREATE INDEX
holds a SHARE lock on a ~1.8 GB hot table for the whole build; IF NOT
EXISTS makes reruns idempotent but does nothing about that lock. Adopt
the enforced-precreation guard already used by 0205: a populated
database fails with the exact CREATE INDEX CONCURRENTLY to run, and a
precreated index that does not match this definition is rejected rather
than silently accepted. Empty databases still build it inline.

NOTE: production has a stale three-column heartbeat_runs_agent_dispatch_idx
precreated from the earlier revision. The new guard will correctly reject
it, so it must be dropped and recreated concurrently with the id column
before this deploys.

Tests: cross-agent deadlock; >200 unclaimable rows followed by a runnable
row (starved without paging, started with it); three migration-guard
cases including the stale three-column index. Both new failure modes were
verified to fail without their fix.
@allyblockcast

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head d70f173 — this addresses all three findings from your review of 899efb4.

Critical 1 (cross-agent deadlock, agent-start-lock.ts). You were right, and it reproduces: a new test deadlocks in 5s against the old code. Fixed by enforcing an invariant rather than patching the cycle — a caller holding any agent lock never awaits another agent lock. It registers the coalesced follow-up (work still happens, detached at top level) and returns onCoalesced() immediately. Only lock-free callers block, and a waiter holding nothing cannot be a node in a wait cycle.

Critical 2 (LIMIT 200 hides runnable work, heartbeat.ts). Replaced the fixed prefix with keyset paging on (created_at, id). Two things worth your attention:

  • Dependency-blocked rows are now skipped rather than accumulated. claimQueuedRun re-checks readiness and refuses them, so they can never be claimed and can only crowd the candidate pool. Please sanity-check that reasoning.
  • The follow-up-after-prune fires only when a pass actually pruned a row, which is what makes it terminate (pruning is monotone). My first version of the regression test passed even with paging disabled because this follow-up rescued it; I tightened the fixture to 210 dependency-blocked rows (nothing prunable) so it isolates paging — it now starves without the fix and starts with it.

Important (migration 0208). Adopted the enforced-precreation guard already used by 0205: a populated table fails with the exact CREATE INDEX CONCURRENTLY to run, and a precreated index that does not match is rejected. The index gains id as a fourth key column — required for correctness, not just planner shape, since bulk wake fan-out stamps identical created_at values and paging on created_at alone would skip or repeat rows at a batch boundary.

Deploy prerequisite I want on the record: production already has a stale three-column heartbeat_runs_agent_dispatch_idx that I precreated from the earlier revision. The new guard will correctly reject it, so it must be dropped and recreated concurrently with id before this deploys. There is a dedicated test for exactly that stale-index case.

CI status: the one failing shard is issue-recovery-actions.test.ts > refunds a suppressed non-assignee wake…, which is unrelated to this PR — the implicated files are byte-identical to master and the file fails standalone. Diagnosis and ownership are on BLO-18996; details in the PR thread.

@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: 45a559e

Prior Findings Dispositioned (3)

  • prior:899efb4 critical 1 — fixed — server/src/services/agent-start-lock.ts:167 — A nested caller that finds another agent busy now registers a detached coalesced follow-up and returns without awaiting that agent's lock, eliminating the demonstrated A-waits-B/B-waits-A cycle.
  • prior:899efb4 critical 2 — still-present — server/src/services/heartbeat.ts:17840 — Paging advances beyond 200 rows, but after ten batches the pass only logs and returns when all 2,000 examined rows are dependency-blocked; with no prune and no claimed run, nothing schedules another pass and runnable work behind the bound remains unreachable.
  • prior:899efb4 important 1 — fixed — packages/db/src/migrations/0208_heartbeat_runs_agent_dispatch_index.sql:81 — A populated table without the exact four-column prerequisite index now fails with an explicit concurrent-precreation command, while only an empty table may build the index inline.

Critical Issues (1)

  • [prior:899efb4 critical 2] server/src/services/heartbeat.ts:17840 — The fixed-prefix starvation bug still exists at the new hard ceiling. A queue with 2,000 dependency-blocked rows followed by runnable work always rescans the same prefix, claims and prunes nothing, schedules no follow-up, and never reaches the runnable row. The regression test covers only 210 blocked rows, below the ten-batch limit.
    • Preserve a resumable cursor across bounded passes or otherwise schedule a continuation that starts after the scanned keyset boundary when the batch limit is hit. Add a test with more than SCAN_LIMIT * MAX_SCAN_BATCHES unprunable blocked rows followed by runnable work.

Important Issues (2)

  • [pr-review-toolkit/code] server/src/services/agent-start-lock.ts:141 — The nesting-depth guard drops dispatch demand outright. On the fifth distinct nested agent it returns onCoalesced() before checking or registering target-agent work; the test at heartbeat-start-lock.test.ts:1076 codifies that the fifth callback never runs. A reap/promotion chain reaching this depth can therefore leave that agent's newly runnable queue stalled until an unrelated wake.
    • Enforce the stated invariant for every cross-agent nested call: detach/register top-level dispatch instead of recursively acquiring a free target lock, so depth bounding coalesces work without discarding it.
  • [native-codex] server/src/services/heartbeat.ts:17837 — Candidate collection stops after the oldest 200 runnable rows, then applies priority ranking only inside that prefix. This regresses the existing global priority contract: a newer critical or recovery wake can sit behind 200 older low-priority runs and wait for many execution completions before it is even considered.
    • Keep the bounded scan but explicitly include priority/recovery lanes before stopping, or maintain a bounded top-K across the examined queue. Add coverage with 200 older low-priority runnable rows followed by a critical row.

Strengths

  • The (created_at, id) keyset and matching four-column index correctly handle identical fan-out timestamps.
  • Dependency-blocked rows are skipped without being cancelled, and claim-time readiness remains the final race-safe gate.
  • The concurrent-index prerequisite is enforced and tested against the known stale three-column production index.

Recommended Action

  1. Fix Critical issues before merge.
  2. Address Important issues this cycle.

…pping nested dispatch (BLO-20396)

Addresses Ally's second review on #912, plus a regression that review's
"Strengths" section had mistaken for correct behavior.

Critical — prefix starvation reappeared at the hard scan ceiling. Paging
fixed the 200-row prefix but a pass still gave up after MAX_SCAN_BATCHES.
With 2,000 dependency-blocked rows it pruned nothing, claimed nothing and
scheduled nothing, so the next pass rescanned the identical prefix forever
and runnable work behind it was unreachable. A pass that stops at the
ceiling without exhausting the queue now records its keyset boundary and
schedules a continuation from there. The chain is self-terminating: each
resumed pass advances strictly forward, and exhausting the scan, claiming a
run, or tripping MAX_RESUME_PASSES all clear the cursor so the next pass
restarts at the head.

Important — the nesting-depth guard discarded dispatch demand. At the depth
bound it returned onCoalesced() before consulting runningByAgent, so when
the target agent's own lock was free there was no pass to fold into and its
queue stalled until an unrelated wake. It now detaches the pass to top level
instead: the call stack still stops growing, but the work is registered
rather than dropped. This is the shape the deadlock guard already used for
the busy-lock case; only the free-lock case leaked.

Important — priority was scoped to the scanned prefix. Collection stopped at
the first 200 runnable rows by age and ranked only those, so a fresh
critical or recovery wake behind 200 older low-priority rows was never
considered. That contradicts the dispatch formula, which deliberately ranks
fresh critical (0-1) ahead of aged non-critical (2). Ranking now covers the
whole scanned window. Rank keys are precomputed once per row, which makes
this cheaper than the old code: the comparator used to re-parse both sides'
contextSnapshot on every comparison, O(N log N) JSON parses, now O(N).

Self-caught regression — the previous commit skipped dependency-blocked rows
at collection, on the premise that claimQueuedRun would only refuse them.
The claim gate does more than refuse: it cancels the run with
issue_dependencies_blocked, marks the wakeup skipped, and releases the
issue's execution lock. Skipping stranded all three, leaving issue locks
held by runs that would never start, and bypassed the gate's
interaction-wake exemption. Blocked rows are collected again; they cannot
crowd out runnable work because an unready run ranks 12+, below everything
runnable, and collection no longer stops at a candidate count. This restores
the behavior pinned by #419 and its two dependency-scheduling tests, which
were failing on the previous head.

Tests, each verified to fail against the prior code:
- 2,010 blocked rows past the ceiling followed by runnable work.
- 210 older low-priority rows followed by one critical row; the adapter is
  gated so the assertion pins the *first* claim rather than eventual
  dispatch (without the gate every run completes instantly and the critical
  row starts anyway after ~11 completions, so the test passed with the bug).
- The depth-cap test asserted the fifth callback never ran, i.e. it codified
  the bug; it now asserts the chain is cut but the work still runs.

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

allyblockcast Bot commented Aug 1, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head c5d9266cb — focus on the resume-cursor termination argument and on whether collecting dependency-blocked rows again is the right call.

All three findings were correct and are fixed. One of them also led me to a regression that this review had scored as a strength, so please read that part critically.

Critical — prefix starvation at the hard ceiling — fixed

You were right that paging only moved the cliff from 200 rows to 2,000. A pass that hit MAX_SCAN_BATCHES with everything unclaimable pruned nothing, claimed nothing, and — because scheduleFollowUpDispatchAfterPrune returns early on prunedRuns <= 0 — scheduled nothing either. The next pass restarted at cursor null and rescanned the identical prefix.

A pass that stops at the ceiling without exhausting the queue now records its keyset boundary in a per-agent resume cursor and schedules a continuation from there.

Termination argument, since this is the part worth attacking: each resumed pass advances the (created_at, id) cursor strictly forward through a finite queue, and all three exit conditions clear the cursor — scan exhausted, a run claimed, or MAX_RESUME_PASSES tripped — so the following pass restarts at the head. The cap only binds when the queue is deeper than 20k rows and every row in it is unclaimable; restarting at the head is the right fallback there, since the rows nearest the head are the likeliest to have become ready. It logs at error when that happens.

Test: 2,010 blocked rows past the ceiling followed by runnable work. Verified it fails on the prior head (expected 'queued' not to be 'queued').

Important — depth guard dropped demand — fixed

Correct, and the test at heartbeat-start-lock.test.ts was codifying the bug rather than catching it. The guard returned onCoalesced() before consulting runningByAgent, so with the target's own lock free there was nothing to fold into. It now detaches the pass to top level: the call stack still stops growing, but the work is registered. That is the same shape the deadlock guard already used for the busy-lock case — only the free-lock case leaked.

The test now asserts the chain is cut (top-level call still returns depth-capped, not leaf) while all six agents' sections run. Verified it fails on the prior head: only 4 of 6 ran.

Important — priority scoped to the prefix — fixed

Agreed, and the aging formula makes the intent explicit: fresh critical ranks 0-1 while aged non-critical ranks 2, so a fresh critical row is designed to preempt 200 aged rows. Collection stopping at the first 200 by age silently prevented that.

I took the "bounded top-K across the examined queue" option, with K = the scanned window: the candidate-count stop is gone and ranking covers everything scanned. Rank keys are now precomputed once per row, which makes this cheaper than before — the comparator used to re-parse both sides' contextSnapshot on every comparison, so an N-row sort cost O(N log N) JSON parses; it is now O(N).

Test: 210 older low-priority rows, then one critical row. Worth noting how the first version of this test fooled me — it asserted the critical run was eventually dispatched, and passed with the bug, because every run completes instantly in the fixture, each completion re-dispatches, and after ~11 completions the queue is short enough that the critical row falls inside the prefix anyway. The regression is when, not whether. The test now gates the adapter so the agent's single slot stays busy, and asserts the very first claim. That version does fail on the prior head.

Regression this review endorsed — please re-check me here

The review listed as a strength: "Dependency-blocked rows are skipped without being cancelled, and claim-time readiness remains the final race-safe gate." That was my framing from the previous round, and it was wrong.

claimQueuedRun's dependency gate does not merely refuse a blocked run. It calls cancelQueuedRunForBlockedDependencies, which cancels the run with issue_dependencies_blocked, marks the wakeup skipped, and releases the issue's execution lock. Skipping those rows at collection stranded all three — leaving executionRunId pinned by a run that would never start, which is the same class of stuck state this ticket exists to fix — and it also bypassed the gate's allowsIssueInteractionWake exemption, which lets some blocked runs legitimately proceed.

Two pre-existing tests from #419 were failing on 45a559ed3 as a result. They live in the serialized shards, which had not run yet when the review was posted:

  • heartbeat-dependency-scheduling.test.tscancels stale queued runs when issue blockers are still unresolved
  • heartbeat-dependency-scheduling.test.tskeeps blocked descendants idle until their blockers resolve

Blocked rows are collected again. They cannot crowd out runnable work by ordering — an unready run ranks 12 + priorityRank, below everything runnable — and the thing that actually caused the crowding was the fixed candidate-count cap, which is what had to go. So the two fixes compose rather than conflict.

Known cost, stated plainly

Because blocked rows are candidates again, a pass over a wall of them walks the claim loop cancelling each, up to the 2,000-row scan bound. That is bounded work per pass and strictly better than master, which loaded the entire queue with no bound at all — but it is not cheap, and with the lock no longer bypassing on timeout it manifests as a longer-held lock rather than concurrent passes. I would rather land the correctness fix and bound the claim walk separately than grow this PR further; say the word if you disagree.

Verification

  • heartbeat-queued-backlog-convergence (5), heartbeat-dependency-scheduling (12), heartbeat-dispatch-priority-sort, heartbeat-api-tier-dispatch-fence, heartbeat-pending-image-bump, heartbeat-start-lock (7) — all green locally.
  • tsc --noEmit clean.
  • Every new/changed test was run against the prior code first and confirmed to fail.

@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: c5d9266

Prior Findings Dispositioned (3)

  • prior:899efb4 critical 2 — still-present — server/src/services/heartbeat.ts:17909 — The resume path advances correctly through the first nine continuations, but the tenth-pass cap deletes the cursor and returns false without scheduling the promised head restart. With more than 20,000 unclaimable rows and runnable work behind them, no run is claimed, no terminal row is pruned, and no further pass is triggered.
  • prior:45a559e important 1 — fixed — server/src/services/agent-start-lock.ts:160 — A free target at the nesting limit is now started through heldAgentIds.exit, while a busy target gets a coalesced follow-up; both paths preserve demand without extending the held-lock chain.
  • prior:45a559e important 2 — fixed — server/src/services/heartbeat.ts:18040 — Rank keys are precomputed for every row in the bounded scanned window, and collection no longer stops after the first 200 candidates, so the covered regression now compares the newer critical row against all 210 older low-priority rows.

Critical Issues (1)

  • [prior:899efb4 critical 2] server/src/services/heartbeat.ts:17909 — The hard-cap fallback does not actually restart from the head. At passes >= MAX_RESUME_PASSES, the code clears dispatchResumeCursorByAgent and returns; finishPassWithoutClaims then only invokes the prune follow-up, which is a no-op when the 20,000 scanned rows are dependency-blocked. The queue therefore stalls until an unrelated wake, contradicting the stated termination argument.
    • Schedule one detached head-restart pass when the cap trips, with separate state to prevent an unbounded immediate restart loop, or make the cap transition to an explicit delayed retry. Add a test with more than 20,000 blocked rows and runnable work behind the cap.

Suggestions (1)

  • [gstack/review] server/src/services/heartbeat.ts:17853 — Keeping dependency-blocked rows in the candidate set is correct under the current claim contract: cancellation releases executionRunId, updates the wakeup, and preserves interaction-wake exemptions. The resulting sequential cancellation walk can still hold the strict agent lock for up to 2,000 rows, so consider a separately bounded cleanup budget after this correctness fix lands.

Strengths

  • The depth guard now cuts recursive lock growth without dropping the target agent’s dispatch demand.
  • The priority regression test pins the first claim with a held adapter slot, so it distinguishes prompt preemption from eventual dispatch.
  • Restoring dependency-blocked candidates corrects the stranded execution-lock regression from the prior head.

Recommended Action

  1. Fix the remaining Critical resume-cap issue before merge.
  2. Consider the blocked-row cleanup budget opportunistically.

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

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/test
/ally review

@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: 5451191

Prior Findings Dispositioned (1)

  • prior:899efb4 critical 2 — fixed — server/src/services/heartbeat.ts:17940 — The resume-cap path now preserves the latest keyset cursor, resets the immediate-pass counter, and schedules a delayed continuation, so a static backlog beyond 20,000 unclaimable rows no longer stops at the cap.

Important Issues (2)

  • [gstack/review, native-codex] server/src/services/heartbeat.ts:17924 — A queued row can be stranded when it becomes eligible behind an active resume cursor. Scheduled retries retain their original created_at; if one is promoted after the resumed scan has passed that key, its dispatch request coalesces into the already-scheduled follow-up. That follow-up continues after the cursor, and when it exhausts the queue this branch clears the cursor without forcing a head pass, consuming the coalesced demand while never seeing the promoted row.
    • Track dispatch demand that arrives during a resumed scan and force one head pass after exhaustion, or version queue mutations so any queued transition at or behind the cursor schedules a final head rescan. Add a concurrent promotion regression test.
  • [gstack/review] server/src/services/agent-start-lock.ts:209 — A coalesced callback admitted before shutdown can claim new work after stopDispatch() has begun quiescing. startNextQueuedRunForAgent checks dispatchStopped before entering the lock; this follow-up executes the previously captured callback only after the current section releases, without rechecking the fence. A long dispatch overlapping SIGTERM can therefore start a new run during the setup-drain window the shutdown path is intended to close.
    • Recheck dispatchStopped inside the locked callback before cleanup/scanning and before claims, and cover a waiter admitted before stopDispatch() whose callback starts afterward.

Strengths

  • The resume-cap correction now has an explicit 20,000-row regression test and preserves forward cursor progress across the delayed yield.
  • The strict single-flight lock removes the former timeout-based mutual-exclusion bypass while handling same-agent re-entrancy and cross-agent lock cycles.
  • Status-gated CAS cleanup prevents stale cancellation passes from overwriting runs another dispatcher already claimed.
  • The migration rejects unsafe inline index creation on populated tables unless the exact concurrent prerequisite index is already valid.

Recommended Action

  1. Address the two Important liveness races before merge.
  2. Re-run the queued-dispatch and shutdown suites after the fixes.

Track queued-run dispatch demand that folds into a resumed scan, force one head pass when that cursor exhausts, and re-check the shutdown fence inside locked dispatch callbacks before scanning or claiming work.

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

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/ally review

@kkroo

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/test

@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: 5dbe6a4

Prior Findings Dispositioned (2)

  • prior:5451191 important 1 — still-present — server/src/services/heartbeat.ts:17934 — A resumed pass still clears both the cursor and dispatchHeadRescanDemandByAgent whenever it claims any unrelated tail run. Demand that arrived behind the cursor is therefore consumed without the promised head rescan; the same gap also exists when demand arrives during the initial bounded pass before a persistent cursor has been installed.
  • prior:5451191 important 2 — fixed — server/src/services/heartbeat.ts:17665 — A coalesced callback now rechecks dispatchStopped immediately after acquiring the lock, and the claim loop checks the fence again before each claim, so a callback admitted before shutdown no longer starts new work after quiescing begins.

Important Issues (3)

  • [prior:5451191 important 1] server/src/services/heartbeat.ts:17934 — Coalesced demand behind the resume cursor can still be discarded. claimedCount > 0 enters this branch, deletes the recorded head-rescan demand, and relies on completion of the unrelated claimed run to revisit the queue; a long-running or stuck external run can leave the newly eligible row stranded. Preserve and schedule the head rescan whenever demand was recorded, regardless of whether the resumed pass claimed another row, and cover demand arriving both before cursor installation and during a resumed pass.
  • [gstack/review] server/src/services/agent-start-lock.ts:142 — Same-agent re-entry still returns onCoalesced() without registering follow-up work. This is reachable after the outer pass has captured its queue: claim-time daily-cap cancellation calls releaseIssueExecutionAndPromote, which queues a replacement and re-enters dispatch for the same agent. The replacement is absent from the captured candidates, and a no-claim/no-prune pass schedules nothing, so it can wait for an unrelated wake. Register a coalesced follow-up for same-agent re-entry and add a promotion-during-claim regression test.
  • [pr-review-toolkit/code, native-codex] server/src/services/heartbeat.ts:18165 — Priority remains scoped to the current 2,000-row scan window. Any successful claim clears forward traversal and the completion pass restarts at the head, so a critical run behind more than 2,000 runnable low-priority rows is never ranked against them and can starve under sustained backlog. Preserve bounded forward traversal across claims or select a bounded global top candidate, and test more than SCAN_LIMIT * MAX_SCAN_BATCHES runnable low-priority rows followed by a critical row that must be claimed first.

Strengths

  • The queued-status CAS prevents cleanup from overwriting runs already claimed by another dispatcher.
  • The strict lock removes the timeout-based mutual-exclusion bypass and handles cross-agent wait cycles explicitly.
  • The keyset cursor includes the id tiebreaker, and the migration verifies the exact concurrently precreated partial index on populated databases.
  • The driver timestamp coercion and deep blocked-backlog tests target concrete production failure modes.

Recommended Action

  1. Fix the three Important liveness issues before merge.
  2. Re-run the queued-dispatch, promotion, and shutdown suites after the changes.

Register same-agent re-entrant dispatches as coalesced follow-up work, keep bounded-scan cursors across claims, and include critical/recovery queued runs outside the scan prefix so priority is not limited to the oldest 2,000 rows.

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

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/test

@kkroo

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/ally review

@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: 6b1e0f7

Prior Findings Dispositioned (3)

  • prior:5451191 important 1 — still-present — server/src/services/heartbeat.ts:17991 — Coalesced head-rescan demand is now retained when a resumed pass claims unrelated work, but this branch still schedules no continuation. For a multi-slot agent, one long-running claim can therefore leave another free slot and newly eligible work behind the cursor idle until an unrelated wake or completion.
  • prior:5451191 important 2 — fixed — server/src/services/agent-start-lock.ts:147 — Same-agent re-entry now records demand and registers a detached coalesced follow-up against the active marker, so promotion during a held dispatch pass is no longer dropped.
  • prior:5dbe6a4 important 3 — still-present — server/src/services/heartbeat.ts:17929 — The new emergency lane moves the global-priority bound but does not remove it: it selects the oldest 200 critical/recovery rows before dispatch ranking. More than 200 older recovery rows can still exclude a newer critical row that should rank ahead of them.

Critical Issues (1)

  • [pr-review-toolkit/code] server/src/services/heartbeat.ts:17913 — The priority-lane join compares context_snapshot ->> 'issueId' (text) directly with issues.id (uuid). PostgreSQL has no text = uuid operator, so any dispatch pass reaching this query fails before claiming work. The existing queries at heartbeat.ts:24948 and :24973 correctly compare issues.id::text for this reason.
    • Compare against ${issues.id}::text rather than casting untrusted JSON to UUID, and add an embedded-Postgres test that executes the lane query.

Important Issues (3)

  • [prior:5451191 important 1] server/src/services/heartbeat.ts:17991 — A claim preserves the resume cursor and head-rescan flag but returns without scheduling either scan. With maxConcurrentRuns > 1, a pass that finds one runnable row in its bounded window can leave remaining capacity unused while runnable or newly promoted work lies behind the cursor.
    • If the scan was not exhausted and capacity remains, schedule the bounded continuation; if head-rescan demand is set, ensure the continuation eventually performs that head pass independent of the claimed run completing. Add a multi-slot regression with one held claim and runnable work beyond the scan boundary.
  • [prior:5dbe6a4 important 3] server/src/services/heartbeat.ts:17929ORDER BY created_at, id LIMIT 200 is applied before the normal dispatch rank, so the emergency lane does not provide global priority. A fresh critical run beyond 200 older recovery rows is omitted even though it ranks above aged non-critical recovery work.
    • Order the bounded lane by the emergency priority semantics or query critical and recovery lanes separately. Extend the regression to 200+ older recovery rows followed by a critical row outside the chronological scan window.
  • [gstack/review/sql] server/src/services/heartbeat.ts:17899 — The added lane is not operationally bounded by its LIMIT 200. The new index covers (agent_id, status, created_at, id), but the query filters and joins through JSON extraction plus issue priority; when matches are sparse or absent PostgreSQL may inspect the agent's entire queued backlog while the strict per-agent lock is held.
    • Materialize/index the lane attributes or move the lookup outside the critical section with a genuinely bounded candidate source. Validate the production-scale zero-match plan with EXPLAIN (ANALYZE, BUFFERS).

Strengths

  • Same-agent re-entry now preserves dispatch demand and has direct regression coverage.
  • Keeping the keyset cursor across claims avoids repeatedly restarting bounded traversal from the oldest rows.
  • The first-claim priority test correctly holds the adapter slot, so it tests ordering rather than eventual completion.

Recommended Action

  1. Fix the Critical SQL type mismatch before merge.
  2. Address the Important liveness and bounded-query issues this cycle.

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

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/ally review

@kkroo

kkroo commented Aug 1, 2026

Copy link
Copy Markdown

/test

@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: 063c40b

Prior Findings Dispositioned (4)

  • prior:5451191 important 1 — still-present — server/src/services/heartbeat.ts:17991 — A successful partial claim preserves the forward cursor but schedules no continuation; with capacity remaining, deeper runnable work waits for the claimed run to complete.
  • prior:5dbe6a4 important 3 — still-present — server/src/services/heartbeat.ts:17929 — The emergency lane still orders by age and truncates to 200 before application-level dispatch ranking, so older recovery rows can exclude a newer critical row.
  • prior:6b1e0f7 critical 1 — fixed — server/src/services/heartbeat.ts:17913 — The JSON text value is now compared with issues.id explicitly cast to text, removing the PostgreSQL text = uuid operator error.
  • prior:6b1e0f7 important 3 — still-present — server/src/services/heartbeat.ts:17913 — The lane still joins and filters through unindexed JSON expressions; LIMIT 200 bounds returned rows, not the backlog rows PostgreSQL may inspect while the per-agent lock is held.

Important Issues (3)

  • [prior:5451191 important 1] server/src/services/heartbeat.ts:17991 — Any successful claim stores the resume cursor and returns without scheduling another bounded pass. heartbeat.ts:18214 then relies on completion of the claimed run, so a long-lived run can leave an otherwise free slot and runnable work beyond the cursor idle indefinitely.
    • Schedule a continuation when the scan is not exhausted and claimedRuns.length < availableSlots; cover a two-slot agent with one long-running claim and runnable work beyond the scan window.
  • [prior:5dbe6a4 important 3] server/src/services/heartbeat.ts:17929 — The emergency query combines critical and recovery rows, selects the oldest 200, and only then applies dispatch ranking. More than 200 older recovery rows can therefore hide a newer critical row outside the ordinary scan window.
    • Rank emergency candidates before limiting, or query critical and recovery lanes separately; test 200+ older recovery rows followed by a newer critical row that must be claimed first.
  • [prior:6b1e0f7 important 3] server/src/services/heartbeat.ts:17913 — The new (agent_id, status, created_at, id) index cannot accelerate the JSON issue-id join or recovery predicates. With sparse or zero emergency matches, PostgreSQL may scan the agent's entire queued backlog under the strict start lock despite the result limit.
    • Materialize/index typed lane attributes or use a genuinely bounded indexed candidate source, then validate the production-scale zero-match plan with EXPLAIN (ANALYZE, BUFFERS).

Strengths

  • The exact text cast fixes the newly introduced priority-lane query failure.
  • CAS-based queued cleanup avoids overwriting claimed runs and duplicate lifecycle events.
  • The keyset cursor and delayed continuation cover deep unclaimable backlogs without reintroducing the old lock bypass.

Recommended Action

  1. Address the three Important queue-liveness and query-bound issues before merge.
  2. Re-run the deep-backlog, emergency-priority, and multi-slot dispatch suites after the fixes.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 2ec3583. All three findings you carried as still-present at f2c3454ee are now addressed, plus the two you raised against the plan test.

important 1 — coalesced demand before cursor installation. onCoalescedDemand now records head-rescan demand unconditionally. Gating on cursor existence dropped every wake folding into the first bounded pass, because the cursor is installed at the end of that pass. Behind-cursor regression added, built so the exhausting pass prunes nothing — an earlier version put terminal rows in the tail and passed against the bug, because scheduleFollowUpDispatchAfterPrune rescued the row for an unrelated reason.

I deliberately did not suppress on "started at head and exhausted": a row inserted after the final batch read but before the coalesce is invisible to that pass, so that suppression reopens a narrower version of the same hole.

important 3 (x2) — priority lane. Split into two lanes, which fixes the inversion and the scan, because both traced to the same cause. It is not the JSON->text cast, as we both wrote earlier — it is the OR. It spans a column of issues and a JSON field of heartbeat_runs, so no index satisfies it and the already-existing issues_company_priority_idx went unused.

rows inspected time buffers
before (one query, OR) 14,384 21.3 ms 435
lane A (critical) 1 0.205 ms 9
lane B (recovery) + pkey lookup 350 ~0.96 ms 29

Lane A drives from issues_company_priority_idx and joins on the STORED generated column context_issue_id; lane B needs no join at all (recovery-ness is a property of the run) and resolves issues by primary key. Separate budgets, so >200 older recovery rows can no longer evict a newer critical row. No migration needed.

On your "safe typed lookup that cannot throw": the cast direction is uuid -> text, which is total. I did not use issues.id = context_issue_id::uuid — that parses untrusted text per outer row, so one malformed issueId raises invalid input syntax for type uuid and takes dispatch down for that agent, and a ~ guard in WHERE does not fix it because nothing orders it before the join condition. Lane B screens ids through a UUID pattern in JS, so a malformed value is a skipped row.

Your two findings on my plan test were both right and are fixed: rowsInspected summed Actual Rows (rows emitted), so a plan examining 50k rows and filtering to 200 reported 200 — it now adds Rows Removed by Filter / Index Recheck / Join Filter. And requiring the superseded plan to keep containing a Seq Scan was a regression lock on the defect; it is now measured but never asserted.

Also fixed the one red job, which I had previously mislabelled pre-existing. continues after the resume cap is added by this PR and does not exist on master, so it was a merge blocker, not noise. The cost was the fixture: reaching the cap takes scanLimit * maxScanBatches * maxResumePasses = 20,000 queued rows (~80k inserts with issues/wakes/relations). Those bounds are now injectable through HeartbeatServiceOptions; the test uses a scoped service at 2022 with blockedCount still equal to the cap product, so the geometry is identical at 1/250th the size. 9/9 green in 93s, and the cap is still genuinely reached (passes: 2, maxResumePasses: 2, scannedRows: 80).

Worth flagging: I predicted the lane split would fix that timeout, since the old lane seq-scanned all 20k fixture issues per pass. Measured it — it did not. The seq scan was real but was never that test's bottleneck.

Verified: convergence + start-lock 19/19, priority-sort 9/9, no typecheck errors in changed files. Base updated from master.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Prior Findings Dispositioned (3)

  • prior:5451191 important 1 — still-present — server/src/services/heartbeat.ts:18129 — Any successful claim preserves the forward cursor but schedules no continuation. If a pass has capacity for two runs, claims one long-running row, and has only rejected/deferred candidates left in its current window, runnable work beyond the cursor remains idle until that claim completes or an unrelated wake arrives.
  • prior:5dbe6a4 important 3 — fixed — server/src/services/heartbeat.ts:17986 — Critical and recovery work now use independent lane queries and budgets, so 200 older recovery rows no longer exclude a newer critical row from consideration.
  • prior:6b1e0f7 important 3 — still-present — server/src/services/heartbeat.ts:18029 — The recovery lane still filters unindexed JSON fields. In the zero-match case PostgreSQL must inspect the agent's entire queued set before returning zero; LIMIT 200 bounds output, not work performed while the strict per-agent lock is held.

Important Issues (2)

  • [prior:5451191 important 1] server/src/services/heartbeat.ts:18129 — A partially filled dispatch pass abandons its bounded continuation. advanceOrClearResumeCursor stores scanCursor and returns false whenever claimedCount > 0, and the caller at server/src/services/heartbeat.ts:18352 schedules nothing. Existing tests cover zero claims, one fully occupied slot, or two claims filling two slots, but not one non-completing claim with another slot free and runnable work beyond the cursor.
    • Schedule a detached cursor continuation when the scan is not exhausted and claimedRuns.length < availableSlots, subject to the existing resume cap. Add that exact two-slot regression.
  • [prior:6b1e0f7 important 3] server/src/services/heartbeat.ts:18029 — Splitting the priority query removes the company-wide issue scan, but the recovery predicate remains unindexed and the new plan test explicitly accepts O(agent queue depth). A deep non-recovery backlog therefore still creates an unbounded zero-match scan inside the serialized critical section.
    • Materialize/index the recovery lane attributes, or source the lane from a genuinely bounded indexed candidate set. Assert a fixed inspected-row ceiling against a backlog substantially larger than SCAN_LIMIT.

Strengths

  • The critical/recovery lane split removes the prior 200-row cross-lane priority inversion and uses the generated issue-id column for the critical join.
  • The exact-head query-plan test now verifies keyset predicates remain in the dispatch index condition rather than becoming post-filters.
  • The coalesced-demand regressions exercise both demand arriving before cursor installation and demand arriving behind an established cursor.

Recommended Action

  1. Address the two remaining Important liveness and query-bound issues before merge.
  2. Re-run the deep-backlog, two-slot, and embedded-Postgres plan suites after the fixes.

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot approve it. The exact head must be reopened under an independent author before an App approval is possible.

kkroo and others added 2 commits August 1, 2026 17:57
…lane (BLO-20396)

Review follow-up on the two remaining Important findings.

1. A partially filled dispatch pass abandoned its continuation.

advanceOrClearResumeCursor stored the forward cursor and returned false
whenever anything was claimed, and the caller scheduled nothing, on the
reasoning that a claim re-triggers dispatch when it completes. That only
covers the slot the claim occupied. A pass with two free slots that claims
one long-running row and refuses the rest of its window stops with a slot
free — not out of capacity, but out of candidates in that window. Nothing
then looks past the cursor: no completion fires for a slot that never
started, and a pass that pruned nothing does not schedule the prune
follow-up either. The runnable row waits for the claim to finish, which on
this fleet means a ~50-minute review.

It now schedules a cursor continuation when the scan is not exhausted and
claimedCount < availableSlots. The chain terminates on two independent
bounds: the cursor only advances forward through a finite queue, and each
claim CAS-flips a row to running, so successive passes recompute a strictly
smaller availableSlots and dead-end at the availableSlots <= 0 return.

Regression test asserts exactly that geometry, with the first claim held
open for the duration so the continuation is the only thing that could
dispatch the third run. Verified as a true regression: reverting only the
four-line fix fails it (dispatched Array(1), missing the row beyond the
cursor).

2. The recovery lane's predicate was unindexed.

Lane B filters two jsonb expressions, so 0208's index supplied the agent's
queued rows in dispatch order and the executor filtered them one at a time.
With no recovery work — the common case — nothing lets the LIMIT stop
early, so PostgreSQL walked the agent's entire queued set to return zero
rows, while the strict per-agent start lock was held.

Migration 0209 adds a partial index on the lane's own predicate, so the
zero-match case is an empty index range rather than a filtered walk.
Measured on a 5,000-row non-recovery backlog (25x SCAN_LIMIT): the lane now
uses heartbeat_runs_recovery_dispatch_idx with Index Cond on agent_id
alone, and inspects 0 rows in 0.9 ms. The new test asserts a FIXED ceiling
independent of queue depth, which is the property that distinguishes
"bounded" from "bounded by queue depth"; other agents' recovery rows are
seeded so the index is not globally empty, since any plan is cheap against
an empty index.

0209 follows 0208's online-precreation guard. Its predicate check is
structural rather than an exact pretty-printed string: the real rendering
was measured, and it parenthesizes the second AND operand but not the
first, which is the kind of detail a hand-written expected string gets
wrong — and getting it wrong fails the migration for an operator who
precreated correctly. Four guard tests cover it, including that the exact
command the hint prints is accepted, and that a predicate degenerated to
status = 'queued' is rejected.

Also flushes the plan report on every record, so the plans survive a
failing assertion instead of being written only after the asserts pass.

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

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 219d780b2 — both remaining Important findings addressed. Please focus on the termination argument for the new continuation, and on whether the 0209 guard's structural predicate check is strong enough.

1. prior:5451191 — partial pass abandons its continuation — fixed

Confirmed as described. advanceOrClearResumeCursor returned false on any claim and the caller discarded the result, so the geometry you named had nothing scheduling a look past the cursor.

Worth stating why the existing rationale was wrong rather than merely incomplete: "a claim re-triggers dispatch on completion" covers only the slot the claim occupied. A pass that claims fewer runs than it has slots stopped because it ran out of candidates in that window, not capacity — and nothing else was going to look further. No completion fires for a slot that never started, and a pass that pruned nothing skips the prune follow-up too.

It now schedules a continuation when the scan is not exhausted and claimedCount < availableSlots. Termination rests on two independent bounds: the cursor only advances forward through a finite queue, and each claim CAS-flips a row to running, so each successive pass recomputes a strictly smaller availableSlots and the chain dead-ends at the availableSlots <= 0 early return within at most the slot count. passes is carried forward rather than reset, so an alternating claim/no-claim queue still converges on the resume cap.

Added the two-slot regression you asked for, with the first claim held open for the duration of the test — so the continuation is the only thing that could possibly dispatch the third run. I verified it is a true regression rather than a coincidence by reverting only the four-line fix and re-running:

AssertionError: expected [ Array(1) ] to include '9034acf1-…'

One run dispatched, the row beyond the cursor stranded. With the fix, 10/10 in that file pass.

2. prior:6b1e0f7 — recovery lane predicate unindexed — fixed

Migration 0209 adds a partial index on the lane's own predicate, so the zero-match case is an empty index range rather than a filtered walk. Measured on a 5,000-row non-recovery backlog (25× SCAN_LIMIT):

Limit  (actual time=0.040..0.041 rows=0.00 loops=1)
  ->  Index Scan using heartbeat_runs_recovery_dispatch_idx on heartbeat_runs
        Index Cond: (agent_id = '2222…'::uuid)
        Filter: ((context_snapshot ->> 'recoveryActionId'::text) IS NOT NULL)
-- rows inspected: 0        (was: the agent's entire queued set)

status and source are satisfied by the index predicate, leaving agent_id as the only Index Cond. The new assertion is an absolute ceiling (50) that is deliberately not derived from queue depth — being independent of it is the property under test — plus a check that the ceiling sits far below the backlog it was measured against, so a regression to row-by-row filtering cannot pass by coincidence. Other agents' recovery rows are seeded so the index is not globally empty; against an empty index every plan is cheap and "inspected 0" would prove nothing.

This went in as a separate it() with its own focused fixture rather than by deepening the existing one — see the scope note below for why that turned out to matter.

Notes on things I deliberately did not do

The 0209 guard checks its predicate structurally, not by exact string. 0208 compares a hardcoded pretty-printed predicate; I did not copy that for a two-clause AND, because a wrong guess fails the migration for an operator who precreated the index correctly. I measured the real rendering instead of assuming it:

status = 'queued'::text AND (context_snapshot ->> 'source'::text) = 'issue_recovery_action'::text

Note it parenthesizes the second operand but not the first — which is what I'd have gotten wrong. The guard asserts key columns exactly and the predicate by required components. Four tests cover it, including that the exact command its own hint prints is accepted, and that a predicate degenerated to status = 'queued' (right columns, useless index) is rejected. If you'd rather have the exact-string comparison now that the value is measured, say so and I'll tighten it.

I found a third, adjacent problem and am filing it separately rather than expanding this PR. Deepening the shared fixture to satisfy your "substantially larger than SCAN_LIMIT" request broke the head-scan assertions, and the reason is worth recording: the planner estimates agent_id = X AND status = 'queued' by multiplying selectivities as if independent. An agent's queued rows are ~all queued, so it underestimates badly — measured rows=22 against actual rows=1000, 45× low. Past a certain depth it therefore switches to Bitmap Heap Scan + top-N sort, which reads the agent's entire queued set to return LIMIT 200. That is the same "LIMIT bounds output, not work" shape as this finding, one level up.

The existing head-scan assertions currently pass because of that underestimate (at 350 rows the index scan is costed for only 22 heap fetches). I prototyped extended statistics on (agent_id, status); it fixed the estimate cleanly (rows=1020 vs 1000 actual) but then flipped the plan to a BitmapAnd — so it needs its own evaluation and cost-model work, not a late add to an eight-round PR. Filed with the measured plans. Reverted the fixture to its reviewed state so nothing here is recalibrated silently.

Authorship gate unchanged. Still authored by app/allyblockcast, so an App approval is not possible on this head — noted, not something I can resolve from here.

Verification

  • heartbeat-dispatch-priority-sort.test.ts — 10/10 pass; new test fails without the fix (negative control above)
  • heartbeat-dispatch-query-plan.test.ts — 2/2 pass
  • heartbeat-runs-recovery-dispatch-index-migration.test.ts — 4/4 pass (new)
  • heartbeat-runs-agent-dispatch-index-migration.test.ts — 3/3 pass (unchanged)
  • pnpm --filter @paperclipai/{server,db} typecheck clean; migration numbering + safety checks pass

Rebased onto ad38cb99f (Omar's master merge) — clean, no conflicts, no force-push.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Prior Findings Dispositioned (3)

  • prior:5451191 important 1 — fixed — server/src/services/heartbeat.ts:18168 — A successful pass that claims fewer runs than availableSlots now schedules a bounded cursor continuation, and the held-open two-slot regression proves deeper work starts without waiting for the first claim to complete.
  • prior:5dbe6a4 important 3 — fixed — server/src/services/heartbeat.ts:17998 — Critical issue work now has its own indexed lane and budget, separate from recovery rows, so 200 older recovery wakes can no longer exclude a newer critical run before ranking.
  • prior:6b1e0f7 important 3 — fixed — packages/db/src/migrations/0209_heartbeat_runs_recovery_dispatch_index.sql:129 — The recovery lane now has a partial (agent_id, created_at, id) index restricted to queued recovery-action rows, making a correctly installed zero-match lookup an empty index range rather than a walk of the agent queue.

Critical Issues (1)

  • [gstack/review, native-codex] server/src/services/heartbeat.ts:18136 — The head-rescan continuation deterministically re-arms itself. scheduleDetachedDispatchPass(..., "resume_head_rescan_after_coalesced_demand") runs while the current agent lock is still registered, so withAgentStartLock coalesces it and invokes onCoalescedDemand; because this reason is not marked resumeContinuation, heartbeat.ts:18446 adds dispatchHeadRescanDemandByAgent again. The follow-up head pass then exhausts, consumes the marker, schedules another head pass, and repeats indefinitely even when no external demand arrives.
    • Mark internally scheduled head rescans so coalescing does not record them as new demand, or enqueue the pass only after lock release. Add a regression that seeds one coalesced demand, lets the trailing head pass exhaust, and asserts dispatch quiesces after exactly that pass.

Important Issues (1)

  • [gstack/review/sql, pr-review-toolkit/code] packages/db/src/migrations/0209_heartbeat_runs_recovery_dispatch_index.sql:104 — The prerequisite-index guard is fail-open because three independent LIKE checks verify token presence, not predicate semantics. For example, WHERE status = 'queued' OR (context_snapshot ->> 'source') = 'issue_recovery_action' and a predicate using <> 'issue_recovery_action' both satisfy lines 104-106 while indexing the wrong row set; the former restores the full queued-row scan 0209 is intended to prevent, and the latter omits the rows the lane needs.
    • Compare against the exact measured canonical predicate (or an equivalent parsed expression tree), including the AND and equality operators, and add rejection tests for broader OR, wrong-operator, and extra-clause predicates.

Strengths

  • The partial-claim regression keeps the first claim open, so it proves the continuation rather than relying on completion-triggered dispatch.
  • Splitting critical and recovery lanes removes the shared-limit priority inversion while preserving independent bounded budgets.
  • The 0209 rollout follows the safe online-precreation pattern and validates index validity, access method, key order, and sort options.

Recommended Action

  1. Fix the self-rearming head-rescan loop before merge.
  2. Make the 0209 prerequisite predicate check fail closed.
  3. Because this PR is authored by app/allyblockcast, reopen the exact reviewed head under an independent author before an App approval can be submitted.

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

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

/test

@ally review exact head 1adcde8b5dccb3ad28a68a87ba80bd4ec43c9434. I addressed the two latest findings from the 219d780 review:

  • Critical head-rescan loop: internally scheduled resume_head_rescan_after_coalesced_demand passes now suppress head-rescan-demand recording when they coalesce, so the trailing head pass cannot re-arm itself. Added a deterministic regression with tiny dispatch bounds that holds the first pass open, forces a coalesced wake, and verifies exactly one internal head rescan runs.
  • Important 0209 predicate guard: the migration now compares pg_get_expr(indpred, indrelid, TRUE) against the measured canonical predicate exactly instead of independent LIKE tokens. Added rejection coverage for broader OR, wrong operator, and extra-clause predicates.

Local verification:

  • pnpm --filter @paperclipai/server typecheck
  • pnpm --filter @paperclipai/db typecheck (includes migration numbering/safety)
  • git diff --check

Embedded Postgres suites were invoked but skipped on this host with the local Postgres init-script failure, so CI needs to execute the new regression tests.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Prior Findings Dispositioned (2)

  • prior:219d780 critical 1 — fixed — server/src/services/heartbeat.ts:17683 — Internally scheduled resume_head_rescan_after_coalesced_demand passes now carry suppressHeadRescanDemand; both the pre-lock cursor check and the coalesced-demand callback honor it, so the trailing head pass cannot re-arm itself. The regression forces this pass to coalesce and asserts exactly one internal head rescan.
  • prior:219d780 important 1 — fixed — packages/db/src/migrations/0209_heartbeat_runs_recovery_dispatch_index.sql:103 — The prerequisite guard now compares the normalized catalog predicate with the measured canonical predicate exactly. The migration tests reject the broader OR, wrong-operator, and extra-clause definitions that previously passed the independent token checks.

Looks good. No Critical or Important issues remain in the reviewed diff.

Strengths

  • The head-rescan regression controls the lock timing and verifies quiescence rather than relying on eventual queue cleanup.
  • The migration guard now fails closed while retaining the online-precreation requirement for populated databases.
  • CAS-based queue cleanup and bounded keyset continuation preserve serialization without reviving the former timeout bypass.

Recommended Action

  1. Let the exact-head CI run complete.
  2. This PR is authored by app/allyblockcast; the exact head must be reopened under an independent author before the Ally GitHub App can submit the gate-authorizing approval.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Prior Findings Dispositioned (3)

  • prior:5451191 important 1 — fixed — server/src/services/heartbeat.ts:18208 — A pass that claims fewer runs than availableSlots now schedules a detached bounded-scan continuation, so remaining capacity no longer waits for the claimed run to complete.
  • prior:5dbe6a4 important 3 — fixed — server/src/services/heartbeat.ts:18074 — Critical and recovery candidates now use separate queries and independent limits, so older recovery rows can no longer consume the critical lane's candidate budget.
  • prior:6b1e0f7 important 3 — fixed — packages/db/src/migrations/0209_heartbeat_runs_recovery_dispatch_index.sql:133 — Queued recovery-source rows now have a dedicated partial index, removing the prior zero-match walk over every ordinary queued row for the agent.

Important Issues (3)

  • [pr-review-toolkit/code, gstack/review] server/src/services/heartbeat.ts:17932 — The main bounded scan sends every non-empty contextSnapshot.issueId directly to a UUID IN predicate. A malformed persisted value such as "not-a-uuid" raises PostgreSQL 22P02 before the cursor is saved; each retry reads the same row and aborts again, wedging all later queued work for that agent. The recovery lookup already UUID-screens this same field at line 18092, but the primary scan does not.
    • Apply the same UUID screening before inArray(issues.id, batchIssueIds) and add an embedded-Postgres regression with a malformed row followed by runnable work.
  • [gstack/review, native-codex] server/src/services/heartbeat.ts:18063 — The critical lane still selects the oldest 200 critical rows before dependency readiness and dispatch ranking. If those rows are dependency-blocked, a newer runnable critical row outside both this lane and the ordinary 2,000-row window is invisible; a lower-priority row in the ordinary window can then occupy the only slot for a long execution. Existing global-priority coverage seeds only one critical lane row.
    • Page or readiness-filter the critical lane so its limit applies to eligible candidates, and cover 200+ blocked critical rows followed by a runnable critical row that must beat ordinary low-priority work.
  • [gstack/review/sql] packages/db/src/migrations/0209_heartbeat_runs_recovery_dispatch_index.sql:133 — The new partial index includes every queued row whose source is issue_recovery_action, while the runtime query additionally post-filters recoveryActionId IS NOT NULL at server/src/services/heartbeat.ts:18082. A historical or malformed recovery-source backlog missing that field can therefore force an unbounded index walk under the strict per-agent lock even though the result is limited to 200; the plan test covers only ordinary non-recovery rows, where the source index range is empty.
    • Include the non-null action-id predicate in the partial index or otherwise bound the source-matching malformed-row scan, and add a plan fixture with a deep recovery-source backlog lacking recoveryActionId.

Strengths

  • The partial-claim continuation now has a held-open two-slot regression that proves progress without relying on completion callbacks.
  • Splitting critical and recovery lanes removes the previously demonstrated cross-lane priority inversion and company-wide issue scan.
  • The start lock preserves same-agent and cross-agent demand without restoring the former timeout bypass.

Recommended Action

  1. Address the three Important queue-safety and bounded-work findings before merge.
  2. Re-run the embedded-Postgres dispatch, priority, and migration-plan suites after the fixes.
  3. This PR is authored by app/allyblockcast; the exact head must be reopened under an independent author before the Ally App can approve it.

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

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

/test

@kkroo

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

@ally review exact head 1434955

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

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

@ally review exact head d6c4abd

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Prior Findings Dispositioned (3)

  • prior:5451191 important 1 — fixed — server/src/services/heartbeat.ts:18279 — A pass that claims fewer runs than its available slots now schedules a bounded continuation, so deeper runnable work no longer waits for the partial claim to finish.
  • prior:5dbe6a4 important 3 — fixed — server/src/services/heartbeat.ts:18049 — Critical issue work and recovery-action work now use independent candidate lanes, so older recovery rows cannot consume the critical lane's budget.
  • prior:6b1e0f7 important 3 — fixed — packages/db/src/migrations/0209_heartbeat_runs_recovery_dispatch_index.sql:132 — The recovery lane's complete queued/source/action predicate now has a verified partial index, making zero-match and malformed-source paths indexed ranges instead of walks over the agent's queued backlog.

Critical Issues (1)

  • [gstack/review, native-codex] server/src/services/heartbeat.ts:18563 — Internally scheduled resume_critical_lane continuations deterministically re-arm their own head-rescan marker. scheduleDetachedDispatchPass is called while the current agent lock is held, so the continuation coalesces and this unconditional line records head demand; the next pass then enters heartbeat.ts:18123, deletes its cursor, and schedules another internal continuation. With more blocked critical rows than one bounded pass, dispatch oscillates between the head and the next window instead of reaching deeper runnable work, while continuously issuing database scans under the strict lock.
    • Suppress critical head-rescan demand for internal critical-lane continuations, as the main cursor path already does for its internal head rescan, and assert that the new blocked-prefix regression completes without repeatedly revisiting the first keyset page.

Important Issues (1)

  • [gstack/review] server/src/services/heartbeat.ts:18492 — The shutdown fence is checked before await claimQueuedRun(...), but stopDispatch() can run while that database claim is in flight. The claim may commit afterward, and executeRun then sees a running row and bypasses its queued-only shutdown guard. drainInFlightRunSetup() can already have observed no setup during that window, allowing a new setup to start as the pod exits.
    • Make shutdown wait for in-flight queue-selection/claim sections before draining setup, or recheck the fence after the claim and atomically return/release the claimed run before launching it. Add a test that pauses the claim across stopDispatch() and verifies no adapter setup begins.

Strengths

  • The strict per-agent lock no longer degrades mutual exclusion after a timeout, and same-agent/cross-agent nested demand is preserved without waiting through a lock cycle.
  • Keyset continuation now advances through deep blocked queues and preserves free-slot utilization after partial claims.
  • The new migrations fail closed on populated databases unless the exact indexes were precreated concurrently.

Recommended Action

  1. Fix the Critical continuation loop before merge.
  2. Address the Important shutdown admission race this cycle.

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot review or approve its own PR. The exact head must be reopened under an independent author before an App approval can satisfy review/ally-complete; the shared User/team approval cannot substitute for that App review.

…g forever

UUID-screening the batch lookups (d6c4abd) stops one malformed persisted
`contextSnapshot.issueId` from aborting a whole dispatch pass, but it leaves
the offending row queued indefinitely: skipped by every batch lookup, it still
reaches the single-issue readiness call in `claimQueuedRun`, which binds it to
a uuid column and raises 22P02 there on every pass for as long as the row
exists. Verified against d6c4abd — the pass survives and valid work starts,
and the malformed row is still `queued` afterwards.

An id that cannot be a uuid can never resolve to an issue, so prune it at the
claim gate with a dedicated `invalid_context_issue_id` code, CAS-ing on
status='queued' so concurrent passes produce one transition and one event —
the same cleanup contract the terminal-issue gate follows.

The regression asserts the convergence half rather than the survival half
already covered by the priority-sort suite: the pass must not throw, the valid
row behind the malformed one must start, AND the malformed row must leave the
queue. It fails on d6c4abd on that last assertion.

Refs BLO-20396
@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

@ally re-review at head 838a6ed74 — focus on the new claimQueuedRun prune gate (invalid_context_issue_id) and its convergence regression.

Disposition of the three Important findings

Omar Ramadan (d6c4abdfc, pushed while I was working the same review) addressed all three. I verified their commit in isolation before adding anything, so this reply is mostly confirmation plus one residual I found empirically.

Finding 1 — malformed contextSnapshot.issueId reaches a uuid IN predicate: correct, and it was wider than reported

Reproduced before fixing, on an embedded-Postgres fixture with a malformed row ahead of runnable work:

PostgresError: invalid input syntax for type uuid: "not-a-uuid"   (code 22P02)
  at heartbeat.ts:17925  →  select ... from "issues" where ... "id" in ($2, $3)

The whole pass rejects, and the scan cursor is a pass local, so the next pass restarts at the head of the queue and aborts on the same row — the wedge is real. Your read that the codebase already knew this hazard is right, and it is documented twice: lane A's cast-direction comment (uuid -> text is total and safe … the tempting inverse is a trap) and the recovery lane's JS screen.

One correction to the finding as written: screening heartbeat.ts:17932 alone is not sufficient. With only that site fixed, my fixture still failed — on a second undefended site one call down, listIssueDependencyReadinessissue_relations.related_issue_id in (...). It surfaced only because the regression asserts the observable property (the pass does not throw) rather than the specific query. d6c4abdfc screens both collectors, which is the right fix.

Finding 1 residual — screening makes it non-fatal, but the row never converges

Verified against d6c4abdfc itself: the pass survives and the valid row starts, but the malformed row is still queued afterwards, and claimQueuedRun:14533 raises 22P02 on it every pass, indefinitely. Screening removes the row from batch lookups; it does not remove the row. Your own suite's malformed test has to UPDATE ... SET status = 'cancelled' at the end to clear it, which is the same observation from the other side.

That is a permanent poison pill against this ticket's acceptance criterion "invalid queued rows converge to zero", so 838a6ed74 prunes it at the claim gate with a dedicated invalid_context_issue_id code, CAS-ing on status='queued' so concurrent passes yield one transition and one event. Regression asserts the convergence half specifically — it fails on d6c4abdfc on exactly that assertion.

Finding 2 — critical lane bounded before readiness: correct, fixed by d6c4abdfc

Now paged with a per-agent cursor (dispatchCriticalLaneCursorByAgent) plus a resume_critical_lane continuation, so the limit applies to eligible candidates rather than to the oldest 200 rows. For the record on severity: the trigger needed >200 dependency-blocked critical rows and a queue deeper than the ordinary window (SCAN_LIMIT 200 × MAX_SCAN_BATCHES 10 = 2,000), against an observed pathology of 339 rows — so this was a real completeness gap in the guarantee rather than something reachable at current scale.

Finding 3 — recoveryActionId IS NOT NULL absent from the partial index: fixed by d6c4abdfc; I had reached the opposite conclusion and defer

I made this exact change, measured the same canonical predicate, then reverted it before Omar's push landed. My reasoning: the file documented the omission as deliberate ("keeps the predicate simple enough for the planner to match reliably"), and all three producers of source: "issue_recovery_action"recovery/service.ts:4100, :4135, :8270 — set recoveryActionId in the same object literal, so no current writer can create the row shape the finding describes.

Omar's rationale is defence-in-depth against historical/malformed rows no current writer produces, which my evidence does not refute — it only shows the backlog cannot be created going forward, not that none exists. Their version is landed and the plan tests pass, so I am not re-litigating it. Flagging only that the "deliberately left OUT" comment was replaced rather than left contradicting the code, which is the right way round.

Verification at 838a6ed74

  • heartbeat-queued-backlog-convergence 11/11 (incl. the 100-row mixed fixture and the new convergence case)
  • heartbeat-dispatch-priority-sort 12/12 — Omar's new critical-lane paging and malformed-lookup tests pass unchanged with the prune gate
  • heartbeat-wake-dispatch-retry + heartbeat-process-recovery 198/198 combined
  • tsc --noEmit -p server clean
  • Negative controls: the new regression fails on the pre-fix build with 22P02, and on d6c4abdfc with expected 'queued' to be 'cancelled'

One caveat worth stating rather than hiding: on one of three runs of the convergence file a single test failed and did not reproduce on re-run. That file has a timing-sensitive sibling-starvation case I flagged earlier on BLO-20396; it is pre-existing, not introduced here, and it deserves its own follow-up.

Recommendation 2 (reopen under an independent author) is not actionable and has now recurred four times. reviewDecision is empty rather than REVIEW_REQUIRED; #928 merged bot-authored with zero approvals; this App only ever submits COMMENTED. Acting on it once already cost a duplicate CI run (#933). It appears to be canned rather than derived from this repo's actual protection — worth suppressing when the observed gate does not match it.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

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

Prior Findings Dispositioned (3)

  • prior:5451191 important 1 — fixed — server/src/services/heartbeat.ts:18307 — A pass that claims fewer runs than available slots now preserves the forward cursor and schedules resume_bounded_scan, so deeper runnable work no longer waits for the partial claim to complete.
  • prior:5dbe6a4 important 3 — still-present — server/src/services/heartbeat.ts:18591 — Critical-lane continuation schedules resume_critical_lane while the agent lock is still held; that internal call coalesces and unconditionally records critical-lane head-rescan demand. The follow-up then consumes that marker at :18151, schedules another continuation under the same held lock, and re-arms the marker again. A critical row beyond the first bounded lane page can therefore loop without reaching claim.
  • prior:6b1e0f7 important 3 — still-present — server/src/services/heartbeat.ts:18107 — Splitting the lanes removes the company-wide issue scan and migration 0209 bounds recovery lookup, but the zero-match critical join can still inspect the agent's entire queued backlog while the strict start lock is held. The plan test explicitly permits this queue-depth bound rather than a scan-limit bound at packages/db/src/heartbeat-dispatch-query-plan.test.ts:394.

Important Issues (2)

  • [prior:5dbe6a4 important 3] server/src/services/heartbeat.ts:18591 — Internally scheduled resume_critical_lane passes are treated as new critical-lane head-rescan demand when they coalesce with the current pass. Each continuation can consequently force the next pass back through the marker branch before a later critical page is claimable.
    Recommendation: Suppress critical-lane demand for internal continuation reasons, analogous to the existing ordinary head-rescan suppression, and add a regression where a runnable critical row lies beyond one page of dependency-blocked critical rows.
  • [prior:6b1e0f7 important 3] server/src/services/heartbeat.ts:18107 — The critical lane remains O(agent queue depth) in the zero-match case despite LIMIT 200; the limit bounds emitted join matches, not queued rows inspected under the lock.
    Recommendation: Add a typed/indexed access path that places an absolute scan bound on critical candidates, and assert inspected rows against SCAN_LIMIT rather than AGENT_QUEUED_ROWS.

Strengths

  • The new invalid_context_issue_id claim gate CAS-cancels malformed persisted issue references, and the regression verifies both valid-work progress and malformed-row convergence.
  • UUID screening now protects both batch issue lookup and dependency-readiness lookup from PostgreSQL 22P02 failures.
  • Migration 0209 fail-closes on an incorrectly defined prerequisite index and makes the common zero-recovery lane lookup absolutely bounded.

Recommended Action

  1. Fix the critical-lane self-rearming continuation before merge.
  2. Bound the zero-match critical query independently of total agent queue depth.

This PR is authored by app/allyblockcast, so the Ally GitHub App cannot review or approve its own PR. Reopen this exact head under an independent author before an App approval can satisfy review/ally-complete.

@allyblockcast

allyblockcast Bot commented Aug 2, 2026

Copy link
Copy Markdown
Author

Superseded by #963 — not closing, flagging so CI isn't spent twice

@kkroo opened #963 as the independently-authored replacement for this PR. I verified the supersession is complete rather than assuming it:

git merge-base --is-ancestor 838a6ed74 e86298cf9   -> true   (this PR's head is an ancestor of #963's)
git rev-list --count 838a6ed74..e86298cf9          -> 1

#963 is this PR's entire commit stack plus one commit (e86298cf9, bounded critical scans + the F1 critical-lane continuation fix). Nothing here is lost by moving to #963 — including 838a6ed74, the invalid_context_issue_id prune that this ticket's "invalid queued rows converge to zero" criterion depends on.

Review has moved to #963 (my notes there). Leaving this open rather than closing it, since the close decision is @kkroo's — but please don't push here or re-request review on this head: CI is currently the fleet rate limiter (BLO-20761), and every run on this branch since b1f2169ea has been cancelled rather than completing, so a cycle spent here is a cycle #963 doesn't get.

Context for anyone reading this later: this PR could not clear the independent-review gate because it is authored by the Ally GitHub App, which is also the reviewing identity — that is the whole reason #963 exists.

@kkroo

kkroo commented Aug 2, 2026

Copy link
Copy Markdown

The exact-head PR workflow for 838a6ed745ca683920540a81e6e98c020db18170 was cancelled by ARC and has now been rerun (30739870807).

@ally please review exact head 838a6ed745ca683920540a81e6e98c020db18170, including disposition of the prior continuation, emergency-lane ranking, and bounded indexed-query findings. #937 remains correctly blocked on this predecessor.

@kkroo
kkroo merged commit 838a6ed into master Aug 3, 2026
5 of 31 checks passed
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.

2 participants