/** * auto/phases-pre-dispatch.js — runPreDispatch phase. */ import { basename, dirname, join, parse as parsePath } from "node:path"; import { importExtensionModule } from "@singularity-forge/coding-agent"; import { clearCurrentPhase, setCurrentPhase, } from "../../shared/sf-phase-state.js"; import { atomicWriteSync } from "../atomic-write.js"; import { resetCompletionNudgeState } from "../auto-completion-nudge.js"; import { isAwaitingUserInput, USER_DRIVEN_DEEP_UNITS, } from "../auto-post-unit.js"; import { buildLoopRemediationSteps, diagnoseExpectedArtifact, verifyExpectedArtifact, } from "../auto-recovery.js"; import { formatToolCallSummary, resetToolCallCounts, } from "../auto-tool-tracking.js"; import { appendAutonomousSolverCheckpoint, assessAutonomousSolverTurn, beginAutonomousSolverIteration, buildAutonomousSolverMissingCheckpointRepairPrompt, buildAutonomousSolverPromptBlock, buildAutonomousSolverSteeringPromptBlock, classifyAutonomousSolverMissingCheckpointFailure, consumePendingAutonomousSolverSteering, getConfiguredAutonomousSolverMaxIterations, recordAutonomousSolverMissingCheckpointRetry, } from "../autonomous-solver.js"; import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js"; import { debugLog } from "../debug-logger.js"; import { PROJECT_FILES } from "../detection.js"; import { MergeConflictError } from "../git-service.js"; import { recordLearnedOutcome } from "../learning/runtime.js"; import { sfRoot } from "../paths.js"; import { resolvePersistModelChanges } from "../preferences.js"; import { approveProductionMutationWithLlmPolicy, ensureProductionMutationApprovalTemplate, readProductionMutationApprovalStatus, } from "../production-mutation-approval.js"; import { pauseAutoForProviderError } from "../provider-error-pause.js"; import { buildReasoningAssistPrompt, injectReasoningGuidance, isReasoningAssistEnabled, } from "../reasoning-assist.js"; import { loadEvidenceFromDisk, resetEvidence, } from "../safety/evidence-collector.js"; import { getDirtyFiles } from "../safety/file-change-validator.js"; import { cleanupCheckpoint, createCheckpoint, rollbackToCheckpoint, } from "../safety/git-checkpoint.js"; import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; import { recordSelfFeedback } from "../self-feedback.js"; import { checkpointWal, getMilestoneSlices, getSliceTaskCounts, getTask, isDbAvailable, } from "../sf-db.js"; import { getEligibleSlices } from "../slice-parallel-eligibility.js"; import { startSliceParallel } from "../slice-parallel-orchestrator.js"; import { handleProductAudit } from "../tools/product-audit-tool.js"; import { parseUnitId } from "../unit-id.js"; import { collectSessionTokenUsage, collectWorktreeFingerprint, countChangedFiles, resetRunawayGuardState, } from "../uok/auto-runaway-guard.js"; import { resolveUokFlags } from "../uok/flags.js"; import { UokGateRunner } from "../uok/gate-runner.js"; import { emitModelAutoResolvedEvent } from "../uok/model-route-evidence.js"; import { ensurePlanV2Graph as ensurePlanningFlowGraph, isEmptyPlanV2GraphResult, isMissingFinalizedContextResult, } from "../uok/plan.js"; import { buildUokProgressEvent } from "../uok/progress-event.js"; import { clearUnitRuntimeRecord, writeUnitRuntimeRecord, } from "../uok/unit-runtime.js"; import { _resetLogs, drainAndSummarize, drainLogs, formatForNotification, hasAnyIssues, logError, logWarning, } from "../workflow-logger.js"; import { getRequiredWorkflowToolsForAutoUnit, getWorkflowTransportSupportError, } from "../workflow-tools.js"; import { resolveWorktreeProjectRoot } from "../worktree-root.js"; import { detectStuck } from "./detect-stuck.js"; import { FINALIZE_POST_TIMEOUT_MS, FINALIZE_PRE_TIMEOUT_MS, withTimeout, } from "./finalize-timeout.js"; import { runUnit } from "./run-unit.js"; import { getErrorMessage } from "../error-utils.js"; import { BUDGET_THRESHOLDS, MAX_FINALIZE_TIMEOUTS, MAX_RECOVERY_CHARS, } from "./types.js"; import { closeoutAndStop, generateMilestoneReport, maybeFireProductAudit, shouldRunPlanningFlowGate } from "./phases-helpers.js"; // ─── runPreDispatch ─────────────────────────────────────────────────────────── /** * Phase 1: Pre-dispatch — resource guard, health gate, state derivation, * milestone transition, terminal conditions. * Returns break to exit the loop, or next with PreDispatchData on success. */ export async function runPreDispatch(ic, loopState) { const { ctx, pi, s, deps, prefs } = ic; const uokFlags = resolveUokFlags(prefs); const runPreDispatchGate = async (input) => { if (!uokFlags.gates) return; const gateRunner = new UokGateRunner(); gateRunner.register({ id: input.gateId, type: input.gateType, execute: async () => ({ outcome: input.outcome, failureClass: input.failureClass, rationale: input.rationale, findings: input.findings ?? "", }), }); await gateRunner.run(input.gateId, { basePath: s.basePath, traceId: `pre-dispatch:${ic.flowId}`, turnId: `iter-${ic.iteration}`, milestoneId: input.milestoneId ?? s.currentMilestoneId ?? undefined, unitType: "pre-dispatch", unitId: `iter-${ic.iteration}`, }); }; // Resource version guard const staleMsg = deps.checkResourcesStale(s.resourceVersionOnStart); if (staleMsg) { await runPreDispatchGate({ gateId: "resource-version-guard", gateType: "policy", outcome: "fail", failureClass: "policy", rationale: "resource version guard blocked dispatch", findings: staleMsg, }); await deps.stopAuto(ctx, pi, staleMsg); debugLog("autoLoop", { phase: "exit", reason: "resources-stale" }); return { action: "break", reason: "resources-stale" }; } await runPreDispatchGate({ gateId: "resource-version-guard", gateType: "policy", outcome: "pass", failureClass: "none", rationale: "resource version guard passed", }); deps.invalidateAllCaches(); s.lastPromptCharCount = undefined; s.lastBaselineCharCount = undefined; // Pre-dispatch health gate try { const healthGate = await deps.preDispatchHealthGate(s.basePath); if (healthGate.fixesApplied.length > 0) { ctx.ui.notify( `Pre-dispatch: ${healthGate.fixesApplied.join(", ")}`, "info", ); } if (!healthGate.proceed) { await runPreDispatchGate({ gateId: "pre-dispatch-health-gate", gateType: "execution", outcome: "manual-attention", failureClass: "manual-attention", rationale: "pre-dispatch health gate blocked dispatch", findings: healthGate.reason, }); ctx.ui.notify( healthGate.reason || "Pre-dispatch health check failed — run /doctor for details.", "error", ); await deps.pauseAuto(ctx, pi); debugLog("autoLoop", { phase: "exit", reason: "health-gate-failed" }); return { action: "break", reason: "health-gate-failed" }; } await runPreDispatchGate({ gateId: "pre-dispatch-health-gate", gateType: "execution", outcome: "pass", failureClass: "none", rationale: "pre-dispatch health gate passed", findings: healthGate.fixesApplied.length > 0 ? healthGate.fixesApplied.join(", ") : "", }); } catch (e) { await runPreDispatchGate({ gateId: "pre-dispatch-health-gate", gateType: "execution", outcome: "manual-attention", failureClass: "manual-attention", rationale: "pre-dispatch health gate threw unexpectedly", findings: String(e), }); logWarning("engine", "Pre-dispatch health gate threw unexpectedly", { error: String(e), }); } // Sync project root artifacts into worktree if ( s.originalBasePath && s.basePath !== s.originalBasePath && s.currentMilestoneId ) { deps.syncProjectRootToWorktree( s.originalBasePath, s.basePath, s.currentMilestoneId, ); } // Derive state let state = await deps.deriveState(s.basePath); if ( uokFlags.planningFlow && isDbAvailable() && shouldRunPlanningFlowGate(state.phase) ) { let compiled = ensurePlanningFlowGraph(s.basePath, state); // Empty-graph recovery: stale DB caches can yield 0 nodes right after a // task-complete write. Invalidate caches, re-derive state, and retry once. if (isEmptyPlanV2GraphResult(compiled)) { deps.invalidateAllCaches(); state = await deps.deriveState(s.basePath); compiled = shouldRunPlanningFlowGate(state.phase) ? ensurePlanningFlowGraph(s.basePath, state) : { ok: true, reason: "empty planning-flow graph recovered by state rederive", nodeCount: 0, }; } if (!compiled.ok) { const reason = compiled.reason ?? "Planning flow compilation failed"; if (isMissingFinalizedContextResult(compiled)) { await runPreDispatchGate({ gateId: "planning-flow-gate", gateType: "policy", outcome: "pass", failureClass: "none", rationale: "plan v2 missing context recovery deferred to dispatch", findings: reason, milestoneId: state.activeMilestone?.id ?? undefined, }); } else { await runPreDispatchGate({ gateId: "planning-flow-gate", gateType: "policy", outcome: "manual-attention", failureClass: "manual-attention", rationale: "planning flow compile gate failed", findings: reason, milestoneId: state.activeMilestone?.id ?? undefined, }); ctx.ui.notify( `Plan gate failed-closed: ${reason}\n\nIf this keeps happening, try: /doctor heal`, "error", ); await deps.pauseAuto(ctx, pi); return { action: "break", reason: "planning-flow-gate-failed" }; } } await runPreDispatchGate({ gateId: "planning-flow-gate", gateType: "policy", outcome: "pass", failureClass: "none", rationale: "planning flow compile gate passed", milestoneId: state.activeMilestone?.id ?? undefined, }); } deps.syncCmuxSidebar(prefs, state); let mid = state.activeMilestone?.id; let midTitle = state.activeMilestone?.title; debugLog("autoLoop", { phase: "state-derived", iteration: ic.iteration, mid, statePhase: state.phase, }); // ── Slice-level parallelism gate (#2340) ───────────────────────────── // When slice_parallel is enabled, check if multiple slices are eligible // for parallel execution. If so, dispatch them in parallel and stop the // sequential loop. Workers are spawned via slice-parallel-orchestrator.ts. if ( prefs?.slice_parallel?.enabled && mid && !process.env.SF_PARALLEL_WORKER && isDbAvailable() ) { try { const dbSlices = getMilestoneSlices(mid); if (dbSlices.length > 0) { const doneIds = new Set( dbSlices .filter((sl) => sl.status === "complete" || sl.status === "done") .map((sl) => sl.id), ); const sliceInputs = dbSlices.map((sl) => ({ id: sl.id, done: doneIds.has(sl.id), depends: sl.depends ?? [], })); const eligible = getEligibleSlices(sliceInputs, doneIds); if (eligible.length > 1) { debugLog("autoLoop", { phase: "slice-parallel-dispatch", iteration: ic.iteration, mid, eligibleSlices: eligible.map((e) => e.id), }); ctx.ui.notify( `Slice-parallel: dispatching ${eligible.length} eligible slices for ${mid}.`, "info", ); const result = await startSliceParallel(s.basePath, mid, eligible, { maxWorkers: prefs.slice_parallel.max_workers ?? 2, useExecutionGraph: uokFlags.executionGraph, shellWrapper: prefs.shell_wrapper, }); if (result.started.length > 0) { ctx.ui.notify( `Slice-parallel: started ${result.started.length} worker(s): ${result.started.join(", ")}.`, "info", ); await deps.stopAuto( ctx, pi, `Slice-parallel dispatched for ${mid}`, ); return { action: "break", reason: "slice-parallel-dispatched" }; } // Fall through to sequential if no workers started } } } catch (err) { debugLog("autoLoop", { phase: "slice-parallel-check-error", error: getErrorMessage(err), }); // Non-fatal — fall through to sequential dispatch } } // ── Milestone transition ──────────────────────────────────────────── if (mid && s.currentMilestoneId && mid !== s.currentMilestoneId) { deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "milestone-transition", data: { from: s.currentMilestoneId, to: mid }, }); ctx.ui.notify( `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`, "info", ); deps.sendDesktopNotification( "SF", `Milestone ${s.currentMilestoneId} complete!`, "success", "milestone", basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent( prefs, `Milestone ${s.currentMilestoneId} complete. Advancing to ${mid}.`, "success", ); const vizPrefs = prefs; if (vizPrefs?.auto_visualize) { ctx.ui.notify("Run /visualize to see progress overview.", "info"); } if (vizPrefs?.auto_report !== false) { try { await generateMilestoneReport(s, ctx, s.currentMilestoneId); } catch (err) { ctx.ui.notify( `Report generation failed: ${getErrorMessage(err)}`, "warning", ); } } // Reset dispatch counters for new milestone s.unitDispatchCount.clear(); s.unitRecoveryCount.clear(); s.unitLifetimeDispatches.clear(); loopState.recentUnits.length = 0; loopState.stuckRecoveryAttempts = 0; // Worktree lifecycle on milestone transition — merge current, enter next try { deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); } catch (mergeErr) { if (mergeErr instanceof MergeConflictError) { // Real code conflicts — stop the loop instead of retrying forever (#2330) ctx.ui.notify( `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /autonomous to resume.`, "error", ); await deps.stopAuto( ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`, ); return { action: "break", reason: "merge-conflict" }; } // Non-conflict merge errors — stop auto to avoid advancing with unmerged work logError("engine", "Milestone merge failed with non-conflict error", { milestone: s.currentMilestoneId, error: String(mergeErr), }); ctx.ui.notify( `Merge failed: ${getErrorMessage(mergeErr)}. Resolve and run /autonomous to resume.`, "error", ); await deps.stopAuto( ctx, pi, `Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`, ); return { action: "break", reason: "merge-failed" }; } // Fire product-audit after successful merge (guards against double-fire via s.productAuditMilestoneId) await maybeFireProductAudit(s, ctx); // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302) deps.invalidateAllCaches(); state = await deps.deriveState(s.basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; if (mid) { if (deps.getIsolationMode() !== "none") { deps.captureIntegrationBranch(s.basePath, mid); } deps.resolver.enterMilestone(mid, ctx.ui); } else { // mid is undefined — no milestone to capture integration branch for } const pendingIds = state.registry .filter((m) => m.status !== "complete" && m.status !== "parked") .map((m) => m.id); deps.pruneQueueOrder(s.basePath, pendingIds); // Archive the old completed-units.json instead of wiping it (#2313). try { const completedKeysPath = join( sfRoot(s.basePath), "completed-units.json", ); if (existsSync(completedKeysPath) && s.currentMilestoneId) { const archivePath = join( sfRoot(s.basePath), `completed-units-${s.currentMilestoneId}.json`, ); cpSync(completedKeysPath, archivePath); } atomicWriteSync(completedKeysPath, JSON.stringify([], null, 2)); } catch (e) { logWarning( "engine", "Failed to archive completed-units on milestone transition", { error: String(e) }, ); } // Rebuild STATE.md immediately so it reflects the new active milestone. // This bypasses the 30-second throttle in the normal rebuild path — // milestone transitions are rare and important enough to warrant an // immediate write. try { await deps.rebuildState(s.basePath); } catch (e) { logWarning( "engine", "STATE.md rebuild failed after milestone transition", { error: String(e) }, ); } } if (mid) { s.currentMilestoneId = mid; deps.setActiveMilestoneId(s.basePath, mid); } // ── Terminal conditions ────────────────────────────────────────────── if (!mid) { if (s.currentUnit) { await deps.closeoutUnit( ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), ); } const incomplete = state.registry.filter( (m) => m.status !== "complete" && m.status !== "parked", ); if (incomplete.length === 0 && state.registry.length > 0) { // All milestones complete — merge milestone branch before stopping if (s.currentMilestoneId) { try { deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); // Prevent stopAuto from attempting the same merge (#2645) s.milestoneMergedInPhases = true; // Fire product-audit after successful merge (guards against double-fire via s.productAuditMilestoneId) await maybeFireProductAudit(s, ctx); } catch (mergeErr) { if (mergeErr instanceof MergeConflictError) { ctx.ui.notify( `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /autonomous to resume.`, "error", ); await deps.stopAuto( ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`, ); return { action: "break", reason: "merge-conflict" }; } logError("engine", "Milestone merge failed with non-conflict error", { milestone: s.currentMilestoneId, error: String(mergeErr), }); ctx.ui.notify( `Merge failed: ${getErrorMessage(mergeErr)}. Resolve and run /autonomous to resume.`, "error", ); await deps.stopAuto( ctx, pi, `Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`, ); return { action: "break", reason: "merge-failed" }; } // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302) } deps.sendDesktopNotification( "SF", "All milestones complete!", "success", "milestone", basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent(prefs, "All milestones complete.", "success"); await deps.stopAuto(ctx, pi, "All milestones complete"); } else if (incomplete.length === 0 && state.registry.length === 0) { // Empty registry — no milestones visible, likely a path resolution bug const diag = `basePath=${s.basePath}, phase=${state.phase}`; ctx.ui.notify( `No milestones visible in current scope. Possible path resolution issue.\n Diagnostic: ${diag}`, "error", ); await deps.stopAuto( ctx, pi, `No milestones found — check basePath resolution`, ); } else if (state.phase === "blocked") { const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; // Pause instead of hard-stop so the session is resumable with `/autonomous`. // Hard-stop here was causing premature termination when slice dependencies // were temporarily unresolvable (e.g. after reassessment added new slices). await deps.pauseAuto(ctx, pi); ctx.ui.notify( `${blockerMsg}. Fix and run /autonomous to resume.`, "warning", ); deps.sendDesktopNotification( "SF", blockerMsg, "warning", "attention", basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent(prefs, blockerMsg, "warning"); } else { const ids = incomplete.map((m) => m.id).join(", "); const diag = `basePath=${s.basePath}, milestones=[${state.registry.map((m) => `${m.id}:${m.status}`).join(", ")}], phase=${state.phase}`; ctx.ui.notify( `Unexpected: ${incomplete.length} incomplete milestone(s) (${ids}) but no active milestone.\n Diagnostic: ${diag}`, "error", ); await deps.stopAuto( ctx, pi, `No active milestone — ${incomplete.length} incomplete (${ids}), see diagnostic above`, ); } debugLog("autoLoop", { phase: "exit", reason: "no-active-milestone" }); deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "no-active-milestone" }, }); return { action: "break", reason: "no-active-milestone" }; } if (!midTitle) { midTitle = mid; ctx.ui.notify( `Milestone ${mid} has no title in roadmap — using ID as fallback.`, "warning", ); } // Mid-merge safety check const mergeReconcileResult = deps.reconcileMergeState(s.basePath, ctx); if (mergeReconcileResult === "blocked") { await deps.pauseAuto(ctx, pi); debugLog("autoLoop", { phase: "exit", reason: "merge-reconciliation-blocked", }); return { action: "break", reason: "merge-reconciliation-blocked" }; } if (mergeReconcileResult === "reconciled") { deps.invalidateAllCaches(); state = await deps.deriveState(s.basePath); mid = state.activeMilestone?.id; midTitle = state.activeMilestone?.title; } if (!mid || !midTitle) { const noMilestoneReason = !mid ? "No active milestone after merge reconciliation" : `Milestone ${mid} has no title after reconciliation`; await closeoutAndStop(ctx, pi, s, deps, noMilestoneReason); debugLog("autoLoop", { phase: "exit", reason: "no-milestone-after-reconciliation", }); return { action: "break", reason: "no-milestone-after-reconciliation" }; } // Terminal: complete if (state.phase === "complete") { // Milestone merge on complete (before closeout so branch state is clean) if (s.currentMilestoneId) { try { deps.resolver.mergeAndExit(s.currentMilestoneId, ctx.ui); // Prevent stopAuto from attempting the same merge (#2645) s.milestoneMergedInPhases = true; // Fire product-audit after successful merge (guards against double-fire via s.productAuditMilestoneId) await maybeFireProductAudit(s, ctx); } catch (mergeErr) { if (mergeErr instanceof MergeConflictError) { ctx.ui.notify( `Merge conflict: ${mergeErr.conflictedFiles.join(", ")}. Resolve conflicts manually and run /autonomous to resume.`, "error", ); await deps.stopAuto( ctx, pi, `Merge conflict on milestone ${s.currentMilestoneId}`, ); return { action: "break", reason: "merge-conflict" }; } logError("engine", "Milestone merge failed with non-conflict error", { milestone: s.currentMilestoneId, error: String(mergeErr), }); ctx.ui.notify( `Merge failed: ${getErrorMessage(mergeErr)}. Resolve and run /autonomous to resume.`, "error", ); await deps.stopAuto( ctx, pi, `Merge error on milestone ${s.currentMilestoneId}: ${String(mergeErr)}`, ); return { action: "break", reason: "merge-failed" }; } // PR creation (auto_pr) is handled inside mergeMilestoneToMain (#2302) } deps.sendDesktopNotification( "SF", `Milestone ${mid} complete!`, "success", "milestone", basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent(prefs, `Milestone ${mid} complete.`, "success"); await closeoutAndStop(ctx, pi, s, deps, `Milestone ${mid} complete`); debugLog("autoLoop", { phase: "exit", reason: "milestone-complete" }); deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "milestone-complete", milestoneId: mid }, }); return { action: "break", reason: "milestone-complete" }; } // Terminal: blocked — pause instead of hard-stop so the session is resumable. if (state.phase === "blocked") { const blockerMsg = `Blocked: ${state.blockers.join(", ")}`; if (s.currentUnit) { await deps.closeoutUnit( ctx, s.basePath, s.currentUnit.type, s.currentUnit.id, s.currentUnit.startedAt, deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id), ); } await deps.pauseAuto(ctx, pi); ctx.ui.notify( `${blockerMsg}. Fix and run /autonomous to resume.`, "warning", ); deps.sendDesktopNotification( "SF", blockerMsg, "warning", "attention", basename(s.originalBasePath || s.basePath), ); deps.logCmuxEvent(prefs, blockerMsg, "warning"); debugLog("autoLoop", { phase: "exit", reason: "blocked" }); deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: ic.nextSeq(), eventType: "terminal", data: { reason: "blocked", blockers: state.blockers }, }); return { action: "break", reason: "blocked" }; } return { action: "next", data: { state, mid, midTitle } }; }