feat(KB-147): auto-pause engine on API usage limit errors
- Add UsageLimitPauser class and isUsageLimitError detector for rate limits, overloaded, and quota errors - Integrate usage limit detection into executor, triage, and merger error handlers - Wire shared UsageLimitPauser instance in dashboard startup across all agents - Export UsageLimitPauser and isUsageLimitError from @kb/engine public API - Add comprehensive tests for detector patterns and agent integration
This commit is contained in:
@@ -38,6 +38,7 @@ function createMockStore(tasks: any[] = []) {
|
||||
parseDependenciesFromPrompt: vi.fn().mockResolvedValue([]),
|
||||
logEntry: vi.fn().mockResolvedValue({}),
|
||||
deleteTask: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
@@ -952,3 +953,141 @@ describe("TriageProcessor dependency parsing", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Usage limit detection in triage ──────────────────────────────────
|
||||
|
||||
import { UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
describe("TriageProcessor usage limit detection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("triggers global pause when triage catches a usage-limit error", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
||||
|
||||
mockedCreateHaiAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded"));
|
||||
|
||||
const onError = vi.fn();
|
||||
const triage = new TriageProcessor(store, "/tmp/test", {
|
||||
onSpecifyError: onError,
|
||||
usageLimitPauser: pauser,
|
||||
});
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
||||
"triage",
|
||||
"KB-001",
|
||||
"rate_limit_error: Rate limit exceeded",
|
||||
);
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
|
||||
// Error callback should still fire
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT trigger global pause for non-usage-limit errors", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
||||
|
||||
mockedCreateHaiAgent.mockRejectedValue(new Error("connection refused"));
|
||||
|
||||
const onError = vi.fn();
|
||||
const triage = new TriageProcessor(store, "/tmp/test", {
|
||||
onSpecifyError: onError,
|
||||
usageLimitPauser: pauser,
|
||||
});
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT trigger global pause for ENOENT errors (deleted tasks)", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
||||
|
||||
const enoentError = Object.assign(
|
||||
new Error("ENOENT: no such file or directory"),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
store.updateTask.mockRejectedValue(enoentError);
|
||||
|
||||
const onError = vi.fn();
|
||||
const triage = new TriageProcessor(store, "/tmp/test", {
|
||||
onSpecifyError: onError,
|
||||
usageLimitPauser: pauser,
|
||||
});
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(onUsageLimitHitSpy).not.toHaveBeenCalled();
|
||||
// ENOENT errors don't call onSpecifyError
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works without usageLimitPauser (backward compatible)", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedCreateHaiAgent.mockRejectedValue(new Error("rate_limit_error: Rate limit exceeded"));
|
||||
|
||||
const onError = vi.fn();
|
||||
const triage = new TriageProcessor(store, "/tmp/test", {
|
||||
onSpecifyError: onError,
|
||||
});
|
||||
|
||||
await triage.specifyTask({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Should not crash — just call onError
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user