/** * subagent-manager.ts - Tracks subagents, background execution, resume support. * * Background agents are subject to a configurable concurrency limit (default: 4). * Excess agents are scheduled on a ConcurrencyLimiter and auto-started as running * agents complete. Foreground agents bypass the limiter (they block the parent anyway). */ import { randomUUID } from "node:crypto"; import type { Model } from "@earendil-works/pi-ai"; import { debugLog } from "#src/debug"; import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter"; import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session"; import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot"; import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent"; import type { SubagentSession } from "#src/lifecycle/subagent-session"; import { SubagentState } from "#src/lifecycle/subagent-state"; import type { WorkspaceProvider } from "#src/lifecycle/workspace"; import type { RunConfig } from "#src/runtime"; import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types"; /** Observer interface for agent lifecycle notifications. */ export interface SubagentManagerObserver { onSubagentStarted(record: Subagent): void; onSubagentCompleted(record: Subagent): void; onSubagentCompacted(record: Subagent, info: CompactionInfo): void; /** Fires synchronously after a background agent record is created (before run). */ onSubagentCreated(record: Subagent): void; } export interface SubagentManagerOptions { /** Assembly factory that produces a born-complete SubagentSession per spawn. */ createSubagentSession: (params: CreateSubagentSessionParams) => Promise; /** Concurrency limiter — schedules background run thunks FIFO against the limit. */ limiter: ConcurrencyLimiter; /** Base working directory handed to a workspace provider (the parent cwd). */ baseCwd: string; getRunConfig?: () => RunConfig; observer?: SubagentManagerObserver; } export interface AgentSpawnConfig { description: string; model?: Model; maxTurns?: number; inheritContext?: boolean; thinkingLevel?: ThinkingLevel; isBackground?: boolean; /** * Skip the maxConcurrent queue check for this spawn - start immediately even * if the configured concurrency limit would otherwise queue it. Useful for * callers (e.g. cross-extension RPC) that must not be deferred by the queue. */ bypassQueue?: boolean; /** Resolved invocation snapshot captured for UI display. */ invocation?: AgentInvocation; /** Parent abort signal - when aborted, the subagent is also stopped. */ signal?: AbortSignal; /** Per-subagent lifecycle observer — replaces onSessionCreated callback. */ observer?: SubagentLifecycleObserver; /** Parent session identity - grouped fields that travel together from the tool boundary. */ parentSession?: ParentSessionInfo; } export class SubagentManager { private agents = new Map(); private cleanupInterval: ReturnType; private readonly observer?: SubagentManagerObserver; private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise; private readonly limiter: ConcurrencyLimiter; private readonly baseCwd: string; private getRunConfig?: () => RunConfig; private _workspaceProvider?: WorkspaceProvider; /** The registered workspace provider, or undefined when none is registered. */ get workspaceProvider(): WorkspaceProvider | undefined { return this._workspaceProvider; } constructor(options: SubagentManagerOptions) { this.createSubagentSession = options.createSubagentSession; this.limiter = options.limiter; this.baseCwd = options.baseCwd; this.observer = options.observer; this.getRunConfig = options.getRunConfig; // Cleanup completed agents after 10 minutes (but keep sessions for resume) this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); this.cleanupInterval.unref(); } /** * Register the single workspace provider. Throws if one is already * registered (chaining is out of scope — see ADR 0002). Returns a disposer * that clears the slot only if this provider is still the active one. */ registerWorkspaceProvider(provider: WorkspaceProvider): () => void { if (this._workspaceProvider) { throw new Error( "A WorkspaceProvider is already registered; only one is supported.", ); } this._workspaceProvider = provider; return () => { if (this._workspaceProvider === provider) this._workspaceProvider = undefined; }; } /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */ private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver { return { onStarted: (agent) => { this.observer?.onSubagentStarted(agent); }, onSessionCreated: options.observer?.onSessionCreated ? (agent) => options.observer!.onSessionCreated!(agent) : undefined, onRunFinished: (agent) => { if (options.isBackground) { try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); } } }, onCompacted: (agent, info) => { this.observer?.onSubagentCompacted(agent, info); }, }; } /** * Spawn an agent and return its ID immediately (for background use). * If the concurrency limit is reached, the agent is queued. */ spawn( snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: AgentSpawnConfig, ): string { const id = randomUUID().slice(0, 17); const record = new Subagent({ id, type, description: options.description, invocation: options.invocation, state: new SubagentState({ status: options.isBackground ? "queued" : "running", startedAt: Date.now(), }), execution: { createSubagentSession: this.createSubagentSession, snapshot, prompt, baseCwd: this.baseCwd, observer: this.buildObserver(options), getRunConfig: this.getRunConfig, getWorkspaceProvider: () => this._workspaceProvider, model: options.model, maxTurns: options.maxTurns, thinkingLevel: options.thinkingLevel, parentSession: options.parentSession, signal: options.signal, }, }); this.agents.set(id, record); if (options.isBackground) { this.observer?.onSubagentCreated(record); } if (options.isBackground && !options.bypassQueue) { // Schedule on the limiter — scheduleVia captures the limiter promise // eagerly, so a queued agent is awaitable from spawn; guardedRun guards // against abort-while-queued when the slot frees. record.scheduleVia((thunk) => this.limiter.schedule(thunk)); return id; } record.start(); return id; } /** * Spawn an agent and wait for completion (foreground use). * Foreground agents bypass the concurrency queue. */ async spawnAndWait( snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: Omit, ): Promise { const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false }); const record = this.agents.get(id)!; await record.promise; return record; } /** * Resume an existing agent session with a new prompt. * Delegates to Subagent.resume(), which owns the observer subscription lifecycle. */ async resume( id: string, prompt: string, signal?: AbortSignal, ): Promise { const agent = this.agents.get(id); if (!agent?.isSessionReady()) return undefined; await agent.resume(prompt, signal); return agent; } getRecord(id: string): Subagent | undefined { return this.agents.get(id); } listAgents(): Subagent[] { return [...this.agents.values()].sort( (a, b) => b.startedAt - a.startedAt, ); } abort(id: string): boolean { const record = this.agents.get(id); if (!record) return false; // A queued agent has not started; mark it stopped. Its scheduled thunk // becomes a no-op (status guard) when its slot finally opens. if (record.status === "queued") { record.markStopped(); return true; } return record.abort(); } /** Dispose a record's session and remove it from the map. */ private removeRecord(id: string, record: Subagent): void { record.disposeSession(); this.agents.delete(id); } private cleanup() { const cutoff = Date.now() - 10 * 60_000; for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") continue; if ((record.completedAt ?? 0) >= cutoff) continue; this.removeRecord(id, record); } } /** * Remove all completed/stopped/errored records immediately. * Called on session start/switch so tasks from a prior session don't persist. */ clearCompleted(): void { for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") continue; this.removeRecord(id, record); } } /** Whether any agents are still running or queued. */ // fallow-ignore-next-line unused-class-member hasRunning(): boolean { return [...this.agents.values()].some( r => r.status === "running" || r.status === "queued", ); } /** Abort all running and queued agents immediately. */ // fallow-ignore-next-line unused-class-member abortAll(): number { let count = 0; for (const record of this.agents.values()) { if (record.status === "queued") { record.markStopped(); count++; } else if (record.abort()) { count++; } } // Drop pending thunks (their promises resolve). this.limiter.clear(); return count; } /** Wait for all running and queued agents to complete (including queued ones). */ // fallow-ignore-next-line unused-class-member async waitForAll(): Promise { // Every spawned agent has a settled-on-completion promise (the limiter starts // queued ones as slots free), so a single allSettled covers the queued case. // The loop only catches agents spawned during the wait. let pending = this.pendingPromises(); while (pending.length > 0) { await Promise.allSettled(pending); pending = this.pendingPromises(); } } /** Promises of all running/queued agents that have one. */ private pendingPromises(): Promise[] { return [...this.agents.values()] .filter(r => r.status === "running" || r.status === "queued") .map(r => r.promise) .filter((p): p is Promise => p != null); } dispose() { clearInterval(this.cleanupInterval); // Drop pending thunks this.limiter.clear(); for (const record of this.agents.values()) { record.disposeSession(); } this.agents.clear(); } }