diff --git a/.changeset/fn-9112-memory-keeper-heartbeat-default-off.md b/.changeset/fn-9112-memory-keeper-heartbeat-default-off.md new file mode 100644 index 0000000000..a0dff0b8ed --- /dev/null +++ b/.changeset/fn-9112-memory-keeper-heartbeat-default-off.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 0261f9dc69..87fc83873f 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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. diff --git a/packages/core/src/agents/__tests__/memory-agent-provisioning.test.ts b/packages/core/src/agents/__tests__/memory-agent-provisioning.test.ts index a7d5ff33cf..24feb40a63 100644 --- a/packages/core/src/agents/__tests__/memory-agent-provisioning.test.ts +++ b/packages/core/src/agents/__tests__/memory-agent-provisioning.test.ts @@ -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 = {}): 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 = {}, runtimeConfig: Record = { 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; 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) => { + const created = { ...agent(input.name as string, input.metadata as Record, input.runtimeConfig as Record), ...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; writeAgent: ReturnType }; } 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 }).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(); diff --git a/packages/core/src/agents/agent-store.ts b/packages/core/src/agents/agent-store.ts index 57e735d258..92c7d5ebba 100644 --- a/packages/core/src/agents/agent-store.ts +++ b/packages/core/src/agents/agent-store.ts @@ -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 = { 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; };