fix(engine): preserve step progress + worktree across internal task bounces
Workflow-step REVISE retries, pause→todo handoffs, and the context-overflow fresh-session requeue were all routing tasks back to `todo` before returning to `in-progress`. The default reopen-to-todo path reset every step to pending and rewrote PROMPT.md checkboxes, so each retry restarted from step 0 even when earlier steps had already been done — the symptom seen on FN-2978, where every workflow REVISE or pause cycle wiped the task's progress. - Add `preserveResumeState` to `TaskStore.moveTask`. When set, skip `resetAllStepsToPending` + `resetPromptCheckboxes` and keep `worktree` and `executionStartedAt` so the resumed run reattaches to the same checkout. `status`, `error`, and `blockedBy` still clear. - Use it on the workflow-rerun bounce, the three pause-graceful handoffs, and the context-overflow requeue. The agent-terminated pause path still discards (it nukes worktree+branch by design). - Context-overflow requeue clears `sessionFile` synchronously in the awaited `updateTask` immediately before `moveTask`, so the next dispatch cannot reopen the saturated session via a stale pointer. - `fn_task_update` no longer silently regresses `done`/`skipped` steps to `in-progress`, no longer captures a stale rewind checkpoint when it does, and tells the agent honestly when a regression is ignored. - Mobile chat keyboard: ChatView/QuickChatFAB gate layout on the new `keyboardOpen` flag so focused-input + viewport-shrink iOS cases still adjust when the computed overlap is zero. Tests: - New `preserveResumeState` coverage in store.test.ts; updated workflow-rerun + pause-graceful assertions in executor.test.ts. - Restructured the previously-flaky "routes exhausted prompt-mode workflow hard failures" test to drive the bounce inline; passes in isolation and in the wider workflow/pause/context sweep (59/59). - Added regression tests in ChatView.test.tsx and QuickChatFAB.test.tsx for the iOS last-resort `keyboardOpen=true, keyboardOverlap=0` case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3499,6 +3499,25 @@ describe("TaskStore", () => {
|
||||
expect(fetched.steps[0].status).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("preserves done/skipped steps when updateStep is called with in-progress", async () => {
|
||||
const task = await createTaskWithSteps();
|
||||
await store.updateStep(task.id, 0, "done");
|
||||
await store.updateStep(task.id, 1, "done");
|
||||
const beforeRegression = await store.getTask(task.id);
|
||||
const currentStepBefore = beforeRegression.currentStep;
|
||||
|
||||
// Agent erroneously re-marks an already-done step as in-progress.
|
||||
const result = await store.updateStep(task.id, 0, "in-progress");
|
||||
|
||||
expect(result.steps[0].status).toBe("done");
|
||||
expect(result.steps[1].status).toBe("done");
|
||||
expect(result.currentStep).toBe(currentStepBefore);
|
||||
|
||||
const fetched = await store.getTask(task.id);
|
||||
expect(fetched.steps[0].status).toBe("done");
|
||||
expect(fetched.currentStep).toBe(currentStepBefore);
|
||||
});
|
||||
|
||||
it("addComment recreates missing task directory before persisting metadata", async () => {
|
||||
const task = await createTestTask();
|
||||
const dir = await deleteTaskDir(task.id);
|
||||
@@ -5788,6 +5807,21 @@ Task with acceptance criteria
|
||||
expect(moved.currentStep).toBe(0);
|
||||
});
|
||||
|
||||
it("preserves step progress when moving in-progress → todo with preserveResumeState option", 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", { preserveResumeState: true });
|
||||
|
||||
expect(moved.steps[0].status).toBe("done");
|
||||
expect(moved.steps[1].status).toBe("in-progress");
|
||||
expect(moved.steps[2].status).toBe("pending");
|
||||
expect(moved.currentStep).toBe(2);
|
||||
});
|
||||
|
||||
it("resets steps when moving from in-review to todo", async () => {
|
||||
const task = await createTaskWithSteps();
|
||||
await store.moveTask(task.id, "todo");
|
||||
|
||||
@@ -2634,7 +2634,28 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
async moveTask(id: string, toColumn: Column): Promise<Task> {
|
||||
async moveTask(
|
||||
id: string,
|
||||
toColumn: Column,
|
||||
options?: {
|
||||
/**
|
||||
* Mark this transition as an internal bounce/pause hop rather than a
|
||||
* user-initiated reset. On in-progress/done/in-review → todo/triage,
|
||||
* skip the destructive cleanup that would otherwise discard resume
|
||||
* state: leave step statuses intact (no resetAllStepsToPending), do
|
||||
* not rewrite PROMPT.md checkboxes, and keep `worktree` +
|
||||
* `executionStartedAt` so the resumed run reattaches to the same
|
||||
* checkout and preserves wall-clock execution time. `status`,
|
||||
* `error`, and `blockedBy` are still cleared because those are
|
||||
* per-run failure state that the next run will rebuild.
|
||||
*
|
||||
* Used by the workflow-rerun bounce, the pause→todo paths, and
|
||||
* other executor-internal requeues. NOT used by user-initiated
|
||||
* "move back to todo" actions, which still want a clean slate.
|
||||
*/
|
||||
preserveResumeState?: boolean;
|
||||
},
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
let task: Task;
|
||||
@@ -2704,13 +2725,19 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (isReopenToTodoOrTriage) {
|
||||
task.status = undefined;
|
||||
task.error = undefined;
|
||||
task.worktree = undefined;
|
||||
task.blockedBy = 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear recovery metadata when task reaches in-review (successful completion)
|
||||
@@ -3174,6 +3201,27 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
);
|
||||
}
|
||||
|
||||
// Guard against agents (or stale tool calls) regressing completed work
|
||||
// by re-marking a done/skipped step as "in-progress". Overwriting the
|
||||
// step status would silently undo progress, and the currentStep
|
||||
// rewind below would discard the task's place in the plan.
|
||||
const currentStatus = task.steps[stepIndex].status;
|
||||
if (
|
||||
status === "in-progress" &&
|
||||
(currentStatus === "done" || currentStatus === "skipped")
|
||||
) {
|
||||
const ts = new Date().toISOString();
|
||||
task.updatedAt = ts;
|
||||
task.log.push({
|
||||
timestamp: ts,
|
||||
action: `Ignored ${currentStatus}→in-progress regression for step ${stepIndex} (${task.steps[stepIndex].name})`,
|
||||
});
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
}
|
||||
|
||||
task.steps[stepIndex].status = status;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user