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:
5
.changeset/fix-orphaned-pause-path.md
Normal file
5
.changeset/fix-orphaned-pause-path.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix orphaned pause path: gracefully-paused tasks now correctly move back to todo
|
||||||
@@ -785,7 +785,7 @@ Timeout in milliseconds for detecting stuck tasks. When a task's agent session s
|
|||||||
- Activity is tracked on text deltas, tool calls, tool results, and step status updates
|
- Activity is tracked on text deltas, tool calls, tool results, and step status updates
|
||||||
- Recovery preserves step progress — the task resumes from the current step, not from scratch
|
- Recovery preserves step progress — the task resumes from the current step, not from scratch
|
||||||
- When the timeout value is changed (e.g., reduced from 30 to 10 minutes), the system immediately checks for stuck tasks under the new timer rather than waiting for the next 30-second poll cycle
|
- When the timeout value is changed (e.g., reduced from 30 to 10 minutes), the system immediately checks for stuck tasks under the new timer rather than waiting for the next 30-second poll cycle
|
||||||
- Paused tasks are automatically untracked from monitoring
|
- Paused tasks are automatically untracked from monitoring — when an in-progress task is paused, the executor terminates the agent session and moves the task to `todo` (the stuck-task detector is not involved in pause recovery)
|
||||||
- The timeout is read from settings on every poll cycle, so changes take effect immediately
|
- The timeout is read from settings on every poll cycle, so changes take effect immediately
|
||||||
- When the timeout value is changed (e.g., reduced from 30 to 10 minutes), the system immediately checks for stuck tasks under the new timer rather than waiting for the next poll cycle
|
- When the timeout value is changed (e.g., reduced from 30 to 10 minutes), the system immediately checks for stuck tasks under the new timer rather than waiting for the next poll cycle
|
||||||
|
|
||||||
|
|||||||
@@ -750,6 +750,10 @@ Automatically resolves:
|
|||||||
```
|
```
|
||||||
Terminates and retries tasks with no agent activity for the specified duration (10 minutes in this example).
|
Terminates and retries tasks with no agent activity for the specified duration (10 minutes in this example).
|
||||||
|
|
||||||
|
**Pause Behavior for In-Progress Tasks:**
|
||||||
|
|
||||||
|
Pausing a task that is currently executing will immediately terminate the agent session and move the task back to `todo`. When the task is later unpaused, the scheduler picks it up and resumes execution from where it left off (step progress is preserved). The task is never left stranded in `in-progress` after a pause — both the error-throwing and graceful session exit paths move it to `todo`. Paused tasks are never marked as `failed`.
|
||||||
|
|
||||||
**Push Notifications (ntfy.sh):**
|
**Push Notifications (ntfy.sh):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2033,6 +2033,104 @@ describe("TaskExecutor pause behavior", () => {
|
|||||||
|
|
||||||
// Should NOT move to in-review (paused tasks skip that logic)
|
// Should NOT move to in-review (paused tasks skip that logic)
|
||||||
expect(store.moveTask).not.toHaveBeenCalledWith("FN-001", "in-review");
|
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 () => {
|
it("skips paused tasks during resumeOrphaned", async () => {
|
||||||
|
|||||||
@@ -640,9 +640,14 @@ export class TaskExecutor {
|
|||||||
return;
|
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)) {
|
if (this.pausedAborted.has(task.id)) {
|
||||||
this.pausedAborted.delete(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user