From c41bc58cf61239e560dae062bfb6b78ecf681e90 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Wed, 17 Jun 2026 10:43:47 -0700 Subject: [PATCH] refactor: add session reset delete lifecycle seam (#93659) * refactor: add session reset delete lifecycle seam * refactor: expose session lifecycle seam through accessor --- src/config/sessions.ts | 10 + src/config/sessions/session-accessor.ts | 57 ++- src/config/sessions/store.runtime.ts | 7 + .../store.session-lifecycle-mutation.test.ts | 111 ++++++ src/config/sessions/store.ts | 236 ++++++++++++ src/gateway/server-methods/sessions.ts | 40 +- src/gateway/session-reset-service.ts | 358 ++++++++---------- 7 files changed, 601 insertions(+), 218 deletions(-) create mode 100644 src/config/sessions/store.session-lifecycle-mutation.test.ts diff --git a/src/config/sessions.ts b/src/config/sessions.ts index f6266574f15b..de3ea374e9da 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -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"; diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 5b5eaeebafcf..ce88eeebfdaa 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -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 Promise | 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; + /** 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 { + return await resetFileSessionEntryLifecycle(params); +} + +/** Deletes one persisted session entry and transitions its transcript state. */ +export async function deleteSessionEntryLifecycle( + params: DeleteSessionEntryLifecycleParams, +): Promise { + return await deleteFileSessionEntryLifecycle(params); +} + /** Reads parsed transcript records from an explicit or derived transcript target. */ export async function loadTranscriptEvents( scope: SessionTranscriptReadScope, diff --git a/src/config/sessions/store.runtime.ts b/src/config/sessions/store.runtime.ts index 53e6249ecea5..ea70a5647aec 100644 --- a/src/config/sessions/store.runtime.ts +++ b/src/config/sessions/store.runtime.ts @@ -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"; diff --git a/src/config/sessions/store.session-lifecycle-mutation.test.ts b/src/config/sessions/store.session-lifecycle-mutation.test.ts new file mode 100644 index 000000000000..0018fdb585c9 --- /dev/null +++ b/src/config/sessions/store.session-lifecycle-mutation.test.ts @@ -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); + }); +}); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index fa8cfeb2ae13..50e7f7cd5112 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -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; + 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; + 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; + 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 { + 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; + agentId?: string; + buildNextEntry: (context: { + currentEntry?: SessionEntry; + primaryKey: string; + }) => Promise | SessionEntry; + storePath: string; + target: SessionLifecycleStoreTarget; +}): Promise { + 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 { + 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; diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index ba243cfa56b9..3497d1b52e13 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 4a057c32e41c..1c1859e2f373 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -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 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 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,