feat(FN-3033): align agent asset directory naming and heartbeat path compat

The merge delivers three major features: a droid CLI path reconciliation extension that resolves workspace path mismatches for spawned agents, session-first quick chat with improved heartbeat prompts and a dramatically simplified QuickChatFAB component, and canonical agent asset directory naming wit

Fusion-Task-Id: FN-3033
This commit is contained in:
Fusion
2026-05-01 12:51:23 -07:00
committed by gsxdsm
parent a6ae7b4e04
commit 80b45d0bd5
11 changed files with 392 additions and 36 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix per-agent filesystem defaults to use display-name-plus-id directories (for example `ceo-agent2736`) for heartbeat procedure files and managed instruction bundles, while preserving compatibility with legacy id-only and previously created display-name-based paths. Existing agent files are reused in place and are not auto-renamed or deleted during upgrades.

View File

@@ -227,9 +227,12 @@ The **Settings** tab in the Agent Detail modal includes a **Heartbeat Procedure*
### Relation to upgrade flow ### Relation to upgrade flow
- **Upgrade to Default Heartbeat Procedure** still sets `heartbeatProcedurePath` to: - Canonical per-agent asset directories now use **display name + immutable id suffix** (example: `ceo-agent2736`).
- `.fusion/agents/{agent.id}/HEARTBEAT.md` - Canonical heartbeat path example: `.fusion/agents/ceo-agent2736/HEARTBEAT.md`
- If the default file does not exist yet, the backend seeds it from the built-in template. - Canonical managed bundle directory example: `.fusion/agents/ceo-agent2736-instructions/`
- Legacy id-only paths (for example `.fusion/agents/{agent.id}/HEARTBEAT.md`) and previously created display-name-based paths remain supported.
- Upgrade/create flows preserve existing compatible files and directories in place; Fusion does **not** auto-rename or delete old paths.
- If the selected default file does not exist yet, the backend seeds it from the built-in template.
- After upgrade completes and the agent refreshes, operators can immediately open the seeded per-agent `HEARTBEAT.md` from the same modal section. - After upgrade completes and the agent refreshes, operators can immediately open the seeded per-agent `HEARTBEAT.md` from the same modal section.
## New Agent Presets (Dashboard UI) ## New Agent Presets (Dashboard UI)
@@ -245,7 +248,7 @@ The custom tab exposes separate fields for:
- **Title** (`title`) — optional role title/description - **Title** (`title`) — optional role title/description
- **Soul** (`soul`) — optional personality and communication style guidance - **Soul** (`soul`) — optional personality and communication style guidance
- **Heartbeat Procedure Path** (`heartbeatProcedurePath`) — optional path to the agent heartbeat markdown file, typically `.fusion/agents/<agent-id>/HEARTBEAT.md` - **Heartbeat Procedure Path** (`heartbeatProcedurePath`) — optional path to the agent heartbeat markdown file, typically `.fusion/agents/<display-name>-<agent-id>/HEARTBEAT.md` (legacy id-only paths remain valid)
- **Instructions Path** (`instructionsPath`) — optional file-backed instructions path - **Instructions Path** (`instructionsPath`) — optional file-backed instructions path
- **Inline Instructions** (`instructionsText`) — optional inline behavior instructions - **Inline Instructions** (`instructionsText`) — optional inline behavior instructions

View File

@@ -3,6 +3,11 @@ import { mkdtemp, rm, mkdir, writeFile, readFile, access } from "node:fs/promise
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { AgentStore } from "../agent-store.js"; import { AgentStore } from "../agent-store.js";
import {
getCanonicalAgentInstructionsBundleDirName,
getLegacyAgentInstructionsBundleDirName,
getSafeAgentAssetIdSegment,
} from "../types.js";
describe("AgentStore — instructions bundle", () => { describe("AgentStore — instructions bundle", () => {
let testDir: string; let testDir: string;
@@ -62,7 +67,9 @@ describe("AgentStore — instructions bundle", () => {
it("getInstructionsDir returns the managed bundle directory path", async () => { it("getInstructionsDir returns the managed bundle directory path", async () => {
const agent = await store.createAgent({ name: "dir-agent", role: "executor" }); const agent = await store.createAgent({ name: "dir-agent", role: "executor" });
createdAgentIds.push(agent.id); createdAgentIds.push(agent.id);
expect(store.getInstructionsDir(agent.id)).toBe(join(testDir, "agents", `${agent.id}-instructions`)); expect(store.getInstructionsDir(agent.id)).toBe(
join(testDir, "agents", getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id)),
);
}); });
it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => { it("listBundleFiles returns empty for missing directory and sorted .md files only", async () => {
@@ -273,4 +280,27 @@ describe("AgentStore — instructions bundle", () => {
expect(migrated.instructionsText).toBeUndefined(); expect(migrated.instructionsText).toBeUndefined();
expect(migrated.instructionsPath).toBeUndefined(); expect(migrated.instructionsPath).toBeUndefined();
}); });
it("uses existing legacy id-only instructions directory when present", async () => {
const agent = await store.createAgent({ name: "Legacy Bundle", role: "executor" });
createdAgentIds.push(agent.id);
const legacyDir = join(testDir, "agents", getLegacyAgentInstructionsBundleDirName(agent.id));
await mkdir(legacyDir, { recursive: true });
await writeFile(join(legacyDir, "AGENTS.md"), "legacy content", "utf-8");
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("legacy content");
});
it("uses previously-created display-name instructions directory for same id", async () => {
const agent = await store.createAgent({ name: "Current Name", role: "executor" });
createdAgentIds.push(agent.id);
const priorDirName = `previous-name-${getSafeAgentAssetIdSegment(agent.id)}-instructions`;
const priorDir = join(testDir, "agents", priorDirName);
await mkdir(priorDir, { recursive: true });
await writeFile(join(priorDir, "AGENTS.md"), "existing display path", "utf-8");
await expect(store.readBundleFile(agent.id, "AGENTS.md")).resolves.toBe("existing display path");
});
}); });

View File

@@ -18,7 +18,12 @@ import { join } from "node:path";
import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { CheckoutConflictError, type AgentCapability, type AgentState } from "../types.js"; import {
CheckoutConflictError,
getCanonicalAgentAssetDirectoryName,
type AgentCapability,
type AgentState,
} from "../types.js";
function makeTmpDir(): string { function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "fn-agent-store-test-")); return mkdtempSync(join(tmpdir(), "fn-agent-store-test-"));
@@ -150,6 +155,27 @@ describe("AgentStore", () => {
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN(); expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
}); });
it("defaults heartbeat procedure path to canonical display-name directory", async () => {
const agent = await store.createAgent({
name: "CEO",
role: "executor",
});
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
});
it("falls back to id-based segment when display-name slug is empty", async () => {
const agent = await store.createAgent({
name: "!!!",
role: "executor",
});
const expectedDir = getCanonicalAgentAssetDirectoryName(agent.name, agent.id);
expect(expectedDir).toContain("agent-");
expect(agent.heartbeatProcedurePath).toBe(`.fusion/agents/${expectedDir}/HEARTBEAT.md`);
});
it("preserves custom metadata", async () => { it("preserves custom metadata", async () => {
const agent = await store.createAgent({ const agent = await store.createAgent({
name: "With Meta", name: "With Meta",

View File

@@ -38,7 +38,19 @@ import type {
Task, Task,
AgentLogEntry, AgentLogEntry,
} from "./types.js"; } from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath } from "./types.js"; import {
AGENT_VALID_TRANSITIONS,
agentToConfigSnapshot,
diffConfigSnapshots,
isEphemeralAgent,
CheckoutConflictError,
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
getDefaultHeartbeatProcedurePath,
getCanonicalAgentInstructionsBundleDirName,
getLegacyAgentAssetDirectoryName,
getLegacyAgentInstructionsBundleDirName,
getSafeAgentAssetIdSegment,
} from "./types.js";
import type { RunMutationContext } from "./types.js"; import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js"; import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js"; import { computeAccessState } from "./agent-permissions.js";
@@ -262,7 +274,7 @@ export class AgentStore extends EventEmitter {
continue; continue;
} }
const newRelPath = getDefaultHeartbeatProcedurePath(agent.id); const newRelPath = await this.resolveCompatibleHeartbeatProcedurePath(agent);
const newAbsPath = join(this.rootDir, "..", newRelPath); const newAbsPath = join(this.rootDir, "..", newRelPath);
// Best-effort copy of operator edits to the new per-agent location. // Best-effort copy of operator edits to the new per-agent location.
@@ -468,7 +480,7 @@ export class AgentStore extends EventEmitter {
// don't need persistent procedure files. // don't need persistent procedure files.
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo }); const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId)); ?? (ephemeral ? undefined : getDefaultHeartbeatProcedurePath(agentId, input.name));
const agent: Agent = { const agent: Agent = {
id: agentId, id: agentId,
@@ -742,7 +754,9 @@ export class AgentStore extends EventEmitter {
* Does not create the directory. * Does not create the directory.
*/ */
getInstructionsDir(agentId: string): string { getInstructionsDir(agentId: string): string {
return this.getBundleDir(agentId); const agent = this.readAgent(agentId);
const agentName = agent?.name ?? "";
return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agentName, agentId));
} }
/** /**
@@ -750,7 +764,7 @@ export class AgentStore extends EventEmitter {
* Returns [] when the bundle directory does not exist. * Returns [] when the bundle directory does not exist.
*/ */
async listBundleFiles(agentId: string): Promise<string[]> { async listBundleFiles(agentId: string): Promise<string[]> {
const bundleDir = this.getBundleDir(agentId); const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
try { try {
const entries = await readdir(bundleDir, { withFileTypes: true }); const entries = await readdir(bundleDir, { withFileTypes: true });
@@ -771,7 +785,8 @@ export class AgentStore extends EventEmitter {
*/ */
async readBundleFile(agentId: string, filePath: string): Promise<string> { async readBundleFile(agentId: string, filePath: string): Promise<string> {
this.validateBundleFilePath(filePath); this.validateBundleFilePath(filePath);
const resolvedPath = join(this.getBundleDir(agentId), filePath); const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
const resolvedPath = join(bundleDir, filePath);
return readFile(resolvedPath, "utf-8"); return readFile(resolvedPath, "utf-8");
} }
@@ -782,7 +797,7 @@ export class AgentStore extends EventEmitter {
return this.withLock(agentId, async () => { return this.withLock(agentId, async () => {
this.validateBundleFilePath(filePath); this.validateBundleFilePath(filePath);
const bundleDir = this.getBundleDir(agentId); const bundleDir = await this.resolveCompatibleBundleDir(agentId, true);
await mkdir(bundleDir, { recursive: true }); await mkdir(bundleDir, { recursive: true });
const existingFiles = await this.listBundleFiles(agentId); const existingFiles = await this.listBundleFiles(agentId);
@@ -804,7 +819,8 @@ export class AgentStore extends EventEmitter {
async deleteBundleFile(agentId: string, filePath: string): Promise<void> { async deleteBundleFile(agentId: string, filePath: string): Promise<void> {
return this.withLock(agentId, async () => { return this.withLock(agentId, async () => {
this.validateBundleFilePath(filePath); this.validateBundleFilePath(filePath);
await unlink(join(this.getBundleDir(agentId), filePath)); const bundleDir = await this.resolveCompatibleBundleDir(agentId, false);
await unlink(join(bundleDir, filePath));
}); });
} }
@@ -831,7 +847,7 @@ export class AgentStore extends EventEmitter {
const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig }); const updated = await this.updateAgent(agentId, { bundleConfig: normalizedConfig });
if (normalizedConfig.mode === "managed") { if (normalizedConfig.mode === "managed") {
await mkdir(this.getBundleDir(agentId), { recursive: true }); await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
} }
return updated; return updated;
@@ -860,7 +876,7 @@ export class AgentStore extends EventEmitter {
}); });
} }
await mkdir(this.getBundleDir(agentId), { recursive: true }); await mkdir(await this.resolveCompatibleBundleDir(agentId, true), { recursive: true });
const files: string[] = []; const files: string[] = [];
@@ -2057,8 +2073,108 @@ export class AgentStore extends EventEmitter {
return null; return null;
} }
private getBundleDir(agentId: string): string { private getCanonicalBundleDir(agent: Agent): string {
return join(this.agentsDir, `${agentId}-instructions`); return join(this.agentsDir, getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id));
}
private getLegacyBundleDir(agentId: string): string {
return join(this.agentsDir, getLegacyAgentInstructionsBundleDirName(agentId));
}
private async resolveCompatibleBundleDir(agentId: string, createIfMissing: boolean): Promise<string> {
const agent = this.readAgent(agentId);
if (!agent) {
throw new Error(`Agent ${agentId} not found`);
}
const canonicalDir = this.getCanonicalBundleDir(agent);
if (await this.pathExists(canonicalDir)) {
return canonicalDir;
}
const compatibleDir = await this.findExistingDisplayNameBundleDir(agent);
if (compatibleDir) {
return compatibleDir;
}
const legacyDir = this.getLegacyBundleDir(agent.id);
if (await this.pathExists(legacyDir)) {
return legacyDir;
}
return createIfMissing ? canonicalDir : canonicalDir;
}
private async findExistingDisplayNameBundleDir(agent: Agent): Promise<string | null> {
const safeId = getSafeAgentAssetIdSegment(agent.id);
try {
const entries = await readdir(this.agentsDir, { withFileTypes: true });
const candidates = entries
.filter((entry) => entry.isDirectory() && entry.name.endsWith("-instructions"))
.map((entry) => entry.name)
.filter((name) => {
const base = name.slice(0, -"-instructions".length);
return base.endsWith(`-${safeId}`);
})
.sort((a, b) => a.localeCompare(b));
if (candidates.length === 0) {
return null;
}
const canonicalName = getCanonicalAgentInstructionsBundleDirName(agent.name, agent.id);
const selected = candidates.find((candidate) => candidate === canonicalName) ?? candidates[0];
return join(this.agentsDir, selected);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return null;
}
throw err;
}
}
private async resolveCompatibleHeartbeatProcedurePath(agent: Agent): Promise<string> {
const canonicalPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
const canonicalAbs = join(this.rootDir, "..", canonicalPath);
if (await this.pathExists(canonicalAbs)) {
return canonicalPath;
}
const safeId = getSafeAgentAssetIdSegment(agent.id);
try {
const entries = await readdir(this.agentsDir, { withFileTypes: true });
const compatibleDir = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.find((name) => name.endsWith(`-${safeId}`));
if (compatibleDir) {
const candidatePath = `.fusion/agents/${compatibleDir}/HEARTBEAT.md`;
if (await this.pathExists(join(this.rootDir, "..", candidatePath))) {
return candidatePath;
}
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
throw err;
}
}
const legacyPath = `.fusion/agents/${getLegacyAgentAssetDirectoryName(agent.id)}/HEARTBEAT.md`;
const legacyAbs = join(this.rootDir, "..", legacyPath);
if (await this.pathExists(legacyAbs)) {
return legacyPath;
}
return canonicalPath;
}
private async pathExists(path: string): Promise<boolean> {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
} }
private validateBundleFilePath(filePath: string): void { private validateBundleFilePath(filePath: string): void {

View File

@@ -3263,20 +3263,71 @@ export interface AgentConfigRevision {
*/ */
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md"; export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
function slugifyAgentAssetSegment(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function getSafeAgentAssetIdSegment(agentId: string): string {
const slug = slugifyAgentAssetSegment(agentId);
return slug || "agent";
}
/**
* Compute the canonical per-agent asset directory segment.
*
* Canonical format: `<slugged-display-name>-<safe-agent-id>`.
* Example: `CEO` + `agent2736` => `ceo-agent2736`.
*
* If the display-name slug is empty (for example name has only symbols), the
* id-derived segment is used as the directory prefix so the result is always
* filesystem-safe and non-empty.
*/
export function getCanonicalAgentAssetDirectoryName(agentName: string, agentId: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getCanonicalAgentAssetDirectoryName requires a non-empty agentId");
}
const safeId = getSafeAgentAssetIdSegment(agentId);
const nameSlug = slugifyAgentAssetSegment(agentName ?? "");
const prefix = nameSlug || safeId;
return `${prefix}-${safeId}`;
}
/** Legacy per-agent asset directory segment used by older builds. */
export function getLegacyAgentAssetDirectoryName(agentId: string): string {
if (!agentId || typeof agentId !== "string") {
throw new Error("getLegacyAgentAssetDirectoryName requires a non-empty agentId");
}
return agentId;
}
/** Canonical managed instruction bundle directory name for an agent. */
export function getCanonicalAgentInstructionsBundleDirName(agentName: string, agentId: string): string {
return `${getCanonicalAgentAssetDirectoryName(agentName, agentId)}-instructions`;
}
/** Legacy managed instruction bundle directory name used by older builds. */
export function getLegacyAgentInstructionsBundleDirName(agentId: string): string {
return `${getLegacyAgentAssetDirectoryName(agentId)}-instructions`;
}
/** /**
* Compute the project-relative default heartbeat procedure file path for a * Compute the project-relative default heartbeat procedure file path for a
* given agent. Each agent gets their own editable HEARTBEAT.md so operators * given agent. Each agent gets their own editable HEARTBEAT.md so operators
* can tune the per-tick procedure without changes leaking across the team. * 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 * The path is laid out under `.fusion/agents/<canonical-agent-dir>/HEARTBEAT.md`.
* 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 { export function getDefaultHeartbeatProcedurePath(agentId: string, agentName?: string): string {
if (!agentId || typeof agentId !== "string") { if (!agentId || typeof agentId !== "string") {
throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId"); throw new Error("getDefaultHeartbeatProcedurePath requires a non-empty agentId");
} }
return `.fusion/agents/${agentId}/HEARTBEAT.md`; const directory = agentName
? getCanonicalAgentAssetDirectoryName(agentName, agentId)
: getLegacyAgentAssetDirectoryName(agentId);
return `.fusion/agents/${directory}/HEARTBEAT.md`;
} }
/** Extract trackable config fields from an Agent into a snapshot */ /** Extract trackable config fields from an Agent into a snapshot */

View File

@@ -2500,8 +2500,21 @@ function HeartbeatProcedureSection({
const [fileLoadError, setFileLoadError] = useState<string | null>(null); const [fileLoadError, setFileLoadError] = useState<string | null>(null);
const [justSavedFile, setJustSavedFile] = useState(false); const [justSavedFile, setJustSavedFile] = useState(false);
const currentPath = agent.heartbeatProcedurePath?.trim(); const currentPath = agent.heartbeatProcedurePath?.trim();
const expectedDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`; const canonicalDefaultPath = `.fusion/agents/${agent.name
const onDefault = currentPath === expectedDefaultPath; .toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "agent"}-${agent.id
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || "agent"}/HEARTBEAT.md`;
const legacyDefaultPath = `.fusion/agents/${agent.id}/HEARTBEAT.md`;
const safeId = agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
const onDefault = Boolean(
currentPath
&& (currentPath === canonicalDefaultPath
|| currentPath === legacyDefaultPath
|| new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(currentPath)),
);
const hasFilePath = Boolean(currentPath); const hasFilePath = Boolean(currentPath);
const loadHeartbeatFile = useCallback(async (path: string) => { const loadHeartbeatFile = useCallback(async (path: string) => {
@@ -2575,9 +2588,10 @@ function HeartbeatProcedureSection({
<div className="config-section"> <div className="config-section">
<h3>Heartbeat Procedure</h3> <h3>Heartbeat Procedure</h3>
<p className="config-description"> <p className="config-description">
The per-tick procedure this agent runs every wake. Defaults to a project-level The per-tick procedure this agent runs every wake. Defaults to a per-agent
markdown file you can edit. Resets on every tick no need to restart the agent markdown file (for example <code>.fusion/agents/ceo-agent2736/HEARTBEAT.md</code>)
after editing. that you can edit. Legacy id-only default paths remain valid. Resets on every tick
no need to restart the agent after editing.
</p> </p>
<div className="config-fields"> <div className="config-fields">
<div className="config-field"> <div className="config-field">
@@ -2629,7 +2643,7 @@ function HeartbeatProcedureSection({
</button> </button>
<span className="config-hint"> <span className="config-hint">
Sets <code>heartbeatProcedurePath</code> to{" "} Sets <code>heartbeatProcedurePath</code> to{" "}
<code>{expectedDefaultPath}</code> <code>{canonicalDefaultPath}</code>
{" "}and seeds the file from the built-in template if it doesn't exist. {" "}and seeds the file from the built-in template if it doesn't exist.
Each agent gets its own per-agent file, so edits stay scoped to this agent. Each agent gets its own per-agent file, so edits stay scoped to this agent.
Operator edits to the file are preserved. Operator edits to the file are preserved.

View File

@@ -599,12 +599,12 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
id="agent-heartbeat-procedure-path" id="agent-heartbeat-procedure-path"
type="text" type="text"
className="input" className="input"
placeholder="e.g. .fusion/agents/my-agent/HEARTBEAT.md" placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
value={heartbeatProcedurePath} value={heartbeatProcedurePath}
onChange={e => setHeartbeatProcedurePath(e.target.value)} onChange={e => setHeartbeatProcedurePath(e.target.value)}
/> />
<p className="agent-dialog-optional agent-dialog-field-hint"> <p className="agent-dialog-optional agent-dialog-field-hint">
Path to the agent&apos;s heartbeat markdown file, typically .fusion/agents/&lt;agent-id&gt;/HEARTBEAT.md. Path to the agent&apos;s heartbeat procedure path, typically .fusion/agents/ceo-agent2736/HEARTBEAT.md. Legacy id-only default paths still work.
</p> </p>
</div> </div>
<div className="agent-dialog-field"> <div className="agent-dialog-field">
@@ -742,7 +742,7 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId, prefillD
id="agent-review-heartbeat-procedure-path" id="agent-review-heartbeat-procedure-path"
type="text" type="text"
className="input" className="input"
placeholder="e.g. .fusion/agents/my-agent/HEARTBEAT.md" placeholder="e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md"
value={heartbeatProcedurePath} value={heartbeatProcedurePath}
onChange={e => setHeartbeatProcedurePath(e.target.value)} onChange={e => setHeartbeatProcedurePath(e.target.value)}
/> />

View File

@@ -11,6 +11,7 @@ type AgentRecord = {
updatedAt: string; updatedAt: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
reportsTo?: string; reportsTo?: string;
heartbeatProcedurePath?: string;
}; };
const mockInit = vi.fn().mockResolvedValue(undefined); const mockInit = vi.fn().mockResolvedValue(undefined);
@@ -51,8 +52,11 @@ vi.mock("@fusion/core", () => {
prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args), prepareAgentCompaniesImport: (...args: unknown[]) => mockPrepareAgentCompaniesImport(...args),
AgentCompaniesParseError: MockAgentCompaniesParseError, AgentCompaniesParseError: MockAgentCompaniesParseError,
DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/HEARTBEAT.md", DEFAULT_HEARTBEAT_PROCEDURE_PATH: ".fusion/HEARTBEAT.md",
getDefaultHeartbeatProcedurePath: (agentId: string) => getDefaultHeartbeatProcedurePath: (agentId: string, agentName?: string) =>
`.fusion/agents/${agentId}/HEARTBEAT.md`, agentName
? `.fusion/agents/${agentName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}-${agentId}/HEARTBEAT.md`
: `.fusion/agents/${agentId}/HEARTBEAT.md`,
getSafeAgentAssetIdSegment: (agentId: string) => agentId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent",
}; };
}); });
@@ -99,6 +103,7 @@ describe("Agent skills routes", () => {
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
metadata: input.metadata ?? {}, metadata: input.metadata ?? {},
heartbeatProcedurePath: input.heartbeatProcedurePath,
}; };
agents.set(id, agent); agents.set(id, agent);
return agent; return agent;
@@ -184,6 +189,35 @@ describe("Agent skills routes", () => {
}); });
}); });
describe("POST /api/agents/:id/upgrade-heartbeat-procedure", () => {
it("preserves existing legacy default heartbeat path", async () => {
agents.set("agent-001", {
id: "agent-001",
name: "Legacy Agent",
role: "executor",
state: "idle",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
metadata: {},
heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md",
});
const response = await request(
app,
"POST",
"/api/agents/agent-001/upgrade-heartbeat-procedure",
undefined,
{},
);
expect(response.status).toBe(200);
expect(mockUpdateAgent).toHaveBeenCalledWith(
"agent-001",
expect.objectContaining({ heartbeatProcedurePath: ".fusion/agents/agent-001/HEARTBEAT.md" }),
);
});
});
describe("PATCH /api/agents/:id", () => { describe("PATCH /api/agents/:id", () => {
it("updates agent skills", async () => { it("updates agent skills", async () => {
agents.set("agent-001", { agents.set("agent-001", {

View File

@@ -10,6 +10,21 @@ interface AgentCoreRouteDeps {
validateAgentInstructionsPayload: (instructionsPath: unknown, instructionsText: unknown) => boolean; validateAgentInstructionsPayload: (instructionsPath: unknown, instructionsText: unknown) => boolean;
} }
function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent): boolean {
const trimmed = path?.trim();
if (!trimmed) {
return false;
}
if (trimmed === getDefaultHeartbeatProcedurePath(agent.id, agent.name)) {
return true;
}
if (trimmed === getDefaultHeartbeatProcedurePath(agent.id)) {
return true;
}
const safeId = (agent.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent");
return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed);
}
export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void { export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
const { router, getProjectContext, rethrowAsApiError } = ctx; const { router, getProjectContext, rethrowAsApiError } = ctx;
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps; const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
@@ -160,7 +175,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
// the per-agent default path (which createAgent fills in for // the per-agent default path (which createAgent fills in for
// non-ephemeral agents when no override is provided). Idempotent — // non-ephemeral agents when no override is provided). Idempotent —
// operator edits are kept. // operator edits are kept.
const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id); const expectedDefaultPath = getDefaultHeartbeatProcedurePath(agent.id, agent.name);
if (agent.heartbeatProcedurePath === expectedDefaultPath) { if (agent.heartbeatProcedurePath === expectedDefaultPath) {
try { try {
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE); await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), expectedDefaultPath, HEARTBEAT_PROCEDURE);
@@ -490,7 +505,9 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
throw notFound(`agent ${req.params.id} not found`); throw notFound(`agent ${req.params.id} not found`);
} }
const targetPath = getDefaultHeartbeatProcedurePath(req.params.id); const targetPath = isCompatibleDefaultHeartbeatPath(existing.heartbeatProcedurePath, existing)
? existing.heartbeatProcedurePath!
: getDefaultHeartbeatProcedurePath(existing.id, existing.name);
const filePath = await ensureDefaultHeartbeatProcedureFile( const filePath = await ensureDefaultHeartbeatProcedureFile(
scopedStore.getRootDir(), scopedStore.getRootDir(),
targetPath, targetPath,

View File

@@ -8,6 +8,8 @@ import {
resolveAgentInstructionsWithRatings, resolveAgentInstructionsWithRatings,
buildAgentChatPrompt, buildAgentChatPrompt,
buildSystemPromptWithInstructions, buildSystemPromptWithInstructions,
ensureDefaultHeartbeatProcedureFile,
resolveAgentHeartbeatProcedure,
} from "../agent-instructions.js"; } from "../agent-instructions.js";
function makeAgent(overrides: Partial<Agent> = {}): Agent { function makeAgent(overrides: Partial<Agent> = {}): Agent {
@@ -700,3 +702,61 @@ describe("diagnostics logging", () => {
expect(prompt).toContain("## Identity"); expect(prompt).toContain("## Identity");
}); });
}); });
describe("heartbeat procedure path compatibility", () => {
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "agent-heartbeat-proc-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
it("loads canonical display-name heartbeat procedure paths", async () => {
const relPath = ".fusion/agents/ceo-agent2736/HEARTBEAT.md";
await mkdir(join(testDir, ".fusion", "agents", "ceo-agent2736"), { recursive: true });
await writeFile(join(testDir, relPath), "Canonical heartbeat", "utf-8");
const content = await resolveAgentHeartbeatProcedure(
makeAgent({ id: "agent2736", heartbeatProcedurePath: relPath }),
testDir,
);
expect(content).toBe("Canonical heartbeat");
});
it("loads legacy id-only heartbeat procedure paths", async () => {
const relPath = ".fusion/agents/agent-legacy/HEARTBEAT.md";
await mkdir(join(testDir, ".fusion", "agents", "agent-legacy"), { recursive: true });
await writeFile(join(testDir, relPath), "Legacy heartbeat", "utf-8");
const content = await resolveAgentHeartbeatProcedure(
makeAgent({ id: "agent-legacy", heartbeatProcedurePath: relPath }),
testDir,
);
expect(content).toBe("Legacy heartbeat");
});
it("rejects traversal heartbeat procedure paths", async () => {
const content = await resolveAgentHeartbeatProcedure(
makeAgent({ heartbeatProcedurePath: "../outside.md" }),
testDir,
);
expect(content).toBeNull();
});
it("seeds default heartbeat procedure only for valid project-relative markdown paths", async () => {
const seeded = await ensureDefaultHeartbeatProcedureFile(
testDir,
".fusion/agents/ceo-agent2736/HEARTBEAT.md",
"Default",
);
expect(seeded).toBeTruthy();
const invalid = await ensureDefaultHeartbeatProcedureFile(testDir, "../outside.md", "Default");
expect(invalid).toBeNull();
});
});