diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 9c08a4016..dfd72f76f 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -19,6 +19,9 @@ import { mkdirSync, unlinkSync, readdirSync, + openSync, + closeSync, + constants, } from "node:fs"; import { join, dirname } from "node:path"; import type { SessionId, SessionMetadata } from "./types.js"; @@ -188,3 +191,19 @@ export function listMetadata(dataDir: string): SessionId[] { return readdirSync(dir).filter((name) => name !== "archive" && !name.startsWith(".")); } + +/** + * Atomically reserve a session ID by creating its metadata file with O_EXCL. + * Returns true if the ID was successfully reserved, false if it already exists. + */ +export function reserveSessionId(dataDir: string, sessionId: SessionId): boolean { + const path = metadataPath(dataDir, sessionId); + mkdirSync(dirname(path), { recursive: true }); + try { + const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL); + closeSync(fd); + return true; + } catch { + return false; + } +} diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index 7731512ef..2a4094197 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -29,7 +29,13 @@ import type { PluginRegistry, RuntimeHandle, } from "./types.js"; -import { readMetadataRaw, writeMetadata, deleteMetadata, listMetadata } from "./metadata.js"; +import { + readMetadataRaw, + writeMetadata, + deleteMetadata, + listMetadata, + reserveSessionId, +} from "./metadata.js"; import { createEvent } from "./event-bus.js"; /** Escape regex metacharacters in a string. */ @@ -157,10 +163,19 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`); } - // Determine session ID + // Determine session ID — atomically reserve to prevent concurrent collisions const existingSessions = listMetadata(config.dataDir); - const num = getNextSessionNumber(existingSessions, project.sessionPrefix); - const sessionId = `${project.sessionPrefix}-${num}`; + let num = getNextSessionNumber(existingSessions, project.sessionPrefix); + let sessionId: string; + for (let attempts = 0; attempts < 10; attempts++) { + sessionId = `${project.sessionPrefix}-${num}`; + if (reserveSessionId(config.dataDir, sessionId)) break; + num++; + if (attempts === 9) { + throw new Error(`Failed to reserve session ID after 10 attempts (prefix: ${project.sessionPrefix})`); + } + } + sessionId = `${project.sessionPrefix}-${num}`; // Determine branch name — explicit branch always takes priority let branch: string; @@ -355,13 +370,14 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const projectId = raw["project"] ?? ""; const project = config.projects[projectId]; - // Destroy runtime — use handle's runtimeName to find the correct plugin + // Destroy runtime — prefer handle.runtimeName to find the correct plugin if (raw["runtimeHandle"]) { const handle = safeJsonParse(raw["runtimeHandle"]); if (handle) { - const runtimePlugin = project - ? resolvePlugins(project).runtime - : registry.get("runtime", handle.runtimeName ?? config.defaults.runtime); + const runtimePlugin = registry.get( + "runtime", + handle.runtimeName ?? (project ? project.runtime ?? config.defaults.runtime : config.defaults.runtime), + ); if (runtimePlugin) { try { await runtimePlugin.destroy(handle); @@ -469,14 +485,6 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { const raw = readMetadataRaw(config.dataDir, sessionId); if (!raw) throw new Error(`Session ${sessionId} not found`); - const project = config.projects[raw["project"] ?? ""]; - if (!project) throw new Error(`Project not found for session ${sessionId}`); - - const plugins = resolvePlugins(project); - if (!plugins.runtime) { - throw new Error(`No runtime plugin for session ${sessionId}`); - } - if (!raw["runtimeHandle"]) { throw new Error(`No runtime handle for session ${sessionId}`); } @@ -486,7 +494,17 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager { throw new Error(`Corrupted runtime handle for session ${sessionId}`); } - await plugins.runtime.sendMessage(handle, message); + // Prefer handle.runtimeName to find the correct plugin + const project = config.projects[raw["project"] ?? ""]; + const runtimePlugin = registry.get( + "runtime", + handle.runtimeName ?? (project ? project.runtime ?? config.defaults.runtime : config.defaults.runtime), + ); + if (!runtimePlugin) { + throw new Error(`No runtime plugin for session ${sessionId}`); + } + + await runtimePlugin.sendMessage(handle, message); } return { spawn, list, get, kill, cleanup, send };