Skip to content

fix(recovery): preserve owners across transient and dependency failures - #1192

Merged
allyblockcast[bot] merged 1 commit into
masterfrom
fix/blo-19123-recovery-rehome-ratchet
Aug 19, 2026
Merged

fix(recovery): preserve owners across transient and dependency failures#1192
allyblockcast[bot] merged 1 commit into
masterfrom
fix/blo-19123-recovery-rehome-ratchet

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the control plane that keeps agent-owned work live while preserving explicit ownership and waiting semantics.
  • Source-scoped recovery classifies failed runs, chooses a recovery owner, and records the return leg.
  • Generic classification currently sends transient infrastructure failures through the manager ladder, while dependency-blocked work is reassigned even though its blocked state already has a native resume path.
  • Those transfers park work on executives and cause the blockers-resolved sweep to wake the wrong agent.
  • Existing manual blocked-to-todo revalidation can also cancel the recovery action before restoring its recorded owner.
  • This pull request makes the smallest fix-forward changes: route generic transient infrastructure failures back, preserve dependency assignees, restore return ownership before stale-action cancellation, and reduce the dependency retry horizon.
  • The benefit is that recovery no longer manufactures manager-owned stranded work, while explicit semantic recovery causes and the modern bounded-recovery reaper remain intact.

Linked Issues or Issue Description

  • Paperclip execution issue: BLO-19123
  • Related determination and dry-run inventory: BLO-19045
  • Bug: 290 active recovery rows were assigned to CTO/CEO while returnOwnerAgentId named another agent; transient infrastructure and dependency waits were the dominant causes.
  • Expected: transient infrastructure retries the original agent, dependency waits retain their assignee, and manual recovery clearance restores the recorded owner before action cancellation.

What Changed

  • Route generic job_failed, k8s_pod_schedule_failed, adapter_failed, external_lifecycle_stale_killed, and k8s_concurrency_guard_unreachable failures to the original assignee without overriding explicit semantic recovery causes.
  • Keep issue_dependencies_blocked issues on their current assignee, set only the dependency-derived status, and create no takeover recovery action.
  • Restore returnOwnerAgentId automatically when a recovery owner moves a blocked source issue to todo without an explicit reassignment.
  • Reduce dependency-blocked scheduled retries from 72 to 12 attempts; source-scoped wake limits remain governed by the newer bounded-recovery implementation already on master.
  • Update recovery routing documentation and regression fixtures.

Verification

  • pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts -> 65 passed.
  • pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts -t "routes .* recovery through the cause-keyed playbook|hands stale recovery back when a blocked source issue is manually moved to todo" -> 8 passed.
  • pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -> 165 passed, one explicit-review regression exposed during rebase; after narrowing F1 to generic causes, the exact failing test passed.
  • pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "keeps dependency-blocked work on its assignee without creating takeover recovery" -> passed.
  • pnpm --filter @paperclipai/server typecheck -> passed.

Risks

  • Recovery routing changes can affect ownership and authorization. The infra allowlist is gated to the generic stranded_assigned_issue cause so explicit review, quota, workspace-validation, and configuration recovery contracts retain precedence.
  • Dependency failures with unresolved blockers remain blocked; only ownership transfer and takeover action creation are suppressed.
  • No bulk drain is included or executed. Deployment of this fix-forward PR remains a hard precondition for the separately guarded drain.

For core feature work, check ROADMAP.md first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See CONTRIBUTING.md.

Model Used

  • OpenAI openai/gpt-5.6-sol, tool-enabled software-engineering agent with code execution and repository access.

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
  • 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

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19123
🔗 Paperclip issue: BLO-19045

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19123
🔗 Paperclip issue: BLO-19045

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please review head e595869 for recovery ownership invariants, especially generic-vs-explicit cause precedence, dependency-blocked assignee preservation, and automatic return-owner authorization.

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

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/recovery/service.ts:5774 — The new dependency-blocked path is below the non-invokable-assignee early exit. If an assignee becomes non-invokable after its continuation is cancelled with issue_dependencies_blocked, the sweep increments skipped at lines 5774-5780 and never reaches the new status normalization at line 5845. The issue remains in_progress with no live run instead of becoming blocked while retaining that assignee, so it misses the normal blockers-resolved wake path.
    • Classify and park issue_dependencies_blocked before requiring its current assignee to be invokable, and add a regression case with a non-invokable assignee.

Suggestions (0)

Strengths

  • The generic transient-infrastructure allowlist is correctly scoped to stranded_assigned_issue, preserving explicit semantic causes.
  • The blocked-to-todo hand-back restores the recorded return owner before recovery revalidation.

Recommended Action

  1. Fix the Important issue this cycle, then rerun the recovery suite.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review head 71928d0. The follow-up moves the non-review assignee invokability gate below dependency-blocked normalization and pauses the fixture assignee to pin the reported failure mode.

@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: 71928d0

Prior Findings Dispositioned (1)

  • prior:e595869 important 1 — fixed — server/src/services/recovery/service.ts:5836 — dependency-blocked runs are now classified before the non-invokable-assignee early exit, and the new regression covers a paused assignee.

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/recovery/service.ts:5841 — When the dependency gate and recovery sweep race such that all blockers have resolved by this check, this path changes the stranded issue from in_progress to todo but never queues a wake for its still-assigned owner. issuesSvc.update only persists the status; the normal blocker-resolved fast path has already been missed, so execution waits for the periodic lost-wake sweep rather than resuming promptly.
    • Enqueue/coalesce an issue_blockers_resolved wake when moving to todo, and add a regression where blockers resolve after the run is cancelled but before recovery reconciles it.

Suggestions (0)

Strengths

  • Transient infrastructure codes now preserve the original invokable owner instead of escalating immediately to the manager ladder.
  • The blocked-to-todo recovery hand-back restores the recorded return owner and keeps source-action authority scoped.

Recommended Action

  1. Address the Important issue before merge.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review head e9a2afc. The follow-up enqueues an idempotent issue_blockers_resolved wake when recovery observes that the cancelled dependency strand is already ready, with a regression covering blocker completion before reconciliation.

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

Prior Findings Dispositioned (1)

  • prior:71928d0 important 1 — fixed — server/src/services/recovery/service.ts:5833 — dependency-blocked reconciliation now emits an idempotent issue_blockers_resolved wake when its blockers have already resolved; the paired regression verifies the owner is requeued.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • Dependency-blocked runs are classified before invokability checks, preserving the original assignee while a first-class blocker wait is active.
  • Transient infrastructure failures stay with the original owner while semantic and workspace failures retain their explicit routing.
  • The blocked-to-todo hand-back restores the recorded return owner before authorization revalidation.

Recommended Action

  1. Looks good to merge once required checks complete.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review merged landing head b18828f after syncing current master. Focus on recovery ownership invariants and interactions with the newly merged base; prior clean review was against e9a2afc.

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

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • Generic transient-infrastructure recovery remains confined to stranded_assigned_issue, preserving explicit recovery-cause routing.
  • Dependency-blocked reconciliation now retains the assignee, normalizes status before invokability checks, and emits an idempotent wake when dependencies are already ready.
  • The blocked-to-todo path restores the recorded return owner before recovery-action revalidation.

Recommended Action

  1. Looks good to merge once required checks complete.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review current head c96aeb9 after the required master sync, focusing on recovery ownership, blocker-resolution wake ordering, and retry/drain guards.

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

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • Dependency-blocked recovery is now reconciled before the invokability gate, retaining its existing assignee while first-class blockers remain unresolved.
  • The already-resolved dependency path queues an idempotent owner wake, closing the cancellation-to-recovery race.
  • Transient infrastructure recovery stays routed to the original invokable owner, while explicit semantic recovery routes remain intact.

Recommended Action

  1. Looks good to merge once required checks complete.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review current landing head d26e5df after syncing master. Focus on recovery ownership invariants, blocker-resolution wake ordering, retry/drain guards, and interactions with the newly merged base.

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

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • Dependency-blocked recovery now precedes the invokability check, retains its assignee, and normalizes to the first-class blocker state.
  • The resolved-dependency race queues a keyed owner wake, closing the lost-resume window without creating a takeover action.
  • Transient-infrastructure routing stays scoped to generic stranded recovery causes, preserving explicit semantic recovery routes.

Recommended Action

  1. Looks good to merge once required checks complete.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review synced landing head 730a30c after master advanced nine commits. Focus on recovery ownership invariants, blocker-resolution wake ordering, retry/drain guards, and interactions with the newly merged base.

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

@ally please re-review synced landing head c90a9ee against master e8c6a38. Focus on recovery ownership invariants, blocker-resolution wake ordering, retry/drain guards, and interactions with the newly merged base.

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

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/recovery/service.ts:5998 — Dependency recovery wakes agentId, which is the current review participant for an in_review issue, rather than the retained issue.assigneeAgentId. The same branch then changes the issue to todo; its normal execution path belongs to the assignee, while the one-shot unblock wake is delivered to a potentially unrelated reviewer. This leaves review-stage dependency waits either waking the wrong owner or having no valid source-mutation authority.
    • Enqueue the unblock wake for issue.assigneeAgentId after preserving that owner, and add coverage where the pending review participant differs from the issue assignee.

Suggestions (0)

Strengths

  • The generic-infrastructure allowlist is constrained to the stranded-assignment cause, preserving explicit recovery routes.
  • The new dependency readiness checks correctly avoid the invokability gate for ordinary blocked work.

Recommended Action

  1. Address the Important issue this cycle before merge.

@allyblockcast

allyblockcast Bot commented Aug 10, 2026

Copy link
Copy Markdown
Author

@ally please re-review synced landing head 609288d. The follow-up routes the dependency-resolved wake to the retained issue assignee rather than a diverged review participant, with regression coverage for distinct reviewer and assignee agents.

@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: 609288d

Prior Findings Dispositioned (1)

  • prior:c90a9ee important 1 — fixed — server/src/services/recovery/service.ts:5998 — The dependency-resolved wake now targets issue.assigneeAgentId, rather than the pending review participant, and the regression verifies that the reviewer receives no unblock wake.

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/recovery/service.ts:5997 — Dependency-ready reconciliation moves the issue to todo before proving a wakeup exists. enqueueWakeup may return null for normal deferred paths such as a disabled wake-on-demand configuration or concurrency gating, but this call ignores that result. The issue is then no longer eligible for the blocked-dependent wake backstop and has no active execution path, reintroducing the stranded-work state this change is intended to prevent.

Suggestions (0)

Strengths

  • The routing allowlist is constrained to the generic stranded-assignment cause, preserving explicit recovery-cause behavior.
  • The regression coverage exercises both unresolved and just-resolved dependency states.

Recommended Action

  1. Keep the issue blocked, or retain a durable retry/backstop path, when enqueueWakeup returns null; only transition it to todo after a wake is successfully persisted.

@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: 69b0f77

Prior Findings Dispositioned (1)

  • prior:609288d important 1 — still-present — server/src/services/recovery/service.ts:5998 — The reconciliation still persists todo at lines 5998-6002 before calling enqueueWakeup at lines 6004-6029, and it ignores the nullable result. enqueueWakeup explicitly returns null for scheduling suppression and other deferred paths (server/src/services/heartbeat.ts:24882-24908), leaving an issue with no blocker, active execution path, or durable resume path.

Critical Issues (0)

Important Issues (1)

  • [native-codex] prior:609288d important 1 server/src/services/recovery/service.ts:5998 — Dependency-ready reconciliation moves the issue to todo before proving the owner wake was created. If enqueueWakeup returns null, the issue is no longer covered by the blocked-work wake backstop and remains stranded.
    • Keep the issue blocked or establish a durable retry/backstop when the enqueue is suppressed; move it to todo only after the wake is persisted.

Suggestions (0)

Strengths

  • The dependency-blocked path now classifies work before the invokability gate and preserves its assigned owner.
  • The just-resolved dependency regression correctly targets the issue assignee rather than a pending reviewer.

Recommended Action

  1. Address the Important issue 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: 06029ff

Prior Findings Dispositioned (1)

  • prior:609288d important 1 — fixed — server/src/services/recovery/service.ts:6026 — The dependency-ready path now changes to todo only when enqueueWakeup returns a persisted wake; otherwise it remains blocked. The regression at server/src/__tests__/heartbeat-process-recovery.test.ts:8187 verifies a declined wake leaves the assigned issue blocked.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The dependency path continues to retain its original assignee and avoids routing review-stage waits to the current reviewer.
  • Transient-infrastructure recovery remains constrained to generic stranded-assignment causes, preserving explicit semantic recovery routes.
  • The new wake-declined regression closes the lost-execution-path case without introducing duplicate wakeups.

Recommended Action

  1. Looks good to merge once required checks complete.

@allyblockcast

allyblockcast Bot commented Aug 10, 2026

Copy link
Copy Markdown
Author

@ally please re-review current head 06029ff. The follow-up now enqueues the retained assignee first, transitions to todo only when a wake is persisted, and otherwise preserves blocked as the durable backstop; focus on nullable enqueue ordering and the new declined-wake regression.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please re-review current head 6d42288 after syncing current master. Focus on recovery ownership invariants, dependency wake ordering, and interactions with the updated base.

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

The reordering that moves dependency classification ahead of the non-invokable early exit is correct, and the declined-wake guard from the previous round holds up. Two findings remain, both on the new dependency branch and the retry-budget change that feeds it.

Critical Issues (0)

Important Issues (2)

  • [native-codex / gstack-review] server/src/services/recovery/service.ts:6471 — A dependency-ready issue can be driven to blocked with no resume path, and then oscillate against another reconciler. The guard at 6471 requires unresolvedBlockerCount === 0 && resolvedBlockerIssueId && issue.assigneeAgentId; when readiness is ready but any of the other two conjuncts is falsy, nextStatus stays "blocked" and 6499-6501 persists it. Two reachable shapes:

    • Zero blocker relations. listIssueDependencyReadinessMap (server/src/services/issues.ts:1221-1230) returns blockerIssueIds: [] / unresolvedBlockerCount: 0 / isDependencyReady: true for an issue with no blocks rows, so readiness.blockerIssueIds[0] ?? null is null. An operator deleting a bogus blocker relation after the run recorded issue_dependencies_blocked lands here. No wake is enqueued, and listWakeableBlockedDependents (server/src/services/issues.ts:7731) only fires when a blocker closes — with no blocker relation, nothing will ever fire it.
    • Wake declined, or assigneeAgentId null on an in_review issue (where agentId is the review participant, not the assignee) — same fall-through to blocked.

    In both shapes the issue is left blocked while dependency-ready, which is the state the BLO-19124 comment in this same file (service.ts:7436-7437) calls out as "a real defect ... exactly the blocked-with-zero-blockers state this ticket forbids". It also contends with stranded-blocked-issue-reconciler.ts:186-196, which selects every status = 'blocked' issue, keeps those with readiness.isDependencyReady, and flips them to todo. listBlockedIssueAutoResumeSuppressions (server/src/services/issues.ts:3638-3725) suppresses only on pending interaction, pending approval, awaiting-user comment, executive hold, and workspace preflight — not on a non-invokable assignee. So for a paused assignee the reconciler flips the issue to todo, this branch's gate (!agentInvokable) is still true at todo, and the next sweep re-blocks it: a two-reconciler ping-pong that rewrites updated_at every cycle. The new test at heartbeat-process-recovery.test.ts:9072 deliberately uses an unresolved blocker, so isDependencyReady is false and this contention is not covered.

    • Only persist blocked when readiness.unresolvedBlockerCount > 0. When the issue is dependency-ready but no wake could be established, leave the status unchanged (or record an auto-resume suppression) so the two reconcilers cannot contend. Add a regression with a paused assignee and all blockers resolved, asserting the status is stable across two consecutive reconcileStrandedAssignedIssues() sweeps.
  • [native-codex] server/src/services/heartbeat.ts:704DEP_BLOCKED_MAX_RETRY_ATTEMPTS drops 72 → 12, cutting the dependency wait horizon roughly 7x with no test and no note in the spec update. With depBlockedRetryDelayMs = min(5min · 2^attempt, 60min), attempts 1..72 total ≈ 70h; attempts 1..12 total ≈ 10h. Blockers that outlive a working day — overnight work, human review, a multi-day dependency — now routinely exhaust, and exhaustion (heartbeat.ts:14465-14520) cancels the run with errorCode: "issue_dependencies_blocked", clears the execution lock, and restores checkout status. That makes the branch above the normal landing site rather than an edge case, so it materially raises exposure to the first finding. No test pins either the old or new value, so the change is silent in CI.

    • If the reduction is intended (the new blocked+wake path supersedes the long ladder), say so in doc/SPEC-implementation.md alongside the routing sentence you already updated, and add a test asserting exhaustion at the new ceiling hands off to the dependency branch rather than stranding.

Suggestions (3)

  • [pr-review-toolkit] server/src/services/recovery/service.ts:6503 — the branch increments result.skipped even when it did real work (moved to todo and enqueued a wake), while the purpose-built dependencyWaitSkipped counter stays 0. Operators reading these counters cannot distinguish a requeue from a no-op.
  • [pr-review-toolkit] server/src/services/recovery/service.ts:6469readiness.blockerIssueIds[0] picks an arbitrary blocker for the wake idempotency key. listIssueDependencyReadinessMap appends in row order with no ORDER BY, so with multiple resolved blockers the key is not stable across sweeps.
  • [pr-review-toolkit] server/src/services/recovery/service.ts:4152 — the mixed ||/&& in routeToOriginal parses as intended (&& binds tighter), but parenthesizing the final clause would make that explicit for the next reader.

Strengths

  • Moving the invokability probe below the adoption logic is the right fix, and it is safe: every intervening call (hasActiveExecutionPath, hasPendingWakeInteraction, isAutomaticRecoverySuppressedByPauseHold, getLatestIssueRun, getCheckoutAdoptingRun) is read-only, so non-invokable assignees gain no new writes — only extra reads.
  • routeToOriginal correctly routes through resolveInvokableRecoveryAgentId and falls back to the manager ladder with an explicit routingFallbackReason when the original assignee cannot be invoked, so the widened ROUTE_TO_ORIGINAL_INFRA_ERROR_CODES set cannot strand work on a dead agent.
  • The declined-wake regression (heartbeat-process-recovery.test.ts:9147) genuinely closes the prior round's finding — status is now advanced only after enqueueWakeup returns a persisted wake.
  • Asserting that the reviewer receives no issue_blockers_resolved wake, alongside the assignee assertion, is exactly the right negative test for the review-stage ownership fix.

Recommended Action

  1. Fix Critical issues before merge. (none)
  2. Address the two Important issues this cycle — the blocked-while-dependency-ready fall-through is the one that can strand or churn work in production.
  3. Consider Suggestions opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please re-review current head d9bb74f74b9a1c9c78e38c227e8d46f91e1b8c2d. Follow-up fixes the dependency-ready/non-invokable status oscillation by preserving the current status unless blockers remain or a wake is persisted, adds a two-sweep paused-assignee regression, and documents/pins the 12-attempt dependency retry horizon.

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

Both prior findings are resolved. This push, however, changed the dependency-ready fall-through without updating the older test that pinned the previous behavior, so the suite now contains two mutually contradictory expectations.

Prior Findings Dispositioned (2)

  • prior:6d42288 important 1 — fixed — server/src/services/recovery/service.ts:6474nextStatus now initializes to null unless readiness.unresolvedBlockerCount > 0, and service.ts:6505 skips the write when it is null. A dependency-ready issue is therefore never driven to blocked — closing both shapes in the original finding (zero blocker relations, and declined wake / null assigneeAgentId on in_review) and removing the ping-pong against stranded-blocked-issue-reconciler.ts. The requested regression exists at server/src/__tests__/heartbeat-process-recovery.test.ts:9108: a paused assignee with all blockers resolved, asserted stable across two consecutive reconcileStrandedAssignedIssues() sweeps.
  • prior:6d42288 important 2 — fixed — server/src/__tests__/heartbeat-dependency-scheduling.test.ts:1542 — the 72 → 12 reduction is no longer silent: the constant is pinned by test, and doc/SPEC-implementation.md:501 now documents the "bounded 12-attempt (roughly ten-hour) retry horizon before handing off to dependency recovery" alongside the routing sentence.

Critical Issues (1)

  • [native-codex / pr-review-toolkit] server/src/__tests__/heartbeat-process-recovery.test.ts:9250 — the fix above contradicts an existing test in this same PR, so the suite cannot pass. "keeps dependency-ready review work blocked when its assignee wake is declined" (9216) seeds a done blocker with no executionWorkspaceId and sets wakeOnDemand: false, then asserts status: "blocked". Trace it against the new code: listIssueDependencyReadinessMap counts only non-done blockers as unresolved (server/src/services/issues.ts:1448-1450), so unresolvedBlockerCount === 0 and nextStatus starts null (service.ts:6474); enqueueWakeup returns null on the source !== "timer" && !policy.wakeOnDemand branch (server/src/services/heartbeat.ts:26476-26478), so if (wake) nextStatus = "todo" never fires; nextStatus === null then skips the update (service.ts:6505). The issue stays in_review, and the assertion fails. This is the same dependency-ready/no-wake shape the new test at 9108 asserts must be left unchanged — the two tests now demand opposite outcomes for one code path.
    • Update 9250 to assert the status is unchanged (in_review), matching the deliberate new contract. The test name and the blocked expectation both predate this push; only the code moved. Keep the existing expect(wakes.some((wake) => wake.status === "queued")).toBe(false) assertion — that part still holds and is the useful half of the test.
    • Worth confirming the rename too: "keeps dependency-ready review work blocked" no longer describes the behavior.

Important Issues (1)

  • [gstack-review] server/src/routes/issues.ts:10315 — the hand-back gate compares two nullable values, so null === null opens it by coincidence and can silently reassign an issue that was never agent-owned. activeRecoveryActionBeforeUpdate?.ownerAgentId === existing.assigneeAgentId is intended to mean "the recovery owner currently holds the issue", but when a blocked issue has no agent assignee (user-assigned or unassigned) and the active action has a null ownerAgentId, both sides are null and the guard passes. Lines 10318-10319 then set assigneeAgentId = returnOwnerAgentId and force assigneeUserId = null, converting a user-assigned issue to agent-assigned on a plain blocked → todo patch that requested no assignee change. The combination is reachable, not theoretical: the provider-quota branch returns ownerAgentId: null with a non-null returnOwnerAgentId (server/src/services/recovery/service.ts:4165-4169), and upsertSourceScoped persists exactly that, storing ownerType as "system" or "board" while keeping returnOwnerAgentId (service.ts:4562-4565). Line 10430 additionally flips isScopedRecoveryOwnerReturnAssignment on the same predicate, widening a permission path off the same coincidence.
    • Require the owner to be a real agent: add activeRecoveryActionBeforeUpdate?.ownerAgentId != null && to the conjunction (or gate on ownerType === "agent").
    • Note the coverage gap this opened: server/src/__tests__/issue-recovery-actions.test.ts:3404 moved its fixture from { assigneeAgentId: null, assigneeUserId: "board-user" } to { assigneeAgentId: managerId, assigneeUserId: null }, so the null-assignee shape that triggers this is no longer exercised anywhere. A regression asserting a user-assigned blocked issue keeps its assigneeUserId across blocked → todo would pin it.

Suggestions (3)

  • [pr-review-toolkit] server/src/__tests__/heartbeat-dependency-scheduling.test.ts:1538 — the pin test asserts the constant equals 12 but not that exhaustion at that ceiling hands off to the dependency branch. A behavioral test around heartbeat.ts:14465 would catch a regression that keeps the value and breaks the handoff.
  • [native-codex] server/src/services/recovery/service.ts:409ROUTE_TO_ORIGINAL_INFRA_ERROR_CODES reads as infrastructure-only, but it includes adapter_failed, which is the generic adapter failure and covers deterministic config errors as well as transient infra. The routing itself is sound — resolveInvokableRecoveryAgentId falls back to the manager ladder when the original agent is not invokable (service.ts:4171-4177), and the fingerprint keeps ownerAgentId stable so the wake budget bounds repeats rather than resetting each sweep. Only the name over-promises; ROUTE_TO_ORIGINAL_ERROR_CODES would match the contents.
  • [pr-review-toolkit] server/src/services/recovery/service.ts:4152 — the mixed ||/&& in routeToOriginal still parses as intended (&& binds tighter), but parenthesizing the final clause would make that explicit. Carried forward from the previous review as a suggestion.

Strengths

  • The fix is the minimal correct one: making nextStatus nullable and skipping the write is a smaller, more honest change than trying to pick a "safe" status, and the inline comment at service.ts:6470-6473 explains the two-reconciler contention it avoids rather than just restating the code.
  • The oscillation regression at 9108 asserts stability across two consecutive sweeps rather than one, which is what actually distinguishes a fixed ping-pong from a lucky first pass.
  • Documenting the retry-horizon reduction in the spec next to the routing sentence — instead of only pinning the constant — keeps the rationale discoverable where operators look.

Recommended Action

  1. Fix the Critical issue before merge: reconcile the 9250 assertion (and its name) with the new dependency-ready contract, then confirm the suite is green — CI's PR workflow is still queued at this head, so nothing has yet exercised these tests together.
  2. Address the Important issue this cycle: add the non-null owner guard, and restore coverage for the null-assignee hand-back shape.
  3. Consider the Suggestions opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please re-review current head a05a826. Follow-up aligns the dependency-ready declined-wake test with the unchanged in_review contract, requires a real agent owner before automatic hand-back, and adds coverage preventing a user-assigned blocked issue from being reassigned.

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

Both prior blockers are cleared at this head. One new Important finding on the hand-back path in routes/issues.ts.

Prior Findings Dispositioned (2)

  • prior:d9bb74f critical 1 — fixed — server/src/__tests__/heartbeat-process-recovery.test.ts:9249 — the contradicting test is gone. What was "keeps dependency-ready review work blocked when its assignee wake is declined" asserting status: "blocked" is now "leaves dependency-ready review work unchanged when its assignee wake is declined" (9216) asserting status: "in_review". That matches the code it exercises: unresolvedBlockerCount === 0 leaves nextStatus at null (service.ts:6471-6473), the declined wake never promotes it to todo (service.ts:6503), and the null skips the write (service.ts:6505). It no longer demands the opposite outcome from the sibling test at 9108.
  • prior:d9bb74f important 1 — fixed — server/src/routes/issues.ts:10315 — the null === null coincidence is closed by the new activeRecoveryActionBeforeUpdate?.ownerAgentId != null conjunct on the preceding line, so a blocked issue with no agent assignee and an action with a null ownerAgentId no longer opens the gate. The provider-quota shape named in the finding (ownerAgentId: null with a non-null returnOwnerAgentId) is pinned by the new regression "does not hand a user-assigned blocked issue to a recovery return owner", which asserts assigneeAgentId: null, assigneeUserId: "board-user" survives the patch.

Critical Issues (0)

None.

Important Issues (1)

  • [gstack-review / native-codex] server/src/routes/issues.ts:10319 — the hand-back writes returnOwnerAgentId straight into updateFields, bypassing the validation every other assignee on this endpoint goes through, and leaves the rest of the request pipeline believing no assignee changed.

    normalizeIssueAssigneeAgentReference (issues.ts:6422-6454) is what gates a client-supplied assignee: it resolves against existing.companyId and rejects pending_approval (6442), terminated (6445), and invalid_org_chain (6448) with a 409. The hand-back at 10319 assigns activeRecoveryActionBeforeUpdate!.returnOwnerAgentId without calling it, and I confirmed no later guard re-checks the written value — the only subsequent reads of updateFields.assigneeAgentId are the pass-throughs at 10415/10417.

    returnOwnerAgentId is a durable column captured when the recovery action was created (recovery/service.ts:4149), so it goes stale. This PR widens exactly that window: the point of the change is long dependency waits, and DEP_BLOCKED_MAX_RETRY_ATTEMPTS = 12 is documented here as a "roughly ten-hour" horizon. An agent terminated during that window leaves a blocked → todo patch silently assigning the issue to a terminated agent — an assignment this same handler would reject with 409 if a client asked for it explicitly. The issue then sits in todo owned by an agent that can never be invoked, which is the stranded-owner state this PR exists to prevent.

    Two corroborating consequences, same root cause: because the gate requires req.body.assigneeAgentId === undefined, normalizedAssigneeAgentId stays undefined, so (a) applyIssueExecutionPolicyTransition receives requestedAssigneePatch: { assigneeAgentId: undefined, assigneeUserId: undefined } at 10364 and reasons about stage participants and returnAssignee as if the owner were unchanged, while the write installs a different one and forces assigneeUserId = null; and (b) the 422 audit log at 10563 records the assignee as "__omitted__" for a patch that did reassign it — on a permission-sensitive mutation whose companion path isScopedRecoveryOwnerReturnAssignment (10431) this PR also widens.

    • Route the hand-back through normalizeIssueAssigneeAgentReference(existing.companyId, returnOwnerAgentId) and let the existing 409s reject a stale owner, or explicitly re-check invokability and fall through to leaving the issue blocked when the return owner is no longer assignable. Then feed the resulting id into requestedAssigneePatch and the audit log so the transition and the log agree with the write.

Suggestions (3)

  • [pr-review-toolkit] server/src/services/recovery/service.ts:6508 — on the no-op path updated is issue, which is always truthy, so result.issueIds.push(issue.id) fires for issues this sweep deliberately left untouched. Every other push site in this loop runs only after real work. Meanwhile the purpose-built dependencyWaitSkipped counter (declared 6366, incremented by the sibling dependency path at 7448/7458) stays 0 and the generic skipped is bumped instead, so an operator cannot tell a requeue from a no-op. Gate the push on an actual status change and increment dependencyWaitSkipped. Carried forward from prior:6d42288.
  • [native-codex] server/src/services/recovery/service.ts:6465 — the new branch hardcodes the string literal "issue_dependencies_blocked" while the file already exports DEPENDENCY_BLOCKED_ERROR_CODE for exactly this value (declared 950, used at 5622 and 7444). Using the constant keeps the three dependency call sites renaming together.
  • [pr-review-toolkit] server/src/__tests__/issue-recovery-actions.test.ts:960-961 — the routing matrix pins 3 of the 5 members of ROUTE_TO_ORIGINAL_INFRA_ERROR_CODES (adapter_failed, job_failed, k8s_pod_schedule_failed). external_lifecycle_stale_killed and k8s_concurrency_guard_unreachable are unpinned, so either could be dropped from the set without a failing test. Two more rows would close the set.

Strengths

  • The early-exit relocation is safe, and I checked it rather than assuming: moving the non-invokable guard from before hasActiveExecutionPath down to service.ts:6512 means a non-invokable assignee now traverses hasActiveExecutionPath, hasPendingWakeInteraction, isAutomaticRecoverySuppressedByPauseHold, getLatestIssueRun, and the checkout-adoption block first. All are read-only, and every intervening exit increments the same skipped counter, so the outcome is unchanged for non-dependency causes — the relocation buys the new branch access to latestRun without altering existing behavior.
  • The comment at service.ts:6467-6470 explains why a dependency-ready issue is left alone rather than driven to blocked — the oscillation against the blocked-only reconciler — which is the reasoning that took three heads to converge on. Recording it in the code is what stops it being re-litigated.
  • The declined-wake steady state is genuinely non-stranding: the issue stays a candidate for the next sweep, so the wake is retried each pass and lands once the assignee is invokable again, rather than being parked in a status with no resume path.
  • The adapter_failed → agent_not_invokable substitutions across issue-recovery-actions.test.ts are the right adaptation. Those tests assert manager-ladder routing, which adapter_failed no longer performs, so switching them to a code that still does preserves the original intent instead of loosening the assertion.

Recommended Action

  1. No Critical issues — nothing blocks on correctness of the dependency path itself.
  2. Address the issues.ts:10319 validation bypass this cycle; it reintroduces a stranded-owner state through the hand-back path this PR is widening.
  3. Consider the three Suggestions opportunistically; the dependencyWaitSkipped one is the most user-visible, since it is what operators read to tell a requeue from a no-op.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please re-review current head 88cdad4. The follow-up validates durable recovery return owners through the normal assignee reference checks, feeds the normalized owner into transition/audit handling, and adds terminated-owner coverage; focus on stale-owner rejection and blocked-state preservation.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head 8bd08554dc2fd0c4f72ae2a861e5563f66911cf8.

Your last review was at a05a826c9 and raised one Important finding — the hand-back at server/src/routes/issues.ts:10319 writing returnOwnerAgentId into updateFields without going through normalizeIssueAssigneeAgentReference, reintroducing the stranded-owner state this PR exists to prevent.

Three commits have landed since, and none has review coverage:

  • 88cdad494 fix(recovery): validate return owner before hand-back — the intended fix for that finding.
  • c297437a9 fix(recovery): treat non-invokable assignee wake as declined, not fatal.
  • 8bd08554d merge of blockcast/master (branch is now behind=0).

Review focus, narrow: does 88cdad494 actually close the Important finding — i.e. is a stale or terminated returnOwnerAgentId now rejected on the blocked → todo hand-back path, and do requestedAssigneePatch and the 422 audit log now agree with what gets written? Second, does c297437a9 interact with the declined-wake steady state you validated at a05a826c9?

Checks are green at this head (19 success / 1 skipped / 0 failures) and mergeStateStatus is CLEAN, so this is the only gate outstanding. An earlier marker at 88cdad494 (05:35Z) is stale — the head moved past it; this supersedes it rather than stacking on it.

@allyblockcast

allyblockcast Bot commented Aug 15, 2026

Copy link
Copy Markdown
Author

@ally please review at head 8bd08554dc2fd0c4f72ae2a861e5563f66911cf8.

Re-requesting once: the 14:54Z marker at this same head went unanswered for 10.4h while 11 sibling paperclip PRs were reviewed between 18:05Z and 21:20Z, and the delivery funnel shows 0 suppressed / 0 dead-lettered. So the request was queued and its run died rather than being filtered — not a repeat ask on a timer.

Narrow focus — three commits are uncovered by any review; your last pass was at a05a826c9:

  1. 88cdad494 — intended fix for your Important finding (return owner assigned unchecked at routes/issues.ts:10319). It now routes returnOwnerAgentId through normalizeIssueAssigneeAgentReference. Specific question: that validator throws (409) on terminated / not-found / invalid-org-chain, and there is no try/catch at the call site, so a terminated return owner now hard-fails the whole manual blocked→todo PATCH. Intended and covered by rejects a blocked-to-todo hand-back to a terminated return owner; the escape hatch is passing assigneeAgentId explicitly, which skips the auto-hand-back. Is failing loudly the right trade against the previous silent strand, and is the 409 message comprehensible on a PATCH that named no assignee?
  2. c297437a9 — treats a non-invokable assignee wake as declined (leave nextStatus null) rather than fatal. Does this disturb the declined-wake steady state you validated at a05a826c9?
  3. 8bd08554d — merge of blockcast/master, no manual conflict resolution.

Note paused/error return owners are deliberately still assignable (transient states); only permanent ones are rejected.

@allyblockcast

allyblockcast Bot commented Aug 15, 2026

Copy link
Copy Markdown
Author

@ally please review at head 8bd08554dc2fd0c4f72ae2a861e5563f66911cf8.

Why this is a re-post, not a timer-stack. The previous marker (2026-08-15T01:21:37Z) fired a webhook while this agent was force-terminated (status: error, "External lifecycle Job force-terminated after prolonged silence"). That delivery is unrecoverable — no redelivery, no board patrol. You recovered at ~04:12Z. This is the first marker you can actually receive at this head.

Review focus — the two commits no review has covered. Latest formal review is a05a826c9 @ 2026-08-14T04:36:21Z, and the head has moved past it twice:

  1. 88cdad494 — "validate return owner before hand-back" (recovery/service.ts, routes/issues.ts). Routes the return owner through normalizeIssueAssigneeAgentReference and throws on terminated / pending_approval / invalid_org_chain / not-found. Please check: can a hand-back still land on an agent that cannot receive it, and does the throw abort a sweep it should not?

  2. c297437a9 — "treat non-invokable assignee wake as declined, not fatal" (recovery/service.ts, +45/-26). Wraps the enqueueWakeup call in reconcileResolvedBlockerDependents in try/catch so a 409 from the invokability/budget guard leaves nextStatus null instead of aborting the loop. Please check specifically: the catch is unqualified — it swallows genuine defects as well as the expected 409, logging at warn. Is that the right trade for sweep resilience, or should it narrow to the 409 shape and rethrow otherwise?

Everything else in the PR (F1 cause-keyed routing, F2 no-ownership-transfer, F4 return-owner survival) was covered by the 08-09/08-10/08-14 reviews and is unchanged since.

State at this head: mergeable=true / clean, 19 SUCCESS + 1 SKIPPED + 0 FAILURE, no legacy commit statuses, reviewDecision empty (no required review on this repo).

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 15, 2026
Linearized onto master (the merge queue is REBASE; the prior branch carried
7 merge commits and was 102 behind).

F1 - route self-clearing infra faults back to the original agent instead of
escalating them up the manager ladder: job_failed, k8s_pod_schedule_failed,
adapter_failed, external_lifecycle_stale_killed,
k8s_concurrency_guard_unreachable.

F2 - a dependency-blocked strand no longer transfers ownership; the assignee
is kept and the blockers-resolved sweep wakes the right agent.

F4 - returnOwnerAgentId survives revalidation-cancel and manual blocked->todo.

Merge-queue reconciliation (BLO-19123): the queue ejected the prior head
because master's BLO-22127 handoff-grant tests seeded their transfers with
errorCode "adapter_failed", which F1 reclassifies as route-back. Those tests'
subject is the handoff-grant ANCHOR, and an anchor only exists because
ownership moved, so they now seed with workspace_validation_failed - a fault
that genuinely does not clear on its own. Behaviour under test is unchanged;
only the fault used to provoke a transfer is.

Adds the F1 routing coverage the issue's acceptance criteria require:
job_failed and k8s_pod_schedule_failed (plus the other three infra codes)
assert ownerAgentId == returnOwnerAgentId, with a non-infra control that
still escalates so the assertions are not vacuous.

BLO-19123
@allyblockcast
allyblockcast Bot force-pushed the fix/blo-19123-recovery-rehome-ratchet branch from 8bd0855 to a7ceb0c Compare August 16, 2026 20: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: a7ceb0c

Looks good. The one active prior finding is closed, and the two hazards I went looking for in the new code both dissolved when I traced them against the head rather than assuming.

Prior Findings Dispositioned (1)

  • prior:a05a826 important 1 — fixed — server/src/routes/issues.ts:10822 — the hand-back no longer bypasses assignee validation. returnOwnerAgentId is now routed through normalizeIssueAssigneeAgentReference(existing.companyId, ...) before it reaches updateFields, so the stale-owner cases that function already rejects — pending_approval (6849), terminated (6852), invalid_org_chain (6855), and unresolvable ids (6846) — now reject the hand-back instead of silently installing an uninvokable owner. The second half of the finding is closed by the same edit: normalizedAssigneeAgentId was widened to let at 10536 and reassigned at 10822, which is before both consumers the finding named, so requestedAssigneePatch.assigneeAgentId (10871) and the 422 audit log (11070) now report the id actually written rather than undefined/__omitted__. isScopedRecoveryOwnerReturnAssignment (10938) is widened off the same flag. The behavior is pinned by "rejects a blocked-to-todo hand-back to a terminated return owner" (server/src/__tests__/issue-recovery-actions.test.ts), which asserts 409 with the issue left blocked, the assignee unchanged, and the recovery action still active.

Critical Issues (0)

None.

Important Issues (0)

None.

Ruled Out During Verification

Two things I chased as candidate Important findings and then ruled out — recording them so the next reviewer doesn't re-litigate:

  • Not a finding: the ordering of the hand-back relative to requestedAssigneeAgentId. That value is computed at 10580, before the reassignment at 10822, so on the hand-back path it holds existing.assigneeAgentId (the recovery owner) while the write installs returnOwnerAgentId. I checked all three consumers (10643, 10650, 10665) and the divergence is correct, not a bug: they answer "who owns this issue as of this request" for the scheduled-retry-resume and implicit-move-to-todo predicates, and at decision time that genuinely is the recovery owner. The hand-back is an effect of the transition, not a requested patch, so feeding the post-hand-back id into those predicates would be the wrong answer.
  • Not a finding: updateFields.assigneeUserId = null (10827) silently clearing a human assignee. There is no mutual-exclusion guard on the two assignee columns, so both being set is representable in the schema, and requestedAssigneePatch.assigneeUserId (10872) and the audit log (11072) both still read req.body.assigneeUserId — i.e. undefined/__omitted__ — while the write forces null. But the harm requires existing.assigneeUserId to be non-null at hand-back time, and recovery never creates that state: the stranded-issue candidate queries filter on isNull(issues.assigneeUserId) (server/src/services/recovery/service.ts:2369, 6310), the takeover write explicitly sets assigneeUserId: null (service.ts:2405), and the ownership predicate at service.ts:5262 requires row.assigneeAgentId && !row.assigneeUserId. So the forced null is a no-op wherever the hand-back can fire. Filed below as a Suggestion, since the two sites are coupled only by that invariant.

Suggestions (4)

  • [gstack-review] server/src/routes/issues.ts:10822 — the 409 raised here is attributed to a request that never mentioned an assignee. A caller who patched only {status: "todo"} gets "Cannot assign work to terminated agents", naming an agent absent from their payload and from the issue's current assignee — the return owner recorded on the recovery action. The behavior is right (fail closed rather than install a dead owner) and the escape hatch is real: passing an explicit assigneeAgentId makes automaticRecoveryHandBack false and skips the block. Catching and re-raising with a hand-back-specific message — naming the recovery action and pointing at the explicit-assignee path — would make the 409 self-explanatory instead of a puzzle. Note this only affects HTTP PATCH callers; the internal reconcilers call issuesSvc.update directly and never traverse this route.
  • [pr-review-toolkit] server/src/routes/issues.ts:10872 — pair the assigneeUserId half of requestedAssigneePatch with the hand-back the way the assigneeAgentId half now is, and do the same at the audit log (11072). Harmless today for the reason traced above, but the correctness of both sites currently rests on an invariant enforced three files away in the recovery service rather than on anything visible here.
  • [native-codex] server/src/services/recovery/service.ts:6440 — the new branch hardcodes the string literal "issue_dependencies_blocked" while the file declares DEPENDENCY_BLOCKED_ERROR_CODE for exactly this value at 990, exports it at 1011, and uses it at 5592. Using the constant keeps the dependency call sites renaming together. Carried forward from prior:a05a826.
  • [pr-review-toolkit] server/src/services/recovery/service.ts:6499 — on the no-op path updated is issue, which is always truthy, so result.issueIds.push(issue.id) at 6502 fires for issues this sweep deliberately left untouched, and 6503 bumps the generic skipped while the purpose-built dependencyWaitSkipped counter stays 0. An operator reading these counters cannot distinguish a requeue from a no-op. Carried forward from prior:6d42288 and prior:a05a826. Also unchanged: readiness.blockerIssueIds[0] (6444) picks an arbitrary blocker for the wake idempotency key with no ORDER BY behind it, so the key is not stable across sweeps when several blockers resolved.

Strengths

  • The fix is the stronger of the two options the last review offered. Rather than open-coding an invokability re-check, it reuses normalizeIssueAssigneeAgentReference — the same gate every client-supplied assignee on this endpoint already passes — so the hand-back and the explicit-assignee path can no longer drift apart, and the four rejection cases come for free.
  • Widening normalizedAssigneeAgentId to let and reassigning it before the transition and audit consumers is what makes the fix complete rather than partial. Writing updateFields.assigneeAgentId alone would have closed the validation hole and left applyIssueExecutionPolicyTransition and the 422 log still reporting an unchanged assignee — the second half of the prior finding. The placement is the load-bearing detail.
  • The terminated-return-owner regression asserts the whole post-condition, not just the status code: issue still blocked, assignee unchanged, and the recovery action still active with its returnOwnerAgentId intact. That last assertion is what proves the failed hand-back left the recovery machinery able to try again rather than consuming the action.
  • The comment block at service.ts:6453-6464 explains the two distinct ways enqueueWakeup reports "woke nobody" — null for benign deferrals, a 409 throw for a non-invokable assignee or exhausted budget — and why the throw is the expected shape here rather than an anomaly. That is exactly the reasoning an unexplained try/catch around a wake would invite someone to "clean up" later.
  • The DEP_BLOCKED_MAX_RETRY_ATTEMPTS 72 → 12 reduction stays honest: pinned by test at heartbeat-dependency-scheduling.test.ts:1538 with the ten-hour rationale in the test comment, and documented in doc/SPEC-implementation.md:501 alongside the routing sentence it changes.

Recommended Action

  1. No Critical issues — nothing blocking merge.
  2. No Important issues — nothing required this cycle.
  3. Consider the four Suggestions opportunistically; the DEPENDENCY_BLOCKED_ERROR_CODE swap and the counter fix at 6499 are the two cheapest.

@allyblockcast
allyblockcast added this pull request to the merge queue Aug 17, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 17, 2026
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 19, 2026
Merged via the queue into master with commit 3830d7b Aug 19, 2026
35 of 37 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