mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
refactor: add session reset delete lifecycle seam (#93659)
* refactor: add session reset delete lifecycle seam * refactor: expose session lifecycle seam through accessor
This commit is contained in:
@@ -9,6 +9,16 @@ export * from "./sessions/main-session.runtime.js";
|
||||
export * from "./sessions/lifecycle.js";
|
||||
export * from "./sessions/paths.js";
|
||||
export * from "./sessions/reset.js";
|
||||
export {
|
||||
deleteSessionEntryLifecycle,
|
||||
resetSessionEntryLifecycle,
|
||||
type DeleteSessionEntryLifecycleParams,
|
||||
type DeleteSessionEntryLifecycleResult,
|
||||
type ResetSessionEntryLifecycleParams,
|
||||
type ResetSessionEntryLifecycleResult,
|
||||
type SessionLifecycleArchivedTranscript,
|
||||
type SessionLifecycleStoreTarget,
|
||||
} from "./sessions/session-accessor.js";
|
||||
export * from "./sessions/session-key.js";
|
||||
export * from "./sessions/store.js";
|
||||
export * from "./sessions/types.js";
|
||||
|
||||
@@ -23,21 +23,28 @@ import type { ResolvedSessionMaintenanceConfig } from "./store-maintenance.js";
|
||||
import {
|
||||
getSessionEntry,
|
||||
cleanupSessionLifecycleArtifacts as cleanupFileSessionLifecycleArtifacts,
|
||||
deleteSessionEntryLifecycle as deleteFileSessionEntryLifecycle,
|
||||
listSessionEntries as listFileSessionEntries,
|
||||
loadSessionStore,
|
||||
applySessionEntryPatchProjection as applyFileSessionEntryPatchProjection,
|
||||
patchSessionEntry as patchFileSessionEntry,
|
||||
readSessionUpdatedAt as readFileSessionUpdatedAt,
|
||||
resolveSessionStoreEntry,
|
||||
resetSessionEntryLifecycle as resetFileSessionEntryLifecycle,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry as updateFileSessionStoreEntry,
|
||||
type DeleteSessionEntryLifecycleResult,
|
||||
type ResetSessionEntryLifecycleMutation,
|
||||
type ResetSessionEntryLifecycleResult,
|
||||
type SessionEntryPatchProjectionContext,
|
||||
type SessionEntryPatchProjectionFailure,
|
||||
type SessionEntryPatchProjectionResult,
|
||||
type SessionEntryPatchProjectionSnapshot,
|
||||
type SessionEntryPatchProjectionTarget,
|
||||
type SessionLifecycleArchivedTranscript,
|
||||
type SessionLifecycleArtifactCleanupParams,
|
||||
type SessionLifecycleArtifactCleanupResult,
|
||||
type SessionLifecycleStoreTarget,
|
||||
} from "./store.js";
|
||||
import { parseSessionThreadInfo } from "./thread-info.js";
|
||||
import {
|
||||
@@ -280,7 +287,41 @@ export type SessionPatchProjectionResult<TFailure extends SessionPatchProjection
|
||||
export type SessionPatchProjectionSnapshot = SessionEntryPatchProjectionSnapshot;
|
||||
export type SessionPatchProjectionTarget = SessionEntryPatchProjectionTarget;
|
||||
|
||||
export type { SessionLifecycleArtifactCleanupParams, SessionLifecycleArtifactCleanupResult };
|
||||
export type {
|
||||
DeleteSessionEntryLifecycleResult,
|
||||
ResetSessionEntryLifecycleResult,
|
||||
SessionLifecycleArchivedTranscript,
|
||||
SessionLifecycleArtifactCleanupParams,
|
||||
SessionLifecycleArtifactCleanupResult,
|
||||
SessionLifecycleStoreTarget,
|
||||
};
|
||||
|
||||
export type ResetSessionEntryLifecycleParams = {
|
||||
/** Runs after the persisted entry rotates and before transcript artifacts move. */
|
||||
afterEntryMutation?: (mutation: ResetSessionEntryLifecycleMutation) => Promise<void> | void;
|
||||
/** Agent owner used to resolve backend transcript artifacts. */
|
||||
agentId?: string;
|
||||
/** Builds the persisted replacement entry from the current backend row. */
|
||||
buildNextEntry: (context: {
|
||||
currentEntry?: SessionEntry;
|
||||
primaryKey: string;
|
||||
}) => Promise<SessionEntry> | SessionEntry;
|
||||
/** Explicit store target for file-backed stores and SQLite migration adapters. */
|
||||
storePath: string;
|
||||
/** Canonical key plus aliases that identify the logical entry. */
|
||||
target: SessionLifecycleStoreTarget;
|
||||
};
|
||||
|
||||
export type DeleteSessionEntryLifecycleParams = {
|
||||
/** Agent owner used to resolve backend transcript artifacts. */
|
||||
agentId?: string;
|
||||
/** Whether transcript artifacts should be archived/deleted with the entry. */
|
||||
archiveTranscript: boolean;
|
||||
/** Explicit store target for file-backed stores and SQLite migration adapters. */
|
||||
storePath: string;
|
||||
/** Canonical key plus aliases that identify the logical entry. */
|
||||
target: SessionLifecycleStoreTarget;
|
||||
};
|
||||
|
||||
/** Returns the entry for a canonical or alias session key, if one exists. */
|
||||
export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | undefined {
|
||||
@@ -521,6 +562,20 @@ export async function cleanupSessionLifecycleArtifacts(
|
||||
return await cleanupFileSessionLifecycleArtifacts(params);
|
||||
}
|
||||
|
||||
/** Resets one persisted session entry and transitions its transcript state. */
|
||||
export async function resetSessionEntryLifecycle(
|
||||
params: ResetSessionEntryLifecycleParams,
|
||||
): Promise<ResetSessionEntryLifecycleResult> {
|
||||
return await resetFileSessionEntryLifecycle(params);
|
||||
}
|
||||
|
||||
/** Deletes one persisted session entry and transitions its transcript state. */
|
||||
export async function deleteSessionEntryLifecycle(
|
||||
params: DeleteSessionEntryLifecycleParams,
|
||||
): Promise<DeleteSessionEntryLifecycleResult> {
|
||||
return await deleteFileSessionEntryLifecycle(params);
|
||||
}
|
||||
|
||||
/** Reads parsed transcript records from an explicit or derived transcript target. */
|
||||
export async function loadTranscriptEvents(
|
||||
scope: SessionTranscriptReadScope,
|
||||
|
||||
@@ -5,7 +5,14 @@ export {
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
} from "./store.js";
|
||||
export { deleteSessionEntryLifecycle, resetSessionEntryLifecycle } from "./session-accessor.js";
|
||||
export type {
|
||||
SessionLifecycleArtifactCleanupParams,
|
||||
SessionLifecycleArtifactCleanupResult,
|
||||
} from "./store.js";
|
||||
export type {
|
||||
DeleteSessionEntryLifecycleResult,
|
||||
ResetSessionEntryLifecycleResult,
|
||||
SessionLifecycleArchivedTranscript,
|
||||
SessionLifecycleStoreTarget,
|
||||
} from "./session-accessor.js";
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// File-backed session lifecycle operations own entry mutation and transcript artifact transitions.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { deleteSessionEntryLifecycle, resetSessionEntryLifecycle } from "./session-accessor.js";
|
||||
import { clearSessionStoreCacheForTest, loadSessionStore, saveSessionStore } from "./store.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
describe("session store lifecycle mutations", () => {
|
||||
let tempDir: string;
|
||||
let storePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-lifecycle-mutation-"));
|
||||
storePath = path.join(tempDir, "sessions.json");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearSessionStoreCacheForTest();
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resets an entry while archiving the old transcript and creating the new header", async () => {
|
||||
const oldTranscriptPath = path.join(tempDir, "old-session.jsonl");
|
||||
const nextTranscriptPath = path.join(tempDir, "next-session.jsonl");
|
||||
const now = Date.now();
|
||||
fs.writeFileSync(oldTranscriptPath, '{"type":"session","id":"old-session"}\n', "utf-8");
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
"agent:main:room": {
|
||||
sessionFile: path.join(tempDir, "stale-session.jsonl"),
|
||||
sessionId: "stale-session",
|
||||
updatedAt: now - 1,
|
||||
},
|
||||
"Agent:Main:Room": {
|
||||
sessionFile: oldTranscriptPath,
|
||||
sessionId: "old-session",
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const result = await resetSessionEntryLifecycle({
|
||||
storePath,
|
||||
target: {
|
||||
canonicalKey: "agent:main:room",
|
||||
storeKeys: ["agent:main:room", "Agent:Main:Room"],
|
||||
},
|
||||
buildNextEntry: ({ currentEntry }): SessionEntry => ({
|
||||
...currentEntry,
|
||||
sessionFile: nextTranscriptPath,
|
||||
sessionId: "next-session",
|
||||
updatedAt: now + 1,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
expect(store["agent:main:room"]?.sessionId).toBe("next-session");
|
||||
expect(store["Agent:Main:Room"]).toBeUndefined();
|
||||
expect(result.previousSessionId).toBe("old-session");
|
||||
expect(result.archivedTranscripts).toHaveLength(1);
|
||||
expect(result.archivedTranscripts[0]?.archivedPath).toContain(".jsonl.reset.");
|
||||
expect(fs.existsSync(oldTranscriptPath)).toBe(false);
|
||||
expect(fs.readFileSync(nextTranscriptPath, "utf-8")).toContain('"id":"next-session"');
|
||||
});
|
||||
|
||||
it("deletes an entry while archiving its transcript in the same lifecycle operation", async () => {
|
||||
const transcriptPath = path.join(tempDir, "delete-session.jsonl");
|
||||
const now = Date.now();
|
||||
fs.writeFileSync(transcriptPath, '{"type":"session","id":"delete-session"}\n', "utf-8");
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
{
|
||||
"agent:main:keep": {
|
||||
sessionId: "keep-session",
|
||||
sessionFile: path.join(tempDir, "keep-session.jsonl"),
|
||||
updatedAt: now,
|
||||
},
|
||||
"agent:main:delete": {
|
||||
sessionFile: transcriptPath,
|
||||
sessionId: "delete-session",
|
||||
updatedAt: now - 1,
|
||||
},
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const result = await deleteSessionEntryLifecycle({
|
||||
archiveTranscript: true,
|
||||
storePath,
|
||||
target: {
|
||||
canonicalKey: "agent:main:delete",
|
||||
storeKeys: ["agent:main:delete"],
|
||||
},
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
expect(result.deleted).toBe(true);
|
||||
expect(result.deletedSessionId).toBe("delete-session");
|
||||
expect(result.archivedTranscripts).toHaveLength(1);
|
||||
expect(result.archivedTranscripts[0]?.archivedPath).toContain(".jsonl.deleted.");
|
||||
expect(store["agent:main:delete"]).toBeUndefined();
|
||||
expect(store["agent:main:keep"]?.sessionId).toBe("keep-session");
|
||||
expect(fs.existsSync(transcriptPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
type SessionEntry,
|
||||
type SessionSkillPromptRef,
|
||||
} from "./types.js";
|
||||
import { CURRENT_SESSION_VERSION } from "./version.js";
|
||||
|
||||
export {
|
||||
clearSessionStoreCacheForTest,
|
||||
@@ -241,6 +242,39 @@ export type SessionLifecycleArtifactCleanupResult = {
|
||||
archivedTranscriptArtifacts: number;
|
||||
};
|
||||
|
||||
export type SessionLifecycleStoreTarget = {
|
||||
/** Canonical persisted key for the entry being reset or deleted. */
|
||||
canonicalKey: string;
|
||||
/** Canonical key plus legacy aliases that can still identify the same entry. */
|
||||
storeKeys: string[];
|
||||
};
|
||||
|
||||
export type SessionLifecycleArchivedTranscript = {
|
||||
sourcePath: string;
|
||||
archivedPath: string;
|
||||
};
|
||||
|
||||
export type ResetSessionEntryLifecycleResult = {
|
||||
archivedTranscripts: SessionLifecycleArchivedTranscript[];
|
||||
previousEntry?: SessionEntry;
|
||||
previousSessionFile?: string;
|
||||
previousSessionId?: string;
|
||||
nextEntry: SessionEntry;
|
||||
};
|
||||
|
||||
export type ResetSessionEntryLifecycleMutation = Omit<
|
||||
ResetSessionEntryLifecycleResult,
|
||||
"archivedTranscripts"
|
||||
>;
|
||||
|
||||
export type DeleteSessionEntryLifecycleResult = {
|
||||
archivedTranscripts: SessionLifecycleArchivedTranscript[];
|
||||
deleted: boolean;
|
||||
deletedEntry?: SessionEntry;
|
||||
deletedSessionFile?: string;
|
||||
deletedSessionId?: string;
|
||||
};
|
||||
|
||||
function cloneSessionEntry(entry: SessionEntry): SessionEntry {
|
||||
return cloneSessionStoreRecord({ entry }).entry;
|
||||
}
|
||||
@@ -553,6 +587,98 @@ function sessionEntriesHaveSameSerializedForm(
|
||||
return previous !== undefined && JSON.stringify(previous) === JSON.stringify(next);
|
||||
}
|
||||
|
||||
function cloneOptionalSessionEntry(entry: SessionEntry | undefined): SessionEntry | undefined {
|
||||
return entry ? cloneSessionEntry(entry) : undefined;
|
||||
}
|
||||
|
||||
function resolveLifecyclePrimaryEntry(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
target: SessionLifecycleStoreTarget;
|
||||
}): SessionEntry | undefined {
|
||||
const freshestMatch = resolveFreshestLifecycleStoreMatch({
|
||||
store: params.store,
|
||||
storeKeys: params.target.storeKeys,
|
||||
});
|
||||
if (freshestMatch) {
|
||||
const currentPrimary = params.store[params.target.canonicalKey];
|
||||
if (!currentPrimary || (freshestMatch.entry.updatedAt ?? 0) > (currentPrimary.updatedAt ?? 0)) {
|
||||
params.store[params.target.canonicalKey] = freshestMatch.entry;
|
||||
}
|
||||
}
|
||||
pruneLifecycleLegacyStoreKeys({
|
||||
store: params.store,
|
||||
target: params.target,
|
||||
});
|
||||
return params.store[params.target.canonicalKey];
|
||||
}
|
||||
|
||||
function resolveFreshestLifecycleStoreMatch(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
storeKeys: string[];
|
||||
}): { key: string; entry: SessionEntry } | undefined {
|
||||
let freshest: { key: string; entry: SessionEntry } | undefined;
|
||||
for (const key of params.storeKeys) {
|
||||
const entry = params.store[key];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const match = { key, entry };
|
||||
if (!freshest || (entry.updatedAt ?? 0) > (freshest.entry.updatedAt ?? 0)) {
|
||||
freshest = match;
|
||||
}
|
||||
}
|
||||
return freshest;
|
||||
}
|
||||
|
||||
function pruneLifecycleLegacyStoreKeys(params: {
|
||||
store: Record<string, SessionEntry>;
|
||||
target: SessionLifecycleStoreTarget;
|
||||
}): void {
|
||||
for (const key of params.target.storeKeys) {
|
||||
if (key !== params.target.canonicalKey) {
|
||||
delete params.store[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveLifecycleSessionTranscripts(params: {
|
||||
sessionId?: string;
|
||||
storePath: string;
|
||||
sessionFile?: string;
|
||||
agentId?: string;
|
||||
reason: "reset" | "deleted";
|
||||
}): Promise<SessionLifecycleArchivedTranscript[]> {
|
||||
if (!params.sessionId) {
|
||||
return [];
|
||||
}
|
||||
const { archiveSessionTranscriptsDetailed } = await loadSessionArchiveRuntime();
|
||||
return archiveSessionTranscriptsDetailed({
|
||||
sessionId: params.sessionId,
|
||||
storePath: params.storePath,
|
||||
sessionFile: params.sessionFile,
|
||||
agentId: params.agentId,
|
||||
reason: params.reason,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureLifecycleTranscriptHeader(params: { sessionFile: string; sessionId: string }): void {
|
||||
fs.mkdirSync(path.dirname(params.sessionFile), { recursive: true });
|
||||
if (fs.existsSync(params.sessionFile)) {
|
||||
return;
|
||||
}
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: params.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
fs.writeFileSync(params.sessionFile, `${JSON.stringify(header)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePathForLifecycleComparison(filePath: string): string {
|
||||
try {
|
||||
return path.normalize(fs.realpathSync(filePath));
|
||||
@@ -1072,6 +1198,116 @@ export async function applySessionEntryPatchProjection<
|
||||
});
|
||||
}
|
||||
|
||||
/** Resets one persisted session entry and rotates its file-backed transcript artifacts. */
|
||||
export async function resetSessionEntryLifecycle(params: {
|
||||
afterEntryMutation?: (mutation: ResetSessionEntryLifecycleMutation) => Promise<void> | void;
|
||||
agentId?: string;
|
||||
buildNextEntry: (context: {
|
||||
currentEntry?: SessionEntry;
|
||||
primaryKey: string;
|
||||
}) => Promise<SessionEntry> | SessionEntry;
|
||||
storePath: string;
|
||||
target: SessionLifecycleStoreTarget;
|
||||
}): Promise<ResetSessionEntryLifecycleResult> {
|
||||
return await runExclusiveSessionStoreWrite(params.storePath, async () => {
|
||||
const store = loadMutableSessionStoreForWriter(params.storePath);
|
||||
const currentEntry = resolveLifecyclePrimaryEntry({
|
||||
store,
|
||||
target: params.target,
|
||||
});
|
||||
const previousSessionId = currentEntry?.sessionId;
|
||||
const previousSessionFile = currentEntry?.sessionFile;
|
||||
const nextEntry = await params.buildNextEntry({
|
||||
currentEntry: cloneOptionalSessionEntry(currentEntry),
|
||||
primaryKey: params.target.canonicalKey,
|
||||
});
|
||||
const nextSessionFile = nextEntry.sessionFile?.trim();
|
||||
if (!nextSessionFile) {
|
||||
throw new Error("reset session lifecycle requires next entry sessionFile");
|
||||
}
|
||||
store[params.target.canonicalKey] = nextEntry;
|
||||
await saveSessionStoreUnlocked(params.storePath, store);
|
||||
const mutation: ResetSessionEntryLifecycleMutation = {
|
||||
nextEntry: cloneSessionEntry(nextEntry),
|
||||
};
|
||||
const previousEntry = cloneOptionalSessionEntry(currentEntry);
|
||||
if (previousEntry) {
|
||||
mutation.previousEntry = previousEntry;
|
||||
}
|
||||
if (previousSessionFile) {
|
||||
mutation.previousSessionFile = previousSessionFile;
|
||||
}
|
||||
if (previousSessionId) {
|
||||
mutation.previousSessionId = previousSessionId;
|
||||
}
|
||||
await params.afterEntryMutation?.(mutation);
|
||||
const archivedTranscripts = await archiveLifecycleSessionTranscripts({
|
||||
sessionId: previousSessionId,
|
||||
storePath: params.storePath,
|
||||
sessionFile: previousSessionFile,
|
||||
agentId: params.agentId,
|
||||
reason: "reset",
|
||||
});
|
||||
ensureLifecycleTranscriptHeader({
|
||||
sessionFile: nextSessionFile,
|
||||
sessionId: nextEntry.sessionId,
|
||||
});
|
||||
const result: ResetSessionEntryLifecycleResult = {
|
||||
...mutation,
|
||||
archivedTranscripts,
|
||||
};
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes one persisted session entry and archives its file-backed transcript artifacts. */
|
||||
export async function deleteSessionEntryLifecycle(params: {
|
||||
agentId?: string;
|
||||
archiveTranscript: boolean;
|
||||
storePath: string;
|
||||
target: SessionLifecycleStoreTarget;
|
||||
}): Promise<DeleteSessionEntryLifecycleResult> {
|
||||
return await runExclusiveSessionStoreWrite(params.storePath, async () => {
|
||||
const store = loadMutableSessionStoreForWriter(params.storePath);
|
||||
const deletedEntry = resolveLifecyclePrimaryEntry({
|
||||
store,
|
||||
target: params.target,
|
||||
});
|
||||
if (!deletedEntry) {
|
||||
restoreUnchangedSessionStoreCache(params.storePath, store);
|
||||
return {
|
||||
archivedTranscripts: [],
|
||||
deleted: false,
|
||||
};
|
||||
}
|
||||
const deletedSessionId = deletedEntry.sessionId;
|
||||
const deletedSessionFile = deletedEntry.sessionFile;
|
||||
delete store[params.target.canonicalKey];
|
||||
await saveSessionStoreUnlocked(params.storePath, store);
|
||||
const archivedTranscripts = params.archiveTranscript
|
||||
? await archiveLifecycleSessionTranscripts({
|
||||
sessionId: deletedSessionId,
|
||||
storePath: params.storePath,
|
||||
sessionFile: deletedSessionFile,
|
||||
agentId: params.agentId,
|
||||
reason: "deleted",
|
||||
})
|
||||
: [];
|
||||
const result: DeleteSessionEntryLifecycleResult = {
|
||||
archivedTranscripts,
|
||||
deleted: true,
|
||||
};
|
||||
result.deletedEntry = cloneSessionEntry(deletedEntry);
|
||||
if (deletedSessionFile) {
|
||||
result.deletedSessionFile = deletedSessionFile;
|
||||
}
|
||||
if (deletedSessionId) {
|
||||
result.deletedSessionId = deletedSessionId;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveUnreferencedLifecycleTranscriptArtifacts(params: {
|
||||
storePath: string;
|
||||
transcriptContentMarker: string;
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
serializeSessionCleanupResult,
|
||||
resolveMainSessionKey,
|
||||
listConfiguredSessionStoreAgentIds,
|
||||
deleteSessionEntryLifecycle,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
@@ -2292,7 +2293,6 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
|
||||
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
|
||||
const {
|
||||
archiveSessionTranscriptsForSessionDetailed,
|
||||
cleanupSessionBeforeMutation,
|
||||
emitGatewaySessionEndPluginHook,
|
||||
emitSessionUnboundLifecycleEvent,
|
||||
@@ -2317,31 +2317,19 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
respond(false, undefined, mutationCleanupError);
|
||||
return;
|
||||
}
|
||||
const sessionId = entry?.sessionId;
|
||||
const deleted = await updateSessionStore(storePath, (store) => {
|
||||
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key,
|
||||
store,
|
||||
agentId: requestedAgentId,
|
||||
});
|
||||
const hadEntry = Boolean(store[primaryKey]);
|
||||
if (hadEntry) {
|
||||
delete store[primaryKey];
|
||||
}
|
||||
return hadEntry;
|
||||
const deletion = await deleteSessionEntryLifecycle({
|
||||
agentId: target.agentId,
|
||||
archiveTranscript: deleteTranscript,
|
||||
storePath,
|
||||
target: {
|
||||
canonicalKey: target.canonicalKey,
|
||||
storeKeys: target.storeKeys,
|
||||
},
|
||||
});
|
||||
|
||||
const archivedTranscripts =
|
||||
deleted && deleteTranscript
|
||||
? archiveSessionTranscriptsForSessionDetailed({
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
agentId: target.agentId,
|
||||
reason: "deleted",
|
||||
})
|
||||
: [];
|
||||
const deleted = deletion.deleted;
|
||||
const sessionId = deletion.deletedSessionId;
|
||||
const sessionFile = deletion.deletedSessionFile;
|
||||
const archivedTranscripts = deletion.archivedTranscripts;
|
||||
const archived = archivedTranscripts.map((entryLocal) => entryLocal.archivedPath);
|
||||
if (deleted) {
|
||||
emitGatewaySessionEndPluginHook({
|
||||
@@ -2349,7 +2337,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
sessionKey: target.canonicalKey ?? key,
|
||||
sessionId,
|
||||
storePath,
|
||||
sessionFile: entry?.sessionFile,
|
||||
sessionFile,
|
||||
agentId: target.agentId,
|
||||
reason: "deleted",
|
||||
archivedTranscripts,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Gateway session reset/delete service.
|
||||
// Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
|
||||
@@ -31,7 +30,7 @@ import { getRuntimeConfig } from "../config/io.js";
|
||||
import {
|
||||
snapshotSessionOrigin,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
resetSessionEntryLifecycle,
|
||||
} from "../config/sessions.js";
|
||||
import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../config/sessions/paths.js";
|
||||
import { resolveResetPreservedSelection } from "../config/sessions/reset-preserved-selection.js";
|
||||
@@ -40,7 +39,6 @@ import {
|
||||
rewriteSessionFileForNewSessionId,
|
||||
} from "../config/sessions/session-file-rotation.js";
|
||||
import type { SessionAcpMeta } from "../config/sessions/types.js";
|
||||
import { CURRENT_SESSION_VERSION } from "../config/sessions/version.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { logVerbose } from "../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
|
||||
@@ -68,7 +66,6 @@ import {
|
||||
import { readSessionMessagesAsync } from "./session-transcript-readers.js";
|
||||
import {
|
||||
loadSessionEntry,
|
||||
migrateAndPruneGatewaySessionStoreKey,
|
||||
resolveGatewaySessionStoreTarget,
|
||||
resolveSessionStoreKey,
|
||||
resolveSessionModelRef,
|
||||
@@ -963,195 +960,174 @@ export async function performGatewaySessionReset(params: {
|
||||
reason: "session-reset",
|
||||
});
|
||||
|
||||
let oldSessionId: string | undefined;
|
||||
let oldSessionFile: string | undefined;
|
||||
let resetSourceEntry: SessionEntry | undefined;
|
||||
const next = await updateSessionStore(storePath, (store) => {
|
||||
const { primaryKey } = migrateAndPruneGatewaySessionStoreKey({
|
||||
cfg,
|
||||
key: params.key,
|
||||
store,
|
||||
...(requestedAgentId ? { agentId: requestedAgentId } : {}),
|
||||
});
|
||||
const currentEntry = store[primaryKey];
|
||||
if (!isResetLifecycleCurrent() && currentEntry?.sessionId !== entry?.sessionId) {
|
||||
// A newer owner already replaced or removed the session while cleanup
|
||||
// targeted the old id. Preserve that newer state instead of resetting it.
|
||||
params.assertCurrent?.();
|
||||
}
|
||||
resetSourceEntry = currentEntry ? { ...currentEntry } : undefined;
|
||||
const parsed = parseAgentSessionKey(primaryKey);
|
||||
const sessionAgentId = normalizeAgentId(
|
||||
parsed?.agentId ?? target.agentId ?? requestedAgentId ?? resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const resetPreservedSelection = resolveResetPreservedSelection({
|
||||
entry: currentEntry,
|
||||
});
|
||||
const resetEntry = {
|
||||
...stripRuntimeModelState(currentEntry),
|
||||
providerOverride: undefined,
|
||||
modelOverride: undefined,
|
||||
modelOverrideSource: undefined,
|
||||
authProfileOverride: undefined,
|
||||
authProfileOverrideSource: undefined,
|
||||
authProfileOverrideCompactionCount: undefined,
|
||||
...resetPreservedSelection,
|
||||
};
|
||||
const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId);
|
||||
oldSessionId = currentEntry?.sessionId;
|
||||
oldSessionFile = currentEntry?.sessionFile;
|
||||
const now = Date.now();
|
||||
const nextSessionId = randomUUID();
|
||||
const sessionFile = resolveResetSessionFile({
|
||||
nextSessionId,
|
||||
currentEntry,
|
||||
storePath,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId: nextSessionId,
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: currentEntry?.thinkingLevel,
|
||||
fastMode: currentEntry?.fastMode,
|
||||
verboseLevel: currentEntry?.verboseLevel,
|
||||
traceLevel: currentEntry?.traceLevel,
|
||||
reasoningLevel: currentEntry?.reasoningLevel,
|
||||
elevatedLevel: currentEntry?.elevatedLevel,
|
||||
ttsAuto: currentEntry?.ttsAuto,
|
||||
execHost: currentEntry?.execHost,
|
||||
execSecurity: currentEntry?.execSecurity,
|
||||
execAsk: currentEntry?.execAsk,
|
||||
execNode: currentEntry?.execNode,
|
||||
responseUsage: currentEntry?.responseUsage,
|
||||
// Resets should keep the user's explicit selection, but clear any
|
||||
// temporary fallback model that was pinned during the previous run.
|
||||
...resetPreservedSelection,
|
||||
groupActivation: currentEntry?.groupActivation,
|
||||
groupActivationNeedsSystemIntro: currentEntry?.groupActivationNeedsSystemIntro,
|
||||
chatType: currentEntry?.chatType,
|
||||
model: resolvedModel.model,
|
||||
modelProvider: resolvedModel.provider,
|
||||
contextTokens: resetEntry?.contextTokens,
|
||||
compactionCount: currentEntry?.compactionCount,
|
||||
compactionCheckpoints: currentEntry?.compactionCheckpoints,
|
||||
sendPolicy: currentEntry?.sendPolicy,
|
||||
queueMode: currentEntry?.queueMode,
|
||||
queueDebounceMs: currentEntry?.queueDebounceMs,
|
||||
queueCap: currentEntry?.queueCap,
|
||||
queueDrop: currentEntry?.queueDrop,
|
||||
spawnedBy: currentEntry?.spawnedBy,
|
||||
spawnedWorkspaceDir: currentEntry?.spawnedWorkspaceDir,
|
||||
spawnedCwd: currentEntry?.spawnedCwd,
|
||||
parentSessionKey: currentEntry?.parentSessionKey,
|
||||
forkedFromParent: currentEntry?.forkedFromParent,
|
||||
spawnDepth: currentEntry?.spawnDepth,
|
||||
subagentRole: currentEntry?.subagentRole,
|
||||
subagentControlScope: currentEntry?.subagentControlScope,
|
||||
label: currentEntry?.label,
|
||||
displayName: currentEntry?.displayName,
|
||||
channel: currentEntry?.channel,
|
||||
groupId: currentEntry?.groupId,
|
||||
subject: currentEntry?.subject,
|
||||
groupChannel: currentEntry?.groupChannel,
|
||||
space: currentEntry?.space,
|
||||
origin: snapshotSessionOrigin(currentEntry),
|
||||
deliveryContext: currentEntry?.deliveryContext,
|
||||
cliSessionBindings: currentEntry?.cliSessionBindings,
|
||||
cliSessionIds: currentEntry?.cliSessionIds,
|
||||
claudeCliSessionId: currentEntry?.claudeCliSessionId,
|
||||
lastChannel: currentEntry?.lastChannel,
|
||||
lastTo: currentEntry?.lastTo,
|
||||
lastAccountId: currentEntry?.lastAccountId,
|
||||
lastThreadId: currentEntry?.lastThreadId,
|
||||
// Do not carry the cached skills catalog across /new. Long-lived channel
|
||||
// sessions (Signal DMs/groups in particular) otherwise keep advertising a
|
||||
// stale <available_skills> block even after reset/restart, because the
|
||||
// skills snapshot version is runtime-local and may reset to 0.
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: true,
|
||||
};
|
||||
// Drop CLI provider bindings so the next turn after reset starts a fresh
|
||||
// CLI conversation on the provider side. Preserved only for spawned
|
||||
// subagents (canonical `:subagent:` keys), where Tak Hoffman's fa56682b3ced
|
||||
// regression fix intentionally protects CLI continuity for
|
||||
// orchestration-driven resets. Non-subagent sessions that happen to set
|
||||
// `parentSessionKey` (e.g. dashboard children) are not exempt.
|
||||
if (!isSubagentSessionKey(primaryKey)) {
|
||||
clearAllCliSessions(nextEntry);
|
||||
}
|
||||
store[primaryKey] = nextEntry;
|
||||
return nextEntry;
|
||||
});
|
||||
let committedAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined;
|
||||
if (deferredAcpResetState) {
|
||||
const identity = deferredAcpResetState.meta.identity;
|
||||
if (identity?.state === "resolved" && (identity.acpxSessionId || identity.agentSessionId)) {
|
||||
committedAcpResetState = {
|
||||
sessionKey: deferredAcpResetState.sessionKey,
|
||||
meta: buildPendingAcpMeta(deferredAcpResetState.meta, Date.now()),
|
||||
};
|
||||
// The JSON session rotation and SQLite metadata cannot share a transaction.
|
||||
// Bind captured ACP state before acknowledging the committed reset so the
|
||||
// new session never observes an unreadable old-session row.
|
||||
writeAcpSessionMetaForMigration({
|
||||
sessionKey: committedAcpResetState.sessionKey,
|
||||
sessionId: next.sessionId,
|
||||
meta: committedAcpResetState.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
params.onCommitted?.({
|
||||
key: target.canonicalKey,
|
||||
sessionId: next.sessionId,
|
||||
});
|
||||
if (committedAcpResetState && isResetLifecycleCurrent()) {
|
||||
try {
|
||||
await getAcpRuntimeBackend(
|
||||
(committedAcpResetState.meta.backend || cfg.acp?.backend || "").trim() || undefined,
|
||||
)?.runtime.prepareFreshSession?.({
|
||||
sessionKey: committedAcpResetState.sessionKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logVerbose(
|
||||
`sessions.session-reset: ACP prepareFreshSession failed for ${committedAcpResetState.sessionKey}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await emitGatewayBeforeResetPluginHook({
|
||||
cfg,
|
||||
key: params.key,
|
||||
target,
|
||||
storePath,
|
||||
entry: resetSourceEntry,
|
||||
reason: params.reason,
|
||||
});
|
||||
|
||||
const archivedTranscripts = archiveSessionTranscriptsForSessionDetailed({
|
||||
sessionId: oldSessionId,
|
||||
storePath,
|
||||
sessionFile: oldSessionFile,
|
||||
const lifecycle = await resetSessionEntryLifecycle({
|
||||
agentId: target.agentId,
|
||||
reason: "reset",
|
||||
storePath,
|
||||
target: {
|
||||
canonicalKey: target.canonicalKey,
|
||||
storeKeys: target.storeKeys,
|
||||
},
|
||||
buildNextEntry: ({ currentEntry, primaryKey }) => {
|
||||
if (!isResetLifecycleCurrent() && currentEntry?.sessionId !== entry?.sessionId) {
|
||||
// A newer owner already replaced or removed the session while cleanup
|
||||
// targeted the old id. Preserve that newer state instead of resetting it.
|
||||
params.assertCurrent?.();
|
||||
}
|
||||
const parsed = parseAgentSessionKey(primaryKey);
|
||||
const sessionAgentId = normalizeAgentId(
|
||||
parsed?.agentId ?? target.agentId ?? requestedAgentId ?? resolveDefaultAgentId(cfg),
|
||||
);
|
||||
const resetPreservedSelection = resolveResetPreservedSelection({
|
||||
entry: currentEntry,
|
||||
});
|
||||
const resetEntry = {
|
||||
...stripRuntimeModelState(currentEntry),
|
||||
providerOverride: undefined,
|
||||
modelOverride: undefined,
|
||||
modelOverrideSource: undefined,
|
||||
authProfileOverride: undefined,
|
||||
authProfileOverrideSource: undefined,
|
||||
authProfileOverrideCompactionCount: undefined,
|
||||
...resetPreservedSelection,
|
||||
};
|
||||
const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId);
|
||||
const now = Date.now();
|
||||
const nextSessionId = randomUUID();
|
||||
const sessionFile = resolveResetSessionFile({
|
||||
nextSessionId,
|
||||
currentEntry,
|
||||
storePath,
|
||||
agentId: sessionAgentId,
|
||||
});
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId: nextSessionId,
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
abortedLastRun: false,
|
||||
thinkingLevel: currentEntry?.thinkingLevel,
|
||||
fastMode: currentEntry?.fastMode,
|
||||
verboseLevel: currentEntry?.verboseLevel,
|
||||
traceLevel: currentEntry?.traceLevel,
|
||||
reasoningLevel: currentEntry?.reasoningLevel,
|
||||
elevatedLevel: currentEntry?.elevatedLevel,
|
||||
ttsAuto: currentEntry?.ttsAuto,
|
||||
execHost: currentEntry?.execHost,
|
||||
execSecurity: currentEntry?.execSecurity,
|
||||
execAsk: currentEntry?.execAsk,
|
||||
execNode: currentEntry?.execNode,
|
||||
responseUsage: currentEntry?.responseUsage,
|
||||
// Resets should keep the user's explicit selection, but clear any
|
||||
// temporary fallback model that was pinned during the previous run.
|
||||
...resetPreservedSelection,
|
||||
groupActivation: currentEntry?.groupActivation,
|
||||
groupActivationNeedsSystemIntro: currentEntry?.groupActivationNeedsSystemIntro,
|
||||
chatType: currentEntry?.chatType,
|
||||
model: resolvedModel.model,
|
||||
modelProvider: resolvedModel.provider,
|
||||
contextTokens: resetEntry?.contextTokens,
|
||||
compactionCount: currentEntry?.compactionCount,
|
||||
compactionCheckpoints: currentEntry?.compactionCheckpoints,
|
||||
sendPolicy: currentEntry?.sendPolicy,
|
||||
queueMode: currentEntry?.queueMode,
|
||||
queueDebounceMs: currentEntry?.queueDebounceMs,
|
||||
queueCap: currentEntry?.queueCap,
|
||||
queueDrop: currentEntry?.queueDrop,
|
||||
spawnedBy: currentEntry?.spawnedBy,
|
||||
spawnedWorkspaceDir: currentEntry?.spawnedWorkspaceDir,
|
||||
spawnedCwd: currentEntry?.spawnedCwd,
|
||||
parentSessionKey: currentEntry?.parentSessionKey,
|
||||
forkedFromParent: currentEntry?.forkedFromParent,
|
||||
spawnDepth: currentEntry?.spawnDepth,
|
||||
subagentRole: currentEntry?.subagentRole,
|
||||
subagentControlScope: currentEntry?.subagentControlScope,
|
||||
label: currentEntry?.label,
|
||||
displayName: currentEntry?.displayName,
|
||||
channel: currentEntry?.channel,
|
||||
groupId: currentEntry?.groupId,
|
||||
subject: currentEntry?.subject,
|
||||
groupChannel: currentEntry?.groupChannel,
|
||||
space: currentEntry?.space,
|
||||
origin: snapshotSessionOrigin(currentEntry),
|
||||
deliveryContext: currentEntry?.deliveryContext,
|
||||
cliSessionBindings: currentEntry?.cliSessionBindings,
|
||||
cliSessionIds: currentEntry?.cliSessionIds,
|
||||
claudeCliSessionId: currentEntry?.claudeCliSessionId,
|
||||
lastChannel: currentEntry?.lastChannel,
|
||||
lastTo: currentEntry?.lastTo,
|
||||
lastAccountId: currentEntry?.lastAccountId,
|
||||
lastThreadId: currentEntry?.lastThreadId,
|
||||
// Do not carry the cached skills catalog across /new. Long-lived channel
|
||||
// sessions (Signal DMs/groups in particular) otherwise keep advertising a
|
||||
// stale <available_skills> block even after reset/restart, because the
|
||||
// skills snapshot version is runtime-local and may reset to 0.
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalTokensFresh: true,
|
||||
};
|
||||
// Drop CLI provider bindings so the next turn after reset starts a fresh
|
||||
// CLI conversation on the provider side. Preserved only for spawned
|
||||
// subagents (canonical `:subagent:` keys), where Tak Hoffman's fa56682b3ced
|
||||
// regression fix intentionally protects CLI continuity for
|
||||
// orchestration-driven resets. Non-subagent sessions that happen to set
|
||||
// `parentSessionKey` (e.g. dashboard children) are not exempt.
|
||||
if (!isSubagentSessionKey(primaryKey)) {
|
||||
clearAllCliSessions(nextEntry);
|
||||
}
|
||||
return nextEntry;
|
||||
},
|
||||
afterEntryMutation: async (mutation) => {
|
||||
let committedAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined;
|
||||
if (deferredAcpResetState) {
|
||||
const identity = deferredAcpResetState.meta.identity;
|
||||
if (identity?.state === "resolved" && (identity.acpxSessionId || identity.agentSessionId)) {
|
||||
committedAcpResetState = {
|
||||
sessionKey: deferredAcpResetState.sessionKey,
|
||||
meta: buildPendingAcpMeta(deferredAcpResetState.meta, Date.now()),
|
||||
};
|
||||
// The JSON session rotation and SQLite metadata cannot share a transaction.
|
||||
// Bind captured ACP state before acknowledging the committed reset so the
|
||||
// new session never observes an unreadable old-session row.
|
||||
writeAcpSessionMetaForMigration({
|
||||
sessionKey: committedAcpResetState.sessionKey,
|
||||
sessionId: mutation.nextEntry.sessionId,
|
||||
meta: committedAcpResetState.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
params.onCommitted?.({
|
||||
key: target.canonicalKey,
|
||||
sessionId: mutation.nextEntry.sessionId,
|
||||
});
|
||||
if (committedAcpResetState && isResetLifecycleCurrent()) {
|
||||
try {
|
||||
await getAcpRuntimeBackend(
|
||||
(committedAcpResetState.meta.backend || cfg.acp?.backend || "").trim() || undefined,
|
||||
)?.runtime.prepareFreshSession?.({
|
||||
sessionKey: committedAcpResetState.sessionKey,
|
||||
});
|
||||
} catch (error) {
|
||||
logVerbose(
|
||||
`sessions.session-reset: ACP prepareFreshSession failed for ${committedAcpResetState.sessionKey}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await emitGatewayBeforeResetPluginHook({
|
||||
cfg,
|
||||
key: params.key,
|
||||
target,
|
||||
storePath,
|
||||
entry: mutation.previousEntry,
|
||||
reason: params.reason,
|
||||
});
|
||||
},
|
||||
});
|
||||
fs.mkdirSync(path.dirname(next.sessionFile as string), { recursive: true });
|
||||
if (!fs.existsSync(next.sessionFile as string)) {
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: next.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
fs.writeFileSync(next.sessionFile as string, `${JSON.stringify(header)}\n`, {
|
||||
encoding: "utf-8",
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
const next = lifecycle.nextEntry;
|
||||
const oldSessionId = lifecycle.previousSessionId;
|
||||
const oldSessionFile = lifecycle.previousSessionFile;
|
||||
|
||||
const archivedTranscripts = lifecycle.archivedTranscripts;
|
||||
emitGatewaySessionEndPluginHook({
|
||||
cfg,
|
||||
sessionKey: target.canonicalKey ?? params.key,
|
||||
|
||||
Reference in New Issue
Block a user