Fix stranded task retry and respec reset
This commit is contained in:
@@ -253,6 +253,14 @@ export function TaskDetailModal({
|
||||
// Derive a working task that always has all available fields.
|
||||
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
|
||||
const workingTask: TaskDetail = fullDetail ?? { ...task, prompt: "" } as TaskDetail;
|
||||
const canRetryTask =
|
||||
task.status === "failed" ||
|
||||
task.status === "stuck-killed" ||
|
||||
task.status === "specifying" ||
|
||||
task.status === "needs-respecify" ||
|
||||
(task.stuckKillCount ?? 0) > 0 ||
|
||||
(task.recoveryRetryCount ?? 0) > 0 ||
|
||||
Boolean(task.nextRecoveryAt);
|
||||
|
||||
// Sync activeTab when the caller changes initialTab (e.g. opening a different tab)
|
||||
useEffect(() => {
|
||||
@@ -1564,7 +1572,7 @@ export function TaskDetailModal({
|
||||
)}
|
||||
|
||||
{/* Actions dropdown — less common operations */}
|
||||
{(task.column !== "triage" || task.status === "awaiting-approval") && (
|
||||
{(task.column !== "triage" || task.status === "awaiting-approval" || canRetryTask) && (
|
||||
<div className="detail-actions-dropdown" ref={actionsMenuRef}>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
@@ -1623,7 +1631,7 @@ export function TaskDetailModal({
|
||||
)}
|
||||
|
||||
{/* Retry */}
|
||||
{(task.status === "failed" || task.status === "stuck-killed") && onRetryTask && (
|
||||
{canRetryTask && onRetryTask && (
|
||||
<button
|
||||
className="detail-actions-menu-item"
|
||||
role="menuitem"
|
||||
|
||||
@@ -270,6 +270,27 @@ describe("TaskDetailModal", () => {
|
||||
expect(retryButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows Retry for a stranded specifying triage task", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ column: "triage", status: "specifying", stuckKillCount: 6 })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
onRetryTask={noopRetry}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const actionsBtn = screen.getByRole("button", { name: /actions/i });
|
||||
fireEvent.click(actionsBtn);
|
||||
|
||||
const retryButtons = screen.getAllByRole("menuitem", { name: "Retry" });
|
||||
expect(retryButtons).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("shows 'Move to Todo' in Move dropdown for in-review tasks (not 'Retry')", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach }
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -1296,6 +1296,8 @@ describe("POST /tasks/:id/retry", () => {
|
||||
worktree: null,
|
||||
branch: null,
|
||||
stuckKillCount: 0,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
@@ -1330,6 +1332,8 @@ describe("POST /tasks/:id/retry", () => {
|
||||
worktree: null,
|
||||
branch: null,
|
||||
stuckKillCount: 0,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
@@ -1352,10 +1356,67 @@ describe("POST /tasks/:id/retry", () => {
|
||||
worktree: null,
|
||||
branch: null,
|
||||
stuckKillCount: 0,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Retry requested from dashboard (stuck kill budget reset)");
|
||||
});
|
||||
|
||||
it("retries a stranded specifying triage task in triage and removes stale prompt", async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "kb-task-retry-spec-"));
|
||||
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
||||
mkdirSync(taskDir, { recursive: true });
|
||||
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
||||
|
||||
const specifyingTask = {
|
||||
...FAKE_TASK_DETAIL,
|
||||
column: "triage" as const,
|
||||
status: "specifying",
|
||||
stuckKillCount: 6,
|
||||
recoveryRetryCount: 2,
|
||||
nextRecoveryAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
};
|
||||
const retriedTask = {
|
||||
...specifyingTask,
|
||||
status: "needs-respecify",
|
||||
stuckKillCount: 0,
|
||||
recoveryRetryCount: undefined,
|
||||
nextRecoveryAt: undefined,
|
||||
};
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(specifyingTask)
|
||||
.mockResolvedValueOnce(retriedTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(retriedTask);
|
||||
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
||||
|
||||
try {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/retry", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
status: "needs-respecify",
|
||||
error: null,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
stuckKillCount: 0,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
expect(store.moveTask).not.toHaveBeenCalled();
|
||||
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"KB-001",
|
||||
"Retry requested from dashboard (specification retry budget reset)",
|
||||
);
|
||||
expect(res.body.status).toBe("needs-respecify");
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /tasks/:id/duplicate", () => {
|
||||
@@ -5763,27 +5824,37 @@ describe("POST /tasks/:id/spec/revise", () => {
|
||||
it("requests spec revision and moves task from todo to triage", async () => {
|
||||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-revise-"));
|
||||
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
||||
mkdirSync(taskDir, { recursive: true });
|
||||
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/spec/revise",
|
||||
JSON.stringify({ feedback: "Please add more details about error handling" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
try {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/tasks/KB-001/spec/revise",
|
||||
JSON.stringify({ feedback: "Please add more details about error handling" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"AI spec revision requested",
|
||||
"Please add more details about error handling"
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"AI spec revision requested",
|
||||
"Please add more details about error handling"
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||||
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requests spec revision and moves task from in-progress to triage", async () => {
|
||||
@@ -5973,20 +6044,30 @@ describe("POST /tasks/:id/spec/rebuild", () => {
|
||||
it("rebuilds spec and moves task from todo to triage", async () => {
|
||||
const todoTask = { ...FAKE_TASK_DETAIL, column: "todo" as const };
|
||||
const movedTask = { ...FAKE_TASK_DETAIL, column: "triage" as const };
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), "kb-spec-rebuild-"));
|
||||
const taskDir = join(tempRoot, ".fusion", "tasks", "FN-001");
|
||||
mkdirSync(taskDir, { recursive: true });
|
||||
writeFileSync(join(taskDir, "PROMPT.md"), "# stale spec\n");
|
||||
|
||||
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue(todoTask);
|
||||
(store.moveTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
(store.updateTask as ReturnType<typeof vi.fn>).mockResolvedValue(movedTask);
|
||||
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue(tempRoot);
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||||
try {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/spec/rebuild");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Specification rebuild requested by user"
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.logEntry).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"Specification rebuild requested by user"
|
||||
);
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "triage");
|
||||
expect(existsSync(join(taskDir, "PROMPT.md"))).toBe(false);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: "needs-respecify" });
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rebuilds spec and moves task from in-progress to triage", async () => {
|
||||
|
||||
@@ -2496,22 +2496,38 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Retry failed or stuck-killed task
|
||||
// Retry failed, stuck-killed, or stranded triage/specification task
|
||||
router.post("/tasks/:id/retry", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const task = await scopedStore.getTask(req.params.id);
|
||||
if (task.status !== "failed" && task.status !== "stuck-killed") {
|
||||
const retrySpecification =
|
||||
task.column === "triage" &&
|
||||
(task.status === "specifying" || task.status === "needs-respecify" || (task.stuckKillCount ?? 0) > 0);
|
||||
if (task.status !== "failed" && task.status !== "stuck-killed" && !retrySpecification) {
|
||||
throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`);
|
||||
}
|
||||
await scopedStore.updateTask(req.params.id, {
|
||||
status: null,
|
||||
status: retrySpecification ? "needs-respecify" : null,
|
||||
error: null,
|
||||
worktree: null,
|
||||
branch: null,
|
||||
stuckKillCount: 0,
|
||||
recoveryRetryCount: null,
|
||||
nextRecoveryAt: null,
|
||||
});
|
||||
|
||||
if (retrySpecification) {
|
||||
const { rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
await rm(promptPath, { force: true });
|
||||
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (specification retry budget reset)");
|
||||
const updated = await scopedStore.getTask(req.params.id);
|
||||
res.json(updated);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset steps if the branch has no unique commits (work was lost with worktree)
|
||||
const completedSteps = task.steps.filter(
|
||||
(s: { status: string }) => s.status === "done" || s.status === "in-progress",
|
||||
@@ -3401,6 +3417,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Move to triage for re-specification (only valid for todo/in-progress)
|
||||
const updated = await scopedStore.moveTask(task.id, "triage");
|
||||
|
||||
// Remove the existing spec so re-specification starts from the task
|
||||
// description and feedback rather than revising stale PROMPT.md content.
|
||||
const { rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
await rm(promptPath, { force: true });
|
||||
|
||||
// Update status to indicate needs re-specification
|
||||
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
|
||||
|
||||
@@ -3436,6 +3459,13 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
// Move to triage for re-specification
|
||||
const updated = await scopedStore.moveTask(task.id, "triage");
|
||||
|
||||
// Remove the existing spec so rebuilds produce a fresh PROMPT.md instead
|
||||
// of asking triage to revise whatever was already on disk.
|
||||
const { rm } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md");
|
||||
await rm(promptPath, { force: true });
|
||||
|
||||
// Update status to indicate needs re-specification
|
||||
await scopedStore.updateTask(task.id, { status: "needs-respecify" });
|
||||
|
||||
|
||||
@@ -165,6 +165,26 @@ describe("buildSpecificationPrompt", () => {
|
||||
expect(prompt).toContain("revising an existing task specification");
|
||||
});
|
||||
|
||||
it("generates fresh re-specification prompt when only feedback is provided", () => {
|
||||
const feedback = "Start fresh and avoid the stale bootstrap assumption";
|
||||
|
||||
const prompt = buildSpecificationPrompt(
|
||||
baseTask,
|
||||
".fusion/tasks/KB-001/PROMPT.md",
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
feedback,
|
||||
);
|
||||
|
||||
expect(prompt).toContain("Re-specify this task");
|
||||
expect(prompt).toContain("Re-specification Instructions");
|
||||
expect(prompt).toContain("fresh replacement specification");
|
||||
expect(prompt).toContain(feedback);
|
||||
expect(prompt).not.toContain("Existing Specification");
|
||||
expect(prompt).toContain("without carrying forward stale assumptions");
|
||||
});
|
||||
|
||||
it("includes attachments when provided", () => {
|
||||
const attachments = [
|
||||
{
|
||||
|
||||
@@ -652,9 +652,6 @@ export class TriageProcessor {
|
||||
let feedback: string | undefined;
|
||||
|
||||
if (isRespecify) {
|
||||
// Get the existing prompt content
|
||||
existingPrompt = detail.prompt;
|
||||
|
||||
// Extract feedback from the most recent "AI spec revision requested" log entry
|
||||
const revisionLogEntry = [...task.log]
|
||||
.reverse()
|
||||
@@ -1375,7 +1372,9 @@ export function buildSpecificationPrompt(
|
||||
existingPrompt?: string,
|
||||
feedback?: string,
|
||||
): string {
|
||||
const isRevision = existingPrompt && feedback;
|
||||
const hasFeedback = Boolean(feedback?.trim());
|
||||
const isRevision = Boolean(existingPrompt && hasFeedback);
|
||||
const isFreshRespecification = Boolean(!existingPrompt && hasFeedback);
|
||||
|
||||
let commandsSection = "";
|
||||
if (settings?.testCommand || settings?.buildCommand) {
|
||||
@@ -1457,6 +1456,18 @@ ${existingPrompt}
|
||||
${feedback}
|
||||
|
||||
Please revise the specification above to address this feedback. Write the complete revised PROMPT.md to \`${promptPath}\`.`;
|
||||
} else if (isFreshRespecification) {
|
||||
revisionSection = `
|
||||
|
||||
## Re-specification Instructions
|
||||
You are creating a fresh replacement specification based on user feedback.
|
||||
|
||||
**Important:** Do not reuse stale PROMPT.md content. Start from the current task description, inspect the codebase, and write a complete new specification that addresses the feedback below.
|
||||
|
||||
## User Feedback
|
||||
${feedback}
|
||||
|
||||
Please write the complete fresh PROMPT.md to \`${promptPath}\`.`;
|
||||
}
|
||||
|
||||
let subtaskSection = "";
|
||||
@@ -1507,7 +1518,7 @@ The user did not explicitly request subtask breakdown, so you should first asses
|
||||
- If size is uncertain at first, make a quick assessment from the available context before deciding.`;
|
||||
}
|
||||
|
||||
return `${isRevision ? "Revise" : "Specify"} this task and write the result to \`${promptPath}\`.
|
||||
return `${isRevision ? "Revise" : isFreshRespecification ? "Re-specify" : "Specify"} this task and write the result to \`${promptPath}\`.
|
||||
|
||||
## Task
|
||||
- **ID:** ${task.id}
|
||||
@@ -1517,7 +1528,7 @@ ${task.breakIntoSubtasks ? "- **Break into subtasks:** Yes (user requested)" : "
|
||||
${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join(", ")}` : ""}${revisionSection}${subtaskSection}
|
||||
|
||||
## Instructions
|
||||
${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
|
||||
${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : isFreshRespecification ? "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a fresh complete PROMPT.md specification to the given path following the format in your system prompt\n3. Address the user feedback without carrying forward stale assumptions from the old spec\n4. Name actual files, functions, and patterns from the codebase — be specific" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
|
||||
|
||||
Use the write tool to write the specification file.${commandsSection}${memorySection}${attachmentsSection}${userCommentsSection}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user