feat(FN-3185): preserve progress on task reset with explicit confirmation d
This merge implements a "preserve progress" option for task resets across the system. FN-3185 adds a `preserveProgress` flag to `moveTask` that keeps status/history when resetting tasks back to `todo`, with required explicit confirmation dialogs to prevent accidental resets. The feature is wired thr Fusion-Task-Id: FN-3185
This commit is contained in:
@@ -5771,6 +5771,122 @@ Task with acceptance criteria
|
||||
expect(moved.currentStep).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves step progress and currentStep when moving in-progress → todo with preserveProgress", async () => {
|
||||
const task = await createTaskWithSteps();
|
||||
const dir = join(rootDir, ".fusion", "tasks", task.id);
|
||||
await writeFile(
|
||||
join(dir, "PROMPT.md"),
|
||||
`# ${task.id}: Checkbox keep
|
||||
|
||||
## Steps
|
||||
|
||||
### Step 0: Preflight
|
||||
|
||||
- [x] Done thing
|
||||
|
||||
### Step 1: Implement
|
||||
|
||||
- [ ] Pending thing
|
||||
|
||||
### Step 2: Verify
|
||||
|
||||
- [ ] Pending thing
|
||||
`,
|
||||
);
|
||||
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await setMixedStepStatuses(task.id);
|
||||
await store.updateTask(task.id, {
|
||||
currentStep: 2,
|
||||
worktree: "/tmp/worktree",
|
||||
executionStartedAt: new Date().toISOString(),
|
||||
executionCompletedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const moved = await store.moveTask(task.id, "todo", { preserveProgress: true });
|
||||
const prompt = await readFile(join(dir, "PROMPT.md"), "utf-8");
|
||||
|
||||
expect(moved.steps[0].status).toBe("done");
|
||||
expect(moved.steps[1].status).toBe("in-progress");
|
||||
expect(moved.currentStep).toBe(2);
|
||||
expect(moved.worktree).toBeUndefined();
|
||||
expect(moved.executionStartedAt).toBeUndefined();
|
||||
expect(moved.executionCompletedAt).toBeUndefined();
|
||||
expect(prompt).toContain("- [x] Done thing");
|
||||
});
|
||||
|
||||
it("still resets when preserveProgress is true but all steps are pending", async () => {
|
||||
const task = await createTaskWithSteps();
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await setMixedStepStatuses(task.id);
|
||||
await store.updateStep(task.id, 0, "pending");
|
||||
await store.updateStep(task.id, 1, "pending");
|
||||
await store.updateTask(task.id, { currentStep: 2 });
|
||||
|
||||
const moved = await store.moveTask(task.id, "todo", { preserveProgress: true });
|
||||
|
||||
expect(moved.steps.every((step) => step.status === "pending")).toBe(true);
|
||||
expect(moved.currentStep).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves steps for in-review → todo and done → todo with preserveProgress", async () => {
|
||||
const fromReview = await createTaskWithSteps();
|
||||
await store.moveTask(fromReview.id, "todo");
|
||||
await store.moveTask(fromReview.id, "in-progress");
|
||||
await setMixedStepStatuses(fromReview.id);
|
||||
await store.moveTask(fromReview.id, "in-review");
|
||||
await store.updateTask(fromReview.id, { currentStep: 1, executionStartedAt: new Date().toISOString() });
|
||||
|
||||
const reviewMoved = await store.moveTask(fromReview.id, "todo", { preserveProgress: true });
|
||||
expect(reviewMoved.steps[0].status).toBe("done");
|
||||
expect(reviewMoved.steps[1].status).toBe("in-progress");
|
||||
expect(reviewMoved.currentStep).toBe(1);
|
||||
expect(reviewMoved.executionStartedAt).toBeUndefined();
|
||||
|
||||
const fromDone = await createTaskWithSteps();
|
||||
await store.moveTask(fromDone.id, "todo");
|
||||
await store.moveTask(fromDone.id, "in-progress");
|
||||
await setMixedStepStatuses(fromDone.id);
|
||||
await store.updateStep(fromDone.id, 1, "done");
|
||||
await store.updateStep(fromDone.id, 2, "done");
|
||||
await store.moveTask(fromDone.id, "in-review");
|
||||
await store.moveTask(fromDone.id, "done");
|
||||
await store.updateTask(fromDone.id, { currentStep: 2, executionStartedAt: new Date().toISOString() });
|
||||
|
||||
const doneMoved = await store.moveTask(fromDone.id, "todo", { preserveProgress: true });
|
||||
expect(doneMoved.steps[0].status).toBe("done");
|
||||
expect(doneMoved.steps[1].status).toBe("done");
|
||||
expect(doneMoved.currentStep).toBe(2);
|
||||
expect(doneMoved.executionStartedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserveResumeState takes precedence when preserveProgress and preserveResumeState are both true", 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,
|
||||
worktree: "/tmp/worktree",
|
||||
executionStartedAt: new Date().toISOString(),
|
||||
executionCompletedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const moved = await store.moveTask(task.id, "todo", {
|
||||
preserveProgress: true,
|
||||
preserveResumeState: true,
|
||||
});
|
||||
|
||||
expect(moved.steps[0].status).toBe("done");
|
||||
expect(moved.steps[1].status).toBe("in-progress");
|
||||
expect(moved.currentStep).toBe(2);
|
||||
expect(moved.worktree).toBe("/tmp/worktree");
|
||||
expect(moved.executionStartedAt).toBeDefined();
|
||||
expect(moved.executionCompletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resets steps when moving from in-review to todo", async () => {
|
||||
const task = await createTaskWithSteps();
|
||||
await store.moveTask(task.id, "todo");
|
||||
|
||||
@@ -2667,6 +2667,12 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* "move back to todo" actions, which still want a clean slate.
|
||||
*/
|
||||
preserveResumeState?: boolean;
|
||||
/**
|
||||
* Preserve step progress (step statuses + currentStep) when reopening
|
||||
* to todo/triage, while still clearing per-run execution state
|
||||
* (worktree and wall-clock timing fields).
|
||||
*/
|
||||
preserveProgress?: boolean;
|
||||
},
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
@@ -2739,18 +2745,26 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.status = undefined;
|
||||
task.error = undefined;
|
||||
task.blockedBy = undefined;
|
||||
|
||||
const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending");
|
||||
const preserveStepProgress =
|
||||
options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress);
|
||||
|
||||
if (!options?.preserveResumeState) {
|
||||
task.worktree = undefined;
|
||||
// Reset wall-clock runtime so the next run gets a fresh timer.
|
||||
task.executionStartedAt = undefined;
|
||||
task.executionCompletedAt = undefined;
|
||||
this.resetAllStepsToPending(task);
|
||||
await this.resetPromptCheckboxes(dir);
|
||||
} else {
|
||||
// executionCompletedAt is never set on an in-progress task; clear
|
||||
// it defensively in case we are bouncing from done/in-review.
|
||||
task.executionCompletedAt = undefined;
|
||||
}
|
||||
|
||||
if (!preserveStepProgress) {
|
||||
this.resetAllStepsToPending(task);
|
||||
await this.resetPromptCheckboxes(dir);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear recovery metadata when task reaches in-review (successful completion)
|
||||
|
||||
Reference in New Issue
Block a user