/** * Notification batching, nudge-hold dedupe, and the wake payload text. * * The three delays here exist for three different reasons and must not be collapsed: * * - **R-SLEEP-13 nudge hold (200 ms).** A completion is held briefly so that an * orchestrator which reads that run's result in the interim (`agi_worker * view=result`, which sets `resultConsumed`) never gets woken to be told something * it just read. (From tintinweb.) * - **R-SLEEP-14 batch window (400 ms).** Completions landing together coalesce into * one wake. Three workers finishing at once produce one turn, not three. * - **R-SLEEP-15 batch bound (1500 ms).** The window is refreshed by each arrival, so * without a hard bound from the *first* completion one slow sibling delays every * finished worker indefinitely. * * Failure and attention notifications bypass batching entirely (R-SLEEP-14): they are * urgent, and queueing them behind a slower sibling is exactly the delay batching is * supposed to prevent. */ import { elapsed } from "../worker/registry.ts"; import type { RunStatus } from "../worker/status.ts"; import { classifyWorkerOutcome, completionOutcomeText, type WorkerOutcomeCode, } from "../worker/outcome.ts"; export const NUDGE_HOLD_MS = 200; export const BATCH_WINDOW_MS = 400; export const BATCH_MAX_WAIT_MS = 1_500; export interface CompletionItem { runId: string; /** Durable worker-wake record claimed immediately before delivery (R-SLEEP-5/6). */ wakeToken?: string; name: string; agent: string; state: string; elapsed: string; costUsd: number; outcome: string | undefined; outcomeCode: WorkerOutcomeCode; readResult: boolean; lateToolEvidence: boolean; /** Stable internal key for idempotent pending/delivered evidence refreshes. */ evidenceKey: string; error: string | undefined; suspicious: string | undefined; /** Bypasses batching (R-SLEEP-14). */ urgent: boolean; } const URGENT_STATES = new Set(["failed", "timedOut", "orphaned", "unknown"]); /** * The first line of `## Outcome` from the report, which is what the collapsed wake box * shows (§12.6). Absent for a run that produced no parsable report. */ export function outcomeLine(report: string | undefined): string | undefined { if (report === undefined) return undefined; const lines = report.split("\n"); const index = lines.findIndex((line) => /^##\s+Outcome\s*$/i.test(line.trim())); if (index < 0) return undefined; for (let i = index + 1; i < lines.length; i++) { const line = lines[i]; if (line === undefined) break; if (line.startsWith("## ")) break; const trimmed = line.trim(); if (trimmed.length > 0) return trimmed.slice(0, 200); } return undefined; } export function completionItem(runId: string, status: RunStatus, report: string | undefined, now = Date.now()): CompletionItem { const outcome = classifyWorkerOutcome(status, report !== undefined); const evidenceKey = JSON.stringify({ code: outcome.code, readResult: outcome.readResult, lateToolEvidence: outcome.lateToolEvidence, }); return { runId, name: status.name, agent: status.agent, state: status.state, elapsed: elapsed(status, now), costUsd: status.usage.costUsd, outcome: outcomeLine(report), outcomeCode: outcome.code, readResult: outcome.readResult, lateToolEvidence: outcome.lateToolEvidence, evidenceKey, error: status.error ?? undefined, suspicious: status.suspicious, urgent: URGENT_STATES.has(status.state), }; } /** * R-SLEEP-9 / §12.6. The completion wake tells the orchestrator what finished, whether * the result is worth reading, and — for a failure — why, so the turn it triggers has * something to act on without a round trip. */ export function renderCompletionPayload(items: CompletionItem[]): string { const lines: string[] = []; for (const item of items) { lines.push(`Agent ${item.name} ${completionOutcomeText(item.outcomeCode, item.lateToolEvidence)}.`); lines.push( item.readResult ? `Read its preserved output with agi_worker({name:"${item.name}", view:"result"}).` : `Inspect .pi/agi/.runtime/agents/${item.name}/trace.log for its evidence.`, ); } return lines.join("\n"); } /** * R-SLEEP-9 (delta payload) and R-SLEEP-10 (no-op ticks must be cheap). * * The last line is not decoration. Without an explicit instruction not to re-check, the * orchestrator calls `agi_workers`, then `agi_worker`, then reads the plan, then writes * a note — every ten minutes, forever. That is the primary cost risk of unattended * operation, and this sentence is the whole mitigation. */ export function renderTickPayload(input: { note?: string }): string { return input.note === undefined ? "The requested check time arrived. Re-check the condition you were waiting on." : `The requested check time arrived: ${input.note}. Re-check whether it changed.`; } /** R-SLEEP-5a. Short on purpose; internal wait bookkeeping stays out of context. */ export function renderSleepPayload(input: { note?: string }): string { return input.note === undefined ? "Scheduled wait finished. Check whether the awaited condition changed." : `Scheduled wait finished: ${input.note}. Check whether the awaited condition changed.`; } export function humanDuration(ms: number): string { const total = Math.max(0, Math.round(ms / 1000)); if (total < 60) return `${total}s`; const mins = Math.floor(total / 60); const secs = total % 60; if (mins < 60) return secs === 0 ? `${mins}m` : `${mins}m${secs}s`; const hours = Math.floor(mins / 60); return mins % 60 === 0 ? `${hours}h` : `${hours}h${mins % 60}m`; } export interface BatcherTimers { setTimeout(fn: () => void, ms: number): unknown; clearTimeout(handle: unknown): void; now(): number; } export const realTimers: BatcherTimers = { setTimeout: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref?.(); return timer; }, clearTimeout: (handle) => clearTimeout(handle as ReturnType), now: () => Date.now(), }; interface HeldCompletion { item: CompletionItem; handle: unknown; submittedAt: number; } /** * Owns the nudge hold and the batch window. Deliberately timer-injectable: the real * delays are 200/400/1500 ms and a test that waited them out for every case would be * slow *and* flaky, while a test that reimplements them proves nothing. */ export class NotificationBatcher { private timers: BatcherTimers; private deliver: (items: CompletionItem[]) => void; private nudgeHoldMs: number; private batchWindowMs: number; private batchMaxWaitMs: number; private held = new Map(); private batch: CompletionItem[] = []; private windowHandle: unknown; private boundHandle: unknown; private batchStartedAt = 0; constructor(options: { deliver: (items: CompletionItem[]) => void; timers?: BatcherTimers; nudgeHoldMs?: number; batchWindowMs?: number; batchMaxWaitMs?: number; }) { this.deliver = options.deliver; this.timers = options.timers ?? realTimers; this.nudgeHoldMs = options.nudgeHoldMs ?? NUDGE_HOLD_MS; this.batchWindowMs = options.batchWindowMs ?? BATCH_WINDOW_MS; this.batchMaxWaitMs = options.batchMaxWaitMs ?? BATCH_MAX_WAIT_MS; } /** * R-SLEEP-13/14. Every completion gets the result-consumption hold. Urgent items * bypass only the batch window once that hold has elapsed. */ submit(item: CompletionItem): void { const held = this.held.get(item.runId); if (held !== undefined) { // Repeated terminal callbacks are updates, not additional notifications. // Keep the original hold deadline while retaining the newest truthful state. this.held.set(item.runId, { ...held, item }); return; } const batched = this.batch.findIndex((entry) => entry.runId === item.runId); if (batched >= 0) { if (item.urgent) { // Its original hold already elapsed. Extract only this run and deliver the // newest urgent truth now; sibling batch windows/bounds remain untouched. this.cancel(item.runId); this.deliver([item]); } else { this.batch[batched] = item; } return; } const submittedAt = this.timers.now(); const handle = this.timers.setTimeout(() => { const latest = this.held.get(item.runId)?.item ?? item; this.held.delete(item.runId); if (latest.urgent) this.deliver([latest]); else this.enqueue(latest, submittedAt); }, this.nudgeHoldMs); this.held.set(item.runId, { item, handle, submittedAt }); } /** * R-SLEEP-13: cancel the pending notification because the orchestrator read that * run's result. The batch window remains scheduler-owned and retractable too. */ cancel(runId: string): boolean { const held = this.held.get(runId); if (held !== undefined) { this.timers.clearTimeout(held.handle); this.held.delete(runId); } const before = this.batch.length; this.batch = this.batch.filter((item) => item.runId !== runId); if (this.batch.length === 0 && before > 0) { if (this.windowHandle !== undefined) this.timers.clearTimeout(this.windowHandle); if (this.boundHandle !== undefined) this.timers.clearTimeout(this.boundHandle); this.windowHandle = undefined; this.boundHandle = undefined; this.batchStartedAt = 0; } return held !== undefined || before !== this.batch.length; } private enqueue(item: CompletionItem, submittedAt: number): void { if (this.batch.length === 0) { this.batchStartedAt = submittedAt; // R-SLEEP-15: armed once, from the *first* completion, and never refreshed. const remaining = Math.max(0, this.batchMaxWaitMs - (this.timers.now() - this.batchStartedAt)); this.boundHandle = this.timers.setTimeout(() => this.flush(), remaining); } this.batch.push(item); if (this.windowHandle !== undefined) this.timers.clearTimeout(this.windowHandle); // R-SLEEP-14: the window slides with each arrival so near-simultaneous // completions coalesce, bounded by boundHandle above. const remaining = Math.max(0, this.batchMaxWaitMs - (this.timers.now() - this.batchStartedAt)); this.windowHandle = this.timers.setTimeout(() => this.flush(), Math.min(this.batchWindowMs, remaining)); } private flush(): void { if (this.windowHandle !== undefined) { this.timers.clearTimeout(this.windowHandle); this.windowHandle = undefined; } if (this.boundHandle !== undefined) { this.timers.clearTimeout(this.boundHandle); this.boundHandle = undefined; } if (this.batch.length === 0) return; const items = this.batch; this.batch = []; this.deliver(items); } /** Drop everything without delivering. Used on toggle OFF and shutdown. */ clear(): void { for (const held of this.held.values()) this.timers.clearTimeout(held.handle); this.held.clear(); if (this.windowHandle !== undefined) this.timers.clearTimeout(this.windowHandle); if (this.boundHandle !== undefined) this.timers.clearTimeout(this.boundHandle); this.windowHandle = undefined; this.boundHandle = undefined; this.batch = []; } pendingRunIds(): string[] { return [...this.held.keys(), ...this.batch.map((item) => item.runId)]; } }