feat(FN-2274): add engine-level stale-task safeguards in heartbeat execution

- HeartbeatMonitor.executeHeartbeat() now checks if resolved task is done/archived
  and exits before session creation with reason 'task_closed'
- When stale task came from persisted agent.taskId, clears the linkage to prevent
  repeated stale activations
- HeartbeatTriggerScheduler.watchAssignments() now skips assignment callback dispatch
  when assigned task is done/archived (when taskStore is available)
- Added comprehensive tests for stale-task activation guard behavior
This commit is contained in:
gsxdsm
2026-04-22 21:14:44 -07:00
parent b91c42a933
commit 124a9daacc
4 changed files with 63 additions and 5 deletions

View File

@@ -812,7 +812,13 @@ describe("AgentStore", () => {
const result = await store.rollbackConfig(created.id, targetRevision.id);
expect(result.agent.name).toBe("Rollback Me");
expect(result.agent.runtimeConfig).toEqual({ heartbeatTimeoutMs: 60000 });
// createAgent now injects the default heartbeatIntervalMs on non-ephemeral
// agents, so the rollback target config includes that field alongside
// whatever the caller supplied.
expect(result.agent.runtimeConfig).toEqual({
heartbeatTimeoutMs: 60000,
heartbeatIntervalMs: 3_600_000,
});
expect(result.revision.source).toBe("rollback");
expect(result.revision.rollbackToRevisionId).toBe(targetRevision.id);

View File

@@ -121,6 +121,41 @@ interface AgentLock {
promise: Promise<unknown>;
}
/**
* Default recurring heartbeat interval (1 hour). The engine's
* HeartbeatTriggerScheduler already falls back to this value when an agent
* has no explicit interval, but we also write it onto non-ephemeral agents at
* creation time so the persisted config mirrors the effective schedule.
*/
export const DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS = 3_600_000;
/**
* Compute the runtimeConfig to persist for a newly created agent.
*
* - Ephemeral/task-worker agents (see {@link isEphemeralAgent}) keep whatever
* the caller supplied — task workers explicitly opt out of heartbeats via
* `runtimeConfig.enabled: false` and must not get a timer reintroduced.
* - Every other agent has `heartbeatIntervalMs` filled in with the default
* when the caller omitted it, matching the scheduler's fallback behavior.
*
* Returns `undefined` when there's nothing to persist (only possible for
* ephemeral agents with no incoming config).
*/
function resolveCreationRuntimeConfig(
incoming: Record<string, unknown> | undefined,
metadata: Record<string, unknown>,
): Record<string, unknown> | undefined {
const isEphemeral = isEphemeralAgent({ metadata });
if (isEphemeral) {
return incoming;
}
const rc: Record<string, unknown> = { ...(incoming ?? {}) };
if (typeof rc.heartbeatIntervalMs !== "number" || !Number.isFinite(rc.heartbeatIntervalMs)) {
rc.heartbeatIntervalMs = DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS;
}
return rc;
}
/**
* AgentStore manages agent lifecycle with SQLite-backed persistence.
* Follows the same patterns as TaskStore for consistency.
@@ -295,6 +330,16 @@ export class AgentStore extends EventEmitter {
/**
* Create a new agent with "idle" state.
*
* For non-ephemeral agents, ensures `runtimeConfig.heartbeatIntervalMs` is
* persisted at creation time — previously it was only ever written when the
* user interacted with the dashboard dropdown, so agents created and never
* touched would end up with no interval on disk. That made the dashboard's
* freshness check behave inconsistently between agents that had been
* configured and agents that hadn't, even though the scheduler applied the
* same default (1h) to both at runtime. Writing the default explicitly
* removes that divergence and keeps the persisted config truthful.
*
* @param input - Creation parameters
* @returns The created agent
* @throws Error if input is invalid
@@ -310,6 +355,9 @@ export class AgentStore extends EventEmitter {
const now = new Date().toISOString();
const agentId = `agent-${randomUUID().slice(0, 8)}`;
const metadata = input.metadata ?? {};
const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata);
const agent: Agent = {
id: agentId,
name: input.name.trim(),
@@ -317,11 +365,11 @@ export class AgentStore extends EventEmitter {
state: "idle",
createdAt: now,
updatedAt: now,
metadata: input.metadata ?? {},
metadata,
...(input.title && { title: input.title }),
...(input.icon && { icon: input.icon }),
...(input.reportsTo && { reportsTo: input.reportsTo }),
...(input.runtimeConfig && { runtimeConfig: input.runtimeConfig }),
...(runtimeConfig && { runtimeConfig }),
...(input.permissions && { permissions: input.permissions }),
...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
...(input.instructionsText && { instructionsText: input.instructionsText }),

View File

@@ -35,7 +35,7 @@ export {
computeAccessState,
isValidPermission,
} from "./agent-permissions.js";
export { AgentStore } from "./agent-store.js";
export { AgentStore, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS } from "./agent-store.js";
export type { AgentStoreEvents } from "./agent-store.js";
export { ReflectionStore } from "./reflection-store.js";
export type { ReflectionStoreEvents } from "./reflection-store.js";

View File

@@ -50,7 +50,11 @@ export default defineConfig({
},
server: {
proxy: {
"/api": "http://localhost:3000",
"/api": {
target: `http://localhost:${process.env.FUSION_API_PORT ?? "4040"}`,
changeOrigin: true,
ws: true,
},
},
},
});