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
ad4db8243: 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:
gsxdsm
2026-04-29 23:08:21 -07:00
parent ad4db8243d
commit d81fc9b2c7
12 changed files with 358 additions and 61 deletions

View File

@@ -7,8 +7,9 @@
* edited as normal project files.
*/
import { mkdir, readFile, writeFile, readdir, unlink, rename } from "node:fs/promises";
import { basename, join, resolve } from "node:path";
import { mkdir, readFile, writeFile, readdir, unlink, rename, access } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { randomUUID, randomBytes, createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import type {
@@ -36,7 +37,7 @@ import type {
AgentRatingInput,
Task,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js";
import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
@@ -211,9 +212,92 @@ export class AgentStore extends EventEmitter {
* Should be called before other operations.
*/
async init(): Promise<void> {
const _ = this.db;
void this.db;
await mkdir(this.agentsDir, { recursive: true });
await this.importLegacyFileDataOnce();
await this.migrateHeartbeatProcedurePathOnce();
}
/**
* One-shot migration that re-points every non-ephemeral agent off the
* legacy shared `.fusion/HEARTBEAT.md` path onto their own per-agent
* `.fusion/agents/<id>/HEARTBEAT.md` file. The legacy file's contents
* are copied to the new location when present so operator edits are
* preserved across the upgrade. The legacy file itself is left in place
* — the migration is non-destructive in case the operator wants a
* reference copy.
*
* Idempotent: tracks completion in the `__meta` table and short-circuits
* on subsequent calls. Failures during file copy are logged via the
* legacy console (no log dependency in core) and do not block startup —
* the agent's `heartbeatProcedurePath` is still flipped, and the engine's
* heartbeat resolver will fall back to the built-in template until the
* file is seeded on next dashboard interaction.
*/
private async migrateHeartbeatProcedurePathOnce(): Promise<void> {
const migrationKey = "heartbeatProcedurePathPerAgent";
const migrationVersion = "1";
const row = this.db.prepare("SELECT value FROM __meta WHERE key = ?").get(migrationKey) as
| { value: string }
| undefined;
if (row?.value === migrationVersion) {
return;
}
// The legacy shared file lives at <projectRoot>/.fusion/HEARTBEAT.md.
// `this.rootDir` is already `<projectRoot>/.fusion`, so the file is just
// "HEARTBEAT.md" relative to it.
let legacyContent: string | null = null;
try {
legacyContent = await readFile(join(this.rootDir, "HEARTBEAT.md"), "utf-8");
} catch {
legacyContent = null;
}
const agents = await this.listAgents({ includeEphemeral: false });
let migratedCount = 0;
for (const agent of agents) {
if (agent.heartbeatProcedurePath !== DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
continue;
}
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id);
const newAbsPath = join(this.rootDir, "..", newRelPath);
// Best-effort copy of operator edits to the new per-agent location.
// Skip the write when the per-agent file already exists (someone
// could have set this up manually) so we never clobber it.
if (legacyContent !== null) {
try {
await mkdir(dirname(newAbsPath), { recursive: true });
// Only seed the file if it doesn't already exist.
try {
await access(newAbsPath, fsConstants.F_OK);
} catch {
await writeFile(newAbsPath, legacyContent, "utf-8");
}
} catch {
// Non-fatal — proceed with path flip even if the file copy failed.
}
}
const updated: Agent = {
...agent,
heartbeatProcedurePath: newRelPath,
updatedAt: new Date().toISOString(),
};
await this.writeAgent(updated);
migratedCount += 1;
}
this.db.prepare(`
INSERT INTO __meta (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
`).run(migrationKey, migrationVersion);
if (migratedCount > 0) {
this.db.bumpLastModified();
}
}
/**
@@ -376,11 +460,14 @@ export class AgentStore extends EventEmitter {
const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata);
// Default heartbeatProcedurePath for new non-ephemeral agents so operators
// get an editable HEARTBEAT.md file from day one. Ephemeral task workers
// skip this — they're short-lived and don't need persistent procedure files.
// get an editable HEARTBEAT.md file from day one. Each agent gets its
// own per-agent file (under `.fusion/agents/<id>/HEARTBEAT.md`) so
// tweaks to one agent's procedure do not bleed into the rest of the
// team. Ephemeral task workers skip this — they're short-lived and
// don't need persistent procedure files.
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
?? (ephemeral ? undefined : DEFAULT_HEARTBEAT_PROCEDURE_PATH);
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId));
const agent: Agent = {
id: agentId,

View File

@@ -1,4 +1,4 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {

View File

@@ -3141,14 +3141,34 @@ export interface AgentConfigRevision {
}
/**
* Project-relative default path for the per-tick heartbeat procedure markdown
* file. New non-ephemeral agents get this as their `heartbeatProcedurePath`,
* and the engine seeds the file with the built-in HEARTBEAT_PROCEDURE constant
* on first use so operators can edit it freely. Existing agents can be
* upgraded onto this path via the dashboard's "Upgrade Heartbeat" action.
* Legacy project-relative shared path for the heartbeat procedure markdown
* file. Older builds defaulted every non-ephemeral agent to this single
* file, which prevented per-agent customization. New code should use
* {@link getDefaultHeartbeatProcedurePath} instead. This constant is kept
* exported only so migrations can detect agents still pointing at the
* shared path and re-route them to their own per-agent file.
*
* @deprecated Use {@link getDefaultHeartbeatProcedurePath} for new agent
* creation and upgrade flows.
*/
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
/**
* Compute the project-relative default heartbeat procedure file path for a
* given agent. Each agent gets their own editable HEARTBEAT.md so operators
* can tune the per-tick procedure without changes leaking across the team.
*
* The path is laid out under `.fusion/agents/<agentId>/HEARTBEAT.md` so it
* lives alongside any other future per-agent assets and survives agent
* renames (which do not change the immutable agent id).
*/
export function getDefaultHeartbeatProcedurePath(agentId: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
}
return `.fusion/agents/${agentId}/HEARTBEAT.md`;
}
/** Extract trackable config fields from an Agent into a snapshot */
export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
return {