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:
@@ -812,7 +812,13 @@ describe("AgentStore", () => {
|
|||||||
const result = await store.rollbackConfig(created.id, targetRevision.id);
|
const result = await store.rollbackConfig(created.id, targetRevision.id);
|
||||||
|
|
||||||
expect(result.agent.name).toBe("Rollback Me");
|
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.source).toBe("rollback");
|
||||||
expect(result.revision.rollbackToRevisionId).toBe(targetRevision.id);
|
expect(result.revision.rollbackToRevisionId).toBe(targetRevision.id);
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,41 @@ interface AgentLock {
|
|||||||
promise: Promise<unknown>;
|
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.
|
* AgentStore manages agent lifecycle with SQLite-backed persistence.
|
||||||
* Follows the same patterns as TaskStore for consistency.
|
* Follows the same patterns as TaskStore for consistency.
|
||||||
@@ -295,6 +330,16 @@ export class AgentStore extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new agent with "idle" state.
|
* 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
|
* @param input - Creation parameters
|
||||||
* @returns The created agent
|
* @returns The created agent
|
||||||
* @throws Error if input is invalid
|
* @throws Error if input is invalid
|
||||||
@@ -310,6 +355,9 @@ export class AgentStore extends EventEmitter {
|
|||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const agentId = `agent-${randomUUID().slice(0, 8)}`;
|
const agentId = `agent-${randomUUID().slice(0, 8)}`;
|
||||||
|
|
||||||
|
const metadata = input.metadata ?? {};
|
||||||
|
const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata);
|
||||||
|
|
||||||
const agent: Agent = {
|
const agent: Agent = {
|
||||||
id: agentId,
|
id: agentId,
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
@@ -317,11 +365,11 @@ export class AgentStore extends EventEmitter {
|
|||||||
state: "idle",
|
state: "idle",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
metadata: input.metadata ?? {},
|
metadata,
|
||||||
...(input.title && { title: input.title }),
|
...(input.title && { title: input.title }),
|
||||||
...(input.icon && { icon: input.icon }),
|
...(input.icon && { icon: input.icon }),
|
||||||
...(input.reportsTo && { reportsTo: input.reportsTo }),
|
...(input.reportsTo && { reportsTo: input.reportsTo }),
|
||||||
...(input.runtimeConfig && { runtimeConfig: input.runtimeConfig }),
|
...(runtimeConfig && { runtimeConfig }),
|
||||||
...(input.permissions && { permissions: input.permissions }),
|
...(input.permissions && { permissions: input.permissions }),
|
||||||
...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
|
...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
|
||||||
...(input.instructionsText && { instructionsText: input.instructionsText }),
|
...(input.instructionsText && { instructionsText: input.instructionsText }),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export {
|
|||||||
computeAccessState,
|
computeAccessState,
|
||||||
isValidPermission,
|
isValidPermission,
|
||||||
} from "./agent-permissions.js";
|
} 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 type { AgentStoreEvents } from "./agent-store.js";
|
||||||
export { ReflectionStore } from "./reflection-store.js";
|
export { ReflectionStore } from "./reflection-store.js";
|
||||||
export type { ReflectionStoreEvents } from "./reflection-store.js";
|
export type { ReflectionStoreEvents } from "./reflection-store.js";
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://localhost:3000",
|
"/api": {
|
||||||
|
target: `http://localhost:${process.env.FUSION_API_PORT ?? "4040"}`,
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user