fix: lock main session deletion

This commit is contained in:
Peter Steinberger
2026-01-03 23:57:17 +00:00
parent e17c038d18
commit 5862f95bd2
19 changed files with 225 additions and 20 deletions

View File

@@ -2,6 +2,12 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
resolveEmbeddedSessionLane,
waitForEmbeddedPiRunEnd,
} from "../agents/pi-embedded.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
@@ -29,6 +35,7 @@ import {
import { buildConfigSchema } from "../config/schema.js";
import {
loadSessionStore,
resolveMainSessionKey,
resolveStorePath,
type SessionEntry,
saveSessionStore,
@@ -38,6 +45,7 @@ import {
setVoiceWakeTriggers,
} from "../infra/voicewake.js";
import { defaultRuntime } from "../runtime.js";
import { clearCommandLane } from "../process/command-queue.js";
import { normalizeSendPolicy } from "../sessions/send-policy.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import {
@@ -569,12 +577,37 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
};
}
const mainKey = resolveMainSessionKey(loadConfig());
if (key === mainKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `Cannot delete the main session (${mainKey}).`,
},
};
}
const deleteTranscript =
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const { storePath, store, entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
const existed = Boolean(store[key]);
clearCommandLane(resolveEmbeddedSessionLane(key));
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
abortEmbeddedPiRun(sessionId);
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
if (!ended) {
return {
ok: false,
error: {
code: ErrorCodes.UNAVAILABLE,
message: `Session ${key} is still active; try again in a moment.`,
},
};
}
}
if (existed) delete store[key];
await saveSessionStore(storePath, store);

View File

@@ -5,6 +5,7 @@ import { describe, expect, test } from "vitest";
import {
connectOk,
installGatewayTestHooks,
embeddedRunMock,
piSdkMock,
rpcReq,
startServerWithClient,
@@ -217,4 +218,59 @@ describe("gateway server sessions", () => {
ws.close();
await server.close();
});
test("sessions.delete rejects main and aborts active runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-sessions-"));
const storePath = path.join(dir, "sessions.json");
testState.sessionStorePath = storePath;
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
`${JSON.stringify({ role: "user", content: "hello" })}\n`,
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-active.jsonl"),
`${JSON.stringify({ role: "user", content: "active" })}\n`,
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify(
{
main: { sessionId: "sess-main", updatedAt: Date.now() },
"discord:group:dev": {
sessionId: "sess-active",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
embeddedRunMock.activeIds.add("sess-active");
embeddedRunMock.waitResults.set("sess-active", true);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const mainDelete = await rpcReq(ws, "sessions.delete", { key: "main" });
expect(mainDelete.ok).toBe(false);
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
ws,
"sessions.delete",
{ key: "discord:group:dev" },
);
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
expect(embeddedRunMock.abortCalls).toEqual(["sess-active"]);
expect(embeddedRunMock.waitCalls).toEqual(["sess-active"]);
ws.close();
await server.close();
});
});

View File

@@ -39,7 +39,7 @@ export type BridgeStartOpts = {
>;
};
const hoisted = vi.hoisted(() => ({
const hoisted = vi.hoisted(() => ({
bridgeStartCalls: [] as BridgeStartOpts[],
bridgeInvoke: vi.fn(async () => ({
type: "invoke-res",
@@ -66,6 +66,12 @@ const hoisted = vi.hoisted(() => ({
agentCommand: vi.fn().mockResolvedValue(undefined),
testIsNixMode: { value: false },
sessionStoreSaveDelayMs: { value: 0 },
embeddedRunMock: {
activeIds: new Set<string>(),
abortCalls: [] as string[],
waitCalls: [] as string[],
waitResults: new Map<string, boolean>(),
},
}));
export const bridgeStartCalls = hoisted.bridgeStartCalls;
@@ -95,6 +101,7 @@ export const testState = {
export const testIsNixMode = hoisted.testIsNixMode;
export const sessionStoreSaveDelayMs = hoisted.sessionStoreSaveDelayMs;
export const embeddedRunMock = hoisted.embeddedRunMock;
vi.mock("@mariozechner/pi-coding-agent", async () => {
const actual = await vi.importActual<
@@ -284,6 +291,25 @@ vi.mock("../config/config.js", async () => {
};
});
vi.mock("../agents/pi-embedded.js", async () => {
const actual = await vi.importActual<typeof import("../agents/pi-embedded.js")>(
"../agents/pi-embedded.js",
);
return {
...actual,
isEmbeddedPiRunActive: (sessionId: string) =>
embeddedRunMock.activeIds.has(sessionId),
abortEmbeddedPiRun: (sessionId: string) => {
embeddedRunMock.abortCalls.push(sessionId);
return embeddedRunMock.activeIds.has(sessionId);
},
waitForEmbeddedPiRunEnd: async (sessionId: string) => {
embeddedRunMock.waitCalls.push(sessionId);
return embeddedRunMock.waitResults.get(sessionId) ?? true;
},
};
});
vi.mock("../commands/health.js", () => ({
getHealthSnapshot: vi.fn().mockResolvedValue({ ok: true, stub: true }),
}));
@@ -329,6 +355,10 @@ export function installGatewayTestHooks() {
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
agentCommand.mockClear();
embeddedRunMock.activeIds.clear();
embeddedRunMock.abortCalls = [];
embeddedRunMock.waitCalls = [];
embeddedRunMock.waitResults.clear();
drainSystemEvents();
resetAgentRunContextForTest();
const mod = await import("./server.js");