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:
@@ -2154,6 +2154,150 @@ describe("TaskExecutor pause behavior", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-002", "Resumed after engine restart");
|
||||
expect(store.logEntry).not.toHaveBeenCalledWith("FN-001", expect.anything());
|
||||
});
|
||||
|
||||
it("resumes unpaused in-progress task with no active session", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: disposeFn,
|
||||
},
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Simulate unpause of an in-progress task that has no active session
|
||||
// (e.g., engine restarted while task was paused in-progress)
|
||||
store._trigger("task:updated", {
|
||||
id: "FN-001",
|
||||
paused: undefined,
|
||||
column: "in-progress",
|
||||
description: "Test task",
|
||||
title: "Resumed task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Wait for async execution to start
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// Agent should have been created to resume the task
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause");
|
||||
});
|
||||
|
||||
it("does not duplicate execution when unpausing already-executing task", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Simulate rapid unpause during execution — should NOT start a second run
|
||||
store._trigger("task:updated", {
|
||||
id: "FN-001",
|
||||
paused: undefined,
|
||||
column: "in-progress",
|
||||
});
|
||||
// Wait a bit to let the unpause handler run
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}),
|
||||
dispose: disposeFn,
|
||||
},
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
await executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Already executing",
|
||||
description: "Test no duplicate",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Only one agent should have been created (no duplicate from the unpause event)
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not resume unpaused task that is not in-progress", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Unpause a todo task — executor should NOT try to execute it
|
||||
store._trigger("task:updated", {
|
||||
id: "FN-001",
|
||||
paused: undefined,
|
||||
column: "todo",
|
||||
});
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
// No agent should have been created
|
||||
expect(mockedCreateHaiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not resume unpaused task that still has an active session", async () => {
|
||||
const store = createMockStore();
|
||||
const disposeFn = vi.fn();
|
||||
|
||||
let promptResolve: () => void;
|
||||
const promptPromise = new Promise<void>((r) => { promptResolve = r; });
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockImplementation(async () => {
|
||||
// Simulate unpause while session is still active (should be a no-op)
|
||||
store._trigger("task:updated", {
|
||||
id: "FN-001",
|
||||
paused: undefined,
|
||||
column: "in-progress",
|
||||
});
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
}),
|
||||
dispose: disposeFn,
|
||||
},
|
||||
}) as any);
|
||||
|
||||
const executor = new TaskExecutor(store, "/tmp/test");
|
||||
|
||||
// Start execution — session will be active
|
||||
const executePromise = executor.execute({
|
||||
id: "FN-001",
|
||||
title: "Active session",
|
||||
description: "Test active session unpause",
|
||||
column: "in-progress",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
await executePromise;
|
||||
|
||||
// Only one agent session created — the unpause during active session was a no-op
|
||||
expect(mockedCreateHaiAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskExecutor global pause behavior", () => {
|
||||
|
||||
@@ -233,6 +233,23 @@ export class TaskExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle unpause of an in-progress task with no active session.
|
||||
// This covers orphaned states (e.g., engine restarted while task was
|
||||
// paused in-progress) where the task needs to resume execution.
|
||||
// The executing/executing guards prevent duplicate runs.
|
||||
if (!task.paused && task.column === "in-progress" && !this.activeSessions.has(task.id)) {
|
||||
if (!this.executing.has(task.id)) {
|
||||
executorLog.log(`Unpaused ${task.id} in-progress with no session — resuming execution`);
|
||||
try {
|
||||
await this.store.logEntry(task.id, "Resuming execution after unpause");
|
||||
} catch { /* non-critical */ }
|
||||
this.execute(task).catch((err) =>
|
||||
executorLog.error(`Failed to resume unpaused ${task.id}:`, err),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle steering comments - inject new ones into the running session
|
||||
// Only process if session is active (activeSessions check is sufficient
|
||||
// since entries are only added when a task is in-progress)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -89,6 +89,8 @@ export class Scheduler {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
/** The interval (ms) of the currently active `setInterval` timer. */
|
||||
private activePollMs: number | null = null;
|
||||
/** Tracks which task IDs are currently paused, to detect unpause transitions. */
|
||||
private pausedTaskIds = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
@@ -179,8 +181,24 @@ export class Scheduler {
|
||||
|
||||
/**
|
||||
* PR Monitoring: Start monitoring when PR is linked to an in-review task.
|
||||
* Also detects task-level unpause transitions and triggers immediate scheduling.
|
||||
*/
|
||||
this.store.on("task:updated", (task) => {
|
||||
// Track pause state transitions for event-driven scheduling on unpause.
|
||||
// When a previously-paused task is unpaused in a schedulable column,
|
||||
// trigger a scheduling pass immediately instead of waiting for the next
|
||||
// poll interval (up to 15 seconds).
|
||||
if (task.paused) {
|
||||
this.pausedTaskIds.add(task.id);
|
||||
} else if (this.pausedTaskIds.has(task.id)) {
|
||||
// Task was paused, now unpaused — trigger scheduling
|
||||
this.pausedTaskIds.delete(task.id);
|
||||
if (this.running && (task.column === "todo" || task.column === "triage")) {
|
||||
schedulerLog.log(`Task ${task.id} unpaused — triggering scheduling`);
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.options.prMonitor) return;
|
||||
if (task.column !== "in-review") return;
|
||||
if (!task.prInfo) return;
|
||||
|
||||
Reference in New Issue
Block a user