fix(sessions): honor explicit legacy owner (#123887)

This commit is contained in:
Colin Johnson
2026-08-14 19:44:34 -04:00
committed by GitHub
parent ed106bdc7b
commit 722e1ff48e
6 changed files with 39 additions and 15 deletions
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -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:<owner>:legacy-main-conflict-<n>` 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:<owner>:legacy-main-conflict-<n>` in its original database. Quarantine changes only the key; the entry and full transcript remain available for inspection or archival.
</Accordion>
<Accordion title="3a. Legacy plugin manifest migrations">
+2 -2
View File
@@ -338,9 +338,9 @@ export const CORE_FIELD_HELP: Record<string, string> = {
"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.",
};
@@ -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");
@@ -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)) {
+1 -1
View File
@@ -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;
};