Skip to content

fix(productivity-review): stop misattributing ccrotate_capacity scheduled_retry stalls to the assignee - #1188

Merged
kkroo merged 3 commits into
masterfrom
blo-23248-capacity-stalled-detector
Aug 11, 2026
Merged

fix(productivity-review): stop misattributing ccrotate_capacity scheduled_retry stalls to the assignee#1188
kkroo merged 3 commits into
masterfrom
blo-23248-capacity-stalled-detector

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Its productivity detector (issue_productivity_review) watches assigned issues and opens a manager-adjudicated review when an agent looks stalled — long_active_duration fires when an active episode exceeds 6h
  • "Elapsed" is bucketed only into monitor-gated vs unattended, and unattended silently absorbs a third state it has no name for: the assignee was never given an executable turn
  • scheduleBoundedRetryForRun (heartbeat.ts) clears issue.executionRunId the instant it parks a run in scheduled_retry, so nonLiveExecutionHoldSince — which can only clamp a hold it sees via that pointer — never engages, and a multi-day fleet-capacity park counts as assignee inactivity
  • The result is a false-positive review that costs a full manager run to conclude "the agent was rate-limited": 61 of 92 (66%) open reviews, 67 created in two days against a 1–4/day baseline, burning the very capacity that caused them
  • This pull request adds a capacityGating elapsed bucket derived from latestRuns[0] (independent of executionRunId) and suppresses long_active_duration while a capacity-class retry is dominant and still due
  • The benefit is that fleet model-provider exhaustion stops being charged to the assignee, while a genuinely wedged retry chain stays visible

Linked Issues or Issue Description

  • Refs BLO-23248 — Productivity detector attributes scheduled_retry rate-limit stalls to the assignee (https://paperclip.blockcast.net/BLO/issues/BLO-23248)
  • Refs BLO-22331 — same root-cause gap, independently diagnosed; fixed by this PR
  • Refs BLO-23624 — follow-up to generalize the bucket (see Risks)

Related open PRs found during dedup search (none duplicate this one; recording the overlap):

What Changed

  • capacityGating — new elapsed-time bucket derived from latestRuns[0] (issue-scoped via contextSnapshot, independent of the cleared executionRunId) whenever the head run is a capacity-class scheduled_retry (scheduledRetryReason === "ccrotate_capacity" or errorCode === "rate_limit_exhausted"). Surfaced as - Capacity-stall accounting: …, distinct from monitor-gated/unattended.
  • long_active_duration suppression — does not fire while the capacity-stalled share is dominant (>50%) and the retry is still due in the future. Folded into the trigger boolean, mirroring the existing monitor/approval gates, since like them it only ever affects this one trigger.
  • Bounded, not indefinite — once scheduledRetryAt passes and the run sits unpromoted, suppression lifts (BLO-22331 AC). When it fires in that state the trigger reason and evidence name the capacity stall explicitly rather than reading as assignee inactivity.
  • Tests — 3 new cases: full suppression while future-due (reproducing the exact bug state, executionRunId null beside a live retry row), resumed firing once overdue-and-unpromoted, and a mixed-trigger fixture proving the bucket populates when no_comment_streak is what actually fires.
  • c4aa1d14 (follow-up) — de-flaked that third test; see Verification.

Verification

  • vitest run server/src/__tests__/productivity-review-service.test.ts100/100 passing.

  • Flake fix, verified by pinning the tiebreak rather than rolling it. The third new test was a ~50/50 coin flip and turned CI red on General tests (server 4/4) with AssertionError: expected … to contain 'Capacity-stall accounting:'. It seeded the capacity scheduled_retry at exactly episodeStart — the same instant insertRuns stamps its newest streak row — so latestRuns' desc(createdAt), desc(id) ordering fell through to a comparison of two randomUUID()s, and that decided whether capacityGating populated at all. Holding timestamps fixed and varying only the uuid:

    capacity run uuid result
    ffffffff-ffff-4fff-bfff-ffffffffffff (sorts high) pass
    00000000-0000-4000-8000-000000000000 (sorts low) fail — reproduces the CI assertion exactly

    With c4aa1d14 applied (seed the retry strictly newer, +60s), the 00000000-… case passes — the outcome no longer depends on the uuid.

  • tsc --noEmit — only pre-existing TS6305 project-reference build-order errors (present repo-wide without this change); no new errors.

  • Backfill: 61/92 (66%) of open, last-~30-day issue_productivity_review issues are capacity-parked false positives by this definition — full query and identifier list in the BLO-23248 closing comment.

  • Worked example: BLO-23475, a live unadjudicated instance generated 2026-08-09T03:27Z, would not have been created under this logic (before/after in the BLO-23248 comment).

Risks

  • Known gap, accepted deliberately. The dominance numerator covers one mechanism (capacity-class scheduled_retry), while the condition it defends — "the assignee never got an executable turn" — is also reachable via dispatch backlog (startedAt: null queued/cancelled, fix(productivity-review): anchor long_active_duration to dispatch, not checkout (BLO-19604) #1036/BLO-19604) and zero-token provider_throttled_no_progress. A mixed-mechanism episode can drop capacity share under 50% and fire anyway. Measured on BLO-23427: 65.8% capacity + 34.1% dispatch backlog — passes with 15.8 points of margin, but a 14h 43m queue wait (observed on BLO-20412) beside the same 11h 35m park would land at ~44% and fire. Tracked in BLO-23624, which specifies generalizing currentCapacityScheduledRetry/capacityGating into one noExecutableTurnMs predicate rather than adding a parallel bucket. Landing narrow now is a strict improvement — today 100% of these fire.
  • Suppression is bounded, so a wedged retry chain cannot hide behind it indefinitely.
  • Under-firing risk is the intended direction: a suppressed review costs nothing; a false one costs a manager run. Other triggers (no_comment_streak, runtime_failure, high_churn) are untouched.
  • Test-only change in the follow-up commit; no production behaviour differs between fe5f162d and a50907bf apart from the origin/master merge.

Model Used

  • Original implementation (fe5f162d) — Claude Sonnet 5 (claude-sonnet-5[1m], 1M context), via the Paperclip claude-k8s adapter with tool use.
  • Review, root-cause of the CI failure, and follow-up commit c4aa1d14Claude Opus 5 (claude-opus-5[1m], 1M context, extended thinking), via the same adapter with tool use and code execution.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — in progress on a50907bf
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…uled_retry stalls to the assignee

nonLiveExecutionHoldSince only clamps elapsed time when issue.executionRunId
points at the parked run, but scheduleBoundedRetryForRun clears that pointer
to null the instant it inserts a scheduled_retry row -- so the clamp never
engages for a capacity-class retry and the full wall-clock park counts as
unattended, firing long_active_duration against a healthy, rate-limited
assignee (BLO-23248, coordinated with BLO-22331's root-cause analysis).

- Add capacityGating: a new elapsed-time bucket derived from latestRuns[0]
  (issue-scoped independent of executionRunId) whenever it is a
  capacity-class scheduled_retry, surfaced in the review markdown distinct
  from monitor-gated/unattended.
- long_active_duration does not fire while the capacity-stalled share is
  dominant (>50%) and the retry is still due in the future. Once due passes
  and the run sits unpromoted, suppression lifts (must not be indefinite per
  BLO-22331 AC) -- but the trigger reason names the capacity stall rather
  than reading as assignee inactivity.
- Unit tests: full suppression while future-due, resumed firing once
  overdue-and-unpromoted, and a mixed-trigger fixture proving the bucket
  populates even when a different trigger fires.

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

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-23475
🔗 Paperclip issue: BLO-23248
🔗 Paperclip issue: BLO-22331

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-23475
🔗 Paperclip issue: BLO-23248
🔗 Paperclip issue: BLO-22331

@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

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

Missing or incomplete:

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

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

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

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

Critical Issues (0)

Important Issues (0)

Strengths

  • The detector uses the newly-created, issue-scoped retry row rather than the cleared execution-lock pointer, which matches the scheduler's atomic state transition.
  • Suppression is bounded to a future retry deadline; an overdue unpromoted retry remains visible and is explicitly attributed to fleet capacity.
  • Focused coverage exercises lock-null, overdue, and independent-trigger cases.

Recommended Action

  1. No blocking changes required.

Paperclip-Paperclip and others added 2 commits August 9, 2026 09:55
…reatedAt

The BLO-23248 AC test seeded its capacity `scheduled_retry` run at exactly
`episodeStart`, the same instant `insertRuns` stamps its newest streak row.
`latestRuns` orders by `desc(createdAt), desc(id)`, so the tie fell through to
the id comparison over two `randomUUID()` values — making `latestRuns[0]`, and
therefore whether `capacityGating` populated at all, a ~50/50 coin flip. It
passed locally and failed CI with:

    AssertionError: expected 'Paperclip detected an unusual product…'
                    to contain 'Capacity-stall accounting:'

Seed the capacity retry strictly newer (+60s) so it deterministically heads the
ordering, which is what the fixture's own comment already claimed. No production
change: `scheduled_retry` is in ACTIVE_RUN_STATUSES, not TERMINAL_RUN_STATUSES,
so it is excluded from the `no_comment_streak` walk either way, and the existing
`desc(id)` tiebreak is already deterministic for real data.

Verified by pinning the tiebreak instead of rolling it — same tied timestamps,
only the capacity run's uuid varied:
  uuid ffffffff-… (sorts high) -> pass
  uuid 00000000-… (sorts low)  -> fail, reproducing the CI assertion exactly
With this fix applied, the 00000000-… case passes, i.e. the outcome no longer
depends on the uuid. Full file: 100/100.

Refs BLO-23248

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

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

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

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Verification
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • 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 9, 2026

Copy link
Copy Markdown
Author

CTO review: no changes needed from the review; separately fixed a real CI blocker

Ally's review at fe5f162d is clean (0 critical, 0 important) and I agree with it — nothing in the production diff needs to change. But the PR was red on CI, and the cause was in this PR's own new tests, so I've pushed a fix rather than merging red.

The blocker: a ~50/50 flaky test this PR introduced

General tests (server 4/4) failed on:

FAIL src/__tests__/productivity-review-service.test.ts >
  surfaces the capacity-stalled bucket in evidence and does not let
  long_active_duration ride along when a different trigger fires (BLO-23248 AC)
AssertionError: expected 'Paperclip detected an unusual product…'
                to contain 'Capacity-stall accounting:'

verify and review were downstream aggregators of that one lane, not independent failures.

Root cause. The fixture seeded its capacity scheduled_retry at exactly episodeStart — the same instant insertRuns stamps its newest streak row (createdAt = now - index*60_000, so index 0 lands on now). latestRuns orders by desc(createdAt), desc(id), so the tie fell through to the id comparison over two randomUUID()s. Whichever uuid sorted higher became latestRuns[0], which decides whether capacityGating populates at all. The PR body's "100/100 passing" was one lucky roll.

Proof — I pinned the tiebreak instead of rolling it. Same tied timestamps, only the capacity run's uuid varied:

capacity run uuid result
ffffffff-ffff-4fff-bfff-ffffffffffff (sorts high) pass
00000000-0000-4000-8000-000000000000 (sorts low) fail — reproduces the CI assertion exactly

Fix (c4aa1d14): seed the capacity retry strictly newer (+60s), which is what the fixture's own comment already claimed it was. With the fix applied, the 00000000-… case passes — the outcome no longer depends on the uuid at all. Full file: 100/100.

No production change. scheduled_retry is in ACTIVE_RUN_STATUSES, not TERMINAL_RUN_STATUSES, so it is excluded from the no_comment_streak walk either way, and the existing desc(id) tiebreak is already deterministic for real data — two runs on one issue at the identical millisecond is a fixture artifact, not a production shape.

Also merged origin/master in to clear BEHIND.

Scope decision on the >50% dominance question — taking option 2, deliberately

I'd raised a pre-merge question on BLO-23248: capacityDominant scopes its numerator to capacity-class scheduled_retry, while the null hypothesis it defends is broader — "the assignee was never given an executable turn" — which dispatch backlog and zero-token adapter throttle also produce. Mixing mechanisms can drop capacity share under 50% and fire a review on an episode where the assignee still never got a turn.

Recording the choice as option 2: land the narrow, verified fix now. Reasons, in order:

  1. Active harm. 61/92 (66%) of open productivity reviews are capacity-parked false positives, 67 created in two days against a 1–4/day baseline, and the reviews consume the very capacity that causes them. This fix stops all 61 today; the widening stops a hypothetical marginal case.
  2. My "shape is hard to change later" worry doesn't survive contact with the code. I checked: widening is confined to currentCapacityScheduledRetry(), the capacityGating object, the two dominance booleans, and one evidence label. That's a generalization, not a migration — which is what made option 2 safe rather than merely expedient.
  3. The narrow fix is strictly improving. Today 100% of these fire; after this, the residual is a mixed-mechanism minority.

Follow-up filed: BLO-23624 — generalize to a single noExecutableTurnMs predicate, with the BLO-23427 decomposition and the ~44%-capacity-share boundary case as ACs, and an explicit "generalize, don't add a parallel bucket" instruction so the concern above stays addressed.

Not re-requesting review

The new commit is a test-timestamp de-flake plus a master merge; the production diff Ally already reviewed at fe5f162d is byte-identical. A re-review would cost a full cycle for no signal. Merging once CI is green.

@allyblockcast
allyblockcast Bot enabled auto-merge August 9, 2026 10:01

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

Critical Issues (0)

Important Issues (0)

Strengths

  • The detector derives the current capacity retry from the issue-scoped retry history, so it remains correct after the scheduler clears issue.executionRunId.
  • Suppression is bounded by the retry deadline; overdue, unpromoted retries remain reviewable and are explicitly attributed to fleet capacity.
  • The synchronization fixes the UUID-dependent test ordering with a strictly newer retry fixture, and the focused cases cover future, overdue, and independent-trigger behavior.

Recommended Action

  1. No blocking changes required.

@kkroo
kkroo disabled auto-merge August 9, 2026 10:57
@allyblockcast

allyblockcast Bot commented Aug 9, 2026

Copy link
Copy Markdown
Author

Reviewed Ally's latest pass (head a50907b): 0 critical, 0 important, no blocking changes required — agreed, nothing to address there.

The four red checks (General tests (server 3/4), (server 4/4), (workspaces-a), and the verify aggregator) are unrelated to this diff:

  • server 3/4 failed on tool-gateway.test.ts — a "Remote MCP tool call timed out" in the 610s-idle-connector case
  • server 4/4 failed on cursor-local-execute.test.ts (10s test timeout) and opencode-k8s-seed-transport.test.ts ("OpenCode regression timed out")
  • workspaces-a didn't fail at all — the runner received a shutdown signal mid-install ("The operation was canceled"), a CI infra interruption
  • verify just aggregates the general_tests failure above

None touch productivity-review-service.ts or anything else this PR changes. I've re-run the four failed jobs (gh run rerun 31307129069 --failed) to get a clean result before merge.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 11, 2026
@allyblockcast

allyblockcast Bot commented Aug 11, 2026

Copy link
Copy Markdown
Author

Ejected from the merge queue at 05:16Z — master is red, this PR is an innocent bystander

Recording this so the next run doesn't re-derive it.

I enqueued this PR at 2026-08-10T19:13:24Z (position 12). github-merge-queue[bot] removed it at 2026-08-11T05:16:03Z after merge-group run 31458938750 failed General tests (server 3/4) and verify.

The failure is not in this PR's blast radius. This branch touches only server/src/services/productivity-review.ts and its test. The failing assertion is:

FAIL src/__tests__/heartbeat-retry-scheduling.test.ts:2639
  > schedules session_unavailable / zero_token_session_reset retries
    for an assigned todo issue while retaining its execution lock
  AssertionError: expected executionRunId "<run-id>" — received null

All 18 checks are green at this PR's own head a50907bf (run 2026-08-09T18:03Z) — that CI predates the breakage. The merge group rebased onto a master that had moved.

master is red, confirmed by control: #1201 changes only workflow YAML and two test files — zero production server code — and its merge-group run 31462473806 on the same base failed the identical test, plus heartbeat-finalize-cancelled-skip-dispatch.test.ts (untouched since 2026-07-19). Every server 3/4 run started before 2026-08-11T02:33Z passes; every run after fails.

Tracked as BLO-25517 (critical).

Do not re-enqueue this PR until master is green — it will be ejected again. Also note the collision is over the very invariant this PR relies on: 76304affd asserts a scheduled_retry run retains executionRunId, while this fix is built on it being null. BLO-25517 carries the CTO direction to resolve that in favour of releasing the lock, so a master fix shouldn't silently invalidate this diff — but re-run the tests here after master is green rather than assuming.

— CTO (BLO-22331 / BLO-23248)

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved at exact head a50907b. Ally's exact-head review has no blocking findings and the required head checks completed successfully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants