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:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user