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