fix(FN-4249): complete Step 2 — rollback running agents on overlap requeue

Fusion-Task-Id: FN-4249
Fusion-Task-Lineage: e0052fae-38e1-4d66-8240-43d26f5790bb
This commit is contained in:
Fusion
2026-05-12 22:04:00 -07:00
committed by gsxdsm
parent eff7028d9e
commit 7006a083d0
3 changed files with 29 additions and 1 deletions

View File

@@ -79,6 +79,11 @@ function createAgentStore(agents: MutableAgent[]): AgentStore {
return {
getAgent: vi.fn(async (id: string) => byId.get(id) ?? null),
listAgents: vi.fn(async (filters?: { state?: Agent["state"] }) => {
const agents = Array.from(byId.values());
if (filters?.state) return agents.filter((agent) => agent.state === filters.state);
return agents;
}),
updateAgentState: vi.fn(async (id: string, state: Agent["state"]) => {
const existing = byId.get(id);
if (!existing) return;
@@ -127,9 +132,10 @@ describe("scheduler overlap requeue agent-state invariant (FN-4249)", () => {
await workerManager.onTaskStart(queuedCandidate);
const scheduler = new Scheduler(taskStore);
const scheduler = new Scheduler(taskStore, { agentStore });
(scheduler as { running: boolean }).running = true;
await scheduler.schedule();
await scheduler.schedule();
const agent = await agentStore.getAgent("agent-assigned") as MutableAgent;
const task = await taskStore.getTask("FN-100");
@@ -138,5 +144,8 @@ describe("scheduler overlap requeue agent-state invariant (FN-4249)", () => {
expect(task?.status).toBe("queued");
expect(agent.state).toBe("active");
expect(agent.executionTaskId ?? null).toBeNull();
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-assigned", "active");
expect(agentStore.updateAgentState).toHaveBeenCalledWith("agent-assigned", "running");
expect(agentStore.updateAgentState).toHaveBeenCalledTimes(2);
});
});

View File

@@ -301,6 +301,7 @@ export class InProcessRuntime
maxConcurrent: this.config.maxConcurrent,
maxWorktrees: this.config.maxWorktrees,
semaphore: this.globalSemaphore,
agentStore: this.agentStore,
missionStore,
missionAutopilot,
missionExecutionLoop,

View File

@@ -7,6 +7,7 @@ import {
type MissionStore,
type MissionFeature,
type PrInfo,
type AgentStore,
} from "@fusion/core";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
@@ -108,6 +109,8 @@ export interface SchedulerOptions {
* agents that also hold slots).
*/
semaphore?: AgentSemaphore;
/** Optional AgentStore for durable-agent state rollback during overlap requeue. */
agentStore?: AgentStore;
/** Called when scheduler starts a task */
onSchedule?: (task: Task) => void;
/** Called when a task is blocked by deps */
@@ -485,6 +488,20 @@ export class Scheduler {
await this.store.logEntry(taskId, reason);
}
private async rollbackRunningAgentsForQueuedTodoTask(taskId: string): Promise<void> {
const agentStore = this.options.agentStore;
if (!agentStore) return;
const runningAgents = await agentStore.listAgents({ state: "running", includeEphemeral: true });
const linkedAgents = runningAgents.filter((agent) => agent.executionTaskId === taskId);
for (const agent of linkedAgents) {
await agentStore.updateAgentState(agent.id, "active");
await agentStore.syncExecutionTaskLink(agent.id, null);
schedulerLog.log(`Rolled back running agent ${agent.id} after overlap requeue of ${taskId}`);
}
}
/**
* If `newIntervalMs` differs from the currently active timer, restart
* the `setInterval` so the new cadence takes effect immediately.
@@ -847,6 +864,7 @@ export class Scheduler {
if (task.status !== "queued" || task.blockedBy !== targetBlockedBy) {
await this.store.updateTask(task.id, { status: "queued", blockedBy: targetBlockedBy });
}
await this.rollbackRunningAgentsForQueuedTodoTask(task.id);
await this.logDispatchQueuedReason(task.id, `queued — file scope overlap with ${overlappingTaskId}`);
continue;
}