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)
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user