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" },