From 46dee6c66b433791fca8294a25b386ff350180ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 22 Mar 2026 03:52:12 -0400 Subject: [PATCH] test(02-02): add circuit breaker and deliverable enforcement tests - Self-wake circuit breaker tests: trip after 5 wakes, reset on progress, per-agent isolation - Non-code task deliverable tests: research/content require artifacts, code/general advance normally --- tests/unit/self-wake.test.ts | 152 ++++++++++++++++++++++ tests/unit/task-completion.test.ts | 194 +++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 tests/unit/self-wake.test.ts create mode 100644 tests/unit/task-completion.test.ts diff --git a/tests/unit/self-wake.test.ts b/tests/unit/self-wake.test.ts new file mode 100644 index 00000000..73be3a49 --- /dev/null +++ b/tests/unit/self-wake.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; + +/** + * Tests for the self-wake circuit breaker pattern used in worker-service.ts. + * + * The circuit breaker prevents infinite self-wake loops when an agent is stuck + * on a task. After MAX_CONSECUTIVE_WAKES (5) consecutive wakes without progress + * (i.e., without a task status change), self-wake is suppressed and the agent's + * manager is notified instead. + */ + +const MAX_CONSECUTIVE_WAKES = 5; + +/** + * Standalone circuit breaker logic extracted for testability. + * Mirrors the pattern used inside createWorkerService. + */ +function createCircuitBreaker(maxConsecutive: number = MAX_CONSECUTIVE_WAKES) { + const consecutiveWakeMap = new Map(); + + return { + /** Attempt a self-wake. Returns true if allowed, false if circuit breaker trips. */ + tryWake(agentId: string, companyId: string): boolean { + const wakeKey = `${agentId}:${companyId}`; + const count = consecutiveWakeMap.get(wakeKey) ?? 0; + + if (count >= maxConsecutive) { + consecutiveWakeMap.delete(wakeKey); + return false; // Circuit breaker tripped + } + + consecutiveWakeMap.set(wakeKey, count + 1); + return true; + }, + + /** Reset the counter for an agent (called on task progress). */ + reset(agentId: string, companyId: string): void { + const wakeKey = `${agentId}:${companyId}`; + consecutiveWakeMap.delete(wakeKey); + }, + + /** Get the current count for an agent. */ + getCount(agentId: string, companyId: string): number { + const wakeKey = `${agentId}:${companyId}`; + return consecutiveWakeMap.get(wakeKey) ?? 0; + }, + }; +} + +describe("self-wake circuit breaker", () => { + it("allows the first self-wake (counter=1)", () => { + const cb = createCircuitBreaker(); + const allowed = cb.tryWake("agent-1", "company-1"); + expect(allowed).toBe(true); + expect(cb.getCount("agent-1", "company-1")).toBe(1); + }); + + it("allows wakes up to MAX_CONSECUTIVE_WAKES-1 (counter=4)", () => { + const cb = createCircuitBreaker(); + for (let i = 0; i < MAX_CONSECUTIVE_WAKES - 1; i++) { + expect(cb.tryWake("agent-1", "company-1")).toBe(true); + } + expect(cb.getCount("agent-1", "company-1")).toBe(MAX_CONSECUTIVE_WAKES - 1); + }); + + it("trips after MAX_CONSECUTIVE_WAKES (5) consecutive wakes without progress", () => { + const cb = createCircuitBreaker(); + // First 5 wakes succeed (counter goes 0->1, 1->2, 2->3, 3->4, 4->5) + for (let i = 0; i < MAX_CONSECUTIVE_WAKES; i++) { + expect(cb.tryWake("agent-1", "company-1")).toBe(true); + } + // 6th wake should trip the breaker + const tripped = cb.tryWake("agent-1", "company-1"); + expect(tripped).toBe(false); + }); + + it("deletes the counter after circuit breaker trips (reset for next cycle)", () => { + const cb = createCircuitBreaker(); + for (let i = 0; i < MAX_CONSECUTIVE_WAKES; i++) { + cb.tryWake("agent-1", "company-1"); + } + // Trip the breaker + cb.tryWake("agent-1", "company-1"); + // Counter should be deleted + expect(cb.getCount("agent-1", "company-1")).toBe(0); + }); + + it("resets counter to 0 on task status change", () => { + const cb = createCircuitBreaker(); + cb.tryWake("agent-1", "company-1"); + cb.tryWake("agent-1", "company-1"); + expect(cb.getCount("agent-1", "company-1")).toBe(2); + + // Simulate a task status change -> reset + cb.reset("agent-1", "company-1"); + expect(cb.getCount("agent-1", "company-1")).toBe(0); + }); + + it("maintains per-agent isolation (different agents have separate counters)", () => { + const cb = createCircuitBreaker(); + // Agent 1 wakes 4 times + for (let i = 0; i < 4; i++) { + cb.tryWake("agent-1", "company-1"); + } + // Agent 2 wakes 2 times + cb.tryWake("agent-2", "company-1"); + cb.tryWake("agent-2", "company-1"); + + expect(cb.getCount("agent-1", "company-1")).toBe(4); + expect(cb.getCount("agent-2", "company-1")).toBe(2); + }); + + it("maintains per-company isolation for the same agent", () => { + const cb = createCircuitBreaker(); + cb.tryWake("agent-1", "company-1"); + cb.tryWake("agent-1", "company-1"); + cb.tryWake("agent-1", "company-2"); + + expect(cb.getCount("agent-1", "company-1")).toBe(2); + expect(cb.getCount("agent-1", "company-2")).toBe(1); + }); + + it("allows wakes again after reset even if previously near limit", () => { + const cb = createCircuitBreaker(); + // Get to 4 wakes + for (let i = 0; i < MAX_CONSECUTIVE_WAKES - 1; i++) { + cb.tryWake("agent-1", "company-1"); + } + expect(cb.getCount("agent-1", "company-1")).toBe(MAX_CONSECUTIVE_WAKES - 1); + + // Reset + cb.reset("agent-1", "company-1"); + expect(cb.getCount("agent-1", "company-1")).toBe(0); + + // Should allow wakes again + expect(cb.tryWake("agent-1", "company-1")).toBe(true); + expect(cb.getCount("agent-1", "company-1")).toBe(1); + }); + + it("resetting one agent does not affect another", () => { + const cb = createCircuitBreaker(); + cb.tryWake("agent-1", "company-1"); + cb.tryWake("agent-1", "company-1"); + cb.tryWake("agent-2", "company-1"); + cb.tryWake("agent-2", "company-1"); + cb.tryWake("agent-2", "company-1"); + + cb.reset("agent-1", "company-1"); + expect(cb.getCount("agent-1", "company-1")).toBe(0); + expect(cb.getCount("agent-2", "company-1")).toBe(3); + }); +}); diff --git a/tests/unit/task-completion.test.ts b/tests/unit/task-completion.test.ts new file mode 100644 index 00000000..b46eb79f --- /dev/null +++ b/tests/unit/task-completion.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; + +/** + * Tests for non-code task deliverable enforcement in automation-service.ts. + * + * When a run completes successfully on a non-code task (research, content), + * the system checks whether the agent produced a tangible deliverable + * (knowledge entry, document, or substantial comment). If not, the task + * stays in_progress and the manager is notified. + */ + +type TaskType = "code" | "research" | "content" | "management" | "general"; + +interface DeliverableCheckInput { + taskType: TaskType; + hasKnowledgeEntry: boolean; + hasDocument: boolean; + hasSubstantialComment: boolean; +} + +/** + * Standalone deliverable-check logic extracted for testability. + * Mirrors the checkRunProducedDeliverable function inside automation-service.ts. + */ +function needsDeliverableCheck(taskType: TaskType): boolean { + return taskType === "research" || taskType === "content"; +} + +function hasDeliverable(input: DeliverableCheckInput): boolean { + if (!needsDeliverableCheck(input.taskType)) { + // Code/management/general tasks don't require deliverable check + return true; + } + return input.hasKnowledgeEntry || input.hasDocument || input.hasSubstantialComment; +} + +/** + * Determines the next status after a successful run. + * Returns { nextStatus, comment } for how the task should be advanced. + */ +function determinePostRunStatus(input: DeliverableCheckInput): { + nextStatus: "in_review" | "in_progress"; + comment: string; +} { + if (!needsDeliverableCheck(input.taskType)) { + return { + nextStatus: "in_review", + comment: "Run completed successfully. Awaiting review.", + }; + } + + if (hasDeliverable(input)) { + return { + nextStatus: "in_review", + comment: "Run completed with deliverable. Awaiting review.", + }; + } + + return { + nextStatus: "in_progress", + comment: `${input.taskType} task run completed but no deliverable artifact was detected. Flagging for manager review.`, + }; +} + +describe("non-code task deliverable enforcement", () => { + describe("needsDeliverableCheck", () => { + it("returns false for code tasks", () => { + expect(needsDeliverableCheck("code")).toBe(false); + }); + + it("returns true for research tasks", () => { + expect(needsDeliverableCheck("research")).toBe(true); + }); + + it("returns true for content tasks", () => { + expect(needsDeliverableCheck("content")).toBe(true); + }); + + it("returns false for management tasks", () => { + expect(needsDeliverableCheck("management")).toBe(false); + }); + + it("returns false for general tasks", () => { + expect(needsDeliverableCheck("general")).toBe(false); + }); + }); + + describe("determinePostRunStatus", () => { + it("advances code tasks to in_review unconditionally", () => { + const result = determinePostRunStatus({ + taskType: "code", + hasKnowledgeEntry: false, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + expect(result.comment).toContain("Awaiting review"); + }); + + it("advances general tasks to in_review unconditionally", () => { + const result = determinePostRunStatus({ + taskType: "general", + hasKnowledgeEntry: false, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + }); + + it("advances management tasks to in_review unconditionally", () => { + const result = determinePostRunStatus({ + taskType: "management", + hasKnowledgeEntry: false, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + }); + + it("keeps research task in_progress when no deliverable exists", () => { + const result = determinePostRunStatus({ + taskType: "research", + hasKnowledgeEntry: false, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_progress"); + expect(result.comment).toContain("no deliverable artifact"); + expect(result.comment).toContain("manager review"); + }); + + it("advances research task to in_review when knowledge entry exists", () => { + const result = determinePostRunStatus({ + taskType: "research", + hasKnowledgeEntry: true, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + expect(result.comment).toContain("deliverable"); + }); + + it("advances research task to in_review when document exists", () => { + const result = determinePostRunStatus({ + taskType: "research", + hasKnowledgeEntry: false, + hasDocument: true, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + }); + + it("advances research task to in_review when substantial comment exists", () => { + const result = determinePostRunStatus({ + taskType: "research", + hasKnowledgeEntry: false, + hasDocument: false, + hasSubstantialComment: true, + }); + expect(result.nextStatus).toBe("in_review"); + }); + + it("keeps content task in_progress when no deliverable exists", () => { + const result = determinePostRunStatus({ + taskType: "content", + hasKnowledgeEntry: false, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_progress"); + expect(result.comment).toContain("no deliverable artifact"); + }); + + it("advances content task to in_review when document exists", () => { + const result = determinePostRunStatus({ + taskType: "content", + hasKnowledgeEntry: false, + hasDocument: true, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + }); + + it("advances content task when any deliverable exists", () => { + const result = determinePostRunStatus({ + taskType: "content", + hasKnowledgeEntry: true, + hasDocument: false, + hasSubstantialComment: false, + }); + expect(result.nextStatus).toBe("in_review"); + }); + }); +});