feat(FN-832): add event-driven scheduling on task unpause and auto-resume in-progress tasks

- Auto-resume in-progress tasks that were paused and then unpaused, picking up from their last step
- Add event-driven scheduler trigger on task unpause so resumed tasks are immediately scheduled
- Update Store and TaskStore with unpause handling, type changes for resume tracking
- Refactor executor, merger, and scheduler tests for improved reliability
- Remove dead code: mission-interview, pr-comment-handler, taskStuck util, OpenRouter model sync changeset
- Update AGENTS.md documentation for pause/unpause behavior
This commit is contained in:
gsxdsm
2026-04-04 00:40:32 -07:00
parent df756828fc
commit 2c9def070b
6 changed files with 345 additions and 1 deletions

View File

@@ -271,6 +271,156 @@ describe("Scheduler", () => {
});
});
describe("task unpause scheduling", () => {
it("triggers scheduling immediately when a paused todo task is unpaused", async () => {
// Mock filesystem validation so schedule() can proceed
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const listTasksMock = vi.fn()
.mockResolvedValueOnce([]) // Initial schedule from start()
.mockResolvedValueOnce([
createMockTask({ id: "FN-001", column: "todo", dependencies: [] }),
]);
const store = createMockStore({
listTasks: listTasksMock,
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
});
const scheduler = new Scheduler(store);
scheduler.start();
// Wait for initial schedule pass to complete
await new Promise((r) => setTimeout(r, 10));
// Find the task:updated handler
const onCalls = (store.on as any).mock.calls;
const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1];
expect(updatedHandler).toBeDefined();
// First, simulate pause event (to register the task as paused)
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: true }));
// Now simulate unpause event
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined }));
// Wait for async scheduling to complete
await new Promise((r) => setTimeout(r, 10));
// Should have triggered scheduling and moved the task to in-progress
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "in-progress");
});
it("does not trigger scheduling on unpause if scheduler is not running", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
});
const scheduler = new Scheduler(store);
// Don't start the scheduler
const onCalls = (store.on as any).mock.calls;
const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1];
// Pause then unpause
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: true }));
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined }));
// Should NOT have moved any tasks
expect(store.moveTask).not.toHaveBeenCalled();
});
it("does not trigger scheduling for tasks that were never paused", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([
createMockTask({ id: "FN-001", column: "todo", dependencies: [] }),
]),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
updateTask: vi.fn().mockResolvedValue(undefined),
moveTask: vi.fn().mockResolvedValue(undefined),
});
const scheduler = new Scheduler(store);
scheduler.start();
await new Promise((r) => setTimeout(r, 10));
// Clear calls from initial schedule
(store.moveTask as any).mockClear();
const onCalls = (store.on as any).mock.calls;
const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1];
// Fire task:updated for a task that was never paused — should NOT trigger extra scheduling
await updatedHandler(createMockTask({ id: "FN-001", column: "todo", paused: undefined }));
await new Promise((r) => setTimeout(r, 10));
// moveTask should not be called (no scheduling triggered)
expect(store.moveTask).not.toHaveBeenCalled();
});
it("does not trigger scheduling on unpause for in-progress tasks", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
moveTask: vi.fn().mockResolvedValue(undefined),
});
const scheduler = new Scheduler(store);
scheduler.start();
await new Promise((r) => setTimeout(r, 10));
(store.moveTask as any).mockClear();
const onCalls = (store.on as any).mock.calls;
const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1];
// Pause then unpause an in-progress task — executor handles this, not scheduler
await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: true }));
await updatedHandler(createMockTask({ id: "FN-001", column: "in-progress", paused: undefined }));
await new Promise((r) => setTimeout(r, 10));
// Scheduler should NOT try to schedule an in-progress task
expect(store.moveTask).not.toHaveBeenCalled();
});
it("triggers scheduling for unpaused triage tasks", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const scheduleSpy = vi.spyOn(Scheduler.prototype, "schedule");
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 2, maxWorktrees: 4 }),
});
const scheduler = new Scheduler(store);
scheduler.start();
await new Promise((r) => setTimeout(r, 10));
// Clear calls from initial start() schedule
scheduleSpy.mockClear();
const onCalls = (store.on as any).mock.calls;
const updatedHandler = onCalls.find((call: any) => call[0] === "task:updated")?.[1];
// Pause then unpause a triage task
await updatedHandler(createMockTask({ id: "FN-001", column: "triage", paused: true }));
await updatedHandler(createMockTask({ id: "FN-001", column: "triage", paused: undefined }));
// schedule() should have been triggered by the unpause
expect(scheduleSpy).toHaveBeenCalled();
scheduleSpy.mockRestore();
});
});
describe("start/stop", () => {
it("starts and stops the scheduler", () => {
const store = createMockStore();