fix(KB-153): detect exhausted-retry errors from pi-coding-agent sessions
- Add checkSessionError helper that re-raises errors stored on session.state.error after prompt() resolves silently when retries are exhausted - Integrate checkSessionError in executor, triage, merger, and reviewer agents so existing catch blocks with isUsageLimitError can trigger UsageLimitPauser - Add tests for checkSessionError and for each agent's error propagation path - Add audit report documenting the error propagation gap - Add changeset for the fix
This commit is contained in:
@@ -2576,6 +2576,51 @@ describe("TaskExecutor usage limit detection", () => {
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
||||
|
||||
// session.prompt() resolves normally, but session.state.error is set
|
||||
// (this is what happens when pi-coding-agent exhausts retries)
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: { error: "rate_limit_error: Rate limit exceeded" },
|
||||
};
|
||||
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
|
||||
|
||||
const onError = vi.fn();
|
||||
const executor = new TaskExecutor(store, "/tmp/test", {
|
||||
onError,
|
||||
usageLimitPauser: pauser,
|
||||
});
|
||||
|
||||
await executor.execute({
|
||||
id: "KB-001",
|
||||
title: "Test",
|
||||
description: "Test",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// UsageLimitPauser should be called
|
||||
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
||||
"executor",
|
||||
"KB-001",
|
||||
"rate_limit_error: Rate limit exceeded",
|
||||
);
|
||||
// Task should be marked as failed
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: "failed" });
|
||||
// onError callback should fire
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers global pause for overloaded error", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PRIORITY_EXECUTE, type AgentSemaphore } from "./concurrency.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { executorLog, reviewerLog } from "./logger.js";
|
||||
import { isUsageLimitError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
// Re-export for backward compatibility (tests import from executor.ts)
|
||||
export { summarizeToolArgs } from "./agent-logger.js";
|
||||
@@ -430,6 +430,11 @@ export class TaskExecutor {
|
||||
const agentPrompt = buildExecutionPrompt(detail, this.rootDir, settings);
|
||||
await session.prompt(agentPrompt);
|
||||
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
// session.prompt() resolves normally even when retries are exhausted —
|
||||
// the error is stored on session.state.error instead of being thrown.
|
||||
checkSessionError(session);
|
||||
|
||||
// If dependency was added during execution, discard worktree and move to triage
|
||||
if (this.depAborted.has(task.id)) {
|
||||
this.depAborted.delete(task.id);
|
||||
|
||||
@@ -466,6 +466,39 @@ describe("aiMergeTask — usage limit detection", () => {
|
||||
expect(store.updateSettings).toHaveBeenCalledWith({ globalPause: true });
|
||||
});
|
||||
|
||||
it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
);
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
||||
|
||||
// session.prompt() resolves normally, but session.state.error is set
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: { error: "429 Too Many Requests" },
|
||||
};
|
||||
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
|
||||
|
||||
await expect(
|
||||
aiMergeTask(store, "/tmp/root", "KB-050", { usageLimitPauser: pauser }),
|
||||
).rejects.toThrow("AI merge failed");
|
||||
|
||||
// UsageLimitPauser should be called with "merger" agent type
|
||||
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
||||
"merger",
|
||||
"KB-050",
|
||||
"429 Too Many Requests",
|
||||
);
|
||||
// git reset --merge should be called to abort the merge
|
||||
const resetCalls = mockedExecSync.mock.calls.filter(
|
||||
(c) => String(c[0]).includes("reset --merge"),
|
||||
);
|
||||
expect(resetCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does NOT trigger global pause for non-usage-limit errors", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "KB-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createKbAgent } from "./pi.js";
|
||||
import type { WorktreePool } from "./worktree-pool.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { mergerLog } from "./logger.js";
|
||||
import { isUsageLimitError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
/**
|
||||
* Build the merge system prompt. When `includeTaskId` is true (default),
|
||||
@@ -259,6 +259,9 @@ export async function aiMergeTask(
|
||||
const prompt = buildMergePrompt(taskId, branch, commitLog, diffStat, hasConflicts);
|
||||
await session.prompt(prompt);
|
||||
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
checkSessionError(session);
|
||||
|
||||
// 6. Verify the commit happened — if there are still staged changes, agent didn't commit
|
||||
const staged = execSync("git diff --cached --quiet 2>&1; echo $?", {
|
||||
cwd: rootDir,
|
||||
|
||||
@@ -79,3 +79,54 @@ describe("reviewStep — model settings threading", () => {
|
||||
expect(result.verdict).toBe("APPROVE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewStep — exhausted-retry error detection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("throws when session.prompt() resolves with exhausted-retry error on state.error", async () => {
|
||||
// session.prompt() resolves normally, but session.state.error is set
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
state: { error: "rate_limit_error: Rate limit exceeded" },
|
||||
};
|
||||
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
|
||||
|
||||
await expect(
|
||||
reviewStep("/tmp/worktree", "KB-100", 1, "Test Step", "code", "# prompt"),
|
||||
).rejects.toThrow("rate_limit_error: Rate limit exceeded");
|
||||
});
|
||||
|
||||
it("disposes session in finally block despite the error", async () => {
|
||||
const disposeFn = vi.fn();
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
subscribe: vi.fn(),
|
||||
dispose: disposeFn,
|
||||
state: { error: "rate_limit_error: Rate limit exceeded" },
|
||||
};
|
||||
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
|
||||
|
||||
await expect(
|
||||
reviewStep("/tmp/worktree", "KB-100", 1, "Test Step", "code", "# prompt"),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Session should be disposed in the finally block
|
||||
expect(disposeFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw when session completes without error", async () => {
|
||||
mockedCreateHaiAgent.mockResolvedValue(
|
||||
createMockSession("### Verdict: APPROVE\n### Summary\nLooks good."),
|
||||
);
|
||||
|
||||
const result = await reviewStep(
|
||||
"/tmp/worktree", "KB-100", 1, "Test Step", "plan", "# prompt",
|
||||
);
|
||||
|
||||
expect(result.verdict).toBe("APPROVE");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import type { TaskStore } from "@kb/core";
|
||||
import { createKbAgent } from "./pi.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { checkSessionError } from "./usage-limit-detector.js";
|
||||
|
||||
const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
|
||||
|
||||
@@ -180,6 +181,11 @@ export async function reviewStep(
|
||||
|
||||
try {
|
||||
await session.prompt(request);
|
||||
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
// The caller (executor's createReviewStepTool) catches errors and returns
|
||||
// UNAVAILABLE, so the thrown error will be handled there.
|
||||
checkSessionError(session);
|
||||
} finally {
|
||||
if (agentLogger) await agentLogger.flush();
|
||||
session.dispose();
|
||||
|
||||
@@ -1406,6 +1406,50 @@ describe("TriageProcessor usage limit detection", () => {
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers global pause when session.prompt() resolves with exhausted-retry error on state.error", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
const onUsageLimitHitSpy = vi.spyOn(pauser, "onUsageLimitHit");
|
||||
|
||||
// session.prompt() resolves normally, but session.state.error is set
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: { error: "overloaded_error: Overloaded" },
|
||||
};
|
||||
mockedCreateHaiAgent.mockResolvedValue({ session: mockSession } as any);
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
// UsageLimitPauser should be called with "triage" agent type
|
||||
expect(onUsageLimitHitSpy).toHaveBeenCalledWith(
|
||||
"triage",
|
||||
"KB-001",
|
||||
"overloaded_error: Overloaded",
|
||||
);
|
||||
// Task status should be cleared (not moved to todo with broken spec)
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: null });
|
||||
// onSpecifyError callback should fire
|
||||
expect(onError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT trigger global pause for non-usage-limit errors", async () => {
|
||||
const store = createMockStore();
|
||||
const pauser = new UsageLimitPauser(store);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createKbAgent } from "./pi.js";
|
||||
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { triageLog } from "./logger.js";
|
||||
import { isUsageLimitError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
const TRIAGE_SYSTEM_PROMPT = `You are a task specification agent for "kb", an AI-orchestrated task board.
|
||||
|
||||
@@ -366,6 +366,9 @@ export class TriageProcessor {
|
||||
const agentPrompt = buildSpecificationPrompt(detail, promptPath, settings, attachmentContents);
|
||||
await session.prompt(agentPrompt, imageContents.length > 0 ? { images: imageContents } : undefined);
|
||||
|
||||
// Re-raise errors that pi-coding-agent swallowed after exhausting retries.
|
||||
checkSessionError(session);
|
||||
|
||||
// Check if the agent flagged a duplicate
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
import { isUsageLimitError, UsageLimitPauser, checkSessionError } from "./usage-limit-detector.js";
|
||||
|
||||
// ── isUsageLimitError classification tests ───────────────────────────
|
||||
|
||||
@@ -73,6 +73,59 @@ describe("isUsageLimitError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── checkSessionError tests ──────────────────────────────────────────
|
||||
|
||||
describe("checkSessionError", () => {
|
||||
it("throws when session.state.error is set", () => {
|
||||
const session = { state: { error: "rate_limit_error: Rate limit exceeded" } };
|
||||
expect(() => checkSessionError(session)).toThrow("rate_limit_error: Rate limit exceeded");
|
||||
});
|
||||
|
||||
it("does not throw when session.state.error is undefined", () => {
|
||||
const session = { state: { error: undefined } };
|
||||
expect(() => checkSessionError(session)).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw when session.state.error is empty string", () => {
|
||||
const session = { state: { error: "" } };
|
||||
expect(() => checkSessionError(session)).not.toThrow();
|
||||
});
|
||||
|
||||
it("thrown error message matches session.state.error exactly", () => {
|
||||
const errorMessage = "overloaded_error: Overloaded";
|
||||
const session = { state: { error: errorMessage } };
|
||||
|
||||
let thrownMessage: string | undefined;
|
||||
try {
|
||||
checkSessionError(session);
|
||||
} catch (err: any) {
|
||||
thrownMessage = err.message;
|
||||
}
|
||||
|
||||
expect(thrownMessage).toBe(errorMessage);
|
||||
// Verify isUsageLimitError can classify it
|
||||
expect(isUsageLimitError(thrownMessage!)).toBe(true);
|
||||
});
|
||||
|
||||
it("thrown error message for rate limit is classifiable by isUsageLimitError", () => {
|
||||
const session = { state: { error: "429 Too Many Requests" } };
|
||||
|
||||
let thrownMessage: string | undefined;
|
||||
try {
|
||||
checkSessionError(session);
|
||||
} catch (err: any) {
|
||||
thrownMessage = err.message;
|
||||
}
|
||||
|
||||
expect(isUsageLimitError(thrownMessage!)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw when state has no error property", () => {
|
||||
const session = { state: {} };
|
||||
expect(() => checkSessionError(session as any)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── UsageLimitPauser tests ───────────────────────────────────────────
|
||||
|
||||
function createMockStore(globalPause = false) {
|
||||
|
||||
@@ -50,6 +50,25 @@ export function isUsageLimitError(errorMessage: string): boolean {
|
||||
* agents hitting limits only trigger one pause. The flag resets when `globalPause`
|
||||
* is externally set back to `false` (detected by reading settings before pausing).
|
||||
*/
|
||||
/**
|
||||
* Check if an agent session resolved with an error after exhausting retries.
|
||||
*
|
||||
* pi-coding-agent's `session.prompt()` does **not** throw when retries are
|
||||
* exhausted — it resolves normally and stores the error on `session.state.error`.
|
||||
* Call this immediately after every `await session.prompt(...)` to re-raise
|
||||
* the swallowed error so existing `catch` blocks (with `isUsageLimitError`
|
||||
* checks) can detect rate-limit conditions and trigger `UsageLimitPauser`.
|
||||
*
|
||||
* @param session — The agent session (or any object with `state.error?: string`)
|
||||
* @throws {Error} If `session.state.error` is set and non-empty
|
||||
*/
|
||||
export function checkSessionError(session: { state: { error?: string } }): void {
|
||||
const error = session.state?.error;
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export class UsageLimitPauser {
|
||||
private paused = false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user