/** * SubagentManager — owns the registry of running/finished subagents. * * Each subagent is a scoped `SubagentSession` from a `SubagentBackend` plus a * pump fiber that folds its normalized event stream into a mutable * `SubagentSnapshot`. Closing a subagent's scope kills the underlying * session/process and stops the pump. * * The manager also exposes a synchronous `SubagentReadModel` so the * imperative TUI components (which render synchronously) can read snapshots * and issue fire-and-forget commands without touching the Effect runtime. */ import { Context, Effect, Exit, Fiber, Layer, Result, Scope, Stream, } from "effect"; import type { SubagentBackend, SubagentSession } from "./backend.ts"; import { BackendRegistry } from "./backend.ts"; import type { BackendName, LiveToolState, RunOutcome, SpawnTask, SubagentEvent, SubagentOrigin, SubagentMeta, SubagentSnapshot, SubagentStatus, TranscriptItem, } from "./domain.ts"; import { BackendUnavailableError, ConcurrencyLimitError, SendError, SpawnError, } from "./domain.ts"; export const MAX_RUNNING = 4; export const MAX_TRACKED = 64; const STOP_TIMEOUT_MS = 5_000; const ERROR_TEXT_MAX_LENGTH = 4_096; const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024; const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024; const FINAL_TEXT_MAX_LENGTH = 1_024 * 1_024; const MAX_TRANSCRIPT_ITEMS = 512; function bounded(text: string) { return text.slice(0, ERROR_TEXT_MAX_LENGTH); } function boundedTranscriptText(text: string) { return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH); } function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) { snapshot.transcript.push(item); if (snapshot.transcript.length > MAX_TRANSCRIPT_ITEMS) { snapshot.transcript.splice( 0, snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS, ); } } // --- Internal state ----------------------------------------------------------- /** Mutable snapshot; exposed to readers via the readonly SubagentSnapshot type. */ interface MutableSnapshot { id: string; origin: SubagentOrigin; backend: BackendName; title: string; prompt: string; cwd: string; status: SubagentStatus; createdAt: number; settledAt?: number; errorText?: string; meta: SubagentMeta; usage: { tokens?: number; contextWindow?: number }; transcript: TranscriptItem[]; liveAssistant?: { text: string; thinking: string }; liveTools: LiveToolState[]; queued: SubagentSnapshot["queued"]; finalText: string; turns: number; } interface Entry { snapshot: MutableSnapshot; session: SubagentSession; scope: Scope.Closeable; pump?: Fiber.Fiber; liveToolMap: Map; /** Idle restart dispatched but RunStarted not folded yet; counts as running * so concurrent restarts cannot race past the cap. */ restarting?: boolean; } // --- Read model ---------------------------------------------------------------- /** Synchronous bridge for the TUI. Snapshots are live objects; do not mutate. */ export interface SubagentReadModel { list(): ReadonlyArray; get(id: string): SubagentSnapshot | undefined; size(): number; /** Any-change notification (footer status, dashboard). */ subscribe(listener: () => void): () => void; /** Per-subagent notification (takeover view). */ subscribeTo(id: string, listener: () => void): () => void; /** Fire-and-forget: steer/continue a subagent (takeover input). */ requestSend(id: string, text: string): void; /** Fire-and-forget: abort a running subagent (dashboard `x`, takeover). */ requestAbort(id: string): void; /** * Register the settle hook. `consumed` is true when an active * subagent_wait/cancel is collecting the result (so it must not also be * delivered as a follow-up message). */ setOnSettled( hook: ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined, ): void; } // --- Service -------------------------------------------------------------------- export interface CancelResult { readonly id: string; readonly title: string; readonly status: SubagentStatus; readonly cancelled: boolean; } export interface SubagentManagerShape { spawn( backend: BackendName, task: SpawnTask, ): Effect.Effect< SubagentSnapshot, SpawnError | ConcurrencyLimitError | BackendUnavailableError >; /** * Wait until all listed subagents are settled. Unknown ids are treated as * settled (the tool layer validates ids first). While waiting, settles for * these ids are marked "consumed". Interruption (tool abort) releases the * interest and leaves the subagents running. */ waitFor( ids: ReadonlyArray, onPending?: (pending: string[]) => void, ): Effect.Effect; /** Cancel running subagents; resolves when they have settled. */ cancel( ids: ReadonlyArray, ): Effect.Effect>; send(id: string, text: string): Effect.Effect; get(id: string): Effect.Effect; readonly list: Effect.Effect>; readonly disposeAll: Effect.Effect; readonly view: SubagentReadModel; } export class SubagentManager extends Context.Service< SubagentManager, SubagentManagerShape >()("subagents/SubagentManager") {} // --- Implementation -------------------------------------------------------------- const makeManager = Effect.gen(function* () { const registry = yield* BackendRegistry; // Detached forker for sync contexts (read-model commands, pruning) that // preserves the manager's services instead of using the global runtime. const runDetached = Effect.runForkWith(yield* Effect.context()); const entries = new Map(); const waitInterest = new Map(); const listeners = new Set<() => void>(); /** One-shot nextChange waiters, swapped out before invocation so waiters * re-registering during notification are not visited in the same sweep. */ let changeWaiters: Array<() => void> = []; const idListeners = new Map void>>(); const cleanups = new Set>(); let modelCounter = 0; let btwCounter = 0; let reserved = 0; let disposed = false; let onSettled: ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined; const notify = (id?: string) => { const waiters = changeWaiters; changeWaiters = []; for (const waiter of waiters) waiter(); for (const listener of [...listeners]) { try { listener(); } catch { // A failed status/render listener must not corrupt lifecycle state. } } if (id) { for (const listener of idListeners.get(id) ?? []) { try { listener(); } catch { // Same. } } } }; /** Resolves on the next state change. Interruption unregisters the waiter. */ const nextChange = Effect.callback((resume) => { const waiter = () => resume(Effect.void); changeWaiters.push(waiter); return Effect.sync(() => { const index = changeWaiters.indexOf(waiter); if (index >= 0) changeWaiters.splice(index, 1); }); }); const runningCount = () => [...entries.values()].filter( (e) => e.snapshot.status === "running" || e.restarting === true, ).length; const addInterest = (ids: ReadonlyArray) => { for (const id of ids) waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1); }; const releaseInterest = (ids: ReadonlyArray) => { for (const id of ids) { const count = (waitInterest.get(id) ?? 1) - 1; if (count <= 0) waitInterest.delete(id); else waitInterest.set(id, count); } }; const closeEntryScope = (entry: Entry) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore); const pruneSettled = () => { if (entries.size <= MAX_TRACKED) return; const candidates = [...entries.values()] .filter( (e) => e.snapshot.status !== "running" && !waitInterest.has(e.snapshot.id), ) .sort( (a, b) => (a.snapshot.settledAt ?? a.snapshot.createdAt) - (b.snapshot.settledAt ?? b.snapshot.createdAt), ); for (const entry of candidates) { if (entries.size <= MAX_TRACKED) break; entries.delete(entry.snapshot.id); const fiber = runDetached(closeEntryScope(entry)); cleanups.add(fiber); fiber.addObserver(() => cleanups.delete(fiber)); } }; const settle = (entry: Entry, outcome: RunOutcome) => { const s = entry.snapshot; entry.restarting = false; if (s.status !== "running") return; s.settledAt = Date.now(); switch (outcome._tag) { case "Completed": s.status = "done"; s.errorText = undefined; s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH); break; case "Failed": s.status = "error"; s.errorText = bounded(outcome.errorText); // Never let a failed run report the previous run's successful output. s.finalText = (outcome.partialText ?? "").slice( 0, FINAL_TEXT_MAX_LENGTH, ); break; case "Interrupted": s.status = "error"; s.errorText = "Run was aborted"; s.finalText = (outcome.partialText ?? "").slice( 0, FINAL_TEXT_MAX_LENGTH, ); break; } s.liveAssistant = undefined; entry.liveToolMap.clear(); s.liveTools = []; s.queued = []; const consumed = (waitInterest.get(s.id) ?? 0) > 0; notify(s.id); try { // During teardown, don't queue results into a shutting-down session. if (!disposed) onSettled?.(s, consumed); } catch { // The parent session may be unavailable; settlement stays final. } pruneSettled(); }; const foldEvent = (entry: Entry, event: SubagentEvent) => { const s = entry.snapshot; switch (event._tag) { case "RunStarted": entry.restarting = false; s.status = "running"; s.settledAt = undefined; s.errorText = undefined; break; case "RunSettled": settle(entry, event.outcome); return; // settle() already notified case "UserMessage": appendTranscript(s, { kind: "user", text: boundedTranscriptText(event.text), }); break; case "AssistantDelta": { const live = s.liveAssistant ?? { text: "", thinking: "" }; s.liveAssistant = event.kind === "text" ? { ...live, text: (live.text + event.delta).slice( -LIVE_ASSISTANT_MAX_LENGTH, ), } : { ...live, thinking: (live.thinking + event.delta).slice( -LIVE_ASSISTANT_MAX_LENGTH, ), }; break; } case "AssistantMessage": appendTranscript(s, { kind: "assistant", parts: event.parts.map((part) => part.type === "toolCall" ? { ...part, argsPreview: part.argsPreview ? boundedTranscriptText(part.argsPreview) : undefined, } : { ...part, text: boundedTranscriptText(part.text) }, ), }); s.liveAssistant = undefined; s.turns++; break; case "ToolStart": entry.liveToolMap.set(event.toolId, { toolId: event.toolId, name: event.name, argsPreview: event.argsPreview ? boundedTranscriptText(event.argsPreview) : undefined, }); s.liveTools = [...entry.liveToolMap.values()]; break; case "ToolUpdate": { const current = entry.liveToolMap.get(event.toolId); if (current) { entry.liveToolMap.set(event.toolId, { ...current, outputPreview: event.outputPreview ? boundedTranscriptText(event.outputPreview) : current.outputPreview, }); s.liveTools = [...entry.liveToolMap.values()]; } break; } case "ToolEnd": entry.liveToolMap.delete(event.toolId); s.liveTools = [...entry.liveToolMap.values()]; appendTranscript(s, { kind: "toolResult", toolId: event.toolId, name: event.name, isError: event.isError, outputPreview: event.outputPreview ? boundedTranscriptText(event.outputPreview) : undefined, }); break; case "QueueChanged": s.queued = event.queued; break; case "UsageChanged": s.usage = { tokens: event.tokens ?? s.usage.tokens, contextWindow: event.contextWindow ?? s.usage.contextWindow, }; break; case "MetaChanged": s.meta = { ...s.meta, ...event.meta }; break; case "BackendError": s.errorText = bounded(event.message); break; } notify(s.id); }; const spawn = (backendName: BackendName, task: SpawnTask) => Effect.gen(function* () { // Reserve synchronously (before the first yield inside doSpawn) so // parallel tool calls cannot race past the global cap. yield* Effect.suspend( (): Effect.Effect => { if (disposed) { return new SpawnError({ message: "Subagent manager is shutting down.", }); } if (runningCount() + reserved >= MAX_RUNNING) { return new ConcurrencyLimitError({ message: `Max ${MAX_RUNNING} subagents can run concurrently. Wait for one to finish before spawning another.`, }); } reserved++; return Effect.void; }, ); const doSpawn = Effect.gen(function* () { const backend: SubagentBackend | undefined = registry.get(backendName); if (!backend) { return yield* new BackendUnavailableError({ message: `Unknown backend "${backendName}".`, }); } const available = yield* backend.available; if (!available) { return yield* new BackendUnavailableError({ message: `Backend "${backendName}" is not available on this machine (binary/SDK/credentials missing).`, }); } const scope = yield* Scope.make(); const session = yield* Scope.provide(backend.spawn(task), scope).pipe( Effect.onError(() => Scope.close(scope, Exit.void)), ); if (disposed) { yield* Scope.close(scope, Exit.void); return yield* new SpawnError({ message: "Subagent manager shut down while spawning.", }); } const origin = task.origin ?? "model"; const id = origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`; const meta = yield* session.meta; const entry: Entry = { snapshot: { id, origin, backend: backendName, title: task.title, prompt: task.prompt, cwd: task.cwd, status: "running", createdAt: Date.now(), meta, usage: { contextWindow: meta.contextWindow }, transcript: [], liveTools: [], queued: [], finalText: "", turns: 0, }, session, scope, liveToolMap: new Map(), }; entries.set(id, entry); // Pump: fold the event stream into the snapshot. Tied to the entry // scope, so closing the scope stops it. If the stream ends while the // subagent still looks running, the backend died out from under us. const pump = Stream.runForEach(session.events, (event) => Effect.sync(() => foldEvent(entry, event)), ).pipe( Effect.ensuring( Effect.sync(() => { if (entry.snapshot.status === "running") { settle(entry, { _tag: "Failed", errorText: "Backend event stream ended unexpectedly", }); } }), ), ); entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope); notify(id); return entry.snapshot as SubagentSnapshot; }); return yield* doSpawn.pipe( Effect.ensuring( Effect.sync(() => { reserved--; notify(); }), ), ); }); const waitFor = ( ids: ReadonlyArray, onPending?: (pending: string[]) => void, ) => Effect.suspend(() => { const unique = [...new Set(ids)]; addInterest(unique); const loop = Effect.gen(function* () { while (true) { const pending = unique.filter( (id) => entries.get(id)?.snapshot.status === "running", ); if (pending.length === 0) return; onPending?.(pending); yield* nextChange; } }); return loop.pipe( Effect.ensuring( Effect.sync(() => { releaseInterest(unique); pruneSettled(); }), ), ); }); /** Interrupt one running entry, force-closing its scope after 5s. */ const abortEntry = (entry: Entry) => Effect.gen(function* () { if (entry.snapshot.status !== "running") return; const graceful = yield* entry.session.interrupt.pipe( Effect.timeout(STOP_TIMEOUT_MS), Effect.result, ); if (Result.isFailure(graceful)) { // Settle before closing the scope so the pump's stream-ended // fallback ("Backend event stream ended unexpectedly") cannot win // the race and report the wrong terminal reason. yield* Effect.sync(() => { settle(entry, { _tag: "Interrupted" }); entry.snapshot.errorText = "Abort deadline exceeded; session was force-disposed"; notify(entry.snapshot.id); }); // Bound the close like disposeAll does: a stuck backend finalizer // must not hang cancel after the run is already settled. yield* closeEntryScope(entry).pipe( Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore, ); } }); const cancel = (ids: ReadonlyArray) => Effect.suspend(() => { const unique = [...new Set(ids)]; const running = unique .map((id) => entries.get(id)) .filter( (entry): entry is Entry => entry?.snapshot.status === "running", ); const runningIds = running.map((entry) => entry.snapshot.id); // Mark consumed before interrupting so cancellation does not also // enqueue duplicate automatic result messages into the parent. addInterest(runningIds); const work = Effect.gen(function* () { yield* Effect.forEach(running, abortEntry, { concurrency: "unbounded", }); while (running.some((entry) => entry.snapshot.status === "running")) { yield* nextChange; } }); return work.pipe( Effect.ensuring( Effect.sync(() => { releaseInterest(runningIds); pruneSettled(); }), ), Effect.map((): ReadonlyArray => unique.map((id) => { const snapshot = entries.get(id)?.snapshot; return { id, title: snapshot?.title ?? "?", status: snapshot?.status ?? "error", cancelled: runningIds.includes(id), }; }), ), ); }); const send = (id: string, text: string) => Effect.suspend((): Effect.Effect => { const entry = entries.get(id); if (!entry || disposed) { return new SendError({ message: `Subagent "${id}" is no longer tracked.`, }); } // Restarting a settled subagent occupies a running slot again, so it // must respect the same cap as spawn. Steering an already-running one // does not consume additional capacity. if (entry.snapshot.status !== "running") { if (runningCount() + reserved >= MAX_RUNNING) { return new SendError({ message: `Max ${MAX_RUNNING} subagents can run concurrently; restarting "${id}" would exceed that.`, }); } // Occupy the slot synchronously: the RunStarted that flips status // arrives via the async pump, and two concurrent restarts must not // both pass the check in that window. Cleared by RunStarted/settle, // or here when the backend rejects the send. entry.restarting = true; return entry.session.send(text).pipe( Effect.onError(() => Effect.sync(() => { entry.restarting = false; }), ), ); } return entry.session.send(text); }); const disposeAll = Effect.gen(function* () { disposed = true; const all = [...entries.values()]; entries.clear(); yield* Effect.forEach( all, (entry) => closeEntryScope(entry).pipe( Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore, ), { concurrency: "unbounded" }, ); // Pruning cleanups are detached; bound them like everything else so a // stuck backend finalizer cannot block runtime shutdown indefinitely. yield* Effect.forEach( [...cleanups], (fiber) => Fiber.await(fiber).pipe(Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore), { concurrency: "unbounded" }, ).pipe(Effect.ignore); yield* Effect.sync(() => notify()); }); const view: SubagentReadModel = { list: () => [...entries.values()].map((entry) => entry.snapshot), get: (id) => entries.get(id)?.snapshot, size: () => entries.size, subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); }, subscribeTo: (id, listener) => { let set = idListeners.get(id); if (!set) { set = new Set(); idListeners.set(id, set); } set.add(listener); return () => { set.delete(listener); if (set.size === 0) idListeners.delete(id); }; }, requestSend: (id, text) => { runDetached(send(id, text).pipe(Effect.ignore)); }, requestAbort: (id) => { const entry = entries.get(id); if (!entry) return; // UI-initiated aborts are not "consumed": the failed result still // flows back to the parent as a follow-up message, matching v1. runDetached(abortEntry(entry).pipe(Effect.ignore)); }, setOnSettled: (hook) => { onSettled = hook; }, }; // Safety net: disposing the ManagedRuntime tears everything down even if // the extension forgot to call disposeAll explicitly. yield* Effect.addFinalizer(() => disposeAll); return SubagentManager.of({ spawn, waitFor, cancel, send, get: (id) => Effect.sync(() => entries.get(id)?.snapshot), list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)), disposeAll, view, }); }); export const SubagentManagerLive: Layer.Layer< SubagentManager, never, BackendRegistry > = Layer.effect(SubagentManager, makeManager);