Files
ci/src/core/escalation.ts
T
Jon Chery 04c4489e70 fix(P01): migrate audit trail to git-native and replace audit_file with commit_hash
---ci---
project: ci
phase: 1
milestone: v0.8
status: in_progress
decisions:
  - id: D-024
    decision: Audit trail reads from git log instead of .ciagent/audit/*.json
    rationale: Git-native context means audit data should come from commit history, not files
    confidence: 0.88
  - id: D-025
    decision: Replace audit_file with commit_hash in Escalation type
    rationale: Escalations are committed to git; reference by hash instead of deprecated file path
    confidence: 0.90
requirements:
  covered: [FIX-04, FIX-05]
---/ci---

FIX-04: audit.ts logDecision/logEscalation now emit deprecation warnings
and are no-ops (decisions/escalations live in ---ci--- blocks). readAudit()
and getAuditSummary() parse git log for ---ci--- blocks instead of reading
.ciagent/audit/*.json files. ArtifactManager no longer creates audit dir.

FIX-05: Escalation type replaces audit_file: string with commit_hash: string.
All consumers updated (escalation.ts, ollama-base.ts, opencode.ts).
Audit tests rewritten for git-native approach.
2026-05-29 20:02:07 +00:00

229 lines
6.8 KiB
TypeScript

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<string, Escalation>;
private timeoutCallback: (escalation: Escalation, chosenOption: string) => void;
private timers: NodeJS.Timeout[];
private timerEscalationMap: Map<NodeJS.Timeout, string>;
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);
}
}