From 5862f95bd23fe744aba1cbc50c786ad46729ff9e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 3 Jan 2026 23:57:17 +0000 Subject: [PATCH] fix: lock main session deletion --- CHANGELOG.md | 2 + .../Sources/Clawdis/GatewayConnection.swift | 9 ++- .../Clawdis/MenuSessionsInjector.swift | 2 +- .../WebChatMainSessionKeyTests.swift | 12 +++- docs/agent-send.md | 2 +- docs/clawd.md | 3 +- docs/configuration.md | 2 +- docs/heartbeat.md | 2 +- docs/mac/webchat.md | 2 +- docs/session.md | 4 +- docs/tui.md | 2 +- src/agents/pi-embedded-runner.ts | 43 ++++++++++++++ src/agents/pi-embedded.ts | 1 + src/cli/tui-cli.ts | 2 +- src/config/config.ts | 29 ++++++++-- src/config/sessions.ts | 7 +++ src/gateway/server-bridge.ts | 33 +++++++++++ src/gateway/server.sessions.test.ts | 56 +++++++++++++++++++ src/gateway/test-helpers.ts | 32 ++++++++++- 19 files changed, 225 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd10a6a49..fa2d2a253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Identifiers: rename bundle IDs and internal domains to `com.clawdis.*` (macOS: `com.clawdis.mac`, iOS: `com.clawdis.ios`, Android: `com.clawdis.android`) and update the gateway LaunchAgent label to `com.clawdis.gateway`. - Agent tools: drop the `clawdis_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`). - Bash tool: remove `stdinMode: "pty"`/node-pty support; use the tmux skill for real TTYs. +- Sessions: primary session key is fixed to `main` (or `global` for global scope); `session.mainKey` is ignored. ### Features - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. @@ -40,6 +41,7 @@ - Agent tools: emit verbose tool summaries at tool start (no debounce). - Gateway: split server helpers/tests into hooks/session-utils/ws-log/net modules for better isolation; add unit coverage for hooks/session utils/ws log. - Gateway: extract WS method handling + HTTP/provider/constant helpers to shrink server wiring and improve testability. +- Gateway: prevent deleting the main session and abort active runs before deleting other sessions. - Onboarding: fix Control UI basePath usage when showing/opening gateway URLs. - Onboarding: clarify provider requirements (WhatsApp/Signal phone numbers, iMessage Apple ID guidance) in the provider picker. - macOS Connections: move to sidebar + detail layout with structured sections and header actions. diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index c64291958..2076f9a87 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -306,6 +306,7 @@ extension GatewayConnection { struct SnapshotConfig: Decodable, Sendable { struct Session: Decodable, Sendable { let mainKey: String? + let scope: String? } let session: Session? @@ -316,9 +317,11 @@ extension GatewayConnection { static func mainSessionKey(fromConfigGetData data: Data) throws -> String { let snapshot = try JSONDecoder().decode(ConfigGetSnapshot.self, from: data) - let raw = snapshot.config?.session?.mainKey - let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "main" : trimmed + let scope = snapshot.config?.session?.scope?.trimmingCharacters(in: .whitespacesAndNewlines) + if scope == "global" { + return "global" + } + return "main" } func mainSessionKey(timeoutMs: Double = 15000) async -> String { diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index 03182bbf5..7f3a22b8d 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -435,7 +435,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { compact.representedObject = row.key menu.addItem(compact) - if row.key != "main" { + if row.key != "main" && row.key != "global" { let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") del.target = self del.representedObject = row.key diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift index 9eecc94e7..8b3bef0d6 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatMainSessionKeyTests.swift @@ -32,7 +32,7 @@ import Testing } """ let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8)) - #expect(key == "primary") + #expect(key == "main") } @Test func configGetSnapshotMainKeyFallsBackWhenEmptyOrWhitespace() throws { @@ -54,4 +54,14 @@ import Testing let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8)) #expect(key == "main") } + + @Test func configGetSnapshotUsesGlobalScope() throws { + let json = """ + { + "config": { "session": { "scope": "global" } } + } + """ + let key = try GatewayConnection.mainSessionKey(fromConfigGetData: Data(json.utf8)) + #expect(key == "global") + } } diff --git a/docs/agent-send.md b/docs/agent-send.md index 08f0124ec..363a00511 100644 --- a/docs/agent-send.md +++ b/docs/agent-send.md @@ -11,7 +11,7 @@ read_when: - Required: `--message ` - Session selection: - If `--session-id` is given, reuse it. - - Else if `--to ` is given, derive the session key from `session.scope` (direct chats collapse to `session.mainKey`). + - Else if `--to ` is given, derive the session key from `session.scope` (direct chats collapse to `main`, or `global` when scope is global). - Runs the embedded Pi agent (configured via `agent`). - Thinking/verbose: - Flags `--thinking ` and `--verbose ` persist into the session store. diff --git a/docs/clawd.md b/docs/clawd.md index b00aedac7..973ab5a7f 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -138,8 +138,7 @@ Example: session: { scope: "per-sender", resetTriggers: ["/new", "/reset"], - idleMinutes: 10080, - mainKey: "main" + idleMinutes: 10080 } } ``` diff --git a/docs/configuration.md b/docs/configuration.md index 831a2a494..297aebbaf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -620,7 +620,7 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto idleMinutes: 60, resetTriggers: ["/new", "/reset"], store: "~/.clawdis/sessions/sessions.json", - mainKey: "main", + // mainKey is ignored; primary key is fixed to "main" sendPolicy: { rules: [ { action: "deny", match: { surface: "discord", chatType: "group" } } diff --git a/docs/heartbeat.md b/docs/heartbeat.md index 9edd2f058..4ca483843 100644 --- a/docs/heartbeat.md +++ b/docs/heartbeat.md @@ -50,7 +50,7 @@ message. If the reply is only `HEARTBEAT_OK`, it is dropped. - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). ## Behavior -- Runs in the main session (`session.mainKey`, or `global` when scope is global). +- Runs in the main session (`main`, or `global` when scope is global). - Uses the main lane queue; if requests are in flight, the wake is retried. - Empty output or `HEARTBEAT_OK` is treated as “ok” and does **not** keep the session alive (`updatedAt` is restored). diff --git a/docs/mac/webchat.md b/docs/mac/webchat.md index f19b7a0c2..11b92f106 100644 --- a/docs/mac/webchat.md +++ b/docs/mac/webchat.md @@ -5,7 +5,7 @@ read_when: --- # Web Chat (macOS app) -The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main` by default, configurable via `session.mainKey`). +The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main`, or `global` when scope is global). - **Local mode**: connects directly to the local Gateway WebSocket. - **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane. diff --git a/docs/session.md b/docs/session.md index 6e71e3ef1..a8f433a71 100644 --- a/docs/session.md +++ b/docs/session.md @@ -5,7 +5,7 @@ read_when: --- # Session Management -Clawdis treats **one session as primary**. By default the canonical key is `main` for every direct chat; no configuration is required. You can rename it via `session.mainKey` if you really want, but there is still only a single primary session. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls. +Clawdis treats **one session as primary**. The canonical key is fixed to `main` for direct chats (or `global` when scope is global); no configuration is required. `session.mainKey` is ignored. Older/local sessions can stay on disk, but only the primary key is used for desktop/web chat and direct agent calls. ## Gateway is the source of truth All session state is **owned by the gateway** (the “master” Clawdis). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files. @@ -67,7 +67,7 @@ Runtime override (owner only): idleMinutes: 120, resetTriggers: ["/new", "/reset"], store: "~/.clawdis/sessions/sessions.json", - mainKey: "main" // optional rename; still a single primary + // mainKey is ignored; primary key is fixed to "main" } } ``` diff --git a/docs/tui.md b/docs/tui.md index 262779c4a..f70999dd5 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -28,7 +28,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS. - `--url `: Gateway WebSocket URL (defaults to config `gateway.remote.url` or `ws://127.0.0.1:18789`). - `--token `: Gateway token (if required). - `--password `: Gateway password (if required). -- `--session `: Session key (default: `session.mainKey` or `main`). +- `--session `: Session key (default: `main`, or `global` when scope is global). - `--deliver`: Deliver assistant replies to the provider (default off). - `--thinking `: Override thinking level for sends. - `--timeout-ms `: Agent timeout in ms (default 30000). diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 1f7227d63..5eced8e58 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -98,6 +98,11 @@ type EmbeddedPiQueueHandle = { const log = createSubsystemLogger("agent/embedded"); const ACTIVE_EMBEDDED_RUNS = new Map(); +type EmbeddedRunWaiter = { + resolve: (ended: boolean) => void; + timer: NodeJS.Timeout; +}; +const EMBEDDED_RUN_WAITERS = new Map>(); const OAUTH_FILENAME = "oauth.json"; const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); @@ -247,6 +252,43 @@ export function isEmbeddedPiRunStreaming(sessionId: string): boolean { return handle.isStreaming(); } +export function waitForEmbeddedPiRunEnd( + sessionId: string, + timeoutMs = 15_000, +): Promise { + if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId)) + return Promise.resolve(true); + return new Promise((resolve) => { + const waiters = EMBEDDED_RUN_WAITERS.get(sessionId) ?? new Set(); + const waiter: EmbeddedRunWaiter = { + resolve, + timer: setTimeout(() => { + waiters.delete(waiter); + if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId); + resolve(false); + }, Math.max(100, timeoutMs)), + }; + waiters.add(waiter); + EMBEDDED_RUN_WAITERS.set(sessionId, waiters); + if (!ACTIVE_EMBEDDED_RUNS.has(sessionId)) { + waiters.delete(waiter); + if (waiters.size === 0) EMBEDDED_RUN_WAITERS.delete(sessionId); + clearTimeout(waiter.timer); + resolve(true); + } + }); +} + +function notifyEmbeddedRunEnded(sessionId: string) { + const waiters = EMBEDDED_RUN_WAITERS.get(sessionId); + if (!waiters || waiters.size === 0) return; + EMBEDDED_RUN_WAITERS.delete(sessionId); + for (const waiter of waiters) { + clearTimeout(waiter.timer); + waiter.resolve(true); + } +} + export function resolveEmbeddedSessionLane(key: string) { return resolveSessionLane(key); } @@ -602,6 +644,7 @@ export async function runEmbeddedPiAgent(params: { unsubscribe(); if (ACTIVE_EMBEDDED_RUNS.get(params.sessionId) === queueHandle) { ACTIVE_EMBEDDED_RUNS.delete(params.sessionId); + notifyEmbeddedRunEnded(params.sessionId); } session.dispose(); params.abortSignal?.removeEventListener?.("abort", onAbort); diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index b521dbf48..e36a2c458 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -8,6 +8,7 @@ export { isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, queueEmbeddedPiMessage, + waitForEmbeddedPiRunEnd, resolveEmbeddedSessionLane, runEmbeddedPiAgent, } from "./pi-embedded-runner.js"; diff --git a/src/cli/tui-cli.ts b/src/cli/tui-cli.ts index 1ebbfdce6..beb4f84f1 100644 --- a/src/cli/tui-cli.ts +++ b/src/cli/tui-cli.ts @@ -14,7 +14,7 @@ export function registerTuiCli(program: Command) { .option("--password ", "Gateway password (if required)") .option( "--session ", - "Session key (default: session.mainKey from config)", + 'Session key (default: "main", or "global" when scope is global)', ) .option("--deliver", "Deliver assistant replies", false) .option("--thinking ", "Thinking level override") diff --git a/src/config/config.ts b/src/config/config.ts index de8ddb5ce..d4b88d10f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -15,6 +15,7 @@ import { parseDurationMs } from "../cli/parse-duration.js"; * - Config is managed externally (read-only from Nix perspective) */ export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1"; +let warnedMainKeyOverride = false; export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; @@ -1771,6 +1772,22 @@ function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig { return mutated ? next : cfg; } +function applySessionDefaults(cfg: ClawdisConfig): ClawdisConfig { + const session = cfg.session; + if (!session || session.mainKey === undefined) return cfg; + + const trimmed = session.mainKey.trim(); + const next: ClawdisConfig = { ...cfg, session: { ...session } }; + next.session.mainKey = "main"; + + if (trimmed && trimmed !== "main" && !warnedMainKeyOverride) { + warnedMainKeyOverride = true; + console.warn('session.mainKey is ignored; main session is always "main".'); + } + + return next; +} + export function loadConfig(): ClawdisConfig { // Read config file (JSON5) if present. const configPath = CONFIG_PATH_CLAWDIS; @@ -1787,7 +1804,9 @@ export function loadConfig(): ClawdisConfig { } return {}; } - return applyIdentityDefaults(validated.data as ClawdisConfig); + return applySessionDefaults( + applyIdentityDefaults(validated.data as ClawdisConfig), + ); } catch (err) { console.error(`Failed to read config at ${configPath}`, err); return {}; @@ -1821,7 +1840,9 @@ export function validateConfigObject( } return { ok: true, - config: applyIdentityDefaults(validated.data as ClawdisConfig), + config: applySessionDefaults( + applyIdentityDefaults(validated.data as ClawdisConfig), + ), }; } @@ -1880,7 +1901,7 @@ export async function readConfigFileSnapshot(): Promise { const configPath = CONFIG_PATH_CLAWDIS; const exists = fs.existsSync(configPath); if (!exists) { - const config = applyTalkApiKey({}); + const config = applyTalkApiKey(applySessionDefaults({})); const legacyIssues: LegacyConfigIssue[] = []; return { path: configPath, @@ -1934,7 +1955,7 @@ export async function readConfigFileSnapshot(): Promise { raw, parsed: parsedRes.parsed, valid: true, - config: applyTalkApiKey(validated.config), + config: applyTalkApiKey(applySessionDefaults(validated.config)), issues: [], legacyIssues, }; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 357220a23..b9895ac16 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -103,6 +103,13 @@ export function resolveStorePath(store?: string) { return path.resolve(store); } +export function resolveMainSessionKey( + cfg?: { session?: { scope?: SessionScope; mainKey?: string } }, +): string { + if (cfg?.session?.scope === "global") return "global"; + return "main"; +} + function normalizeGroupLabel(raw?: string) { const trimmed = raw?.trim().toLowerCase() ?? ""; if (!trimmed) return ""; diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index e3284a689..5692b3b59 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -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); diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts index 64c498131..4e56c56a7 100644 --- a/src/gateway/server.sessions.test.ts +++ b/src/gateway/server.sessions.test.ts @@ -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(); + }); }); diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts index 17a152215..3bbb60eb8 100644 --- a/src/gateway/test-helpers.ts +++ b/src/gateway/test-helpers.ts @@ -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(), + abortCalls: [] as string[], + waitCalls: [] as string[], + waitResults: new Map(), + }, })); 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( + "../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");