04c4489e70
---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.
229 lines
6.8 KiB
TypeScript
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);
|
|
}
|
|
} |