fix(sessions): preserve concurrent appends across reset (#127842)

This commit is contained in:
Vyctor H. Brzezowski
2026-08-23 13:15:06 -03:00
committed by GitHub
parent 3d75c27103
commit f9b5657cbd
9 changed files with 160 additions and 253 deletions
+1 -1
View File
@@ -2763,7 +2763,7 @@ src/config/sessions/session-accessor.transcript-range.ts 3
src/config/sessions/session-accessor.transcript-turn.ts 1
src/config/sessions/session-entry-json.ts 1
src/config/sessions/session-history-eviction.ts 2
src/config/sessions/session-reset-boundary-event.ts 11
src/config/sessions/session-reset-boundary-event.ts 5
src/config/sessions/session-sharing-store.ts 2
src/config/sessions/session-snapshot-merge.ts 26
src/config/sessions/session-sqlite-target.ts 3
@@ -0,0 +1,123 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js";
import * as agentDatabase from "../../state/openclaw-agent-db.js";
import {
applySessionEntryLifecycleMutation,
loadTranscriptEvents,
resetSessionEntryLifecycle,
upsertSessionEntryCore,
} from "./session-accessor.js";
import {
readRecentSessionTranscriptActiveEvents,
waitForSessionTranscriptProjection,
} from "./session-accessor.sqlite-active-events.js";
import { appendTranscriptMessageSync } from "./session-accessor.sqlite-transcript-write.js";
const transactionInjection = vi.hoisted(() => ({ run: null as (() => void) | null }));
vi.mock("../../state/openclaw-agent-db.js", async (importOriginal) => {
const actual = await importOriginal<typeof agentDatabase>();
return {
...actual,
runOpenClawAgentWriteTransaction: <T>(
run: Parameters<typeof actual.runOpenClawAgentWriteTransaction<T>>[0],
options: Parameters<typeof actual.runOpenClawAgentWriteTransaction<T>>[1],
) => {
const inject = transactionInjection.run;
transactionInjection.run = null;
inject?.();
return actual.runOpenClawAgentWriteTransaction(run, options);
},
};
});
describe("reset boundary concurrency", () => {
const tempDirs: string[] = [];
let tempDir: string;
let storePath: string;
beforeEach(() => {
tempDir = makeTempDir(tempDirs, "openclaw-reset-boundary-race-");
storePath = path.join(tempDir, "sessions.json");
});
afterEach(() => {
transactionInjection.run = null;
agentDatabase.closeOpenClawAgentDatabasesForTest();
cleanupTempDirs(tempDirs);
});
it.each([
{
name: "single reset",
reset: async (scope: { sessionId: string; sessionKey: string; storePath: string }) =>
resetSessionEntryLifecycle({
buildNextEntry: () => ({ sessionId: "next-single", updatedAt: 20 }),
resetBoundaryReason: "reset",
storePath: scope.storePath,
target: { canonicalKey: scope.sessionKey, storeKeys: [scope.sessionKey] },
}),
},
{
name: "bulk lifecycle reset",
reset: async (scope: { sessionId: string; sessionKey: string; storePath: string }) =>
applySessionEntryLifecycleMutation({
skipMaintenance: true,
storePath: scope.storePath,
upserts: [
{
entry: { sessionId: "next-bulk", updatedAt: 20 },
resetBoundaryReason: "reset",
sessionKey: scope.sessionKey,
},
],
}),
},
])("parents the $name boundary after a concurrent accepted message", async ({ reset }) => {
const scope = {
sessionId: "current-session",
sessionKey: "agent:main:reset-race",
storePath,
};
await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 10 });
appendTranscriptMessageSync(scope, {
eventId: "initial",
message: { role: "user", content: "initial" },
parentId: null,
});
transactionInjection.run = () => {
appendTranscriptMessageSync(scope, {
eventId: "concurrent",
message: { role: "user", content: "accepted concurrently" },
parentId: "initial",
});
};
await reset(scope);
const raw = await loadTranscriptEvents(scope);
const boundary = raw.find(
(event) =>
event !== null &&
typeof event === "object" &&
!Array.isArray(event) &&
(event as { type?: unknown }).type === "reset",
);
expect(boundary).toMatchObject({ parentId: "concurrent" });
await waitForSessionTranscriptProjection(scope);
expect(
readRecentSessionTranscriptActiveEvents(scope, 10).map(
(event) => (event as { id?: unknown }).id,
),
).toContain("concurrent");
agentDatabase.closeOpenClawAgentDatabasesForTest();
await waitForSessionTranscriptProjection(scope);
expect(
readRecentSessionTranscriptActiveEvents(scope, 10).map(
(event) => (event as { id?: unknown }).id,
),
).toContain("concurrent");
});
});
@@ -35,11 +35,9 @@ import type {
SessionEntryRemovalPlan,
} from "./session-accessor.sqlite-lifecycle-types.js";
import { coerceSqliteNumber } from "./session-accessor.sqlite-normalize.js";
import { loadTranscriptEventsFromDatabase } from "./session-accessor.sqlite-read.js";
import { collectSessionStateIdsForEntry } from "./session-accessor.sqlite-references.js";
import { cloneSessionEntry, getSessionKysely } from "./session-accessor.sqlite-scope.js";
import { parseSessionEntryJson as parseSessionEntryRow } from "./session-accessor.sqlite-status.js";
import { buildSessionResetBoundaryPlan } from "./session-reset-boundary-event.js";
import { deleteSessionTranscriptIndexInTransaction } from "./session-transcript-index.js";
import type { SessionEntry } from "./types.js";
@@ -394,19 +392,12 @@ export async function projectSessionEntryLifecycleMutation(
const cloned = cloneSessionEntry(entry);
store[sessionKey] = cloned;
changedSessionKeys.add(sessionKey);
const resetBoundaryPlan =
upsert.resetBoundaryReason && expectedEntry?.sessionId
? await buildSessionResetBoundaryPlan({
events: loadTranscriptEventsFromDatabase(database, expectedEntry.sessionId),
reason: upsert.resetBoundaryReason,
})
: undefined;
upsertedEntries.push({
expectedEntry,
sessionKey,
entry: cloned,
...(upsert.routeContext !== undefined ? { routeContext: upsert.routeContext } : {}),
...(resetBoundaryPlan ? { resetBoundaryPlan } : {}),
...(upsert.resetBoundaryReason ? { resetBoundaryReason: upsert.resetBoundaryReason } : {}),
});
}
const referencedSessionIds = collectProjectedReferencedSessionIds({
@@ -2,7 +2,7 @@ import type { ConversationRouteContext } from "./conversation-route-context.js";
import type { SessionLifecycleArchivedTranscript } from "./session-accessor.lifecycle-types.js";
import type { SessionStateDeletePlan } from "./session-accessor.sqlite-archive.js";
import type { SessionEntryLifecycleRemoval } from "./session-accessor.sqlite-contract.js";
import type { SessionResetBoundaryPlan } from "./session-reset-boundary-event.js";
import type { SessionResetBoundaryReason } from "./session-reset-boundary-event.js";
import type { SessionEntry } from "./types.js";
// Shared plan shapes only. Runtime ownership stays in maintenance and lifecycle-state.
@@ -39,7 +39,7 @@ export type ProjectedLifecycleMutation = {
entry: SessionEntry;
expectedEntry: SessionEntry | undefined;
routeContext?: ConversationRouteContext | null;
resetBoundaryPlan?: SessionResetBoundaryPlan;
resetBoundaryReason?: SessionResetBoundaryReason;
sessionKey: string;
}>;
};
@@ -67,7 +67,7 @@ import {
collectAdmissionProtectedSessionIds,
kickSessionHistoryDiskBudgetMaintenance,
} from "./session-history-eviction.js";
import { buildSessionResetBoundaryPlan } from "./session-reset-boundary-event.js";
import { buildSessionResetBoundaryEvent } from "./session-reset-boundary-event.js";
import type { InternalSessionEntry as SessionEntry } from "./types.js";
// Single-target lifecycle owner: cleanup, reset, guarded delete, and trusted rollback.
@@ -189,15 +189,10 @@ export async function resetSessionEntryLifecycle(
currentEntry: current ? cloneSessionEntry(current.entry) : undefined,
primaryKey: params.target.canonicalKey,
});
const resetBoundaryPlan =
const shouldAppendResetBoundary =
params.resetBoundaryReason &&
current?.entry.sessionId &&
!sqliteSessionEntriesEqual(current.entry, nextEntry)
? await buildSessionResetBoundaryPlan({
events: loadTranscriptEventsFromDatabase(database, current.entry.sessionId),
reason: params.resetBoundaryReason,
})
: undefined;
!sqliteSessionEntriesEqual(current.entry, nextEntry);
const mutation: ResetSessionEntryLifecycleMutation = {
nextEntry: cloneSessionEntry(nextEntry),
...(current ? { previousEntry: cloneSessionEntry(current.entry) } : {}),
@@ -205,8 +200,11 @@ export async function resetSessionEntryLifecycle(
};
runOpenClawAgentWriteTransaction((transactionDb) => {
assertLifecycleTargetUnchanged(transactionDb, params.target, current?.entry, "reset");
if (resetBoundaryPlan && current?.entry.sessionId) {
const events = [...resetBoundaryPlan.seedEvents, resetBoundaryPlan.event];
if (shouldAppendResetBoundary && current?.entry.sessionId && params.resetBoundaryReason) {
const event = buildSessionResetBoundaryEvent({
events: loadTranscriptEventsFromDatabase(transactionDb, current.entry.sessionId),
reason: params.resetBoundaryReason,
});
const appended = appendTranscriptEventsInTransaction(
transactionDb,
{
@@ -214,9 +212,9 @@ export async function resetSessionEntryLifecycle(
sessionId: current.entry.sessionId,
sessionKey: current.key,
},
events,
[event],
);
if (appended !== events.length) {
if (appended !== 1) {
throw new Error(`Failed to append reset boundary for ${current.key}`);
}
}
@@ -67,6 +67,7 @@ import {
applySessionEntryMaintenance,
finalizeSessionEntryMaintenancePlansAfterWriterReleaseBestEffort,
} from "./session-accessor.sqlite-maintenance.js";
import { loadTranscriptEventsFromDatabase } from "./session-accessor.sqlite-read.js";
import { applySessionEntryExactReplacements } from "./session-accessor.sqlite-replacement-projection.js";
import {
cloneSessionEntry,
@@ -77,6 +78,7 @@ import {
toDatabaseOptions,
} from "./session-accessor.sqlite-scope.js";
import { appendTranscriptEventsInTransaction } from "./session-accessor.sqlite-transcript-store.js";
import { buildSessionResetBoundaryEvent } from "./session-reset-boundary-event.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
import type { SessionEntry } from "./types.js";
@@ -340,7 +342,7 @@ export async function applySessionEntryLifecycleMutation(params: {
entry,
expectedEntry,
routeContext,
resetBoundaryPlan,
resetBoundaryReason,
} of projected.upsertedEntries) {
const sameKeyRemoval = validatedRemovals.find(
(removal) => removal.sessionKey === sessionKey,
@@ -363,14 +365,17 @@ export async function applySessionEntryLifecycleMutation(params: {
if (sameKeyRemoval && !shouldRemoveSessionEntry(currentEntry, sameKeyRemoval.removal)) {
throw new Error(`SQLite session entry has stale lifecycle state for ${sessionKey}`);
}
if (resetBoundaryPlan && expectedEntry?.sessionId) {
const events = [...resetBoundaryPlan.seedEvents, resetBoundaryPlan.event];
if (resetBoundaryReason && expectedEntry?.sessionId) {
const event = buildSessionResetBoundaryEvent({
events: loadTranscriptEventsFromDatabase(transactionDb, expectedEntry.sessionId),
reason: resetBoundaryReason,
});
const appended = appendTranscriptEventsInTransaction(
transactionDb,
{ ...resolved, sessionId: expectedEntry.sessionId, sessionKey },
events,
[event],
);
if (appended !== events.length) {
if (appended !== 1) {
throw new Error(`Failed to append reset boundary for ${sessionKey}`);
}
}
@@ -1,8 +1,5 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { withTestDir } from "../../test-helpers/temp-dir.js";
import { buildSessionResetBoundaryPlan } from "./session-reset-boundary-event.js";
import { buildSessionResetBoundaryEvent } from "./session-reset-boundary-event.js";
function message(params: {
id: string;
@@ -60,12 +57,10 @@ describe("reset boundary planning", () => {
};
expect(
(
await buildSessionResetBoundaryPlan({
events: [oldUser, oldAssistant, keptUser, keptAssistant, firstReset],
reason: "reset",
})
).event,
buildSessionResetBoundaryEvent({
events: [oldUser, oldAssistant, keptUser, keptAssistant, firstReset],
reason: "reset",
}),
).toMatchObject({
parentId: firstReset.id,
firstKeptEntryId: keptUser.id,
@@ -105,122 +100,13 @@ describe("reset boundary planning", () => {
};
expect(
(
await buildSessionResetBoundaryPlan({
events: [discarded, keptUser, keptAssistant, compaction],
reason: "new",
})
).event,
buildSessionResetBoundaryEvent({
events: [discarded, keptUser, keptAssistant, compaction],
reason: "new",
}),
).toMatchObject({
parentId: compaction.id,
firstKeptEntryId: keptUser.id,
});
});
it("seeds only the bounded replay tail from a legacy transcript", async () => {
await withTestDir({ prefix: "openclaw-reset-boundary-" }, async (dir) => {
const sessionFile = path.join(dir, "legacy.jsonl");
const records = Array.from({ length: 20 }, (_, index) =>
message({
id: `message-${index}`,
parentId: index === 0 ? null : `message-${index - 1}`,
role: index % 2 === 0 ? "user" : "assistant",
content: `message ${index}`,
second: index,
}),
);
await fs.writeFile(
sessionFile,
`${records.map((entry) => JSON.stringify(entry)).join("\n")}\n`,
);
const plan = await buildSessionResetBoundaryPlan({
events: [],
legacySessionFile: sessionFile,
reason: "new",
});
expect(plan.seedEvents).toHaveLength(6);
expect(plan.seedEvents.map((entry) => (entry as { id?: string }).id)).toEqual(
records.slice(-6).map((entry) => entry.id),
);
expect(plan.event.firstKeptEntryId).toBe("message-14");
const metadataOnlyPlan = await buildSessionResetBoundaryPlan({
events: [{ type: "model_change", id: "metadata-only", parentId: null }],
legacySessionFile: sessionFile,
reason: "new",
});
expect(metadataOnlyPlan.seedEvents).toHaveLength(6);
expect(metadataOnlyPlan.event.firstKeptEntryId).toBe("message-14");
});
});
it("respects legacy reset cuts and reparents the selected tail", async () => {
await withTestDir({ prefix: "openclaw-reset-boundary-" }, async (dir) => {
const sessionFile = path.join(dir, "legacy-reset.jsonl");
const oldUser = message({
id: "legacy-old-user",
parentId: null,
role: "user",
content: "discarded",
second: 1,
});
const oldAssistant = message({
id: "legacy-old-assistant",
parentId: oldUser.id,
role: "assistant",
content: "discarded answer",
second: 2,
});
const keptUser = message({
id: "legacy-kept-user",
parentId: oldAssistant.id,
role: "user",
content: "kept",
second: 3,
});
const toolResult = {
type: "message",
id: "legacy-tool-result",
parentId: keptUser.id,
timestamp: "2026-07-22T00:00:04.000Z",
message: { role: "toolResult", content: "tool" },
};
const keptAssistant = message({
id: "legacy-kept-assistant",
parentId: toolResult.id,
role: "assistant",
content: "kept answer",
second: 5,
});
const reset = {
type: "reset",
id: "legacy-reset",
parentId: keptAssistant.id,
timestamp: "2026-07-22T00:00:06.000Z",
reason: "new",
firstKeptEntryId: keptUser.id,
};
await fs.writeFile(
sessionFile,
`${[oldUser, oldAssistant, keptUser, toolResult, keptAssistant, reset]
.map((entry) => JSON.stringify(entry))
.join("\n")}\n`,
);
const plan = await buildSessionResetBoundaryPlan({
events: [],
legacySessionFile: sessionFile,
reason: "reset",
});
expect(plan.seedEvents).toEqual([
expect.objectContaining({ id: keptUser.id, parentId: null }),
expect.objectContaining({ id: keptAssistant.id, parentId: keptUser.id }),
]);
expect(JSON.stringify(plan.seedEvents)).not.toContain("discarded");
expect(plan.event.firstKeptEntryId).toBe(keptUser.id);
});
});
});
@@ -1,11 +1,5 @@
import { randomUUID } from "node:crypto";
import path from "node:path";
import {
DEFAULT_REPLAY_MAX_MESSAGES,
replayableTranscriptRole,
selectRecentUserAssistantReplayRecords,
} from "./transcript-replay.js";
import { streamSessionTranscriptLinesReverse } from "./transcript-stream.js";
import { selectRecentUserAssistantReplayRecords } from "./transcript-replay.js";
import { selectSessionTranscriptLeafControlledPath } from "./transcript-tree.js";
export type SessionResetBoundaryReason = "new" | "reset" | "idle" | "daily" | "cron-stale";
@@ -19,11 +13,6 @@ type SessionResetBoundaryEvent = {
firstKeptEntryId?: string;
};
export type SessionResetBoundaryPlan = {
event: SessionResetBoundaryEvent;
seedEvents: unknown[];
};
function recordId(record: unknown): string | undefined {
if (!record || typeof record !== "object" || Array.isArray(record)) {
return undefined;
@@ -73,7 +62,7 @@ function projectLatestBoundaryWindow(entries: readonly unknown[]): unknown[] {
return [...kept, ...entries.slice(boundaryIndex + 1)];
}
function buildSessionResetBoundaryEvent(params: {
export function buildSessionResetBoundaryEvent(params: {
events: readonly unknown[];
reason: SessionResetBoundaryReason;
}): SessionResetBoundaryEvent {
@@ -98,86 +87,3 @@ function buildSessionResetBoundaryEvent(params: {
...(firstKeptEntryId ? { firstKeptEntryId } : {}),
};
}
async function readLegacyTranscriptEvents(sessionFile: string | undefined): Promise<unknown[]> {
const filePath = sessionFile?.trim();
if (!filePath || !path.isAbsolute(filePath) || !filePath.endsWith(".jsonl")) {
return [];
}
try {
const newestFirst: unknown[] = [];
let boundaryFirstKeptEntryId: string | undefined;
let foundBoundary = false;
for await (const line of streamSessionTranscriptLinesReverse(filePath)) {
let record: unknown;
try {
record = JSON.parse(line) as unknown;
} catch {
continue;
}
const type =
record && typeof record === "object" && !Array.isArray(record)
? (record as { type?: unknown }).type
: undefined;
if (!foundBoundary && (type === "reset" || type === "compaction")) {
foundBoundary = true;
const firstKept = (record as { firstKeptEntryId?: unknown }).firstKeptEntryId;
boundaryFirstKeptEntryId =
typeof firstKept === "string" && firstKept.trim() ? firstKept : undefined;
if (!boundaryFirstKeptEntryId) {
break;
}
continue;
}
if (foundBoundary && (type === "reset" || type === "compaction")) {
break;
}
if (replayableTranscriptRole(record as Parameters<typeof replayableTranscriptRole>[0])) {
newestFirst.push(record);
}
if (
newestFirst.length >= DEFAULT_REPLAY_MAX_MESSAGES ||
(foundBoundary && recordId(record) === boundaryFirstKeptEntryId)
) {
break;
}
}
const selected = selectRecentUserAssistantReplayRecords(newestFirst.toReversed());
return selected.map((record, index) =>
Object.assign({}, record as Record<string, unknown>, {
parentId: index === 0 ? null : (recordId(selected[index - 1]) ?? null),
}),
);
} catch {
return [];
}
}
export async function buildSessionResetBoundaryPlan(params: {
events: readonly unknown[];
legacySessionFile?: string;
reason: SessionResetBoundaryReason;
}): Promise<SessionResetBoundaryPlan> {
const hasConversationEvents = params.events.some((event) => {
const type =
event !== null && typeof event === "object" && !Array.isArray(event)
? (event as { type?: unknown }).type
: undefined;
return type === "message" || type === "compaction" || type === "reset";
});
const legacyEvents = hasConversationEvents
? []
: await readLegacyTranscriptEvents(params.legacySessionFile);
const seedEvents = legacyEvents.filter(
(event) =>
event !== null &&
typeof event === "object" &&
!Array.isArray(event) &&
(event as { type?: unknown }).type !== "session",
);
const events = seedEvents.length > 0 ? [...params.events, ...seedEvents] : params.events;
return {
event: buildSessionResetBoundaryEvent({ events, reason: params.reason }),
seedEvents,
};
}
+2 -4
View File
@@ -1,7 +1,7 @@
// Selects safe user/assistant tails for in-log lifecycle boundaries.
/** Tail kept so DM continuity survives silent session rotations. */
export const DEFAULT_REPLAY_MAX_MESSAGES = 6;
const DEFAULT_REPLAY_MAX_MESSAGES = 6;
type SessionRecord = {
type?: unknown;
@@ -19,9 +19,7 @@ function isValidReplayTimestamp(value: unknown): boolean {
return typeof value === "string" && value.trim().length > 0;
}
export function replayableTranscriptRole(
record: SessionRecord | null,
): "user" | "assistant" | undefined {
function replayableTranscriptRole(record: SessionRecord | null): "user" | "assistant" | undefined {
if (
!record ||
record.type !== "message" ||