diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index f7736ce50162..091211d16bf7 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -589,7 +589,7 @@ Selects the agent whose model and credentials own ambient OpenClaw system-agent } ``` -Delegated consults with a requesting agent keep that requester as their owner. When `agentId` is absent, a sole configured agent resolves implicitly; ambient consults in a multi-agent fleet fail with an actionable error. Upgrade-only ownership lives at `agents.defaults.authInheritance.agentId` for inherited credentials and `agents.defaults.sessionStore.agentId` for unscoped rows in a fixed `session.store`. +Delegated consults with a requesting agent keep that requester as their owner. When `agentId` is absent, a sole configured agent resolves implicitly; ambient consults in a multi-agent fleet fail with an actionable error. Upgrade-only ownership lives at `agents.defaults.authInheritance.agentId` for inherited credentials and `agents.defaults.sessionStore.agentId` for retired `main` session rows or unscoped rows in a fixed `session.store`. ### `agents.defaults.compaction` diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index defec1fa8ce2..d0fa776509b0 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -415,7 +415,7 @@ That stages grounded durable candidates into the short-term dreaming store while These migrations are best-effort and idempotent; doctor emits warnings when it leaves any legacy folders behind as backups. The Gateway/CLI also auto-migrates the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `openclaw doctor`. Talk provider/provider-map normalization compares by structural equality, so key-order-only diffs no longer trigger repeat no-op `doctor --fix` changes. - When an explicit roster no longer contains `main`, OpenClaw migrates durable `agent:main:*` SQLite rows only if the replacement owner is unambiguous: the sole roster member or the configured fixed-store owner in `agents.defaults.sessionStore.agentId`. Conflicting canonical or alias rows are preserved during startup and reported with a Doctor hint. `openclaw doctor --fix` first imports any legacy JSON session store, then keeps the winning canonical claim and renames each losing claim to `agent::legacy-main-conflict-` in its original database. Quarantine changes only the key; the entry and full transcript remain available for inspection or archival. + When an explicit roster no longer contains `main`, OpenClaw migrates durable `agent:main:*` SQLite rows only if the replacement owner is unambiguous: the sole roster member or the configured upgrade owner in `agents.defaults.sessionStore.agentId`. The explicit owner works for both per-agent and fixed session stores; fixed-store runtime ownership remains scoped to that physical store. Conflicting canonical or alias rows are preserved during startup and reported with a Doctor hint. `openclaw doctor --fix` first imports any legacy JSON session store, then keeps the winning canonical claim and renames each losing claim to `agent::legacy-main-conflict-` in its original database. Quarantine changes only the key; the entry and full transcript remain available for inspection or archival. diff --git a/src/config/schema.help.core.ts b/src/config/schema.help.core.ts index 510e697a445c..d4b5e7bbc154 100644 --- a/src/config/schema.help.core.ts +++ b/src/config/schema.help.core.ts @@ -338,9 +338,9 @@ export const CORE_FIELD_HELP: Record = { "agents.defaults.authInheritance.agentId": "Agent whose legacy credential store remains the inheritance source after default-marker retirement. Written automatically during upgrade when the former owner was not main.", "agents.defaults.sessionStore": - "Upgrade compatibility owner for a fixed legacy session store until its SQLite database records ownership.", + "Upgrade compatibility owner for retired main-agent rows and fixed legacy session stores.", "agents.defaults.sessionStore.agentId": - "Agent that owns unscoped rows in a fixed legacy session store after default-marker retirement. Written automatically during upgrade when the former owner was not main or the sole agent.", + "Agent that owns retired main-agent rows or unscoped rows in a fixed legacy session store after default-marker retirement. Written automatically during upgrade when the former owner was not main or the sole agent.", "talk.agentId": "Agent that owns Talk sessions created without an explicit agent-scoped session key.", }; diff --git a/src/config/sessions/legacy-main-session-migration.test.ts b/src/config/sessions/legacy-main-session-migration.test.ts index 4898ed5af1f2..1c94064bc22b 100644 --- a/src/config/sessions/legacy-main-session-migration.test.ts +++ b/src/config/sessions/legacy-main-session-migration.test.ts @@ -547,7 +547,7 @@ describe("legacy main session migration", () => { }); }); - it("uses an explicit fixed-store owner only when the multi-agent roster is unambiguous", async () => { + it("uses an explicit migration owner when the multi-agent roster is unambiguous", async () => { const resolved = createFixture({ agents: { ownership: "explicit", @@ -577,7 +577,7 @@ describe("legacy main session migration", () => { env: unresolved.env, mode: "detect", }); - const perAgentNotArmed = await migrateLegacyMainSessionKeys({ + const perAgentArmed = await migrateLegacyMainSessionKeys({ cfg: perAgentPinned.cfg, env: perAgentPinned.env, mode: "detect", @@ -588,13 +588,40 @@ describe("legacy main session migration", () => { armed: false, outcomes: [{ kind: "not-armed", detail: "owner-unresolved" }], }); - expect(perAgentNotArmed).toMatchObject({ - armed: false, - outcomes: [{ kind: "not-armed", detail: "owner-unresolved" }], - }); + expect(perAgentArmed).toMatchObject({ armed: true, ownerAgentId: "ops" }); expect(notArmed.warnings[0]).toContain("agents.defaults.sessionStore.agentId"); }); + it("uses an explicit migration owner for retired main rows in per-agent stores", async () => { + const fixture = createFixture({ + agents: { + ownership: "explicit", + defaults: { sessionStore: { agentId: "ops" } }, + entries: { ops: {}, research: {} }, + }, + }); + const mainPath = databasePath(fixture.stateDir, "main"); + seedClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }); + + const result = await migrateLegacyMainSessionKeys({ + cfg: fixture.cfg, + env: fixture.env, + mode: "automatic", + }); + + expect(result).toMatchObject({ armed: true, complete: true, ownerAgentId: "ops" }); + expect( + readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }), + ).toBeUndefined(); + expect( + readClaim({ + databaseAgentId: "ops", + databasePath: databasePath(fixture.stateDir, "ops"), + key: "agent:ops:chat", + }), + ).toBeDefined(); + }); + it("keeps automatic detection non-throwing for unreadable stores and treats ENOENT as absence", async () => { const absent = createFixture(); const unreadablePath = path.join(tempDirs.make("automatic-unreadable-"), "sessions.sqlite"); diff --git a/src/config/sessions/legacy-main-session-migration.ts b/src/config/sessions/legacy-main-session-migration.ts index d7eeb7b61642..9cb1e94642e6 100644 --- a/src/config/sessions/legacy-main-session-migration.ts +++ b/src/config/sessions/legacy-main-session-migration.ts @@ -38,7 +38,6 @@ import type { import { resolveSessionStorePathCore } from "./paths.js"; import { getSessionKysely } from "./session-accessor.sqlite-scope.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; -import { isPerAgentSessionStoreConfig } from "./session-store-config.js"; import { resolveAllAgentSessionStoreCandidateTargetsSync, resolveAgentSessionStoreTargetsSync, @@ -73,9 +72,7 @@ function resolveArmingDecision(cfg: OpenClawConfig, legacyAgentId: string): Armi if (sole && roster.has(normalizeAgentId(sole))) { return { armed: true, ownerAgentId: normalizeAgentId(sole) }; } - const sessionStoreOwner = !isPerAgentSessionStoreConfig(cfg.session?.store) - ? cfg.agents?.defaults?.sessionStore?.agentId?.trim() - : undefined; + const sessionStoreOwner = cfg.agents?.defaults?.sessionStore?.agentId?.trim(); if (sessionStoreOwner) { const normalized = normalizeAgentId(sessionStoreOwner); if (roster.has(normalized)) { diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 57787e90c1d0..81e1bd88e404 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -328,7 +328,7 @@ export type AgentDefaultsConfig = { authInheritance?: { agentId?: string; }; - /** Upgrade-only owner for legacy fixed session stores until SQLite records ownership. */ + /** Upgrade-only owner for retired main-agent rows and legacy fixed session stores. */ sessionStore?: { agentId?: string; };