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:
Dustin Byrne
2026-03-28 03:22:53 -04:00
parent a2a12f94eb
commit 3dc741c56e
12 changed files with 410 additions and 4 deletions

View File

@@ -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);

View File

@@ -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);

View File

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

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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();

View File

@@ -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);

View File

@@ -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");

View File

@@ -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) {

View File

@@ -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;