feat(productivity-review): report a dependency-blocked accounting bucket (BLO-22887) - #1722
feat(productivity-review): report a dependency-blocked accounting bucket (BLO-22887)#1722allyblockcast[bot] wants to merge 1 commit into
Conversation
…ket (BLO-22887) BLO-22436 suppresses a productivity review whose source is dependency-blocked and whose entire fired-trigger set is dependency-closable. Reviews that survive that gate — `high_churn`, `runtime_failure_streak` — are still generated against blocked sources, and until now their evidence block said nothing about the blocker at all. A manager read `Elapsed accounting`'s unattended figure with no indication that the control plane had independently classified the same wall-clock as dependency-blocked, which is the subsystem disagreement BLO-22887 was filed for. Adds a third bucket alongside monitor-gated and no-executable-turn accounting, rendered in both the review description and the refresh comment. Deliberately reports blocker STATE rather than a span. The readiness map carries counts and ids, never the edge's own age, so there is no honest "blocked for N hours" figure available from it — and manufacturing one is what sank the first attempt (PR #1361: an unbounded `latestRuns.find` against a 100-run cap, so an older park silently read as absent). The line says which figures it does not adjust, and why. Costs no extra query: `reconcileProductivityReviews` already computes the `listDependencyReadiness` map for the suppression gate; the counts now ride along on it. `collectEvidence` still does not fetch dependency state, so the continuation-hold caller is untouched. Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 22ca24a
The design call is the right one — reporting blocker state next to the elapsed split instead of inventing a span from a readiness map that carries no edge timestamps, and threading the counts through the Set→Map change so the bucket costs no second listDependencyReadiness round-trip. Two things about the rendered line itself do not hold up.
Critical Issues (0)
Important Issues (2)
-
[code / gstack-review]
server/src/services/productivity-review.ts:1149— the closing caveat asserts the existence of figures that are frequently absent from the artifact it is printed in.- The line ends
…the elapsed figures above are wall-clock and are NOT reduced by this, so read their unattended portion as covering dependency-blocked time of unrecorded length. The "unattended portion" exists only inside theElapsed accountingline, which both builders render conditionally onevidence.monitorGating(productivity-review.ts:3894,:4002). monitorGatingis null wheneverelapsedMsis null (monitorGatingBreakdown,productivity-review.ts:1026), andelapsedMsis null unless the source isin_progress(productivity-review.ts:3463). Candidates arestatus IN ('todo','in_progress')(productivity-review.ts:4769) — so everytodocandidate renders the dependency line with no elapsed split above it. Atodoissue carrying an unresolvedblockedByedge is the single most ordinary dependency-blocked shape in this system, not a corner.- It is worse in
buildRefreshComment(productivity-review.ts:3963): unlike the description, that body has no unconditional elapsed figure at all, so withmonitorGatingnull the sentence points at nothing whatsoever. The description at least printsCurrent active elapsed time: unknown(msToHuman(null),:534). - This is the one clause whose whole job is stopping a manager misreading a number; pointing it at a number that is not on the page inverts that. Recommend making the tail conditional — pass
Boolean(evidence.monitorGating)(or the gating itself) intoformatDependencyGatingand fall back to something likeno elapsed split was computed for this episode, so there is no wall-clock figure this reduces.
- The line ends
-
[tests]
server/src/__tests__/productivity-review-service.test.ts:1549— everythingformatDependencyGatingrenders after the count prefix is uncovered; deleting it leaves the suite green.- Both positive tests assert only the prefix
- Dependency accounting: 1 unresolved \blockedBy` blocker(:1549,:1630). ThependingFinalizeBlockerCountparenthetical (productivity-review.ts:1139), thenonClosableTriggers"reviewed anyway because … fired" clause (:1146), and the "not a measured span" caveat (:1149`) have no assertion pinning them. - The assertion intended to cover the trigger clause —
expect(review?.description).toContain("\high_churn`")at:1552, whose comment says it verifies the line "names the trigger that kept the review alive" — is satisfied by the pre-existingPrimary trigger: `high_churn`line that the *next* test asserts at:1586. It passes with thesurvived` ternary deleted entirely. - Given how much of this PR's value is in the prose (the counts alone are the part a reviewer could get from the source issue), recommend asserting the full rendered line, or at minimum a scoped substring such as
reviewed anyway because \high_churn` fired, plus one case with adone-but-pending-finalize blocker to exercisependingFinalizeBlockerCount`.
- Both positive tests assert only the prefix
Suggestions (2)
-
[comments / tests]
server/src/__tests__/productivity-review-service.test.ts:1553—// The bucket is reported next to the elapsed split, never folded into it.sits abovenot.toContain("Dependency accounting: 0 "), which checks neither adjacency nor non-folding, and cannot fail:formatDependencyGatingis only reached whendependencyGatingis non-null, which requiresunresolvedBlockerCount > 0. The never-folded invariant is the design centrepiece and is directly assertable — pin theElapsed accountingunattended figure across a with-blocker / without-blocker pair and require it identical. -
[code]
server/src/services/productivity-review.ts:400— the durable description only picks updependencyGatingat creation or on a trigger flip (descriptionStale,:4149), while the refresh comment always carries the live value. A blocker added or resolved mid-episode therefore leaves the description's dependency line permanently disagreeing with the newest refresh comment. That is pre-existing behaviour shared withElapsed accounting, but dependency edges flip far more often than monitor gating does, so it will be visible sooner here.at this evidence passpartly inoculates the wording; worth a note in the type comment either way.
Strengths
- The refusal to synthesise a duration is correct and the reasoning is recorded where the next editor will hit it (
productivity-review.ts:233-241), including the concrete prior failure (PR #1361's unboundedlatestRuns.findagainst a 100-run cap). Set→Mapkeeps the.has()membership test at:4868byte-identical while carrying the counts, so BLO-22436's suppression semantics are provably unchanged and no second readiness query is added.- The attach point is right: after the approval/monitor suppression branches, where the union has narrowed and both non-rendering paths have already
continued.nonClosableTriggersreally is non-empty by construction there —collectEvidencereturns null when no trigger fires (:3544), andisDependencyBlockedClosableTriggerSetfails closed on an empty set (:1399) — so the invariant the comment claims holds. pendingFinalizeBlockerCountis a genuine subset ofunresolvedBlockerCount(issues.ts:1655-1667), so the parenthetical is arithmetically honest rather than a second, overlapping total.- Leaving
dependencyGating: nullon thecollectEvidencepath is the right call and the note explains why the continuation-hold caller must not see dependency state at all.
Recommended Action
- Fix Critical issues before merge. — none.
- Address Important issues this cycle: gate the "elapsed figures above" caveat on
monitorGating, and extend the assertions past the count prefix. - Consider Suggestions opportunistically.
Thinking Path
Linked Issues or Issue Description
Refs BLO-22887 — "Productivity detector counts dependency-blocked time as unattended, generating false
long_active_durationreviews".That issue's ACs 1, 3 and 4 (suppression on both the generation and close paths, monitor-lapse independence, true-positive preservation) already landed on
mastervia BLO-22436. AC2 is the only one outstanding and is what this PR closes:Supersedes #1361, which was closed as superseded (it re-implemented ACs 1/3/4 by a worse mechanism and was written against
capacityGating, deleted by BLO-23624). Nothing from that branch is reused.What Changed
DependencyGatingtype +evidence.dependencyGating(server/src/services/productivity-review.ts) — unresolved blocker count, thedone-but-awaiting-workspace-finalize subset, and the fired triggers a blocker does not excuse.nullwhenever the source has no unresolved blocker, so the line's presence is itself the signal.formatDependencyGating— renders the bucket beside the existingformatMonitorGating/formatNoExecutableTurnGatinghelpers.- Dependency accounting:inbuildReviewMarkdown(the durable evidence block) and inbuildRefreshComment(what lands in the manager's notifications), matching how both existing accounting lines are wired.reconcileProductivityReviews—dependencyBlockedSourceIssueIdswidened fromSet<string>to aMapcarrying the counts. No new query: thelistDependencyReadinesscall it already makes for BLO-22436's suppression gate is the sole source. The.has()membership semantics of the suppression check are unchanged.collectEvidencesetsdependencyGating: nulland fetches nothing. Its other caller —isProductivityReviewContinuationHoldActive— must not see dependency state (existing header note), and would otherwise pay a second readiness round-trip for a field it never renders.server/src/__tests__/productivity-review-service.test.ts.Deliberate scope limit: state, not a span
The line reports blocker state at the evidence pass, and says so in its own text:
The readiness map carries ids and counts, never the edge's own age, so there is no honest "blocked for N hours" figure derivable from it. Manufacturing one is precisely what sank #1361 — an unbounded
latestRuns.find(run => run.status === "scheduled_retry" …)againstMAX_RUNS_FOR_STREAK = 100, so an older park silently read as absent. Subtracting an unmeasured span from the wall-clock buckets would replace a known-wrong attribution with an invented one. Naming the limit is the deliverable.This also respects
master:3470-3481(BLO-25877), which explicitly rejects folding a suppression subtraction into thelongActivepredicate — an accounting line does not touch that predicate, and this one does not.Verification
Red before green. Both new assertion cells fail on
master(commit2ebf8009) with the tests present and the implementation absent:The third cell — the over-reporting guard — passes on
masterby construction and is a regression guard, not a red case.Green after the change:
The three cells, and what each is load-bearing for:
reports a dependency-blocked bucket alongside the elapsed split when a review still fires on a non-closable trigger— 10 commenting runs + an unresolved blocker →high_churnreview created, description contains the line with the correct count and nameshigh_churnas the reason it survived.omits the dependency accounting line when the source issue's only blocker is resolved— the over-reporting guard, and the counterpart to BLO-22436's cell 3. A line that rendered unconditionally would pass cell 1 while telling every reviewer in the fleet that an unblocked issue is dependency-blocked. Uses adoneblocker rather than no edge at all, so it proves the key is unresolved readiness and not mere edge existence. Assertsnot.toContain("Dependency accounting")while the review is still created.carries the dependency accounting line into the refresh comment— the refresh comment is what reaches a manager's notifications and already mirrors both existing accounting lines; a bucket present only in the description would leave the summary telling a different story from the artifact it summarises.The pre-existing BLO-22436 suppression cells (
skips an issue with an unresolved blocker…→dependencyBlockedSuppressed === 1, and both "still generates a review for a dependency-blocked issue" cells) all still pass unchanged, which is the AC1/AC3/AC4 regression surface.Risks
Low. Additive reporting only — no predicate, no suppression decision, and no counter changes.
.has()test is identical after theSet→Mapwidening; only the value type changed.result.dependencyBlockedSuppressedis untouched.issueRelationsquery and nolatestRunsscan were added — the two mechanisms that made fix(productivity): account for dependency-blocked elapsed time #1361 unsafe.collectEvidencehard-codesdependencyGating: nulland fetches nothing, soisProductivityReviewContinuationHoldActivesees no dependency state and pays no extra round-trip — preserving the invariant its header comment documents.Model Used
Claude —
claude-opus-5[1m], 1M context, extended thinking, tool use / code execution via Claude Code running as a Paperclip heartbeat agent.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatePaperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-22887