From c4eb4423dbc921c58c12b4c618530c0399da2ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 27 Mar 2026 00:43:08 -0400 Subject: [PATCH] feat(07-02): wire sweepStaleReviews into heartbeat timer - Import sweepStaleReviews, resolveApprovalDecision, dispatchAgentMessage - Insert stale review sweep into per-company heartbeat loop after budget reset - Sweep runs on every 15-second heartbeat cycle for each active company - Local callback stubs for handleTaskStatusChange and handleApprovalResolved - Error handling prevents sweep failures from crashing the heartbeat loop - Fix test type casts to use as-unknown-as for vi.fn mock compatibility - TypeScript compiles cleanly, 249/249 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/orchestration-service.ts | 31 +++++++++++++++++++++++++++ tests/unit/task-quality-gates.test.ts | 14 ++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/main/orchestration-service.ts b/src/main/orchestration-service.ts index e6e40a36..cc89df56 100644 --- a/src/main/orchestration-service.ts +++ b/src/main/orchestration-service.ts @@ -4,6 +4,9 @@ import { getConnectorExecutionReadinessIssue } from "@shared/connector-policy"; import type { DesktopResult } from "@shared/contracts"; import type { ConnectorRecord } from "@shared/types"; import type { NotificationOptions } from "./notification-service"; +import { sweepStaleReviews } from "./task-quality-gates"; +import { resolveApprovalDecision } from "./approval-policy"; +import { dispatchAgentMessage } from "./message-dispatch"; type WorkspaceLockMode = "exclusive" | "shared" | "none"; @@ -678,6 +681,34 @@ export function createOrchestrationService({ db.setLastBudgetResetMonth(company.id, currentMonth); logger.info(`[heartbeat] Reset monthly budgets for company ${company.name} (${currentMonth})`); } + + // TASK-04: Stale review escalation sweep + try { + const escalated = sweepStaleReviews(company.id, { + db: db as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["db"], + dispatchAgentMessage: dispatchAgentMessage as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["dispatchAgentMessage"], + resolveApprovalDecision: resolveApprovalDecision as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["resolveApprovalDecision"], + notify: notify as unknown as import("./task-quality-gates").SweepStaleReviewsDeps["notify"], + handleTaskStatusChange: (_taskId: string, _companyId: string, _prev: string, _next: string) => { + publishDomainChanged(); + }, + handleApprovalResolved: (_approvalId: string, _companyId: string, _decision: string) => { + publishDomainChanged(); + }, + emitEvent: (..._args: unknown[]) => {}, + publishDomainChanged, + wakeAgentIfPossible: (agentId: string, cId: string, trigger: string) => { + wakeAgentIfPossible(agentId, cId, trigger); + }, + logger, + }); + if (escalated > 0) { + logger.info(`[heartbeat] Auto-escalated ${escalated} stale review(s) for company ${company.name}`); + publishDomainChanged(); + } + } catch (error) { + logger.error(`[heartbeat] Stale review sweep failed: ${error instanceof Error ? error.message : String(error)}`); + } } catch (error) { logger.error(`[heartbeat] Error processing company ${company.id}: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/tests/unit/task-quality-gates.test.ts b/tests/unit/task-quality-gates.test.ts index 68255855..59be2811 100644 --- a/tests/unit/task-quality-gates.test.ts +++ b/tests/unit/task-quality-gates.test.ts @@ -237,7 +237,7 @@ describe("sweepStaleReviews", () => { it("escalates to manager's manager when task in_review for 35 minutes", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; // Task in review for 35 min dbMock.queryAll.mockReturnValueOnce([ @@ -265,7 +265,7 @@ describe("sweepStaleReviews", () => { it("does not escalate when task in_review for only 25 minutes (under threshold)", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType }; dbMock.queryAll.mockReturnValueOnce([ { id: "task-1", assigneeAgentId: "agent-1", updatedAt: TWENTY_FIVE_MIN_AGO, title: "Build login" }, @@ -279,7 +279,7 @@ describe("sweepStaleReviews", () => { it("notifies human when no higher manager (reviewer is CEO)", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; dbMock.queryAll .mockReturnValueOnce([ @@ -305,7 +305,7 @@ describe("sweepStaleReviews", () => { it("auto-approves pending approval linked to stale review task", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; // First queryAll: stale task dbMock.queryAll.mockReturnValueOnce([ @@ -332,7 +332,7 @@ describe("sweepStaleReviews", () => { it("catches resolveApprovalDecision error gracefully (race condition)", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType }; dbMock.queryAll.mockReturnValueOnce([ { id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" }, @@ -352,7 +352,7 @@ describe("sweepStaleReviews", () => { it("advances task status to done after escalation", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType; advanceTaskStatus: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType; getManagerForAgent: ReturnType; getChainOfCommand: ReturnType; advanceTaskStatus: ReturnType }; dbMock.queryAll.mockReturnValueOnce([ { id: "task-1", assigneeAgentId: "agent-1", updatedAt: THIRTY_FIVE_MIN_AGO, title: "Build login" }, @@ -373,7 +373,7 @@ describe("sweepStaleReviews", () => { it("skips task with no assigneeAgentId", () => { const deps = makeSweepDeps(); - const dbMock = deps.db as { queryAll: ReturnType }; + const dbMock = deps.db as unknown as { queryAll: ReturnType }; dbMock.queryAll.mockReturnValueOnce([ { id: "task-1", assigneeAgentId: null, updatedAt: THIRTY_FIVE_MIN_AGO, title: "Orphan task" },