feat(FN-2501): merge fusion/fn-2501

This commit is contained in:
gsxdsm
2026-04-25 14:24:35 -07:00
parent 633644a665
commit 1a8058f334
10 changed files with 231 additions and 40 deletions

View File

@@ -459,6 +459,27 @@ describe("InProcessRuntime", () => {
expect(registeredAgents).toContain(createdAgent.id);
});
it("does not register paused agents on startup", async () => {
await runtime.start();
const store = getAgentStore(runtime);
const pausedAgent = await store.createAgent({
name: "Paused Agent",
role: "executor",
runtimeConfig: { heartbeatIntervalMs: 30000, enabled: true },
});
await store.updateAgentState(pausedAgent.id, "active");
await store.updateAgentState(pausedAgent.id, "paused");
await runtime.stop();
runtime = new InProcessRuntime(buildTestConfig(testDir), mockCentralCore);
await runtime.start();
const scheduler = runtime.getTriggerScheduler();
expect(scheduler).toBeDefined();
expect(scheduler!.getRegisteredAgents()).not.toContain(pausedAgent.id);
});
it("routes assignment triggers through executeHeartbeat", async () => {
await runtime.start();
@@ -885,6 +906,59 @@ describe("InProcessRuntime", () => {
expect(scheduler!.getRegisteredAgents()).not.toContain(agent.id);
});
it("clears timers on pause and re-arms from resume without stale pre-pause firing", async () => {
const store = getAgentStore(runtime);
const monitor = runtime.getHeartbeatMonitor();
expect(monitor).toBeDefined();
const executeHeartbeatSpy = vi
.spyOn(monitor!, "executeHeartbeat")
.mockResolvedValue({ id: "run-resume-test" } as any);
const agent = await store.createAgent({
name: "resume-timer-agent",
role: "executor",
runtimeConfig: {
enabled: true,
heartbeatIntervalMs: 1000,
},
});
await store.updateAgentState(agent.id, "active");
const scheduler = runtime.getTriggerScheduler();
expect(scheduler).toBeDefined();
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
await vi.advanceTimersByTimeAsync(400);
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
await store.updateAgentState(agent.id, "paused");
expect(scheduler!.getRegisteredAgents()).not.toContain(agent.id);
// Advance beyond the original tick window; stale pre-pause timer must not fire.
await vi.advanceTimersByTimeAsync(800);
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
await store.updateAgentState(agent.id, "active");
expect(scheduler!.getRegisteredAgents()).toContain(agent.id);
// Resume should start a fresh interval from now, not from pre-pause start.
await vi.advanceTimersByTimeAsync(900);
expect(executeHeartbeatSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
await vi.waitFor(() => {
expect(executeHeartbeatSpy).toHaveBeenCalledTimes(1);
});
expect(executeHeartbeatSpy).toHaveBeenCalledWith(
expect.objectContaining({
agentId: agent.id,
source: "timer",
}),
);
});
it("removes event listeners when runtime is stopped", async () => {
// Create a new agent before stopping
const store = getAgentStore(runtime);

View File

@@ -492,15 +492,19 @@ export class InProcessRuntime
);
this.triggerScheduler.start();
// Dynamic registration follows per-agent heartbeat enablement.
// Non-ephemeral agents are managed unless runtimeConfig.enabled is
// explicitly false. Ephemeral/task-worker agents are never armed.
// Dynamic registration follows per-agent heartbeat enablement and tickable state.
// Non-ephemeral agents are managed unless runtimeConfig.enabled is explicitly false.
// Paused/error/terminated states are never timer-armed.
const isHeartbeatEnabledAgent = (agent: import("@fusion/core").Agent) =>
!isEphemeralAgent(agent) && agent.runtimeConfig?.enabled !== false;
const isTickableHeartbeatState = (state: import("@fusion/core").AgentState) =>
state === "active" || state === "running" || state === "idle";
const isTimerManagedAgent = (agent: import("@fusion/core").Agent) =>
isHeartbeatEnabledAgent(agent) && isTickableHeartbeatState(agent.state);
this.agentCreatedListener = (agent) => {
if (!this.triggerScheduler) return;
if (!isHeartbeatEnabledAgent(agent)) return;
if (!isTimerManagedAgent(agent)) return;
const rc = agent.runtimeConfig;
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,
@@ -512,7 +516,7 @@ export class InProcessRuntime
this.agentUpdatedListener = (agent) => {
if (!this.triggerScheduler) return;
if (!isHeartbeatEnabledAgent(agent)) {
if (!isTimerManagedAgent(agent)) {
this.triggerScheduler.unregisterAgent(agent.id);
runtimeLog.log(`Unregistered agent ${agent.id} from heartbeat triggers`);
return;
@@ -569,12 +573,12 @@ export class InProcessRuntime
};
this.agentStore.on("agent:stateChanged", this.ephemeralTerminationListener);
// Register existing non-ephemeral agents with heartbeat enabled.
// Register existing non-ephemeral, heartbeat-enabled agents in tickable states.
try {
const agents = await this.agentStore.listAgents();
let registeredCount = 0;
for (const agent of agents) {
if (!isHeartbeatEnabledAgent(agent)) continue;
if (!isTimerManagedAgent(agent)) continue;
const rc = agent.runtimeConfig;
this.triggerScheduler.registerAgent(agent.id, {
heartbeatIntervalMs: rc?.heartbeatIntervalMs as number | undefined,