/** * Smart batching for successful background completions. * * A short debounce coalesces sibling runs while a max-wait timer, measured from * the first item in the open group, bounds delivery latency. Failures are * intentionally handled by the caller: flush held successes, then emit the * failure directly so it is never delayed. */ import { formatUsageTokens, sumUsage, type RunWaitReason } from "../presentation/monitor.ts"; import type { UsageStats } from "../execution/rpc-control.ts"; export interface CompletionBatchTimings { debounceMs: number; maxWaitMs: number; } export const DEFAULT_COMPLETION_BATCH_TIMINGS: CompletionBatchTimings = { debounceMs: 150, maxWaitMs: 1_000, }; type TimerHandle = ReturnType; function unrefHandle(handle: TimerHandle): void { if ( handle && typeof handle === "object" && "unref" in handle && typeof (handle as { unref: unknown }).unref === "function" ) { (handle as { unref: () => void }).unref(); } } export interface CompletionBatcherOptions { emit: (items: T[]) => void; timings?: Partial; } export interface CompletionBatcher { /** Add an item to the current debounced group. */ push(item: T): void; /** Emit any held items immediately as one group. */ flush(): void; /** Clear timers and return held items without emitting them. */ dispose(): T[]; } export function createCompletionBatcher(options: CompletionBatcherOptions): CompletionBatcher { const timings = { ...DEFAULT_COMPLETION_BATCH_TIMINGS, ...options.timings }; let pending: T[] = []; let debounceTimer: TimerHandle | null = null; let maxWaitTimer: TimerHandle | null = null; const clearTimers = (): void => { if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } if (maxWaitTimer !== null) { clearTimeout(maxWaitTimer); maxWaitTimer = null; } }; const emitGroup = (): void => { clearTimers(); if (pending.length === 0) return; const items = pending; pending = []; options.emit(items); }; return { push(item: T): void { pending.push(item); if (debounceTimer !== null) clearTimeout(debounceTimer); debounceTimer = setTimeout(emitGroup, timings.debounceMs); unrefHandle(debounceTimer); if (maxWaitTimer === null) { maxWaitTimer = setTimeout(emitGroup, timings.maxWaitMs); unrefHandle(maxWaitTimer); } }, flush: emitGroup, dispose(): T[] { clearTimers(); const abandoned = pending; pending = []; return abandoned; }, }; } export interface CompletionMessageItem { agent: string; block: string; /** Final usage of the underlying run (or chain); aggregated into the group totals. */ usage?: UsageStats; /** Model ref that produced this run's usage; group totals stay per model * instead of summing different models' spend into one number. */ model?: string; } /** Keep the established single-result shape; add a group header and a per-model * token/cost footer only for real groups. */ export function formatCompletionMessage(items: readonly CompletionMessageItem[]): string { if (items.length === 0) return ""; if (items.length === 1) return items[0].block; const agents = items.map((item) => item.agent).join(", "); const totals = perModelTotals(items); const footer = totals ? `\n\nTotals: ${items.length} runs · ${totals}` : ""; return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}${footer}`; } /** One `model ↑x ↓y $z` segment per model, first-seen order — models are never * merged, because each model's spend comes out of its own budget. The cost is * always present (even `$0.0000`) so every model line reads as a tally. */ export function perModelTotals(items: readonly CompletionMessageItem[]): string { const byModel = new Map(); for (const item of items) { if (item.usage === undefined) continue; const key = item.model?.trim() || "unknown model"; byModel.set(key, [...(byModel.get(key) ?? []), item.usage]); } if (byModel.size === 0) return ""; return [...byModel.entries()] .map(([model, parts]) => { const total = sumUsage(parts); const tokens = formatUsageTokens(total); return `${model}: ${tokens ? `${tokens} ` : ""}$${total.cost.toFixed(4)}`; }) .join(" · "); } /** Minimal shape of an active run, for the "others still running" footer. Kept * decoupled from the monitor's RunView so this stays a pure, easily tested * formatter; the caller maps its live runs into this shape. */ export interface ActiveRunFoot { id: number; agent: string; /** Optional content label (task-derived) shown next to the agent name. */ label?: string; /** Why a not-yet-executing run is waiting. Stated precisely so a repository * lane wait or a starting child is never mistaken for an exhausted pool. */ wait?: RunWaitReason; } function activeRunWaitTag(wait: RunWaitReason | undefined): string { switch (wait) { case "process-slot": return " (queued, starts when a process slot frees)"; case "repository-lane": return " (waiting for the repository write lane, not for a slot)"; case "starting": return " (starting)"; default: return ""; } } /** * Footer appended to a completion message when OTHER runs are still active, so * the main agent does not declare the overall task done prematurely. A result * arriving for one run does not mean sibling runs are finished; naming them * gives the main agent concrete, in-context awareness to keep waiting. * * Returns "" when nothing is active (the common, single-run case stays quiet). */ export function formatActiveRunsFooter(runs: readonly ActiveRunFoot[], maxListed = 4): string { if (runs.length === 0) return ""; const listed = runs.slice(0, maxListed); const items = listed .map((run) => `#${run.id} ${run.agent}${run.label ? `·${run.label}` : ""}${activeRunWaitTag(run.wait)}`) .join(", "); const more = runs.length > listed.length ? `, +${runs.length - listed.length} more` : ""; return `\n\n⚠ ${runs.length} other run${runs.length === 1 ? "" : "s"} still active: ${items}${more}. Do not conclude the overall task yet — their results wake you automatically.`; }