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:
gsxdsm
2026-08-15 20:17:07 -07:00
parent 2a9ae0aca3
commit d9fcabfaef
4 changed files with 86 additions and 16 deletions

View File

@@ -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.

View File

@@ -1738,7 +1738,7 @@ Per-agent overrides via `runtimeConfig`:
## 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.

View File

@@ -1,29 +1,72 @@
import { describe, expect, it, vi } from "vitest";
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";
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[]) {
const store = new AgentStore({ rootDir: process.cwd() }); const self = store as unknown as Record<string, unknown>;
self.listAgents = vi.fn(async () => agents);
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.writeAgent = vi.fn(async () => undefined);
self.createAgent = vi.fn(async (input: Record<string, unknown>) => {
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> };
}
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();
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]);
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 () => {
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();

View File

@@ -2168,10 +2168,10 @@ export class AgentStore extends EventEmitter {
}
/*
FNXC:MemoryAgent 2026-08-11-09:41:
Memory Keeper is custom because it is not a workflow-stage principal. Unlike the four routed
owners its heartbeat is enabled, while auto-claim remains off because it only maintains memory.
This runs during init, where createAgent name collisions would abort startup; preflight probes,
FNXC:MemoryAgent 2026-08-16-02:31:
Memory Keeper is custom because it is not a workflow-stage principal. Its heartbeat defaults off
until an operator opts in, while auto-claim remains off because it only maintains memory. This
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
agent untouched and the project runnable.
*/
@@ -2204,7 +2204,13 @@ export class AgentStore extends EventEmitter {
roles: [...BUILTIN_MEMORY_AGENT_DEFAULT.roles],
title: BUILTIN_MEMORY_AGENT_DEFAULT.title,
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,
soul: BUILTIN_MEMORY_AGENT_DEFAULT.soul,
bundleConfig: { ...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig, files: [...BUILTIN_MEMORY_AGENT_DEFAULT.bundleConfig.files] },
@@ -2219,7 +2225,19 @@ export class AgentStore extends EventEmitter {
return owner;
}
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> = {
roles: [...BUILTIN_MEMORY_AGENT_DEFAULT.roles],
role: "custom",
@@ -2229,7 +2247,9 @@ export class AgentStore extends EventEmitter {
instructionsText: owner.instructionsText?.trim() ? owner.instructionsText : BUILTIN_MEMORY_AGENT_DEFAULT.instructionsText,
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);
return owner;
};