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:
Josh Lehman
2026-06-17 10:43:47 -07:00
committed by GitHub
parent 8ce486a3be
commit c41bc58cf6
7 changed files with 601 additions and 218 deletions
+10
View File
@@ -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";
+56 -1
View File
@@ -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,
+7
View File
@@ -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);
});
});
+236
View File
@@ -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;
+14 -26
View File
@@ -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,
+167 -191
View File
@@ -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,