import { execSync } from "node:child_process"; import { Escalation, EscalationType, EscalationOption, EscalationResolution, ESCALATION_TYPES, } from "../types/escalation.js"; import { CIAgentConfig } from "../types/config.js"; import { CommitBuilder, EscalationCommitInput } from "./commit-builder.js"; import { CommitEscalation } from "../types/commit-meta.js"; export interface EscalationInput { type: EscalationType; phase: string; description: string; context: string; options: EscalationOption[]; default_option_id: string; plan?: string; task?: string; } export class EscalationProtocol { private config: CIAgentConfig; private projectPath: string; private currentMilestone: string; private counter: number; private pendingEscalations: Map; private timeoutCallback: (escalation: Escalation, chosenOption: string) => void; private timers: NodeJS.Timeout[]; private timerEscalationMap: Map; constructor( config: CIAgentConfig, projectPath: string, milestone: string = "v1.0", timeoutCallback: (escalation: Escalation, chosenOption: string) => void = () => {} ) { this.config = config; this.projectPath = projectPath; this.currentMilestone = milestone; this.counter = 0; this.pendingEscalations = new Map(); this.timeoutCallback = timeoutCallback; this.timers = []; this.timerEscalationMap = new Map(); } setMilestone(milestone: string): void { this.currentMilestone = milestone; } escalate(input: EscalationInput): Escalation { const id = `E-${String(++this.counter).padStart(3, "0")}`; const escalation: Escalation = { id, timestamp: new Date().toISOString(), type: input.type, phase: input.phase, plan: input.plan, task: input.task, description: input.description, context: input.context, options: input.options, default_option_id: input.default_option_id, resolution: "pending", commit_hash: "", }; this.pendingEscalations.set(id, escalation); if (this.config.git.auto_commit) { const commitEscalation: CommitEscalation = { id, type: input.type, description: input.description, resolution: "pending", }; const commitMessage = CommitBuilder.buildEscalationCommit({ phase: parseInt(input.phase) || 0, milestone: this.currentMilestone, subject: input.description, escalations: [commitEscalation], }); this.commitEscalation(commitMessage); } if (this.config.autonomy.escalation_timeout_ms > 0) { this.scheduleTimeout(escalation); } return escalation; } resolveEscalation( escalationId: string, chosenOptionId: string, resolution: EscalationResolution = "approved" ): Escalation | null { const escalation = this.pendingEscalations.get(escalationId); if (!escalation) return null; for (let i = this.timers.length - 1; i >= 0; i--) { const timer = this.timers[i]; const mappedId = this.timerEscalationMap.get(timer); if (mappedId === escalationId) { clearTimeout(timer); this.timerEscalationMap.delete(timer); this.timers.splice(i, 1); } } escalation.resolution = resolution; escalation.resolved_at = new Date().toISOString(); escalation.resolution_detail = `Chose option: ${chosenOptionId}`; if (this.config.git.auto_commit) { const commitEscalation: CommitEscalation = { id: escalation.id, type: escalation.type, description: escalation.description, resolution: resolution === "timeout_auto_proceed" ? "timeout" : resolution === "approved" ? "auto" : resolution === "rejected" ? "human" : resolution === "modified" ? "human" : resolution, }; const commitMessage = CommitBuilder.buildEscalationCommit({ phase: parseInt(escalation.phase) || 0, milestone: this.currentMilestone, subject: `resolved: ${escalation.description}`, escalations: [commitEscalation], }); this.commitEscalation(commitMessage); } this.pendingEscalations.delete(escalationId); return escalation; } getPendingEscalations(): Escalation[] { return [...this.pendingEscalations.values()]; } hasPending(): boolean { return this.pendingEscalations.size > 0; } clearAllTimers(): void { for (const timer of this.timers) { clearTimeout(timer); this.timerEscalationMap.delete(timer); } this.timers = []; this.pendingEscalations.clear(); } dispose(): void { this.clearAllTimers(); } formatEscalation(escalation: Escalation): string { const lines: string[] = [ `⚠️ ESCALATION [${escalation.id}]`, "", `Type: ${ESCALATION_TYPES[escalation.type]}`, `Phase: ${escalation.phase}${escalation.plan ? `, Plan: ${escalation.plan}` : ""}${escalation.task ? `, Task: ${escalation.task}` : ""}`, `Decision Required: ${escalation.description}`, "", `Context: ${escalation.context}`, "", "Options:", ]; for (const opt of escalation.options) { const marker = opt.recommended ? " (recommended)" : ""; lines.push(` ${opt.id}) ${opt.label}${marker} - ${opt.description}`); } const defaultOpt = escalation.options.find( (o) => o.id === escalation.default_option_id ); lines.push(""); lines.push( `Default: ${defaultOpt?.label || escalation.default_option_id}` ); if (this.config.autonomy.escalation_timeout_ms > 0) { const seconds = Math.floor(this.config.autonomy.escalation_timeout_ms / 1000); lines.push( `(auto-proceed in ${seconds}s if no response)` ); } return lines.join("\n"); } private commitEscalation(commitMessage: string): void { try { execSync(`git add -A && git commit -m "${commitMessage.replace(/"/g, '\\"')}" --allow-empty`, { cwd: this.projectPath, stdio: "pipe", }); } catch { } } private scheduleTimeout(escalation: Escalation): void { const timeout = this.config.autonomy.escalation_timeout_ms; if (timeout <= 0) return; const timer = setTimeout(() => { if (this.pendingEscalations.has(escalation.id)) { escalation.resolution = "timeout_auto_proceed"; escalation.resolved_at = new Date().toISOString(); escalation.resolution_detail = `Auto-proceeded with default: ${escalation.default_option_id}`; this.pendingEscalations.delete(escalation.id); this.timerEscalationMap.delete(timer); const idx = this.timers.indexOf(timer); if (idx >= 0) this.timers.splice(idx, 1); this.timeoutCallback(escalation, escalation.default_option_id); } }, timeout); this.timers.push(timer); this.timerEscalationMap.set(timer, escalation.id); } }