import { cpSync, existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import type { CommandSpec, HarnessAdapter, HarnessDiscovery, HarnessRunInput, HarnessUsage, } from "./harnesses"; import { assertHarnessSettingsSupported, DEFAULT_TASK_TIMEOUT_MS, inputGuidanceNotes, withInputGuidance } from "./harnesses"; import { preview, tailItems } from "./internal"; import { signalOwnedProcess } from "./process"; import { augmentPrompt, toAppliedSkillInfo, type AppliedSkillInfo, } from "./skills"; /** Ennodia's own instructions are tagged, so workers do not read them as task content. */ export const EXECUTION_NOTICE_TAG = "ennodia-execution-notice"; export type TaskStatus = "running" | "succeeded" | "failed" | "cancelled"; export type TaskEvent = { at: string; type: "started" | "input-guidance" | "restart" | "stdout" | "stderr" | "exit" | "cancel" | "error"; message?: string; }; export type TaskView = { id: string; harnessId: string; harnessName: string; model?: string; reasoningEffort?: HarnessRunInput["reasoningEffort"]; nativeSubagents?: "disabled"; nativeSandbox?: "read-only" | "workspace-write"; persistSession?: boolean; continueTaskId?: string; continuationTaskId?: string; canContinue?: boolean; status: TaskStatus; cancelRequested: boolean; pid?: number; cwd: string; command: string[]; promptPreview: string; createdAt: string; updatedAt: string; endedAt?: string; elapsedMs: number; timeoutMs: number; /** Execution cutoff, not an estimate of task completion. */ deadlineAt?: string; timeoutSource?: "caller" | "task-manager-default"; remainingMs: number | null; etaConfidence: "timeout-budget" | "unknown" | "complete"; lastOutputAt?: string; exitCode?: number | null; timedOut: boolean; drainTimedOut: boolean; stdoutChars: number; stderrChars: number; eventCount: number; stdout: string; stderr: string; events: TaskEvent[]; appliedSkills?: AppliedSkillInfo[]; /** Clean final message, distinct from raw stdout, when the adapter supports * writing one out-of-band (e.g. Codex's --output-last-message). Prefer this * over stdout for cross-harness comparisons: some adapters' stdout is a * full session transcript, others' is just the final reply. */ finalMessage?: string; finalMessageChars?: number; /** Availability of non-whitespace answer evidence, even in compact views. */ hasOutput?: boolean; /** The preferred answer was truncated during capture or in this view. */ outputTruncated?: boolean; /** True when preferred answer bytes were lost during stream capture, independent of view limits. */ captureTruncated?: boolean; /** Best-effort usage metrics parsed from the adapter's own output. */ usage?: HarnessUsage; /** Set when this task ran against an isolated copy of the requested cwd * (see HarnessRunInput.isolateCwd), naming the original directory. */ isolatedFrom?: string; /** Starts repeated because another instance of the same CLI held shared * local state. Present only when a restart happened. */ startupRestarts?: number; }; export type TaskViewOptions = { includeOutput?: boolean; includeEvents?: boolean; maxOutputChars?: number; maxEvents?: number; }; type TaskProcess = Bun.Subprocess<"ignore" | "pipe", "pipe", "pipe">; export type TaskSpawnInput = { cmd: string[]; cwd: string; env: Record; stdin: "ignore" | "pipe"; stdout: "pipe"; stderr: "pipe"; detached: boolean; }; export type TaskSpawn = (input: TaskSpawnInput) => TaskProcess; type InternalTask = Omit< TaskView, | "createdAt" | "updatedAt" | "endedAt" | "elapsedMs" | "remainingMs" | "etaConfidence" | "lastOutputAt" | "stdoutChars" | "stderrChars" | "eventCount" | "finalMessageChars" > & { nativeSessionId?: string; extractSessionId?: HarnessAdapter["extractSessionId"]; streamCaptureTruncated?: boolean; createdAtMs: number; updatedAtMs: number; endedAtMs?: number; lastOutputAtMs?: number; process?: TaskProcess; streamsDone?: Promise; settled?: Promise; streamReaders: Set>; timeout?: Timer; killTimeout?: Timer; forceSignalled?: boolean; finalMessagePath?: string; /** Exact temporary directory allocated by TaskManager. Keep this separate * from cwd because an adapter can override the command working directory. */ isolatedCwd?: string; extractUsage?: HarnessAdapter["extractUsage"]; failureReason?: HarnessAdapter["failureReason"]; /** The exact spawn, kept so a start that met a startup conflict can run again. */ spawnInput: TaskSpawnInput; stdinText?: string; startupConflict?: RegExp; attemptStartedAtMs: number; }; export type StartTaskResult = { task: TaskView; }; export type TaskManagerOptions = { drainTimeoutMs?: number; /** Base delay before restarting a start that met a startup conflict. */ startupRestartDelayMs?: number; maxTasks?: number; spawn?: TaskSpawn; removeIsolatedCwd?: (path: string) => void; }; export type TaskManagerShutdownOptions = { deadlineMs?: number; }; const DEFAULT_DRAIN_TIMEOUT_MS = 1_000; const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000; const DEFAULT_MAX_TASKS = 200; const MAX_CAPTURE_CHARS = 200_000; const MAX_EVENT_MESSAGE_CHARS = 4_000; const MAX_EVENTS = 300; // A startup conflict shows up within moments of launch. Later failures are // not restarted, because the worker may have started real work. const STARTUP_CONFLICT_WINDOW_MS = 10_000; const MAX_STARTUP_RESTARTS = 3; const STARTUP_RESTART_DELAY_MS = 500; export class TaskManager { private readonly tasks = new Map(); private readonly drainTimeoutMs: number; private readonly startupRestartDelayMs: number; private readonly maxTasks: number; private readonly spawn: TaskSpawn; private readonly removeIsolatedCwd: (path: string) => void; private readonly ownsProcessGroups: boolean; private readonly retainedTasks = new Map(); private shuttingDown = false; private shutdownPromise?: Promise; constructor(options: TaskManagerOptions = {}) { this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS; this.startupRestartDelayMs = options.startupRestartDelayMs ?? STARTUP_RESTART_DELAY_MS; this.maxTasks = Math.max(1, options.maxTasks ?? DEFAULT_MAX_TASKS); this.spawn = options.spawn ?? ((input) => Bun.spawn(input)); this.ownsProcessGroups = !options.spawn && process.platform !== "win32"; this.removeIsolatedCwd = options.removeIsolatedCwd ?? ((path) => { rmSync(path, { recursive: true, force: true }); }); } start( adapter: HarnessAdapter, discovery: HarnessDiscovery, input: HarnessRunInput, ): StartTaskResult { if (this.shuttingDown) { throw new Error("TaskManager is shutting down."); } if (!adapter.buildCommand || !discovery.commandPath || !discovery.runnable) { throw new Error(`${adapter.name} is not runnable through Ennodia yet.`); } if (input.nativeSessionId) throw new Error("Use continueTaskId, not a native session ID."); let previous: InternalTask | undefined; if (input.continueTaskId) { previous = this.tasks.get(input.continueTaskId); if (!previous || previous.status === "running" || !previous.nativeSessionId || !previous.persistSession) { throw new Error("Continuation requires a settled persistent task owned by this TaskManager."); } if (previous.continuationTaskId) throw new Error("Continue the latest task in this session, not an earlier turn."); if (previous.harnessId !== adapter.id) throw new Error("Continuation must use the same harness."); if (input.cwd && resolve(input.cwd) !== resolve(previous.cwd)) throw new Error("Continuation must use the same working directory."); if (input.persistSession === false) throw new Error("Continuation requires session persistence."); input = { ...input, cwd: previous.cwd, model: input.model ?? previous.model, reasoningEffort: input.reasoningEffort ?? previous.reasoningEffort, nativeSubagents: input.nativeSubagents ?? previous.nativeSubagents, persistSession: true }; } if (input.persistSession && (!adapter.supportsSessionContinuation || input.isolateCwd)) { throw new Error("Persistent sessions require a supported harness and a persistent working directory."); } assertHarnessSettingsSupported(adapter, input); if (adapter.supportsNativeSandbox) input = { ...input, nativeSandbox: input.nativeSandbox ?? "read-only" }; const sourceCwd = input.cwd ?? process.cwd(); if (!existsSync(sourceCwd)) { throw new Error(`Working directory does not exist: ${sourceCwd}`); } let isolatedCwd: string | undefined; let isolatedFrom: string | undefined; let resolvedInputCwd = input.cwd; if (input.isolateCwd) { isolatedCwd = isolateDirectory(sourceCwd); resolvedInputCwd = isolatedCwd; isolatedFrom = sourceCwd; } // Always allocate a scratch path; adapters that support writing a clean // final message (e.g. Codex's --output-last-message) opt in by // referencing input.finalMessagePath in buildCommand. Adapters that // don't support it simply never create the file, and it's cleaned up // as a no-op. const finalMessagePath = join(tmpdir(), `ennodia-final-${randomUUID()}.txt`); const timeoutMs = input.timeoutMs ?? DEFAULT_TASK_TIMEOUT_MS; const now = Date.now(); const deadlineMs = now + timeoutMs; const deadlineAt = new Date(deadlineMs).toISOString(); const guidance = inputGuidanceNotes(input.prompt, adapter); const noticeLines = [ `Execution allowance: ${timeoutMs} ms from task launch (${input.timeoutMs === undefined ? "Ennodia default" : "caller supplied"}). Deadline (UTC): ${deadlineAt}. This is a hard cutoff, not a completion estimate or a target duration.`, "Check actual UTC time before attributing incomplete work to the deadline. Do not report a cutoff merely because the task is difficult or the answer is getting long. Continue within the remaining allowance unless complete or blocked by a demonstrated access or tool failure.", `Configured native sandbox: ${input.nativeSandbox ?? "harness-managed; check available permissions"}. Report any mismatch between the assignment and available access before attempting the work.`, "Before the cutoff, return useful partial findings, verification performed, remaining work, and a justified continuation budget if needed. Only the caller can authorize a follow-up.", "Use only tools and permissions actually available in this session; the caller's tools are not automatically inherited. If access is missing, report the exact tool, file, or permission and its impact. Try a permitted alternative when useful; do not repeat an unchanged failed attempt or claim success without evidence.", ]; // An adapter that takes operator instructions gets Ennodia's notices // there. Other workers get them as tagged blocks after the task. let systemNotice: string | undefined; let executionPrompt: string; if (adapter.systemNotices) { executionPrompt = input.prompt; systemNotice = [ "Ennodia, the local tool that started this session for its caller, sets these limits for the task.", ...noticeLines, ...(guidance ? ["When the task needs native media inspection:", ...guidance.map((note) => `- ${note}`)] : []), ].join("\n"); } else { executionPrompt = `${withInputGuidance(input.prompt, adapter)} <${EXECUTION_NOTICE_TAG}> From Ennodia, not part of the task above. ${noticeLines.join("\n")} `; } const augmentedPrompt = input.skills && input.skills.length > 0 ? augmentPrompt(executionPrompt, input.skills, adapter.id) : executionPrompt; let commandSpec: CommandSpec; let cwd: string; let spawnInput: TaskSpawnInput; let child: TaskProcess; try { commandSpec = adapter.buildCommand(discovery.commandPath, { ...input, cwd: resolvedInputCwd, prompt: augmentedPrompt, systemNotice, finalMessagePath, nativeSessionId: previous?.nativeSessionId, }); cwd = commandSpec.cwd ?? process.cwd(); if (!existsSync(cwd)) { throw new Error(`Working directory does not exist: ${cwd}`); } spawnInput = { cmd: [commandSpec.command, ...commandSpec.args], cwd, env: { ...process.env, ...commandSpec.env }, stdin: commandSpec.stdin === undefined ? "ignore" : "pipe", stdout: "pipe", stderr: "pipe", detached: process.platform !== "win32", }; child = this.spawn(spawnInput); } catch (error) { cleanupFailedStartArtifacts( finalMessagePath, isolatedCwd, ); throw error; } const task: InternalTask = { id: randomUUID(), harnessId: adapter.id, harnessName: adapter.name, model: input.model, reasoningEffort: input.reasoningEffort, nativeSubagents: input.nativeSubagents, nativeSandbox: input.nativeSandbox, persistSession: input.persistSession, continueTaskId: input.continueTaskId, nativeSessionId: previous?.nativeSessionId, extractSessionId: input.persistSession ? adapter.extractSessionId : undefined, status: "running", cwd, isolatedFrom, command: [ basename(commandSpec.command), ...commandSpec.args.map((arg) => arg === augmentedPrompt ? "" : arg === systemNotice ? "" : arg), ...(commandSpec.stdin === undefined ? [] : [""]), ], promptPreview: preview(input.prompt), createdAtMs: now, updatedAtMs: now, timeoutMs, timeoutSource: input.timeoutMs === undefined ? "task-manager-default" : "caller", deadlineAt, cancelRequested: false, drainTimedOut: false, timedOut: false, stdout: "", stderr: "", events: [], streamReaders: new Set(), appliedSkills: input.skills?.map(toAppliedSkillInfo), finalMessagePath, isolatedCwd, extractUsage: adapter.extractUsage, failureReason: adapter.failureReason, spawnInput, stdinText: commandSpec.stdin, startupConflict: adapter.startupConflict, attemptStartedAtMs: Date.now(), }; task.process = child; task.pid = child.pid; // Publish a task only after the process exists. A synchronous spawn // failure must not leave a phantom running task that cannot settle. this.tasks.set(task.id, task); if (previous) previous.continuationTaskId = task.id; this.pruneTasks(task.id); this.pushEvent(task, { type: "started", message: `Task started. Execution limit: ${timeoutMs}ms (${task.timeoutSource}); deadline: ${task.deadlineAt}. This is not a completion estimate.` }); if (guidance) { this.pushEvent(task, { type: "input-guidance", message: "Media input guidance was included in the worker prompt. Native inspection remains unverified until the worker reports evidence.", }); } task.streamsDone = Promise.all([ this.pipeStreamSafely(task, "stdout", child.stdout), this.pipeStreamSafely(task, "stderr", child.stderr), ]).then(() => undefined); task.timeout = setTimeout(() => { if (task.cancelRequested) { return; } task.timedOut = true; this.pushEvent(task, { type: "error", message: `Timed out after ${timeoutMs}ms.`, }); this.terminate(task); }, Math.max(0, deadlineMs - Date.now())); task.settled = this.watchExit(task, child); void task.settled; this.writeStdin(task, child); return { task: this.toView(task) }; } list(): TaskView[] { return this.listViews({ includeOutput: false, includeEvents: false, }); } listViews(options: TaskViewOptions = {}): TaskView[] { return [...this.tasks.values()] .sort((a, b) => b.createdAtMs - a.createdAtMs) .map((task) => this.toView(task, options)); } get(taskId: string, options: TaskViewOptions = {}): TaskView | undefined { const task = this.tasks.get(taskId); return task ? this.toView(task, options) : undefined; } /** Keep evidence alive until its owning operation has recorded its result. */ retain(taskId: string): void { if (!this.tasks.has(taskId)) throw new Error(`Unknown task: ${taskId}`); this.retainedTasks.set(taskId, (this.retainedTasks.get(taskId) ?? 0) + 1); } release(taskId: string): void { const count = this.retainedTasks.get(taskId) ?? 0; if (count > 1) this.retainedTasks.set(taskId, count - 1); else this.retainedTasks.delete(taskId); this.pruneTasks(); } cancel(taskId: string): TaskView { const task = this.tasks.get(taskId); if (!task) { throw new Error(`Unknown task: ${taskId}`); } if (task.status !== "running") { return this.toView(task); } this.requestCancel(task, "Cancellation requested."); return this.toView(task); } async waitForTerminal( taskId: string, timeoutMs?: number, ): Promise { const task = this.tasks.get(taskId); if (!task) { return undefined; } if (task.status === "running" && task.settled) { await this.waitForSettled([task], timeoutMs); } return this.toView(task); } async shutdown(options: TaskManagerShutdownOptions = {}): Promise { this.shuttingDown = true; this.shutdownPromise ??= this.performShutdown( options.deadlineMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS, ); await this.shutdownPromise; } private async performShutdown(deadlineMs: number): Promise { const runningTasks = [...this.tasks.values()].filter((task) => task.status === "running" ); for (const task of runningTasks) { this.requestCancel(task, "Cancellation requested by shutdown."); } await this.waitForSettled(runningTasks, deadlineMs); const stillRunning = runningTasks.filter((task) => task.status === "running" ); for (const task of stillRunning) { this.pushEvent(task, { type: "error", message: `Shutdown deadline exceeded after ${deadlineMs}ms; force-killing task.`, }); this.signal(task, "SIGKILL"); } await Promise.allSettled( stillRunning.map((task) => task.settled ?? Promise.resolve()), ); for (const task of this.tasks.values()) { this.clearTimers(task); this.cleanupIsolatedCwd(task); } } private async watchExit( task: InternalTask, child: TaskProcess, ): Promise { let terminalStatus: TaskStatus = "failed"; try { let exitCode = await child.exited; if (task.killTimeout) this.signal(task, "SIGKILL"); await this.waitForOutputDrain(task); while (exitCode !== 0 && this.metStartupConflict(task)) { const restarted = await this.restartAfterConflict(task); if (!restarted) break; exitCode = await restarted.exited; if (task.killTimeout) this.signal(task, "SIGKILL"); await this.waitForOutputDrain(task); } task.exitCode = exitCode; if (task.cancelRequested) { terminalStatus = "cancelled"; } else { terminalStatus = exitCode === 0 && !task.timedOut && !task.drainTimedOut ? "succeeded" : "failed"; } this.pushEvent(task, { type: "exit", message: `Process exited with code ${exitCode}.`, }); } catch (error) { await this.waitForOutputDrain(task); if (task.cancelRequested) { terminalStatus = "cancelled"; } else { terminalStatus = "failed"; } this.pushEvent(task, { type: "error", message: error instanceof Error ? error.message : String(error), }); } finally { // A CLI can exit before its build/shell children. Clean up the owned // group before deleting an isolated checkout or reporting completion. this.signal(task, "SIGKILL"); await this.collectFinalMessage(task); if (terminalStatus === "succeeded" && task.failureReason) { try { const reason = task.failureReason(task.finalMessage || task.stdout, task.stderr); if (reason) { terminalStatus = "failed"; this.pushEvent(task, { type: "error", message: reason }); } } catch (error) { terminalStatus = "failed"; this.pushEvent(task, { type: "error", message: `Adapter result check failed: ${String(error)}` }); } } this.collectUsage(task); this.cleanupIsolatedCwd(task); this.clearTimers(task); task.status = task.cancelRequested ? "cancelled" : terminalStatus; task.endedAtMs = Date.now(); this.touch(task); this.pruneTasks(task.id); } } private writeStdin(task: InternalTask, child: TaskProcess): void { if (task.stdinText === undefined) { return; } try { const childStdin = child.stdin; if (!childStdin) { throw new Error("Child stdin pipe was not available."); } childStdin.write(task.stdinText); childStdin.end(); } catch (error) { this.pushEvent(task, { type: "error", message: error instanceof Error ? error.message : String(error), }); this.terminate(task); } } /** A start that lost a race for the CLI's shared local state did no work, * so running the same command again is safe. */ private metStartupConflict(task: InternalTask): boolean { return Boolean(task.startupConflict?.test(task.stderr)) && !task.stdout.trim() && !task.cancelRequested && !task.timedOut && !task.drainTimedOut && !this.shuttingDown && (task.startupRestarts ?? 0) < MAX_STARTUP_RESTARTS && Date.now() - task.attemptStartedAtMs < STARTUP_CONFLICT_WINDOW_MS; } private async restartAfterConflict(task: InternalTask): Promise { const restart = (task.startupRestarts ?? 0) + 1; // Jitter spreads out tasks that met the same conflict together. const delayMs = Math.round(this.startupRestartDelayMs * restart * (1 + Math.random())); this.pushEvent(task, { type: "restart", message: `The CLI stopped during startup because another instance held shared local state. Restart ${restart} of ${MAX_STARTUP_RESTARTS} in ${delayMs}ms. stderr: ${tail(task.stderr, 500).trim()}`, }); await Bun.sleep(delayMs); if (task.cancelRequested || task.timedOut || this.shuttingDown) { return undefined; } // Clear anything the failed start left in its process group. this.signal(task, "SIGKILL"); let child: TaskProcess; try { child = this.spawn(task.spawnInput); } catch (error) { this.pushEvent(task, { type: "error", message: `Restart failed: ${error instanceof Error ? error.message : String(error)}`, }); return undefined; } task.forceSignalled = false; task.startupRestarts = restart; task.stdout = ""; task.stderr = ""; task.streamCaptureTruncated = false; task.process = child; task.pid = child.pid; task.attemptStartedAtMs = Date.now(); task.streamsDone = Promise.all([ this.pipeStreamSafely(task, "stdout", child.stdout), this.pipeStreamSafely(task, "stderr", child.stderr), ]).then(() => undefined); this.writeStdin(task, child); return child; } private async collectFinalMessage(task: InternalTask): Promise { if (!task.finalMessagePath) { return; } try { if (existsSync(task.finalMessagePath)) { const content = (await readFile(task.finalMessagePath, "utf8")).trim(); if (content) { task.finalMessage = content; } rmSync(task.finalMessagePath, { force: true }); } } catch (error) { this.pushEvent(task, { type: "error", message: `Failed to read final message file: ${ error instanceof Error ? error.message : String(error) }`, }); } } private collectUsage(task: InternalTask): void { if (!task.extractUsage || task.streamCaptureTruncated) { return; } try { task.usage = task.extractUsage(task.stdout, task.stderr); } catch { task.usage = undefined; } } private cleanupIsolatedCwd(task: InternalTask): boolean { if (!task.isolatedCwd) { return true; } try { this.removeIsolatedCwd(task.isolatedCwd); task.isolatedCwd = undefined; return true; } catch (error) { this.pushEvent(task, { type: "error", message: `Failed to remove isolated working directory: ${ error instanceof Error ? error.message : String(error) }`, }); return false; } } private pruneTasks(protectedTaskId?: string): void { const overflow = this.tasks.size - this.maxTasks; if (overflow <= 0) { return; } const removable = [...this.tasks.values()] .filter((task) => task.id !== protectedTaskId && task.status !== "running" && !this.retainedTasks.has(task.id)) .sort((a, b) => a.createdAtMs - b.createdAtMs); let removed = 0; for (const task of removable) { if (removed >= overflow) { break; } if (!this.cleanupIsolatedCwd(task)) { continue; } this.tasks.delete(task.id); removed += 1; } } private requestCancel(task: InternalTask, message: string): void { if (!task.cancelRequested) { task.cancelRequested = true; this.pushEvent(task, { type: "cancel", message }); } this.terminate(task); } private signal(task: InternalTask, signal: "SIGTERM" | "SIGKILL"): void { if (!task.process || task.forceSignalled) return; try { signalOwnedProcess(task.process, signal, this.ownsProcessGroups); if (signal === "SIGKILL") task.forceSignalled = true; } catch (error) { this.pushEvent(task, { type: "error", message: `Process cleanup failed: ${String(error)}` }); } } private terminate(task: InternalTask): void { if (task.killTimeout) return; this.signal(task, "SIGTERM"); task.killTimeout = setTimeout(() => { this.pushEvent(task, { type: "error", message: "Termination grace period exceeded; force-killing task." }); this.signal(task, "SIGKILL"); }, 300); } private async waitForSettled( tasks: InternalTask[], timeoutMs?: number, ): Promise { const waiting = tasks .filter((task) => task.status === "running") .map((task) => task.settled ?? Promise.resolve()); if (waiting.length === 0) { return; } const allSettled = Promise.allSettled(waiting).then(() => undefined); if (timeoutMs === undefined) { await allSettled; return; } let timeout: Timer | undefined; await Promise.race([ allSettled, new Promise((resolve) => { timeout = setTimeout(resolve, timeoutMs); }), ]); if (timeout) { clearTimeout(timeout); } } private async waitForOutputDrain(task: InternalTask): Promise { if (!task.streamsDone) { return; } let timeout: Timer | undefined; const drained = await Promise.race([ task.streamsDone.then(() => true), new Promise((resolve) => { timeout = setTimeout(() => resolve(false), this.drainTimeoutMs); }), ]); if (timeout) { clearTimeout(timeout); } if (drained) { return; } task.drainTimedOut = true; this.pushEvent(task, { type: "error", message: `Output drain timed out after ${this.drainTimeoutMs}ms; captured output may be incomplete.`, }); this.cancelStreamReaders(task); } private cancelStreamReaders(task: InternalTask): void { for (const reader of task.streamReaders) { void reader.cancel("Output drain timed out.").catch((error) => { this.pushEvent(task, { type: "error", message: `Output reader cancellation failed: ${ error instanceof Error ? error.message : String(error) }`, }); }); } } private async pipeStreamSafely( task: InternalTask, streamName: "stdout" | "stderr", stream: ReadableStream | null, ): Promise { try { await this.pipeStream(task, streamName, stream); } catch (error) { this.pushEvent(task, { type: "error", message: `${streamName} read failed: ${ error instanceof Error ? error.message : String(error) }`, }); } } private async pipeStream( task: InternalTask, streamName: "stdout" | "stderr", stream: ReadableStream | null, ): Promise { if (!stream) { return; } const decoder = new TextDecoder(); const reader = stream.getReader(); task.streamReaders.add(reader); try { while (true) { const { done, value } = await reader.read(); if (done) { break; } const chunk = decoder.decode(value, { stream: true }); if (streamName === "stdout" && task.extractSessionId && !task.nativeSessionId) { try { task.nativeSessionId = task.extractSessionId(task.stdout + chunk); } catch (error) { task.extractSessionId = undefined; this.pushEvent(task, { type: "error", message: `Session ID extraction failed: ${String(error)}` }); } } task.streamCaptureTruncated ||= task[streamName].length + chunk.length > MAX_CAPTURE_CHARS; task[streamName] = appendLimited(task[streamName], chunk); task.lastOutputAtMs = Date.now(); this.pushEvent(task, { type: streamName, message: chunk }); this.touch(task); } const finalChunk = decoder.decode(); if (finalChunk) { task.streamCaptureTruncated ||= task[streamName].length + finalChunk.length > MAX_CAPTURE_CHARS; task[streamName] = appendLimited(task[streamName], finalChunk); task.lastOutputAtMs = Date.now(); this.pushEvent(task, { type: streamName, message: finalChunk }); this.touch(task); } } finally { task.streamReaders.delete(reader); reader.releaseLock(); } } private toView(task: InternalTask, options: TaskViewOptions = {}): TaskView { const now = Date.now(); const end = task.endedAtMs ?? now; const elapsedMs = Math.max(0, end - task.createdAtMs); const running = task.status === "running"; const remainingMs = running ? Math.max(0, task.timeoutMs - elapsedMs) : 0; const includeOutput = options.includeOutput ?? true; const includeEvents = options.includeEvents ?? true; const maxOutputChars = options.maxOutputChars ?? MAX_CAPTURE_CHARS; const maxEvents = options.maxEvents ?? MAX_EVENTS; return { id: task.id, harnessId: task.harnessId, harnessName: task.harnessName, model: task.model, reasoningEffort: task.reasoningEffort, nativeSubagents: task.nativeSubagents, nativeSandbox: task.nativeSandbox, persistSession: task.persistSession, continueTaskId: task.continueTaskId, continuationTaskId: task.continuationTaskId, canContinue: Boolean(!running && task.persistSession && task.nativeSessionId && !task.continuationTaskId), status: task.status, cancelRequested: task.cancelRequested, pid: task.pid, cwd: task.cwd, command: task.command, promptPreview: task.promptPreview, createdAt: new Date(task.createdAtMs).toISOString(), updatedAt: new Date(task.updatedAtMs).toISOString(), endedAt: task.endedAtMs ? new Date(task.endedAtMs).toISOString() : undefined, elapsedMs, timeoutMs: task.timeoutMs, timeoutSource: task.timeoutSource, deadlineAt: task.deadlineAt, remainingMs, etaConfidence: running ? "timeout-budget" : "complete", lastOutputAt: task.lastOutputAtMs ? new Date(task.lastOutputAtMs).toISOString() : undefined, exitCode: task.exitCode, timedOut: task.timedOut, drainTimedOut: task.drainTimedOut, stdoutChars: task.stdout.length, stderrChars: task.stderr.length, eventCount: task.events.length, stdout: includeOutput ? tail(task.stdout, maxOutputChars) : "", stderr: includeOutput ? tail(task.stderr, maxOutputChars) : "", events: includeEvents ? tailItems(task.events, maxEvents) : [], appliedSkills: task.appliedSkills, finalMessage: includeOutput && task.finalMessage !== undefined ? tail(task.finalMessage, maxOutputChars) : undefined, finalMessageChars: task.finalMessage?.length ?? 0, hasOutput: Boolean(task.finalMessage?.trim() || task.stdout.trim() || task.stderr.trim()), captureTruncated: !task.finalMessage?.trim() && Boolean(task.streamCaptureTruncated), outputTruncated: task.finalMessage?.trim() ? !includeOutput || task.finalMessage.length > maxOutputChars : Boolean(task.streamCaptureTruncated || task.stdout.length > (includeOutput ? maxOutputChars : 0) || task.stderr.length > (includeOutput ? maxOutputChars : 0)), usage: task.usage, isolatedFrom: task.isolatedFrom, startupRestarts: task.startupRestarts || undefined, }; } private pushEvent( task: InternalTask, event: Omit, ): void { task.events.push({ at: new Date().toISOString(), ...event, message: event.message ? trimEventMessage(event.message) : undefined, }); if (task.events.length > MAX_EVENTS) { task.events.splice(0, task.events.length - MAX_EVENTS); } this.touch(task); } private touch(task: InternalTask): void { task.updatedAtMs = Date.now(); } private clearTimers(task: InternalTask): void { if (task.killTimeout) { clearTimeout(task.killTimeout); task.killTimeout = undefined; } if (task.timeout) { clearTimeout(task.timeout); task.timeout = undefined; } } } function appendLimited(current: string, chunk: string): string { const next = current + chunk; if (next.length <= MAX_CAPTURE_CHARS) { return next; } return next.slice(next.length - MAX_CAPTURE_CHARS); } function tail(text: string, maxChars: number): string { if (maxChars <= 0) { return ""; } if (text.length <= maxChars) { return text; } return text.slice(text.length - maxChars); } function trimEventMessage(message: string): string { if (message.length <= MAX_EVENT_MESSAGE_CHARS) { return message; } return `${message.slice(0, MAX_EVENT_MESSAGE_CHARS)}...`; } const ISOLATION_EXCLUDED_DIRS = new Set(["node_modules", ".git"]); function isolateDirectory(source: string): string { const target = mkdtempSync(join(tmpdir(), "ennodia-cwd-")); try { cpSync(source, target, { recursive: true, filter: (candidate) => { if (ISOLATION_EXCLUDED_DIRS.has(basename(candidate))) { return false; } if (lstatSync(candidate).isSymbolicLink()) { throw new Error( `Cannot isolate working directory containing symbolic link: ${candidate}`, ); } return true; }, }); return target; } catch (error) { rmSync(target, { recursive: true, force: true }); throw error; } } function cleanupFailedStartArtifacts( finalMessagePath: string, isolatedCwd?: string, ): void { rmSync(finalMessagePath, { force: true }); if (isolatedCwd) { rmSync(isolatedCwd, { recursive: true, force: true }); } }