feat(FN-2966): reset task steps when reopening to todo or triage

- Reset all task step statuses to pending and set currentStep to 0 when moving from in-progress, in-review, or done back to todo/triage
- Reset checked boxes in PROMPT.md on reopen so execution checklists match the reset step state
- Keep reopen cleanup scoped to reopen transitions while preserving existing behavior for non-reopen moves
- Add targeted store tests covering reopen paths, prompt checkbox reset, and no-op behavior for tasks without steps

Fusion-Task-Id: FN-2966
This commit is contained in:
Fusion
2026-04-29 13:59:39 -07:00
committed by gsxdsm
parent 6a5e240e9a
commit 3dd81d965c
2 changed files with 159 additions and 3 deletions

View File

@@ -5734,6 +5734,133 @@ Task with acceptance criteria
});
});
describe("moveTask — resets steps when moving back to todo/triage", () => {
async function setMixedStepStatuses(taskId: string): Promise<void> {
await store.updateStep(taskId, 0, "done");
await store.updateStep(taskId, 1, "in-progress");
await store.updateStep(taskId, 2, "pending");
}
it("resets all steps to pending and currentStep to 0 when moving from in-progress to todo", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateTask(task.id, { currentStep: 2 });
const moved = await store.moveTask(task.id, "todo");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets all steps to pending and currentStep to 0 when moving from in-progress to triage", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await setMixedStepStatuses(task.id);
await store.updateTask(task.id, { currentStep: 1 });
const moved = await store.moveTask(task.id, "triage");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets steps when moving from in-review to todo", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const withSteps = await store.getTask(task.id);
await store.updateTask(task.id, {
steps: withSteps.steps.map((step) => ({ ...step, status: "done" })),
currentStep: 2,
});
const moved = await store.moveTask(task.id, "todo");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets steps when moving from done to todo", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const withDoneSteps = await store.getTask(task.id);
await store.updateTask(task.id, {
steps: withDoneSteps.steps.map((step) => ({ ...step, status: "done" })),
currentStep: 2,
});
const moved = await store.moveTask(task.id, "todo");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("resets steps when moving from done to triage", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const withDoneSteps = await store.getTask(task.id);
await store.updateTask(task.id, {
steps: withDoneSteps.steps.map((step) => ({ ...step, status: "done" })),
currentStep: 2,
});
const moved = await store.moveTask(task.id, "triage");
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
expect(moved.currentStep).toBe(0);
});
it("does not reset steps when moving from todo to triage", async () => {
const task = await createTaskWithSteps();
await store.moveTask(task.id, "todo");
await store.updateStep(task.id, 0, "done");
const moved = await store.moveTask(task.id, "triage");
expect(moved.steps[0]?.status).toBe("done");
});
it("resets PROMPT.md checkboxes when moving from in-progress to todo", async () => {
const task = await createTaskWithSteps();
const dir = join(rootDir, ".fusion", "tasks", task.id);
await writeFile(
join(dir, "PROMPT.md"),
`# ${task.id}: Checkbox reset
## Steps
### Step 0: Preflight
- [x] Done thing
### Step 1: Implement
- [x] Done thing
`,
);
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "todo");
const prompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
expect(prompt).not.toContain("- [x]");
expect(prompt).toContain("- [ ] Done thing");
});
it("is a no-op when steps array is empty", async () => {
const task = await store.createTask({ description: "no steps reset" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.moveTask(task.id, "todo")).resolves.toMatchObject({ id: task.id, column: "todo" });
});
});
describe("moveTask — clears transient fields when leaving in-progress", () => {
it("clears status, error, worktree, and blockedBy when moving from in-progress to todo", async () => {
const task = await store.createTask({ description: "test clear fields" });

View File

@@ -2662,14 +2662,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Note: recovery metadata (recoveryRetryCount, nextRecoveryAt) is intentionally
// preserved here — the recovery-policy module manages those fields. They are
// only cleared on terminal transitions (in-review, done, archived).
if (
const isReopenToTodoOrTriage =
(fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review")
&& (toColumn === "todo" || toColumn === "triage")
) {
&& (toColumn === "todo" || toColumn === "triage");
if (isReopenToTodoOrTriage) {
task.status = undefined;
task.error = undefined;
task.worktree = undefined;
task.blockedBy = undefined;
this.resetAllStepsToPending(task);
await this.resetPromptCheckboxes(dir);
}
// Clear recovery metadata when task reaches in-review (successful completion)
@@ -2709,6 +2712,32 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
private resetAllStepsToPending(task: Task): void {
if (task.steps.length === 0) {
return;
}
for (const step of task.steps) {
step.status = "pending";
}
task.currentStep = 0;
}
private async resetPromptCheckboxes(dir: string): Promise<void> {
const promptPath = join(dir, "PROMPT.md");
if (!existsSync(promptPath)) {
return;
}
const content = await readFile(promptPath, "utf-8");
const resetContent = content.replace(/^- \[x\]/gm, "- [ ]");
if (resetContent !== content) {
await writeFile(promptPath, resetContent, "utf-8");
}
}
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; currentStep?: number; blockedBy?: string | null; assignedAgentId?: string | null; assigneeUserId?: string | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; postReviewFixCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },