diff --git a/server/src/__tests__/productivity-review-service.test.ts b/server/src/__tests__/productivity-review-service.test.ts index 3633d1163c45..f4e96f9e6680 100644 --- a/server/src/__tests__/productivity-review-service.test.ts +++ b/server/src/__tests__/productivity-review-service.test.ts @@ -185,6 +185,37 @@ describeEmbeddedPostgres("productivity review service", () => { return { companyId, ownerUserId, managerId, coderId, issueId, issuePrefix, createdAt }; } + // BLO-22436: creates an explicit `blocks` edge so the source issue has an + // unresolved blocker unless the blocker is already done. + async function addBlocker(input: { + companyId: string; + issuePrefix: string; + blockedIssueId: string; + blockerStatus?: "todo" | "done"; + }) { + const blockerId = randomUUID(); + const createdAt = new Date("2026-04-28T09:00:00.000Z"); + await db.insert(issues).values({ + id: blockerId, + companyId: input.companyId, + title: "Blocking issue", + status: input.blockerStatus ?? "todo", + priority: "medium", + originKind: "manual", + issueNumber: 900, + identifier: `${input.issuePrefix}-900`, + createdAt, + updatedAt: createdAt, + }); + await db.insert(issueRelations).values({ + companyId: input.companyId, + issueId: blockerId, + relatedIssueId: input.blockedIssueId, + type: "blocks", + }); + return blockerId; + } + async function insertRuns(input: { companyId: string; agentId: string; @@ -196,11 +227,13 @@ describeEmbeddedPostgres("productivity review service", () => { status?: string; livenessState?: string | null; usageJson?: Record | null; + errorCode?: string | null; + spacingMs?: number; }) { const runs: Array = []; for (let index = 0; index < input.count; index += 1) { const runId = randomUUID(); - const createdAt = new Date(input.now.getTime() - index * 60_000); + const createdAt = new Date(input.now.getTime() - index * (input.spacingMs ?? 60_000)); runs.push({ id: runId, companyId: input.companyId, @@ -215,6 +248,7 @@ describeEmbeddedPostgres("productivity review service", () => { : { issueId: input.issueId, taskId: input.issueId }, livenessState: input.livenessState !== undefined ? input.livenessState : "advanced", usageJson: input.usageJson !== undefined ? input.usageJson : undefined, + errorCode: input.errorCode !== undefined ? input.errorCode : undefined, nextAction: "Continue processing the next batch.", createdAt, updatedAt: createdAt, @@ -470,6 +504,292 @@ describeEmbeddedPostgres("productivity review service", () => { expect(reviews[0]?.description).toContain("Runtime-failure streak (terminal, never-executed runs): 0"); }); + // BLO-22436: runs cancelled by the dependency gate never reach the adapter, + // and so cannot be evidence that the assignee ran silently. There is no + // current blocker in this fixture: it proves historical cancellations do not + // manufacture a trigger after the dependency has resolved. + it("excludes dependency-gate cancellations from both streaks without creating a review (BLO-22436)", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now, + spacingMs: 10 * 60_000, + status: "cancelled", + livenessState: null, + usageJson: null, + errorCode: "issue_dependencies_blocked", + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.created).toBe(0); + expect(result.dependencyBlockedSkipped).toBe(0); + expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0); + }); + + it("counts current dependency-blocked candidates separately from generic skips (BLO-22436)", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now, + }); + await addBlocker({ + companyId: seeded.companyId, + issuePrefix: seeded.issuePrefix, + blockedIssueId: seeded.issueId, + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.created).toBe(0); + expect(result.dependencyBlockedSkipped).toBe(1); + expect(result.skipped).toBe(0); + expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0); + }); + + it("keeps dependency-gate cancellations transparent to a subsequent infra-failure streak (BLO-22436)", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 3, + now, + status: "cancelled", + livenessState: null, + usageJson: null, + errorCode: "issue_dependencies_blocked", + }); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now: new Date(now.getTime() - 4 * 60_000), + status: "failed", + livenessState: "failed", + usageJson: null, + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.created).toBe(1); + const [review] = await listProductivityReviews(seeded.companyId); + expect(review?.description).toContain("Primary trigger: `runtime_failure_streak`"); + expect(review?.description).toContain("No-comment streak (terminal, turn-executing runs): 0"); + expect(review?.description).toContain("Runtime-failure streak (terminal, never-executed runs): 10"); + }); + + it("reports dependency-gate cancellations separately when another trigger fires (BLO-22436)", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 3, + now, + status: "cancelled", + livenessState: null, + usageJson: null, + errorCode: "issue_dependencies_blocked", + }); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now: new Date(now.getTime() - 4 * 60_000), + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.created).toBe(1); + const [review] = await listProductivityReviews(seeded.companyId); + expect(review?.description).toContain("Primary trigger: `no_comment_streak`"); + expect(review?.description).toContain("No-comment streak (terminal, turn-executing runs): 10"); + expect(review?.description).toContain("Runtime-failure streak (terminal, never-executed runs): 0"); + expect(review?.description).toContain( + "Non-executing runs in sample window (excluded from streaks above): 3 (dominant errorCode: `issue_dependencies_blocked`, 3/3)", + ); + }); + + it("distinguishes a missing error code from literal `unknown` in non-executing telemetry", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 3, + now, + status: "failed", + livenessState: "failed", + usageJson: null, + errorCode: null, + }); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 1, + now: new Date(now.getTime() - 4 * 60_000), + status: "failed", + livenessState: "failed", + usageJson: null, + errorCode: "unknown", + }); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now: new Date(now.getTime() - 6 * 60_000), + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.created).toBe(1); + const [review] = await listProductivityReviews(seeded.companyId); + expect(review?.description).toContain( + "Non-executing runs in sample window (excluded from streaks above): 4 (dominant errorCode: `(none)`, 3/4)", + ); + }); + + it("omits a non-executing error-code diagnosis when no code has a strict majority", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 2, + now, + status: "failed", + livenessState: "failed", + usageJson: null, + errorCode: null, + }); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: 2, + now: new Date(now.getTime() - 3 * 60_000), + status: "failed", + livenessState: "failed", + usageJson: null, + errorCode: "unknown", + }); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now: new Date(now.getTime() - 6 * 60_000), + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.created).toBe(1); + const [review] = await listProductivityReviews(seeded.companyId); + expect(review?.description).toContain( + "Non-executing runs in sample window (excluded from streaks above): 4", + ); + expect(review?.description).not.toContain("dominant errorCode:"); + }); + + it("retires every open productivity review whose source becomes dependency-blocked (BLO-22436)", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + const reviewId = await insertProductivityReview({ seeded, createdAt: now }); + const blockerId = await addBlocker({ + companyId: seeded.companyId, + issuePrefix: seeded.issuePrefix, + blockedIssueId: seeded.issueId, + }); + + const result = await productivityReviewService(db).reconcileProductivityReviews({ + now, + companyId: seeded.companyId, + }); + + expect(result.closedDependencyBlockedReviews).toBe(1); + expect(result.dependencyBlockedSkipped).toBe(1); + const [review] = await listProductivityReviews(seeded.companyId); + expect(review?.id).toBe(reviewId); + expect(review?.status).toBe("done"); + const [closure] = await db + .select() + .from(activityLog) + .where(eq(activityLog.action, "issue.productivity_review_suppressed_open_review_closed")); + expect(closure?.details).toMatchObject({ + sourceIssueId: seeded.issueId, + suppressedBy: "dependency_blocked", + unresolvedBlockerCount: 1, + unresolvedBlockerIssueIds: [blockerId], + }); + }); + + it("keeps an existing continuation hold active until reconciliation retires a blocked review (BLO-22436)", async () => { + const now = new Date("2026-04-28T12:00:00.000Z"); + const seeded = await seedAssignedIssue(); + await insertRuns({ + companyId: seeded.companyId, + agentId: seeded.coderId, + issueId: seeded.issueId, + count: DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, + now, + }); + const service = productivityReviewService(db); + expect((await service.reconcileProductivityReviews({ now, companyId: seeded.companyId })).created).toBe(1); + await addBlocker({ + companyId: seeded.companyId, + issuePrefix: seeded.issuePrefix, + blockedIssueId: seeded.issueId, + }); + + const hold = await service.isProductivityReviewContinuationHoldActive({ + companyId: seeded.companyId, + issueId: seeded.issueId, + agentId: seeded.coderId, + now, + }); + expect(hold.held).toBe(true); + + const result = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId }); + expect(result.closedDependencyBlockedReviews).toBe(1); + const [review] = await listProductivityReviews(seeded.companyId); + expect(review?.status).toBe("done"); + }); + // BLO-19094: an open review grants its assignee issue:comment/issue:mutate on // the SOURCE issue, and an issue with no agent assignee is mutable by any // company agent (allow_company_agent). An unassigned review would therefore diff --git a/server/src/services/productivity-review.ts b/server/src/services/productivity-review.ts index e7f910d476ed..c46ce50d8f5f 100644 --- a/server/src/services/productivity-review.ts +++ b/server/src/services/productivity-review.ts @@ -133,6 +133,11 @@ type ProductivityReviewThresholds = { monitorDispatchBatchSize: number; }; +type DominantErrorCode = { + errorCode: string | null; + count: number; +}; + type ProductivityReviewEvidence = { trigger: ProductivityReviewTrigger; triggerReasons: string[]; @@ -140,6 +145,12 @@ type ProductivityReviewEvidence = { sourceAgent: AgentRow; noCommentStreak: number; runtimeFailureStreak: number; + // Runs in the sample window that could not possibly have produced a comment + // (an infrastructure failure or dependency-gate cancellation). They are + // reported separately from the two streaks so the review body does not make + // operators reconstruct dispatch health from raw run telemetry (BLO-22436). + nonExecutingRunCount: number; + nonExecutingDominantErrorCode: DominantErrorCode | null; totalRunCount: number; terminalRunCount: number; activeRunCount: number; @@ -722,6 +733,14 @@ function formatTrigger(trigger: ProductivityReviewTrigger) { return "Long active duration"; } +// True when the dependency gate cancelled a queued run before dispatch (see +// `cancelQueuedRunForBlockedDependencies` in heartbeat.ts). This is a +// graph-state fact about the source issue, not an infrastructure fault +// (BLO-22436). +function isDependencyBlockedRun(run: Pick): boolean { + return run.errorCode === "issue_dependencies_blocked"; +} + // True when a run's most recent classification is `failed` liveness AND it // burned zero input+output tokens. That combination means the agent never // got a model turn — the runtime crashed, the process was killed, or every @@ -730,13 +749,59 @@ function formatTrigger(trigger: ProductivityReviewTrigger) { // provider capacity 429 kill, and retry-budget exhaustion with no error code // at all (`error: "unknown"`, `error_status: null`). Keying on token usage // rather than error code/status/dispatch-state is deliberate: it is the one -// signature all four causes share (BLO-21769). -function isNeverExecutedRun(run: Pick): boolean { +// signature all four causes share (BLO-21769). Dependency-gate cancellations +// are deliberately excluded so they cannot be reported as infrastructure. +function isInfraFailureRun( + run: Pick, +): boolean { + if (isDependencyBlockedRun(run)) return false; if (run.livenessState !== "failed") return false; const { inputTokens, outputTokens } = runUsageTokenCounts(run.usageJson); return inputTokens === 0 && outputTokens === 0; } +// Every run that could not have emitted a progress comment is transparent to +// the no-comment streak. The same dependency-gate population is transparent +// to the infra-failure streak below: a cancelled queue entry neither proves +// nor breaks a sequence of actual runtime failures. +function isNeverExecutedRun( + run: Pick, +): boolean { + return isInfraFailureRun(run) || isDependencyBlockedRun(run); +} + +function dominantErrorCode( + runs: Array>, +): DominantErrorCode | null { + const counts = new Map(); + for (const run of runs) { + counts.set(run.errorCode, (counts.get(run.errorCode) ?? 0) + 1); + } + + let winner: string | null | undefined; + let winnerCount = 0; + for (const [errorCode, count] of counts) { + if (count > winnerCount) { + winner = errorCode; + winnerCount = count; + } + } + // A tied error-code mix is telemetry, not a diagnosis. Omit it rather than + // letting map insertion order turn it into a misleading definite cause. + if (winner === undefined || winnerCount * 2 <= runs.length) return null; + return { errorCode: winner, count: winnerCount }; +} + +function formatNonExecutingRunEvidence( + runCount: number, + dominant: DominantErrorCode | null, +): string { + const prefix = `- Non-executing runs in sample window (excluded from streaks above): ${runCount}`; + if (!dominant) return prefix; + const errorCode = dominant.errorCode === null ? "`(none)`" : `\`${dominant.errorCode}\``; + return `${prefix} (dominant errorCode: ${errorCode}, ${dominant.count}/${runCount})`; +} + /** * Either the pooled handle or an open transaction. Helpers that participate in * the BLO-3737 refresh-throttle critical section accept this so the read and the @@ -1764,6 +1829,26 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi for (const source of sourceRows) sourceIssueById.set(source.id, source); } + // The open-review sweep can span companies, while readiness is company + // scoped. Read each company's sources in one batch so retiring a blocked + // review uses exactly the dependency policy that cancels its queued runs. + const sourceIssueIdsByCompany = new Map(); + for (const sourceIssue of sourceIssueById.values()) { + const ids = sourceIssueIdsByCompany.get(sourceIssue.companyId) ?? []; + ids.push(sourceIssue.id); + sourceIssueIdsByCompany.set(sourceIssue.companyId, ids); + } + const dependencyReadinessByCompany = new Map< + string, + Awaited> + >(); + for (const [sourceCompanyId, sourceIds] of sourceIssueIdsByCompany) { + dependencyReadinessByCompany.set( + sourceCompanyId, + await issuesSvc.listDependencyReadiness(sourceCompanyId, sourceIds, db), + ); + } + const reviewTriggerById = new Map(); const reviewIds = reviewRows.map((review) => review.id); for (const chunk of reviewIds.length > 0 ? [reviewIds] : []) { @@ -1786,25 +1871,40 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi let closedMonitorScheduled = 0; let closedTerminalSource = 0; + let closedDependencyBlocked = 0; for (const review of reviewRows) { if (!review.originId) continue; const sourceIssue = sourceIssueById.get(review.originId) ?? null; if (!sourceIssue) continue; if (sourceIssue.companyId !== review.companyId) continue; const trigger = reviewTriggerById.get(review.id); + const dependencyReadiness = dependencyReadinessByCompany + .get(sourceIssue.companyId) + ?.get(sourceIssue.id); - let suppressedBy: "terminal_source" | "monitor_scheduled" | null = null; + let suppressedBy: "terminal_source" | "monitor_scheduled" | "dependency_blocked" | null = null; let suppressionDetails: Record = {}; - // A `done` source can retire an already-open long-active review: the work - // episode finished under the terminal-status evidence gate, so the - // elapsed-time alarm no longer needs manager adjudication. This does not - // extend to `cancelled`; an assignee can abandon and later restore their - // own source issue, so cancellation must not retire its oversight - // artifact. It also does not extend to historical/accountability triggers - // (`no_comment_streak`, `high_churn`) or missing provenance: completion - // does not invalidate those signals, and unknown trigger semantics fail - // closed. - if (trigger === "long_active_duration" && sourceIssue.status === "done") { + // A dependency-blocked source cannot make progress until the relation is + // resolved, so an already-open review is no longer actionable by its + // assignee. Unlike terminal/monitor suppression, this applies to every + // productivity trigger: the candidate path below also exempts every + // trigger while the same gate is active (BLO-22436). + if ((dependencyReadiness?.unresolvedBlockerCount ?? 0) > 0) { + suppressedBy = "dependency_blocked"; + suppressionDetails = { + unresolvedBlockerCount: dependencyReadiness?.unresolvedBlockerCount ?? 0, + unresolvedBlockerIssueIds: dependencyReadiness?.unresolvedBlockerIssueIds ?? [], + }; + } else if (trigger === "long_active_duration" && sourceIssue.status === "done") { + // A `done` source can retire an already-open long-active review: the work + // episode finished under the terminal-status evidence gate, so the + // elapsed-time alarm no longer needs manager adjudication. This does not + // extend to `cancelled`; an assignee can abandon and later restore their + // own source issue, so cancellation must not retire its oversight + // artifact. It also does not extend to historical/accountability triggers + // (`no_comment_streak`, `high_churn`) or missing provenance: completion + // does not invalidate those signals, and unknown trigger semantics fail + // closed. suppressedBy = "terminal_source"; suppressionDetails = { sourceStatus: sourceIssue.status }; } else if (trigger === "long_active_duration" && !isTerminalIssueStatus(sourceIssue.status)) { @@ -1838,11 +1938,45 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi and source_issue.status = 'done' )`); } - const closed = await db - .update(issues) - .set({ status: "done", completedAt: now, updatedAt: now }) - .where(and(...closePredicates)) - .returning({ id: issues.id }); + let closed: Array<{ id: string }>; + if (suppressedBy === "dependency_blocked") { + // Relation writers acquire this same per-source lock before changing a + // `blocks` edge or its readiness. Re-read while holding it so a blocker + // that just resolved cannot turn into a stale review closure and a + // six-hour resolved-review snooze. + const lockedClose = await db.transaction(async (tx) => { + await tx.execute(sql` + select pg_advisory_xact_lock( + hashtextextended(${`paperclip:issue-blockers:${review.companyId}:${sourceIssue.id}`}, 0) + ) + `); + const lockedReadiness = await issuesSvc.listDependencyReadiness( + review.companyId, + [sourceIssue.id], + tx, + ); + const lockedDependencyReadiness = lockedReadiness.get(sourceIssue.id); + if ((lockedDependencyReadiness?.unresolvedBlockerCount ?? 0) === 0) return null; + const lockedClosed = await tx + .update(issues) + .set({ status: "done", completedAt: now, updatedAt: now }) + .where(and(...closePredicates)) + .returning({ id: issues.id }); + return { closed: lockedClosed, dependencyReadiness: lockedDependencyReadiness }; + }); + if (!lockedClose) continue; + closed = lockedClose.closed; + suppressionDetails = { + unresolvedBlockerCount: lockedClose.dependencyReadiness?.unresolvedBlockerCount ?? 0, + unresolvedBlockerIssueIds: lockedClose.dependencyReadiness?.unresolvedBlockerIssueIds ?? [], + }; + } else { + closed = await db + .update(issues) + .set({ status: "done", completedAt: now, updatedAt: now }) + .where(and(...closePredicates)) + .returning({ id: issues.id }); + } if (closed.length === 0) continue; await logActivity(db, { @@ -1862,9 +1996,14 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi }, }); if (suppressedBy === "terminal_source") closedTerminalSource += 1; + else if (suppressedBy === "dependency_blocked") closedDependencyBlocked += 1; else closedMonitorScheduled += 1; } - return { monitorScheduled: closedMonitorScheduled, terminalSource: closedTerminalSource }; + return { + monitorScheduled: closedMonitorScheduled, + terminalSource: closedTerminalSource, + dependencyBlocked: closedDependencyBlocked, + }; } async function monitorBacklogGraceMs( @@ -2058,14 +2197,17 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi TERMINAL_RUN_STATUSES.includes(run.status as (typeof TERMINAL_RUN_STATUSES)[number]), ); - // BLO-21769: a run that never executed a model turn (see - // `isNeverExecutedRun`) is infrastructure telemetry, not agent behaviour. - // It must not extend `noCommentStreak` — the agent was never given a - // chance to comment — so it is filtered out of the walk entirely rather - // than counted as silence or treated as a streak-breaker. + // BLO-21769: an infrastructure failure never executed a model turn, so it + // is not agent behaviour and must not extend `noCommentStreak`. Dependency + // gate cancellations are similarly transparent to both walks: they did not + // execute, but they also must not break a preceding infrastructure-failure + // streak once the source becomes runnable again (BLO-22436). + const terminalRunsWithoutDependencyGateCancellation = terminalRuns.filter( + (run) => !isDependencyBlockedRun(run), + ); let runtimeFailureStreak = 0; - for (const run of terminalRuns) { - if (!isNeverExecutedRun(run)) break; + for (const run of terminalRunsWithoutDependencyGateCancellation) { + if (!isInfraFailureRun(run)) break; runtimeFailureStreak += 1; } const executedTerminalRuns = terminalRuns.filter((run) => !isNeverExecutedRun(run)); @@ -2074,6 +2216,9 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi if (commentRunIds.has(run.id)) break; noCommentStreak += 1; } + const nonExecutingRuns = terminalRuns.filter((run) => isNeverExecutedRun(run)); + const nonExecutingRunCount = nonExecutingRuns.length; + const nonExecutingDominantErrorCode = dominantErrorCode(nonExecutingRuns); const [ runCountLastHour, @@ -2250,6 +2395,8 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi sourceAgent, noCommentStreak, runtimeFailureStreak, + nonExecutingRunCount, + nonExecutingDominantErrorCode, totalRunCount: latestRuns.length, terminalRunCount: terminalRuns.length, activeRunCount, @@ -2369,6 +2516,14 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi `- Active queued/running/scheduled runs: ${evidence.activeRunCount}`, `- No-comment streak (terminal, turn-executing runs): ${evidence.noCommentStreak}`, `- Runtime-failure streak (terminal, never-executed runs): ${evidence.runtimeFailureStreak}`, + ...(evidence.nonExecutingRunCount > 0 + ? [ + formatNonExecutingRunEvidence( + evidence.nonExecutingRunCount, + evidence.nonExecutingDominantErrorCode, + ), + ] + : []), `- Current active elapsed time: ${msToHuman(evidence.elapsedMs)}`, ...(evidence.nonLiveHoldMs > 0 ? [ @@ -3109,9 +3264,11 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi approvalGatedSuppressed: 0, closedSuppressedMonitorReviews: 0, closedTerminalSourceReviews: 0, + closedDependencyBlockedReviews: 0, creationCapped: 0, noActionSuppressed: 0, skipped: 0, + dependencyBlockedSkipped: 0, suppressedTerminalSource: 0, failed: 0, reviewIssueIds: [] as string[], @@ -3121,6 +3278,7 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi const closedSuppressed = await closeOpenSuppressedReviews(now, opts?.companyId); result.closedSuppressedMonitorReviews = closedSuppressed.monitorScheduled; result.closedTerminalSourceReviews = closedSuppressed.terminalSource; + result.closedDependencyBlockedReviews = closedSuppressed.dependencyBlocked; const recoveredReservations = await recoverStaleReservedProductivityReviews({ now, @@ -3151,6 +3309,26 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi .limit(MAX_CANDIDATE_ISSUES); result.scanned = candidates.length; + // Read readiness in company batches once per reconciliation. The + // continuation-hold path intentionally calls `collectEvidence` directly, + // so keeping this gate here preserves its existing enforcement semantics. + const candidateIssueIdsByCompany = new Map(); + for (const candidate of candidates) { + const ids = candidateIssueIdsByCompany.get(candidate.companyId) ?? []; + ids.push(candidate.id); + candidateIssueIdsByCompany.set(candidate.companyId, ids); + } + const candidateDependencyReadinessByCompany = new Map< + string, + Awaited> + >(); + for (const [candidateCompanyId, candidateIds] of candidateIssueIdsByCompany) { + candidateDependencyReadinessByCompany.set( + candidateCompanyId, + await issuesSvc.listDependencyReadiness(candidateCompanyId, candidateIds, db), + ); + } + const prefixCache = new Map(); for (const candidate of candidates) { if (recoveredReservations.recoveredSourceIssueIds.has(candidate.id)) { @@ -3168,6 +3346,15 @@ export function productivityReviewService(db: Db, deps?: ProductivityReviewServi result.optedOut += 1; continue; } + if ( + (candidateDependencyReadinessByCompany + .get(candidate.companyId) + ?.get(candidate.id) + ?.unresolvedBlockerCount ?? 0) > 0 + ) { + result.dependencyBlockedSkipped += 1; + continue; + } const sourceAgent = await getAgent(candidate.assigneeAgentId); if (!sourceAgent || sourceAgent.companyId !== candidate.companyId) { result.skipped += 1;