clawdbot-d02.1.9.1.16: add session patch projection seam (#93739)

This commit is contained in:
Josh Lehman
2026-06-17 08:02:45 -07:00
committed by GitHub
parent 402c85b07a
commit c85bd45284
9 changed files with 395 additions and 50 deletions
+8 -1
View File
@@ -68,6 +68,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions.ts",
"src/infra/outbound/message-action-tts.ts",
"src/tui/embedded-backend.ts",
]);
export const migratedBundledPluginSessionAccessorFiles = new Set([
@@ -98,6 +99,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/session-reset-model.ts",
"src/auto-reply/reply/session-updates.ts",
"src/auto-reply/reply/session-usage.ts",
"src/tui/embedded-backend.ts",
]);
export const migratedTranscriptWriterFiles = new Set([
@@ -290,8 +292,13 @@ export async function main() {
"src/cron",
"src/gateway",
"src/infra",
"src/tui",
]);
const writeSourceRoots = resolveSourceRoots(repoRoot, [
"src/agents",
"src/auto-reply",
"src/tui",
]);
const writeSourceRoots = resolveSourceRoots(repoRoot, ["src/agents", "src/auto-reply"]);
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [
"src/agents/command",
"src/agents/embedded-agent-runner",
@@ -6,6 +6,7 @@ import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import {
appendTranscriptMessage,
appendTranscriptEvent,
applySessionPatchProjection,
cleanupSessionLifecycleArtifacts,
createSessionEntryWithTranscript,
listSessionEntries,
@@ -323,6 +324,93 @@ describe("session accessor file-backed seam", () => {
});
});
it("applies projected session patches after migrating legacy candidate keys", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
sessionId: "canonical-session",
updatedAt: 10,
},
"AGENT:MAIN:MAIN": {
sessionId: "legacy-session",
updatedAt: 20,
},
}),
"utf8",
);
const projected = await applySessionPatchProjection({
storePath,
resolveTarget: () => ({
primaryKey: "agent:main:main",
candidateKeys: ["agent:main:main"],
}),
project: ({ entries, existingEntry, primaryKey }) => {
expect(primaryKey).toBe("agent:main:main");
expect(existingEntry?.sessionId).toBe("legacy-session");
expect(entries.map((entry) => entry.sessionKey)).toEqual(["agent:main:main"]);
return {
ok: true as const,
entry: {
...existingEntry,
label: "Projected",
} as SessionEntry,
};
},
});
expect(projected).toMatchObject({
ok: true,
entry: {
label: "Projected",
sessionId: "legacy-session",
},
});
expect(loadSessionStore(storePath)).toEqual({
"agent:main:main": expect.objectContaining({
label: "Projected",
sessionId: "legacy-session",
}),
});
});
it("persists legacy key pruning when projected session patches fail validation", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:main": {
sessionId: "canonical-session",
updatedAt: 10,
},
"AGENT:MAIN:MAIN": {
sessionId: "legacy-session",
updatedAt: 20,
},
}),
"utf8",
);
const projected = await applySessionPatchProjection({
storePath,
resolveTarget: () => ({
primaryKey: "agent:main:main",
candidateKeys: ["agent:main:main"],
}),
project: () => ({
ok: false as const,
error: "invalid patch",
}),
});
expect(projected).toEqual({ ok: false, error: "invalid patch" });
expect(loadSessionStore(storePath)).toEqual({
"agent:main:main": expect.objectContaining({
sessionId: "legacy-session",
}),
});
});
it("cleans scoped lifecycle entries and unreferenced transcript artifacts", async () => {
const nowMs = Date.now();
const oldDate = new Date(nowMs - 600_000);
+30
View File
@@ -25,11 +25,17 @@ import {
cleanupSessionLifecycleArtifacts as cleanupFileSessionLifecycleArtifacts,
listSessionEntries as listFileSessionEntries,
loadSessionStore,
applySessionEntryPatchProjection as applyFileSessionEntryPatchProjection,
patchSessionEntry as patchFileSessionEntry,
readSessionUpdatedAt as readFileSessionUpdatedAt,
resolveSessionStoreEntry,
updateSessionStore,
updateSessionStoreEntry as updateFileSessionStoreEntry,
type SessionEntryPatchProjectionContext,
type SessionEntryPatchProjectionFailure,
type SessionEntryPatchProjectionResult,
type SessionEntryPatchProjectionSnapshot,
type SessionEntryPatchProjectionTarget,
type SessionLifecycleArtifactCleanupParams,
type SessionLifecycleArtifactCleanupResult,
} from "./store.js";
@@ -267,6 +273,13 @@ type CreatedSessionTranscriptResult =
| { ok: true; sessionFile: string }
| { ok: false; error: string; phase: "transcript" };
export type SessionPatchProjectionContext = SessionEntryPatchProjectionContext;
export type SessionPatchProjectionFailure = SessionEntryPatchProjectionFailure;
export type SessionPatchProjectionResult<TFailure extends SessionPatchProjectionFailure> =
SessionEntryPatchProjectionResult<TFailure>;
export type SessionPatchProjectionSnapshot = SessionEntryPatchProjectionSnapshot;
export type SessionPatchProjectionTarget = SessionEntryPatchProjectionTarget;
export type { SessionLifecycleArtifactCleanupParams, SessionLifecycleArtifactCleanupResult };
/** Returns the entry for a canonical or alias session key, if one exists. */
@@ -484,6 +497,23 @@ export async function updateSessionEntry(
});
}
/**
* Applies a session patch projection through the accessor boundary.
* The resolver sees a read-only snapshot and names the persisted key set; the
* projector returns one replacement entry without receiving the mutable store.
*/
export async function applySessionPatchProjection<
TFailure extends SessionPatchProjectionFailure,
>(params: {
storePath: string;
resolveTarget: (snapshot: SessionPatchProjectionSnapshot) => SessionPatchProjectionTarget;
project: (
context: SessionPatchProjectionContext,
) => Promise<SessionPatchProjectionResult<TFailure>> | SessionPatchProjectionResult<TFailure>;
}): Promise<SessionPatchProjectionResult<TFailure>> {
return await applyFileSessionEntryPatchProjection(params);
}
/** Removes entries and orphan transcript artifacts owned by a named session lifecycle. */
export async function cleanupSessionLifecycleArtifacts(
params: SessionLifecycleArtifactCleanupParams,
+146
View File
@@ -1,6 +1,10 @@
// Session store facade coordinates reads, writes, maintenance, delivery metadata, and exports.
import fs from "node:fs";
import path from "node:path";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import type { MsgContext } from "../../auto-reply/templating.js";
import { writeTextAtomic } from "../../infra/json-files.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -86,6 +90,25 @@ export type {
} from "./store-cache.js";
export { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js";
export type SessionEntryPatchProjectionSnapshot = {
entries: ReadonlyArray<{ sessionKey: string; entry: SessionEntry }>;
};
export type SessionEntryPatchProjectionTarget = {
candidateKeys?: readonly string[];
primaryKey: string;
};
export type SessionEntryPatchProjectionContext = SessionEntryPatchProjectionSnapshot &
SessionEntryPatchProjectionTarget & {
existingEntry?: SessionEntry;
};
export type SessionEntryPatchProjectionFailure = { ok: false };
export type SessionEntryPatchProjectionResult<TFailure extends SessionEntryPatchProjectionFailure> =
{ ok: true; entry: SessionEntry } | TFailure;
const log = createSubsystemLogger("sessions/store");
let sessionArchiveRuntimePromise: Promise<
typeof import("../../gateway/session-archive.runtime.js")
@@ -926,6 +949,129 @@ export async function updateSessionStore<T>(
});
}
function cloneSessionEntryProjectionSnapshot(
store: Record<string, SessionEntry>,
): SessionEntryPatchProjectionSnapshot {
return {
entries: Object.entries(store).map(([sessionKey, entry]) => ({
sessionKey,
entry: cloneSessionEntry(entry),
})),
};
}
function resolveFreshestProjectedEntry(params: {
store: Record<string, SessionEntry>;
candidateKeys: readonly string[];
}): SessionEntry | undefined {
let freshest: SessionEntry | undefined;
const keys = new Set<string>();
for (const candidateKey of params.candidateKeys) {
const trimmed = normalizeOptionalString(candidateKey) ?? "";
if (!trimmed) {
continue;
}
keys.add(trimmed);
for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) {
keys.add(match);
}
}
for (const key of keys) {
const entry = params.store[key];
if (!entry) {
continue;
}
if (!freshest || (entry.updatedAt ?? 0) > (freshest.updatedAt ?? 0)) {
freshest = entry;
}
}
return freshest;
}
function findSessionStoreKeysIgnoreCase(
store: Record<string, SessionEntry>,
targetKey: string,
): string[] {
const lowered = normalizeLowercaseStringOrEmpty(targetKey);
return Object.keys(store).filter((key) => normalizeLowercaseStringOrEmpty(key) === lowered);
}
function migrateSessionEntryProjectionTarget(params: {
store: Record<string, SessionEntry>;
target: SessionEntryPatchProjectionTarget;
}): void {
const candidateKeys = params.target.candidateKeys ?? [params.target.primaryKey];
const freshest = resolveFreshestProjectedEntry({
store: params.store,
candidateKeys,
});
const currentPrimary = params.store[params.target.primaryKey];
if (
freshest &&
(!currentPrimary || (freshest.updatedAt ?? 0) > (currentPrimary.updatedAt ?? 0))
) {
params.store[params.target.primaryKey] = freshest;
}
const keysToDelete = new Set<string>();
for (const candidateKey of candidateKeys) {
const trimmed = normalizeOptionalString(candidateKey) ?? "";
if (!trimmed) {
continue;
}
if (trimmed !== params.target.primaryKey) {
keysToDelete.add(trimmed);
}
for (const match of findSessionStoreKeysIgnoreCase(params.store, trimmed)) {
if (match !== params.target.primaryKey) {
keysToDelete.add(match);
}
}
}
for (const key of keysToDelete) {
delete params.store[key];
}
}
/**
* Applies a storage-neutral entry projection inside the session-store writer.
* The projection receives a cloned snapshot and returns the replacement entry;
* it cannot mutate the backing whole store.
*/
export async function applySessionEntryPatchProjection<
TFailure extends SessionEntryPatchProjectionFailure,
>(params: {
storePath: string;
resolveTarget: (
snapshot: SessionEntryPatchProjectionSnapshot,
) => SessionEntryPatchProjectionTarget;
project: (
context: SessionEntryPatchProjectionContext,
) =>
| Promise<SessionEntryPatchProjectionResult<TFailure>>
| SessionEntryPatchProjectionResult<TFailure>;
}): Promise<SessionEntryPatchProjectionResult<TFailure>> {
return await runExclusiveSessionStoreWrite(params.storePath, async () => {
const store = loadMutableSessionStoreForWriter(params.storePath);
const target = params.resolveTarget(cloneSessionEntryProjectionSnapshot(store));
migrateSessionEntryProjectionTarget({ store, target });
const projected = await params.project({
...target,
entries: cloneSessionEntryProjectionSnapshot(store).entries,
...(store[target.primaryKey]
? { existingEntry: cloneSessionEntry(store[target.primaryKey]) }
: {}),
});
if (projected.ok) {
store[target.primaryKey] = cloneSessionEntry(projected.entry);
}
await saveSessionStoreUnlocked(params.storePath, store, {
activeSessionKey: target.primaryKey,
});
return projected.ok ? { ...projected, entry: cloneSessionEntry(projected.entry) } : projected;
});
}
async function archiveUnreferencedLifecycleTranscriptArtifacts(params: {
storePath: string;
transcriptContentMarker: string;
+29 -17
View File
@@ -57,7 +57,10 @@ import {
updateSessionStore,
} from "../../config/sessions.js";
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
import { createSessionEntryWithTranscript } from "../../config/sessions/session-accessor.js";
import {
applySessionPatchProjection,
createSessionEntryWithTranscript,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
createInternalHookEvent,
@@ -116,7 +119,7 @@ import {
type SessionsPreviewEntry,
type SessionsPreviewResult,
} from "../session-utils.js";
import { applySessionsPatchToStore } from "../sessions-patch.js";
import { applySessionsPatchToStore, projectSessionsPatchEntry } from "../sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
import { setGatewayDedupeEntry } from "./agent-wait-dedupe.js";
import { chatHandlers } from "./chat.js";
@@ -2066,21 +2069,30 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const { target, storePath } = resolveGatewaySessionTargetFromKey(key, cfg, {
agentId: requestedAgentId,
});
const applied = await updateSessionStore(storePath, async (store) => {
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
cfg,
key,
store,
agentId: requestedAgentId,
});
return await applySessionsPatchToStore({
cfg,
store,
storeKey: primaryKey,
agentId: requestedAgentId,
patch: p,
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
});
const applied = await applySessionPatchProjection({
storePath,
resolveTarget: ({ entries }) => {
const store = Object.fromEntries(
entries.map(({ sessionKey, entry }) => [sessionKey, entry]),
);
const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({
cfg,
key,
store,
agentId: requestedAgentId,
});
return { primaryKey, candidateKeys: migratedTarget.storeKeys };
},
project: async ({ primaryKey, existingEntry, entries }) =>
await projectSessionsPatchEntry({
cfg,
entries,
existingEntry,
storeKey: primaryKey,
agentId: requestedAgentId,
patch: p,
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
}),
});
if (!applied.ok) {
respond(false, undefined, applied.error);
+37 -8
View File
@@ -130,16 +130,22 @@ function normalizeSubagentControlScope(raw: string): "children" | "none" | undef
return undefined;
}
/** Apply a validated gateway session patch to an in-memory session store entry. */
export async function applySessionsPatchToStore(params: {
type SessionPatchProjectionEntry = {
entry: SessionEntry;
sessionKey: string;
};
/** Project a validated gateway session patch for one session entry. */
export async function projectSessionsPatchEntry(params: {
cfg: OpenClawConfig;
store: Record<string, SessionEntry>;
entries: readonly SessionPatchProjectionEntry[];
existingEntry?: SessionEntry;
storeKey: string;
agentId?: string;
patch: SessionsPatchParams;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
const { cfg, store, storeKey, patch } = params;
const { cfg, storeKey, patch } = params;
const now = Date.now();
const parsedAgent = parseAgentSessionKey(storeKey);
const sessionAgentId = normalizeAgentId(
@@ -162,7 +168,7 @@ export async function applySessionsPatchToStore(params: {
return loadedModelCatalog;
};
const existing = store[storeKey];
const existing = params.existingEntry;
// Existing entries without session ids are placeholder aliases; assigning an id makes them real.
const next: SessionEntry = existing?.sessionId
? {
@@ -356,8 +362,8 @@ export async function applySessionsPatchToStore(params: {
if (!parsed.ok) {
return invalid(parsed.error);
}
for (const [key, entry] of Object.entries(store)) {
if (key === storeKey) {
for (const { sessionKey, entry } of params.entries) {
if (sessionKey === storeKey) {
continue;
}
if (entry?.label === parsed.label) {
@@ -642,6 +648,29 @@ export async function applySessionsPatchToStore(params: {
}
}
store[storeKey] = next;
return { ok: true, entry: next };
}
/** Apply a validated gateway session patch to an in-memory session store entry. */
export async function applySessionsPatchToStore(params: {
cfg: OpenClawConfig;
store: Record<string, SessionEntry>;
storeKey: string;
agentId?: string;
patch: SessionsPatchParams;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
const projected = await projectSessionsPatchEntry({
cfg: params.cfg,
entries: Object.entries(params.store).map(([sessionKey, entry]) => ({ sessionKey, entry })),
existingEntry: params.store[params.storeKey],
storeKey: params.storeKey,
agentId: params.agentId,
patch: params.patch,
loadGatewayModelCatalog: params.loadGatewayModelCatalog,
});
if (projected.ok) {
params.store[params.storeKey] = projected.entry;
}
return projected;
}
+29 -7
View File
@@ -7,7 +7,8 @@ import { withEnvAsync } from "../test-utils/env.js";
const agentCommandFromIngressMock = vi.fn();
const runBtwSideQuestionMock = vi.fn();
const updateSessionStoreMock = vi.fn();
const applySessionsPatchToStoreMock = vi.fn();
const applySessionPatchProjectionMock = vi.fn();
const projectSessionsPatchEntryMock = vi.fn();
const createSessionGoalMock = vi.fn();
const clearSessionGoalMock = vi.fn();
const getSessionGoalMock = vi.fn();
@@ -101,6 +102,10 @@ vi.mock("../config/sessions.js", () => ({
updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args),
}));
vi.mock("../config/sessions/session-accessor.js", () => ({
applySessionPatchProjection: (...args: unknown[]) => applySessionPatchProjectionMock(...args),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentDir: (_cfg: unknown, agentId: string) => `/tmp/openclaw-agent-${agentId}/agent`,
resolveAgentWorkspaceDir: (_cfg: unknown, agentId: string) => `/tmp/openclaw-agent-${agentId}`,
@@ -175,7 +180,10 @@ vi.mock("../gateway/session-utils.js", () => ({
loadCombinedSessionStoreForGatewayMock(...args),
loadSessionEntry: (sessionKey: string, opts?: { agentId?: string }) =>
loadSessionEntryMock(sessionKey, opts),
migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({ primaryKey: key }),
migrateAndPruneGatewaySessionStoreKey: ({ key }: { key: string }) => ({
primaryKey: key,
target: { storeKeys: [key] },
}),
resolveGatewaySessionStoreTarget: ({ key }: { key: string }) => ({
canonicalKey: key,
storePath: "/tmp/openclaw-sessions.json",
@@ -198,7 +206,7 @@ vi.mock("../gateway/session-transcript-readers.js", () => ({
}));
vi.mock("../gateway/sessions-patch.js", () => ({
applySessionsPatchToStore: (...args: unknown[]) => applySessionsPatchToStoreMock(...args),
projectSessionsPatchEntry: (...args: unknown[]) => projectSessionsPatchEntryMock(...args),
}));
vi.mock("../gateway/server-methods/agent-timestamp.js", () => ({
@@ -267,8 +275,22 @@ describe("EmbeddedTuiBackend", () => {
storePath: "/tmp/openclaw-sessions.json",
store: {},
});
applySessionsPatchToStoreMock.mockReset();
applySessionsPatchToStoreMock.mockResolvedValue({ ok: true, entry: {} });
applySessionPatchProjectionMock.mockReset();
applySessionPatchProjectionMock.mockImplementation(
async (params: {
project: (context: {
entries: unknown[];
existingEntry?: unknown;
primaryKey: string;
}) => Promise<unknown>;
resolveTarget: (snapshot: { entries: unknown[] }) => { primaryKey: string };
}) => {
const target = params.resolveTarget({ entries: [] });
return await params.project({ ...target, entries: [] });
},
);
projectSessionsPatchEntryMock.mockReset();
projectSessionsPatchEntryMock.mockResolvedValue({ ok: true, entry: {} });
getRuntimeConfigMock.mockReset();
getRuntimeConfigMock.mockReturnValue({});
loadGatewayModelCatalogMock.mockReset();
@@ -494,7 +516,7 @@ describe("EmbeddedTuiBackend", () => {
provider: "tui-pty-mock",
},
]);
applySessionsPatchToStoreMock.mockImplementation(
projectSessionsPatchEntryMock.mockImplementation(
async ({
loadGatewayModelCatalog,
}: {
@@ -1345,7 +1367,7 @@ describe("EmbeddedTuiBackend", () => {
fastMode: true,
});
expect(applySessionsPatchToStoreMock).toHaveBeenCalledWith(
expect(projectSessionsPatchEntryMock).toHaveBeenCalledWith(
expect.objectContaining({
storeKey: "global",
agentId: "work",
+26 -17
View File
@@ -25,8 +25,8 @@ import {
formatSessionGoalStatus,
getSessionGoal,
updateSessionGoalStatus,
updateSessionStore,
} from "../config/sessions.js";
import { applySessionPatchProjection } from "../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isChatStopCommandText } from "../gateway/chat-abort.js";
import {
@@ -64,7 +64,7 @@ import {
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
} from "../gateway/session-utils.js";
import { applySessionsPatchToStore } from "../gateway/sessions-patch.js";
import { projectSessionsPatchEntry } from "../gateway/sessions-patch.js";
import { type AgentEventPayload, onAgentEvent } from "../infra/agent-events.js";
import { setEmbeddedMode } from "../infra/embedded-mode.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -545,21 +545,30 @@ export class EmbeddedTuiBackend implements TuiBackend {
key: opts.key,
agentId: opts.agentId,
});
const applied = await updateSessionStore(target.storePath, async (store) => {
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
cfg,
key: opts.key,
store,
agentId: opts.agentId,
});
return await applySessionsPatchToStore({
cfg,
store,
storeKey: primaryKey,
agentId: opts.agentId,
patch: opts,
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
});
const applied = await applySessionPatchProjection({
storePath: target.storePath,
resolveTarget: ({ entries }) => {
const store = Object.fromEntries(
entries.map(({ sessionKey, entry }) => [sessionKey, entry]),
);
const { target: migratedTarget, primaryKey } = migrateAndPruneGatewaySessionStoreKey({
cfg,
key: opts.key,
store,
agentId: opts.agentId,
});
return { primaryKey, candidateKeys: migratedTarget.storeKeys };
},
project: async ({ primaryKey, existingEntry, entries }) =>
await projectSessionsPatchEntry({
cfg,
entries,
existingEntry,
storeKey: primaryKey,
agentId: opts.agentId,
patch: opts,
loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg),
}),
});
if (!applied.ok) {
throw new Error(applied.error.message);
@@ -39,6 +39,7 @@ describe("session accessor boundary guard", () => {
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions.ts",
"src/infra/outbound/message-action-tts.ts",
"src/tui/embedded-backend.ts",
]),
);
});
@@ -77,6 +78,7 @@ describe("session accessor boundary guard", () => {
"src/auto-reply/reply/session-reset-model.ts",
"src/auto-reply/reply/session-updates.ts",
"src/auto-reply/reply/session-usage.ts",
"src/tui/embedded-backend.ts",
]),
);
});