fix(FN-827): fix orphaned pause path so gracefully-paused tasks return to todo

- Fix executor bug where tasks paused during execution with a graceful session exit were silently dropped instead of moved back to todo
- When session.dispose() resolves the prompt without throwing, the paused-aborted flag now triggers moveTask(todo) so the scheduler can resume after unpause
- Add executor test suite covering pause via graceful exit, pause via abort error, and pause during session disposal
- Document corrected pause/unpause behavior for in-progress tasks in README and AGENTS.md
This commit is contained in:
gsxdsm
2026-04-03 22:09:56 -07:00
parent acfeea4fd3
commit a907256bab
5 changed files with 114 additions and 2 deletions

View File

@@ -2033,6 +2033,104 @@ describe("TaskExecutor pause behavior", () => {
// Should NOT move to in-review (paused tasks skip that logic)
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
// Should move to todo instead (regression: was stranding in in-progress)
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", { status: "failed" });
});
it("moves paused task to todo when session ends gracefully (regression for FN-827)", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
mockedCreateHaiAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
// Simulate pause during execution — session ends gracefully (no throw)
store._trigger("task:updated", { id: "FN-805", paused: true, column: "in-progress" });
// No error thrown — this is the "graceful exit" path
}),
dispose: disposeFn,
},
} as any;
});
const stuckTaskDetector = { trackTask: vi.fn(), untrackTask: vi.fn(), recordActivity: vi.fn() } as any;
const executor = new TaskExecutor(store, "/tmp/test", { stuckTaskDetector });
await executor.execute({
id: "FN-805",
title: "Stranded task",
description: "A task that was paused and stranded",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// The critical fix: task must end in todo, not stranded in in-progress
expect(store.moveTask).toHaveBeenCalledWith("FN-805", "todo");
// Should NOT be marked as failed
expect(store.updateTask).not.toHaveBeenCalledWith("FN-805", expect.objectContaining({ status: "failed" }));
// Should log the pause event
expect(store.logEntry).toHaveBeenCalledWith("FN-805", expect.stringContaining("Execution paused"));
// Session should be disposed
expect(disposeFn).toHaveBeenCalled();
// Stuck detector should have untracked the task
expect(stuckTaskDetector.untrackTask).toHaveBeenCalledWith("FN-805");
});
it("handles rapid pause→unpause without duplicate executor runs", async () => {
const store = createMockStore();
const disposeFn = vi.fn();
let promptCallCount = 0;
mockedCreateHaiAgent.mockImplementation(async () => {
return {
session: {
prompt: vi.fn().mockImplementation(async () => {
promptCallCount++;
// Simulate pause during execution
store._trigger("task:updated", { id: "FN-001", paused: true, column: "in-progress" });
// Simulate rapid unpause while executor is still handling the pause
store._trigger("task:updated", { id: "FN-001", paused: undefined, column: "in-progress" });
// Session ends gracefully (no throw)
}),
dispose: disposeFn,
},
} as any;
});
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute({
id: "FN-001",
title: "Rapid pause/unpause",
description: "Test rapid pause then unpause",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// The task should still be moved to todo exactly once (the pause took effect)
// Even if unpause happened rapidly, the session was already disposed
const todoCalls = store.moveTask.mock.calls.filter(
(call: any[]) => call[0] === "FN-001" && call[1] === "todo",
);
expect(todoCalls.length).toBe(1);
// Should NOT have duplicate in-review calls
const inReviewCalls = store.moveTask.mock.calls.filter(
(call: any[]) => call[0] === "FN-001" && call[1] === "in-review",
);
expect(inReviewCalls.length).toBe(0);
// Agent should only have been prompted once
expect(promptCallCount).toBe(1);
});
it("skips paused tasks during resumeOrphaned", async () => {

View File

@@ -640,9 +640,14 @@ export class TaskExecutor {
return;
}
// If paused during execution, don't move to in-review
// If paused during execution, move to todo so the scheduler can resume
// after unpause. This path fires when session.dispose() causes the
// prompt to resolve gracefully instead of throwing.
if (this.pausedAborted.has(task.id)) {
this.pausedAborted.delete(task.id);
executorLog.log(`${task.id} paused (graceful session exit) — moving to todo`);
await this.store.logEntry(task.id, "Execution paused — agent terminated, moved to todo");
await this.store.moveTask(task.id, "todo");
return;
}