fix(FN-2116): use sqlite-backed agent storage

This commit is contained in:
gsxdsm
2026-04-18 17:25:17 -07:00
parent e7a09c77a8
commit 301faefbd6
20 changed files with 587 additions and 451 deletions

View File

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

View File

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

View File

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

View File

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