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 e46d357a6a
commit e9dcca0a28
6 changed files with 345 additions and 1 deletions

View File

@@ -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", () => {

View File

@@ -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)

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();

View File

@@ -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;