/** * KHAELOR * File: src/agent/interruption.ts * Description: Interruption controller — turn-scoped abort, durable Interrupted, dangling tool_use closure (ADR-11). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { findDanglingToolUseIds } from "../session/index.js"; import { CANCELLED_RESULT_CONTENT } from "../tools/index.js"; import type { KernelSession } from "./session-handle.js"; /** Why a dangling tool_use is being closed with a synthetic tool_result. */ export type CancellationReason = "interrupted" | "resume-recovery" | "shutdown"; /** * Close every `ToolRequested` lacking a terminal result with a synthetic, * durable `ToolCancelled` so the LLM history stays protocol-valid — every * tool_use gets exactly one tool_result (EVENT_MODEL.md §6.5). * Returns the toolUseIds that were closed. */ export function closeDanglingToolUses( session: KernelSession, reason: CancellationReason, ): string[] { const dangling = findDanglingToolUseIds(session.events()); for (const toolUseId of dangling) { session.publishDurable({ type: "tool.cancelled", payload: { toolUseId, reason, modelText: CANCELLED_RESULT_CONTENT }, }); } return dangling; } /** * Resume recovery (EVENT_MODEL.md §6.5.2): after replay, any tool_use without * a terminal event is closed durably AT RESUME TIME — recovery is itself an * event, so the next replay needs no recovery. */ export function recoverDanglingOnResume(session: KernelSession): string[] { return closeDanglingToolUses(session, "resume-recovery"); } /** * The Esc path (ARCHITECTURE.md §8). `interrupt()`: * 1. records the durable `Interrupted` event (state the next `deriveNext` * observes — exit conditions derive from state, never in-memory flags), * 2. aborts the turn controller — the cancellation root for the model * stream and every in-flight tool (ADR-11). * Dangling tool_use blocks are then closed by the kernel's interrupted * branch via `closeDanglingToolUses`, keeping the session resumable. * Background `process`-managed processes are NOT touched. */ export class InterruptionController { readonly #session: KernelSession; #turn: AbortController | null = null; constructor(session: KernelSession) { this.#session = session; } /** Called by the kernel at the start of every run — registers the cancellation root. */ beginTurn(controller: AbortController): void { this.#turn = controller; } /** Called by the kernel when the run ends (any outcome). */ endTurn(): void { this.#turn = null; } /** True while a turn is running and not yet aborted. */ get turnActive(): boolean { return this.#turn !== null && !this.#turn.signal.aborted; } /** * Interrupt the current turn. Returns false when there is nothing to * interrupt (no active turn, or already aborted) — idempotent. */ interrupt(): boolean { const turn = this.#turn; if (turn === null || turn.signal.aborted) return false; const pendingToolUseIds = findDanglingToolUseIds(this.#session.events()); this.#session.publishDurable({ type: "user.interrupted", payload: { scope: "turn", pendingToolUseIds }, }); turn.abort(); return true; } }