fix(FN-2116): use sqlite-backed agent storage
This commit is contained in:
@@ -3500,6 +3500,15 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
beforeEach(() => {
|
||||
callback = vi.fn().mockResolvedValue(undefined);
|
||||
store = {
|
||||
getAgent: vi.fn().mockResolvedValue({
|
||||
id: "agent-001",
|
||||
name: "Agent 001",
|
||||
role: "executor",
|
||||
state: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
metadata: {},
|
||||
}),
|
||||
getActiveHeartbeatRun: vi.fn().mockResolvedValue(null),
|
||||
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
|
||||
on: vi.fn(),
|
||||
|
||||
@@ -1604,6 +1604,8 @@ export class HeartbeatTriggerScheduler {
|
||||
private timers: Map<string, AgentTimer> = new Map();
|
||||
private running = false;
|
||||
private assignedListener: ((agent: import("@fusion/core").Agent, taskId: string) => void) | null = null;
|
||||
private updatedListener: ((agent: import("@fusion/core").Agent) => void) | null = null;
|
||||
private deletedListener: ((agentId: string) => void) | null = null;
|
||||
|
||||
constructor(store: AgentStore, callback: TriggerCallback, taskStore?: TaskStore) {
|
||||
this.store = store;
|
||||
@@ -1619,6 +1621,7 @@ export class HeartbeatTriggerScheduler {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.watchAssignments();
|
||||
this.watchAgentLifecycle();
|
||||
heartbeatLog.log("HeartbeatTriggerScheduler started");
|
||||
}
|
||||
|
||||
@@ -1631,6 +1634,7 @@ export class HeartbeatTriggerScheduler {
|
||||
|
||||
// Unwatch assignments
|
||||
this.unwatchAssignments();
|
||||
this.unwatchAgentLifecycle();
|
||||
|
||||
// Clear all timers
|
||||
for (const [agentId, timer] of this.timers) {
|
||||
@@ -1803,6 +1807,33 @@ export class HeartbeatTriggerScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private watchAgentLifecycle(): void {
|
||||
if (this.updatedListener || this.deletedListener) return;
|
||||
|
||||
this.updatedListener = (agent) => {
|
||||
if (agent.state === "terminated" || agent.runtimeConfig?.enabled === false) {
|
||||
this.unregisterAgent(agent.id);
|
||||
}
|
||||
};
|
||||
this.deletedListener = (agentId) => {
|
||||
this.unregisterAgent(agentId);
|
||||
};
|
||||
|
||||
this.store.on("agent:updated", this.updatedListener);
|
||||
this.store.on("agent:deleted", this.deletedListener);
|
||||
}
|
||||
|
||||
private unwatchAgentLifecycle(): void {
|
||||
if (this.updatedListener) {
|
||||
this.store.off("agent:updated", this.updatedListener);
|
||||
this.updatedListener = null;
|
||||
}
|
||||
if (this.deletedListener) {
|
||||
this.store.off("agent:deleted", this.deletedListener);
|
||||
this.deletedListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a timer tick for an agent.
|
||||
* Checks for active runs before invoking the callback.
|
||||
@@ -1811,6 +1842,18 @@ export class HeartbeatTriggerScheduler {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
const agent = await this.store.getAgent(agentId);
|
||||
if (!agent) {
|
||||
heartbeatLog.log(`Timer tick skipped for ${agentId} (agent missing)`);
|
||||
this.unregisterAgent(agentId);
|
||||
return;
|
||||
}
|
||||
if (agent.state === "terminated" || agent.runtimeConfig?.enabled === false) {
|
||||
heartbeatLog.log(`Timer tick skipped for ${agentId} (disabled or terminated)`);
|
||||
this.unregisterAgent(agentId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for active runs
|
||||
const activeRun = await this.store.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
|
||||
@@ -2865,6 +2865,48 @@ describe("TaskExecutor pause behavior", () => {
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Resuming execution after unpause", undefined, undefined);
|
||||
});
|
||||
|
||||
it("does not recursively resume when resume logging emits task updated", async () => {
|
||||
const store = createMockStore();
|
||||
const task = {
|
||||
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(),
|
||||
};
|
||||
|
||||
mockedCreateHaiAgent.mockImplementation(async () => ({
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
},
|
||||
}) as any);
|
||||
|
||||
let emittedUpdateFromLog = false;
|
||||
store.logEntry.mockImplementation(async (_id: string, action: string) => {
|
||||
if (action === "Resuming execution after unpause" && !emittedUpdateFromLog) {
|
||||
emittedUpdateFromLog = true;
|
||||
store._trigger("task:updated", { ...task, updatedAt: new Date().toISOString() });
|
||||
}
|
||||
});
|
||||
|
||||
new TaskExecutor(store, "/tmp/test");
|
||||
store._trigger("task:updated", task);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const resumeLogCalls = store.logEntry.mock.calls.filter(
|
||||
([id, action]: [string, string]) => id === "FN-001" && action === "Resuming execution after unpause",
|
||||
);
|
||||
expect(resumeLogCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clears stale failed state before resuming unpaused in-progress task", async () => {
|
||||
const store = createMockStore();
|
||||
|
||||
|
||||
@@ -398,6 +398,8 @@ export interface TaskExecutorOptions {
|
||||
export class TaskExecutor {
|
||||
private activeWorktrees = new Map<string, string>();
|
||||
private executing = new Set<string>();
|
||||
/** Tasks currently being prepared for unpause resume, before execute() has registered them. */
|
||||
private resumingUnpaused = new Set<string>();
|
||||
/** Completed orphan recovery tasks currently running during startup. */
|
||||
private recoveringCompleted = new Set<string>();
|
||||
/** Active agent sessions per task, used to terminate on pause and inject steering. */
|
||||
@@ -564,9 +566,15 @@ export class TaskExecutor {
|
||||
// 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)) {
|
||||
// The executing/resuming guards prevent duplicate runs.
|
||||
if (
|
||||
!task.paused
|
||||
&& task.column === "in-progress"
|
||||
&& !this.activeSessions.has(task.id)
|
||||
&& !this.activeStepExecutors.has(task.id)
|
||||
) {
|
||||
if (!this.executing.has(task.id) && !this.resumingUnpaused.has(task.id)) {
|
||||
this.resumingUnpaused.add(task.id);
|
||||
executorLog.log(`Unpaused ${task.id} in-progress with no session — resuming execution`);
|
||||
try {
|
||||
await this.clearResumeFailureState(task);
|
||||
@@ -574,9 +582,13 @@ export class TaskExecutor {
|
||||
} catch (clearErr) {
|
||||
executorLog.warn(`${task.id} clearResumeFailureState failed during unpause: ${clearErr instanceof Error ? clearErr.message : String(clearErr)}`);
|
||||
}
|
||||
this.execute(task).catch((err) =>
|
||||
executorLog.error(`Failed to resume unpaused ${task.id}:`, err),
|
||||
);
|
||||
this.execute(task)
|
||||
.catch((err) =>
|
||||
executorLog.error(`Failed to resume unpaused ${task.id}:`, err),
|
||||
)
|
||||
.finally(() => {
|
||||
this.resumingUnpaused.delete(task.id);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user