/** * Session-scoped manager for agent output IDs. * * Ensures unique output IDs across task tool invocations within a session. * Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi"). * If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask"). * * This enables reliable agent:// URL resolution and prevents artifact collisions. */ import * as fs from "node:fs/promises"; import { validateAllocatedTaskId, validateTaskId } from "./id"; function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } const RESERVED_OUTPUT_EXTENSIONS_PATTERN = "(?:md|jsonl|patch)"; /** * Manages agent output ID allocation to ensure uniqueness. * * Each allocated ID gets a numeric prefix based on allocation order. * If configured with a parent prefix, the numeric prefix is appended after * the parent (e.g., "0-Parent.0-Child"). * On resume, scans existing files to find the next available index. */ export class AgentOutputManager { #nextId = 0; #initPromise: Promise | undefined; readonly #getArtifactsDir: () => string | null; readonly #parentPrefix: string | undefined; readonly #getAuthorizedArtifactsDirs: (() => readonly string[]) | undefined; constructor( getArtifactsDir: () => string | null, options?: { parentPrefix?: string; getAuthorizedArtifactsDirs?: () => readonly string[] }, ) { this.#getArtifactsDir = getArtifactsDir; this.#parentPrefix = options?.parentPrefix; this.#getAuthorizedArtifactsDirs = options?.getAuthorizedArtifactsDirs; } /** * Memoize the in-flight scan so concurrent `allocate*`/`peekNextIndex` * calls await the SAME `readdir` before `#nextId` is derived. Assigning the * promise synchronously (before any `await`) closes the TOCTOU window where a * boolean "initialized" flag, flipped ahead of the awaited scan, let a second * caller allocate at `#nextId === 0` while the first was still scanning — * producing duplicate indices that overwrite prior outputs on resume. */ #ensureInitialized(): Promise { this.#initPromise ??= this.#scanExistingOutputs(); return this.#initPromise; } /** * Scan existing agent output files to find the next available ID. * This ensures we don't overwrite outputs when resuming a session. */ async #scanExistingOutputs(): Promise { const dirs: string[] = []; const add = (dir: string | null | undefined) => { if (!dir || dirs.includes(dir)) return; dirs.push(dir); }; add(this.#getArtifactsDir()); for (const dir of this.#getAuthorizedArtifactsDirs?.() ?? []) add(dir); if (dirs.length === 0) return; const pattern = this.#parentPrefix ? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.${RESERVED_OUTPUT_EXTENSIONS_PATTERN}$`) : new RegExp(`^(\\d+)-.*\\.${RESERVED_OUTPUT_EXTENSIONS_PATTERN}$`); let maxId = -1; for (const dir of dirs) { let files: string[]; try { files = await fs.readdir(dir); } catch { continue; } for (const file of files) { const match = file.match(pattern); if (!match) continue; const id = Number.parseInt(match[1], 10); if (id > maxId) maxId = id; } } this.#nextId = maxId + 1; } /** * Allocate a unique ID with numeric prefix. * * @param id Requested ID (e.g., "AuthProvider") * @returns Unique ID with prefix (e.g., "0-AuthProvider") */ async allocate(id: string): Promise { await this.#ensureInitialized(); const prefix = this.#parentPrefix ? `${validateAllocatedTaskId(this.#parentPrefix)}.` : ""; return `${prefix}${this.#nextId++}-${validateTaskId(id)}`; } /** * Allocate unique IDs for a batch of tasks. * * @param ids Array of requested IDs * @returns Array of unique IDs in same order */ async allocateBatch(ids: string[]): Promise { await this.#ensureInitialized(); const prefix = this.#parentPrefix ? `${validateAllocatedTaskId(this.#parentPrefix)}.` : ""; return ids.map(id => `${prefix}${this.#nextId++}-${validateTaskId(id)}`); } /** * Get the next ID that would be allocated (without allocating). */ async peekNextIndex(): Promise { await this.#ensureInitialized(); return this.#nextId; } /** * Reset state (primarily for testing). */ reset(): void { this.#nextId = 0; this.#initPromise = undefined; } }