Implement real supervisor-driven auto-recovery

Orchestrator changes:
- Force-spawn GAMMA on iteration_limit before abort
- GAMMA.synthesize() creates emergency handoff payload
- loadRecoveryContext() logs "Resuming from {task_id} handoff"
- POST to /api/pipeline/log for resume message visibility

AgentGamma changes:
- Add synthesize() method for emergency abort synthesis
- Merges existing proposals into coherent handoff
- Stores as synthesis_type: "abort_recovery"

Server changes:
- Add POST /api/pipeline/log endpoint for orchestrator logging
- Recovery pipeline properly inherits GAMMA synthesis

Test coverage:
- test_auto_recovery.py: 6 unit tests
- test_e2e_auto_recovery.py: 5 E2E tests
- test_supervisor_recovery.py: 3 supervisor tests
  - Success on attempt 2 (recovery works)
  - Max failures (3 retries then FAILED)
  - Success on attempt 1 (no recovery needed)

Recovery flow:
1. iteration_limit triggers
2. GAMMA force-spawned for emergency synthesis
3. Handoff dumped with GAMMA synthesis
4. Exit code 3 triggers auto-recovery
5. Recovery pipeline loads handoff
6. Logs "Resuming from {prior_pipeline} handoff"
7. Repeat up to 3 times or until success

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
profit
2026-01-24 19:47:56 -05:00
co-authored by Claude Opus 4.5
parent c96919fe35
commit ef18567674
4 changed files with 617 additions and 0 deletions
+63
View File
@@ -888,6 +888,69 @@ Output JSON: {
return { achieved: acceptVotes.length >= 2, reasoning: response };
}
}
/**
* Quick synthesis for abort recovery - synthesizes existing proposals into a handoff payload.
* Called when iteration_limit is hit to preserve work for the next run.
*/
async synthesize(task: TaskDefinition): Promise<any> {
this.log("ABORT SYNTHESIS: Creating handoff payload from existing proposals...");
await this.updateState({ status: "WORKING", current_task: "Emergency synthesis for handoff" });
// Gather all current work
const solutions = await this.blackboard.readSection("solutions");
const synthesis = await this.blackboard.readSection("synthesis");
const problem = await this.blackboard.readSection("problem");
if (solutions.length === 0) {
this.log("No proposals to synthesize");
return { synthesized: false, reason: "no_proposals" };
}
this.log(`Synthesizing ${solutions.length} proposals...`);
try {
const response = await this.callLLM(
`You are Agent GAMMA performing emergency synthesis before abort. Quickly merge the best ideas from all proposals into a coherent handoff document.
Output JSON: {
"merged_solution": "comprehensive merged solution incorporating best elements",
"key_insights": ["insight1", "insight2", ...],
"unresolved_issues": ["issue1", "issue2", ...],
"recommended_focus": "what the next run should prioritize",
"confidence": 0.0-1.0
}`,
`Task: ${task.objective}\nProposals: ${JSON.stringify(solutions.map(s => ({ author: s.author, value: s.value })))}\nPrior synthesis attempts: ${JSON.stringify(synthesis.map(s => s.value))}`
);
let result;
try {
const match = response.match(/\{[\s\S]*\}/);
result = match ? JSON.parse(match[0]) : { merged_solution: response, confidence: 0.5 };
} catch {
result = { merged_solution: response, confidence: 0.5 };
}
// Store the synthesis result for handoff
await this.writeToBlackboard("synthesis", "gamma_emergency_synthesis", {
...result,
synthesis_type: "abort_recovery",
timestamp: now(),
proposals_merged: solutions.length
});
this.log(`Synthesis complete: ${result.key_insights?.length || 0} insights, confidence ${result.confidence}`);
await this.updateState({ status: "COMPLETED", progress: 1.0 });
return { synthesized: true, ...result };
} catch (e: any) {
this.log(`Synthesis failed: ${e.message}`);
await this.updateState({ status: "FAILED" });
return { synthesized: false, error: e.message };
}
}
}
export { BaseAgent };
+58
View File
@@ -219,6 +219,10 @@ export class MultiAgentOrchestrator {
this.recoveryAttempt = parseInt(pipelineData.recovery_attempt || "1");
this.forceGamma = pipelineData.force_gamma === "true";
// Get the prior task ID for the resume message
let priorTaskId = "unknown";
let priorPipelineId = pipelineData.parent_pipeline || "unknown";
console.log("\n" + "=".repeat(70));
console.log(`RECOVERY RUN - Attempt ${this.recoveryAttempt}`);
console.log("=".repeat(70));
@@ -229,9 +233,25 @@ export class MultiAgentOrchestrator {
const inheritedData = await redis.get(inheritedKey);
if (inheritedData) {
this.inheritedContext = JSON.parse(inheritedData);
priorTaskId = this.inheritedContext.from_pipeline || priorPipelineId;
// LOG THE RESUME MESSAGE
console.log(`\n>>> Resuming from ${priorTaskId} handoff <<<\n`);
console.log(`Loaded inherited context from: ${inheritedKey}`);
console.log(`- Prior proposals: ${this.inheritedContext.proposals?.length || 0}`);
console.log(`- Prior synthesis attempts: ${this.inheritedContext.synthesis_attempts?.length || 0}`);
// Check for GAMMA emergency synthesis
if (this.inheritedContext.synthesis_attempts?.length > 0) {
const emergencySynth = this.inheritedContext.synthesis_attempts.find(
(s: any) => s.synthesis_type === "abort_recovery"
);
if (emergencySynth) {
console.log(`- GAMMA emergency synthesis found: ${emergencySynth.key_insights?.length || 0} insights`);
}
}
console.log(`- Recovery hints:`);
this.inheritedContext.recovery_hints?.forEach((hint: string, i: number) => {
console.log(` ${i + 1}. ${hint}`);
@@ -252,6 +272,20 @@ export class MultiAgentOrchestrator {
}
console.log("=".repeat(70) + "\n");
// Log to pipeline log as well
try {
await fetch("http://localhost:3000/api/pipeline/log", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
pipeline_id: this.pipelineId,
agent: "ORCHESTRATOR",
message: `Resuming from ${priorTaskId} handoff (attempt ${this.recoveryAttempt}, ${this.inheritedContext?.proposals?.length || 0} inherited proposals)`,
level: "INFO"
})
});
} catch { /* ignore logging errors */ }
}
} catch (e: any) {
this.log(`Warning: Could not load recovery context: ${e.message}`);
@@ -646,6 +680,30 @@ export class MultiAgentOrchestrator {
clearInterval(this.monitorInterval);
}
// CRITICAL: Force-spawn GAMMA to synthesize existing proposals before abort
if (!this.gammaAgent) {
this.log("ABORT RECOVERY: Force-spawning GAMMA to synthesize proposals before handoff...");
const forceReason: SpawnCondition = {
type: "STUCK",
threshold: 0,
current_value: 1,
triggered: true,
description: `Force-spawned on ${abortReason} for synthesis before abort`
};
await this.spawnGamma(forceReason);
// Give GAMMA a short window to synthesize (10 seconds max)
if (this.gammaAgent) {
this.log("GAMMA synthesizing proposals for handoff...");
const synthTimeout = new Promise<void>(resolve => setTimeout(resolve, 10000));
await Promise.race([
this.gammaAgent.synthesize(task).catch(e => this.log(`GAMMA synthesis error: ${e.message}`)),
synthTimeout
]);
this.log("GAMMA synthesis complete (or timed out)");
}
}
// CRITICAL: Dump all agent proposals/analysis to handoff JSON BEFORE revoking tokens
this.log("Dumping agent handoff data for recovery pipeline...");
await this.dumpAgentHandoff();