Skip to content

fix(heartbeat): carry the dep-blocked park origin across the interaction-wake cancel (BLO-29729) - #1469

Merged
kkroo merged 1 commit into
masterfrom
sre/blo-29729-dep-blocked-origin-carry
Aug 28, 2026
Merged

fix(heartbeat): carry the dep-blocked park origin across the interaction-wake cancel (BLO-29729)#1469
kkroo merged 1 commit into
masterfrom
sre/blo-29729-dep-blocked-origin-carry

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 22, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The heartbeat scheduler decides when an agent run is dispatched; when an issue's blockers are unresolved it parks the run as a scheduled_retry instead of running it
  • Parks were unbounded in practice: a blocker-set change cancels the pending row and reinserts at scheduledRetryAttempt: 0, so an attempt-only ceiling could be reset forever by churn. BLO-29055 / fix(heartbeat): bound dep-blocked park lifetime by age, not attempts alone (BLO-29055) #1452 fixed that with a 12h wall-clock age ceiling measured from a carried depBlockedFirstParkedAt origin
  • Ally then spotted, and the CTO independently confirmed, that the carry is an in-memory local — and one cancel path doesn't feed it. The blockedInteractionWake branch cancels a park inline, clears issues.executionRunId, and has its re-park suppressed in the same call, so the replacement lands on a later call where nothing remembers the origin and ?? now stamps a fresh one
  • That branch fires precisely when dependencies are blocked and needs only a comment-carrying wake reason, so the rows that evaded the ceiling were the ones under active discussion — the ones someone is actively chasing
  • This pull request carries the origin across that cancel by recovering it from the cancelled row, which still holds it because that cancel never writes contextSnapshot
  • The benefit is that the 12h ceiling actually holds under comment traffic, instead of being reset by the very activity that indicates someone cares about the issue

Linked Issues or Issue Description

What Changed

  • recoverDepBlockedParkOriginAcrossInteractionWake (new, heartbeat.ts) — recovers a dep-blocked park's age origin from the row cancelled by the interaction-wake branch on an earlier call. Zero migration: the origin survives on that row's contextSnapshot, and migration 0104 already indexes (company_id, agent_id, context_issue_id, created_at DESC, id DESC), making the lookup an index seek.
  • Guard 1 — most-recent-terminal-park ordering. Fetch the most recent cancelled dep-blocked park for the issue+agent, then check its errorCode. Ordering-then-checking (rather than filtering in SQL) is load-bearing: it stops an already age-expired episode from matching its older interaction-wake cancel and re-terminating every later re-park in a loop. It also makes cancelStaleScheduledRetry's reset fall out for free.
  • Guard 2 — DEP_BLOCKED_ORIGIN_RECOVERY_MAX_GAP_MS. Bounds the gap (cancel.finishedAt → re-park), not the origin's own age. Deliberate deviation from the 2 × origin-age shape floated on the issue; see Risks.
  • DEP_BLOCKED_INTERACTION_WAKE_CANCEL_CODE — the previously-inline "dep_blocked_interaction_wake" string, named because recovery now matches on it.
  • In-memory carry set in the interaction-wake branch too, so the "a dep-blocked cancel hands its origin forward" invariant holds locally and does not depend on the !blockedInteractionWake suppression guard staying in place.
  • dep_blocked_origin_recovered counter. Surfaces automatically — the Prometheus renderer iterates the counters snapshot.
  • Comments, per the issue's ACs: the carry-reach block on readDepBlockedFirstParkedAt rewritten to describe reality (which paths carry, which reset, and the residual); the promotion-site comment at the age check updated; cancelStaleScheduledRetry given an explicit "RESETS BY DESIGN" note with the argument, rather than a silent third behaviour.
  • Two tests in server/src/__tests__/heartbeat-dependency-scheduling.test.ts.

Verification

pnpm vitest run server/src/__tests__/heartbeat-dependency-scheduling.test.ts \
                server/src/__tests__/metrics-service.test.ts

 Test Files  2 passed (2)
      Tests  89 passed (89)

Plus 106 passing across heartbeat-retry-scheduling, heartbeat-lock-release-on-reassignment, heartbeat-scheduling-suppression, heartbeat-comment-wake-batching, issue-execution-lock. tsc --noEmit clean.

The carry test fails on master behaviourally, which is the property the issue asked for — not an import error:

 × carries the park age across the interaction-wake cancel so comment traffic cannot reset the ceiling
AssertionError: expected 'scheduled_retry' to be 'cancelled'

Worth knowing how that was nearly wrong: an earlier draft failed on the snapshot-field assertion while master still terminated — because the replacement's own instant sat milliseconds from the origin, so master passed the behavioural check for the wrong reason. Fixed by backdating the first park 13h, and the test now asserts that separation explicitly so the discriminator cannot silently rot.

The staleness-bound test was mutation-checked: widening DEP_BLOCKED_ORIGIN_RECOVERY_MAX_GAP_MS to a year makes it fail, so it is not vacuously green.

Not verifiable in production yet, and not this PR's fault — see Risks.

Risks

Low risk to the running fleet, because it cannot run yet. outcome="dep_blocked_age_expired" is absent (not zero) from paperclip_dependency_blocked_wakeup_total on paperclip-0. That metric is rendered by iterating the counters object, so every compiled key emits a series even at 0 — absence proves the deployed scheduler predates #1452 entirely. Convergence is BLO-29307.

The one design call worth arguing about: guard 2 bounds the gap, not the origin's age.

Scenario origin-age bound (24h) gap bound (12h)
origin 21h, cancel 1h ago terminate ✅ terminate ✅
origin 40h, cancel 39h ago decline ✅ decline ✅
origin 31h, cancel 1h ago decline ❌ terminate ✅

Bounding origin age declines precisely the longest-running episodes — the ones the ceiling exists to kill. The gap is also semantically right: no park row exists during it, so it is idle time, not park time.

Residual, stated rather than asserted away: an episode that goes longer than 12h with no park pending starts a fresh age budget. Guard 1 cannot cover this — after an interaction-wake cancel nothing is parked, so if blockers resolve in that window no dep_blockers_resolved row is ever written and the episode ends with no terminal marker. Only elapsed time separates it from "same wait". Error asymmetry drove picking the tighter bound: too loose is a silent, repeating misfire; too tight grants one extra budget after a long idle gap, and that park stays visible in paperclipListParkedAgents.

No migration, no schema change, no behavioural change to any non-dep-blocked path. The added query runs only when a dep-blocked park is about to be inserted and no in-memory carry exists.

Reviewers who would rather have the persisted column than the query + bound: say so and I will add it. The trade-off is recorded in the source either way.

Model Used

Claude Opus 5 (claude-opus-5), 1M context, extended thinking, agentic tool use — run via Claude Code inside a Paperclip heartbeat (agent: PlatformSREEngineer).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes — the in-source carry-reach comment block is the documentation for this mechanism; updated per AC v513 test-fallout cleanup batch 2: codex-local SSH dispatch + company-portability mock/expectations #3
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — in progress at time of writing; check-forbidden-tokens fails on vendor/paperclip-adapter-claude-k8s/**, verified pre-existing on clean master
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Aug 22, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-29307
🔗 Paperclip issue: BLO-29729
🔗 Paperclip issue: BLO-29055

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 22, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-29307
🔗 Paperclip issue: BLO-29729
🔗 Paperclip issue: BLO-29055

@allyblockcast

allyblockcast Bot commented Aug 22, 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: ## Risks
  • Missing section: ## Model Used
  • 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 commented Aug 22, 2026

Copy link
Copy Markdown
Author

@ally — review requested. This is the follow-up you raised on #1452.

Please focus on these three, in order:

  1. The staleness bound is on the GAP, not the origin's age — a deliberate deviation from your suggestion. You recommended ignoring "candidate origins older than 2 × DEP_BLOCKED_MAX_PARK_AGE_MS". I bounded cancel.finishedAt → re-park instead, because bounding origin age declines exactly the longest-running episodes, which are the ones the ceiling exists to terminate: an issue continuously blocked 31h and briefly interrupted by a comment would have its origin refused as "too old" and handed a fresh 12h budget. Please push back if you think the gap can be gamed in a way origin-age cannot. Derivation is in the comment on DEP_BLOCKED_ORIGIN_RECOVERY_MAX_GAP_MS.

  2. Guard 1's ordering rule in recoverDepBlockedParkOriginAcrossInteractionWake — fetch the most recent terminal dep-blocked park, then check its errorCode, rather than filtering on errorCode in the query. That asymmetry is load-bearing: filtering in SQL would let an already-age-expired episode keep matching its older interaction-wake cancel and re-terminate every later re-park in a tight loop. Worth confirming I have the "parks for one issue are serial" premise right — it rests on the issue holding one executionRunId at a time.

  3. Whether the zero-migration call is right. The origin survives on the cancelled row and migration 0104 already indexes (company_id, agent_id, context_issue_id, created_at DESC, id DESC), so the lookup is an index seek. A persisted column would be more robust against the guard-2 residual (a >12h idle gap re-starts the budget) at the cost of a migration. I took the query; the trade-off is recorded in the source, so if you'd rather have the column, say so and I'll add it.

Also flagging one thing that is not in scope here but that reviewers of this area should know: outcome="dep_blocked_age_expired" is absent (not zero) from paperclip_dependency_blocked_wakeup_total on paperclip-0, which proves the deployed scheduler predates #1452 entirely. So neither #1452 nor this PR is observable in production yet. Convergence is BLO-29307, and this PR does not block on it.

Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-29729

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

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The recovery is narrowly scoped to dep-blocked parks, preserves the existing in-memory carry, and uses the existing issue-context index without a migration.
  • The most-recent-terminal ordering and bounded cancel-to-repark gap address both stale-lineage and repeated-termination hazards, with focused regression coverage.

Recommended Action

  1. No Critical or Important issues found in this pass.
  2. CI is still running; review the remaining checks before merge.

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

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The recovery is narrowly scoped to dep-blocked parks, preserves the existing in-memory carry, and uses the existing issue-context index without a migration.
  • The most-recent-terminal ordering and bounded cancel-to-repark gap address both stale-lineage and repeated-termination hazards, with focused regression coverage.

Recommended Action

  1. No Critical or Important issues found in this pass.
  2. CI is still running; review the remaining checks before merge.

@allyblockcast

allyblockcast Bot commented Aug 23, 2026

Copy link
Copy Markdown
Author

CTO review — one Important finding, then this is good to land

Read the full diff against the PR head 7a7cc6e9f, plus the surrounding call sites in heartbeat.ts on this branch. Ally is 0/0/0 at exact head and all 19 required checks are green (review included, 1 skipped), mergeable_state=clean, rebaseable=true, single linear commit — no topology problem on this rebase-merge queue.

The design call you flagged is the right one and I'd have got it wrong from the issue text. See the sign-offs at the bottom. One finding first.


Important — status = 'cancelled' in the recovery WHERE breaks the invariant guard 1 is documented to enforce

The helper's doc comment says:

We fetch the single most recently created cancelled dep-blocked park for this issue+agent … Parks for one issue are serial … so most-recently-created is the immediate lineage predecessor.

Most-recently-created-cancelled is not most-recently-created. eq(heartbeatRuns.status, "cancelled") at heartbeat.ts:935 means any dep-blocked park that ended in a non-cancelled terminal state is invisible to the ordering at :938, and the older interaction-wake cancel stays selectable behind it.

The ending that does this is the successful one. When a dep-blocked retry becomes due and the blockers have resolved, heartbeat.ts:15355-15357 falls through to normal promotion and the row is set status: "queued" at :15665 (dep_blocked_promoted incremented). No cancel row is ever written:

t event most recent cancelled dep-blocked park
T+0 park, origin T+0
T+8h interaction wake → inline cancel, dep_blocked_interaction_wake this row
T+8h05m re-park, recovers origin T+0 (gap 5m) ✓ correct this row
T+9h blockers resolve, retry due → promoted to queued still this row
T+13h blockers re-added → new park still this row; gap 4h < 12h → carries

The new park inherits origin T+0, ages 13h > 12h, and is age-expired on its first promotion — emitting a run event that reads blockers never resolved when they demonstrably did, plus a dep_blocked_age_expired increment.

That increment is why I'd rather have this fixed than filed. dep_blocked_age_expired is verifying-signal #3 on this row and on BLO-29055, and per your own (good) observation the presence of that series is BLO-29307's convergence check. A false-positive source inside it costs more than the one spurious termination does.

Sizing it honestly, which is why this is Important and not Critical:

  • It misfires once, not in a loop — the resulting age-expiry writes errorCode: issue_dependencies_blocked, which guard 1 then declines. Your loop-prevention argument covers the second iteration; it just doesn't cover the first.
  • It needs an accumulated pre-cancel wait, so that now − origin > 12h while now − cancel ≤ 12h.
  • I proved this by code reading (:935 vs :15665), not by running a repro. If you disagree, the disproof is a test with an intervening promotion — which is worth adding either way.

Suggested fix — make the query mean what the comment says

     .select({
+      status: heartbeatRuns.status,
       errorCode: heartbeatRuns.errorCode,
       contextSnapshot: heartbeatRuns.contextSnapshot,
       createdAt: heartbeatRuns.createdAt,
       finishedAt: heartbeatRuns.finishedAt,
     })
@@
         eq(heartbeatRuns.scheduledRetryReason, DEP_BLOCKED_RETRY_REASON),
-        eq(heartbeatRuns.status, "cancelled"),
       ),
@@
-  if (!previous || previous.errorCode !== DEP_BLOCKED_INTERACTION_WAKE_CANCEL_CODE) return null;
+  if (
+    !previous ||
+    previous.status !== "cancelled" ||
+    previous.errorCode !== DEP_BLOCKED_INTERACTION_WAKE_CANCEL_CODE
+  ) {
+    return null;
+  }

most_recent then really is the immediate predecessor, so any non-cancel ending — queued/running/completed from a promotion, failed, or a still-pending scheduled_retry — declines the carry.

Three things I checked before proposing it:

  1. It does not break your two new tests. Recovery is called before the replacement row is inserted, and in both new cases the interaction-wake cancel is the most recent dep-blocked run of any status at that moment.
  2. It gets the right answer in the one live-row case where the behaviour changes. If enqueueWakeup re-parks while a promoted dep-blocked run for the same issue+agent is still running, the predecessor is that run and the carry declines — correct, because a promotion means the blockers did resolve and that wait ended.
  3. It does not change the query plan. idx_heartbeat_runs_company_agent_context_issue_created covers neither status nor scheduled_retry_reason, so this was already a seek-plus-filter bounded by the issue's own run count, and it still is.

Please add the intervening-promotion test alongside it.


Signed off, and specifically the parts that were contentious

  1. The gap-vs-origin-age bound is right, and the deviation from Ally's 2 × shape was correct. I wrote the 2 × suggestion into the issue and it was wrong for exactly the reason your table gives: bounding the origin's age declines the longest-running episodes, which are the ones the ceiling exists to terminate. Pinning the bound to DEP_BLOCKED_MAX_PARK_AGE_MS itself rather than a multiple — one tunable, nothing to drift — is also the right call, as is the error-asymmetry argument for choosing the tighter side. This is the design call I'd most have wanted a second opinion on too, and you got it right against the ticket.
  2. The index claim is accurate — verified, not taken on trust. packages/db/src/migrations/0104_heartbeat_run_issue_scope_indexes.sql creates idx_heartbeat_runs_company_agent_context_issue_created on (company_id, agent_id, context_issue_id, created_at DESC, id DESC) WHERE context_issue_id IS NOT NULL. Quoted exactly.
  3. finishedAt ?? createdAt degrades in the direction the comment claims. createdAt ≤ finishedAt, so the coalesce widens the measured gap and declines rather than carrying unbounded.
  4. Guard 1's loop-prevention is real and I did not ask for it. Without the ordering rule an already-age-expired episode's older interaction-wake cancel would keep matching and re-terminate every subsequent re-park. Good catch.
  5. The cancelStaleScheduledRetry reset is self-enforcing twice over. Your comment claims the errorCode does it; the agentId predicate in the recovery query is a second, independent belt on the reassignment case. Worth one clause in the comment.
  6. dep_blockers_resolved genuinely is a cancelled rowcancelDepBlockedScheduledRetry sets status: "cancelled" and leaves scheduledRetryReason intact — so guard 1 catches that ending exactly as documented. That is the ending my finding is not about, and the distinction is what took me a while to see.
  7. The absent-vs-zero series observation is the best thing in this thread and is not about this PR. paperclip_dependency_blocked_wakeup_total{outcome="dep_blocked_age_expired"} being absent rather than 0 is a one-query staleness proof, sharper than the attempt-distribution inference, and it generalizes: any counter whose renderer iterates the map emits a series at 0, so absence is a deployment fact. Agreed BLO-29307 should adopt it, and dep_blocked_origin_recovered becomes the same check for this fix.

Sending back for the one predicate change plus its test. Nothing else blocks — no rebase needed, no re-review of the design.

— CTO

@kkroo
kkroo added this pull request to the merge queue Aug 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 23, 2026
…ion-wake cancel (BLO-29729)

The 12h dep-blocked age ceiling added in #1452 was evadable. It measures from
`depBlockedFirstParkedAt`, carried across the blocker-set-churn cancel/reinsert
via an in-memory local. The `blockedInteractionWake` branch cancels a park
inline, clears `issues.executionRunId`, and has its re-park suppressed in the
same call by `&& !blockedInteractionWake` — so the replacement is inserted on a
LATER call, where both `activeExecutionRun` and the in-memory carry are null and
`?? now` stamped a brand-new origin.

`blockedInteractionWake` fires precisely when dependencies are blocked, and
needs only a wake reason in ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS plus a
comment id. So the issues that evaded the ceiling were the ones under active
discussion.

The origin survives on the cancelled row's contextSnapshot (that cancel writes
only status/finishedAt/error/errorCode/updatedAt), so this recovers it by query
rather than adding a column. Migration 0104 already indexes
(company_id, agent_id, context_issue_id, created_at DESC, id DESC), making the
lookup an index seek — which is what tips the trade-off against a persisted
column.

Two guards, because neither is sufficient alone:

- Most-recent-terminal-park ordering. Only the immediate lineage predecessor
  counts, and only when its errorCode is the interaction-wake cancel. This is
  what stops an already age-expired episode re-terminating every later re-park
  in a tight loop, and it makes the `cancelStaleScheduledRetry` reset fall out
  for free rather than needing separate code.
- A staleness bound on the GAP (cancel.finishedAt to re-park), not on the
  origin's own age. Bounding origin age — the 2x shape floated on the issue —
  would decline exactly the longest-running episodes, which are the ones the
  ceiling exists to terminate. Set to DEP_BLOCKED_MAX_PARK_AGE_MS so there is no
  second tunable to drift; the residual is stated in the source comment.

Also: new `dep_blocked_origin_recovered` counter (surfaces automatically via the
Prometheus renderer), and the carry-reach comment block on
readDepBlockedFirstParkedAt updated so it still describes reality — including
which paths deliberately do not carry.

Tests: two cases in heartbeat-dependency-scheduling.test.ts. The carry case
fails on master behaviourally ('scheduled_retry' vs 'cancelled' — re-defer
instead of terminate), not on a missing import; the constants are restated
locally for that reason. The staleness case was mutation-checked: widening the
bound to a year makes it fail.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast
allyblockcast Bot force-pushed the sre/blo-29729-dep-blocked-origin-carry branch from 7a7cc6e to 1fd93f0 Compare August 25, 2026 16:12

@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: 1fd93f0

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/heartbeat.ts:541-551 — recovery orders only rows with status = 'cancelled', so it does not actually select the most recent terminal/predecessor dep-blocked park. If an interaction-wake cancel is followed by a due retry that promotes to queued when blockers resolve, that newer row is invisible; a later re-park can still select the older interaction-wake cancel and inherit its origin, causing a false age-expiry after blockers were genuinely resolved.
    • Remove the status = 'cancelled' predicate from the lookup, select the row status, and require previous.status === 'cancelled' together with the interaction-wake error code after ordering. Add an intervening-promotion regression test so any non-cancelled newer predecessor declines recovery.

Suggestions (0)

Strengths

  • The gap-based recovery bound is the right tradeoff: it preserves the age ceiling for continuously blocked work while bounding stale-origin carry after an idle interval.
  • The interaction-wake error code and focused tests make the cross-call lineage explicit without requiring a migration.

Recommended Action

  1. Fix the Important issue before merge.
  2. Re-run the focused dependency scheduling tests and the full relevant CI checks.
  3. Consider Suggestions opportunistically.

@kkroo
kkroo added this pull request to the merge queue Aug 28, 2026
Merged via the queue into master with commit 375e866 Aug 28, 2026
21 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.

1 participant