/** * src/lanes/escalation.ts — B5 escalation channel (parent side, ask_master). * * Mirror of the B2 steer file channel, direction child -> parent (benchmark * ryan_nookpi escalation.ts, pi-small-dense.md §2.3): * - the child `ask_master` tool (child/escalation.ts) atomically writes * `/.escalation.json` and exits with code 42; * - the LanePool detects exit 42 and calls `consumeEscalation` — the file * is read AND deleted IMMEDIATELY (consume-once: the same escalation can * never be surfaced twice, and a stale file cannot leak into a later run); * - a missing or invalid file still yields an `escalated` outcome without a * message (clean fallback — the exit code is the source of truth). * * The message body lives only in the consumed file + the in-memory * ChildResult.escalationMessage; the ledger/events persist the sha-256 * `escalationHash` only (hash-only posture, invariant I1). * * Zero @earendil-works/* imports. */ import { readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; /** * Exit code signaling a child escalation (mirror of child/escalation.ts — * duplicated on purpose so the child adapter keeps zero src/ imports). */ export const ESCALATION_EXIT_CODE = 42; /** Env var marking a child lane as a subagent session (ask_master gate). */ export const ESCALATION_RUN_ID_ENV = "PI_SUBAGENTS_RUN_ID"; /** Env var carrying the escalation dir (the parent's steer dir). */ export const ESCALATION_DIR_ENV = "PI_SUBAGENTS_ESCALATION_DIR"; /** Payload of a `.escalation.json` file (mirror of the child shape). */ export interface EscalationFilePayload { runId: string; message: string; timestamp: number; } /** Resolve the escalation file path for a run id. */ export function escalationFilePath(runId: string, dir: string): string { return join(dir, `${runId}.escalation.json`); } /** Outcome of a consume-once escalation read. */ export interface ConsumedEscalation { /** True when an escalation file was found (and consumed/deleted). */ consumed: boolean; /** The escalated message; absent when no file existed or it was invalid. */ message?: string; } /** * Consume the escalation file for `runId` (read + IMMEDIATE delete). Never * throws: a missing file returns `{ consumed: false }`, and an invalid payload * is still consumed (deleted) so a corrupt file cannot wedge a lane. */ export function consumeEscalation(runId: string | undefined, dir: string | undefined): ConsumedEscalation { if (!runId || !dir) return { consumed: false }; const file = escalationFilePath(runId, dir); let raw: string; try { raw = readFileSync(file, "utf8"); } catch { return { consumed: false }; } try { unlinkSync(file); } catch { /* already consumed by a concurrent path */ } try { const parsed = JSON.parse(raw) as Partial; if (typeof parsed?.message !== "string" || !parsed.message.trim()) return { consumed: true }; return { consumed: true, message: parsed.message }; } catch { return { consumed: true }; } }