Просмотр исходного кода

feat: add loop runtime scaffolding

Michael Henke 2 месяцев назад
Родитель
Сommit
6373411f0e
3 измененных файлов с 371 добавлено и 4 удалено
  1. 236 0
      src/loop/loop-engine.ts
  2. 89 0
      src/loop/loop-session.ts
  3. 46 4
      src/utils/background-job-board.ts

+ 236 - 0
src/loop/loop-engine.ts

@@ -0,0 +1,236 @@
+import { unlinkSync } from 'node:fs';
+import type {
+  BackgroundJobBoard,
+  BackgroundJobRecord,
+} from '../utils/background-job-board';
+import type {
+  LoopDefinition,
+  LoopSession,
+  VerificationResult,
+} from './loop-session';
+import { createLoopSession, writeHistoryFile } from './loop-session';
+
+export type DispatchCallback = (
+  agent: string,
+  prompt: string,
+  contextFiles: string[],
+) => string;
+
+export interface LoopEngineCallbacks {
+  onLoopComplete?: (loopID: string, success: boolean) => void;
+  onEscalated?: (loopID: string, reason: string) => void;
+  onManualReview?: (loopID: string, reason: string) => void;
+  onArtifactWrite?: (loopID: string, artifactPath: string) => void;
+}
+
+export class LoopEngine {
+  private sessions = new Map<string, LoopSession>();
+
+  constructor(
+    private readonly jobBoard: BackgroundJobBoard,
+    private readonly callbacks: LoopEngineCallbacks,
+    private readonly dispatch: DispatchCallback,
+  ) {
+    this.jobBoard.addTerminalStateListener(this.handleTerminalJob.bind(this));
+  }
+
+  startLoop(definition: LoopDefinition): string {
+    if (
+      (definition.executeAgent as string) === (definition.verifyAgent as string)
+    ) {
+      throw new Error('executeAgent and verifyAgent must differ');
+    }
+
+    const loopID = `loop-${Date.now().toString(36)}-${Math.random()
+      .toString(36)
+      .slice(2, 8)}`;
+    const session = createLoopSession(definition, loopID);
+    this.sessions.set(loopID, session);
+    writeHistoryFile(session);
+    this.dispatchPhase(session);
+    return loopID;
+  }
+
+  resolveManualReview(loopID: string, passed: boolean, reason?: string): void {
+    const session = this.sessions.get(loopID);
+    if (!session || session.currentPhase !== 'verifying') return;
+    session.manualReviewPending = false;
+    const verification: VerificationResult = {
+      passed,
+      reason:
+        reason ?? (passed ? 'Manual review passed' : 'Manual review failed'),
+    };
+    session.history.push({
+      attemptNumber: session.attempts,
+      executionResult: 'manual review',
+      verificationResult: verification,
+    });
+    writeHistoryFile(session);
+
+    if (passed) {
+      this.finishSession(session, true);
+      return;
+    }
+
+    if (session.attempts >= session.definition.maxAttempts) {
+      this.escalate(session, 'Manual review failed, max attempts reached');
+      return;
+    }
+
+    session.attempts += 1;
+    session.currentPhase = 'executing';
+    this.dispatchPhase(session);
+  }
+
+  private dispatchPhase(session: LoopSession): void {
+    if (session.manualReviewPending) return;
+
+    if (session.currentPhase === 'executing') {
+      const prompt = `Loop ${session.loopID} attempt ${session.attempts}`;
+      const taskID = this.dispatch(
+        session.definition.executeAgent,
+        prompt,
+        session.definition.contextFiles ?? [],
+      );
+      session.activeJobID = taskID;
+      this.jobBoard.registerLaunch({
+        taskID,
+        parentSessionID: session.loopID,
+        agent: session.definition.executeAgent,
+        description: prompt,
+      });
+      return;
+    }
+
+    if (session.currentPhase === 'verifying') {
+      if (session.definition.success.type === 'manual') {
+        session.manualReviewPending = true;
+        this.callbacks.onManualReview?.(
+          session.loopID,
+          session.definition.successCriteria,
+        );
+        return;
+      }
+
+      const prompt = `Loop ${session.loopID} verification attempt ${session.attempts}`;
+      const taskID = this.dispatch(
+        session.definition.verifyAgent,
+        prompt,
+        session.definition.contextFiles ?? [],
+      );
+      session.activeJobID = taskID;
+      this.jobBoard.registerLaunch({
+        taskID,
+        parentSessionID: session.loopID,
+        agent: session.definition.verifyAgent,
+        description: prompt,
+      });
+    }
+  }
+
+  private handleTerminalJob(taskID: string): void {
+    const record = this.jobBoard.get(taskID);
+    if (!record) return;
+    const session = this.sessions.get(record.parentSessionID);
+    if (!session) return;
+    session.activeJobID = undefined;
+
+    if (record.state === 'cancelled') {
+      this.finishSession(session, false);
+      return;
+    }
+
+    if (record.state === 'error') {
+      if (this.jobBoard.hasConvergenceSignals(taskID)) {
+        this.escalate(session, 'Convergence signals exceeded');
+        return;
+      }
+      this.failSession(session, record);
+      return;
+    }
+
+    if (session.currentPhase === 'executing') {
+      session.currentPhase = 'verifying';
+      this.dispatchPhase(session);
+      return;
+    }
+
+    if (session.currentPhase === 'verifying') {
+      this.evaluateVerification(session, record);
+    }
+  }
+
+  private evaluateVerification(
+    session: LoopSession,
+    record: BackgroundJobRecord,
+  ): void {
+    const result = this.parseVerification(record.resultSummary);
+    if (result) {
+      session.history.push({
+        attemptNumber: session.attempts,
+        executionResult: record.description,
+        verificationResult: result,
+      });
+      writeHistoryFile(session);
+      if (result.passed) {
+        this.finishSession(session, true);
+        return;
+      }
+
+      if (session.attempts >= session.definition.maxAttempts) {
+        this.escalate(session, result.reason);
+        return;
+      }
+      session.attempts += 1;
+      session.currentPhase = 'executing';
+      writeHistoryFile(session);
+      this.dispatchPhase(session);
+      return;
+    }
+
+    session.currentPhase = 'executing';
+    writeHistoryFile(session);
+    this.dispatchPhase(session);
+  }
+
+  private parseVerification(raw?: string): VerificationResult | null {
+    if (!raw) return null;
+    try {
+      const parsed = JSON.parse(raw);
+      if (typeof parsed.passed !== 'boolean') return null;
+      if (typeof parsed.reason !== 'string') return null;
+      return {
+        passed: parsed.passed,
+        reason: parsed.reason,
+        suggestedFix: parsed.suggestedFix,
+      };
+    } catch {
+      return null;
+    }
+  }
+
+  private escalate(session: LoopSession, reason: string): void {
+    session.currentPhase = 'escalated';
+    this.cleanupSession(session);
+    this.callbacks.onEscalated?.(session.loopID, reason);
+  }
+
+  private failSession(session: LoopSession, record: BackgroundJobRecord): void {
+    this.escalate(session, record.lastStatusError ?? 'Execution failed');
+  }
+
+  private finishSession(session: LoopSession, success: boolean): void {
+    session.currentPhase = success ? 'done' : 'escalated';
+    this.cleanupSession(session);
+    this.callbacks.onLoopComplete?.(session.loopID, success);
+  }
+
+  private cleanupSession(session: LoopSession): void {
+    try {
+      unlinkSync(session.historyFilePath);
+    } catch {
+      // best effort
+    }
+    this.sessions.delete(session.loopID);
+  }
+}

+ 89 - 0
src/loop/loop-session.ts

@@ -0,0 +1,89 @@
+import { writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+export type LoopPhase =
+  | 'executing'
+  | 'verifying'
+  | 'done'
+  | 'escalated'
+  | 'cancelled';
+
+export type ExecuteAgent = 'fixer' | 'designer' | 'explorer' | 'librarian';
+export type VerifyAgent = 'oracle' | 'observer' | 'test';
+
+export type SuccessCriterion =
+  | { type: 'test'; command: string }
+  | { type: 'build'; command: string }
+  | { type: 'lint'; command: string }
+  | { type: 'fileExists'; path: string }
+  | { type: 'command'; command: string; expectExitCode?: number }
+  | { type: 'oracle' }
+  | { type: 'observer' }
+  | { type: 'manual' };
+
+export interface LoopDefinition {
+  goal: string;
+  successCriteria: string;
+  success: SuccessCriterion;
+  maxAttempts: number;
+  executeAgent: ExecuteAgent;
+  verifyAgent: VerifyAgent;
+  contextFiles?: string[];
+}
+
+export type VerificationResult =
+  | { passed: true; reason: string }
+  | { passed: false; reason: string; suggestedFix?: string };
+
+export interface AttemptRecord {
+  attemptNumber: number;
+  executionResult: string;
+  verificationResult: VerificationResult;
+  artifactPaths?: string[];
+}
+
+export interface LoopSession {
+  loopID: string;
+  definition: LoopDefinition;
+  currentPhase: LoopPhase;
+  attempts: number;
+  activeJobID?: string;
+  history: AttemptRecord[];
+  historyFilePath: string;
+  manualReviewPending: boolean;
+}
+
+export function createLoopSession(
+  definition: LoopDefinition,
+  loopID: string,
+): LoopSession {
+  const historyFilePath = join(process.cwd(), `.loop-history-${loopID}.md`);
+  return {
+    loopID,
+    definition,
+    currentPhase: 'executing',
+    attempts: 1,
+    history: [],
+    historyFilePath,
+    manualReviewPending: false,
+  };
+}
+
+export function compactHistory(history: AttemptRecord[]): string {
+  if (history.length === 0) return '';
+  const lines = history.map((attempt, index) => {
+    const outcome = attempt.verificationResult.passed
+      ? 'PASS'
+      : `FAIL: ${attempt.verificationResult.reason}`;
+    const artifacts = attempt.artifactPaths?.length
+      ? ` → artifacts: ${attempt.artifactPaths.join(', ')}`
+      : '';
+    return `[Attempt ${index + 1}] ${outcome}${artifacts}`;
+  });
+  return `# Loop Attempt History\n\n${lines.join('\n')}\n`;
+}
+
+export function writeHistoryFile(session: LoopSession): void {
+  const content = compactHistory(session.history);
+  writeFileSync(session.historyFilePath, content, { encoding: 'utf-8' });
+}

+ 46 - 4
src/utils/background-job-board.ts

@@ -31,6 +31,9 @@ export interface BackgroundJobRecord {
   lastUsedAt: number;
   terminalState?: TaskOutputState;
   contextFiles: ContextFile[];
+  totalErrors?: number;
+  timeoutCount?: number;
+  lastErrorAt?: number;
 }
 
 export interface BackgroundJobBoardOptions {
@@ -79,7 +82,7 @@ const AGENT_PREFIX: Record<string, string> = {
 export class BackgroundJobBoard {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
-  private terminalStateListener?: TerminalStateListener;
+  private terminalStateListeners: TerminalStateListener[] = [];
 
   private readonly maxReusablePerAgent: number;
   private readonly readContextMinLines: number;
@@ -91,8 +94,24 @@ export class BackgroundJobBoard {
     this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
   }
 
+  addTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners.push(listener);
+  }
+
+  removeTerminalStateListener(listener: TerminalStateListener): void {
+    this.terminalStateListeners = this.terminalStateListeners.filter(
+      (entry) => entry !== listener,
+    );
+  }
+
   setTerminalStateListener(listener?: TerminalStateListener): void {
-    this.terminalStateListener = listener;
+    this.terminalStateListeners = listener ? [listener] : [];
+  }
+
+  private notifyTerminalStateListeners(taskID: string): void {
+    for (const listener of this.terminalStateListeners) {
+      listener(taskID);
+    }
   }
 
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
@@ -118,6 +137,8 @@ export class BackgroundJobBoard {
         lastLiveBusyAt: now,
         lastUsedAt: now,
         updatedAt: now,
+        totalErrors: existing.totalErrors ?? 0,
+        timeoutCount: existing.timeoutCount ?? 0,
       } satisfies BackgroundJobRecord;
       this.jobs.set(input.taskID, updated);
       return updated;
@@ -141,6 +162,8 @@ export class BackgroundJobBoard {
       updatedAt: now,
       alias: this.nextAlias(input.parentSessionID, input.agent),
       contextFiles: [],
+      totalErrors: 0,
+      timeoutCount: 0,
     };
 
     this.jobs.set(input.taskID, record);
@@ -180,9 +203,20 @@ export class BackgroundJobBoard {
       lastStatusError: input.lastStatusError,
     };
 
+    if (input.state === 'completed') {
+      updated.timeoutCount = 0;
+    }
+    if (input.state === 'error') {
+      updated.totalErrors = (existing.totalErrors ?? 0) + 1;
+      updated.lastErrorAt = updated.updatedAt;
+    }
+    if (input.timedOut) {
+      updated.timeoutCount = (existing.timeoutCount ?? 0) + 1;
+    }
+
     this.jobs.set(input.taskID, updated);
     this.trimReusable(input.taskID);
-    if (notifyTerminal) this.terminalStateListener?.(input.taskID);
+    if (notifyTerminal) this.notifyTerminalStateListeners(input.taskID);
     return updated;
   }
 
@@ -285,7 +319,7 @@ export class BackgroundJobBoard {
     };
 
     this.jobs.set(taskID, updated);
-    if (notifyTerminal) this.terminalStateListener?.(taskID);
+    if (notifyTerminal) this.notifyTerminalStateListeners(taskID);
     return updated;
   }
 
@@ -367,6 +401,14 @@ export class BackgroundJobBoard {
     return this.list(parentSessionID).some((job) => job.terminalUnreconciled);
   }
 
+  hasConvergenceSignals(taskID: string, threshold = 3): boolean {
+    const job = this.jobs.get(taskID);
+    if (!job) return false;
+    const errors = job.totalErrors ?? 0;
+    const timeouts = job.timeoutCount ?? 0;
+    return errors >= threshold || timeouts >= threshold;
+  }
+
   formatForPrompt(
     parentSessionID: string,
     now = Date.now(),