fix(sessions): retain assigned owners during doctor and startup repairs (#127706)

This commit is contained in:
Peter Steinberger
2026-08-21 17:33:49 -07:00
committed by GitHub
parent 7e84b2c722
commit 980e67e2ae
9 changed files with 296 additions and 92 deletions
@@ -2,11 +2,13 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { resolveSessionStorePathCore } from "../config/sessions/paths.js";
import {
assignSessionOwner,
loadExactSessionEntryReadOnly,
loadTranscriptEvents,
} from "../config/sessions/session-accessor.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../state/openclaw-agent-db-additive-columns.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
@@ -42,6 +44,116 @@ function insertEmptyAlias(params: {
}
describe("doctor transcript owner repair", () => {
it.each([
{ label: "replaces a stale same-store owner", sourceAgentId: "main", winnerOwned: true },
{ label: "clears a stale same-store owner", sourceAgentId: "main", winnerOwned: false },
{ label: "lazily restores cross-store owner columns", sourceAgentId: "ops", winnerOwned: true },
{
label: "preserves an owner while repairing malformed session metadata",
sourceAgentId: "main",
winnerOwned: true,
malformed: true,
},
])("$label from the selected canonical-repair winner", async (fixture) => {
const { sourceAgentId, winnerOwned } = fixture;
await withStateDirEnv("openclaw-doctor-assigned-owner-", async ({ stateDir }) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const storeTemplate = path.join(stateDir, "agents", "{agentId}", "sessions.json");
const destinationStore = resolveSessionStorePathCore(storeTemplate, { agentId: "main", env });
const sourceStore = resolveSessionStorePathCore(storeTemplate, {
agentId: sourceAgentId,
env,
});
const canonicalKey = "agent:main:work";
const winnerKey = "agent:main:main";
const cfg = {
agents: {
list: [
{ id: "main", default: true },
...(sourceAgentId === "ops" ? [{ id: "ops" }] : []),
],
},
session: { mainKey: "work", store: storeTemplate },
} as OpenClawConfig;
if (sourceAgentId === "main") {
insertLegacySession({
agentId: "main",
entry: { sessionId: "stale-destination", updatedAt: 10 },
env,
sessionKey: canonicalKey,
storePath: destinationStore,
});
assignSessionOwner(
{ agentId: "main", env, sessionKey: canonicalKey, storePath: destinationStore },
{
owner: { type: "human", id: "profile-stale" },
assignedBy: { type: "human", id: "profile-stale-assigner" },
assignedAt: 10,
},
);
}
insertLegacySession({
agentId: sourceAgentId,
entry: { sessionId: "selected-winner", updatedAt: 20 },
env,
sessionKey: winnerKey,
storePath: sourceStore,
});
const owner = winnerOwned
? assignSessionOwner(
{ agentId: sourceAgentId, env, sessionKey: winnerKey, storePath: sourceStore },
{
owner: { type: "human", id: "profile-winner" },
assignedBy: { type: "agent", id: "research" },
assignedAt: 1234,
},
)
: undefined;
if ("malformed" in fixture) {
openOpenClawAgentDatabase({
agentId: sourceAgentId,
env,
path: resolveSqliteTargetFromSessionStorePath(sourceStore, {
agentId: sourceAgentId,
env,
}).path,
})
.db.prepare("UPDATE session_nodes SET entry_json = ? WHERE session_key = ?")
.run("{malformed", winnerKey);
}
if (sourceAgentId === "ops") {
const database = openOpenClawAgentDatabase({
agentId: "main",
env,
path: resolveSqliteTargetFromSessionStorePath(destinationStore, {
agentId: "main",
env,
}).path,
});
for (const { columnName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) {
database.db.exec(`ALTER TABLE session_nodes DROP COLUMN ${columnName};`);
}
}
expect(await repairCanonicalSessionKeys({ apply: true, cfg, env })).toMatchObject({
foundGroups: 1,
repairedGroups: 1,
});
expect(
loadExactSessionEntryReadOnly({
agentId: "main",
env,
sessionKey: canonicalKey,
storePath: destinationStore,
})?.entry.owner,
).toEqual(owner ?? undefined);
});
});
it("restores a valid node after an empty alias steals its transcript window", async () => {
await withStateDirEnv("openclaw-doctor-transcript-owner-", async ({ stateDir }) => {
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
@@ -14,6 +14,7 @@ import {
copySessionNodeArtifactsForRepair,
deleteSessionMembersForRepair,
} from "../config/sessions/session-accessor.sqlite-node-artifacts.js";
import { replaceSessionOwnerInTransaction } from "../config/sessions/session-accessor.sqlite-owner.js";
import { collectSessionStateIdsForEntry } from "../config/sessions/session-accessor.sqlite-references.js";
import { resolveSqliteTranscriptArchiveDirectory } from "../config/sessions/session-accessor.sqlite-scope.js";
import { setCanonicalSqliteSessionMainKey } from "../config/sessions/session-canonical-key.js";
@@ -257,6 +258,11 @@ function applyCanonicalDestinationArtifacts(params: {
rehomeDeliveries: boolean;
winner: CanonicalSessionCandidate;
}): void {
replaceSessionOwnerInTransaction(
params.database,
params.winner.canonicalKey,
params.winner.entry.owner,
);
const destinationAliasKeys = listCanonicalDestinationAliasKeys(
params.destinationStore,
params.winner,
@@ -23,6 +23,7 @@ import {
} from "./session-accessor.sqlite-entry-store.js";
import { importSqliteSessionRows } from "./session-accessor.sqlite-import.js";
import { deleteSessionEntryLifecycle } from "./session-accessor.sqlite-lifecycle.js";
import { replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner.js";
import { getSessionKysely } from "./session-accessor.sqlite-scope.js";
import type { SessionEntry } from "./types.js";
@@ -108,6 +109,18 @@ export function warningForDivergence(
return `session: ${kind} for ${canonicalKey}; preserved claims ${claimsText}. Run openclaw doctor --fix to quarantine the losing claims.`;
}
function writeMigratedSessionClaim(
database: OpenClawAgentDatabase,
sessionKey: string,
entry: SessionEntry,
): void {
writeSessionEntry(database, sessionKey, entry, {
allowStoredAliases: true,
previousEntry: null,
});
replaceSessionOwnerInTransaction(database, sessionKey, entry.owner);
}
function migrateClaimsInPlace(params: {
aliases: readonly SessionClaim[];
canonical?: SessionClaim;
@@ -135,10 +148,7 @@ function migrateClaimsInPlace(params: {
return;
}
if (!currentCanonical) {
writeSessionEntry(database, params.canonicalKey, params.winner.entry, {
allowStoredAliases: true,
previousEntry: null,
});
writeMigratedSessionClaim(database, params.canonicalKey, params.winner.entry);
}
deleteLegacySessionEntryRows(
database,
@@ -225,10 +235,7 @@ function quarantineClaim(params: {
break;
}
}
writeSessionEntry(database, quarantineKey, params.claim.entry, {
allowStoredAliases: true,
previousEntry: null,
});
writeMigratedSessionClaim(database, quarantineKey, params.claim.entry);
deleteLegacySessionEntryRows(database, [params.claim.key], quarantineKey, {
rehomeMembers: true,
});
@@ -14,6 +14,7 @@ import {
import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js";
import type { OpenClawConfig } from "../types.openclaw.js";
import { migrateLegacyMainSessionKeys } from "./legacy-main-session-migration.js";
import { assignSessionOwner } from "./session-accessor.js";
import { readExactSessionEntryRowForCanonicalRepair } from "./session-accessor.sqlite-canonical-repair.js";
import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js";
import { readTranscriptEventRows } from "./session-accessor.sqlite-read.js";
@@ -21,6 +22,11 @@ import { appendTranscriptEventInTransaction } from "./session-accessor.sqlite-tr
import type { SessionEntry } from "./types.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const humanOwner = {
actor: { type: "human", id: "alice" },
assignedBy: { type: "human", id: "bob" },
assignedAt: 123,
} as const;
type Fixture = {
cfg: OpenClawConfig;
@@ -48,6 +54,19 @@ function databasePath(stateDir: string, agentId: string): string {
return path.join(stateDir, "agents", agentId, "agent", "openclaw-agent.sqlite");
}
function assignHumanOwner(storePath: string): void {
expect(
assignSessionOwner(
{ agentId: "main", sessionKey: "agent:main:chat", storePath },
{
owner: humanOwner.actor,
assignedBy: humanOwner.assignedBy,
assignedAt: humanOwner.assignedAt,
},
),
).toEqual(humanOwner);
}
function seedClaim(params: {
databaseAgentId: string;
databasePath: string;
@@ -400,6 +419,41 @@ describe("legacy main session migration", () => {
).toBeDefined();
});
it.each([
{ kind: "migrated-in-place", sharedStore: true },
{ kind: "migrated-cross-store", sharedStore: false },
])("preserves the assigned human owner when $kind", async ({ kind, sharedStore }) => {
const storePath = sharedStore
? path.join(tempDirs.make("owned-in-place-migration-"), "sessions.sqlite")
: undefined;
const fixture = createFixture({
agents: { entries: { ops: {} } },
...(storePath ? { session: { store: storePath } } : {}),
});
const sourcePath = storePath ?? databasePath(fixture.stateDir, "main");
seedClaim({ databaseAgentId: "main", databasePath: sourcePath, key: "agent:main:chat" });
assignHumanOwner(sourcePath);
const result = await migrateLegacyMainSessionKeys({
cfg: fixture.cfg,
env: fixture.env,
mode: "automatic",
});
expect(result.complete).toBe(true);
expect(outcomeKinds(result)).toContain(kind);
expect(
readClaim({
databaseAgentId: sharedStore ? "main" : "ops",
databasePath: storePath ?? databasePath(fixture.stateDir, "ops"),
key: "agent:ops:chat",
})?.entry.owner,
).toEqual(humanOwner);
expect(
readClaim({ databaseAgentId: "main", databasePath: sourcePath, key: "agent:main:chat" }),
).toBeUndefined();
});
it.each([
{ copiedBeforeCrash: true, label: "copy committed before source cleanup" },
{ copiedBeforeCrash: false, label: "source cleanup committed before ledger" },
@@ -452,47 +506,53 @@ describe("legacy main session migration", () => {
]);
});
it("quarantines losing claims without overwriting existing quarantine keys", async () => {
const fixture = createFixture();
const mainPath = databasePath(fixture.stateDir, "main");
seedClaim({
databaseAgentId: "main",
databasePath: mainPath,
entry: { sessionId: "legacy", updatedAt: 100 },
key: "agent:main:chat",
});
seedClaim({
databaseAgentId: "main",
databasePath: mainPath,
entry: { sessionId: "occupied", updatedAt: 50 },
key: "agent:ops:legacy-main-conflict-1",
});
seedClaim({
databaseAgentId: "ops",
databasePath: databasePath(fixture.stateDir, "ops"),
entry: { sessionId: "canonical", updatedAt: 200 },
key: "agent:ops:chat",
});
it.each([false, true])(
"quarantines losing claims without overwriting existing quarantine keys (assigned owner: %s)",
async (hasHumanOwner) => {
const fixture = createFixture();
const mainPath = databasePath(fixture.stateDir, "main");
seedClaim({
databaseAgentId: "main",
databasePath: mainPath,
entry: { sessionId: "legacy", updatedAt: 100 },
key: "agent:main:chat",
});
if (hasHumanOwner) {
assignHumanOwner(mainPath);
}
seedClaim({
databaseAgentId: "main",
databasePath: mainPath,
entry: { sessionId: "occupied", updatedAt: 50 },
key: "agent:ops:legacy-main-conflict-1",
});
seedClaim({
databaseAgentId: "ops",
databasePath: databasePath(fixture.stateDir, "ops"),
entry: { sessionId: "canonical", updatedAt: 200 },
key: "agent:ops:chat",
});
const result = await migrateLegacyMainSessionKeys({
cfg: fixture.cfg,
env: fixture.env,
mode: "doctor-fix",
});
const result = await migrateLegacyMainSessionKeys({
cfg: fixture.cfg,
env: fixture.env,
mode: "doctor-fix",
});
const outcome = result.outcomes.find((entry) => entry.kind === "divergent-canonical");
expect(outcome?.quarantinedKeys).toEqual(["agent:ops:legacy-main-conflict-2"]);
expect(
readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }),
).toBeUndefined();
expect(
readClaim({
const outcome = result.outcomes.find((entry) => entry.kind === "divergent-canonical");
expect(outcome?.quarantinedKeys).toEqual(["agent:ops:legacy-main-conflict-2"]);
expect(
readClaim({ databaseAgentId: "main", databasePath: mainPath, key: "agent:main:chat" }),
).toBeUndefined();
const quarantined = readClaim({
databaseAgentId: "main",
databasePath: mainPath,
key: "agent:ops:legacy-main-conflict-2",
})?.events,
).toEqual(['{"type":"message","id":"event-1","text":"hello"}']);
});
});
expect(quarantined?.events).toEqual(['{"type":"message","id":"event-1","text":"hello"}']);
expect(quarantined?.entry.owner).toEqual(hasHumanOwner ? humanOwner : undefined);
},
);
it("uses a completed ledger once and rearms when its identity changes", async () => {
const fixture = createFixture();
@@ -11,6 +11,7 @@ import {
} from "../../utils/delivery-context.shared.js";
import { isInternalSessionEffectsKey } from "./internal-session-key.js";
import type { SessionEntrySummary } from "./session-accessor.sqlite-contract.js";
import { projectSqliteSessionOwner } from "./session-accessor.sqlite-owner-projection.js";
import {
getSessionKysely,
resolveSqliteScope,
@@ -106,7 +107,7 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry {
},
})
: undefined;
return projectCanonicalSessionEntryShape({
const entry = projectCanonicalSessionEntryShape({
...record,
...(row.status ? { status: row.status } : {}),
...(row.current_started_at !== null ? { startedAt: row.current_started_at } : {}),
@@ -141,6 +142,7 @@ function hydrateCanonicalRepairEntry(row: CanonicalRepairRow): SessionEntry {
sessionId: row.current_session_id,
updatedAt: row.updated_at,
});
return projectSqliteSessionOwner(entry, row);
}
function canonicalRepairQuery(database: Pick<OpenClawAgentDatabase, "db">) {
@@ -10,6 +10,7 @@ import { readExactSessionEntryRowForCanonicalRepair } from "./session-accessor.s
import type { TranscriptEvent } from "./session-accessor.sqlite-contract.js";
import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js";
import { writeSessionEntry } from "./session-accessor.sqlite-entry-store.js";
import { replaceSessionOwnerInTransaction } from "./session-accessor.sqlite-owner.js";
import { readTranscriptEventJsonSetInTransaction } from "./session-accessor.sqlite-read.js";
import {
formatSqliteSessionReferenceForScope,
@@ -116,10 +117,11 @@ function importSqliteSessionRowsInTransaction(
allowStoredAliases: true,
previousEntry: currentEntry ?? null,
});
// The legacy-main handoff hashes raw ordered rows. Parsing or deduping here would make the
// destination proof differ and strand a partially imported canonical claim.
// Only trusted SQLite handoffs can transfer ownership and hash exact ordered rows;
// parsing, deduping, or trusting JSON ownership would break the migration boundary.
const exactTranscriptRows = prepared.exactTranscriptRows;
if (exactTranscriptRows) {
replaceSessionOwnerInTransaction(database, resolved.sessionKey, params.entry.owner);
const transcriptScope = {
...resolved,
sessionId: params.entry.sessionId,
@@ -3,6 +3,7 @@ import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../../state/opencla
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
runOpenClawAgentWriteTransaction,
} from "../../state/openclaw-agent-db.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import {
@@ -39,6 +40,23 @@ describe("SQLite session owner assignment", () => {
});
expect(loadSessionEntry(scope)?.owner).toBeUndefined();
expect(() =>
runOpenClawAgentWriteTransaction(
() => {
expect(
assignSessionOwner(scope, {
owner: { type: "agent", id: "rolled-back-owner" },
assignedBy: { type: "human", id: "profile-assigner" },
assignedAt: 1233,
}),
).not.toBeNull();
throw new Error("roll back owner schema");
},
{ agentId: "main", env: state.env },
),
).toThrow("roll back owner schema");
expect(loadSessionEntry(scope)?.owner).toBeUndefined();
expect(
assignSessionOwner(scope, {
owner: { type: "agent", id: "research" },
@@ -1,28 +1,51 @@
import type { DatabaseSync } from "node:sqlite";
import { executeSqliteQuerySync } from "../../infra/kysely-sync.js";
import { FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS } from "../../state/openclaw-agent-db-additive-columns.js";
import {
openOpenClawAgentDatabase,
runOpenClawAgentWriteTransaction,
type OpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import { ensureColumn } from "../../state/openclaw-state-db-schema-helpers.js";
import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js";
import { publishSessionEntryCacheInvalidation } from "./session-accessor.sqlite-entry-cache.js";
import { hasSqliteSessionOwnerColumns } from "./session-accessor.sqlite-owner-projection.js";
import {
getSessionKysely,
resolveSqliteScope,
toDatabaseOptions,
} from "./session-accessor.sqlite-scope.js";
import type { SessionCreatedActor, SessionOwnerAssignment } from "./session-entry-provenance.js";
const ensuredOwnerDatabases = new WeakSet<DatabaseSync>();
function ensureSessionOwnerColumns(database: DatabaseSync): void {
if (ensuredOwnerDatabases.has(database)) {
return;
export function replaceSessionOwnerInTransaction(
database: OpenClawAgentDatabase,
sessionKey: string,
owner: SessionOwnerAssignment | undefined,
): boolean {
if (!hasSqliteSessionOwnerColumns(database.db)) {
if (!owner?.actor.id) {
return false;
}
for (const { columnName, dataType, tableName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) {
ensureColumn(database.db, tableName, `${columnName} ${dataType}`);
}
}
for (const { columnName, dataType, tableName } of FIRST_USE_ADDITIVE_AGENT_COLUMN_DEFINITIONS) {
ensureColumn(database, tableName, `${columnName} ${dataType}`);
const result = executeSqliteQuerySync(
database.db,
getSessionKysely(database.db)
.updateTable("session_nodes")
.set({
owner_actor_type: owner?.actor.type ?? null,
owner_actor_id: owner?.actor.id ?? null,
owner_assigned_by_type: owner?.assignedBy?.type ?? null,
owner_assigned_by_id: owner?.assignedBy?.id ?? null,
owner_assigned_at: owner?.assignedAt ?? null,
})
.where("session_key", "=", sessionKey),
);
if (result.numAffectedRows !== 1n) {
return false;
}
publishSessionEntryCacheInvalidation(database);
return true;
}
export function assignSessionOwner(
@@ -36,45 +59,18 @@ export function assignSessionOwner(
): SessionOwnerAssignment | null {
const resolved = resolveSqliteScope(scope);
const options = toDatabaseOptions(resolved);
const opened = openOpenClawAgentDatabase(options);
const assignedAt = params.assignedAt ?? Date.now();
const owner: SessionOwnerAssignment = {
actor: params.owner,
assignedBy: params.assignedBy,
assignedAt,
assignedAt: params.assignedAt ?? Date.now(),
};
let ensured = false;
const updated = runOpenClawAgentWriteTransaction(
(database) => {
if (!ensuredOwnerDatabases.has(database.db)) {
ensureSessionOwnerColumns(database.db);
ensured = true;
}
params.assertCurrent?.();
const result = executeSqliteQuerySync(
database.db,
getSessionKysely(database.db)
.updateTable("session_nodes")
.set({
owner_actor_type: params.owner.type,
owner_actor_id: params.owner.id,
owner_assigned_by_type: params.assignedBy.type,
owner_assigned_by_id: params.assignedBy.id,
owner_assigned_at: assignedAt,
})
.where("session_key", "=", resolved.sessionKey),
);
if (result.numAffectedRows === 1n) {
publishSessionEntryCacheInvalidation(database);
return true;
}
return false;
return replaceSessionOwnerInTransaction(database, resolved.sessionKey, owner);
},
options,
{ operationLabel: "sessions.assign-owner" },
);
if (ensured) {
ensuredOwnerDatabases.add(opened.db);
}
return updated ? owner : null;
}
+9 -8
View File
@@ -4327,11 +4327,12 @@ describe("session accessor seam", () => {
expect(target.sessionKey).toBe(canonicalScope.sessionKey);
});
it("drops imported legacy session transcript paths from canonical rows", async () => {
it("drops imported legacy transcript paths and untrusted owners from canonical rows", async () => {
const sessionKey = "agent:main:main";
await importSqliteSessionRows({
agentId: "main",
entry: {
owner: { actor: { type: "human", id: "spoofed" } },
sessionFile: path.join(tempDir, "legacy-transcript.jsonl"),
sessionId: "session-1",
updatedAt: 10,
@@ -4340,13 +4341,13 @@ describe("session accessor seam", () => {
storePath,
});
expect(
loadExactSessionEntry({
agentId: "main",
sessionKey,
storePath,
})?.entry,
).not.toHaveProperty("sessionFile");
const entry = loadExactSessionEntry({
agentId: "main",
sessionKey,
storePath,
})?.entry;
expect(entry).not.toHaveProperty("sessionFile");
expect(entry).not.toHaveProperty("owner");
});
it("reads imported transcripts before opening the SQLite transaction", async () => {