fix(gateway): split conversation reset from admin reset

This commit is contained in:
Peter Steinberger
2026-03-11 02:49:38 +00:00
parent 0ab8d20917
commit c91d1622d5
6 changed files with 456 additions and 420 deletions

View File

@@ -88,6 +88,7 @@ Docs: https://docs.openclaw.ai
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
## 2026.3.8

View File

@@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({
updateSessionStore: vi.fn(),
agentCommand: vi.fn(),
registerAgentRunContext: vi.fn(),
sessionsResetHandler: vi.fn(),
performGatewaySessionReset: vi.fn(),
loadConfigReturn: {} as Record<string, unknown>,
}));
@@ -62,11 +62,9 @@ vi.mock("../../infra/agent-events.js", () => ({
onAgentEvent: vi.fn(),
}));
vi.mock("./sessions.js", () => ({
sessionsHandlers: {
"sessions.reset": (...args: unknown[]) =>
(mocks.sessionsResetHandler as (...args: unknown[]) => unknown)(...args),
},
vi.mock("../session-reset-service.js", () => ({
performGatewaySessionReset: (...args: unknown[]) =>
(mocks.performGatewaySessionReset as (...args: unknown[]) => unknown)(...args),
}));
vi.mock("../../sessions/send-policy.js", () => ({
@@ -158,7 +156,7 @@ function resetTimeConfig() {
async function expectResetCall(expectedMessage: string) {
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1);
expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1);
const call = readLastAgentCommandCall();
expect(call?.message).toBe(expectedMessage);
return call;
@@ -208,18 +206,16 @@ function mockSessionResetSuccess(params: {
}) {
const key = params.key ?? "agent:main:main";
const sessionId = params.sessionId ?? "reset-session-id";
mocks.sessionsResetHandler.mockImplementation(
async (opts: {
params: { key: string; reason: string };
respond: (ok: boolean, payload?: unknown) => void;
}) => {
expect(opts.params.key).toBe(key);
expect(opts.params.reason).toBe(params.reason);
opts.respond(true, {
mocks.performGatewaySessionReset.mockImplementation(
async (opts: { key: string; reason: string; commandSource: string }) => {
expect(opts.key).toBe(key);
expect(opts.reason).toBe(params.reason);
expect(opts.commandSource).toBe("gateway:agent");
return {
ok: true,
key,
entry: { sessionId },
});
};
},
);
}
@@ -560,7 +556,7 @@ describe("gateway agent handler", () => {
);
await vi.waitFor(() => expect(mocks.agentCommand).toHaveBeenCalled());
expect(mocks.sessionsResetHandler).toHaveBeenCalledTimes(1);
expect(mocks.performGatewaySessionReset).toHaveBeenCalledTimes(1);
const call = readLastAgentCommandCall();
// Message is now dynamically built with current date — check key substrings
expect(call?.message).toContain("Execute your Session Startup sequence now");
@@ -572,7 +568,7 @@ describe("gateway agent handler", () => {
it("uses /reset suffix as the post-reset message and still injects timestamp", async () => {
setupNewYorkTimeConfig("2026-01-29T01:30:00.000Z");
mockSessionResetSuccess({ reason: "reset" });
mocks.sessionsResetHandler.mockClear();
mocks.performGatewaySessionReset.mockClear();
primeMainAgentRun({
sessionId: "reset-session-id",
cfg: mocks.loadConfigReturn,

View File

@@ -46,6 +46,7 @@ import {
validateAgentParams,
validateAgentWaitParams,
} from "../protocol/index.js";
import { performGatewaySessionReset } from "../session-reset-service.js";
import {
canonicalizeSpawnedByForAgent,
loadSessionEntry,
@@ -62,7 +63,6 @@ import {
waitForTerminalGatewayDedupe,
} from "./agent-wait-dedupe.js";
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
import { sessionsHandlers } from "./sessions.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
const RESET_COMMAND_RE = /^\/(new|reset)(?:\s+([\s\S]*))?$/i;
@@ -72,101 +72,26 @@ function resolveSenderIsOwnerFromClient(client: GatewayRequestHandlerOptions["cl
return scopes.includes(ADMIN_SCOPE);
}
function isGatewayErrorShape(value: unknown): value is { code: string; message: string } {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as { code?: unknown; message?: unknown };
return typeof candidate.code === "string" && typeof candidate.message === "string";
}
async function runSessionResetFromAgent(params: {
key: string;
reason: "new" | "reset";
idempotencyKey: string;
context: GatewayRequestHandlerOptions["context"];
client: GatewayRequestHandlerOptions["client"];
isWebchatConnect: GatewayRequestHandlerOptions["isWebchatConnect"];
}): Promise<
| { ok: true; key: string; sessionId?: string }
| { ok: false; error: ReturnType<typeof errorShape> }
> {
return await new Promise((resolve) => {
let settled = false;
const settle = (
result:
| { ok: true; key: string; sessionId?: string }
| { ok: false; error: ReturnType<typeof errorShape> },
) => {
if (settled) {
return;
}
settled = true;
resolve(result);
};
const respond: GatewayRequestHandlerOptions["respond"] = (ok, payload, error) => {
if (!ok) {
settle({
ok: false,
error: isGatewayErrorShape(error)
? error
: errorShape(ErrorCodes.UNAVAILABLE, String(error ?? "sessions.reset failed")),
});
return;
}
const payloadObj = payload as
| {
key?: unknown;
entry?: {
sessionId?: unknown;
};
}
| undefined;
const key = typeof payloadObj?.key === "string" ? payloadObj.key : params.key;
const sessionId =
payloadObj?.entry && typeof payloadObj.entry.sessionId === "string"
? payloadObj.entry.sessionId
: undefined;
settle({ ok: true, key, sessionId });
};
const resetResult = sessionsHandlers["sessions.reset"]({
req: {
type: "req",
id: `${params.idempotencyKey}:reset`,
method: "sessions.reset",
},
params: {
key: params.key,
reason: params.reason,
},
context: params.context,
client: params.client,
isWebchatConnect: params.isWebchatConnect,
respond,
});
void (async () => {
try {
await resetResult;
if (!settled) {
settle({
ok: false,
error: errorShape(
ErrorCodes.UNAVAILABLE,
"sessions.reset completed without returning a response",
),
});
}
} catch (err: unknown) {
settle({
ok: false,
error: errorShape(ErrorCodes.UNAVAILABLE, String(err)),
});
}
})();
const result = await performGatewaySessionReset({
key: params.key,
reason: params.reason,
commandSource: "gateway:agent",
});
if (!result.ok) {
return result;
}
return {
ok: true,
key: result.key,
sessionId: result.entry.sessionId,
};
}
function dispatchAgentRunFromGateway(params: {
@@ -399,10 +324,6 @@ export const agentHandlers: GatewayRequestHandlers = {
const resetResult = await runSessionResetFromAgent({
key: requestedSessionKey,
reason: resetReason,
idempotencyKey: idem,
context,
client,
isWebchatConnect,
});
if (!resetResult.ok) {
respond(false, undefined, resetResult.error);

View File

@@ -1,29 +1,13 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { clearBootstrapSnapshot } from "../../agents/bootstrap-cache.js";
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../../agents/pi-embedded.js";
import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
import { closeTrackedBrowserTabsForSessions } from "../../browser/session-tab-registry.js";
import { loadConfig } from "../../config/config.js";
import {
loadSessionStore,
snapshotSessionOrigin,
resolveMainSessionKey,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import { unbindThreadBindingsBySessionKey } from "../../discord/monitor/thread-bindings.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js";
import {
ErrorCodes,
@@ -36,9 +20,14 @@ import {
validateSessionsResetParams,
validateSessionsResolveParams,
} from "../protocol/index.js";
import {
archiveSessionTranscriptsForSession,
cleanupSessionBeforeMutation,
emitSessionUnboundLifecycleEvent,
performGatewaySessionReset,
} from "../session-reset-service.js";
import {
archiveFileOnDisk,
archiveSessionTranscripts,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
@@ -128,219 +117,6 @@ function migrateAndPruneSessionStoreKey(params: {
return { target, primaryKey, entry: params.store[primaryKey] };
}
function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined {
if (!entry) {
return entry;
}
return {
...entry,
model: undefined,
modelProvider: undefined,
contextTokens: undefined,
systemPromptReport: undefined,
};
}
function archiveSessionTranscriptsForSession(params: {
sessionId: string | undefined;
storePath: string;
sessionFile?: string;
agentId?: string;
reason: "reset" | "deleted";
}): string[] {
if (!params.sessionId) {
return [];
}
return archiveSessionTranscripts({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
agentId: params.agentId,
reason: params.reason,
});
}
async function emitSessionUnboundLifecycleEvent(params: {
targetSessionKey: string;
reason: "session-reset" | "session-delete";
emitHooks?: boolean;
}) {
const targetKind = isSubagentSessionKey(params.targetSessionKey) ? "subagent" : "acp";
unbindThreadBindingsBySessionKey({
targetSessionKey: params.targetSessionKey,
targetKind,
reason: params.reason,
sendFarewell: true,
});
if (params.emitHooks === false) {
return;
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("subagent_ended")) {
return;
}
await hookRunner.runSubagentEnded(
{
targetSessionKey: params.targetSessionKey,
targetKind,
reason: params.reason,
sendFarewell: true,
outcome: params.reason === "session-reset" ? "reset" : "deleted",
},
{
childSessionKey: params.targetSessionKey,
},
);
}
async function ensureSessionRuntimeCleanup(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
sessionId?: string;
}) {
const closeTrackedBrowserTabs = async () => {
const closeKeys = new Set<string>([
params.key,
params.target.canonicalKey,
...params.target.storeKeys,
params.sessionId ?? "",
]);
return await closeTrackedBrowserTabsForSessions({
sessionKeys: [...closeKeys],
onWarn: (message) => logVerbose(message),
});
};
const queueKeys = new Set<string>(params.target.storeKeys);
queueKeys.add(params.target.canonicalKey);
if (params.sessionId) {
queueKeys.add(params.sessionId);
}
clearSessionQueues([...queueKeys]);
stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: params.target.canonicalKey });
if (!params.sessionId) {
clearBootstrapSnapshot(params.target.canonicalKey);
await closeTrackedBrowserTabs();
return undefined;
}
abortEmbeddedPiRun(params.sessionId);
const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000);
clearBootstrapSnapshot(params.target.canonicalKey);
if (ended) {
await closeTrackedBrowserTabs();
return undefined;
}
return errorShape(
ErrorCodes.UNAVAILABLE,
`Session ${params.key} is still active; try again in a moment.`,
);
}
const ACP_RUNTIME_CLEANUP_TIMEOUT_MS = 15_000;
async function runAcpCleanupStep(params: {
op: () => Promise<void>;
}): Promise<{ status: "ok" } | { status: "timeout" } | { status: "error"; error: unknown }> {
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<{ status: "timeout" }>((resolve) => {
timer = setTimeout(() => resolve({ status: "timeout" }), ACP_RUNTIME_CLEANUP_TIMEOUT_MS);
});
const opPromise = params
.op()
.then(() => ({ status: "ok" as const }))
.catch((error: unknown) => ({ status: "error" as const, error }));
const outcome = await Promise.race([opPromise, timeoutPromise]);
if (timer) {
clearTimeout(timer);
}
return outcome;
}
async function closeAcpRuntimeForSession(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey: string;
entry?: SessionEntry;
reason: "session-reset" | "session-delete";
}) {
if (!params.entry?.acp) {
return undefined;
}
const acpManager = getAcpSessionManager();
const cancelOutcome = await runAcpCleanupStep({
op: async () => {
await acpManager.cancelSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
reason: params.reason,
});
},
});
if (cancelOutcome.status === "timeout") {
return errorShape(
ErrorCodes.UNAVAILABLE,
`Session ${params.sessionKey} is still active; try again in a moment.`,
);
}
if (cancelOutcome.status === "error") {
logVerbose(
`sessions.${params.reason}: ACP cancel failed for ${params.sessionKey}: ${String(cancelOutcome.error)}`,
);
}
const closeOutcome = await runAcpCleanupStep({
op: async () => {
await acpManager.closeSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
reason: params.reason,
requireAcpSession: false,
allowBackendUnavailable: true,
});
},
});
if (closeOutcome.status === "timeout") {
return errorShape(
ErrorCodes.UNAVAILABLE,
`Session ${params.sessionKey} is still active; try again in a moment.`,
);
}
if (closeOutcome.status === "error") {
logVerbose(
`sessions.${params.reason}: ACP runtime close failed for ${params.sessionKey}: ${String(closeOutcome.error)}`,
);
}
return undefined;
}
async function cleanupSessionBeforeMutation(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
entry: SessionEntry | undefined;
legacyKey?: string;
canonicalKey?: string;
reason: "session-reset" | "session-delete";
}) {
const cleanupError = await ensureSessionRuntimeCleanup({
cfg: params.cfg,
key: params.key,
target: params.target,
sessionId: params.entry?.sessionId,
});
if (cleanupError) {
return cleanupError;
}
return await closeAcpRuntimeForSession({
cfg: params.cfg,
sessionKey: params.legacyKey ?? params.canonicalKey ?? params.target.canonicalKey ?? params.key,
entry: params.entry,
reason: params.reason,
});
}
export const sessionsHandlers: GatewayRequestHandlers = {
"sessions.list": ({ params, respond }) => {
if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) {
@@ -486,89 +262,17 @@ export const sessionsHandlers: GatewayRequestHandlers = {
return;
}
const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key);
const { entry, legacyKey, canonicalKey } = loadSessionEntry(key);
const hadExistingEntry = Boolean(entry);
const commandReason = p.reason === "new" ? "new" : "reset";
const hookEvent = createInternalHookEvent(
"command",
commandReason,
target.canonicalKey ?? key,
{
sessionEntry: entry,
previousSessionEntry: entry,
commandSource: "gateway:sessions.reset",
cfg,
},
);
await triggerInternalHook(hookEvent);
const mutationCleanupError = await cleanupSessionBeforeMutation({
cfg,
const reason = p.reason === "new" ? "new" : "reset";
const result = await performGatewaySessionReset({
key,
target,
entry,
legacyKey,
canonicalKey,
reason: "session-reset",
reason,
commandSource: "gateway:sessions.reset",
});
if (mutationCleanupError) {
respond(false, undefined, mutationCleanupError);
if (!result.ok) {
respond(false, undefined, result.error);
return;
}
let oldSessionId: string | undefined;
let oldSessionFile: string | undefined;
const next = await updateSessionStore(storePath, (store) => {
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store });
const entry = store[primaryKey];
const resetEntry = stripRuntimeModelState(entry);
const parsed = parseAgentSessionKey(primaryKey);
const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId);
oldSessionId = entry?.sessionId;
oldSessionFile = entry?.sessionFile;
const now = Date.now();
const nextEntry: SessionEntry = {
sessionId: randomUUID(),
updatedAt: now,
systemSent: false,
abortedLastRun: false,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
responseUsage: entry?.responseUsage,
model: resolvedModel.model,
modelProvider: resolvedModel.provider,
contextTokens: resetEntry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
origin: snapshotSessionOrigin(entry),
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
// Reset token counts to 0 on session reset (#1523)
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
totalTokensFresh: true,
};
store[primaryKey] = nextEntry;
return nextEntry;
});
// Archive old transcript so it doesn't accumulate on disk (#14869).
archiveSessionTranscriptsForSession({
sessionId: oldSessionId,
storePath,
sessionFile: oldSessionFile,
agentId: target.agentId,
reason: "reset",
});
if (hadExistingEntry) {
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? key,
reason: "session-reset",
});
}
respond(true, { ok: true, key: target.canonicalKey, entry: next }, undefined);
respond(true, { ok: true, key: result.key, entry: result.entry }, undefined);
},
"sessions.delete": async ({ params, respond, client, isWebchatConnect }) => {
if (!assertValidParams(params, validateSessionsDeleteParams, "sessions.delete", respond)) {

View File

@@ -293,6 +293,56 @@ describe("gateway server agent", () => {
expect(call.sessionId).not.toBe("sess-main-before-reset");
});
test("write-scoped callers cannot use sessions.reset directly but can still reset conversations via agent", async () => {
await withGatewayServer(async ({ port }) => {
await useTempSessionStorePath();
const storePath = testState.sessionStorePath;
if (!storePath) {
throw new Error("missing session store path");
}
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main-before-write-reset",
updatedAt: Date.now(),
},
},
});
const writeWs = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(writeWs);
await new Promise<void>((resolve) => writeWs.once("open", resolve));
await connectOk(writeWs, { scopes: ["operator.write"] });
const directReset = await rpcReq(writeWs, "sessions.reset", { key: "main" });
expect(directReset.ok).toBe(false);
expect(directReset.error?.message).toContain("missing scope: operator.admin");
vi.mocked(agentCommand).mockClear();
const viaAgent = await rpcReq(writeWs, "agent", {
message: "/reset",
sessionKey: "main",
idempotencyKey: "idem-agent-write-reset",
});
expect(viaAgent.ok).toBe(true);
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{ sessionId?: string }
>;
expect(store["agent:main:main"]?.sessionId).toBeDefined();
expect(store["agent:main:main"]?.sessionId).not.toBe("sess-main-before-write-reset");
await vi.waitFor(() => expect(vi.mocked(agentCommand)).toHaveBeenCalled());
const call = readAgentCommandCall();
expect(typeof call.sessionId).toBe("string");
expect(call.sessionId).not.toBe("sess-main-before-write-reset");
writeWs.close();
});
});
test("agent ack response then final response", { timeout: 8000 }, async () => {
const ackP = onceMessage(
ws,

View File

@@ -0,0 +1,364 @@
import { randomUUID } from "node:crypto";
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { clearBootstrapSnapshot } from "../agents/bootstrap-cache.js";
import { abortEmbeddedPiRun, waitForEmbeddedPiRunEnd } from "../agents/pi-embedded.js";
import { stopSubagentsForRequester } from "../auto-reply/reply/abort.js";
import { clearSessionQueues } from "../auto-reply/reply/queue.js";
import { closeTrackedBrowserTabsForSessions } from "../browser/session-tab-registry.js";
import { loadConfig } from "../config/config.js";
import {
snapshotSessionOrigin,
type SessionEntry,
updateSessionStore,
} from "../config/sessions.js";
import { unbindThreadBindingsBySessionKey } from "../discord/monitor/thread-bindings.js";
import { logVerbose } from "../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import {
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
import { ErrorCodes, errorShape } from "./protocol/index.js";
import {
archiveSessionTranscripts,
loadSessionEntry,
pruneLegacyStoreKeys,
resolveGatewaySessionStoreTarget,
resolveSessionModelRef,
} from "./session-utils.js";
const ACP_RUNTIME_CLEANUP_TIMEOUT_MS = 15_000;
function migrateAndPruneSessionStoreKey(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
store: Record<string, SessionEntry>;
}) {
const target = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: params.key,
store: params.store,
});
const primaryKey = target.canonicalKey;
if (!params.store[primaryKey]) {
const existingKey = target.storeKeys.find((candidate) => Boolean(params.store[candidate]));
if (existingKey) {
params.store[primaryKey] = params.store[existingKey];
}
}
pruneLegacyStoreKeys({
store: params.store,
canonicalKey: primaryKey,
candidates: target.storeKeys,
});
return { target, primaryKey, entry: params.store[primaryKey] };
}
function stripRuntimeModelState(entry?: SessionEntry): SessionEntry | undefined {
if (!entry) {
return entry;
}
return {
...entry,
model: undefined,
modelProvider: undefined,
contextTokens: undefined,
systemPromptReport: undefined,
};
}
export function archiveSessionTranscriptsForSession(params: {
sessionId: string | undefined;
storePath: string;
sessionFile?: string;
agentId?: string;
reason: "reset" | "deleted";
}): string[] {
if (!params.sessionId) {
return [];
}
return archiveSessionTranscripts({
sessionId: params.sessionId,
storePath: params.storePath,
sessionFile: params.sessionFile,
agentId: params.agentId,
reason: params.reason,
});
}
export async function emitSessionUnboundLifecycleEvent(params: {
targetSessionKey: string;
reason: "session-reset" | "session-delete";
emitHooks?: boolean;
}) {
const targetKind = isSubagentSessionKey(params.targetSessionKey) ? "subagent" : "acp";
unbindThreadBindingsBySessionKey({
targetSessionKey: params.targetSessionKey,
targetKind,
reason: params.reason,
sendFarewell: true,
});
if (params.emitHooks === false) {
return;
}
const hookRunner = getGlobalHookRunner();
if (!hookRunner?.hasHooks("subagent_ended")) {
return;
}
await hookRunner.runSubagentEnded(
{
targetSessionKey: params.targetSessionKey,
targetKind,
reason: params.reason,
sendFarewell: true,
outcome: params.reason === "session-reset" ? "reset" : "deleted",
},
{
childSessionKey: params.targetSessionKey,
},
);
}
async function ensureSessionRuntimeCleanup(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
sessionId?: string;
}) {
const closeTrackedBrowserTabs = async () => {
const closeKeys = new Set<string>([
params.key,
params.target.canonicalKey,
...params.target.storeKeys,
params.sessionId ?? "",
]);
return await closeTrackedBrowserTabsForSessions({
sessionKeys: [...closeKeys],
onWarn: (message) => logVerbose(message),
});
};
const queueKeys = new Set<string>(params.target.storeKeys);
queueKeys.add(params.target.canonicalKey);
if (params.sessionId) {
queueKeys.add(params.sessionId);
}
clearSessionQueues([...queueKeys]);
stopSubagentsForRequester({ cfg: params.cfg, requesterSessionKey: params.target.canonicalKey });
if (!params.sessionId) {
clearBootstrapSnapshot(params.target.canonicalKey);
await closeTrackedBrowserTabs();
return undefined;
}
abortEmbeddedPiRun(params.sessionId);
const ended = await waitForEmbeddedPiRunEnd(params.sessionId, 15_000);
clearBootstrapSnapshot(params.target.canonicalKey);
if (ended) {
await closeTrackedBrowserTabs();
return undefined;
}
return errorShape(
ErrorCodes.UNAVAILABLE,
`Session ${params.key} is still active; try again in a moment.`,
);
}
async function runAcpCleanupStep(params: {
op: () => Promise<void>;
}): Promise<{ status: "ok" } | { status: "timeout" } | { status: "error"; error: unknown }> {
let timer: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<{ status: "timeout" }>((resolve) => {
timer = setTimeout(() => resolve({ status: "timeout" }), ACP_RUNTIME_CLEANUP_TIMEOUT_MS);
});
const opPromise = params
.op()
.then(() => ({ status: "ok" as const }))
.catch((error: unknown) => ({ status: "error" as const, error }));
const outcome = await Promise.race([opPromise, timeoutPromise]);
if (timer) {
clearTimeout(timer);
}
return outcome;
}
async function closeAcpRuntimeForSession(params: {
cfg: ReturnType<typeof loadConfig>;
sessionKey: string;
entry?: SessionEntry;
reason: "session-reset" | "session-delete";
}) {
if (!params.entry?.acp) {
return undefined;
}
const acpManager = getAcpSessionManager();
const cancelOutcome = await runAcpCleanupStep({
op: async () => {
await acpManager.cancelSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
reason: params.reason,
});
},
});
if (cancelOutcome.status === "timeout") {
return errorShape(
ErrorCodes.UNAVAILABLE,
`Session ${params.sessionKey} is still active; try again in a moment.`,
);
}
if (cancelOutcome.status === "error") {
logVerbose(
`sessions.${params.reason}: ACP cancel failed for ${params.sessionKey}: ${String(cancelOutcome.error)}`,
);
}
const closeOutcome = await runAcpCleanupStep({
op: async () => {
await acpManager.closeSession({
cfg: params.cfg,
sessionKey: params.sessionKey,
reason: params.reason,
requireAcpSession: false,
allowBackendUnavailable: true,
});
},
});
if (closeOutcome.status === "timeout") {
return errorShape(
ErrorCodes.UNAVAILABLE,
`Session ${params.sessionKey} is still active; try again in a moment.`,
);
}
if (closeOutcome.status === "error") {
logVerbose(
`sessions.${params.reason}: ACP runtime close failed for ${params.sessionKey}: ${String(closeOutcome.error)}`,
);
}
return undefined;
}
export async function cleanupSessionBeforeMutation(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
entry: SessionEntry | undefined;
legacyKey?: string;
canonicalKey?: string;
reason: "session-reset" | "session-delete";
}) {
const cleanupError = await ensureSessionRuntimeCleanup({
cfg: params.cfg,
key: params.key,
target: params.target,
sessionId: params.entry?.sessionId,
});
if (cleanupError) {
return cleanupError;
}
return await closeAcpRuntimeForSession({
cfg: params.cfg,
sessionKey: params.legacyKey ?? params.canonicalKey ?? params.target.canonicalKey ?? params.key,
entry: params.entry,
reason: params.reason,
});
}
export async function performGatewaySessionReset(params: {
key: string;
reason: "new" | "reset";
commandSource: string;
}): Promise<
| { ok: true; key: string; entry: SessionEntry }
| { ok: false; error: ReturnType<typeof errorShape> }
> {
const { cfg, target, storePath } = (() => {
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key: params.key });
return { cfg, target, storePath: target.storePath };
})();
const { entry, legacyKey, canonicalKey } = loadSessionEntry(params.key);
const hadExistingEntry = Boolean(entry);
const hookEvent = createInternalHookEvent(
"command",
params.reason,
target.canonicalKey ?? params.key,
{
sessionEntry: entry,
previousSessionEntry: entry,
commandSource: params.commandSource,
cfg,
},
);
await triggerInternalHook(hookEvent);
const mutationCleanupError = await cleanupSessionBeforeMutation({
cfg,
key: params.key,
target,
entry,
legacyKey,
canonicalKey,
reason: "session-reset",
});
if (mutationCleanupError) {
return { ok: false, error: mutationCleanupError };
}
let oldSessionId: string | undefined;
let oldSessionFile: string | undefined;
const next = await updateSessionStore(storePath, (store) => {
const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key: params.key, store });
const currentEntry = store[primaryKey];
const resetEntry = stripRuntimeModelState(currentEntry);
const parsed = parseAgentSessionKey(primaryKey);
const sessionAgentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
const resolvedModel = resolveSessionModelRef(cfg, resetEntry, sessionAgentId);
oldSessionId = currentEntry?.sessionId;
oldSessionFile = currentEntry?.sessionFile;
const now = Date.now();
const nextEntry: SessionEntry = {
sessionId: randomUUID(),
updatedAt: now,
systemSent: false,
abortedLastRun: false,
thinkingLevel: currentEntry?.thinkingLevel,
verboseLevel: currentEntry?.verboseLevel,
reasoningLevel: currentEntry?.reasoningLevel,
responseUsage: currentEntry?.responseUsage,
model: resolvedModel.model,
modelProvider: resolvedModel.provider,
contextTokens: resetEntry?.contextTokens,
sendPolicy: currentEntry?.sendPolicy,
label: currentEntry?.label,
origin: snapshotSessionOrigin(currentEntry),
lastChannel: currentEntry?.lastChannel,
lastTo: currentEntry?.lastTo,
skillsSnapshot: currentEntry?.skillsSnapshot,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
totalTokensFresh: true,
};
store[primaryKey] = nextEntry;
return nextEntry;
});
archiveSessionTranscriptsForSession({
sessionId: oldSessionId,
storePath,
sessionFile: oldSessionFile,
agentId: target.agentId,
reason: "reset",
});
if (hadExistingEntry) {
await emitSessionUnboundLifecycleEvent({
targetSessionKey: target.canonicalKey ?? params.key,
reason: "session-reset",
});
}
return { ok: true, key: target.canonicalKey, entry: next };
}