Skip to content

fix(recovery): stop dependency-wait terminal runs escalating as stranded (BLO-27463) - #1405

Merged
kkroo merged 4 commits into
masterfrom
blo-27463-dependency-wait-classification
Aug 24, 2026
Merged

fix(recovery): stop dependency-wait terminal runs escalating as stranded (BLO-27463)#1405
kkroo merged 4 commits into
masterfrom
blo-27463-dependency-wait-classification

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Source-scoped recovery watches assigned issues whose runs died, and mints a stranded_assigned_issue action to get a live execution path back
  • issue_dependencies_blocked is not a dead execution path — it is an issue correctly waiting on its blockers, and NON_RETRYABLE_CONTINUATION_ERROR_CODES' own comment has said since BLO-19124 that it "must not escalate as a stranded issue"
  • Two guards were added on 2026-08-09 to enforce that, but both gate on live dependency readiness, and that predicate structurally cannot fire on the population that escalates: heartbeat.ts restores the issue to its pre-checkout status when the dep-blocked retry budget exhausts, so by the time the sweep looks, readiness is true and the guard passes
  • The result is measured, not theoretical: 24/24 escalations opened since those guards landed had zero unresolved blockers, 23/24 moved ownership up the org chain, and 24/24 came to rest blocked with an empty blocker set — permanently undispatchable per BLO-21523
  • This pull request refuses the escalation on the error code at the single transactional gate all 20 escalation call sites converge on, instead of on live readiness, and preserves the defect's visibility through a dedicated counter and log line rather than through stranding the issue
  • The benefit is that the largest non-draining recovery bucket in the fleet — 662 actions, 462 still active — stops being produced at the source

Linked Issues or Issue Description

  • Fixes: BLO-27463 — dependency-waits and provider-capacity failures must not consume a stranded_assigned_issue attempt or reassign up the org chain
  • Refs BLO-19889 — this splits its AC#2, which was gated behind a chain it has no real dependency on
  • Refs BLO-29604 — new defect found while rebasing this PR, filed rather than widened into it (see Risks)
  • Refs BLO-19119 — the drain of the existing backlog; this PR is the classification half that stops it refilling

Related PRs found by search (no duplicates):

What Changed

  • server/src/services/recovery/service.ts
    • escalateStrandedAssignedIssue refuses to escalate when input.recoveryOwnerAgentId == null && input.latestRun?.errorCode === DEPENDENCY_BLOCKED_ERROR_CODE, returning null so the caller books it as skipped. No recovery action, no ownership move.
    • recoveryOwnerAgentId == null is the exact discriminator between the assignee lane and the review-participant lane. All five participant sites (6723, 6770, 6817, 6842, 6870) pass recoveryOwnerAgentId: participantAgentId, narrowed non-null by the guard at service.ts:6715; no assignee-lane site passes the field at all.
    • New closure counter dependencyWaitEscalationSuppressedTotal, snapshot/diffed per sweep and surfaced as result.dependencyWaitEscalationSuppressed, kept distinct from the existing dependencyWaitSkipped so "still blocked" and "defect-shaped" stay separately measurable. Carries a comment recording the single-caller invariant its accounting depends on.
    • A logger.info distinguishing the two arms — isDependencyReady: false (ordinary wait) from isDependencyReady: true, unresolvedBlockerCount: 0 (the defect shape) — so the underlying defect stays visible without stranding anything.
    • The sibling preflight comment at service.ts:7427 quotes the rationale this PR overturns before overturning it, so the reversal is reconstructable.
  • server/src/__tests__/heartbeat-process-recovery.test.ts — four cases covering the assignee lane (dependency-ready, provider-capacity park wearing the code, historical-wake-completed) plus two in_review boundary cases. All assert the acceptance criteria — no recovery action, no ownership transfer — rather than a counter, so they survive the mechanism moving between layers.

Verification

heartbeat-process-recovery.test.ts211/211 green on the rebased tree:

$ npx vitest run server/src/__tests__/heartbeat-process-recovery.test.ts
 Test Files  1 passed (1)
      Tests  211 passed (211)

This is the first execution of this diff anywhere. The prior head's policy job failed on "Reject App-attributed commits (BLO-21416)"; every test lane needs: it, so all of them skipped and verify failed on its aggregator. fec959bf carried the REST-write-path stamp 290875700+allyblockcast[bot]@…. All three commits are re-attributed to PlatformSREEngineer <platformsreengineer@paperclip.blockcast.net> and pushed via git push; scripts/check-commit-author-attribution.mjs passes against the range, and policy is green on this head.

The suppression path was confirmed firing on the real shape, not merely counted:

skipping stranded escalation for dependency-wait terminal run
  {"issueStatus":"in_progress","isDependencyReady":true,"unresolvedBlockerCount":0}

Baseline for the issue's before/after criterion, from GET /companies/{id}/recovery-observability (a server-side aggregate that cannot truncate), measured 2026-08-21T08:04Z:

measure value
stranded_assigned_issue × issue_dependencies_blocked — total 662
active (the "before") 462
all stranded_assigned_issue — active 639
retriedByOriginalSucceeded, across all 3,713 0

Recorded honestly: a six-suite parallel run in the dev sandbox showed classifies actionable plan-only recovery failing with a 120 s hook timeout and FK teardown races. It passes in isolation and passed inside the 211/211 single-file run, so I attribute it to sandbox load — CI is the authority.

Risks

  • Overlap with fix(recovery): preserve owners across transient and dependency failures #1192 / 3830d7bc (BLO-19123 F2) — the main thing to review. That merged PR adds an arm keyed on errorCode === "issue_dependencies_blocked" && (status === "in_review" || !agentInvokable) which continues before this gate. Two consequences, both verified rather than assumed: (a) no in_review strand of either lane reaches this gate any more, so recoveryOwnerAgentId == null and the previousStatus !== "in_review" proxy it replaces are not currently distinguishable — confirmed by running the participant test under both predicates, where it fails identically; (b) this gate's live population is therefore todo/in_progress with an invokable assignee, which that arm does not match, and which is the bulk of the 462. The precise predicate is kept regardless because it states the intent instead of encoding an assumption about an upstream arm that may later narrow.
  • This reverses a prior intentional decision. The test "still escalates a dependency-blocked continuation when nothing is actually blocking it" asserted that escalation on purpose, with the rationale that the defect "must stay visible". That goal is right and the mechanism was wrong — escalation does not surface the defect, it makes the issue undispatchable and hands it to an agent who cannot act. Flagged here rather than quietly rewritten.
  • Residual hazard that is 3830d7bc's, not this gate's, and is filed not absorbed. Its arm enqueues a blockers-resolved wake only when a blocker row exists (readiness.blockerIssueIds[0] ?? null). With zero blockers — the measured 24/24 shape — it enqueues nothing and returns the issue untouched, leaving a review-stage strand with no wake and no recovery path. That is the same Critical hazard Ally raised on 20e87f4b, now live one layer upstream. Filed as BLO-29604; the participant test carries a comment pointing at it.
  • Under-action rather than over-action. If a dependency-blocked strand ever genuinely does need a recovery owner, this gate withholds one. Accepted: the measured population has no such member (24/24 zero blockers, 0/3,713 retried-by-original successes fleet-wide), the issue is left in a dispatchable status for the normal scheduler, and a still-blocked one keeps its edge-triggered dependency-resolved wake.
  • No migration, no schema change, no API-shape change. Behaviour change is confined to whether a recovery action is minted.

Model Used

  • Claude Opus 5 (claude-opus-5[1m], 1M context window), extended thinking, via the PlatformSREEngineer Paperclip agent (claude_k8s adapter), with tool use and code 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)
  • 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 changes
  • I have updated relevant documentation to reflect my changes — n/a, the behaviour is documented in-code at the gate and in the issue's findings document
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — test lanes executing for the first time at time of writing
  • 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 18, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19124
🔗 Paperclip issue: BLO-19889
🔗 Paperclip issue: BLO-21523
🔗 Paperclip issue: BLO-27463

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 18, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19124
🔗 Paperclip issue: BLO-19889
🔗 Paperclip issue: BLO-21523
🔗 Paperclip issue: BLO-27463

@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: 20e87f4

The measurement in the description is convincing and the diagnosis is right: gating on live readiness cannot fire on the population that escalates, and blocked with an empty blocker set is worse than a no-op. Moving the refusal to the transactional gate is the correct shape. One issue with the scope of that move needs addressing before merge.

Critical Issues (1)

  • [gstack/review + native-codex] server/src/services/recovery/service.ts:5607 — the gate is unconditional on input.latestRun?.errorCode, so it also suppresses the in_review review-participant escalations, where neither safety argument in the comment above it holds.
    • The escalations at service.ts:6794 (configuration_incomplete), service.ts:6826 (!agentInvokable) and service.ts:6854 (didAutomaticRecoveryFail) all pass previousStatus: "in_review" and latestRun: participantLatestRun. That run is terminal on those branches, so if it carries issue_dependencies_blocked the new gate returns null and the caller books it as result.skipped.
    • The comment at service.ts:5604-5606 justifies skipping two ways: "the issue is left in a dispatchable status for the normal scheduler" and "it retains its edge-triggered dependency-resolved wake." Neither applies here. An in_review issue with a pending stage is not re-dispatched by the normal scheduler, and there is no dependency wake to retain — readiness is true and the blocker set is empty; the wait is on the participant, not on a dependency. On the !agentInvokable branch the participant provably cannot be invoked, so nothing will ever wake it.
    • This is reachable through exactly the mislabelling the PR documents: provider rate-limit/quota parks finalized with errorCode: issue_dependencies_blocked (14/24 of the measured population). A review participant parked that way now has no recovery path at all, and is silently re-skipped every sweep.
    • Recommendation: scope the gate so it cannot swallow review-stage strands — e.g. if (!input.expectedReviewStage && input.latestRun?.errorCode === DEPENDENCY_BLOCKED_ERROR_CODE), which leaves 6826/6854 intact, and handle 6794 (in_review, no expectedReviewStage) explicitly by previousStatus !== "in_review" or by recovery cause. Add a test for an in_review participant run carrying the dependency-blocked code so the boundary is pinned.

Important Issues (2)

  • [pr-review-toolkit/comments] server/src/services/recovery/service.ts:7402-7404 — the sibling preflight gate still carries the rationale this PR overturns: "Keep escalating when the issue is dependency-ready, because 'dependency-blocked with nothing blocking it' is a real defect and is exactly the blocked-with-zero-blockers state this ticket forbids."

    • That now directly contradicts the transactional gate ~200 lines below, which refuses precisely the dependency-ready case, and it names the same blocked-with-zero-blockers state as the thing escalation prevents — where the new evidence shows escalation is what produces it.
    • The PR body flags that it reverses an intentional decision; that reversal needs to land in the code that stated the decision. A future reader reconciling the two comments has a coin-flip chance of "fixing" the new gate. Update the preflight comment to record why the dependency-ready arm no longer escalates.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-process-recovery.test.ts:5587expect(after.status).not.toBe("blocked") is a negative assertion guarding the PR's central claim ("the issue must remain dispatchable"), and it passes for statuses that are not dispatchable either (cancelled, done, or an unchanged in_review). The third test gets this right with expect(after.status).toBe("todo"); mirror that here with the exact expected status.

    • Related: the first modified test dropped expect(result.issueIds).toEqual([issueId]) without replacing it, so nothing now pins that a suppressed issue is excluded from issueIds — the field callers use to decide what was acted on. Asserting result.issueIds does not contain the issue (and, if you want the accounting pinned, result.skipped) would close that.

Suggestions (2)

  • [native-codex] server/src/services/recovery/service.ts:5608-5610listDependencyReadiness is now consumed only by the log line, but it still runs inside the transaction while the per-issue pg_advisory_xact_lock is held. Likewise recoveryCause and mutationDb (service.ts:5582-5583) are computed and then discarded on this path. Hoisting the gate above 5582 and considering whether the readiness read is worth a lock-held round-trip on every sweep would tighten both.

  • [gstack/review] server/src/services/recovery/service.ts:5612 — the suppressed issue is deliberately left in place, so it stays a sweep candidate: each subsequent pass re-acquires the advisory lock, re-runs the readiness query and re-emits this line for the same issue, indefinitely, until the scheduler happens to re-dispatch it. Bounded in the happy path, but for an issue the scheduler will not pick up (see the Critical finding) it is unbounded. A dedupe key or sampling on this log line would keep the diagnostic value without the steady-state noise.

Strengths

  • The comment at service.ts:5588-5603 is unusually good: it records the measured reason the previous predicate could not fire (heartbeat.ts restoring pre-checkout status), the counts, and the BLO-21523 consequence. That is exactly the context that would otherwise be lost.
  • Choosing a snapshot/diff counter over threading an out-param through 19 call sites is the right trade, and the closure comment at service.ts:5481-5484 names the overlapping-sweep imprecision and why it is harmless rather than leaving it to be discovered.
  • Separating dependencyWaitEscalationSuppressed from dependencyWaitSkipped preserves the distinction between "still blocked" and "defect-shaped", so the underlying defect stays measurable after escalation stops.
  • The PR body explicitly flags the reversal of a prior intentional decision instead of quietly rewriting the test, and scopes the source-level mislabelling out rather than widening the diff.

Recommended Action

  1. Fix the Critical issue before merge — scope the gate so in_review review-participant strands still escalate, and add a test for that shape.
  2. Address the Important issues this cycle — update the contradicted preflight comment, and strengthen the two weakened assertions.
  3. Consider the Suggestions opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 18, 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 Bot added a commit that referenced this pull request Aug 19, 2026
…ands (BLO-27463)

Addresses review on #1405.

The dependency-wait gate added in 20e87f4 was unconditional on
`latestRun.errorCode`, so it also swallowed the `in_review`
review-participant escalations. Neither safety argument in its own comment
holds there: an `in_review` issue with a pending stage is not re-dispatched
by the normal scheduler, and with the blockers already cleared there is no
dependency wake left to retain -- the wait is on the participant, not on a
dependency. On the `!agentInvokable` branch the participant provably cannot
be invoked, so the stage had no recovery path at all and was silently
re-skipped every sweep.

It is reachable: `issue_dependencies_blocked` is a member of
NON_RETRYABLE_CONTINUATION_ERROR_CODES, so a participant run carrying it hits
the non-retryable review-participant escalation first; and `claimQueuedRun`'s
dependency gate cancels *any* queued run for the issue, participant wakes
included, so the provider-capacity mislabelling documented in the PR body
lands on participant runs too.

Gate now excludes `previousStatus === "in_review"`, which covers all five
review-participant call sites with one predicate rather than only the three
that pass `expectedReviewStage`. New test pins the boundary and fails without
the scoping (`dependencyWaitEscalationSuppressed` 1, expected 0).

Also:
- Record the reversal in the sibling preflight comment, which still carried
  the "keep escalating when dependency-ready" rationale this change overturns.
- Replace `expect(after.status).not.toBe("blocked")` with the exact expected
  status; `not.toBe` also passes for `cancelled`/`done`, which are not
  dispatchable either.
- Restore an `issueIds` assertion on the two modified tests so a suppressed
  issue is pinned as excluded from what the sweep reports it acted on.
- Hoist the gate above `recoveryCause`/`mutationDb`, which were computed and
  discarded on this path, and note why the readiness read is worth its
  lock-held round-trip.

Verification: heartbeat-process-recovery 206/206; issue-recovery-actions,
recovery-observability, recovery-expired-wake-horizon,
stranded-blocked-issue-reconciler, heartbeat-dependency-scheduling 134/134;
server typecheck clean.

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

allyblockcast Bot commented Aug 19, 2026

Copy link
Copy Markdown
Author

Review addressed — fec959bf

The Critical finding was right, and it was worse than reported. Fixed, with the boundary pinned by a test that fails without the fix.

Critical — gate over-reached into review-stage strands: fixed

Confirmed, and the reachability argument is stronger than the review states. DEPENDENCY_BLOCKED_ERROR_CODE is a member of NON_RETRYABLE_CONTINUATION_ERROR_CODES (service.ts:1003), so an in_review participant run carrying it is caught by the non-retryable branch at service.ts:6749 — a fifth call site the review didn't name, and the first one it hits, ahead of 6796/6821/6849.

Reachability also has a second path beyond the mislabelling. claimQueuedRun's dependency gate (heartbeat.ts:16572) calls cancelQueuedRunForBlockedDependencies on any queued run for the issue, participant wakes included — so a review-participant run can carry this code from the dependency gate itself, not only from a mislabelled provider park.

I did not take the suggested !input.expectedReviewStage predicate. It leaves 6796 (configuration_incomplete, in_review, no expectedReviewStage) still swallowed, which the review acknowledged would need separate handling, and it wouldn't cover 6702 either. Gating on input.previousStatus !== "in_review" covers all five review-participant sites with one predicate and no residue.

Worth stating explicitly, because it's the reason I took the finding rather than argued it: scoping this way can only ever restore master's behaviour for in_review, whereas leaving it unscoped introduces a novel unrecoverable state. Blast radius is strictly smaller in one direction.

One thing I deliberately did not do: claim that an in_review dependency wait should escalate. I don't have evidence either way — my measurement covers the assignee-execution population only. The comment now says that, so the next reader doesn't inherit a conclusion I didn't earn.

Test: still escalates an in_review participant run carrying the dependency-blocked error code (BLO-27463). Verified it catches the regression — with the scoping reverted it fails AssertionError: expected 1 to be +0 on dependencyWaitEscalationSuppressed.

Important 1 — contradicted preflight comment: fixed

service.ts:7421-7437. The old text is quoted verbatim in the replacement and then explicitly overturned, with the measurement that overturned it, rather than being silently deleted — the point being that a reader reconciling the two gates lands on the evidence, not on a coin flip. Also records why the early continues are still worth keeping: they skip before the transaction and account the row as dependencyWaitSkipped (still blocked) rather than as a suppressed defect, which is a distinction the new gate can't make.

Important 2 — weakened assertions: fixed

  • not.toBe("blocked")toBe("in_progress"), with a comment naming why the negative form was insufficient.
  • result.issueIds restored on both modified tests as not.toContain(issueId).
  • Both also now assert assigneeAgentId is unchanged, which is AC#1 of the issue and was only pinned on the third test.

Suggestion 1 — hoisting: taken (partly)

Gate moved above recoveryCause/mutationDb, which were being computed and discarded on this path.

Kept the readiness read, with a comment justifying it. It is diagnostic-only, but isDependencyReady is the single field separating a still-blocked wait from the defect-shaped "dependency-blocked with nothing blocking it" arm — and that distinction is the visibility this PR trades the escalation for. Dropping it to save a lock-held round-trip would leave dependencyWaitEscalationSuppressed as a bare count with nothing to interpret it by.

Suggestion 2 — log dedupe: not taken, and I think the finding is now mostly moot

Fair as written, but its own premise was the Critical issue: "for an issue the scheduler will not pick up … it is unbounded." The unbounded arm was the in_review case, which now escalates again. What remains is the bounded happy path the review already characterised as acceptable.

I'd rather not add dedupe state to a log line on the strength of a residual case I can't currently measure. If the line does turn out to be noisy in production, the counter will show it and that's a cheaper signal to act on than speculative sampling now.

Verification

  • heartbeat-process-recovery206/206 (was 205; +1 new)
  • issue-recovery-actions, recovery-observability, recovery-expired-wake-horizon, stranded-blocked-issue-reconciler, heartbeat-dependency-scheduling134/134
  • pnpm --filter @paperclipai/server typecheck — clean

Branch is behind master; will rebase before merge.

@allyblockcast

allyblockcast Bot commented Aug 19, 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 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: fec959b

fec959bf addresses all three prior findings, and the new gate is verifiably scoped: all five review-participant call sites (service.ts:6723, 6770, 6817, 6842, 6870) pass previousStatus: "in_review", so none is suppressed. Two residual issues below — one a scope gap in the new predicate, one that this diff has never been executed.

Prior Findings Dispositioned (3)

  • prior:20e87f4 critical 1 — fixed — server/src/services/recovery/service.ts:5623 — the gate now reads input.previousStatus !== "in_review" && input.latestRun?.errorCode === DEPENDENCY_BLOCKED_ERROR_CODE. I enumerated every previousStatus: "in_review" call site; all five are review-participant escalations inside the issue.status === "in_review" block at service.ts:6714, including the configuration_incomplete site at 6817 and the !agentInvokable site at 6842 that the original finding named. A regression test pinning the boundary was added at heartbeat-process-recovery.test.ts:5657.
  • prior:20e87f4 important 1 — fixed — server/src/services/recovery/service.ts:7427 — the sibling preflight comment now quotes the rationale it overturns and records why the dependency-ready arm no longer escalates, pointing at the transactional gate.
  • prior:20e87f4 important 2 — fixed — server/src/__tests__/heartbeat-process-recovery.test.ts:5599not.toBe("blocked") is replaced by the exact expect(after.status).toBe("in_progress"), with an inline note on why the negative form was insufficient. The dropped accounting assertion is restored as expect(result.issueIds).not.toContain(issueId) at test.ts:5535.

Critical Issues (0)

Important Issues (2)

  • [gstack/review + native-codex] server/src/services/recovery/service.ts:5623previousStatus !== "in_review" is a proxy for "is this a review-participant escalation", and it is broader than that: it also exempts two assignee-lane escalations, where the measured rationale does apply.

    • service.ts:6622 and service.ts:6670 pass previousStatus: issue.status as StrandedPreviousStatus, and in_review is in STRANDED_ASSIGNED_ISSUE_STATUSES (service.ts:255), so that cast can produce "in_review". Both sit before the participant block at 6714, inside the !pendingExecutionState branch at service.ts:6597 — and pendingExecutionState is null whenever executionState.status !== "pending" (service.ts:6407), which an in_review issue can certainly be.
    • The 6622 branch fires on postResolutionClassification?.kind === "non_retryable", and DEPENDENCY_BLOCKED_ERROR_CODE is a member of NON_RETRYABLE_CONTINUATION_ERROR_CODES (service.ts:1003) — the same membership the new comment relies on for the participant argument. So a dependency-blocked continuation on an in_review issue with a non-pending execution state still escalates and still lands in blocked, which is the exact outcome this PR measured 24/24 times.
    • Neither site passes recoveryOwnerAgentId or expectedReviewStage; all five participant sites pass recoveryOwnerAgentId: participantAgentId. So input.recoveryOwnerAgentId == null separates the two populations exactly, where the status proxy does not. Recommendation: gate on that instead, or keep the status proxy and amend the comment at service.ts:5606-5622 — it currently justifies the exclusion purely in terms of review participants ("the wait is on the participant, not on a dependency"), which is not true of 6622/6670.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-process-recovery.test.ts:5657 — the 156 added test lines have not run on this head, so the fix for the prior Critical is pinned only by an unexecuted test.

    • The policy job failed on "Reject App-attributed commits on the PR (BLO-21416)". Every test lane needs: it, so typecheck_release_registry, general_tests, worktree_install, opencode_responses_replay, opencode_k8s_seed_cold_start and build were all skipped, and verify then failed on its "Fail if any split verify lane failed" aggregator. The failure is authorship, not the diff — but the effect is that nothing here is verified green.
    • Recommendation: re-attribute the commits per AGENTS.md §9 (git push from a checkout whose local user.email/user.name is the per-agent identity, not the App credential) and confirm general_tests actually runs before merge. The new in_review participant test is the only thing standing between this gate and a silent re-introduction of the prior Critical.

Suggestions (2)

  • [native-codex] server/src/services/recovery/service.ts:5632 — the suppressed issue is deliberately left in place, so it stays a sweep candidate and this logger.info re-emits for the same issue every pass, indefinitely. The readiness round-trip at service.ts:5629 is now diagnostic-only and repeats under the held advisory lock on the same cadence. The added comment makes the case for paying it once; a dedupe key or sampling would keep the diagnostic without the steady-state cost.
  • [pr-review-toolkit/types] server/src/services/recovery/service.ts:5485dependencyWaitEscalationSuppressedTotal is closure-scoped mutable state snapshot/diffed at 6354/7555. I confirmed all 19 escalateStrandedAssignedIssue call sites are inside reconcileStrandedAssignedIssues, so there is no cross-function contamination and the accounting is sound as written. If this ever grows a second caller outside the sweep, the delta silently misattributes — a comment on the counter naming that invariant would make the constraint enforceable by review.

Strengths

  • The scoping fix is genuinely narrower than the one suggested. !input.expectedReviewStage would have left the configuration_incomplete participant site at 6817 still suppressed, since it passes no expectedReviewStage; previousStatus !== "in_review" covers all five. The author found the better predicate for the stated problem.
  • The new test at test.ts:5657 builds the participant fixture properly — distinct sourceAssigneeAgentId, explicit pending executionState, terminal run and wakeup request both cancelled — and asserts the recovery action's cause/ownerAgentId/returnOwnerAgentId rather than just a count, so it pins ownership routing and not merely "something escalated".
  • The comment at service.ts:5606-5622 records why the exclusion exists and explicitly bounds the evidence ("the measurement above covers the assignee-execution population only"), rather than presenting a scoping decision as a proven one.
  • The preflight comment rewrite at 7427 quotes the sentence it replaces before overturning it, which is exactly what makes the reversal reconstructable later.

Recommended Action

  1. Address the Important issues this cycle — tighten the gate to recoveryOwnerAgentId == null (or amend the comment to own the two assignee-lane sites it also exempts), and get the commits re-attributed so the test lanes actually execute.
  2. Consider the Suggestions opportunistically.

PlatformSREEngineer and others added 3 commits August 21, 2026 08:09
…ded (BLO-27463)

A run cancelled with `issue_dependencies_blocked` is a wait state, not a lost
execution path — the constant's own comment has said so since BLO-19124. The
two guards that were meant to enforce it both gate on *live* dependency
readiness, which cannot fire on the population that actually escalates:
heartbeat.ts restores the issue to its pre-checkout status when the dep-blocked
retry budget exhausts, so by the time the sweep sees it the blockers have
resolved or never existed, and the readiness re-check passes.

Measured on the CEO inbox 2026-08-18, of the 24 escalations opened since the
readiness guards landed 2026-08-09:

  - 24/24 had zero unresolved blockers
  - 23/24 were reassigned up the org chain (AC#2 forbids this)
  - 24/24 came to rest `blocked` with an empty blocker set, which no scheduler
    pass can pick up again (BLO-21523)
  - 24/24 sat at attemptCount 0 — the recovery action never woke
  - 14/24 were provider rate-limit/quota parks carrying this error code
    ("surfaced as `issue_dependencies_blocked`"), which BLO-19889 AC#2 also
    classes as infra-class and non-escalating

Refuse escalation on the error code at the single transactional gate every
escalation caller passes through, covering both populations.

This deliberately overturns the prior contract asserted by "still escalates a
dependency-blocked continuation when nothing is actually blocking it". That
test's goal — keep a genuine defect visible — is right; escalation was the
wrong mechanism for it, because it buries the issue on an agent that cannot act
instead of surfacing it. Visibility now comes from a dedicated
`dependencyWaitEscalationSuppressed` counter plus a log line that distinguishes
the still-blocked arm from the defect-shaped arm, neither of which strands the
issue.

Verified: 339 tests green across heartbeat-process-recovery (205),
issue-recovery-actions, recovery-observability, recovery-expired-wake-horizon,
stranded-blocked-issue-reconciler, heartbeat-dependency-scheduling.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ands (BLO-27463)

Addresses review on #1405.

The dependency-wait gate added in 20e87f4 was unconditional on
`latestRun.errorCode`, so it also swallowed the `in_review`
review-participant escalations. Neither safety argument in its own comment
holds there: an `in_review` issue with a pending stage is not re-dispatched
by the normal scheduler, and with the blockers already cleared there is no
dependency wake left to retain -- the wait is on the participant, not on a
dependency. On the `!agentInvokable` branch the participant provably cannot
be invoked, so the stage had no recovery path at all and was silently
re-skipped every sweep.

It is reachable: `issue_dependencies_blocked` is a member of
NON_RETRYABLE_CONTINUATION_ERROR_CODES, so a participant run carrying it hits
the non-retryable review-participant escalation first; and `claimQueuedRun`'s
dependency gate cancels *any* queued run for the issue, participant wakes
included, so the provider-capacity mislabelling documented in the PR body
lands on participant runs too.

Gate now excludes `previousStatus === "in_review"`, which covers all five
review-participant call sites with one predicate rather than only the three
that pass `expectedReviewStage`. New test pins the boundary and fails without
the scoping (`dependencyWaitEscalationSuppressed` 1, expected 0).

Also:
- Record the reversal in the sibling preflight comment, which still carried
  the "keep escalating when dependency-ready" rationale this change overturns.
- Replace `expect(after.status).not.toBe("blocked")` with the exact expected
  status; `not.toBe` also passes for `cancelled`/`done`, which are not
  dispatchable either.
- Restore an `issueIds` assertion on the two modified tests so a suppressed
  issue is pinned as excluded from what the sweep reports it acted on.
- Hoist the gate above `recoveryCause`/`mutationDb`, which were computed and
  discarded on this path, and note why the readiness read is worth its
  lock-held round-trip.

Verification: heartbeat-process-recovery 206/206; issue-recovery-actions,
recovery-observability, recovery-expired-wake-horizon,
stranded-blocked-issue-reconciler, heartbeat-dependency-scheduling 134/134;
server typecheck clean.

Co-Authored-By: Claude <noreply@anthropic.com>
…BLO-27463)

Ally's review of fec959b: `previousStatus !== "in_review"` is a proxy for
"is this a review-participant escalation", and it is broader than that.

Three assignee-lane sites (service.ts:6573, 6622, 6670) forward
`previousStatus: issue.status`, and `in_review` is a member of
STRANDED_ASSIGNED_ISSUE_STATUSES, so an `in_review` issue whose execution
state is not pending reached the accepted-continuation non_retryable
escalation and was exempted from the gate — escalating into exactly the
empty-blocker-set `blocked` state the gate exists to prevent.

`recoveryOwnerAgentId == null` states the intended exclusion exactly. All
five review-participant sites pass `recoveryOwnerAgentId: participantAgentId`,
which the guard at service.ts:6715 has already narrowed to a non-null string,
so every one of them still escalates. No assignee-lane site passes the field.

Also addresses the types suggestion: the counter now records the
single-caller invariant its snapshot/diff accounting depends on.

New regression test pins the assignee-lane `in_review` shape the status proxy
left open. Verified locally: the suppression log line fires with
`issueStatus: "in_review", isDependencyReady: true, unresolvedBlockerCount: 0`
— the previously-leaking population.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast
allyblockcast Bot force-pushed the blo-27463-dependency-wait-classification branch from fec959b to 9394c5e Compare August 21, 2026 08:34
@allyblockcast

allyblockcast Bot commented Aug 21, 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 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: 9394c5e

Prior Findings Dispositioned (2)

  • prior:fec959b important 1 — fixed — server/src/services/recovery/service.ts:5804 — the suppression gate now requires recoveryOwnerAgentId == null, so the three assignee-lane call sites are no longer exempted merely because previousStatus can be in_review; only the five participant call sites pass a recovery owner.
  • prior:fec959b important 2 — still-present — server/src/__tests__/heartbeat-process-recovery.test.ts:5657 — the new participant regression test is present, but the current required review check is failing on the PR-description policy and dependent test lanes are not evidenced as having executed. The boundary remains an unverified change until the test lanes run successfully.

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit/tests] prior:fec959b important 2 server/src/__tests__/heartbeat-process-recovery.test.ts:5657 — the new regression coverage for the review-participant boundary has not been verified by the current CI run. The review check is failing before dependent test lanes execute because the PR description is missing required template sections, so this change still lacks a green execution signal.
    • Recommendation: satisfy the PR template/policy check, rerun the dependent test lanes, and confirm the heartbeat-process-recovery suite passes on this exact head before merge.

Suggestions (2)

  • [native-codex] server/src/services/recovery/service.ts:5812 — the closure-scoped suppression counter is snapshot/diffed per sweep and the code documents that overlapping sweeps can split deltas. If this metric becomes operational accounting rather than diagnostic telemetry, pass an explicit per-sweep counter to avoid attribution errors.
  • [pr-review-toolkit/tests] server/src/__tests__/heartbeat-process-recovery.test.ts:143 — the participant regression test asserts no action and no ownership transfer, but not the issue's exact post-reconciliation status or recovery path. Add the expected status/transition assertion if the intended invariant is that the review stage remains unchanged.

Strengths

  • The gate's new recoveryOwnerAgentId == null predicate expresses the intended assignee-execution scope directly and avoids the broader previousStatus proxy.
  • The tests restore exact status, ownership, action-count, and suppression-counter assertions for the assignee execution paths.
  • The preflight comment now records the measured evidence and explains why the transactional gate, rather than escalation, preserves observability.

Recommended Action

  1. Address the Important issue and obtain green dependent test coverage before merge.
  2. Consider Suggestions opportunistically.

…it arm (BLO-27463)

Rebasing onto master surfaced BLO-19123's F2 (`3830d7bc`), which added an
arm keyed on `errorCode === issue_dependencies_blocked && (status ===
"in_review" || !agentInvokable)` that `continue`s before this gate is
reached. Two consequences, both now recorded in code rather than assumed:

1. No `in_review` strand of either lane reaches this gate any more, so the
   `recoveryOwnerAgentId == null` predicate and the `previousStatus !==
   "in_review"` proxy it replaced are not currently distinguishable.
   Verified by running the participant test under both predicates: it fails
   identically, so the behaviour change is 3830d7b's, not this gate's. The
   exact form is kept because it states the intent rather than encoding an
   assumption about an upstream arm that may later narrow.

2. This gate's live population is `todo`/`in_progress` with an invokable
   assignee — which 3830d7b does not match, and which is the bulk of the
   462 active dependency-blocked actions measured today.

The participant test asserted an escalation that 3830d7b removed. Rewritten
to assert observed behaviour, with the residual hazard recorded: that arm
only enqueues a blockers-resolved wake when a blocker row exists, so with
zero blockers — the measured 24/24 shape — a review-stage strand is left with
no wake and no recovery path. Filed as BLO-29604 rather than widened into this
diff or silently absorbed.

Both tests now assert the acceptance criteria (no recovery action, no
ownership transfer) instead of a counter, so they survive the mechanism
moving.

Verified: heartbeat-process-recovery 211/211 green on the rebased tree.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast
allyblockcast Bot force-pushed the blo-27463-dependency-wait-classification branch from 9394c5e to b86f0ba Compare August 21, 2026 08:42

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

Prior Findings Dispositioned (1)

  • prior:fec959b important 2 — still-present — server/src/__tests__/heartbeat-process-recovery.test.ts:5664 — the review-participant boundary regression remains in the current head, but the current PR CI run still has the build and server test lanes pending. The required execution signal is therefore not yet verified on this head.

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit/tests] prior:fec959b important 2 server/src/__tests__/heartbeat-process-recovery.test.ts:5664 — the new review-participant regression coverage is present, but it is not yet evidenced by a completed green CI test lane for this head; the current run remains pending. Please wait for or rerun the relevant server test lane and verify the test executes successfully on this exact revision.

Suggestions (2)

  • [native-codex] server/src/services/recovery/service.ts:5809-5823 — the readiness query and informational log run inside the per-issue transaction/advisory-lock path for every suppressed run. Consider sampling or deduplicating this diagnostic if repeated dependency-wait candidates create sustained lock-held query and log volume.
  • [native-codex] server/src/services/recovery/service.ts:5648 — the closure-scoped suppression counter is intentionally snapshot/diffed per sweep and documents its single-sweep caller invariant. If another caller is added, prefer an explicit per-sweep counter to avoid concurrent-sweep attribution drift.

Strengths

  • The suppression predicate is narrowly scoped to assignee-lane recovery and the dependency-blocked error code, avoiding the earlier status-based proxy.
  • The added tests assert the concrete no-action, ownership, and dispatchable-status outcomes for the principal assignee and provider-capacity cases.
  • The separate suppression counter and structured log preserve observability without creating a stranded recovery action.

Recommended Action

  1. Verify the added regression coverage in a completed green CI test lane on this head.
  2. Address the Important issue before merge.
  3. Consider Suggestions opportunistically.

@allyblockcast
allyblockcast Bot 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 failed status checks Aug 23, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 24, 2026
Merged via the queue into master with commit f952483 Aug 24, 2026
37 of 38 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