feat(KB-199): add retry CLI command and extension tool

- Add kb task retry command to CLI with --force and --all flags
- Implement runTaskRetry() to re-run failed or completed tasks
- Add kb_task_retry extension tool for Pi agent integration
- Update CLI help text and command registration
- Add comprehensive tests for retry command and extension tool
- Include changeset for patch release
This commit is contained in:
gsxdsm
2026-03-30 13:37:09 -07:00
parent 80e9575cbc
commit 297013000f
6 changed files with 188 additions and 2 deletions

View File

@@ -80,6 +80,7 @@ describe("kb pi extension", () => {
"kb_task_attach",
"kb_task_pause",
"kb_task_unpause",
"kb_task_retry",
"kb_task_duplicate",
"kb_task_refine",
"kb_task_import_github",

View File

@@ -39,7 +39,7 @@ if (isBunBinary) {
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry } = await import("./commands/task.js");
const HELP = `
kb — AI-orchestrated task board
@@ -65,6 +65,7 @@ Usage:
kb task attach <id> <file> Attach a file to a task
kb task pause <id> Pause a task (stops all automation)
kb task unpause <id> Unpause a task (resumes automation)
kb task retry <id> Retry a failed task (clears error, moves to todo)
kb task import <owner/repo> [opts] Import GitHub issues as tasks
Options:
@@ -249,6 +250,15 @@ async function main() {
await runTaskUnpause(id);
break;
}
case "retry": {
const id = args[2];
if (!id) {
console.error("Usage: kb task retry <id>");
process.exit(1);
}
await runTaskRetry(id);
break;
}
case "import": {
const ownerRepo = args[2];
if (!ownerRepo) {

View File

@@ -28,7 +28,7 @@ vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@kb/core";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete } from "./task.js";
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry } from "./task.js";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -1175,3 +1175,86 @@ describe("runTaskDelete", () => {
exitSpy.mockRestore();
});
});
// --- Retry Tests ---
describe("runTaskRetry", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let mockGetTask: ReturnType<typeof vi.fn>;
let mockUpdateTask: ReturnType<typeof vi.fn>;
let mockMoveTask: ReturnType<typeof vi.fn>;
let mockLogEntry: ReturnType<typeof vi.fn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
mockGetTask = vi.fn();
mockUpdateTask = vi.fn();
mockMoveTask = vi.fn();
mockLogEntry = vi.fn();
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: mockGetTask,
updateTask: mockUpdateTask,
moveTask: mockMoveTask,
logEntry: mockLogEntry,
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("retries failed task successfully", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "KB-001",
status: "failed",
error: "Some error",
column: "in-progress"
}));
mockUpdateTask.mockResolvedValueOnce(makeTask({ id: "KB-001", status: undefined, error: undefined }));
mockMoveTask.mockResolvedValueOnce(makeTask({ id: "KB-001", column: "todo" }));
mockLogEntry.mockResolvedValueOnce(makeTask({ id: "KB-001" }));
await runTaskRetry("KB-001");
expect(mockGetTask).toHaveBeenCalledWith("KB-001");
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", { status: null, error: null });
expect(mockMoveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(mockLogEntry).toHaveBeenCalledWith("KB-001", "Retry requested from CLI", "Task reset to todo for retry");
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Retried"),
);
expect(successLine).toBeDefined();
expect(successLine![0]).toContain("KB-001");
expect(successLine![0]).toContain("todo");
});
it("throws error when task not found", async () => {
mockGetTask.mockRejectedValueOnce(new Error("Task not found"));
await expect(runTaskRetry("KB-999")).rejects.toThrow("Task KB-999 not found");
});
it("throws error when task is not failed", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "KB-001",
status: undefined,
column: "in-progress"
}));
await expect(runTaskRetry("KB-001")).rejects.toThrow("Task KB-001 is not failed (status: none)");
});
it("throws error with correct status when task has different status", async () => {
mockGetTask.mockResolvedValueOnce(makeTask({
id: "KB-001",
status: "paused",
column: "in-progress"
}));
await expect(runTaskRetry("KB-001")).rejects.toThrow("Task KB-001 is not failed (status: paused)");
});
});

View File

@@ -339,6 +339,36 @@ export async function runTaskArchive(id: string) {
console.log();
}
export async function runTaskRetry(id: string) {
const store = await getStore();
// Fetch task and validate it exists
let task;
try {
task = await store.getTask(id);
} catch {
throw new Error(`Task ${id} not found`);
}
// Validate task is in failed state
if (task.status !== 'failed') {
throw new Error(`Task ${id} is not failed (status: ${task.status || 'none'})`);
}
// Clear failure state
await store.updateTask(id, { status: null, error: null });
// Move to todo column
await store.moveTask(id, 'todo');
// Log the retry action
await store.logEntry(id, "Retry requested from CLI", "Task reset to todo for retry");
console.log();
console.log(` ✓ Retried ${id} → todo (failure state cleared)`);
console.log();
}
export async function runTaskDelete(id: string, force?: boolean) {
const store = await getStore();

View File

@@ -341,6 +341,63 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── kb_task_retry ────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_retry",
label: "KB: Retry Task",
description:
"Retry a failed task — clears the error state and moves it back to the todo column for re-execution.",
promptSnippet: "Retry a failed kb task (clears error, moves to todo)",
promptGuidelines: [
"Use when a task has failed and needs to be retried from the beginning",
"Only tasks in 'failed' state can be retried",
"The task will be moved to the todo column with error state cleared",
],
parameters: Type.Object({
id: Type.String({ description: "Task ID to retry (e.g. KB-001). Must be in 'failed' state." }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
// Validate task exists
let task;
try {
task = await store.getTask(params.id);
} catch {
return {
content: [{ type: "text", text: `Task ${params.id} not found` }],
isError: true,
details: { error: "Task not found" },
};
}
// Validate task is in failed state
if (task.status !== 'failed') {
return {
content: [{ type: "text", text: `Task ${params.id} is not failed (status: ${task.status || 'none'})` }],
isError: true,
details: { taskId: params.id, currentStatus: task.status },
};
}
// Clear failure state
await store.updateTask(params.id, { status: null, error: null });
// Move to todo column
await store.moveTask(params.id, 'todo');
// Log the retry action
await store.logEntry(params.id, "Retry requested via Pi extension", "Task reset to todo for retry");
return {
content: [{ type: "text", text: `Retried ${params.id} → todo (failure state cleared)` }],
details: { taskId: params.id, newColumn: 'todo' },
};
},
});
// ── kb_task_duplicate ─────────────────────────────────────────────
pi.registerTool({