fix(heartbeat): per-agent HEARTBEAT.md and phase-aligned scheduler
Each agent now gets its own .fusion/agents/<id>/HEARTBEAT.md procedure
file instead of sharing a single project-wide file. A one-shot
migration in AgentStore.init() re-points existing agents off the legacy
shared path and copies the legacy file's contents into each agent's
new per-agent location so operator edits are preserved.
The HeartbeatTriggerScheduler now phase-aligns the first tick to
lastHeartbeatAt + intervalMs so a process restart resumes each agent's
existing schedule rather than waiting up to a full interval before
firing again. Overdue ticks fire promptly within a small jitter window
to avoid a thundering herd at boot.
Also fixes three pre-existing QuickChatFAB test failures introduced by
739e899b5: auto-select default model now switches to model mode whether
or not agents are present, the model tag only renders in model mode,
and one test scopes its option lookup to role="option" to disambiguate
the in-header tag from the dropdown entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4619,6 +4619,61 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("phase-aligns the first tick to lastHeartbeatAt when supplied", async () => {
|
||||
// Simulate: last tick was 4s ago, interval is 5s.
|
||||
// The next tick is due in 1s, not in a fresh full 5s window.
|
||||
vi.setSystemTime(new Date("2026-04-30T05:00:00.000Z"));
|
||||
const lastHeartbeatAt = new Date("2026-04-30T04:59:56.000Z").toISOString();
|
||||
|
||||
scheduler.registerAgent(
|
||||
"agent-001",
|
||||
{ heartbeatIntervalMs: 5000 },
|
||||
{ lastHeartbeatAt },
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
|
||||
// Subsequent ticks resume the steady cadence.
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fires promptly with jitter when lastHeartbeatAt is already overdue", async () => {
|
||||
// Interval is 60s but the last tick was 10 minutes ago — fire immediately
|
||||
// (within the OVERDUE_FIRE_JITTER_MS window) instead of waiting another
|
||||
// full 60s. This is the core fix for "agents look unresponsive after a
|
||||
// dashboard restart" — the previous setInterval-only scheduler would
|
||||
// have made the user wait a full interval before the catch-up tick.
|
||||
vi.setSystemTime(new Date("2026-04-30T05:00:00.000Z"));
|
||||
const lastHeartbeatAt = new Date("2026-04-30T04:50:00.000Z").toISOString();
|
||||
|
||||
scheduler.registerAgent(
|
||||
"agent-001",
|
||||
{ heartbeatIntervalMs: 60_000 },
|
||||
{ lastHeartbeatAt },
|
||||
);
|
||||
|
||||
// Jitter window is 5s; advance past it to guarantee the fire happens.
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to full-interval delay when lastHeartbeatAt is missing", async () => {
|
||||
// No options provided — preserves the original "wait one full interval"
|
||||
// behavior for agents that have never ticked.
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips tick when agent has active run", async () => {
|
||||
(store.getActiveHeartbeatRun as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
id: "run-active",
|
||||
|
||||
Reference in New Issue
Block a user