FN-9112: default Memory Keeper heartbeats to disabled
Default built-in Memory Keeper agents to opt-in heartbeat scheduling while retaining operator choices. - Provision new Memory Keeper agents with heartbeat disabled and hourly scheduling preconfigured. - Preserve explicit heartbeat settings during startup convergence and avoid no-op rewrites. - Add provisioning coverage, operator documentation, and a release changeset. Files changed: .../fn-9112-memory-keeper-heartbeat-default-off.md | 7 +++ docs/agents.md | 2 +- .../__tests__/memory-agent-provisioning.test.ts | 59 +++++++++++++++++++--- packages/core/src/agents/agent-store.ts | 34 ++++++++++--- 4 files changed, 86 insertions(+), 16 deletions(-) Fusion-Task-Id: FN-9112 Fusion-Task-Lineage: 49edc6d9-3d9b-412f-91a5-c26f4a26a662 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Memory Keeper is now added to projects with its heartbeat off by default.
|
||||||
|
category: feature
|
||||||
|
dev: provisionBuiltinMemoryAgent now defaults enabled false and preserves existing runtimeConfig.enabled during convergence.
|
||||||
@@ -1738,7 +1738,7 @@ Per-agent overrides via `runtimeConfig`:
|
|||||||
|
|
||||||
## Memory Keeper (FN-8932)
|
## Memory Keeper (FN-8932)
|
||||||
|
|
||||||
Each project provisions a durable **Memory Keeper** custom agent for deterministic, hourly memory upkeep. It is heartbeat-enabled and has task auto-claim disabled, so it cannot claim board work or make product decisions. Provisioning identifies the owner by its provenance marker, not its display name: if an operator already owns `Memory Keeper`, Fusion creates `Memory Keeper (built-in)` instead; if both names are occupied, startup continues without a memory agent rather than renaming/adopting the operator agent or failing initialization.
|
Each project provisions a durable **Memory Keeper** custom agent for deterministic memory upkeep. It is heartbeat-disabled by default, so consolidation is opt-in: enable its heartbeat from Agent Detail when you want the hourly schedule to run. Task auto-claim remains disabled, and the one-hour heartbeat interval is preconfigured for that later opt-in, so it cannot claim board work or make product decisions. Provisioning identifies the owner by its provenance marker, not its display name: if an operator already owns `Memory Keeper`, Fusion creates `Memory Keeper (built-in)` instead; if both names are occupied, startup continues without a memory agent rather than renaming/adopting the operator agent or failing initialization. Later startups preserve an operator's explicit heartbeat choice in either direction.
|
||||||
|
|
||||||
When enabled, a heartbeat refreshes the knowledge graph incrementally, appends deterministic FNXC rationale decisions through recall deduplication, then merges rationale/file node identifiers into each resulting recall record. Cross-references only grow: the per-record PostgreSQL advisory lock reads, unions, and writes in one transaction, and equal unions perform no update. Pruning is intentionally out of scope. A fingerprint-stable graph, duplicate recall results, and equal cross-reference unions yield a no-write tick; an in-process `(agentId, projectId)` guard skips re-entry. The guard is defense-in-depth for manual callers and does not fence another process or CLI graph build.
|
When enabled, a heartbeat refreshes the knowledge graph incrementally, appends deterministic FNXC rationale decisions through recall deduplication, then merges rationale/file node identifiers into each resulting recall record. Cross-references only grow: the per-record PostgreSQL advisory lock reads, unions, and writes in one transaction, and equal unions perform no update. Pruning is intentionally out of scope. A fingerprint-stable graph, duplicate recall results, and equal cross-reference unions yield a no-write tick; an in-process `(agentId, projectId)` guard skips re-entry. The guard is defense-in-depth for manual callers and does not fence another process or CLI graph build.
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,72 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { AgentStore } from "../agent-store.js";
|
import { AgentStore } from "../agent-store.js";
|
||||||
import { BUILTIN_MEMORY_AGENT_FALLBACK_NAME, BUILTIN_MEMORY_AGENT_NAME, BUILTIN_MEMORY_AGENT_PROVENANCE_KEY } from "../memory-agent-defaults.js";
|
import { BUILTIN_MEMORY_AGENT_DEFAULT, BUILTIN_MEMORY_AGENT_FALLBACK_NAME, BUILTIN_MEMORY_AGENT_NAME, BUILTIN_MEMORY_AGENT_PROVENANCE_KEY } from "../memory-agent-defaults.js";
|
||||||
import type { Agent } from "../../types/agents/agents.js";
|
import type { Agent } from "../../types/agents/agents.js";
|
||||||
|
|
||||||
const agent = (name: string, metadata: Record<string, unknown> = {}): Agent => ({ id: name.toLowerCase().replaceAll(/[^a-z]/g, ""), name, role: "custom", roles: ["custom"], state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata, runtimeConfig: { enabled: true } } as Agent);
|
const heartbeatConfig = (enabled: boolean) => ({ enabled, autoClaimRelevantTasks: false, heartbeatIntervalMs: 3_600_000 });
|
||||||
|
const agent = (name: string, metadata: Record<string, unknown> = {}, runtimeConfig: Record<string, unknown> = { enabled: true }): Agent => ({ id: name.toLowerCase().replaceAll(/[^a-z]/g, ""), name, role: "custom", roles: ["custom"], state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", metadata, runtimeConfig } as Agent);
|
||||||
|
const memoryOwner = (enabled: boolean): Agent => ({
|
||||||
|
...agent(BUILTIN_MEMORY_AGENT_NAME, { [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true }, heartbeatConfig(enabled)),
|
||||||
|
title: BUILTIN_MEMORY_AGENT_DEFAULT.title,
|
||||||
|
instructionsText: BUILTIN_MEMORY_AGENT_DEFAULT.instructionsText,
|
||||||
|
soul: BUILTIN_MEMORY_AGENT_DEFAULT.soul,
|
||||||
|
bundleConfig: { ...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig, files: [...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig.files] },
|
||||||
|
});
|
||||||
function fakeStore(agents: Agent[]) {
|
function fakeStore(agents: Agent[]) {
|
||||||
const store = new AgentStore({ rootDir: process.cwd() }); const self = store as unknown as Record<string, unknown>;
|
const store = new AgentStore({ rootDir: process.cwd() }); const self = store as unknown as Record<string, unknown>;
|
||||||
self.listAgents = vi.fn(async () => agents);
|
self.listAgents = vi.fn(async () => agents);
|
||||||
self.findAgentByName = vi.fn(async (name: string) => agents.find((item) => item.name === name) ?? null);
|
self.findAgentByName = vi.fn(async (name: string) => agents.find((item) => item.name === name) ?? null);
|
||||||
self.createAgent = vi.fn(async (input: { name: string }) => { const created = agent(input.name, { [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true }); agents.push(created); return created; });
|
self.createAgent = vi.fn(async (input: Record<string, unknown>) => {
|
||||||
self.writeAgent = vi.fn(async () => undefined);
|
const created = { ...agent(input.name as string, input.metadata as Record<string, unknown>, input.runtimeConfig as Record<string, unknown>), ...input, id: (input.name as string).toLowerCase().replaceAll(/[^a-z]/g, ""), role: "custom", state: "idle", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" } as Agent;
|
||||||
|
agents.push(created);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
self.writeAgent = vi.fn(async (updated: Agent) => {
|
||||||
|
const index = agents.findIndex((item) => item.id === updated.id);
|
||||||
|
if (index >= 0) agents[index] = updated;
|
||||||
|
});
|
||||||
return store as AgentStore & { createAgent: ReturnType<typeof vi.fn>; writeAgent: ReturnType<typeof vi.fn> };
|
return store as AgentStore & { createAgent: ReturnType<typeof vi.fn>; writeAgent: ReturnType<typeof vi.fn> };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Memory Keeper provisioning", () => {
|
describe("Memory Keeper provisioning", () => {
|
||||||
it("creates exactly one custom, heartbeat-enabled owner", async () => {
|
it("creates exactly one custom, heartbeat-disabled owner", async () => {
|
||||||
const store = fakeStore([]); const first = await store.provisionBuiltinMemoryAgent(); const second = await store.provisionBuiltinMemoryAgent();
|
const store = fakeStore([]); const first = await store.provisionBuiltinMemoryAgent(); const second = await store.provisionBuiltinMemoryAgent();
|
||||||
expect(first?.id).toBe(second?.id); expect(store.createAgent).toHaveBeenCalledTimes(1);
|
expect(first?.id).toBe(second?.id); expect(store.createAgent).toHaveBeenCalledTimes(1);
|
||||||
expect(store.createAgent).toHaveBeenCalledWith(expect.objectContaining({ name: BUILTIN_MEMORY_AGENT_NAME, roles: ["custom"], runtimeConfig: expect.objectContaining({ enabled: true, autoClaimRelevantTasks: false, heartbeatIntervalMs: 3_600_000 }) }), undefined);
|
expect(store.createAgent).toHaveBeenCalledWith(expect.objectContaining({ name: BUILTIN_MEMORY_AGENT_NAME, roles: ["custom"], runtimeConfig: expect.objectContaining(heartbeatConfig(false)) }), undefined);
|
||||||
});
|
});
|
||||||
it("does not adopt an operator agent with the canonical name", async () => {
|
|
||||||
|
it("preserves an operator-disabled heartbeat during startup convergence", async () => {
|
||||||
|
const owner = memoryOwner(false);
|
||||||
|
const store = fakeStore([owner]);
|
||||||
|
const result = await store.provisionBuiltinMemoryAgent();
|
||||||
|
expect(result?.runtimeConfig).toEqual(heartbeatConfig(false));
|
||||||
|
expect(store.writeAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not disable an operator-enabled heartbeat during startup convergence", async () => {
|
||||||
|
const owner = memoryOwner(true);
|
||||||
|
const store = fakeStore([owner]);
|
||||||
|
const result = await store.provisionBuiltinMemoryAgent();
|
||||||
|
expect(result?.runtimeConfig).toEqual(heartbeatConfig(true));
|
||||||
|
expect(store.writeAgent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("converges a legacy owner without runtime config to the heartbeat-disabled default", async () => {
|
||||||
|
const owner = agent(BUILTIN_MEMORY_AGENT_NAME, { [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true });
|
||||||
|
delete (owner as { runtimeConfig?: Record<string, unknown> }).runtimeConfig;
|
||||||
|
const store = fakeStore([owner]);
|
||||||
|
const result = await store.provisionBuiltinMemoryAgent();
|
||||||
|
expect(result?.runtimeConfig).toEqual(heartbeatConfig(false));
|
||||||
|
expect(store.writeAgent).toHaveBeenCalledWith(expect.objectContaining({ runtimeConfig: heartbeatConfig(false) }), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the fallback name without enabling its heartbeat when the canonical name is operator-owned", async () => {
|
||||||
const operator = agent(BUILTIN_MEMORY_AGENT_NAME); const store = fakeStore([operator]);
|
const operator = agent(BUILTIN_MEMORY_AGENT_NAME); const store = fakeStore([operator]);
|
||||||
await store.provisionBuiltinMemoryAgent();
|
await store.provisionBuiltinMemoryAgent();
|
||||||
expect(operator.name).toBe(BUILTIN_MEMORY_AGENT_NAME); expect(store.createAgent).toHaveBeenCalledWith(expect.objectContaining({ name: BUILTIN_MEMORY_AGENT_FALLBACK_NAME }), undefined);
|
expect(operator.name).toBe(BUILTIN_MEMORY_AGENT_NAME);
|
||||||
|
expect(store.createAgent).toHaveBeenCalledWith(expect.objectContaining({ name: BUILTIN_MEMORY_AGENT_FALLBACK_NAME, runtimeConfig: expect.objectContaining(heartbeatConfig(false)) }), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("degrades safely when both reserved names are occupied", async () => {
|
it("degrades safely when both reserved names are occupied", async () => {
|
||||||
const store = fakeStore([agent(BUILTIN_MEMORY_AGENT_NAME), agent(BUILTIN_MEMORY_AGENT_FALLBACK_NAME)]);
|
const store = fakeStore([agent(BUILTIN_MEMORY_AGENT_NAME), agent(BUILTIN_MEMORY_AGENT_FALLBACK_NAME)]);
|
||||||
await expect(store.provisionBuiltinMemoryAgent()).resolves.toBeNull(); expect(store.createAgent).not.toHaveBeenCalled();
|
await expect(store.provisionBuiltinMemoryAgent()).resolves.toBeNull(); expect(store.createAgent).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -2168,10 +2168,10 @@ export class AgentStore extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:MemoryAgent 2026-08-11-09:41:
|
FNXC:MemoryAgent 2026-08-16-02:31:
|
||||||
Memory Keeper is custom because it is not a workflow-stage principal. Unlike the four routed
|
Memory Keeper is custom because it is not a workflow-stage principal. Its heartbeat defaults off
|
||||||
owners its heartbeat is enabled, while auto-claim remains off because it only maintains memory.
|
until an operator opts in, while auto-claim remains off because it only maintains memory. This
|
||||||
This runs during init, where createAgent name collisions would abort startup; preflight probes,
|
runs during init, where createAgent name collisions would abort startup; preflight probes,
|
||||||
a fallback name, a null degraded result, and the init-side catch keep an operator's same-named
|
a fallback name, a null degraded result, and the init-side catch keep an operator's same-named
|
||||||
agent untouched and the project runnable.
|
agent untouched and the project runnable.
|
||||||
*/
|
*/
|
||||||
@@ -2204,7 +2204,13 @@ export class AgentStore extends EventEmitter {
|
|||||||
roles: [...BUILTIN_MEMORY_AGENT_DEFAULT.roles],
|
roles: [...BUILTIN_MEMORY_AGENT_DEFAULT.roles],
|
||||||
title: BUILTIN_MEMORY_AGENT_DEFAULT.title,
|
title: BUILTIN_MEMORY_AGENT_DEFAULT.title,
|
||||||
metadata: { [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true },
|
metadata: { [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true },
|
||||||
runtimeConfig: { enabled: true, autoClaimRelevantTasks: false, heartbeatIntervalMs: 3_600_000 },
|
/*
|
||||||
|
FNXC:MemoryAgent 2026-08-16-02:31:
|
||||||
|
Memory consolidation is opt-in: newly provisioned Memory Keepers must not begin
|
||||||
|
hourly autonomous heartbeats until an operator enables the existing heartbeat toggle.
|
||||||
|
Keep auto-claim off and the one-hour interval ready for that later opt-in.
|
||||||
|
*/
|
||||||
|
runtimeConfig: { enabled: false, autoClaimRelevantTasks: false, heartbeatIntervalMs: 3_600_000 },
|
||||||
instructionsText: BUILTIN_MEMORY_AGENT_DEFAULT.instructionsText,
|
instructionsText: BUILTIN_MEMORY_AGENT_DEFAULT.instructionsText,
|
||||||
soul: BUILTIN_MEMORY_AGENT_DEFAULT.soul,
|
soul: BUILTIN_MEMORY_AGENT_DEFAULT.soul,
|
||||||
bundleConfig: { ...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig, files: [...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig.files] },
|
bundleConfig: { ...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig, files: [...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig.files] },
|
||||||
@@ -2219,7 +2225,19 @@ export class AgentStore extends EventEmitter {
|
|||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
const metadata = { ...(owner.metadata ?? {}), [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true };
|
const metadata = { ...(owner.metadata ?? {}), [BUILTIN_MEMORY_AGENT_PROVENANCE_KEY]: true };
|
||||||
const runtimeConfig = { ...(owner.runtimeConfig ?? {}), enabled: true, autoClaimRelevantTasks: false, heartbeatIntervalMs: 3_600_000 };
|
const currentRuntimeConfig = owner.runtimeConfig ?? {};
|
||||||
|
/*
|
||||||
|
FNXC:MemoryAgent 2026-08-16-02:31:
|
||||||
|
Startup convergence owns the safe maintenance defaults but never an operator's explicit
|
||||||
|
heartbeat choice. Preserve either boolean direction across init runs; missing legacy config
|
||||||
|
adopts the opt-in default without unnecessarily rewriting an already-converged owner.
|
||||||
|
*/
|
||||||
|
const enabled = typeof currentRuntimeConfig.enabled === "boolean" ? currentRuntimeConfig.enabled : false;
|
||||||
|
const runtimeConfig = currentRuntimeConfig.enabled === enabled
|
||||||
|
&& currentRuntimeConfig.autoClaimRelevantTasks === false
|
||||||
|
&& currentRuntimeConfig.heartbeatIntervalMs === DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS
|
||||||
|
? currentRuntimeConfig
|
||||||
|
: { ...currentRuntimeConfig, enabled, autoClaimRelevantTasks: false, heartbeatIntervalMs: DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS };
|
||||||
const updates: Partial<Agent> = {
|
const updates: Partial<Agent> = {
|
||||||
roles: [...BUILTIN_MEMORY_AGENT_DEFAULT.roles],
|
roles: [...BUILTIN_MEMORY_AGENT_DEFAULT.roles],
|
||||||
role: "custom",
|
role: "custom",
|
||||||
@@ -2229,7 +2247,9 @@ export class AgentStore extends EventEmitter {
|
|||||||
instructionsText: owner.instructionsText?.trim() ? owner.instructionsText : BUILTIN_MEMORY_AGENT_DEFAULT.instructionsText,
|
instructionsText: owner.instructionsText?.trim() ? owner.instructionsText : BUILTIN_MEMORY_AGENT_DEFAULT.instructionsText,
|
||||||
soul: owner.soul?.trim() ? owner.soul : BUILTIN_MEMORY_AGENT_DEFAULT.soul,
|
soul: owner.soul?.trim() ? owner.soul : BUILTIN_MEMORY_AGENT_DEFAULT.soul,
|
||||||
};
|
};
|
||||||
owner = { ...owner, ...updates, updatedAt: new Date().toISOString() };
|
const nextOwner = { ...owner, ...updates };
|
||||||
|
if (JSON.stringify(nextOwner) === JSON.stringify(owner)) return owner;
|
||||||
|
owner = { ...nextOwner, updatedAt: new Date().toISOString() };
|
||||||
await this.writeAgent(owner, executor);
|
await this.writeAgent(owner, executor);
|
||||||
return owner;
|
return owner;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user