Skip to content

feat(recovery): drain mis-owned blocked rows back to their real owners (BLO-19123) - #1549

Merged
allyblockcast[bot] merged 6 commits into
masterfrom
fix/blo-19123-drain
Aug 30, 2026
Merged

feat(recovery): drain mis-owned blocked rows back to their real owners (BLO-19123)#1549
allyblockcast[bot] merged 6 commits into
masterfrom
fix/blo-19123-drain

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The recovery subsystem detects a run that has stranded and re-homes the issue up the management ladder so a human-ish owner can adjudicate it
  • That ladder is wrong for one class: a dependency-blocked issue is not stranded, it is waiting for its blocker — but it still gets transferred onto the owner's manager
  • The transfer is silent and compounding. When the blocker finally resolves, reconcileResolvedBlockerDependents wakes candidate.assigneeAgentId — now the manager — so the IC who was actually doing the work is never woken, and the row sits. ~360 rows have accrued on two agents (CTO/CEO) this way
  • The forward fixes are already merged: F1/F2/F3/F4 in fix(recovery): preserve owners across transient and dependency failures #1192 stop new rows accruing, and fix(recovery): return ownership without falsifying blocked status (BLO-19123) #1548 added an ownership-only restore that returns the owner without falsifying blocked. But nothing automatic calls it, so the existing stock never drains
  • This pull request adds reconcileStrandedRecoveryHandBacks, a scheduler pass that performs that same ownership-only restore in bulk, behind guards that stop a naive drain from re-triggering the very ratchet it is clearing
  • The benefit is that ~360 mis-owned rows return to the agent that can actually progress them, and the wake lands on the right agent when each blocker clears

Linked Issues or Issue Description

  • Closes BLO-19123 — the drain (section D) is the last open code gap on that issue.
  • Refs BLO-19045 — the dry run that established the population and reclassified the defect as an ownership transfer rather than a wrong blocked status.

Related PRs found in the duplicate search (none supersede this one):

PR State Relationship
#1548 merged Added the blocked+blocked ownership-only restore endpoint. This PR is the automatic caller it never got.
#1192 merged F1/F2/F4 — stops new rows accruing. Ordering constraint from the issue: fix forward before draining. Satisfied.
#875 open BLO-19124, bounds/re-arms stranded recovery actions. Adjacent retry-bound work, disjoint from the drain.
#1405, #1510 merged Narrow the strand-creation paths. Complementary; neither drains existing stock.

What Changed

  • server/src/services/recovery/service.ts — adds reconcileStrandedRecoveryHandBacks / reconcileStrandedRecoveryHandBacksImpl: selects active recovery actions whose source issue is blocked and mis-owned, and returns assigneeAgentId to returnOwnerAgentId while the row stays blocked. Reaches the same contract fix(recovery): return ownership without falsifying blocked status (BLO-19123) #1548 added to the resolve endpoint, including its precondition that a real unresolved first-class blocks relation backs the status, so the drain cannot launder a false blocked. Every skip is recorded per row in residual with its reason.
  • server/src/config.ts — adds PAPERCLIP_STRANDED_RECOVERY_HAND_BACK_DRAIN_ENABLED, default off.
  • server/src/index.ts — wires the pass as its own tracked scheduler pass rather than a link in the long serial recovery chain, which begins with provider-facing work; an outage there must not starve this.
  • server/src/services/heartbeat.ts — supporting wiring for the hand-back path.
  • server/src/__tests__/issue-recovery-actions.test.ts — 12 new cases covering the hand-back, the no-wake invariant, each guard below, the calendar-day window boundary, and budget exhaustion across repeated re-strands.

No wake is enqueued by the pass. The blockers-resolved sweep wakes the now-correct assignee when the blocker actually clears.

Guards

Each exists because a naive drain re-triggers the ratchet:

Guard Why
Hand-back budget — 2 per source issue, ever Bounds blocked → handed back → stranded → blocked oscillation. Counted over resolved history; rows are never deleted, so the count is durable.
Positive run evidence agents.lastHeartbeatAt proves the process ticked, not that it progressed — and a no-op run still reports status: succeeded. Requires livenessState in (completed, advanced).
Return-owner eligibility A hand-back only converges if the return owner can run. Terminated / pending_approval / invalid-org-chain owners are skipped and reported with the reason.
Source-scoped cooldown Keyed on the issue's last hand-back, not the action's lastAttemptAt: a successful hand-back resolves its action, so the next strand always arrives with lastAttemptAt: null and an action-scoped cooldown could never fire.
Validity window An issue past its targetDate is reported as lost rather than silently returned late. Compared on the calendar day, so "due today" is still inside the window.

Recording every skip in residual means the acceptance criterion "rows not handed back are enumerated individually with the failing reason" is served by the pass itself, rather than by a separate reconstruction that could disagree with it.

Verification

  • issue-recovery-actions.test.ts: 148 passed (148), up from 136 — the +12 delta is the collection proof that the new cases ran (the basic reporter does not print every passing test).
  • Server typecheck: 0 errors.
  • Full CI on this head is the authority and is currently in flight; this PR is not proposed for merge until it is green.
  • Two bugs this found and fixed pre-merge: a Date bound into a raw sql template (postgres.js only accepts string/Buffer there, so it threw ERR_INVALID_ARG_TYPE at bind time), and that error escaping the per-candidate try — which in production would have aborted the whole batch and left the remaining rows unexamined with no record of why.

Risks

  • This pass reassigns real in-flight work in bulk (~360 rows). That is the headline risk and the reason for the flag below.
  • Shipped dark. Behind PAPERCLIP_STRANDED_RECOVERY_HAND_BACK_DRAIN_ENABLED, opt-in, unlike its sibling reconcilers which default on. Defaulting it on would make "deploy the code" and "perform the data move" the same irreversible act, with no run in between to re-derive the inventory the plan requires. Enabling the flag is the drain. Please push back if a default-on reconciler is the house convention here.
  • Race on the ownership write. The UPDATE re-asserts the observed assigneeAgentId in its WHERE and throws HandBackOwnershipRaceLost to roll the action resolution back with it. Chosen deliberately: a resolved action whose issue never moved would be strictly worse than not draining. Flagged for review — see below.
  • Duplicated precondition. The truthfulness guard (hasUnresolvedFirstClassBlocker) re-implements the one at routes/issues.ts:8710 rather than sharing it. Divergence risk if one side is later changed alone.
  • Budget-guard schema gap. The budget counts outcome = 'handed_back' without constraining status. Today only resolved rows can carry that outcome, but the schema does not forbid a cancelled one, so the count could drift if that ever changes.
  • No migration, no schema change, additive only (875 additions, 0 deletions). With the flag off the pass does not run, so the deploy-time risk is limited to the scheduler registration.

Review focus

  1. Transaction boundary in reconcileStrandedRecoveryHandBacksImpl — is rolling the action resolution back on a lost ownership race the right failure mode, versus resolving the action anyway?
  2. Duplicated truthfulness precondition — extract to shared, or is the duplication correct given one path is HTTP-shaped and one is sweep-shaped?
  3. Budget guard counting handed_back without constraining status.
  4. Opt-in flag — deliberate, but push back if default-on is the convention.
  5. The sql template binds cutoff.toISOString()::timestamptz rather than a Date, because postgres.js throws on a Date in a raw template. Please check I have not missed that pattern elsewhere in the new code.

Model Used

Claude Opus 5 (claude-opus-5), 1M context, extended thinking enabled, with tool use and code execution, driven through Claude Code as the Paperclip Release Engineer agent. The implementing commit cd8fea1f is Co-Authored-By: Claude.

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 (issue-recovery-actions.test.ts 148/148; server typecheck clean)
  • I have added or updated tests where applicable (12 new cases)
  • If this change affects the UI, I have included before/after screenshots — n/a, server-only change
  • I have updated relevant documentation to reflect my changes — n/a, no documented contract changes; the flag is described in this PR and on BLO-19123
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — in flight at cd8fea1f; not proposed for merge until green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — review not yet returned
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

…s (BLO-19123)

The stranded-recovery ladder re-homes a strand onto the owner's manager. For a
dependency-blocked issue that transfer is an artifact: the issue is not stranded,
it is waiting for its blocker, and when the blocker resolves
reconcileResolvedBlockerDependents wakes the *current* assignee — the manager —
so the agent that was doing the work is never woken. ~360 rows accrued on two
agents that way.

Adds reconcileStrandedRecoveryHandBacks: an ownership-only pass that returns
assigneeAgentId to returnOwnerAgentId while the row stays `blocked`. It reaches
the same blocked+blocked contract PR #1548 added to the resolve endpoint, from a
sweep instead of an HTTP call, including that path's precondition that a real
unresolved `blocks` relation backs the status — so the drain cannot launder a
false `blocked`. No wake is enqueued; the blockers-resolved sweep wakes the
now-correct assignee when the blocker clears.

Guards, each because a naive drain re-triggers the ratchet:
- Hand-back budget: 2 auto-returns per source issue, ever, counted over resolved
  history. Bounds blocked -> handed back -> stranded -> blocked oscillation.
- Positive run evidence: a recent run with livenessState in (completed,advanced),
  not agents.lastHeartbeatAt, which proves liveness rather than progress — a
  no-op run also reports status `succeeded`.
- Return-owner eligibility: terminated / pending_approval / invalid-org-chain
  owners are skipped and reported with the reason, since a hand-back only
  converges if the return owner can actually run.
- Source-scoped cooldown, keyed on the issue's last hand-back rather than the
  action's lastAttemptAt: a successful hand-back resolves its action, so the next
  strand always arrives with lastAttemptAt null and an action-scoped cooldown
  could never fire.
- Validity window: an issue past its targetDate is reported as lost rather than
  silently returned late.

Every skip is recorded per row in `residual` with its reason, so the acceptance
criterion "rows not handed back are enumerated individually" is served by the
pass itself rather than a separate reconstruction that could disagree with it.

Shipped dark behind PAPERCLIP_STRANDED_RECOVERY_HAND_BACK_DRAIN_ENABLED. Unlike
its sibling reconcilers this is opt-in on purpose: it reassigns real in-flight
work in bulk, and defaulting it on would make deploying the code and performing
the data move the same irreversible act, with no run in between to re-derive the
inventory first.

Wired as its own tracked scheduler pass rather than a link in the long serial
recovery chain, which begins with provider-facing work — an outage there must not
starve this.

Tests: 12 new cases in issue-recovery-actions.test.ts covering the hand-back,
the no-wake invariant, each guard, the calendar-day window boundary, and budget
exhaustion across repeated re-strands. Suite 148/148 (was 136); server typecheck
clean.

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

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19123

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-19123

@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

@ally please review at head cd8fea1 — new automated drain that reassigns ~360 production rows (BLO-19123).

Focus:

  1. Transaction boundary in reconcileStrandedRecoveryHandBacksImpl: the ownership UPDATE re-asserts the observed assigneeAgentId in its WHERE and throws HandBackOwnershipRaceLost to roll the action resolution back with it. Is that the right failure mode versus resolving the action anyway?
  2. Duplicated truthfulness precondition: hasUnresolvedFirstClassBlocker re-implements the guard at routes/issues.ts:8710. Extract to shared, or is duplication correct here?
  3. Budget guard counts outcome='handed_back' without constraining status; only resolved rows can carry it today but the schema does not forbid cancelled.
  4. Opt-in flag — I shipped this dark deliberately so deploy and the bulk data move are separate decisions. Push back if a default-on reconciler is the house convention here.
  5. The sql template binds cutoff.toISOString()::timestamptz rather than a Date, because postgres.js throws on a Date in a raw template. Check I have not missed that pattern elsewhere in the new code.

@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

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

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

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

— commitperclip

@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

The red review gate here was not a code failure — commitperclip flagged the PR description for missing the required template sections (## Thinking Path, ## What Changed, ## Risks, ## Model Used) and the unchecked dedup-search box. I have rewritten the description to the template and completed the duplicate search (nearest neighbours: #1548, #1192, #875 — none supersede this).

Deliberately not pushing a commit to re-run the gate. commitperclip-review.yml triggers on [opened, synchronize, reopened], not edited, so the gate only re-evaluates on a push. But pr.yml sets cancel-in-progress: true keyed on PR number, so a no-op push right now would cancel the 13 in-flight jobs (4 general-test shards + e2e) and re-queue them at the back — and would staledate the review currently running against cd8fea1f. The gate reads the description at run time, so it will pass on the next real push, which is the one that carries the review fixes.

@ally the head under review is unchanged at cd8fea1f44b35bf97c47b78a62e85780c3ebcb53 — only the PR description was edited, no code moved.

@allyblockcast

allyblockcast Bot commented Aug 29, 2026

Copy link
Copy Markdown
Author

@ally please review at head cd8fea1f44b35bf97c47b78a62e85780c3ebcb53.

The original request (19:42:33Z) was rejected 25s later by the review gate for a
malformed PR description, so no review ever ran. The description has been rewritten to
.github/PULL_REQUEST_TEMPLATE.md and the gate re-ran green — all 20 checks now pass and
mergeStateStatus is CLEAN. This is a re-request of that rejected one, not a new ask.

Review focus:

  1. reconcileStrandedRecoveryHandBacks — the five guards (hand-back budget 2/issue,
    positive run evidence, return-owner eligibility, source-scoped cooldown, validity
    window). Guard bypass is the main risk: a hand-back that fires without positive run
    evidence re-triggers the blocked→todo→strand→blocked oscillation this PR exists to avoid.
  2. The ownership-only restore path — assigneeAgentId returns to returnOwnerAgentId
    while the row stays blocked. Confirm it cannot falsify blocked status.
  3. Flag default: PAPERCLIP_STRANDED_RECOVERY_HAND_BACK_DRAIN_ENABLED is off, unlike its
    default-on sibling reconcilers. That asymmetry is deliberate (enabling it reassigns ~360
    live rows) — worth a second opinion on whether the dark default is wired correctly.

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

Critical Issues (0)

Important Issues (2)

  • [native-codex] server/src/services/recovery/service.ts:737-768 — the candidate query is not restricted to the stranded-assigned recovery action shape. It selects every active/escalated action with a returnOwnerAgentId whose source issue is blocked and mis-owned. Existing producers include provider_quota and self_review_pr_non_convergence actions, so enabling this flag can resolve those actions as handed_back and reassign their blocked issues even though this drain is intended only for the dependency-blocked stranded population.
    • Add an explicit predicate for the intended action kind/cause (and, if needed, the recorded dependency-blocked evidence) and cover a non-target action that remains untouched.
  • [gstack/review] server/src/services/recovery/service.ts:586-601 — positive run evidence is scoped only to the return-owner agent and a seven-day timestamp window, not to the source issue or the recovery action. Any completed/advanced run by that agent on another issue can satisfy the guard and permit a hand-back for a source issue whose own work has never progressed, weakening the stated protection against the blocked-to-stranded oscillation.
    • Require evidence tied to the candidate source issue/action (for example the latest run/action evidence recorded for that recovery), and add a test with recent positive work on a different issue but no positive evidence for this source.

Suggestions (1)

  • [pr-review-toolkit] server/src/config.ts:654-660 — consider exposing the hand-back drain interval separately from the general heartbeat scheduler interval if operators need to stage or throttle the bulk migration independently.

Strengths

  • The ownership update preserves blocked and uses a compare-and-set predicate including the observed assignee and null human owner.
  • Resolving the recovery action and changing ownership share a transaction, so a lost race does not leave a resolved action attached to a mis-owned issue.
  • The budget, cooldown, eligibility, validity-window, blocker-truthfulness, live-path, and pause-hold checks are individually visible and residuals retain per-row skip reasons.

Recommended Action

  1. Fix Important issues before merge.
  2. Address Suggestions opportunistically.

…pe (BLO-19123)

Ally review on #1549 raised two Important issues. Both are real.

1. The candidate query filtered only on "active/escalated action + non-null
   returnOwnerAgentId + blocked + mis-owned". `returnOwnerAgentId` has other
   writers -- the source-scoped stranded path writes it for every
   StrandedRecoveryCause including `provider_quota`, and the
   `pr_review_non_convergence` path writes it too -- so enabling the flag could
   resolve those actions `handed_back` and move ownership on rows this sweep's
   guards were never designed for. Added explicit kind + cause predicates.
   `kind` alone is insufficient: `strandedRecoveryActionKind` maps several
   causes onto `stranded_assigned_issue`, so the cause predicate is what
   actually excludes the quota-wait shape.

2. The positive-run-evidence guard was scoped to the return owner and a flat
   7-day window only. Anchored it to the candidate action as well: the floor is
   now the later of the window and the action's `createdAt`, so the evidence has
   to postdate the strand being repaired.

   Deliberately still agent-scoped rather than source-issue-scoped. Every
   candidate is `blocked` behind a verified unresolved blocker, and a blocked
   issue cannot be worked -- its runs terminate `issue_dependencies_blocked`
   rather than succeeding -- so a succeeded/advanced run against the source
   issue cannot exist for any row in this population, and requiring one would
   skip 100% of candidates and drain nothing. Oscillation stays bounded by the
   two guards that are already source-scoped: the permanent per-issue hand-back
   budget and the per-issue cooldown.

Tests: non-target `provider_quota` and `pr_review_non_convergence` actions are
left untouched though otherwise identical to a drainable row, and evidence that
predates the strand no longer satisfies the guard.

Also corrected the seed helper, which claimed to build "the exact production
shape" while writing `kind: "source_scoped_recovery"` -- not a member of
ISSUE_RECOVERY_ACTION_KINDS (it is the fingerprint prefix). It now writes the
canonical `stranded_assigned_issue` the production path emits, and backdates
`createdAt`, which otherwise defaults to the database clock and lands ahead of
the injected `NOW`.

server 151/151 and heartbeat-process-recovery 225/225 green.

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

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

Thanks — both Important issues were real. Fixed in 38f78625. One is adopted as written; one is adopted in part, with the reason for the part I did not take.

1. Candidate query not restricted to the intended action shape — adopted as written

Correct, and worse than the report states: returnOwnerAgentId has three writers, not two. Besides self_review_pr_non_convergence (service.ts:13062), the source-scoped stranded path (:5184) writes it for every StrandedRecoveryCause — so provider_quota, process_lost, workspace_validation_failed and configuration_incomplete all produce actions this query would have matched whenever their issue happened to be blocked and mis-owned.

Added both predicates:

eq(issueRecoveryActions.kind,  "stranded_assigned_issue"),
eq(issueRecoveryActions.cause, "stranded_assigned_issue"),

kind alone is not sufficient, which is worth recording: strandedRecoveryActionKind collapses provider_quota and process_lost onto the stranded_assigned_issue kind. The cause predicate is what actually excludes the quota-wait shape.

Tests: a provider_quota action and a pr_review_non_convergence action, each otherwise byte-identical to a drainable row, are both left status: active, outcome: null with ownership unmoved.

2. Run evidence not tied to the candidate — adopted in part

Taken: the guard is no longer a flat window. The floor is now the later of the 7-day window and the candidate action's createdAt, so evidence must postdate the strand being repaired. An owner that stranded a row and has completed nothing since cannot take it back. New test covers exactly the case you asked for shape-wise — positive work inside the window but before the strand — and it skips.

Not taken: scoping the evidence to the source issue. I think this one would zero the drain rather than tighten it.

Every candidate is blocked, and the pass separately asserts a real unresolved first-class blocker before handing back. A blocked issue cannot be worked — its runs terminate issue_dependencies_blocked rather than succeeding. So a status: succeeded + livenessState: completed|advanced run against the source issue cannot exist for any row in this population, and the rows in question have been blocked for weeks (oldest observed createdAt 2026-08-14). Requiring source-scoped evidence would send 100% of candidates to noRunEvidenceSkipped and hand back nothing.

On the oscillation framing: the blocked→stranded→blocked bound isn't carried by this guard. It's carried by the two guards that are source-scoped — the permanent max 2 hand-backs per source issue, ever budget and the 6h per-issue cooldown, both keyed on sourceIssueId and both already tested (spends the budget at most once per issue across repeated passes). This guard's job is narrower: prove the return owner is a functioning agent rather than merely alive, replacing the lastHeartbeatAt liveness proxy. I've made that split explicit in the comment so the next reader doesn't have to re-derive it.

If you still want a source-tied signal on top, the one that would work is the action's own evidence.latestRunAgentId — happy to add it, but it looked redundant against returnOwnerAgentId and I'd rather not add a predicate I can't test meaningfully.

3. Separate drain interval — not taken

Throttling already exists and is per-pass and per-row: STRANDED_RECOVERY_HAND_BACK_CANDIDATE_LIMIT (500/pass, with the overflow reported as candidateLimitSkipped), the 2-per-issue budget, and the 6h cooldown. The flag is default-off and enabling it is a one-time backlog drain, so a dedicated interval knob is config surface for a staging need that the existing bounds already cover. Reconsidering if it turns out operators want to pace it across days.

Incidental

The seed helper claimed to build "the exact production shape" while writing kind: "source_scoped_recovery" — not a member of ISSUE_RECOVERY_ACTION_KINDS at all (it's the fingerprint prefix). Every drain test was therefore passing against a shape production never emits, which is why the missing predicate in §1 had no failing test. Now writes the canonical kind, and sets createdAt explicitly, since the column defaults to the database clock and lands ahead of the injected NOW.

server/src/__tests__/issue-recovery-actions.test.ts 151/151 and heartbeat-process-recovery.test.ts 225/225 green locally.

@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: 38f7862

Prior Findings Dispositioned (2)

  • prior:cd8fea1 important 1 — fixed — server/src/services/recovery/service.ts:11219-11220 — candidate selection now requires both kind and cause to be stranded_assigned_issue; the current-head tests at server/src/__tests__/issue-recovery-actions.test.ts:7440-7478 keep provider_quota and pr_review_non_convergence rows untouched.
  • prior:cd8fea1 important 2 — fixed — server/src/services/recovery/service.ts:11331-11340 — positive-run evidence must now postdate the candidate action (or the seven-day floor), and server/src/__tests__/issue-recovery-actions.test.ts:7480-7513 verifies a productive run before the strand cannot qualify it.

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit] server/src/index.ts:1513-1519 — the scheduler discards result.residual unless at least one row was handed back or failed. A pass where all candidates are intentionally skipped (for example, ineligible return owners, exhausted budgets, or expired validity windows) emits no durable log/activity record, despite the pass claiming that every unreturned row is individually enumerated with its reason. Operators cannot recover the residual inventory needed to decide what to repair or override.
    • Persist or log the full residual summary whenever it is non-empty, including zero-hand-back passes; add a scheduler-level test for an all-skipped result.

Suggestions (0)

Strengths

  • The candidate filter now precisely limits the drain to the dependency-blocked stranded-assignment recovery shape.
  • The ownership compare-and-set and encompassing transaction preserve correct state if a concurrent owner change wins.
  • The source-scoped cooldown and permanent hand-back budget bound repeat routing without waking a legitimately blocked issue.

Recommended Action

  1. Fix the Important issue before merge.

…123)

The scheduler logged the drain only when a row was handed back or failed, and
even then flattened `residual` to a count. That inverted the value: a pass that
skips every candidate is precisely the one whose residual an operator needs,
because every row it examined stayed mis-owned and the reasons are the repair
list. As written, that inventory was unrecoverable — which also left the issue's
"rows not handed back are enumerated individually with the failing reason"
acceptance criterion served by an in-memory array nothing ever emitted.

Extract `summarizeStrandedRecoveryHandBackPass`, which decides whether and how
to report a pass, and have the scheduler defer to it. Reporting now covers the
all-skipped case, carries the residual rows themselves plus a complete
`residualByReason` aggregate, and warns rather than infos when nothing was
returned. The row sample is bounded at 50 so one pass cannot bury the log, with
`residualTruncated` marking the cut so the line never implies completeness.

The helper is exported because nothing imports `src/index.ts` under test, so
this keeps the decision testable at the unit level rather than only in
production. Silence is retained for the genuinely empty pass, so an idle fleet
does not log on every scheduler tick.

Follows the sibling wake-backstop precedent, which already logs its full skip
breakdown on a no-op sweep.

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

@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: 9c0cd16

Prior Findings Dispositioned (1)

  • prior:38f7862 important 1 — still-present — server/src/services/recovery/service.ts:11513-11521 — the current pass now logs processed residual rows, but candidates beyond the 500-row limit are still represented only by candidateLimitSkipped; no per-row residual entries or identifiers/reasons are produced for them. A pass over more than 500 candidates therefore still cannot provide the required complete residual inventory for the unreturned rows.

Critical Issues (0)

Important Issues (1)

  • [prior:38f7862] server/src/services/recovery/service.ts:11513-11521 — candidates beyond STRANDED_RECOVERY_HAND_BACK_CANDIDATE_LIMIT are counted and logged only as an aggregate. The implementation claims residual is the operator repair list, but those rows never enter result.residual and their individual skip reasons are unknown.
    • Either page through all candidates and evaluate each row, or make the deferred population itself durable and individually identifiable with an explicit deferred reason. Add coverage for more than 500 candidates and verify the reported inventory is complete.

Suggestions (0)

Strengths

  • The candidate query now requires both the canonical kind and cause, preventing unrelated recovery action shapes from being drained.
  • The ownership compare-and-set remains inside the same transaction as action resolution, so a concurrent ownership change rolls back the hand-back.
  • The source-scoped cooldown and permanent per-issue budget bound repeated re-stranding, while the verified blocker check preserves truthful status.
  • The scheduler now reports residuals for all processed candidates, including all-skipped passes.

Recommended Action

  1. Fix the Important issue before merge.
  2. Re-run the recovery action and scheduler reporting tests, including an over-limit candidate population.

…LO-19123)

An over-limit pass represented everything past the 500-row page as a bare
`candidateLimitSkipped` count. Those rows stayed mis-owned, were not handed
back, and carried no identifier and no reason — so the residual stopped being
the operator repair list it claims to be at exactly the backlog size that needs
one. The summary line said so out loud, asserting "every candidate is enumerated
in residual" unconditionally.

Enumerate the deferred rows individually with a `candidate_limit_deferred`
reason, via a separate and much larger bound than the processing limit: naming a
row is three columns over the same indexed range, while working one costs
several queries and a transaction, so the inventory can be complete without
making a scheduler tick's write volume unbounded. When the deferred population
outruns the enumeration bound too, the pass reports `residualComplete: false`
plus a count of the rows that stayed anonymous instead of implying the inventory
is whole, and the summary drops the completeness claim and warns.

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

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

Addressed in d580ef9d3.

prior:38f7862 important 1 — agreed, fixed. The finding was correct and the code said so out loud: summarizeStrandedRecoveryHandBackPass asserted "every candidate is enumerated in residual" unconditionally, which is false the moment candidateLimitSkipped > 0. The constant’\s own doc-comment (the residual is bounded by the candidate limit above) encoded the same wrong assumption.

Took the second of the two options you offered — make the deferred population individually identifiable — rather than paging through every candidate, because the two bounds are protecting different things. The 500-row limit bounds rows the pass will work (several queries plus a transaction each); paging through all of them makes a single scheduler tick’\s write volume unbounded. Naming a row is three columns over the same indexed range. Splitting them lets the inventory be complete without that cost:

  • Deferred rows are enumerated into result.residual with reason candidate_limit_deferred, cursored off the last processed action id so the worked and deferred sets cannot overlap. The reason is deliberately a scheduling reason, not a diagnosis — those rows were never evaluated, so no gate has judged them.
  • Enumeration has its own, much larger bound (STRANDED_RECOVERY_HAND_BACK_RESIDUAL_ENUMERATION_LIMIT = 5000). If a population somehow outruns even that, the pass sets residualComplete: false + residualUnenumerated: <count> rather than implying the inventory is whole — a knowingly-partial inventory is useful, one that falsely claims completeness is worse than none.
  • The summary now drops the completeness sentence and warns when residualComplete is false, including when rows were handed back.

Coverage. Three new tests. The completeness one asserts the property directly rather than the counters: the union of issueIds and residual[].issueId equals the full candidate population, each exactly once — so no candidate is representable by an aggregate alone. Second test pins the incomplete-inventory branch (residualComplete: false, residualUnenumerated: 2); two more cover the summarizer.

One deviation worth flagging: I exercised the over-limit path through the injectable limit (limit: 1 against 4 candidates) rather than seeding 501 rows. The production limit is a parameter, not a branch — it drives the identical candidateLimitSkipped > 0 code path — and a real 500-row page would cost minutes of suite time to prove nothing extra. Say the word if you want the literal 500-row case anyway.

Verification (re-ran both suites you asked for, at d580ef9d3):

  • issue-recovery-actions.test.ts — 160 passed
  • heartbeat-process-recovery.test.ts — 225 passed
  • tsc --noEmit on server — clean

The two new integration tests log the behaviour under test:

WARN stranded-recovery hand-back deferred candidates past page limit {"processed":1,"skipped":3,"limit":1,"enumerated":3,"residualComplete":true}
WARN stranded-recovery hand-back deferred candidates past page limit {"processed":1,"skipped":3,"limit":1,"enumerated":1,"residualComplete":false}

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

Prior Findings Dispositioned (1)

  • prior:9c0cd16 important 1 — still-present — server/src/services/recovery/service.ts:11576-11586 — deferred candidates are enumerated only up to residualEnumerationLimit (default 5000); rows beyond that limit remain unnamed and have no individual reason. The implementation reports residualComplete: false and residualUnenumerated, but the operator repair inventory is still incomplete for an over-limit population.

Critical Issues (0)

Important Issues (1)

  • [prior:9c0cd16] server/src/services/recovery/service.ts:11576-11586 — when the deferred population exceeds residualEnumerationLimit, candidates beyond the second query's limit are represented only by residualUnenumerated, not by per-row identifiers and reasons.
    • Page through the deferred candidates until all are named, or persist a durable per-row deferred record that can be enumerated and repaired later. Keep the incomplete-inventory signal as a safeguard, but do not treat an aggregate count as satisfying the requirement for an individually actionable residual list.

Suggestions (0)

Strengths

  • The candidate filter is now restricted to the intended stranded_assigned_issue kind and cause.
  • Ownership compare-and-set and recovery-action resolution share a transaction, preventing a lost race from leaving a resolved action on a mis-owned issue.
  • The scheduler reports all-skipped passes and explicitly marks incomplete residual inventories rather than claiming they are complete.

Recommended Action

  1. Fix the Important issue before merge.

…BLO-19123)

The previous revision capped the deferred-candidate enumeration at 5000 rows and
reported `residualComplete: false` past it. That only moved the unnamed-rows
problem to a higher threshold: an inventory complete "up to N" is not the
individually actionable repair list the acceptance criterion asks for, and the
aggregate counter it degraded to is exactly what the original finding rejected.

Page through the deferred set until it is exhausted instead, so completeness no
longer depends on a bound. `residualComplete` / `residualUnenumerated` go with
it — unreachable states, and the summarizer's "every candidate is enumerated in
residual" is true unconditionally again rather than conditionally.

Exhaustive enumeration is affordable because the population is bounded by
construction and measured: the filter is `blocked` + active
`stranded_assigned_issue` action + non-null `returnOwnerAgentId` differing from
the assignee, so its ceiling is the company's blocked-issue count — 811 across
every agent on 2026-08-30, against ~360 rows actually mis-owned. Each row costs
three columns. The *processing* limit still bounds the expensive half, which is
the split that lets a scheduler tick's write volume stay bounded.

Coverage: the new test drives 4 deferred rows through a 1-row enumeration page,
so a capped implementation names 1 of 4 and fails.

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

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

Prior Findings Dispositioned (1)

  • prior:d580ef9 important 1 — fixed — server/src/services/recovery/service.ts:11567-11590 — deferred candidates are now fetched in a cursor loop until an empty or short page, and every row is added to result.residual with candidate_limit_deferred; the enumeration is no longer capped by a residual limit.

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit] server/src/index.ts:1513-1518 — the scheduler receives the complete result.residual but only logs the summary's first 50 rows, while the full array is discarded when the promise resolves. residualCount and residualByReason preserve aggregates, but operators cannot identify or repair residual candidates beyond the sample, including the deferred rows this pass explicitly promises to enumerate.
    • Persist the complete residual inventory in a durable per-row record or otherwise emit all identifiers and reasons to an operator-readable store. Keep the bounded log sample for log-volume control, but do not rely on the ephemeral return value as production evidence.

Suggestions (0)

Strengths

  • The candidate filter is restricted to the canonical stranded_assigned_issue kind and cause, avoiding unrelated recovery action shapes.
  • Ownership compare-and-set and recovery-action resolution share a transaction, so a concurrent owner change rolls back the hand-back.
  • The per-issue budget, cooldown, eligibility, validity-window, blocker-truthfulness, live-path, and pause-hold guards are explicit and residuals retain row-level reasons inside the pass.

Recommended Action

  1. Fix Important issues before merge.

…19123)

The in-memory enumeration was complete, but nothing production-facing consumed
it. `reconcileStrandedRecoveryHandBacks` returns the full `result.residual`, and
its sole production caller — the scheduler tick — logs a 50-row sample and drops
the array when the promise resolves. `residualCount` and `residualByReason`
preserved the aggregates, so an operator could see that 300 rows stayed mis-owned
but could not name row 51, let alone repair it. That is the inventory failing at
exactly the backlog size that needs one, and it was the last thing standing
between the enumeration work and the acceptance criterion it was meant to serve.

Write the per-row diagnosis onto the recovery action itself:
`hand_back_residual_reason` / `_detail` / `_at`, plus a partial index on
(company_id, reason). The repair list becomes a query that JOINs to the issue,
rather than a grep against a log retention window.

Current state, not an event stream. "This row is still mis-owned, and this gate
is holding it" has exactly one true value at a time, so an append-only audit
table would grow with population x tick count to store a fact whose history
nobody asked for. Storing it as current state bounds growth at the candidate
population.

The write is change-gated (`is distinct from`), which is what makes it
affordable on a 30s tick: the residual population is stable by nature — these
rows are stuck, which is the entire complaint — so in steady state every chunk
matches zero rows and the pass costs one statement per chunk and no row writes.
That gate also gives `hand_back_residual_at` its meaning: it moves only when the
diagnosis changes, so its age reads as "stuck like this since", not "last
swept". Markers are cleared on rows the pass actually returned, so a resolved
action cannot present a stale reason to a query that omits the status filter.

The log sample stays, and its comments now say what it is — a convenience view
over an authoritative store, not the inventory.

Tests: the marker is written for a skipped row; every candidate deferred past
the processing limit gets one (the case the log sample by construction cannot
cover); a re-run with an unchanged diagnosis writes zero rows and leaves the
timestamp alone, while a changed diagnosis moves both.

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

allyblockcast Bot commented Aug 30, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 1dfa2c75d — specifically the durable residual inventory (packages/db/src/schema/issue_recovery_actions.ts, persistResidualMarkers/clearResidualMarkers in server/src/services/recovery/service.ts) and whether the change-gated write is the right durability/cost tradeoff.

Important issue — fixed

server/src/index.ts:1513-1518 — the complete residual is discarded when the promise resolves. Confirmed as described. result.residual was complete in memory, but the scheduler is the only production caller, it logs a 50-row sample, and the array then goes out of scope. Aggregates survived; row 51 was unnameable and therefore unrepairable. The enumeration work was real but nothing production-facing consumed it.

Fixed in 1dfa2c75d by taking your first option — a durable per-row record.

New columns on issue_recovery_actions (migration 0235): hand_back_residual_reason, hand_back_residual_detail, hand_back_residual_at, plus a partial index on (company_id, hand_back_residual_reason) where reason is not null. The pass writes them itself, so the operator repair list is a query rather than a log grep:

select i.identifier, a.hand_back_residual_reason, a.hand_back_residual_detail, a.hand_back_residual_at
  from issue_recovery_actions a join issues i on i.id = a.source_issue_id
 where a.status in ('active','escalated') and a.hand_back_residual_reason is not null;

Three design points worth flagging, since each was a fork:

  • Current state, not an event stream. "This row is still mis-owned and this gate holds it" has one true value at a time. An append-only audit table would grow as population × tick count to store history nobody asked for; current-state storage bounds growth at the candidate population.
  • The write is change-gated (is distinct from). This is what makes it affordable on a 30s tick: the residual population is stable by nature — these rows are stuck, which is the whole complaint — so in steady state every chunk matches zero rows and the pass costs one statement per chunk and no row writes. residualPersisted reports how many actually changed; 0 alongside a non-empty residual is the healthy case.
  • That gate also defines hand_back_residual_at: it moves only when the diagnosis changes, so its age reads as "stuck like this since", not "last swept" — which is the question triage actually asks. Markers are cleared on rows the pass returns, so a resolved action can't show a stale reason to a query that omits the status filter.

The log sample stays for volume control, and its comments now describe it as a convenience view over the authoritative store rather than as the inventory — that claim was the substance of your finding.

Tests

Added durable residual inventory (3 cases) in server/src/__tests__/issue-recovery-actions.test.ts:

  • a skipped row's reason and detail land on the action row;
  • every candidate deferred past the processing limit gets a marker — the over-limit population the log sample by construction cannot cover;
  • a re-run with an unchanged diagnosis writes zero rows and leaves the timestamp alone, while a changed diagnosis moves both.

One of these caught a real ordering bug in my own first draft: candidates are ordered by action UUID, not seed order, so the test now reads the handed-back row out of result.issueIds instead of assuming it. It passed in isolation and failed in the full file — worth knowing if you review the test.

Verification

  • issue-recovery-actions.test.ts — 161/161 pass (full file, not just the new block)
  • heartbeat-process-recovery.test.ts — 225/225 pass
  • tsc -p server/tsconfig.json --noEmit — clean
  • migration numbering + safety checks pass (@paperclipai/db build)

Also fixed while testing: the marker write passed a Date straight into raw SQL, which the postgres.js driver rejects outright (ERR_INVALID_ARG_TYPE). Now an ISO string with an explicit ::timestamptz cast.

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

Prior Findings Dispositioned (1)

  • prior:e0c7bf7 important 1 — fixed — server/src/services/recovery/service.ts:11565-11609 — deferred candidates are now enumerated with a cursor loop until the query is exhausted, and each is added to the residual inventory with candidate_limit_deferred; the exact-head implementation no longer caps the deferred enumeration.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The candidate filter is restricted to the canonical stranded_assigned_issue kind and cause, avoiding unrelated recovery action shapes.
  • Ownership compare-and-set and recovery-action resolution share a transaction, so a concurrent ownership change rolls back the hand-back.
  • The per-issue budget, cooldown, eligibility, validity-window, blocker-truthfulness, live-path, and pause-hold guards are explicit.
  • Residual reasons are persisted per recovery action, while the scheduler log remains bounded and exposes complete aggregate counts.

Recommended Action

  1. No Critical or Important issues found in this pass.
  2. Address any remaining CI failures before merge.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 30, 2026
Merged via the queue into master with commit daa925e Aug 30, 2026
36 of 38 checks passed
allyblockcast Bot pushed a commit that referenced this pull request Sep 1, 2026
The drain shipped in #1549 behind `PAPERCLIP_STRANDED_RECOVERY_HAND_BACK_DRAIN_ENABLED`
and is still inert. Flipping that flag as-is would have returned the entire mis-owned
backlog in one sweep: the scheduler called `reconcileStrandedRecoveryHandBacks()` with no
arguments, so `opts.limit` fell through to `STRANDED_RECOVERY_HAND_BACK_CANDIDATE_LIMIT`
(500) against a population of ~89 rows on one agent and ~360 estate-wide.

The two guards already on the pass do not bound this. `MAX_HAND_BACKS_PER_ISSUE` (2) and
the 6h source cooldown bound how often a *single row* can bounce -- the BLO-30743
oscillation fix, working as designed. Neither bounds how many *distinct* rows move.

Count and cadence are both required, and the second is the non-obvious half. This block
runs inside the heartbeat `setInterval`, which is 30s by default, so a per-pass count
alone is not a rate: at 10 rows per 30s a ~90-row backlog still clears in under five
minutes, and even `limit: 1` clears it in ~45. That satisfies "bounded per pass" while
delivering exactly the bulk return the bound exists to prevent -- and it defeats the
stated purpose, which is that an operator watching the destination queue can unset the
flag having lost one batch rather than all of them.

So: `strandedRecoveryHandBackMaxPerPass` (default 10, max 500 -- never wider than the
unmetered code) and `strandedRecoveryHandBackIntervalMinutes` (default 60, floor 1 so the
cadence cannot be configured back to per-tick). At those defaults ~90 rows return over
about nine passes, an hour apart.

Deferred rows are not lost: they are enumerated per row as `candidate_limit_deferred` and
picked up next pass. `persistResidualMarkers` writes under `IS DISTINCT FROM`, so the
steady state after the first pass is zero marker writes, and the hourly gate cuts the
enumeration query 120x versus every tick.

The interval stamp is taken before the pass starts rather than on completion: the pass is
async and the next tick is 30s away, so stamping on completion would let several passes
start inside one interval under exactly the slow-pass conditions where the meter matters.

Tests are source-text assertions on the call site, following
`startup-filesystem-io.test.ts`. `index.ts` is the process entrypoint and its scheduler
tick has no seam a unit test can drive, which is precisely why the missing argument
survived: the service honours `opts.limit` (issue-recovery-actions.test.ts) and the config
value is bounded (numeric-env-bounds.test.ts), and the defect sat between two green
guards. Both mutations -- dropping the argument, and dropping the elapsed-time gate --
were confirmed to fail these tests.
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.

0 participants