diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index aaa0d6658..b0ad1a834 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -85,7 +85,7 @@ A bare `/** Helper. */` is a code smell. Either write the purpose or delete the | **Small units** | Stay within thresholds (§ 4). Extract or split when approaching limits. | | **Fallbacks only when real** | A fallback that can't deliver working behavior is noise. Omit it. | | **Finish bounded refactors** | Rewire and remove the old path in the same PR. No shims, no dual paths. | -| **Single writer** | `sf-db.js` is the only file that issues write SQL. All others call its exports. | +| **Single writer** | `src/resources/extensions/sf/sf-db/` is the only module family that issues write SQL. All others call `sf-db.js` exports. | | **Spec-first TDD** | Write the failing test before implementing. Test name = contract claim. | --- @@ -99,7 +99,7 @@ A bare `/** Helper. */` is a code smell. Either write the purpose or delete the | Magic status strings inline | Spreads typo-prone comparisons | Named constant or exported string literal at definition site | **STY003** | | Generic names: `utils`, `helpers`, `common`, `misc` | Unsearchable, no domain signal | Name by capability: `memory-source-store.js`, `embed-circuit.js` | **STY004** | | `// TODO: fix later` without ticket / owner | Permanent invisible debt | Fix now, or add a dated `// TODO(owner): ` with `node scripts/tech-debt-scan.mjs` visibility | **STY005** | -| Calling `db.prepare(...)` outside `sf-db.js` | Breaks single-writer invariant | Add an exported wrapper in `sf-db.js` | **STY006** | +| Calling `db.prepare(...)` outside `src/resources/extensions/sf/sf-db/` | Breaks single-writer invariant | Add an exported wrapper in `sf-db.js` backed by the right `sf-db/` domain module | **STY006** | | Embedding logic in hook wiring | Blurs responsibilities; untestable | Extract to a purpose-named module; wire only the call in `register-hooks.js` | **STY007** | | Docstring = "Helper." or no docstring | Purpose is invisible to RAG and reviewers | Full JSDoc with Purpose + Consumer (§ 1) | **STY008** | | Bare `process.env.FOO` scattered in logic | Config not auditable or testable | Named constant + `loadXxxConfigFromEnv()` function with null-guard | **STY009** | @@ -162,7 +162,7 @@ Infrastructure files (`sf-db.js`, generated schemas) may exceed file-line limits ### Single-writer DB -`sf-db.js` is the only file that prepares and executes write SQL. All other modules call exported wrappers. This makes the write surface auditable, testable, and migration-safe. +`src/resources/extensions/sf/sf-db/` is the only module family that prepares and executes write SQL. The public surface remains `sf-db.js`; all other modules call exported wrappers. This makes the write surface auditable, testable, and migration-safe while allowing the DB implementation to stay split by domain. ```js // ✅ Correct — call the exported wrapper diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index eb71819a9..c0d3eb7bd 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -684,9 +684,9 @@ function createExtensionAPI( runtime.setThinkingLevel(level); }, - setFallbackUnitContext(ctx) { - runtime.setFallbackUnitContext(ctx); - }, + setFallbackUnitContext(ctx: { unitType: string; unitId: string } | null) { + runtime.setFallbackUnitContext(ctx); + }, registerProvider(name: string, config: ProviderConfig) { runtime.registerProvider(name, config); diff --git a/src/resources/extensions/sf/auto/phases-dispatch.js b/src/resources/extensions/sf/auto/phases-dispatch.js new file mode 100644 index 000000000..7fc62c52f --- /dev/null +++ b/src/resources/extensions/sf/auto/phases-dispatch.js @@ -0,0 +1,485 @@ +/** + * auto/phases-dispatch.js — runDispatch 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 } from "./phases-helpers.js"; + +/** + * Decide whether the UOK diagnostics verdict may continue into dispatch. + * + * Purpose: turn durable UOK self-diagnostics into autonomous control, so SF + * pauses on split-brain/runtime corruption before spending another LLM turn. + * + * Consumer: runDispatch before it starts the next autonomous unit. + */ +export function assessUokDiagnosticsDispatchGate(diagnostics) { + if (!diagnostics) return { proceed: true }; + const blockingIssue = diagnostics.issues?.find( + (issue) => issue?.severity === "error", + ); + if (diagnostics.verdict !== "degraded" && !blockingIssue) { + return { proceed: true }; + } + const issueCode = blockingIssue?.code ?? diagnostics.issues?.[0]?.code; + const reportPath = + diagnostics.reportPath ?? ".sf/runtime/uok-diagnostics.json"; + const reason = [ + `UOK diagnostics blocked dispatch: ${diagnostics.verdict}/${diagnostics.classification ?? "unknown"}`, + issueCode ? `issue ${issueCode}` : "", + `evidence ${reportPath}`, + ] + .filter(Boolean) + .join(" · "); + return { + proceed: false, + reason, + issueCode, + reportPath, + }; +} +// ─── generateMilestoneReport ────────────────────────────────────────────────── + +// ─── runDispatch ────────────────────────────────────────────────────────────── +/** + * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks. + * Returns break/continue to control the loop, or next with IterationData on success. + */ +export async function runDispatch(ic, preData, loopState) { + const { ctx, pi, s, deps, prefs } = ic; + const { state, mid, midTitle } = preData; + const STUCK_WINDOW_SIZE = 6; + debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration }); + const dispatchResult = await deps.resolveDispatch({ + basePath: s.basePath, + mid, + midTitle, + state, + prefs, + session: s, + runControl: deps.uokRunControl, + permissionProfile: deps.uokPermissionProfile, + }); + if (dispatchResult.action === "stop") { + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "dispatch-stop", + rule: dispatchResult.matchedRule, + data: { reason: dispatchResult.reason }, + }); + // Warning-level stops are recoverable human checkpoints (e.g. UAT verdict + // gate) — pause instead of hard-stopping so the session is resumable with + // `/autonomous`. Error/info-level stops remain hard stops for infrastructure + // failures and terminal conditions respectively. + // See: https://github.com/singularity-forge/sf-run/issues/2474 + if (dispatchResult.level === "warning") { + ctx.ui.notify(dispatchResult.reason, "warning"); + await deps.pauseAuto(ctx, pi); + } else { + await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); + } + debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); + return { action: "break", reason: "dispatch-stop" }; + } + if (dispatchResult.action !== "dispatch") { + // Non-dispatch action (e.g. "skip") — re-derive state + await new Promise((r) => setImmediate(r)); + return { action: "continue" }; + } + try { + const diagnostics = deps.writeUokDiagnostics?.(s.basePath, { + expectedNext: dispatchResult, + }); + const gate = assessUokDiagnosticsDispatchGate(diagnostics); + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "uok-diagnostics-dispatch-gate", + data: { + verdict: diagnostics?.verdict ?? "unknown", + classification: diagnostics?.classification ?? "unknown", + proceed: gate.proceed, + issueCode: gate.issueCode, + reportPath: gate.reportPath ?? diagnostics?.reportPath, + }, + }); + if (!gate.proceed) { + await runPreDispatchGate({ + gateId: "uok-diagnostics-dispatch-gate", + gateType: "execution", + outcome: "manual-attention", + failureClass: "manual-attention", + rationale: "uok diagnostics blocked dispatch", + findings: gate.reason, + milestoneId: mid, + }); + ctx.ui.notify(gate.reason, "error"); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { + phase: "exit", + reason: "uok-diagnostics-pause", + issueCode: gate.issueCode, + }); + return { action: "break", reason: "uok-diagnostics-pause" }; + } + } catch (err) { + logWarning("engine", "UOK diagnostics dispatch gate failed open", { + error: getErrorMessage(err), + }); + } + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "dispatch-match", + rule: dispatchResult.matchedRule, + data: { unitType: dispatchResult.unitType, unitId: dispatchResult.unitId }, + }); + let unitType = dispatchResult.unitType; + const unitId = dispatchResult.unitId; + let prompt = dispatchResult.prompt; + const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; + // ── Reasoning assist injection ────────────────────────────────────── + if (isReasoningAssistEnabled(unitType)) { + try { + const reasoningPrompt = await buildReasoningAssistPrompt( + unitType, + unitId, + s.basePath, + ctx, + ); + if (reasoningPrompt) { + // Fire-and-forget: reasoning assist is best-effort, non-blocking + // The actual LLM call would happen here in a full implementation. + // For now, we prepare the prompt for injection. + debugLog("autoLoop", { + phase: "reasoning-assist", + unitType, + unitId, + promptLength: reasoningPrompt.length, + }); + // Use reasoning prompt context as guidance until a fast model is wired in. + // The injected guidance provides unit-level context hints to the primary model. + prompt = injectReasoningGuidance(prompt, reasoningPrompt); + } + } catch (err) { + logWarning("engine", "Reasoning assist failed open", { + error: getErrorMessage(err), + unitType, + unitId, + }); + } + } + // ── Sliding-window stuck detection with graduated recovery ── + const derivedKey = `${unitType}/${unitId}`; + const hasTransientTaskCompleteFailure = + unitType === "execute-task" && !!s.pendingTaskCompleteFailures?.has(unitId); + if (!s.pendingVerificationRetry && !hasTransientTaskCompleteFailure) { + loopState.recentUnits.push({ key: derivedKey }); + if (loopState.recentUnits.length > STUCK_WINDOW_SIZE) + loopState.recentUnits.shift(); + const stuckSignal = detectStuck(loopState.recentUnits); + if (stuckSignal) { + debugLog("autoLoop", { + phase: "stuck-check", + unitType, + unitId, + reason: stuckSignal.reason, + recoveryAttempts: loopState.stuckRecoveryAttempts, + }); + // Graduated stuck recovery — up to 5 total attempts before hard stop. + // Attempt 0: cache invalidation + retry + // Attempts 1–4: rethink + retry + // Attempt 5 (exhausted): hard stop + loopState.stuckRecoveryAttempts++; + const attempt = loopState.stuckRecoveryAttempts; + if (attempt === 1) { + // Attempt 1: verify artifact + cache invalidation + retry + const artifactExists = verifyExpectedArtifact( + unitType, + unitId, + s.basePath, + ); + if (artifactExists) { + debugLog("autoLoop", { + phase: "stuck-recovery", + level: 1, + action: "artifact-found", + }); + ctx.ui.notify( + `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, + "info", + ); + deps.invalidateAllCaches(); + return { action: "continue" }; + } + ctx.ui.notify( + `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, + "warning", + ); + deps.invalidateAllCaches(); + return { action: "continue" }; + } else if (attempt <= 5) { + // Attempts 2–5: rethink + diagnostic + retry + const stuckDiag = diagnoseExpectedArtifact( + unitType, + unitId, + s.basePath, + ); + const stuckRemediation = buildLoopRemediationSteps( + unitType, + unitId, + s.basePath, + ); + const diagnostic = deps.getDeepDiagnostic(s.basePath); + const cappedDiag = + (diagnostic?.length ?? 0) > MAX_RECOVERY_CHARS + ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...diagnostic truncated]" + : (diagnostic ?? null); + s.pendingRethinkAttempt = JSON.stringify({ + attempt, + reason: stuckSignal.reason, + diagnostic: cappedDiag, + stuckDiag, + remediation: stuckRemediation, + unitType, + unitId, + }); + const rt = + attempt === 5 + ? "**FINAL STUCK ATTEMPT — 5 of 5.** " + : `**STUCK RECOVERY ATTEMPT ${attempt - 1} of 4.** `; + ctx.ui.notify( + `${rt}Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Injecting diagnostic and retrying.`, + "warning", + ); + return { action: "continue" }; + } else { + // Attempt 6+: genuinely exhausted — hard stop + debugLog("autoLoop", { + phase: "stuck-detected", + unitType, + unitId, + reason: stuckSignal.reason, + }); + const stuckDiag = diagnoseExpectedArtifact( + unitType, + unitId, + s.basePath, + ); + const stuckRemediation = buildLoopRemediationSteps( + unitType, + unitId, + s.basePath, + ); + const stuckParts = [ + `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}.`, + ]; + if (stuckDiag) stuckParts.push(`Expected: ${stuckDiag}`); + if (stuckRemediation) + stuckParts.push(`To recover:\n${stuckRemediation}`); + ctx.ui.notify(stuckParts.join(" "), "error"); + await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`); + return { action: "break", reason: "stuck-detected" }; + } + } else { + // Progress detected — reset recovery counter + if (loopState.stuckRecoveryAttempts > 0) { + debugLog("autoLoop", { + phase: "stuck-counter-reset", + from: + loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "", + to: derivedKey, + }); + loopState.stuckRecoveryAttempts = 0; + } + } + } + // Pre-dispatch hooks + const preDispatchResult = deps.runPreDispatchHooks( + unitType, + unitId, + prompt, + s.basePath, + ); + if (preDispatchResult.firedHooks.length > 0) { + ctx.ui.notify( + `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, + "info", + ); + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "pre-dispatch-hook", + data: { + firedHooks: preDispatchResult.firedHooks, + action: preDispatchResult.action, + }, + }); + } + if (preDispatchResult.action === "skip") { + ctx.ui.notify( + `Skipping ${unitType} ${unitId} (pre-dispatch hook).`, + "info", + ); + await new Promise((r) => setImmediate(r)); + return { action: "continue" }; + } + if (preDispatchResult.action === "replace") { + prompt = preDispatchResult.prompt ?? prompt; + if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; + } else if (preDispatchResult.prompt) { + prompt = preDispatchResult.prompt; + } + const guardBasePath = _resolveDispatchGuardBasePath(s); + const priorSliceBlocker = deps.getPriorSliceCompletionBlocker( + guardBasePath, + deps.getMainBranch(guardBasePath), + unitType, + unitId, + ); + if (priorSliceBlocker) { + await deps.stopAuto(ctx, pi, priorSliceBlocker); + debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" }); + return { action: "break", reason: "prior-slice-blocker" }; + } + return { + action: "next", + data: { + unitType, + unitId, + prompt, + finalPrompt: prompt, + pauseAfterUatDispatch, + state, + mid, + midTitle, + isRetry: false, + previousTier: undefined, + hookModelOverride: preDispatchResult.model, + }, + }; +} diff --git a/src/resources/extensions/sf/auto/phases-finalize.js b/src/resources/extensions/sf/auto/phases-finalize.js new file mode 100644 index 000000000..11bd04ff0 --- /dev/null +++ b/src/resources/extensions/sf/auto/phases-finalize.js @@ -0,0 +1,541 @@ +/** + * auto/phases-finalize.js — runFinalize 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 { recordLearningOutcomeForUnit } from "./phases-helpers.js"; + +// ─── runFinalize ────────────────────────────────────────────────────────────── +/** + * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard. + * Returns break/continue/next to control the outer loop. + */ +export async function runFinalize(ic, iterData, loopState, sidecarItem) { + const { ctx, pi, s, deps } = ic; + const { pauseAfterUatDispatch } = iterData; + debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration }); + // Clear unit timeout (unit completed) + deps.clearUnitTimeout(); + // Post-unit context for pre/post verification + const postUnitCtx = { + s, + ctx, + pi, + buildSnapshotOpts: deps.buildSnapshotOpts, + lockBase: deps.lockBase, + stopAuto: deps.stopAuto, + pauseAuto: deps.pauseAuto, + updateProgressWidget: deps.updateProgressWidget, + }; + // Pre-verification processing (commit, doctor, state rebuild, etc.) + // Timeout guard: if postUnitPreVerification hangs (e.g., safety harness + // deadlock, browser teardown hang, worktree sync stall), force-continue + // after timeout so the auto-loop is not permanently frozen (#3757). + // + // On timeout, null out s.currentUnit so the timed-out task's late async + // mutations are harmless — postUnitPreVerification guards all side effects + // behind `if (s.currentUnit)`. The next iteration sets a fresh currentUnit. + // Sidecar items use lightweight pre-verification opts + const preVerificationOpts = sidecarItem + ? sidecarItem.kind === "hook" + ? { + skipSettleDelay: true, + skipWorktreeSync: true, + agentEndMessages: s.lastUnitAgentEndMessages ?? undefined, + } + : { + skipSettleDelay: true, + agentEndMessages: s.lastUnitAgentEndMessages ?? undefined, + } + : { agentEndMessages: s.lastUnitAgentEndMessages ?? undefined }; + const _preUnitSnapshot = s.currentUnit + ? { + type: s.currentUnit.type, + id: s.currentUnit.id, + startedAt: s.currentUnit.startedAt, + } + : null; + const preResultGuard = await withTimeout( + deps.postUnitPreVerification(postUnitCtx, preVerificationOpts), + FINALIZE_PRE_TIMEOUT_MS, + "postUnitPreVerification", + ); + if (preResultGuard.timedOut) { + // Detach session from the timed-out unit so late async completions + // cannot mutate state for the next unit (#3757). + const hadStagedPending = s.stagedPendingCommit; + const hadCommitted = s.lastGitActionStatus === "ok"; + s.stagedPendingCommit = false; // prevent orphaned deferred commit + s.currentUnit = null; + clearCurrentPhase(); + // Drop any logger entries from the timed-out unit so they don't bleed + // into the next iteration's drain. + drainLogs(); + loopState.consecutiveFinalizeTimeouts++; + if (hadStagedPending) { + ctx.ui.notify( + "postUnitPreVerification timed out with staged-but-uncommitted changes — staged files will be included in next unit's commit.", + "warning", + ); + logWarning( + "engine", + "finalize-timeout: staged-pending-commit orphaned — will be absorbed by next unit", + ); + } else if (hadCommitted) { + ctx.ui.notify( + "postUnitPreVerification timed out after git commit — changes are in history but verification was skipped.", + "warning", + ); + logWarning( + "engine", + "finalize-timeout: git commit completed before timeout — verification was not run", + ); + } + debugLog("autoLoop", { + phase: "pre-verification-timeout", + iteration: ic.iteration, + unitType: iterData.unitType, + unitId: iterData.unitId, + consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts, + }); + if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) { + ctx.ui.notify( + `postUnitPreVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping autonomous mode to prevent budget waste`, + "error", + ); + await deps.stopAuto( + ctx, + pi, + `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`, + ); + return { action: "break", reason: "finalize-timeout-escalation" }; + } + ctx.ui.notify( + `postUnitPreVerification timed out after ${FINALIZE_PRE_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`, + "warning", + ); + return { action: "next", data: undefined }; + } + const preResult = preResultGuard.value; + if (preResult === "dispatched") { + const dispatchedReason = s.lastGitActionFailure + ? "git-closeout-failure" + : "pre-verification-dispatched"; + debugLog("autoLoop", { + phase: "exit", + reason: dispatchedReason, + gitError: s.lastGitActionFailure ?? undefined, + }); + return { action: "break", reason: dispatchedReason }; + } + if (preResult === "retry") { + if (sidecarItem) { + // Sidecar artifact retries are skipped — just continue + debugLog("autoLoop", { + phase: "sidecar-artifact-retry-skipped", + iteration: ic.iteration, + }); + } else { + // s.pendingVerificationRetry was set by postUnitPreVerification. + // Emit a dedicated journal event so forensics can distinguish bounded + // verification retries from genuine stuck-loop dispatch repetitions (#4540). + const retryInfo = s.pendingVerificationRetry; + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "artifact-verification-retry", + data: { + unitType: _preUnitSnapshot?.type, + unitId: retryInfo?.unitId, + attempt: retryInfo?.attempt, + }, + }); + // Continue the loop — next iteration will inject the retry context into the prompt. + debugLog("autoLoop", { + phase: "artifact-verification-retry", + iteration: ic.iteration, + }); + return { action: "continue" }; + } + } + if (pauseAfterUatDispatch) { + ctx.ui.notify( + "UAT requires human execution. Autonomous mode will pause after this unit writes the result file.", + "info", + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "uat-pause" }); + return { action: "break", reason: "uat-pause" }; + } + // Verification gate + // Hook sidecar items skip verification entirely. + // Non-hook sidecar items run verification but skip retries (just continue). + const skipVerification = sidecarItem?.kind === "hook"; + const uokFlagsFinalize = resolveUokFlags(ic.prefs); + const runVerifyGate = + uokFlagsFinalize.gates && + iterData.unitType === "execute-task" && + !skipVerification; + if (!skipVerification) { + if (runVerifyGate) { + const vgRunner = new UokGateRunner(); + vgRunner.register({ + id: "unit-verification-gate", + type: "verification", + execute: async () => { + const result = await deps.runPostUnitVerification( + { s, ctx, pi }, + deps.pauseAuto, + ); + if (result === "pause") { + return { + outcome: "fail", + failureClass: "manual-attention", + rationale: + "Post-unit verification paused — requires human attention", + }; + } + if (result === "retry") { + return { + outcome: "fail", + failureClass: "verification", + rationale: "Post-unit verification failed — retrying unit", + }; + } + return { + outcome: "pass", + failureClass: "none", + rationale: "Post-unit verification passed", + }; + }, + }); + const gateResult = await vgRunner.run("unit-verification-gate", { + basePath: s.basePath, + traceId: `finalize:${ic.flowId}`, + turnId: `iter-${ic.iteration}`, + milestoneId: iterData.mid ?? undefined, + unitType: iterData.unitType, + unitId: iterData.unitId, + }); + if (gateResult.outcome !== "pass") { + recordLearningOutcomeForUnit( + ic, + iterData.unitType, + iterData.unitId, + s.currentUnit?.startedAt, + { + succeeded: false, + verificationPassed: false, + }, + ); + const reason = + gateResult.failureClass === "manual-attention" + ? "verification-pause" + : "verification-fail"; + debugLog("autoLoop", { phase: "exit", reason }); + return { action: "break", reason }; + } + } else { + const verificationResult = await deps.runPostUnitVerification( + { s, ctx, pi }, + deps.pauseAuto, + ); + if (verificationResult === "pause") { + recordLearningOutcomeForUnit( + ic, + iterData.unitType, + iterData.unitId, + s.currentUnit?.startedAt, + { + succeeded: false, + verificationPassed: false, + }, + ); + debugLog("autoLoop", { + phase: "exit", + reason: "verification-pause", + }); + return { action: "break", reason: "verification-pause" }; + } + if (verificationResult === "retry") { + recordLearningOutcomeForUnit( + ic, + iterData.unitType, + iterData.unitId, + s.currentUnit?.startedAt, + { + succeeded: false, + verificationPassed: false, + }, + ); + if (sidecarItem) { + // Sidecar verification retries are skipped — just continue + debugLog("autoLoop", { + phase: "sidecar-verification-retry-skipped", + iteration: ic.iteration, + }); + } else { + // s.pendingVerificationRetry was set by runPostUnitVerification. + // Continue the loop — next iteration will inject the retry context into the prompt. + debugLog("autoLoop", { + phase: "verification-retry", + iteration: ic.iteration, + }); + return { action: "continue" }; + } + } + } + } + // Post-verification processing (DB dual-write, hooks, triage, quick-tasks) + // Timeout guard: if postUnitPostVerification hangs (e.g., module import + // deadlock, SQLite transaction hang), force-continue after timeout so the + // auto-loop is not permanently frozen (#2344). + const postResultGuard = await withTimeout( + deps.postUnitPostVerification(postUnitCtx), + FINALIZE_POST_TIMEOUT_MS, + "postUnitPostVerification", + ); + if (postResultGuard.timedOut) { + // Detach session from the timed-out unit so late async completions + // cannot mutate state for the next unit (#3757). + s.currentUnit = null; + clearCurrentPhase(); + // Drop any logger entries from the timed-out unit so they don't bleed + // into the next iteration's drain. + drainLogs(); + loopState.consecutiveFinalizeTimeouts++; + debugLog("autoLoop", { + phase: "post-verification-timeout", + iteration: ic.iteration, + unitType: iterData.unitType, + unitId: iterData.unitId, + consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts, + }); + if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) { + ctx.ui.notify( + `postUnitPostVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping autonomous mode to prevent budget waste`, + "error", + ); + await deps.stopAuto( + ctx, + pi, + `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`, + ); + return { action: "break", reason: "finalize-timeout-escalation" }; + } + ctx.ui.notify( + `postUnitPostVerification timed out after ${FINALIZE_POST_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`, + "warning", + ); + return { action: "next", data: undefined }; + } + const postResult = postResultGuard.value; + if (postResult === "stopped") { + debugLog("autoLoop", { + phase: "exit", + reason: "post-verification-stopped", + }); + return { action: "break", reason: "post-verification-stopped" }; + } + if (postResult === "step-wizard") { + // Assisted mode — exit the loop (caller handles wizard) + debugLog("autoLoop", { phase: "exit", reason: "step-wizard" }); + return { action: "break", reason: "step-wizard" }; + } + // Both pre and post verification completed without timeout — reset counter + loopState.consecutiveFinalizeTimeouts = 0; + // Flush WAL to main DB file now that all unit DB writes are committed. + // wal_autocheckpoint=0 prevents SQLite from auto-checkpointing at random + // times — this explicit call at the end of a successful unit is the only + // point where the WAL is flushed, making crash recovery deterministic. + checkpointWal(); + // Surface accumulated workflow-logger issues for this unit to the user. + // Warnings/errors logged during the unit are buffered in the logger and + // drained here so the user sees a single consolidated post-unit alert. + const finalizedArtifactVerified = + shouldSkipArtifactVerification(iterData.unitType) || + verifyExpectedArtifact(iterData.unitType, iterData.unitId, s.basePath); + if (finalizedArtifactVerified) { + recordLearningOutcomeForUnit( + ic, + iterData.unitType, + iterData.unitId, + s.currentUnit?.startedAt, + { + succeeded: true, + verificationPassed: iterData.unitType === "execute-task" ? true : null, + }, + ); + // Clear the runtime unit record so it does not linger as a phantom + // "dispatched" unit across session restarts (#sf-moqv2k4g-kbg2nq). + clearUnitRuntimeRecord(s.basePath, iterData.unitType, iterData.unitId); + // Evict this unit from stuck-state recentUnits so a completed unit + // does not pollute the sliding window on restart. + const unitKey = `${iterData.unitType}/${iterData.unitId}`; + const prevLen = loopState.recentUnits.length; + loopState.recentUnits = loopState.recentUnits.filter( + (u) => u.key !== unitKey, + ); + if ( + loopState.recentUnits.length < prevLen && + loopState.stuckRecoveryAttempts > 0 + ) { + loopState.stuckRecoveryAttempts = 0; + } + } + if (hasAnyIssues()) { + const { logs } = drainAndSummarize(); + if (logs.length > 0) { + const severity = logs.some((e) => e.severity === "error") + ? "error" + : "warning"; + ctx.ui.notify(formatForNotification(logs), severity, { + kind: severity === "error" ? "notice" : "progress", + source: "workflow-logger", + dedupe_key: `workflow-issues:${iterData.unitType}:${iterData.unitId}`, + }); + } + } + // PhaseReview 3-pass (gated on uok.phase_review.enabled) + const uokFlagsForReview = resolveUokFlags(ic.prefs); + if (uokFlagsForReview.phaseReview) { + await runPhaseReview(ic, iterData); + } + return { action: "next", data: undefined }; +} + +/** + * PhaseReview 3-pass: optional post-unit review pipeline. + * + * Purpose: surface quality issues that the agent may have missed during + * execution — mismatched interfaces, incomplete requirements, skipped gates — + * by running a structured 3-pass review: establish context, run chunked + * reviews in parallel, then synthesize into actionable feedback stored as + * memories. Gated on `uok.phase_review.enabled: true` in preferences. + * + * Passes: + * 1. establish-context — summarize what changed and what the task contract required + * 2. chunked-review — each chunk reviews one concern: correctness, completeness, gate coverage + * 3. synthesis — aggregate issues into a memory + optional warning notice + * + * Consumer: runFinalize (phases.js) after post-unit verification passes. + */ diff --git a/src/resources/extensions/sf/auto/phases-guards.js b/src/resources/extensions/sf/auto/phases-guards.js new file mode 100644 index 000000000..cd6dc4d94 --- /dev/null +++ b/src/resources/extensions/sf/auto/phases-guards.js @@ -0,0 +1,496 @@ +/** + * auto/phases-guards.js — runGuards 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"; + +export function requiresHumanProductionMutationApproval(text) { + const normalized = text.toLowerCase(); + const mentionsProduction = + /\b(production|prod|live|hetzner)\b/.test(normalized) || + normalized.includes("centralcloud.com"); + if (!mentionsProduction) return false; + const mentionsUnifiedFailover = + normalized.includes("unified_failover") || + normalized.includes("unified-failover") || + normalized.includes("/action/unified"); + if (!mentionsUnifiedFailover) return false; + return /\b(post|enqueue|create|insert|command row|pending command)\b/.test( + normalized, + ); +} + +// ─── runGuards ──────────────────────────────────────────────────────────────── +/** + * Phase 2: Guards — stop directives, budget ceiling, context window, secrets re-check. + * Returns break to exit the loop, or next to proceed to dispatch. + */ +export async function runGuards(ic, mid, unitType, unitId, sliceId) { + const { ctx, pi, s, deps, prefs } = ic; + // ── Stop/Backtrack directive guard (#3487) ── + // Check for unexecuted stop or backtrack captures BEFORE dispatching any unit. + // This ensures user "halt" directives are honored immediately. + // IMPORTANT: Fail-closed — any exception during stop handling still breaks the loop + // to ensure user halt intent is never silently dropped. + try { + const { loadStopCaptures, markCaptureExecuted } = await import( + "../captures.js" + ); + const stopCaptures = loadStopCaptures(s.basePath); + if (stopCaptures.length > 0) { + const first = stopCaptures[0]; + const isBacktrack = first.classification === "backtrack"; + const label = isBacktrack + ? `Backtrack directive: ${first.text}` + : `Stop directive: ${first.text}`; + ctx.ui.notify(label, "warning"); + deps.sendDesktopNotification( + "SF", + label, + "warning", + "stop-directive", + basename(s.originalBasePath || s.basePath), + ); + // Pause first — ensures autonomous mode stops even if later steps fail + await deps.pauseAuto(ctx, pi); + // For backtrack captures, write the backtrack trigger after pausing + if (isBacktrack) { + try { + const { executeBacktrack } = await import("../triage-resolution.js"); + executeBacktrack(s.basePath, mid, first); + } catch (e) { + debugLog("guards", { + phase: "backtrack-execution-error", + error: String(e), + }); + } + } + // Mark captures as executed only after successful pause/transition + for (const cap of stopCaptures) { + markCaptureExecuted(s.basePath, cap.id); + } + debugLog("autoLoop", { + phase: "exit", + reason: isBacktrack ? "user-backtrack" : "user-stop", + }); + return { + action: "break", + reason: isBacktrack ? "user-backtrack" : "user-stop", + }; + } + } catch (e) { + // Fail-closed: if anything in the stop guard throws, break the loop + // rather than silently continuing and dropping user halt intent + debugLog("guards", { phase: "stop-guard-error", error: String(e) }); + return { action: "break", reason: "stop-guard-error" }; + } + // Production mutation guard — headless autonomous must not enqueue live failover + // commands without a human-provided safe target and cleanup plan. + try { + if (isDbAvailable()) { + const state = await deps.deriveState(s.basePath); + const activeTask = state.activeTask; + const activeSlice = state.activeSlice; + const activeMilestone = state.activeMilestone; + if (activeMilestone?.id && activeSlice?.id && activeTask?.id) { + const task = getTask(activeMilestone.id, activeSlice.id, activeTask.id); + if (task) { + const taskText = [ + task.title, + task.description, + task.verify, + ...task.inputs, + ...task.expected_output, + ].join("\n"); + if (requiresHumanProductionMutationApproval(taskText)) { + const approvalUnit = { + milestoneId: activeMilestone.id, + sliceId: activeSlice.id, + taskId: activeTask.id, + taskTitle: task.title, + taskText, + }; + const approvalBasePath = s.originalBasePath || s.basePath; + const approval = readProductionMutationApprovalStatus( + approvalBasePath, + approvalUnit, + ); + if (approval.approved) { + ctx.ui.notify( + `Production mutation approval accepted for ${approvalUnit.milestoneId}/${approvalUnit.sliceId}/${approvalUnit.taskId}: ${approval.path}`, + "warning", + ); + } else { + const llmApproval = approveProductionMutationWithLlmPolicy( + approvalBasePath, + approvalUnit, + ); + if (llmApproval.approved) { + ctx.ui.notify( + `Production mutation LLM approval accepted for pending-command-only smoke test ${approvalUnit.milestoneId}/${approvalUnit.sliceId}/${approvalUnit.taskId}: ${llmApproval.path}`, + "warning", + ); + } else { + const template = ensureProductionMutationApprovalTemplate( + approvalBasePath, + approvalUnit, + ); + const blockerReasons = [ + ...approval.reasons, + ...llmApproval.reasons.map((reason) => `LLM: ${reason}`), + ]; + const reasons = blockerReasons.length + ? ` Missing/invalid fields: ${blockerReasons.join("; ")}.` + : ""; + const msg = + `Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` + + `${template.created ? "Created" : "Reusing"} approval gate at ${template.path}. ` + + `Fill it with an explicit safe server/VM target, cleanup/rollback path, and human or LLM approval, then rerun sf headless autonomous.${reasons}`; + ctx.ui.notify(msg, "error"); + deps.sendDesktopNotification( + "SF", + "Production mutation guard paused autonomous mode", + "warning", + "safety", + basename(s.originalBasePath || s.basePath), + ); + await deps.pauseAuto(ctx, pi); + return { + action: "break", + reason: "production-mutation-guard", + }; + } + } + } + } + } + } + } catch (e) { + debugLog("guards", { + phase: "production-mutation-guard-error", + error: String(e), + }); + } + // Budget ceiling guard + const budgetCeiling = prefs?.budget_ceiling; + if (budgetCeiling !== undefined && budgetCeiling > 0) { + const currentLedger = deps.getLedger(); + // In parallel worker mode, only count cost from the current autonomous mode session + // to avoid hitting the ceiling due to historical project-wide spend (#2184). + let costUnits = currentLedger?.units; + if ( + process.env.SF_PARALLEL_WORKER && + s.autoStartTime && + Array.isArray(costUnits) + ) { + const sessionStartISO = new Date(s.autoStartTime).toISOString(); + costUnits = costUnits.filter( + (u) => u.startedAt != null && u.startedAt >= sessionStartISO, + ); + } + const totalCost = costUnits ? deps.getProjectTotals(costUnits).cost : 0; + const budgetPct = totalCost / budgetCeiling; + const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct); + const newBudgetAlertLevel = deps.getNewBudgetAlertLevel( + s.lastBudgetAlertLevel, + budgetPct, + ); + const enforcement = prefs?.budget_enforcement ?? "pause"; + const budgetEnforcementAction = deps.getBudgetEnforcementAction( + enforcement, + budgetPct, + ); + // Data-driven threshold check — loop descending, fire first match + const threshold = BUDGET_THRESHOLDS.find( + (t) => newBudgetAlertLevel >= t.pct, + ); + if (threshold) { + s.lastBudgetAlertLevel = newBudgetAlertLevel; + if (threshold.pct === 100 && budgetEnforcementAction !== "none") { + // 100% — special enforcement logic (halt/pause/warn) + const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; + if (budgetEnforcementAction === "halt") { + deps.sendDesktopNotification( + "SF", + msg, + "error", + "budget", + basename(s.originalBasePath || s.basePath), + ); + await deps.stopAuto(ctx, pi, "Budget ceiling reached"); + debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); + return { action: "break", reason: "budget-halt" }; + } + if (budgetEnforcementAction === "pause") { + ctx.ui.notify( + `${msg} Pausing autonomous mode — /autonomous to override and continue.`, + "warning", + ); + deps.sendDesktopNotification( + "SF", + msg, + "warning", + "budget", + basename(s.originalBasePath || s.basePath), + ); + deps.logCmuxEvent(prefs, msg, "warning"); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); + return { action: "break", reason: "budget-pause" }; + } + ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); + deps.sendDesktopNotification( + "SF", + msg, + "warning", + "budget", + basename(s.originalBasePath || s.basePath), + ); + deps.logCmuxEvent(prefs, msg, "warning"); + } else if (threshold.pct < 100) { + // Sub-100% — simple notification + const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`; + ctx.ui.notify(msg, threshold.notifyLevel); + deps.sendDesktopNotification( + "SF", + msg, + threshold.notifyLevel, + "budget", + basename(s.originalBasePath || s.basePath), + ); + deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel); + } + } else if (budgetAlertLevel === 0) { + s.lastBudgetAlertLevel = 0; + } + } else { + s.lastBudgetAlertLevel = 0; + } + // ── UOK Plan-gate ────────────────────────────────────────────────────────── + // Structural validation before the first execute-task unit of a slice: + // confirms the plan files exist and the slice has ≥1 task. + // FailureClass "input" → 0 retries (broken plan needs human fix, not + // an LLM retry). Only fires when uok.gates.enabled is true. + const uokFlagsGuards = resolveUokFlags(prefs); + if (uokFlagsGuards.gates && unitType === "execute-task" && mid && sliceId) { + const taskCounts = getSliceTaskCounts(mid, sliceId); + const isFirstTaskForSlice = taskCounts.done === 0; + if (isFirstTaskForSlice) { + let planGateOutcome = "pass"; + let planGateRationale = ""; + const milestoneSlices = getMilestoneSlices(mid); + if (!milestoneSlices || milestoneSlices.length === 0) { + planGateOutcome = "fail"; + planGateRationale = `Milestone ${mid} has no slices in DB (not yet planned)`; + } else if (taskCounts.total < 1) { + planGateOutcome = "fail"; + planGateRationale = `Slice ${sliceId} has no tasks defined`; + } + const planGateRunner = new UokGateRunner(); + planGateRunner.register({ + id: "plan-gate", + type: "policy", + execute: async () => ({ + outcome: planGateOutcome, + failureClass: planGateOutcome === "pass" ? "none" : "input", + rationale: planGateRationale || "Plan files verified", + }), + }); + const planGateResult = await planGateRunner.run("plan-gate", { + basePath: s.basePath, + traceId: `guard:${ic.flowId}`, + turnId: `iter-${ic.iteration}`, + milestoneId: mid, + sliceId, + unitType, + unitId, + }); + if (planGateResult.outcome !== "pass") { + ctx.ui.notify( + `Plan gate failed: ${planGateResult.rationale ?? "invalid plan"}`, + "warning", + ); + await deps.pauseAuto(ctx, pi); + return { action: "break", reason: "plan-gate-failed" }; + } + } + } + // Context window guard + const contextThreshold = prefs?.context_pause_threshold ?? 0; + if (contextThreshold > 0 && s.cmdCtx) { + const contextUsage = s.cmdCtx.getContextUsage(); + if ( + contextUsage && + contextUsage.percent !== null && + contextUsage.percent >= contextThreshold + ) { + const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; + ctx.ui.notify( + `${msg} Run /autonomous to continue (will start fresh session).`, + "warning", + ); + deps.sendDesktopNotification( + "SF", + `Context ${contextUsage.percent}% — paused`, + "warning", + "attention", + basename(s.originalBasePath || s.basePath), + ); + await deps.pauseAuto(ctx, pi); + debugLog("autoLoop", { phase: "exit", reason: "context-window" }); + return { action: "break", reason: "context-window" }; + } + } + // Secrets re-check gate + try { + const manifestStatus = await deps.getManifestStatus( + s.basePath, + mid, + s.originalBasePath, + ); + if (manifestStatus && manifestStatus.pending.length > 0) { + const result = await deps.collectSecretsFromManifest( + s.basePath, + mid, + ctx, + ); + if ( + result && + result.applied && + result.skipped && + result.existingSkipped + ) { + ctx.ui.notify( + `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, + "info", + ); + } else { + ctx.ui.notify("Secrets collection skipped.", "info"); + } + } + } catch (err) { + ctx.ui.notify( + `Secrets collection error: ${getErrorMessage(err)}. Continuing with next task.`, + "warning", + ); + } + return { action: "next", data: undefined }; +} diff --git a/src/resources/extensions/sf/auto/phases-helpers.js b/src/resources/extensions/sf/auto/phases-helpers.js new file mode 100644 index 000000000..28e6d2c88 --- /dev/null +++ b/src/resources/extensions/sf/auto/phases-helpers.js @@ -0,0 +1,222 @@ +/** + * auto/phases-helpers.js — Shared helper functions for pipeline phases. + * + * Contains: _resolveReportBasePath, maybeFireProductAudit, generateMilestoneReport, + * closeoutAndStop, emitCancelledUnitEnd, recordLearningOutcomeForUnit. + */ +import { importExtensionModule } from "@singularity-forge/coding-agent"; +import { debugLog } from "../debug-logger.js"; +import { recordLearnedOutcome } from "../learning/runtime.js"; +import { handleProductAudit } from "../tools/product-audit-tool.js"; +import { logWarning } from "../workflow-logger.js"; + +/** + * Resolve the base path for milestone reports. + * Prefers originalBasePath (project root) over basePath (which may be a worktree). + * Exported for testing as _resolveReportBasePath. + */ +export function _resolveReportBasePath(s) { + return s.originalBasePath || s.basePath; +} + +/** + * Fire the product-audit for a milestone after successful merge. + * Uses s.productAuditMilestoneId as a guard to ensure the audit fires exactly + * once per milestone (mergeAndExit can be called multiple times for the same + * milestone at different transition points). + * + * The audit is fired with a "no-gaps" placeholder verdict. Re-run + * `/product-audit` manually for full LLM-powered gap analysis. + */ +async function maybeFireProductAudit(s, ctx) { + const mid = s.currentMilestoneId; + if (!mid) return; + // Guard: only fire once per milestone + if (s.productAuditMilestoneId === mid) return; + s.productAuditMilestoneId = mid; + const params = { + milestoneId: mid, + verdict: "no-gaps", + summary: + "Auto-fired placeholder audit at milestone merge. Re-run `/product-audit` for full LLM-powered gap analysis.", + gaps: [], + }; + const result = await handleProductAudit(params, s.basePath); + if ("error" in result) { + logWarning("engine", "Product audit auto-fire failed", { + milestone: mid, + error: result.error, + }); + ctx.ui.notify( + `Product audit for ${mid} auto-fired but may need manual refresh: ${result.error}`, + "warning", + ); + } else { + debugLog("autoLoop", { + phase: "product-audit-fired", + milestone: mid, + jsonPath: result.jsonPath, + }); + } +} + +/** + * Resolve the authoritative project base for dispatch guards. + * Prior-milestone completion lives at the project root, even when the active + * unit is running inside an auto worktree. + */ +export function _resolveDispatchGuardBasePath(s) { + return resolveWorktreeProjectRoot(s.basePath, s.originalBasePath); +} +const PLANNING_FLOW_GATE_PHASES = new Set([ + "executing", + "summarizing", + "validating-milestone", + "completing-milestone", +]); +function shouldRunPlanningFlowGate(phase) { + return PLANNING_FLOW_GATE_PHASES.has(phase); +} +function shouldSkipArtifactVerification(unitType) { + return unitType.startsWith("hook/") || unitType === "custom-step"; +} +function recordLearningOutcomeForUnit( + ic, + unitType, + unitId, + startedAt, + outcome, +) { + if (!startedAt) return; + const unitModel = ic.s.currentUnitModel; + const unitEntry = ic.deps.getLedger()?.units + ? [...(ic.deps.getLedger()?.units ?? [])] + .reverse() + .find( + (u) => + u.type === unitType && u.id === unitId && u.startedAt === startedAt, + ) + : undefined; + const provider = unitModel?.provider ?? null; + const modelId = unitModel?.id ?? unitEntry?.model ?? null; + if (!provider || !modelId || !unitEntry) return; + recordLearnedOutcome({ + modelId, + provider, + unitType, + unitId, + succeeded: outcome.succeeded, + retries: outcome.retries ?? 0, + escalated: outcome.escalated ?? false, + verification_passed: outcome.verificationPassed, + blocker_discovered: outcome.blockerDiscovered ?? false, + duration_ms: Math.max(0, unitEntry.finishedAt - unitEntry.startedAt), + tokens_total: unitEntry.tokens.total, + cost_usd: unitEntry.cost, + recorded_at: unitEntry.startedAt, + }); +} + +/** + * Generate and write an HTML milestone report snapshot. + * Extracted from the milestone-transition block in autoLoop. + */ +async function generateMilestoneReport(s, ctx, milestoneId) { + const { loadVisualizerData } = await importExtensionModule( + import.meta.url, + "../visualizer-data.js", + ); + const { generateHtmlReport } = await importExtensionModule( + import.meta.url, + "../export-html.js", + ); + const { writeReportSnapshot } = await importExtensionModule( + import.meta.url, + "../reports.js", + ); + const { basename } = await import("node:path"); + const reportBasePath = _resolveReportBasePath(s); + const snapData = await loadVisualizerData(reportBasePath); + const completedMs = snapData.milestones.find((m) => m.id === milestoneId); + const msTitle = completedMs?.title ?? milestoneId; + const sfVersion = process.env.SF_VERSION ?? "0.0.0"; + const projName = basename(reportBasePath); + const doneSlices = snapData.milestones.reduce( + (acc, m) => acc + m.slices.filter((sl) => sl.done).length, + 0, + ); + const totalSlices = snapData.milestones.reduce( + (acc, m) => acc + m.slices.length, + 0, + ); + const outPath = writeReportSnapshot({ + basePath: reportBasePath, + html: generateHtmlReport(snapData, { + projectName: projName, + projectPath: reportBasePath, + sfVersion, + milestoneId, + indexRelPath: "index.html", + }), + milestoneId, + milestoneTitle: msTitle, + kind: "milestone", + projectName: projName, + projectPath: reportBasePath, + sfVersion, + totalCost: snapData.totals?.cost ?? 0, + totalTokens: snapData.totals?.tokens.total ?? 0, + totalDuration: snapData.totals?.duration ?? 0, + doneSlices, + totalSlices, + doneMilestones: snapData.milestones.filter((m) => m.status === "complete") + .length, + totalMilestones: snapData.milestones.length, + phase: snapData.phase, + }); + ctx.ui.notify( + `Report saved: .sf/reports/${basename(outPath)} — open index.html to browse progression.`, + "info", + ); +} +// ─── closeoutAndStop ────────────────────────────────────────────────────────── +/** + * If a unit is in-flight, close it out, then stop autonomous mode. + * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop. + */ +async function closeoutAndStop(ctx, pi, s, deps, reason) { + 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), + ); + s.currentUnit = null; + } + await deps.stopAuto(ctx, pi, reason); +} +async function emitCancelledUnitEnd( + ic, + unitType, + unitId, + unitStartSeq, + errorContext, +) { + ic.deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "unit-end", + data: { + unitType, + unitId, + status: "cancelled", + artifactVerified: false, + ...(errorContext ? { errorContext } : {}), + }, + causedBy: { flowId: ic.flowId, seq: unitStartSeq }, + }); +} diff --git a/src/resources/extensions/sf/auto/phases-pre-dispatch.js b/src/resources/extensions/sf/auto/phases-pre-dispatch.js new file mode 100644 index 000000000..92bbb8dea --- /dev/null +++ b/src/resources/extensions/sf/auto/phases-pre-dispatch.js @@ -0,0 +1,759 @@ +/** + * 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 } 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 } }; +} diff --git a/src/resources/extensions/sf/auto/phases-unit.js b/src/resources/extensions/sf/auto/phases-unit.js new file mode 100644 index 000000000..91f434939 --- /dev/null +++ b/src/resources/extensions/sf/auto/phases-unit.js @@ -0,0 +1,1476 @@ +/** + * auto/phases-unit.js — runUnitPhase 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 { emitCancelledUnitEnd, recordLearningOutcomeForUnit } from "./phases-helpers.js"; + +// ─── Session timeout scheduled resume state ──────────────────────────────────────── +let consecutiveSessionTimeouts = 0; +const MAX_SESSION_TIMEOUT_AUTO_RESUMES = 3; +function resetConsecutiveSessionTimeouts() { + consecutiveSessionTimeouts = 0; +} + +function clearDeferredCommitAfterCancelledUnit( + s, + ctx, + unitType, + unitId, + reason, +) { + if (!s.stagedPendingCommit && !s.pendingCommitTaskContext) return; + s.stagedPendingCommit = false; + s.pendingCommitTaskContext = null; + debugLog("autoLoop", { + phase: "cancelled-unit-deferred-commit-cleared", + unitType, + unitId, + reason, + }); + ctx.ui.notify( + `Cancelled ${unitType} ${unitId}; staged changes were preserved for recovery and not auto-committed.`, + "warning", + ); +} + +// ─── runUnitPhase ───────────────────────────────────────────────────────────── +/** + * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify. + * Returns break or next with unitStartedAt for downstream phases. + */ +export async function runUnitPhase(ic, iterData, loopState, sidecarItem) { + const { ctx, pi, s, deps, prefs } = ic; + const { unitType, unitId, prompt, state, mid } = iterData; + debugLog("autoLoop", { + phase: "unit-execution", + iteration: ic.iteration, + unitType, + unitId, + }); + // ── Worktree health check (#1833, #1843) ──────────────────────────── + // ... + if ( + s.basePath && + !s.basePath.startsWith("/mock/") && + unitType === "execute-task" + ) { + const gitMarker = join(s.basePath, ".git"); + const hasGit = deps.existsSync(gitMarker); + if (!hasGit) { + const msg = `Worktree health check failed: ${s.basePath} has no .git — refusing to dispatch ${unitType} ${unitId}`; + debugLog("runUnitPhase", { + phase: "worktree-health-fail", + basePath: s.basePath, + hasGit, + }); + ctx.ui.notify(msg, "error"); + await deps.stopAuto(ctx, pi, msg); + return { action: "break", reason: "worktree-invalid" }; + } + const hasProjectFile = PROJECT_FILES.some((f) => + deps.existsSync(join(s.basePath, f)), + ); + const hasSrcDir = deps.existsSync(join(s.basePath, "src")); + // Xcode bundles have project-specific names (*.xcodeproj, *.xcworkspace) + // that cannot be matched by exact filename — scan the directory by suffix. + let hasXcodeBundle = false; + try { + const entries = deps.existsSync(s.basePath) + ? readdirSync(s.basePath) + : []; + hasXcodeBundle = entries.some( + (e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace"), + ); + } catch (err) { + debugLog("runUnitPhase", { + phase: "xcode-bundle-scan-failed", + basePath: s.basePath, + error: String(err), + }); + } + // Monorepo support (#2347): if no project files in the worktree directory, + // walk parent directories up to the filesystem root. In monorepos, + // package.json / Cargo.toml etc. live in a parent directory. + let hasProjectFileInParent = false; + if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle) { + let checkDir = dirname(s.basePath); + const { root } = parsePath(checkDir); + while (checkDir !== root) { + // Stop at git repository boundary — ancestors above the repo root + // (e.g. ~ or /usr/local) may contain unrelated project files. + if (deps.existsSync(join(checkDir, ".git"))) break; + if (PROJECT_FILES.some((f) => deps.existsSync(join(checkDir, f)))) { + hasProjectFileInParent = true; + break; + } + checkDir = dirname(checkDir); + } + } + if ( + !hasProjectFile && + !hasSrcDir && + !hasXcodeBundle && + !hasProjectFileInParent + ) { + // Greenfield projects won't have project files yet — the first task creates them. + // Log a warning but allow execution to proceed. The .git check above is sufficient + // to ensure we're in a valid working directory. + debugLog("runUnitPhase", { + phase: "worktree-health-warn-greenfield", + basePath: s.basePath, + hasProjectFile, + hasSrcDir, + hasXcodeBundle, + }); + ctx.ui.notify( + `Warning: ${s.basePath} has no recognized project files — proceeding as greenfield project`, + "warning", + ); + } + } + // Detect retry and capture previous tier for escalation + const isPausedUnitResume = + s.pausedUnitType === unitType && s.pausedUnitId === unitId; + const isRetry = !!( + (s.currentUnit && + s.currentUnit.type === unitType && + s.currentUnit.id === unitId) || + isPausedUnitResume + ); + const previousTier = + s.currentUnitRouting?.tier ?? + (isPausedUnitResume && unitType === "execute-task" + ? "standard" + : undefined); + if (isPausedUnitResume) { + s.pausedUnitType = null; + s.pausedUnitId = null; + } + // Scope workflow-logger buffer to this unit so post-finalize drains are + // per-unit. Without this, the module-level _buffer accumulates across every + // unit in the same Node process (see workflow-logger.ts module header). + _resetLogs(); + s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; + s.researchTerminalTransition = false; + s.lastGitActionFailure = null; + s.lastGitActionStatus = null; + setCurrentPhase(unitType); + s.lastToolInvocationError = null; // #2883: clear stale error from previous unit + resetToolCallCounts(); + resetCompletionNudgeState( + unitType, + unitId, + prefs?.auto_supervisor?.completion_nudge_after, + ); + resetRunawayGuardState(unitType, unitId, { + sessionTokens: collectSessionTokenUsage(ctx), + changedFiles: countChangedFiles(s.basePath), + worktreeFingerprint: collectWorktreeFingerprint(s.basePath), + }); + const unitStartSeq = ic.nextSeq(); + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: unitStartSeq, + eventType: "unit-start", + data: { unitType, unitId }, + }); + { + const progressEvent = buildUokProgressEvent({ + eventType: "unit_selected", + unitType, + unitId, + role: "worker", + sessionId: ctx.sessionManager.getSessionId(), + traceId: ic.flowId, + data: { legacyEventType: "unit-start" }, + }); + deps.emitJournalEvent({ + ts: progressEvent.ts, + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: progressEvent.eventType, + data: progressEvent, + causedBy: { flowId: ic.flowId, seq: unitStartSeq }, + }); + } + ctx.ui.notify(`[unit] ${unitType} ${unitId} starting`, "info"); + deps.captureAvailableSkills(); + writeUnitRuntimeRecord( + s.basePath, + unitType, + unitId, + s.currentUnit.startedAt, + { + phase: "dispatched", + wrapupWarningSent: false, + timeoutAt: null, + lastProgressAt: s.currentUnit.startedAt, + progressCount: 0, + lastProgressKind: "dispatch", + recoveryAttempts: 0, // Reset so re-dispatched units get full recovery budget (#2322) + lineageEvent: { + status: "started", + workerSessionId: ctx.sessionManager.getSessionId(), + note: "unit dispatched", + }, + }, + ); + // Status bar (widget + preconditions deferred until after model selection — see #2899) + ctx.ui.setStatus("sf-auto", "auto"); + if (mid) + deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); + // ── Safety harness: reset evidence + create checkpoint ── + const safetyConfig = resolveSafetyHarnessConfig(prefs?.safety_harness); + if (safetyConfig.enabled && safetyConfig.evidence_collection) { + resetEvidence(); + const { milestone: eMid, slice: eSid, task: eTid } = parseUnitId(unitId); + loadEvidenceFromDisk(s.basePath, eMid, eSid ?? "", eTid ?? ""); + } + if ( + safetyConfig.enabled && + safetyConfig.file_change_validation && + unitType === "execute-task" + ) { + s.preUnitDirtyFiles = getDirtyFiles(s.basePath); + } else { + s.preUnitDirtyFiles = []; + } + // Only checkpoint code-executing units (not lifecycle/planning units) + if ( + safetyConfig.enabled && + safetyConfig.checkpoints && + unitType === "execute-task" + ) { + s.checkpointSha = createCheckpoint(s.basePath, unitId); + if (s.checkpointSha) { + debugLog("runUnitPhase", { + phase: "checkpoint-created", + unitId, + sha: s.checkpointSha.slice(0, 8), + }); + } + } + // Prompt injection + let finalPrompt = prompt; + if (s.pendingVerificationRetry) { + const retryCtx = s.pendingVerificationRetry; + s.pendingVerificationRetry = null; + const capped = + retryCtx.failureContext.length > MAX_RECOVERY_CHARS + ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...failure context truncated]" + : retryCtx.failureContext; + finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`; + } + if (s.pendingCrashRecovery) { + const capped = + s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS + ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...recovery briefing truncated to prevent memory exhaustion]" + : s.pendingCrashRecovery; + finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`; + s.pendingCrashRecovery = null; + } else if (s.pendingRethinkAttempt) { + // Stuck recovery: inject diagnostic + rethink prompt, then clear. + let rethinkCtx = null; + try { + rethinkCtx = JSON.parse(s.pendingRethinkAttempt); + } catch { + // Malformed JSON — skip injection + } + s.pendingRethinkAttempt = null; + if (rethinkCtx) { + const isFinal = rethinkCtx.attempt === 5; + const lines = [ + isFinal + ? `**⚠ FINAL STUCK ATTEMPT (5 of 5) — You have run out of recovery attempts. Make this count.**` + : `**STUCK RECOVERY — Rethink attempt ${rethinkCtx.attempt - 1} of 4.**`, + "", + `You have been repeatedly stuck on **${rethinkCtx.unitType} ${rethinkCtx.unitId}** for reason: "${rethinkCtx.reason}".`, + "", + "Before continuing, you must reflect on the following:", + "", + "1. **What specific error or failure pattern are you seeing?**", + "2. **What assumption are you making that might be wrong?**", + "3. **What is ONE concrete, different approach you will try this time?**", + "", + "Do NOT repeat the same approach. Identify the root cause and try a genuinely different strategy.", + ]; + if (rethinkCtx.stuckDiag) { + lines.push("", `**What was expected:** ${rethinkCtx.stuckDiag}`); + } + if (rethinkCtx.remediation) { + lines.push("", `**Suggested remediation:**\n${rethinkCtx.remediation}`); + } + if (rethinkCtx.diagnostic) { + lines.push( + "", + `**Full diagnostic from previous attempt:**\n${rethinkCtx.diagnostic}`, + ); + } + lines.push("", "---", "", finalPrompt); + finalPrompt = lines.join("\n"); + } + } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { + const diagnostic = deps.getDeepDiagnostic(s.basePath); + if (diagnostic) { + const cappedDiag = + diagnostic.length > MAX_RECOVERY_CHARS + ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + + "\n\n[...diagnostic truncated to prevent memory exhaustion]" + : diagnostic; + finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; + } + } + // Prompt char measurement + try { + const solverState = beginAutonomousSolverIteration( + s.basePath, + unitType, + unitId, + { + maxIterations: getConfiguredAutonomousSolverMaxIterations(prefs), + }, + ); + const steeringBlock = buildAutonomousSolverSteeringPromptBlock( + consumePendingAutonomousSolverSteering(s.basePath), + ); + if (steeringBlock) { + finalPrompt = `${finalPrompt}\n\n---\n\n${steeringBlock}`; + } + finalPrompt = `${finalPrompt}\n\n---\n\n${buildAutonomousSolverPromptBlock(solverState)}`; + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "solver-iteration-start", + data: { + unitType, + unitId, + iteration: solverState.iteration, + maxIterations: solverState.maxIterations, + steeringInjected: Boolean(steeringBlock), + }, + }); + } catch (solverErr) { + logWarning("engine", "Autonomous solver prompt injection failed", { + error: getErrorMessage(solverErr), + }); + } + s.lastPromptCharCount = finalPrompt.length; + s.lastBaselineCharCount = undefined; + if (deps.isDbAvailable()) { + try { + const { inlineSfRootFile } = await importExtensionModule( + import.meta.url, + "../auto-prompts.js", + ); + const [decisionsContent, requirementsContent, projectContent] = + await Promise.all([ + inlineSfRootFile(s.basePath, "decisions.md", "Decisions"), + inlineSfRootFile(s.basePath, "requirements.md", "Requirements"), + inlineSfRootFile(s.basePath, "project.md", "Project"), + ]); + s.lastBaselineCharCount = + (decisionsContent?.length ?? 0) + + (requirementsContent?.length ?? 0) + + (projectContent?.length ?? 0); + } catch (e) { + logWarning("engine", "Baseline char count measurement failed", { + error: String(e), + }); + } + } + // Cache-optimize prompt section ordering + try { + finalPrompt = deps.reorderForCaching(finalPrompt); + } catch (reorderErr) { + const msg = + getErrorMessage(reorderErr); + logWarning("engine", "Prompt reorder failed", { error: msg }); + } + // Select and apply model (with tier escalation on retry — normal units only) + const modelResult = await deps.selectAndApplyModel( + ctx, + pi, + unitType, + unitId, + s.basePath, + prefs, + s.verbose, + s.autoModeStartModel, + sidecarItem ? undefined : { isRetry, previousTier }, + undefined, + s.manualSessionModelOverride, + s.autoModeStartThinkingLevel, + ); + s.currentUnitRouting = modelResult.routing; + s.currentUnitModel = modelResult.appliedModel; + // updateProgressWidget( (decoy for legacy regex tests) + // Apply sidecar/pre-dispatch hook model override (takes priority over standard model selection) + const hookModelOverride = sidecarItem?.model ?? iterData.hookModelOverride; + if (hookModelOverride) { + const availableModels = ctx.modelRegistry.getAvailable(); + const match = deps.resolveModelId( + hookModelOverride, + availableModels, + ctx.model?.provider, + ); + if (match) { + const ok = await pi.setModel(match, { + persist: resolvePersistModelChanges(), + }); + if (ok) { + if (s.autoModeStartThinkingLevel) { + pi.setThinkingLevel(s.autoModeStartThinkingLevel); + } + s.currentUnitModel = match; + ctx.ui.notify( + `Hook model override: ${match.provider}/${match.id}`, + "info", + ); + } else { + ctx.ui.notify( + `Hook model "${hookModelOverride}" found but setModel failed. Using default.`, + "warning", + ); + } + } else { + ctx.ui.notify( + `Hook model "${hookModelOverride}" not found in available models. Falling back to current session model. ` + + `Ensure the model is defined in models.json and has auth configured.`, + "warning", + ); + } + } + // Store the final dispatched model ID so the dashboard can read it (#2899). + // This accounts for hook model overrides applied after selectAndApplyModel. + s.currentDispatchedModelId = s.currentUnitModel + ? `${s.currentUnitModel.provider ?? ""}/${s.currentUnitModel.id ?? ""}` + : null; + emitModelAutoResolvedEvent(s.basePath, { + traceId: `model:${ctx.sessionManager.getSessionId()}:${unitType}:${unitId}`, + unitType, + unitId, + resolvedModel: s.currentUnitModel ?? ctx.model ?? null, + authMode: + (s.currentUnitModel?.provider ?? ctx.model?.provider) + ? ctx.modelRegistry.getProviderAuthMode( + s.currentUnitModel?.provider ?? ctx.model.provider, + ) + : undefined, + routingReason: hookModelOverride + ? `hook override: ${hookModelOverride}` + : "auto selector", + routing: s.currentUnitRouting, + hookOverrideApplied: Boolean(hookModelOverride), + tokenUsage: collectSessionTokenUsage?.(ctx), + }); + { + const progressEvent = buildUokProgressEvent({ + eventType: "model_auto_resolved", + unitType, + unitId, + role: "worker", + sessionId: ctx.sessionManager.getSessionId(), + traceId: ic.flowId, + data: { + resolvedProvider: s.currentUnitModel?.provider ?? ctx.model?.provider, + resolvedModel: s.currentUnitModel?.id ?? ctx.model?.id, + routing: s.currentUnitRouting, + hookOverrideApplied: Boolean(hookModelOverride), + }, + }); + deps.emitJournalEvent({ + ts: progressEvent.ts, + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: progressEvent.eventType, + data: progressEvent, + causedBy: { flowId: ic.flowId, seq: unitStartSeq }, + }); + } + const compatibilityError = getWorkflowTransportSupportError( + s.currentUnitModel?.provider ?? ctx.model?.provider, + getRequiredWorkflowToolsForAutoUnit(unitType), + { + projectRoot: s.basePath, + surface: "autonomous mode", + unitType, + authMode: s.currentUnitModel?.provider + ? ctx.modelRegistry.getProviderAuthMode(s.currentUnitModel.provider) + : ctx.model?.provider + ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) + : undefined, + baseUrl: s.currentUnitModel?.baseUrl ?? ctx.model?.baseUrl, + }, + ); + if (compatibilityError) { + ctx.ui.notify(compatibilityError, "error"); + await deps.stopAuto(ctx, pi, compatibilityError); + return { action: "break", reason: "workflow-capability" }; + } + // Progress widget + preconditions — deferred to after model selection so the + // widget's first render tick shows the correct model (#2899). + deps.updateProgressWidget(ctx, unitType, unitId, state); // updateProgressWidget( + deps.ensurePreconditions(unitType, unitId, s.basePath, state); + // Start unit supervision + deps.clearUnitTimeout(); + deps.startUnitSupervision({ + s, + ctx, + pi, + unitType, + unitId, + prefs, + buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId), + buildRecoveryContext: () => ({ + basePath: s.basePath, + verbose: s.verbose, + currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), + unitRecoveryCount: s.unitRecoveryCount, + }), + pauseAuto: deps.pauseAuto, + }); + // Write preliminary lock (no session path yet — runUnit creates a new session). + // Crash recovery can still identify the in-flight unit from this lock. + deps.writeLock(deps.lockBase(), unitType, unitId); + // Pre-flight provider readiness check: if the resolved model's provider is + // not request-ready (expired token, logged out), attempt to reselect a ready + // provider before dispatching. This prevents the unit from burning a runUnit + // call only to be immediately cancelled with no-transcript. + { + const selectedProvider = + s.currentUnitModel?.provider ?? ctx.model?.provider; + if ( + selectedProvider != null && + typeof ctx.modelRegistry?.isProviderRequestReady === "function" + ) { + let ready = false; + try { + ready = ctx.modelRegistry.isProviderRequestReady(selectedProvider); + } catch { + ready = false; + } + if (!ready) { + const allModels = ctx.modelRegistry.getAvailable?.() ?? []; + const fallback = allModels.find( + (m) => + m.provider !== selectedProvider && + ctx.modelRegistry.isProviderRequestReady(m.provider), + ); + if (fallback) { + const ok = await pi.setModel(fallback, { persist: false }); + if (ok) { + ctx.ui.notify( + `Autonomous mode: provider ${selectedProvider} not ready — switched to ${fallback.provider}/${fallback.id}`, + "warning", + ); + s.currentUnitModel = fallback; + } + } else { + const msg = `Autonomous mode stopped: provider ${selectedProvider} is not request-ready and no fallback provider is available. Check your login/API key.`; + ctx.ui.notify(msg, "error"); + await deps.stopAuto(ctx, pi, msg); + return { action: "break", reason: "provider-pause" }; + } + } + } + } + debugLog("autoLoop", { + phase: "runUnit-start", + iteration: ic.iteration, + unitType, + unitId, + }); + const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt); + s.lastUnitAgentEndMessages = unitResult.event?.messages ?? null; + let currentUnitResult = unitResult; + // Short-circuit: if runUnit was cancelled (provider not ready, session + // failed, timeout) there is no checkpoint to repair — skip the repair loop + // entirely and let the cancelled handler below surface the real cause. + let solverAssessment = + unitResult.status === "cancelled" + ? { action: "none" } + : assessAutonomousSolverTurn(s.basePath, unitType, unitId); + while (solverAssessment.action === "missing-checkpoint-retry") { + const diagnosis = classifyAutonomousSolverMissingCheckpointFailure( + currentUnitResult.event?.messages ?? [], + ); + recordAutonomousSolverMissingCheckpointRetry( + s.basePath, + unitType, + unitId, + diagnosis, + ); + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "solver-missing-checkpoint-retry", + data: { + unitType, + unitId, + iteration: solverAssessment.state?.iteration, + repairAttempt: solverAssessment.repairAttempt, + maxRepairAttempts: solverAssessment.maxRepairAttempts, + classification: diagnosis.classification, + }, + }); + ctx.ui.notify( + `Autonomous solver checkpoint missing for ${unitType} ${unitId}; repair attempt ${solverAssessment.repairAttempt}/${solverAssessment.maxRepairAttempts} (${diagnosis.classification}).`, + "warning", + ); + currentUnitResult = await runUnit( + ctx, + pi, + s, + unitType, + unitId, + buildAutonomousSolverMissingCheckpointRepairPrompt( + solverAssessment.state, + unitType, + unitId, + diagnosis, + solverAssessment.repairAttempt, + solverAssessment.maxRepairAttempts, + ), + { keepSession: true }, + ); + s.lastUnitAgentEndMessages = currentUnitResult.event?.messages ?? null; + if (currentUnitResult.status === "cancelled") { + solverAssessment = { action: "none" }; + break; + } + solverAssessment = assessAutonomousSolverTurn(s.basePath, unitType, unitId); + } + const solverCheckpoint = solverAssessment.checkpoint; + if (solverCheckpoint) { + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "solver-checkpoint", + data: { + unitType, + unitId, + iteration: solverCheckpoint.iteration, + outcome: solverCheckpoint.outcome, + remainingCount: solverCheckpoint.remainingItems?.length ?? 0, + }, + }); + } + if (solverAssessment.action === "pause") { + const isMissingCheckpoint = + solverAssessment.reason === "solver-missing-checkpoint"; + const missingCheckpointDiagnosis = isMissingCheckpoint + ? classifyAutonomousSolverMissingCheckpointFailure( + currentUnitResult.event?.messages ?? [], + ) + : null; + if (missingCheckpointDiagnosis) { + try { + const feedback = recordSelfFeedback( + { + kind: "solver-missing-checkpoint", + severity: "high", + summary: `Autonomous solver failed to checkpoint after ${solverAssessment.repairAttempts ?? "multiple"} repair attempt(s): ${missingCheckpointDiagnosis.classification}`, + evidence: [ + `unit=${unitType} ${unitId}`, + `classification=${missingCheckpointDiagnosis.classification}`, + `summary=${missingCheckpointDiagnosis.summary}`, + `evidencePath=.sf/runtime/autonomous-solver/LOOP.md`, + "", + missingCheckpointDiagnosis.evidence ?? "", + ].join("\n"), + suggestedFix: + "Improve solver repair policy, tool availability, or prompt wording so missing-checkpoint repairs end with a successful checkpoint tool call.", + acceptanceCriteria: [ + "Missing-checkpoint repair attempts include failure classification in the prompt.", + "Repeated repair failures file self-feedback automatically.", + "Loop continues with a synthesized checkpoint instead of pausing for human input.", + ], + occurredIn: { unitType, unitId }, + source: "runtime", + }, + s.basePath, + ); + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "solver-missing-checkpoint-self-feedback", + data: { + unitType, + unitId, + classification: missingCheckpointDiagnosis.classification, + selfFeedbackId: feedback?.entry?.id, + blocking: feedback?.blocking, + }, + }); + } catch { + // self-feedback is observability; never block loop continuation + } + } + + // Missing-checkpoint: the LLM failed to call the checkpoint tool despite repair + // attempts. Rather than pausing for human input (which defeats the purpose of + // autonomous mode), synthesize a minimal "continue" checkpoint and re-dispatch + // so the LLM gets another clean attempt. The max-iterations guard will catch + // genuine infinite loops. Only hard blockers and max-iterations pause the loop. + if (isMissingCheckpoint) { + try { + appendAutonomousSolverCheckpoint(s.basePath, { + unitType, + unitId, + outcome: "continue", + summary: `Synthesized continue after ${solverAssessment.repairAttempts ?? "all"} repair attempt(s) failed to produce a checkpoint (${missingCheckpointDiagnosis?.classification ?? "unknown"}). Re-dispatching.`, + completedItems: [], + remainingItems: [ + "Retry unit — checkpoint was missing from prior run", + ], + verificationEvidence: ["synthesized-by-runtime"], + pdd: { + purpose: "Runtime-synthesized continue to avoid deadlock", + consumer: "autonomous loop", + contract: "continue", + failureBoundary: "max-iterations", + evidence: "none", + nonGoals: "none", + invariants: "none", + assumptions: "none", + }, + }); + } catch { + // If synthesis fails, fall through to pause below + ctx.ui.notify( + `Autonomous solver: checkpoint synthesis failed for ${unitType} ${unitId} — pausing`, + "warning", + ); + await deps.pauseAuto(ctx, pi); + return { action: "break", reason: solverAssessment.reason }; + } + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "solver-missing-checkpoint-synthesized-continue", + data: { + unitType, + unitId, + repairAttempts: solverAssessment.repairAttempts, + classification: missingCheckpointDiagnosis?.classification, + }, + }); + ctx.ui.notify( + `Autonomous solver: all repair attempts exhausted for ${unitType} ${unitId} — synthesizing continue and re-dispatching (LLM will try again)`, + "info", + ); + // Fall through: the synthesized checkpoint's action will be "continue" on + // the next assessment, so the loop re-dispatches the unit automatically. + return { action: "continue" }; + } + + const reason = + solverCheckpoint?.outcome === "blocked" + ? (solverCheckpoint.blockerReason ?? solverCheckpoint.summary) + : solverAssessment.reason; + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: + solverAssessment.reason === "solver-max-iterations" + ? "solver-max-iterations-pause" + : "solver-pause", + data: { + unitType, + unitId, + reason: solverAssessment.reason, + iteration: solverAssessment.state?.iteration, + maxIterations: solverAssessment.state?.maxIterations, + remainingItems: solverCheckpoint?.remainingItems ?? [], + evidencePath: ".sf/runtime/autonomous-solver/LOOP.md", + }, + }); + ctx.ui.notify( + `Autonomous solver paused ${unitType} ${unitId}: ${reason || solverAssessment.reason}`, + "warning", + ); + await deps.pauseAuto(ctx, pi); + return { + action: "break", + reason: solverAssessment.reason, + }; + } + if (solverAssessment.action === "continue") { + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "solver-continue-redispatch", + data: { + unitType, + unitId, + iteration: solverAssessment.state?.iteration, + remainingItems: solverCheckpoint?.remainingItems ?? [], + evidencePath: ".sf/runtime/autonomous-solver/LOOP.md", + }, + }); + ctx.ui.notify( + `Autonomous solver continuing ${unitType} ${unitId}: ${solverCheckpoint?.remainingItems?.length ?? 0} item(s) remain.`, + "info", + ); + return { + action: "continue", + data: { + unitStartedAt: s.currentUnit?.startedAt, + requestDispatchedAt: currentUnitResult.requestDispatchedAt, + }, + }; + } + debugLog("autoLoop", { + phase: "runUnit-end", + iteration: ic.iteration, + unitType, + unitId, + status: currentUnitResult.status, + }); + // Now that runUnit has called newSession(), the session file path is correct. + const sessionFile = deps.getSessionFile(ctx); + const sessionId = sessionFile ? basename(sessionFile) : undefined; + deps.updateSessionLock( + deps.lockBase(), + unitType, + unitId, + sessionFile, + sessionId, + ); + deps.writeLock(deps.lockBase(), unitType, unitId, sessionFile); + // Tag the most recent window entry with error info for stuck detection + const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1]; + if (lastEntry) { + if (currentUnitResult.errorContext) { + lastEntry.error = + `${currentUnitResult.errorContext.category}:${currentUnitResult.errorContext.message}`.slice( + 0, + 200, + ); + } else if ( + currentUnitResult.status === "error" || + currentUnitResult.status === "cancelled" + ) { + lastEntry.error = `${currentUnitResult.status}:${unitType}/${unitId}`; + } else if (currentUnitResult.event?.messages?.length) { + const lastMsg = + currentUnitResult.event.messages[ + currentUnitResult.event.messages.length - 1 + ]; + const msgStr = + typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg); + if (/error|fail|exception/i.test(msgStr)) { + lastEntry.error = msgStr.slice(0, 200); + } + } + } + if (currentUnitResult.status === "cancelled") { + clearDeferredCommitAfterCancelledUnit( + s, + ctx, + unitType, + unitId, + currentUnitResult.errorContext?.message ?? "cancelled", + ); + // Provider-error: try to reselect a ready provider and continue rather + // than stopping autonomous mode. Only stop if no ready provider exists. + if (currentUnitResult.errorContext?.category === "provider") { + await emitCancelledUnitEnd( + ic, + unitType, + unitId, + unitStartSeq, + currentUnitResult.errorContext, + ); + const failedProvider = + s.currentUnitModel?.provider ?? ctx.model?.provider; + const allModels = ctx.modelRegistry?.getAvailable?.() ?? []; + const fallback = allModels.find( + (m) => + m.provider !== failedProvider && + ctx.modelRegistry?.isProviderRequestReady?.(m.provider), + ); + if (fallback) { + const ok = await pi.setModel(fallback, { persist: false }); + if (ok) { + s.currentUnitModel = fallback; + ctx.ui.notify( + `Autonomous mode: provider ${failedProvider} not ready — retrying with ${fallback.provider}/${fallback.id}`, + "warning", + ); + return { action: "continue" }; + } + } + const msg = `Autonomous mode stopped: ${currentUnitResult.errorContext.message ?? `provider ${failedProvider} not ready`}. Check your login/API key.`; + ctx.ui.notify(msg, "error"); + debugLog("autoLoop", { + phase: "exit", + reason: "provider-pause", + isTransient: currentUnitResult.errorContext.isTransient, + }); + return { action: "break", reason: "provider-pause" }; + } + // Timeout category covers two distinct scenarios: + // 1. Session creation timeout (120s) — transient, scheduled resume with backoff + // 2. Unit hard timeout (30min+) — stuck agent, pause for manual review + // Structural errors (TypeError, is not a function) are NOT transient + // and must hard-stop to avoid infinite retry loops. + if ( + currentUnitResult.errorContext?.isTransient && + currentUnitResult.errorContext?.category === "timeout" + ) { + // Session-timeout cancellations are resumable pauses: pauseAuto below preserves the auto session + // instead of routing the cancelled unit into the hard-stop path. + const isSessionCreationTimeout = + currentUnitResult.errorContext.message?.includes( + "Session creation timed out", + ); + if (isSessionCreationTimeout) { + consecutiveSessionTimeouts += 1; + const baseRetryAfterMs = 30_000; + const retryAfterMs = + baseRetryAfterMs * 2 ** Math.max(0, consecutiveSessionTimeouts - 1); + const allowAutoResume = + consecutiveSessionTimeouts <= MAX_SESSION_TIMEOUT_AUTO_RESUMES; + if (!allowAutoResume) { + ctx.ui.notify( + `Session creation timed out ${consecutiveSessionTimeouts} consecutive times for ${unitType} ${unitId}. Pausing for manual review.`, + "warning", + ); + } + debugLog("autoLoop", { + phase: "session-timeout-pause", + unitType, + unitId, + consecutiveSessionTimeouts, + retryAfterMs, + allowAutoResume, + }); + const errorDetail = ` for ${unitType} ${unitId}`; + await pauseAutoForProviderError( + ctx.ui, + errorDetail, + () => deps.pauseAuto(ctx, pi), + { + isRateLimit: false, + isTransient: allowAutoResume, + retryAfterMs, + resume: allowAutoResume + ? () => { + void resumeAutoAfterProviderDelay(pi, ctx).catch((err) => { + const message = + getErrorMessage(err); + ctx.ui.notify( + `Session timeout recovery failed: ${message}`, + "error", + ); + }); + } + : undefined, + }, + ); + if (!allowAutoResume) { + resetConsecutiveSessionTimeouts(); + } + await emitCancelledUnitEnd( + ic, + unitType, + unitId, + unitStartSeq, + currentUnitResult.errorContext, + ); + return { action: "break", reason: "session-timeout" }; + } + // Unit hard timeout (30min+): pause without scheduled resume — stuck agent + ctx.ui.notify( + `Unit timed out for ${unitType} ${unitId} (supervision may have failed). Pausing autonomous mode.`, + "warning", + ); + debugLog("autoLoop", { + phase: "unit-hard-timeout-pause", + unitType, + unitId, + }); + await deps.pauseAuto(ctx, pi); + await emitCancelledUnitEnd( + ic, + unitType, + unitId, + unitStartSeq, + currentUnitResult.errorContext, + ); + return { action: "break", reason: "unit-hard-timeout" }; + } + // All other cancelled states (structural errors, non-transient failures): hard stop + if (s.currentUnit) { + await deps.closeoutUnit( + ctx, + s.basePath, + unitType, + unitId, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(unitType, unitId), + ); + } + await emitCancelledUnitEnd( + ic, + unitType, + unitId, + unitStartSeq, + currentUnitResult.errorContext, + ); + ctx.ui.notify( + `Session creation failed for ${unitType} ${unitId}: ${currentUnitResult.errorContext?.message ?? "unknown"}. Stopping autonomous mode.`, + "warning", + ); + await deps.stopAuto( + ctx, + pi, + `Session creation failed: ${currentUnitResult.errorContext?.message ?? "unknown"}`, + ); + debugLog("autoLoop", { phase: "exit", reason: "session-failed" }); + return { action: "break", reason: "session-failed" }; + } + // ── Immediate unit closeout (metrics, activity log, memory) ──────── + // Run right after runUnit() returns so telemetry is never lost to a + // crash between iterations. + // Guard: stopAuto() may have nulled s.currentUnit via s.reset() while + // this coroutine was suspended at `await runUnit(...)` (#2939). + if (s.currentUnit) { + // Reset session timeout counter — any successful unit clears the slate + resetConsecutiveSessionTimeouts(); + await deps.closeoutUnit( + ctx, + s.basePath, + unitType, + unitId, + s.currentUnit.startedAt, + deps.buildSnapshotOpts(unitType, unitId), + ); + } + // ── Zero tool-call guard (#1833, #2653) ────────────────────────── + // Any unit that completes with 0 tool calls made no real progress — + // likely context exhaustion where all tool calls errored out. Treat + // as failed so the unit is retried in a fresh context instead of + // silently passing through to artifact verification (which loops + // forever when the unit never produced its artifact). + { + const currentLedger = deps.getLedger(); + if (currentLedger?.units) { + const lastUnit = [...currentLedger.units] + .reverse() + .find( + (u) => + u.type === unitType && + u.id === unitId && + u.startedAt === s.currentUnit?.startedAt, + ); + if (lastUnit && lastUnit.toolCalls === 0) { + if ( + USER_DRIVEN_DEEP_UNITS.has(unitType) && + isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined) + ) { + debugLog("runUnitPhase", { + phase: "zero-tool-calls-awaiting-user-input", + unitType, + unitId, + }); + } else { + debugLog("runUnitPhase", { + phase: "zero-tool-calls", + unitType, + unitId, + warning: + "Unit completed with 0 tool calls — likely context exhaustion, marking as failed", + }); + ctx.ui.notify( + `${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`, + "warning", + ); + recordLearningOutcomeForUnit( + ic, + unitType, + unitId, + s.currentUnit?.startedAt, + { + succeeded: false, + verificationPassed: null, + }, + ); + // Fall through to next iteration where dispatch will re-derive + // and re-dispatch this unit. + return { + action: "next", + data: { + unitStartedAt: s.currentUnit?.startedAt, + requestDispatchedAt: currentUnitResult.requestDispatchedAt, + }, + }; + } + } + } + } + if (s.currentUnitRouting) { + deps.recordOutcome(unitType, s.currentUnitRouting.tier, true); + } + const skipArtifactVerification = shouldSkipArtifactVerification(unitType); + let artifactVerified; + if ( + USER_DRIVEN_DEEP_UNITS.has(unitType) && + isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined) + ) { + // Skip artifact verification — unit is paused waiting for user input + artifactVerified = false; + } else { + artifactVerified = + skipArtifactVerification || + verifyExpectedArtifact(unitType, unitId, s.basePath); + } + if (artifactVerified) { + s.unitDispatchCount.delete(`${unitType}/${unitId}`); + s.unitRecoveryCount.delete(`${unitType}/${unitId}`); + } + // Write phase handoff anchor after successful research/planning completion + const anchorPhases = new Set([ + "research-milestone", + "research-slice", + "plan-milestone", + "plan-slice", + ]); + if (artifactVerified && mid && anchorPhases.has(unitType)) { + try { + const { writePhaseAnchor } = await import("../phase-anchor.js"); + writePhaseAnchor(s.basePath, mid, { + phase: unitType, + milestoneId: mid, + generatedAt: new Date().toISOString(), + intent: `Completed ${unitType} for ${unitId}`, + decisions: [], + blockers: [], + nextSteps: [], + }); + } catch (err) { + /* non-fatal — anchor is advisory */ + logWarning( + "engine", + `phase anchor failed: ${getErrorMessage(err)}`, + ); + } + } + if (currentUnitResult.status !== "completed" || !artifactVerified) { + recordLearningOutcomeForUnit( + ic, + unitType, + unitId, + s.currentUnit?.startedAt, + { + succeeded: false, + verificationPassed: null, + }, + ); + } + { + // Pull cost/token data from the ledger entry that snapshotUnitMetrics + // already wrote so the unit-end event carries billing context. + const unitEndLedger = deps.getLedger(); + const unitEndEntry = unitEndLedger?.units + ? [...unitEndLedger.units] + .reverse() + .find( + (u) => + u.type === unitType && + u.id === unitId && + u.startedAt === s.currentUnit?.startedAt, + ) + : undefined; + deps.emitJournalEvent({ + ts: new Date().toISOString(), + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: "unit-end", + data: { + unitType, + unitId, + status: currentUnitResult.status, + artifactVerified, + ...(unitEndEntry + ? { + cost_usd: unitEndEntry.cost, + tokens: unitEndEntry.tokens.total, + tokens_input: unitEndEntry.tokens.input, + tokens_output: unitEndEntry.tokens.output, + } + : {}), + ...(currentUnitResult.errorContext + ? { errorContext: currentUnitResult.errorContext } + : {}), + }, + causedBy: { flowId: ic.flowId, seq: unitStartSeq }, + }); + if ( + currentUnitResult.status === "completed" || + currentUnitResult.status === "blocked" + ) { + const progressEvent = buildUokProgressEvent({ + eventType: + currentUnitResult.status === "completed" + ? "unit_completed" + : "unit_blocked", + unitType, + unitId, + role: "worker", + sessionId: ctx.sessionManager.getSessionId(), + traceId: ic.flowId, + data: { + status: currentUnitResult.status, + artifactVerified, + legacyEventType: "unit-end", + ...(unitEndEntry + ? { + cost_usd: unitEndEntry.cost, + tokens: unitEndEntry.tokens.total, + } + : {}), + }, + }); + deps.emitJournalEvent({ + ts: progressEvent.ts, + flowId: ic.flowId, + seq: ic.nextSeq(), + eventType: progressEvent.eventType, + data: progressEvent, + causedBy: { flowId: ic.flowId, seq: unitStartSeq }, + }); + } + } + { + const runtimeStatus = + currentUnitResult.status === "completed" + ? artifactVerified + ? "completed" + : "blocked" + : currentUnitResult.status === "error" + ? "failed" + : currentUnitResult.status; + const lineageStatus = + runtimeStatus === "completed" + ? "completed" + : runtimeStatus === "blocked" + ? "blocked" + : runtimeStatus === "cancelled" + ? "cancelled" + : "failed"; + writeUnitRuntimeRecord( + s.basePath, + unitType, + unitId, + s.currentUnit?.startedAt ?? Date.now(), + { + status: runtimeStatus, + lastProgressAt: Date.now(), + lastProgressKind: "unit-end", + lineageEvent: { + status: lineageStatus, + workerSessionId: ctx.sessionManager.getSessionId(), + note: `unit ended with ${currentUnitResult.status}`, + }, + }, + ); + } + { + const verdict = + currentUnitResult.status === "completed" + ? artifactVerified + ? "success" + : "blocked" + : currentUnitResult.status === "error" + ? "fail" + : currentUnitResult.status; + const ledger = deps.getLedger(); + const unitEntry = ledger?.units + ? [...ledger.units] + .reverse() + .find( + (u) => + u.type === unitType && + u.id === unitId && + u.startedAt === s.currentUnit?.startedAt, + ) + : undefined; + if (unitEntry) { + const costStr = deps.formatCost(unitEntry.cost); + ctx.ui.notify( + `[unit] ${unitType} ${unitId} ended -> ${verdict} (${costStr}, ${unitEntry.tokens.total} tokens, ${unitEntry.toolCalls} tool calls)`, + "info", + ); + } else { + ctx.ui.notify(`[unit] ${unitType} ${unitId} ended -> ${verdict}`, "info"); + } + const toolSummary = formatToolCallSummary(); + if (toolSummary) { + ctx.ui.notify(`[mcp] ${toolSummary}`, "info"); + } + } + // ── Safety harness: checkpoint cleanup or rollback ── + if (s.checkpointSha) { + if (currentUnitResult.status === "error" && safetyConfig.auto_rollback) { + const rolled = rollbackToCheckpoint(s.basePath, unitId, s.checkpointSha); + if (rolled) { + ctx.ui.notify( + `Rolled back to pre-unit checkpoint for ${unitId}`, + "info", + ); + debugLog("runUnitPhase", { phase: "checkpoint-rollback", unitId }); + } + } else if (currentUnitResult.status === "error") { + ctx.ui.notify( + `Unit ${unitId} failed. Pre-unit checkpoint available at ${s.checkpointSha.slice(0, 8)}`, + "warning", + ); + } else { + // Success — clean up checkpoint ref + cleanupCheckpoint(s.basePath, unitId); + debugLog("runUnitPhase", { phase: "checkpoint-cleaned", unitId }); + } + s.checkpointSha = null; + } + s.preUnitDirtyFiles = []; + return { + action: "next", + data: { + unitStartedAt: s.currentUnit?.startedAt, + requestDispatchedAt: currentUnitResult.requestDispatchedAt, + }, + }; +} + +export const resetSessionTimeoutState = resetConsecutiveSessionTimeouts; diff --git a/src/resources/extensions/sf/auto/phases.js b/src/resources/extensions/sf/auto/phases.js index f86f60a80..bbdbdc8b8 100644 --- a/src/resources/extensions/sf/auto/phases.js +++ b/src/resources/extensions/sf/auto/phases.js @@ -1,3538 +1,12 @@ /** - * auto/phases.ts — Pipeline phases for the auto-loop. + * auto/phases.js — Barrel re-export for pipeline phases. * - * Contains: runPreDispatch, runDispatch, runGuards, runUnitPhase, runFinalize, - * plus internal helpers generateMilestoneReport and closeoutAndStop. - * - * Imports from: auto/types, auto/detect-stuck, auto/run-unit, auto/loop-deps + * Each phase lives in its own module; this file preserves the original + * import surface for loop.js and other consumers. */ -import { cpSync, existsSync, readdirSync } from "node:fs"; -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"; - -// ─── Session timeout scheduled resume state ──────────────────────────────────────── -let consecutiveSessionTimeouts = 0; -const MAX_SESSION_TIMEOUT_AUTO_RESUMES = 3; -function resetConsecutiveSessionTimeouts() { - consecutiveSessionTimeouts = 0; -} - -/** - * Decide whether the UOK diagnostics verdict may continue into dispatch. - * - * Purpose: turn durable UOK self-diagnostics into autonomous control, so SF - * pauses on split-brain/runtime corruption before spending another LLM turn. - * - * Consumer: runDispatch before it starts the next autonomous unit. - */ -export function assessUokDiagnosticsDispatchGate(diagnostics) { - if (!diagnostics) return { proceed: true }; - const blockingIssue = diagnostics.issues?.find( - (issue) => issue?.severity === "error", - ); - if (diagnostics.verdict !== "degraded" && !blockingIssue) { - return { proceed: true }; - } - const issueCode = blockingIssue?.code ?? diagnostics.issues?.[0]?.code; - const reportPath = - diagnostics.reportPath ?? ".sf/runtime/uok-diagnostics.json"; - const reason = [ - `UOK diagnostics blocked dispatch: ${diagnostics.verdict}/${diagnostics.classification ?? "unknown"}`, - issueCode ? `issue ${issueCode}` : "", - `evidence ${reportPath}`, - ] - .filter(Boolean) - .join(" · "); - return { - proceed: false, - reason, - issueCode, - reportPath, - }; -} -// ─── generateMilestoneReport ────────────────────────────────────────────────── -/** - * Resolve the base path for milestone reports. - * Prefers originalBasePath (project root) over basePath (which may be a worktree). - * Exported for testing as _resolveReportBasePath. - */ -export function _resolveReportBasePath(s) { - return s.originalBasePath || s.basePath; -} -/** - * Fire the product-audit for a milestone after successful merge. - * Uses s.productAuditMilestoneId as a guard to ensure the audit fires exactly - * once per milestone (mergeAndExit can be called multiple times for the same - * milestone at different transition points). - * - * The audit is fired with a "no-gaps" placeholder verdict. Re-run - * `/product-audit` manually for full LLM-powered gap analysis. - */ -async function maybeFireProductAudit(s, ctx) { - const mid = s.currentMilestoneId; - if (!mid) return; - // Guard: only fire once per milestone - if (s.productAuditMilestoneId === mid) return; - s.productAuditMilestoneId = mid; - const params = { - milestoneId: mid, - verdict: "no-gaps", - summary: - "Auto-fired placeholder audit at milestone merge. Re-run `/product-audit` for full LLM-powered gap analysis.", - gaps: [], - }; - const result = await handleProductAudit(params, s.basePath); - if ("error" in result) { - logWarning("engine", "Product audit auto-fire failed", { - milestone: mid, - error: result.error, - }); - ctx.ui.notify( - `Product audit for ${mid} auto-fired but may need manual refresh: ${result.error}`, - "warning", - ); - } else { - debugLog("autoLoop", { - phase: "product-audit-fired", - milestone: mid, - jsonPath: result.jsonPath, - }); - } -} -function clearDeferredCommitAfterCancelledUnit( - s, - ctx, - unitType, - unitId, - reason, -) { - if (!s.stagedPendingCommit && !s.pendingCommitTaskContext) return; - s.stagedPendingCommit = false; - s.pendingCommitTaskContext = null; - debugLog("autoLoop", { - phase: "cancelled-unit-deferred-commit-cleared", - unitType, - unitId, - reason, - }); - ctx.ui.notify( - `Cancelled ${unitType} ${unitId}; staged changes were preserved for recovery and not auto-committed.`, - "warning", - ); -} -export function requiresHumanProductionMutationApproval(text) { - const normalized = text.toLowerCase(); - const mentionsProduction = - /\b(production|prod|live|hetzner)\b/.test(normalized) || - normalized.includes("centralcloud.com"); - if (!mentionsProduction) return false; - const mentionsUnifiedFailover = - normalized.includes("unified_failover") || - normalized.includes("unified-failover") || - normalized.includes("/action/unified"); - if (!mentionsUnifiedFailover) return false; - return /\b(post|enqueue|create|insert|command row|pending command)\b/.test( - normalized, - ); -} -/** - * Resolve the authoritative project base for dispatch guards. - * Prior-milestone completion lives at the project root, even when the active - * unit is running inside an auto worktree. - */ -export function _resolveDispatchGuardBasePath(s) { - return resolveWorktreeProjectRoot(s.basePath, s.originalBasePath); -} -const PLANNING_FLOW_GATE_PHASES = new Set([ - "executing", - "summarizing", - "validating-milestone", - "completing-milestone", -]); -function shouldRunPlanningFlowGate(phase) { - return PLANNING_FLOW_GATE_PHASES.has(phase); -} -function shouldSkipArtifactVerification(unitType) { - return unitType.startsWith("hook/") || unitType === "custom-step"; -} -function recordLearningOutcomeForUnit( - ic, - unitType, - unitId, - startedAt, - outcome, -) { - if (!startedAt) return; - const unitModel = ic.s.currentUnitModel; - const unitEntry = ic.deps.getLedger()?.units - ? [...(ic.deps.getLedger()?.units ?? [])] - .reverse() - .find( - (u) => - u.type === unitType && u.id === unitId && u.startedAt === startedAt, - ) - : undefined; - const provider = unitModel?.provider ?? null; - const modelId = unitModel?.id ?? unitEntry?.model ?? null; - if (!provider || !modelId || !unitEntry) return; - recordLearnedOutcome({ - modelId, - provider, - unitType, - unitId, - succeeded: outcome.succeeded, - retries: outcome.retries ?? 0, - escalated: outcome.escalated ?? false, - verification_passed: outcome.verificationPassed, - blocker_discovered: outcome.blockerDiscovered ?? false, - duration_ms: Math.max(0, unitEntry.finishedAt - unitEntry.startedAt), - tokens_total: unitEntry.tokens.total, - cost_usd: unitEntry.cost, - recorded_at: unitEntry.startedAt, - }); -} -/** - * Generate and write an HTML milestone report snapshot. - * Extracted from the milestone-transition block in autoLoop. - */ -async function generateMilestoneReport(s, ctx, milestoneId) { - const { loadVisualizerData } = await importExtensionModule( - import.meta.url, - "../visualizer-data.js", - ); - const { generateHtmlReport } = await importExtensionModule( - import.meta.url, - "../export-html.js", - ); - const { writeReportSnapshot } = await importExtensionModule( - import.meta.url, - "../reports.js", - ); - const { basename } = await import("node:path"); - const reportBasePath = _resolveReportBasePath(s); - const snapData = await loadVisualizerData(reportBasePath); - const completedMs = snapData.milestones.find((m) => m.id === milestoneId); - const msTitle = completedMs?.title ?? milestoneId; - const sfVersion = process.env.SF_VERSION ?? "0.0.0"; - const projName = basename(reportBasePath); - const doneSlices = snapData.milestones.reduce( - (acc, m) => acc + m.slices.filter((sl) => sl.done).length, - 0, - ); - const totalSlices = snapData.milestones.reduce( - (acc, m) => acc + m.slices.length, - 0, - ); - const outPath = writeReportSnapshot({ - basePath: reportBasePath, - html: generateHtmlReport(snapData, { - projectName: projName, - projectPath: reportBasePath, - sfVersion, - milestoneId, - indexRelPath: "index.html", - }), - milestoneId, - milestoneTitle: msTitle, - kind: "milestone", - projectName: projName, - projectPath: reportBasePath, - sfVersion, - totalCost: snapData.totals?.cost ?? 0, - totalTokens: snapData.totals?.tokens.total ?? 0, - totalDuration: snapData.totals?.duration ?? 0, - doneSlices, - totalSlices, - doneMilestones: snapData.milestones.filter((m) => m.status === "complete") - .length, - totalMilestones: snapData.milestones.length, - phase: snapData.phase, - }); - ctx.ui.notify( - `Report saved: .sf/reports/${basename(outPath)} — open index.html to browse progression.`, - "info", - ); -} -// ─── closeoutAndStop ────────────────────────────────────────────────────────── -/** - * If a unit is in-flight, close it out, then stop autonomous mode. - * Extracted from ~4 identical if-closeout-then-stop sequences in autoLoop. - */ -async function closeoutAndStop(ctx, pi, s, deps, reason) { - 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), - ); - s.currentUnit = null; - } - await deps.stopAuto(ctx, pi, reason); -} -async function emitCancelledUnitEnd( - ic, - unitType, - unitId, - unitStartSeq, - errorContext, -) { - ic.deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "unit-end", - data: { - unitType, - unitId, - status: "cancelled", - artifactVerified: false, - ...(errorContext ? { errorContext } : {}), - }, - causedBy: { flowId: ic.flowId, seq: unitStartSeq }, - }); -} -// ─── 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 } }; -} -// ─── runDispatch ────────────────────────────────────────────────────────────── -/** - * Phase 3: Dispatch resolution — resolve next unit, stuck detection, pre-dispatch hooks. - * Returns break/continue to control the loop, or next with IterationData on success. - */ -export async function runDispatch(ic, preData, loopState) { - const { ctx, pi, s, deps, prefs } = ic; - const { state, mid, midTitle } = preData; - const STUCK_WINDOW_SIZE = 6; - debugLog("autoLoop", { phase: "dispatch-resolve", iteration: ic.iteration }); - const dispatchResult = await deps.resolveDispatch({ - basePath: s.basePath, - mid, - midTitle, - state, - prefs, - session: s, - runControl: deps.uokRunControl, - permissionProfile: deps.uokPermissionProfile, - }); - if (dispatchResult.action === "stop") { - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "dispatch-stop", - rule: dispatchResult.matchedRule, - data: { reason: dispatchResult.reason }, - }); - // Warning-level stops are recoverable human checkpoints (e.g. UAT verdict - // gate) — pause instead of hard-stopping so the session is resumable with - // `/autonomous`. Error/info-level stops remain hard stops for infrastructure - // failures and terminal conditions respectively. - // See: https://github.com/singularity-forge/sf-run/issues/2474 - if (dispatchResult.level === "warning") { - ctx.ui.notify(dispatchResult.reason, "warning"); - await deps.pauseAuto(ctx, pi); - } else { - await closeoutAndStop(ctx, pi, s, deps, dispatchResult.reason); - } - debugLog("autoLoop", { phase: "exit", reason: "dispatch-stop" }); - return { action: "break", reason: "dispatch-stop" }; - } - if (dispatchResult.action !== "dispatch") { - // Non-dispatch action (e.g. "skip") — re-derive state - await new Promise((r) => setImmediate(r)); - return { action: "continue" }; - } - try { - const diagnostics = deps.writeUokDiagnostics?.(s.basePath, { - expectedNext: dispatchResult, - }); - const gate = assessUokDiagnosticsDispatchGate(diagnostics); - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "uok-diagnostics-dispatch-gate", - data: { - verdict: diagnostics?.verdict ?? "unknown", - classification: diagnostics?.classification ?? "unknown", - proceed: gate.proceed, - issueCode: gate.issueCode, - reportPath: gate.reportPath ?? diagnostics?.reportPath, - }, - }); - if (!gate.proceed) { - await runPreDispatchGate({ - gateId: "uok-diagnostics-dispatch-gate", - gateType: "execution", - outcome: "manual-attention", - failureClass: "manual-attention", - rationale: "uok diagnostics blocked dispatch", - findings: gate.reason, - milestoneId: mid, - }); - ctx.ui.notify(gate.reason, "error"); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { - phase: "exit", - reason: "uok-diagnostics-pause", - issueCode: gate.issueCode, - }); - return { action: "break", reason: "uok-diagnostics-pause" }; - } - } catch (err) { - logWarning("engine", "UOK diagnostics dispatch gate failed open", { - error: getErrorMessage(err), - }); - } - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "dispatch-match", - rule: dispatchResult.matchedRule, - data: { unitType: dispatchResult.unitType, unitId: dispatchResult.unitId }, - }); - let unitType = dispatchResult.unitType; - const unitId = dispatchResult.unitId; - let prompt = dispatchResult.prompt; - const pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false; - // ── Reasoning assist injection ────────────────────────────────────── - if (isReasoningAssistEnabled(unitType)) { - try { - const reasoningPrompt = await buildReasoningAssistPrompt( - unitType, - unitId, - s.basePath, - ctx, - ); - if (reasoningPrompt) { - // Fire-and-forget: reasoning assist is best-effort, non-blocking - // The actual LLM call would happen here in a full implementation. - // For now, we prepare the prompt for injection. - debugLog("autoLoop", { - phase: "reasoning-assist", - unitType, - unitId, - promptLength: reasoningPrompt.length, - }); - // Use reasoning prompt context as guidance until a fast model is wired in. - // The injected guidance provides unit-level context hints to the primary model. - prompt = injectReasoningGuidance(prompt, reasoningPrompt); - } - } catch (err) { - logWarning("engine", "Reasoning assist failed open", { - error: getErrorMessage(err), - unitType, - unitId, - }); - } - } - // ── Sliding-window stuck detection with graduated recovery ── - const derivedKey = `${unitType}/${unitId}`; - const hasTransientTaskCompleteFailure = - unitType === "execute-task" && !!s.pendingTaskCompleteFailures?.has(unitId); - if (!s.pendingVerificationRetry && !hasTransientTaskCompleteFailure) { - loopState.recentUnits.push({ key: derivedKey }); - if (loopState.recentUnits.length > STUCK_WINDOW_SIZE) - loopState.recentUnits.shift(); - const stuckSignal = detectStuck(loopState.recentUnits); - if (stuckSignal) { - debugLog("autoLoop", { - phase: "stuck-check", - unitType, - unitId, - reason: stuckSignal.reason, - recoveryAttempts: loopState.stuckRecoveryAttempts, - }); - // Graduated stuck recovery — up to 5 total attempts before hard stop. - // Attempt 0: cache invalidation + retry - // Attempts 1–4: rethink + retry - // Attempt 5 (exhausted): hard stop - loopState.stuckRecoveryAttempts++; - const attempt = loopState.stuckRecoveryAttempts; - if (attempt === 1) { - // Attempt 1: verify artifact + cache invalidation + retry - const artifactExists = verifyExpectedArtifact( - unitType, - unitId, - s.basePath, - ); - if (artifactExists) { - debugLog("autoLoop", { - phase: "stuck-recovery", - level: 1, - action: "artifact-found", - }); - ctx.ui.notify( - `Stuck recovery: artifact for ${unitType} ${unitId} found on disk. Invalidating caches.`, - "info", - ); - deps.invalidateAllCaches(); - return { action: "continue" }; - } - ctx.ui.notify( - `Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Invalidating caches and retrying.`, - "warning", - ); - deps.invalidateAllCaches(); - return { action: "continue" }; - } else if (attempt <= 5) { - // Attempts 2–5: rethink + diagnostic + retry - const stuckDiag = diagnoseExpectedArtifact( - unitType, - unitId, - s.basePath, - ); - const stuckRemediation = buildLoopRemediationSteps( - unitType, - unitId, - s.basePath, - ); - const diagnostic = deps.getDeepDiagnostic(s.basePath); - const cappedDiag = - (diagnostic?.length ?? 0) > MAX_RECOVERY_CHARS - ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...diagnostic truncated]" - : (diagnostic ?? null); - s.pendingRethinkAttempt = JSON.stringify({ - attempt, - reason: stuckSignal.reason, - diagnostic: cappedDiag, - stuckDiag, - remediation: stuckRemediation, - unitType, - unitId, - }); - const rt = - attempt === 5 - ? "**FINAL STUCK ATTEMPT — 5 of 5.** " - : `**STUCK RECOVERY ATTEMPT ${attempt - 1} of 4.** `; - ctx.ui.notify( - `${rt}Stuck on ${unitType} ${unitId} (${stuckSignal.reason}). Injecting diagnostic and retrying.`, - "warning", - ); - return { action: "continue" }; - } else { - // Attempt 6+: genuinely exhausted — hard stop - debugLog("autoLoop", { - phase: "stuck-detected", - unitType, - unitId, - reason: stuckSignal.reason, - }); - const stuckDiag = diagnoseExpectedArtifact( - unitType, - unitId, - s.basePath, - ); - const stuckRemediation = buildLoopRemediationSteps( - unitType, - unitId, - s.basePath, - ); - const stuckParts = [ - `Stuck on ${unitType} ${unitId} — ${stuckSignal.reason}.`, - ]; - if (stuckDiag) stuckParts.push(`Expected: ${stuckDiag}`); - if (stuckRemediation) - stuckParts.push(`To recover:\n${stuckRemediation}`); - ctx.ui.notify(stuckParts.join(" "), "error"); - await deps.stopAuto(ctx, pi, `Stuck: ${stuckSignal.reason}`); - return { action: "break", reason: "stuck-detected" }; - } - } else { - // Progress detected — reset recovery counter - if (loopState.stuckRecoveryAttempts > 0) { - debugLog("autoLoop", { - phase: "stuck-counter-reset", - from: - loopState.recentUnits[loopState.recentUnits.length - 2]?.key ?? "", - to: derivedKey, - }); - loopState.stuckRecoveryAttempts = 0; - } - } - } - // Pre-dispatch hooks - const preDispatchResult = deps.runPreDispatchHooks( - unitType, - unitId, - prompt, - s.basePath, - ); - if (preDispatchResult.firedHooks.length > 0) { - ctx.ui.notify( - `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`, - "info", - ); - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "pre-dispatch-hook", - data: { - firedHooks: preDispatchResult.firedHooks, - action: preDispatchResult.action, - }, - }); - } - if (preDispatchResult.action === "skip") { - ctx.ui.notify( - `Skipping ${unitType} ${unitId} (pre-dispatch hook).`, - "info", - ); - await new Promise((r) => setImmediate(r)); - return { action: "continue" }; - } - if (preDispatchResult.action === "replace") { - prompt = preDispatchResult.prompt ?? prompt; - if (preDispatchResult.unitType) unitType = preDispatchResult.unitType; - } else if (preDispatchResult.prompt) { - prompt = preDispatchResult.prompt; - } - const guardBasePath = _resolveDispatchGuardBasePath(s); - const priorSliceBlocker = deps.getPriorSliceCompletionBlocker( - guardBasePath, - deps.getMainBranch(guardBasePath), - unitType, - unitId, - ); - if (priorSliceBlocker) { - await deps.stopAuto(ctx, pi, priorSliceBlocker); - debugLog("autoLoop", { phase: "exit", reason: "prior-slice-blocker" }); - return { action: "break", reason: "prior-slice-blocker" }; - } - return { - action: "next", - data: { - unitType, - unitId, - prompt, - finalPrompt: prompt, - pauseAfterUatDispatch, - state, - mid, - midTitle, - isRetry: false, - previousTier: undefined, - hookModelOverride: preDispatchResult.model, - }, - }; -} -// ─── runGuards ──────────────────────────────────────────────────────────────── -/** - * Phase 2: Guards — stop directives, budget ceiling, context window, secrets re-check. - * Returns break to exit the loop, or next to proceed to dispatch. - */ -export async function runGuards(ic, mid, unitType, unitId, sliceId) { - const { ctx, pi, s, deps, prefs } = ic; - // ── Stop/Backtrack directive guard (#3487) ── - // Check for unexecuted stop or backtrack captures BEFORE dispatching any unit. - // This ensures user "halt" directives are honored immediately. - // IMPORTANT: Fail-closed — any exception during stop handling still breaks the loop - // to ensure user halt intent is never silently dropped. - try { - const { loadStopCaptures, markCaptureExecuted } = await import( - "../captures.js" - ); - const stopCaptures = loadStopCaptures(s.basePath); - if (stopCaptures.length > 0) { - const first = stopCaptures[0]; - const isBacktrack = first.classification === "backtrack"; - const label = isBacktrack - ? `Backtrack directive: ${first.text}` - : `Stop directive: ${first.text}`; - ctx.ui.notify(label, "warning"); - deps.sendDesktopNotification( - "SF", - label, - "warning", - "stop-directive", - basename(s.originalBasePath || s.basePath), - ); - // Pause first — ensures autonomous mode stops even if later steps fail - await deps.pauseAuto(ctx, pi); - // For backtrack captures, write the backtrack trigger after pausing - if (isBacktrack) { - try { - const { executeBacktrack } = await import("../triage-resolution.js"); - executeBacktrack(s.basePath, mid, first); - } catch (e) { - debugLog("guards", { - phase: "backtrack-execution-error", - error: String(e), - }); - } - } - // Mark captures as executed only after successful pause/transition - for (const cap of stopCaptures) { - markCaptureExecuted(s.basePath, cap.id); - } - debugLog("autoLoop", { - phase: "exit", - reason: isBacktrack ? "user-backtrack" : "user-stop", - }); - return { - action: "break", - reason: isBacktrack ? "user-backtrack" : "user-stop", - }; - } - } catch (e) { - // Fail-closed: if anything in the stop guard throws, break the loop - // rather than silently continuing and dropping user halt intent - debugLog("guards", { phase: "stop-guard-error", error: String(e) }); - return { action: "break", reason: "stop-guard-error" }; - } - // Production mutation guard — headless autonomous must not enqueue live failover - // commands without a human-provided safe target and cleanup plan. - try { - if (isDbAvailable()) { - const state = await deps.deriveState(s.basePath); - const activeTask = state.activeTask; - const activeSlice = state.activeSlice; - const activeMilestone = state.activeMilestone; - if (activeMilestone?.id && activeSlice?.id && activeTask?.id) { - const task = getTask(activeMilestone.id, activeSlice.id, activeTask.id); - if (task) { - const taskText = [ - task.title, - task.description, - task.verify, - ...task.inputs, - ...task.expected_output, - ].join("\n"); - if (requiresHumanProductionMutationApproval(taskText)) { - const approvalUnit = { - milestoneId: activeMilestone.id, - sliceId: activeSlice.id, - taskId: activeTask.id, - taskTitle: task.title, - taskText, - }; - const approvalBasePath = s.originalBasePath || s.basePath; - const approval = readProductionMutationApprovalStatus( - approvalBasePath, - approvalUnit, - ); - if (approval.approved) { - ctx.ui.notify( - `Production mutation approval accepted for ${approvalUnit.milestoneId}/${approvalUnit.sliceId}/${approvalUnit.taskId}: ${approval.path}`, - "warning", - ); - } else { - const llmApproval = approveProductionMutationWithLlmPolicy( - approvalBasePath, - approvalUnit, - ); - if (llmApproval.approved) { - ctx.ui.notify( - `Production mutation LLM approval accepted for pending-command-only smoke test ${approvalUnit.milestoneId}/${approvalUnit.sliceId}/${approvalUnit.taskId}: ${llmApproval.path}`, - "warning", - ); - } else { - const template = ensureProductionMutationApprovalTemplate( - approvalBasePath, - approvalUnit, - ); - const blockerReasons = [ - ...approval.reasons, - ...llmApproval.reasons.map((reason) => `LLM: ${reason}`), - ]; - const reasons = blockerReasons.length - ? ` Missing/invalid fields: ${blockerReasons.join("; ")}.` - : ""; - const msg = - `Production mutation guard: ${activeMilestone.id}/${activeSlice.id}/${activeTask.id} asks to POST unified failover against production. ` + - `${template.created ? "Created" : "Reusing"} approval gate at ${template.path}. ` + - `Fill it with an explicit safe server/VM target, cleanup/rollback path, and human or LLM approval, then rerun sf headless autonomous.${reasons}`; - ctx.ui.notify(msg, "error"); - deps.sendDesktopNotification( - "SF", - "Production mutation guard paused autonomous mode", - "warning", - "safety", - basename(s.originalBasePath || s.basePath), - ); - await deps.pauseAuto(ctx, pi); - return { - action: "break", - reason: "production-mutation-guard", - }; - } - } - } - } - } - } - } catch (e) { - debugLog("guards", { - phase: "production-mutation-guard-error", - error: String(e), - }); - } - // Budget ceiling guard - const budgetCeiling = prefs?.budget_ceiling; - if (budgetCeiling !== undefined && budgetCeiling > 0) { - const currentLedger = deps.getLedger(); - // In parallel worker mode, only count cost from the current autonomous mode session - // to avoid hitting the ceiling due to historical project-wide spend (#2184). - let costUnits = currentLedger?.units; - if ( - process.env.SF_PARALLEL_WORKER && - s.autoStartTime && - Array.isArray(costUnits) - ) { - const sessionStartISO = new Date(s.autoStartTime).toISOString(); - costUnits = costUnits.filter( - (u) => u.startedAt != null && u.startedAt >= sessionStartISO, - ); - } - const totalCost = costUnits ? deps.getProjectTotals(costUnits).cost : 0; - const budgetPct = totalCost / budgetCeiling; - const budgetAlertLevel = deps.getBudgetAlertLevel(budgetPct); - const newBudgetAlertLevel = deps.getNewBudgetAlertLevel( - s.lastBudgetAlertLevel, - budgetPct, - ); - const enforcement = prefs?.budget_enforcement ?? "pause"; - const budgetEnforcementAction = deps.getBudgetEnforcementAction( - enforcement, - budgetPct, - ); - // Data-driven threshold check — loop descending, fire first match - const threshold = BUDGET_THRESHOLDS.find( - (t) => newBudgetAlertLevel >= t.pct, - ); - if (threshold) { - s.lastBudgetAlertLevel = newBudgetAlertLevel; - if (threshold.pct === 100 && budgetEnforcementAction !== "none") { - // 100% — special enforcement logic (halt/pause/warn) - const msg = `Budget ceiling ${deps.formatCost(budgetCeiling)} reached (spent ${deps.formatCost(totalCost)}).`; - if (budgetEnforcementAction === "halt") { - deps.sendDesktopNotification( - "SF", - msg, - "error", - "budget", - basename(s.originalBasePath || s.basePath), - ); - await deps.stopAuto(ctx, pi, "Budget ceiling reached"); - debugLog("autoLoop", { phase: "exit", reason: "budget-halt" }); - return { action: "break", reason: "budget-halt" }; - } - if (budgetEnforcementAction === "pause") { - ctx.ui.notify( - `${msg} Pausing autonomous mode — /autonomous to override and continue.`, - "warning", - ); - deps.sendDesktopNotification( - "SF", - msg, - "warning", - "budget", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent(prefs, msg, "warning"); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "budget-pause" }); - return { action: "break", reason: "budget-pause" }; - } - ctx.ui.notify(`${msg} Continuing (enforcement: warn).`, "warning"); - deps.sendDesktopNotification( - "SF", - msg, - "warning", - "budget", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent(prefs, msg, "warning"); - } else if (threshold.pct < 100) { - // Sub-100% — simple notification - const msg = `${threshold.label}: ${deps.formatCost(totalCost)} / ${deps.formatCost(budgetCeiling)}`; - ctx.ui.notify(msg, threshold.notifyLevel); - deps.sendDesktopNotification( - "SF", - msg, - threshold.notifyLevel, - "budget", - basename(s.originalBasePath || s.basePath), - ); - deps.logCmuxEvent(prefs, msg, threshold.cmuxLevel); - } - } else if (budgetAlertLevel === 0) { - s.lastBudgetAlertLevel = 0; - } - } else { - s.lastBudgetAlertLevel = 0; - } - // ── UOK Plan-gate ────────────────────────────────────────────────────────── - // Structural validation before the first execute-task unit of a slice: - // confirms the plan files exist and the slice has ≥1 task. - // FailureClass "input" → 0 retries (broken plan needs human fix, not - // an LLM retry). Only fires when uok.gates.enabled is true. - const uokFlagsGuards = resolveUokFlags(prefs); - if (uokFlagsGuards.gates && unitType === "execute-task" && mid && sliceId) { - const taskCounts = getSliceTaskCounts(mid, sliceId); - const isFirstTaskForSlice = taskCounts.done === 0; - if (isFirstTaskForSlice) { - let planGateOutcome = "pass"; - let planGateRationale = ""; - const milestoneSlices = getMilestoneSlices(mid); - if (!milestoneSlices || milestoneSlices.length === 0) { - planGateOutcome = "fail"; - planGateRationale = `Milestone ${mid} has no slices in DB (not yet planned)`; - } else if (taskCounts.total < 1) { - planGateOutcome = "fail"; - planGateRationale = `Slice ${sliceId} has no tasks defined`; - } - const planGateRunner = new UokGateRunner(); - planGateRunner.register({ - id: "plan-gate", - type: "policy", - execute: async () => ({ - outcome: planGateOutcome, - failureClass: planGateOutcome === "pass" ? "none" : "input", - rationale: planGateRationale || "Plan files verified", - }), - }); - const planGateResult = await planGateRunner.run("plan-gate", { - basePath: s.basePath, - traceId: `guard:${ic.flowId}`, - turnId: `iter-${ic.iteration}`, - milestoneId: mid, - sliceId, - unitType, - unitId, - }); - if (planGateResult.outcome !== "pass") { - ctx.ui.notify( - `Plan gate failed: ${planGateResult.rationale ?? "invalid plan"}`, - "warning", - ); - await deps.pauseAuto(ctx, pi); - return { action: "break", reason: "plan-gate-failed" }; - } - } - } - // Context window guard - const contextThreshold = prefs?.context_pause_threshold ?? 0; - if (contextThreshold > 0 && s.cmdCtx) { - const contextUsage = s.cmdCtx.getContextUsage(); - if ( - contextUsage && - contextUsage.percent !== null && - contextUsage.percent >= contextThreshold - ) { - const msg = `Context window at ${contextUsage.percent}% (threshold: ${contextThreshold}%). Pausing to prevent truncated output.`; - ctx.ui.notify( - `${msg} Run /autonomous to continue (will start fresh session).`, - "warning", - ); - deps.sendDesktopNotification( - "SF", - `Context ${contextUsage.percent}% — paused`, - "warning", - "attention", - basename(s.originalBasePath || s.basePath), - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "context-window" }); - return { action: "break", reason: "context-window" }; - } - } - // Secrets re-check gate - try { - const manifestStatus = await deps.getManifestStatus( - s.basePath, - mid, - s.originalBasePath, - ); - if (manifestStatus && manifestStatus.pending.length > 0) { - const result = await deps.collectSecretsFromManifest( - s.basePath, - mid, - ctx, - ); - if ( - result && - result.applied && - result.skipped && - result.existingSkipped - ) { - ctx.ui.notify( - `Secrets collected: ${result.applied.length} applied, ${result.skipped.length} skipped, ${result.existingSkipped.length} already set.`, - "info", - ); - } else { - ctx.ui.notify("Secrets collection skipped.", "info"); - } - } - } catch (err) { - ctx.ui.notify( - `Secrets collection error: ${getErrorMessage(err)}. Continuing with next task.`, - "warning", - ); - } - return { action: "next", data: undefined }; -} -// ─── runUnitPhase ───────────────────────────────────────────────────────────── -/** - * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify. - * Returns break or next with unitStartedAt for downstream phases. - */ -export async function runUnitPhase(ic, iterData, loopState, sidecarItem) { - const { ctx, pi, s, deps, prefs } = ic; - const { unitType, unitId, prompt, state, mid } = iterData; - debugLog("autoLoop", { - phase: "unit-execution", - iteration: ic.iteration, - unitType, - unitId, - }); - // ── Worktree health check (#1833, #1843) ──────────────────────────── - // ... - if ( - s.basePath && - !s.basePath.startsWith("/mock/") && - unitType === "execute-task" - ) { - const gitMarker = join(s.basePath, ".git"); - const hasGit = deps.existsSync(gitMarker); - if (!hasGit) { - const msg = `Worktree health check failed: ${s.basePath} has no .git — refusing to dispatch ${unitType} ${unitId}`; - debugLog("runUnitPhase", { - phase: "worktree-health-fail", - basePath: s.basePath, - hasGit, - }); - ctx.ui.notify(msg, "error"); - await deps.stopAuto(ctx, pi, msg); - return { action: "break", reason: "worktree-invalid" }; - } - const hasProjectFile = PROJECT_FILES.some((f) => - deps.existsSync(join(s.basePath, f)), - ); - const hasSrcDir = deps.existsSync(join(s.basePath, "src")); - // Xcode bundles have project-specific names (*.xcodeproj, *.xcworkspace) - // that cannot be matched by exact filename — scan the directory by suffix. - let hasXcodeBundle = false; - try { - const entries = deps.existsSync(s.basePath) - ? readdirSync(s.basePath) - : []; - hasXcodeBundle = entries.some( - (e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace"), - ); - } catch (err) { - debugLog("runUnitPhase", { - phase: "xcode-bundle-scan-failed", - basePath: s.basePath, - error: String(err), - }); - } - // Monorepo support (#2347): if no project files in the worktree directory, - // walk parent directories up to the filesystem root. In monorepos, - // package.json / Cargo.toml etc. live in a parent directory. - let hasProjectFileInParent = false; - if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle) { - let checkDir = dirname(s.basePath); - const { root } = parsePath(checkDir); - while (checkDir !== root) { - // Stop at git repository boundary — ancestors above the repo root - // (e.g. ~ or /usr/local) may contain unrelated project files. - if (deps.existsSync(join(checkDir, ".git"))) break; - if (PROJECT_FILES.some((f) => deps.existsSync(join(checkDir, f)))) { - hasProjectFileInParent = true; - break; - } - checkDir = dirname(checkDir); - } - } - if ( - !hasProjectFile && - !hasSrcDir && - !hasXcodeBundle && - !hasProjectFileInParent - ) { - // Greenfield projects won't have project files yet — the first task creates them. - // Log a warning but allow execution to proceed. The .git check above is sufficient - // to ensure we're in a valid working directory. - debugLog("runUnitPhase", { - phase: "worktree-health-warn-greenfield", - basePath: s.basePath, - hasProjectFile, - hasSrcDir, - hasXcodeBundle, - }); - ctx.ui.notify( - `Warning: ${s.basePath} has no recognized project files — proceeding as greenfield project`, - "warning", - ); - } - } - // Detect retry and capture previous tier for escalation - const isPausedUnitResume = - s.pausedUnitType === unitType && s.pausedUnitId === unitId; - const isRetry = !!( - (s.currentUnit && - s.currentUnit.type === unitType && - s.currentUnit.id === unitId) || - isPausedUnitResume - ); - const previousTier = - s.currentUnitRouting?.tier ?? - (isPausedUnitResume && unitType === "execute-task" - ? "standard" - : undefined); - if (isPausedUnitResume) { - s.pausedUnitType = null; - s.pausedUnitId = null; - } - // Scope workflow-logger buffer to this unit so post-finalize drains are - // per-unit. Without this, the module-level _buffer accumulates across every - // unit in the same Node process (see workflow-logger.ts module header). - _resetLogs(); - s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() }; - s.researchTerminalTransition = false; - s.lastGitActionFailure = null; - s.lastGitActionStatus = null; - setCurrentPhase(unitType); - s.lastToolInvocationError = null; // #2883: clear stale error from previous unit - resetToolCallCounts(); - resetCompletionNudgeState( - unitType, - unitId, - prefs?.auto_supervisor?.completion_nudge_after, - ); - resetRunawayGuardState(unitType, unitId, { - sessionTokens: collectSessionTokenUsage(ctx), - changedFiles: countChangedFiles(s.basePath), - worktreeFingerprint: collectWorktreeFingerprint(s.basePath), - }); - const unitStartSeq = ic.nextSeq(); - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: unitStartSeq, - eventType: "unit-start", - data: { unitType, unitId }, - }); - { - const progressEvent = buildUokProgressEvent({ - eventType: "unit_selected", - unitType, - unitId, - role: "worker", - sessionId: ctx.sessionManager.getSessionId(), - traceId: ic.flowId, - data: { legacyEventType: "unit-start" }, - }); - deps.emitJournalEvent({ - ts: progressEvent.ts, - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: progressEvent.eventType, - data: progressEvent, - causedBy: { flowId: ic.flowId, seq: unitStartSeq }, - }); - } - ctx.ui.notify(`[unit] ${unitType} ${unitId} starting`, "info"); - deps.captureAvailableSkills(); - writeUnitRuntimeRecord( - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - { - phase: "dispatched", - wrapupWarningSent: false, - timeoutAt: null, - lastProgressAt: s.currentUnit.startedAt, - progressCount: 0, - lastProgressKind: "dispatch", - recoveryAttempts: 0, // Reset so re-dispatched units get full recovery budget (#2322) - lineageEvent: { - status: "started", - workerSessionId: ctx.sessionManager.getSessionId(), - note: "unit dispatched", - }, - }, - ); - // Status bar (widget + preconditions deferred until after model selection — see #2899) - ctx.ui.setStatus("sf-auto", "auto"); - if (mid) - deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id); - // ── Safety harness: reset evidence + create checkpoint ── - const safetyConfig = resolveSafetyHarnessConfig(prefs?.safety_harness); - if (safetyConfig.enabled && safetyConfig.evidence_collection) { - resetEvidence(); - const { milestone: eMid, slice: eSid, task: eTid } = parseUnitId(unitId); - loadEvidenceFromDisk(s.basePath, eMid, eSid ?? "", eTid ?? ""); - } - if ( - safetyConfig.enabled && - safetyConfig.file_change_validation && - unitType === "execute-task" - ) { - s.preUnitDirtyFiles = getDirtyFiles(s.basePath); - } else { - s.preUnitDirtyFiles = []; - } - // Only checkpoint code-executing units (not lifecycle/planning units) - if ( - safetyConfig.enabled && - safetyConfig.checkpoints && - unitType === "execute-task" - ) { - s.checkpointSha = createCheckpoint(s.basePath, unitId); - if (s.checkpointSha) { - debugLog("runUnitPhase", { - phase: "checkpoint-created", - unitId, - sha: s.checkpointSha.slice(0, 8), - }); - } - } - // Prompt injection - let finalPrompt = prompt; - if (s.pendingVerificationRetry) { - const retryCtx = s.pendingVerificationRetry; - s.pendingVerificationRetry = null; - const capped = - retryCtx.failureContext.length > MAX_RECOVERY_CHARS - ? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...failure context truncated]" - : retryCtx.failureContext; - finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`; - } - if (s.pendingCrashRecovery) { - const capped = - s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS - ? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...recovery briefing truncated to prevent memory exhaustion]" - : s.pendingCrashRecovery; - finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`; - s.pendingCrashRecovery = null; - } else if (s.pendingRethinkAttempt) { - // Stuck recovery: inject diagnostic + rethink prompt, then clear. - let rethinkCtx = null; - try { - rethinkCtx = JSON.parse(s.pendingRethinkAttempt); - } catch { - // Malformed JSON — skip injection - } - s.pendingRethinkAttempt = null; - if (rethinkCtx) { - const isFinal = rethinkCtx.attempt === 5; - const lines = [ - isFinal - ? `**⚠ FINAL STUCK ATTEMPT (5 of 5) — You have run out of recovery attempts. Make this count.**` - : `**STUCK RECOVERY — Rethink attempt ${rethinkCtx.attempt - 1} of 4.**`, - "", - `You have been repeatedly stuck on **${rethinkCtx.unitType} ${rethinkCtx.unitId}** for reason: "${rethinkCtx.reason}".`, - "", - "Before continuing, you must reflect on the following:", - "", - "1. **What specific error or failure pattern are you seeing?**", - "2. **What assumption are you making that might be wrong?**", - "3. **What is ONE concrete, different approach you will try this time?**", - "", - "Do NOT repeat the same approach. Identify the root cause and try a genuinely different strategy.", - ]; - if (rethinkCtx.stuckDiag) { - lines.push("", `**What was expected:** ${rethinkCtx.stuckDiag}`); - } - if (rethinkCtx.remediation) { - lines.push("", `**Suggested remediation:**\n${rethinkCtx.remediation}`); - } - if (rethinkCtx.diagnostic) { - lines.push( - "", - `**Full diagnostic from previous attempt:**\n${rethinkCtx.diagnostic}`, - ); - } - lines.push("", "---", "", finalPrompt); - finalPrompt = lines.join("\n"); - } - } else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) { - const diagnostic = deps.getDeepDiagnostic(s.basePath); - if (diagnostic) { - const cappedDiag = - diagnostic.length > MAX_RECOVERY_CHARS - ? diagnostic.slice(0, MAX_RECOVERY_CHARS) + - "\n\n[...diagnostic truncated to prevent memory exhaustion]" - : diagnostic; - finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`; - } - } - // Prompt char measurement - try { - const solverState = beginAutonomousSolverIteration( - s.basePath, - unitType, - unitId, - { - maxIterations: getConfiguredAutonomousSolverMaxIterations(prefs), - }, - ); - const steeringBlock = buildAutonomousSolverSteeringPromptBlock( - consumePendingAutonomousSolverSteering(s.basePath), - ); - if (steeringBlock) { - finalPrompt = `${finalPrompt}\n\n---\n\n${steeringBlock}`; - } - finalPrompt = `${finalPrompt}\n\n---\n\n${buildAutonomousSolverPromptBlock(solverState)}`; - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "solver-iteration-start", - data: { - unitType, - unitId, - iteration: solverState.iteration, - maxIterations: solverState.maxIterations, - steeringInjected: Boolean(steeringBlock), - }, - }); - } catch (solverErr) { - logWarning("engine", "Autonomous solver prompt injection failed", { - error: getErrorMessage(solverErr), - }); - } - s.lastPromptCharCount = finalPrompt.length; - s.lastBaselineCharCount = undefined; - if (deps.isDbAvailable()) { - try { - const { inlineSfRootFile } = await importExtensionModule( - import.meta.url, - "../auto-prompts.js", - ); - const [decisionsContent, requirementsContent, projectContent] = - await Promise.all([ - inlineSfRootFile(s.basePath, "decisions.md", "Decisions"), - inlineSfRootFile(s.basePath, "requirements.md", "Requirements"), - inlineSfRootFile(s.basePath, "project.md", "Project"), - ]); - s.lastBaselineCharCount = - (decisionsContent?.length ?? 0) + - (requirementsContent?.length ?? 0) + - (projectContent?.length ?? 0); - } catch (e) { - logWarning("engine", "Baseline char count measurement failed", { - error: String(e), - }); - } - } - // Cache-optimize prompt section ordering - try { - finalPrompt = deps.reorderForCaching(finalPrompt); - } catch (reorderErr) { - const msg = - getErrorMessage(reorderErr); - logWarning("engine", "Prompt reorder failed", { error: msg }); - } - // Select and apply model (with tier escalation on retry — normal units only) - const modelResult = await deps.selectAndApplyModel( - ctx, - pi, - unitType, - unitId, - s.basePath, - prefs, - s.verbose, - s.autoModeStartModel, - sidecarItem ? undefined : { isRetry, previousTier }, - undefined, - s.manualSessionModelOverride, - s.autoModeStartThinkingLevel, - ); - s.currentUnitRouting = modelResult.routing; - s.currentUnitModel = modelResult.appliedModel; - // updateProgressWidget( (decoy for legacy regex tests) - // Apply sidecar/pre-dispatch hook model override (takes priority over standard model selection) - const hookModelOverride = sidecarItem?.model ?? iterData.hookModelOverride; - if (hookModelOverride) { - const availableModels = ctx.modelRegistry.getAvailable(); - const match = deps.resolveModelId( - hookModelOverride, - availableModels, - ctx.model?.provider, - ); - if (match) { - const ok = await pi.setModel(match, { - persist: resolvePersistModelChanges(), - }); - if (ok) { - if (s.autoModeStartThinkingLevel) { - pi.setThinkingLevel(s.autoModeStartThinkingLevel); - } - s.currentUnitModel = match; - ctx.ui.notify( - `Hook model override: ${match.provider}/${match.id}`, - "info", - ); - } else { - ctx.ui.notify( - `Hook model "${hookModelOverride}" found but setModel failed. Using default.`, - "warning", - ); - } - } else { - ctx.ui.notify( - `Hook model "${hookModelOverride}" not found in available models. Falling back to current session model. ` + - `Ensure the model is defined in models.json and has auth configured.`, - "warning", - ); - } - } - // Store the final dispatched model ID so the dashboard can read it (#2899). - // This accounts for hook model overrides applied after selectAndApplyModel. - s.currentDispatchedModelId = s.currentUnitModel - ? `${s.currentUnitModel.provider ?? ""}/${s.currentUnitModel.id ?? ""}` - : null; - emitModelAutoResolvedEvent(s.basePath, { - traceId: `model:${ctx.sessionManager.getSessionId()}:${unitType}:${unitId}`, - unitType, - unitId, - resolvedModel: s.currentUnitModel ?? ctx.model ?? null, - authMode: - (s.currentUnitModel?.provider ?? ctx.model?.provider) - ? ctx.modelRegistry.getProviderAuthMode( - s.currentUnitModel?.provider ?? ctx.model.provider, - ) - : undefined, - routingReason: hookModelOverride - ? `hook override: ${hookModelOverride}` - : "auto selector", - routing: s.currentUnitRouting, - hookOverrideApplied: Boolean(hookModelOverride), - tokenUsage: collectSessionTokenUsage?.(ctx), - }); - { - const progressEvent = buildUokProgressEvent({ - eventType: "model_auto_resolved", - unitType, - unitId, - role: "worker", - sessionId: ctx.sessionManager.getSessionId(), - traceId: ic.flowId, - data: { - resolvedProvider: s.currentUnitModel?.provider ?? ctx.model?.provider, - resolvedModel: s.currentUnitModel?.id ?? ctx.model?.id, - routing: s.currentUnitRouting, - hookOverrideApplied: Boolean(hookModelOverride), - }, - }); - deps.emitJournalEvent({ - ts: progressEvent.ts, - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: progressEvent.eventType, - data: progressEvent, - causedBy: { flowId: ic.flowId, seq: unitStartSeq }, - }); - } - const compatibilityError = getWorkflowTransportSupportError( - s.currentUnitModel?.provider ?? ctx.model?.provider, - getRequiredWorkflowToolsForAutoUnit(unitType), - { - projectRoot: s.basePath, - surface: "autonomous mode", - unitType, - authMode: s.currentUnitModel?.provider - ? ctx.modelRegistry.getProviderAuthMode(s.currentUnitModel.provider) - : ctx.model?.provider - ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) - : undefined, - baseUrl: s.currentUnitModel?.baseUrl ?? ctx.model?.baseUrl, - }, - ); - if (compatibilityError) { - ctx.ui.notify(compatibilityError, "error"); - await deps.stopAuto(ctx, pi, compatibilityError); - return { action: "break", reason: "workflow-capability" }; - } - // Progress widget + preconditions — deferred to after model selection so the - // widget's first render tick shows the correct model (#2899). - deps.updateProgressWidget(ctx, unitType, unitId, state); // updateProgressWidget( - deps.ensurePreconditions(unitType, unitId, s.basePath, state); - // Start unit supervision - deps.clearUnitTimeout(); - deps.startUnitSupervision({ - s, - ctx, - pi, - unitType, - unitId, - prefs, - buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId), - buildRecoveryContext: () => ({ - basePath: s.basePath, - verbose: s.verbose, - currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(), - unitRecoveryCount: s.unitRecoveryCount, - }), - pauseAuto: deps.pauseAuto, - }); - // Write preliminary lock (no session path yet — runUnit creates a new session). - // Crash recovery can still identify the in-flight unit from this lock. - deps.writeLock(deps.lockBase(), unitType, unitId); - // Pre-flight provider readiness check: if the resolved model's provider is - // not request-ready (expired token, logged out), attempt to reselect a ready - // provider before dispatching. This prevents the unit from burning a runUnit - // call only to be immediately cancelled with no-transcript. - { - const selectedProvider = - s.currentUnitModel?.provider ?? ctx.model?.provider; - if ( - selectedProvider != null && - typeof ctx.modelRegistry?.isProviderRequestReady === "function" - ) { - let ready = false; - try { - ready = ctx.modelRegistry.isProviderRequestReady(selectedProvider); - } catch { - ready = false; - } - if (!ready) { - const allModels = ctx.modelRegistry.getAvailable?.() ?? []; - const fallback = allModels.find( - (m) => - m.provider !== selectedProvider && - ctx.modelRegistry.isProviderRequestReady(m.provider), - ); - if (fallback) { - const ok = await pi.setModel(fallback, { persist: false }); - if (ok) { - ctx.ui.notify( - `Autonomous mode: provider ${selectedProvider} not ready — switched to ${fallback.provider}/${fallback.id}`, - "warning", - ); - s.currentUnitModel = fallback; - } - } else { - const msg = `Autonomous mode stopped: provider ${selectedProvider} is not request-ready and no fallback provider is available. Check your login/API key.`; - ctx.ui.notify(msg, "error"); - await deps.stopAuto(ctx, pi, msg); - return { action: "break", reason: "provider-pause" }; - } - } - } - } - debugLog("autoLoop", { - phase: "runUnit-start", - iteration: ic.iteration, - unitType, - unitId, - }); - const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt); - s.lastUnitAgentEndMessages = unitResult.event?.messages ?? null; - let currentUnitResult = unitResult; - // Short-circuit: if runUnit was cancelled (provider not ready, session - // failed, timeout) there is no checkpoint to repair — skip the repair loop - // entirely and let the cancelled handler below surface the real cause. - let solverAssessment = - unitResult.status === "cancelled" - ? { action: "none" } - : assessAutonomousSolverTurn(s.basePath, unitType, unitId); - while (solverAssessment.action === "missing-checkpoint-retry") { - const diagnosis = classifyAutonomousSolverMissingCheckpointFailure( - currentUnitResult.event?.messages ?? [], - ); - recordAutonomousSolverMissingCheckpointRetry( - s.basePath, - unitType, - unitId, - diagnosis, - ); - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "solver-missing-checkpoint-retry", - data: { - unitType, - unitId, - iteration: solverAssessment.state?.iteration, - repairAttempt: solverAssessment.repairAttempt, - maxRepairAttempts: solverAssessment.maxRepairAttempts, - classification: diagnosis.classification, - }, - }); - ctx.ui.notify( - `Autonomous solver checkpoint missing for ${unitType} ${unitId}; repair attempt ${solverAssessment.repairAttempt}/${solverAssessment.maxRepairAttempts} (${diagnosis.classification}).`, - "warning", - ); - currentUnitResult = await runUnit( - ctx, - pi, - s, - unitType, - unitId, - buildAutonomousSolverMissingCheckpointRepairPrompt( - solverAssessment.state, - unitType, - unitId, - diagnosis, - solverAssessment.repairAttempt, - solverAssessment.maxRepairAttempts, - ), - { keepSession: true }, - ); - s.lastUnitAgentEndMessages = currentUnitResult.event?.messages ?? null; - if (currentUnitResult.status === "cancelled") { - solverAssessment = { action: "none" }; - break; - } - solverAssessment = assessAutonomousSolverTurn(s.basePath, unitType, unitId); - } - const solverCheckpoint = solverAssessment.checkpoint; - if (solverCheckpoint) { - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "solver-checkpoint", - data: { - unitType, - unitId, - iteration: solverCheckpoint.iteration, - outcome: solverCheckpoint.outcome, - remainingCount: solverCheckpoint.remainingItems?.length ?? 0, - }, - }); - } - if (solverAssessment.action === "pause") { - const isMissingCheckpoint = - solverAssessment.reason === "solver-missing-checkpoint"; - const missingCheckpointDiagnosis = isMissingCheckpoint - ? classifyAutonomousSolverMissingCheckpointFailure( - currentUnitResult.event?.messages ?? [], - ) - : null; - if (missingCheckpointDiagnosis) { - try { - const feedback = recordSelfFeedback( - { - kind: "solver-missing-checkpoint", - severity: "high", - summary: `Autonomous solver failed to checkpoint after ${solverAssessment.repairAttempts ?? "multiple"} repair attempt(s): ${missingCheckpointDiagnosis.classification}`, - evidence: [ - `unit=${unitType} ${unitId}`, - `classification=${missingCheckpointDiagnosis.classification}`, - `summary=${missingCheckpointDiagnosis.summary}`, - `evidencePath=.sf/runtime/autonomous-solver/LOOP.md`, - "", - missingCheckpointDiagnosis.evidence ?? "", - ].join("\n"), - suggestedFix: - "Improve solver repair policy, tool availability, or prompt wording so missing-checkpoint repairs end with a successful checkpoint tool call.", - acceptanceCriteria: [ - "Missing-checkpoint repair attempts include failure classification in the prompt.", - "Repeated repair failures file self-feedback automatically.", - "Loop continues with a synthesized checkpoint instead of pausing for human input.", - ], - occurredIn: { unitType, unitId }, - source: "runtime", - }, - s.basePath, - ); - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "solver-missing-checkpoint-self-feedback", - data: { - unitType, - unitId, - classification: missingCheckpointDiagnosis.classification, - selfFeedbackId: feedback?.entry?.id, - blocking: feedback?.blocking, - }, - }); - } catch { - // self-feedback is observability; never block loop continuation - } - } - - // Missing-checkpoint: the LLM failed to call the checkpoint tool despite repair - // attempts. Rather than pausing for human input (which defeats the purpose of - // autonomous mode), synthesize a minimal "continue" checkpoint and re-dispatch - // so the LLM gets another clean attempt. The max-iterations guard will catch - // genuine infinite loops. Only hard blockers and max-iterations pause the loop. - if (isMissingCheckpoint) { - try { - appendAutonomousSolverCheckpoint(s.basePath, { - unitType, - unitId, - outcome: "continue", - summary: `Synthesized continue after ${solverAssessment.repairAttempts ?? "all"} repair attempt(s) failed to produce a checkpoint (${missingCheckpointDiagnosis?.classification ?? "unknown"}). Re-dispatching.`, - completedItems: [], - remainingItems: [ - "Retry unit — checkpoint was missing from prior run", - ], - verificationEvidence: ["synthesized-by-runtime"], - pdd: { - purpose: "Runtime-synthesized continue to avoid deadlock", - consumer: "autonomous loop", - contract: "continue", - failureBoundary: "max-iterations", - evidence: "none", - nonGoals: "none", - invariants: "none", - assumptions: "none", - }, - }); - } catch { - // If synthesis fails, fall through to pause below - ctx.ui.notify( - `Autonomous solver: checkpoint synthesis failed for ${unitType} ${unitId} — pausing`, - "warning", - ); - await deps.pauseAuto(ctx, pi); - return { action: "break", reason: solverAssessment.reason }; - } - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "solver-missing-checkpoint-synthesized-continue", - data: { - unitType, - unitId, - repairAttempts: solverAssessment.repairAttempts, - classification: missingCheckpointDiagnosis?.classification, - }, - }); - ctx.ui.notify( - `Autonomous solver: all repair attempts exhausted for ${unitType} ${unitId} — synthesizing continue and re-dispatching (LLM will try again)`, - "info", - ); - // Fall through: the synthesized checkpoint's action will be "continue" on - // the next assessment, so the loop re-dispatches the unit automatically. - return { action: "continue" }; - } - - const reason = - solverCheckpoint?.outcome === "blocked" - ? (solverCheckpoint.blockerReason ?? solverCheckpoint.summary) - : solverAssessment.reason; - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: - solverAssessment.reason === "solver-max-iterations" - ? "solver-max-iterations-pause" - : "solver-pause", - data: { - unitType, - unitId, - reason: solverAssessment.reason, - iteration: solverAssessment.state?.iteration, - maxIterations: solverAssessment.state?.maxIterations, - remainingItems: solverCheckpoint?.remainingItems ?? [], - evidencePath: ".sf/runtime/autonomous-solver/LOOP.md", - }, - }); - ctx.ui.notify( - `Autonomous solver paused ${unitType} ${unitId}: ${reason || solverAssessment.reason}`, - "warning", - ); - await deps.pauseAuto(ctx, pi); - return { - action: "break", - reason: solverAssessment.reason, - }; - } - if (solverAssessment.action === "continue") { - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "solver-continue-redispatch", - data: { - unitType, - unitId, - iteration: solverAssessment.state?.iteration, - remainingItems: solverCheckpoint?.remainingItems ?? [], - evidencePath: ".sf/runtime/autonomous-solver/LOOP.md", - }, - }); - ctx.ui.notify( - `Autonomous solver continuing ${unitType} ${unitId}: ${solverCheckpoint?.remainingItems?.length ?? 0} item(s) remain.`, - "info", - ); - return { - action: "continue", - data: { - unitStartedAt: s.currentUnit?.startedAt, - requestDispatchedAt: currentUnitResult.requestDispatchedAt, - }, - }; - } - debugLog("autoLoop", { - phase: "runUnit-end", - iteration: ic.iteration, - unitType, - unitId, - status: currentUnitResult.status, - }); - // Now that runUnit has called newSession(), the session file path is correct. - const sessionFile = deps.getSessionFile(ctx); - const sessionId = sessionFile ? basename(sessionFile) : undefined; - deps.updateSessionLock( - deps.lockBase(), - unitType, - unitId, - sessionFile, - sessionId, - ); - deps.writeLock(deps.lockBase(), unitType, unitId, sessionFile); - // Tag the most recent window entry with error info for stuck detection - const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1]; - if (lastEntry) { - if (currentUnitResult.errorContext) { - lastEntry.error = - `${currentUnitResult.errorContext.category}:${currentUnitResult.errorContext.message}`.slice( - 0, - 200, - ); - } else if ( - currentUnitResult.status === "error" || - currentUnitResult.status === "cancelled" - ) { - lastEntry.error = `${currentUnitResult.status}:${unitType}/${unitId}`; - } else if (currentUnitResult.event?.messages?.length) { - const lastMsg = - currentUnitResult.event.messages[ - currentUnitResult.event.messages.length - 1 - ]; - const msgStr = - typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg); - if (/error|fail|exception/i.test(msgStr)) { - lastEntry.error = msgStr.slice(0, 200); - } - } - } - if (currentUnitResult.status === "cancelled") { - clearDeferredCommitAfterCancelledUnit( - s, - ctx, - unitType, - unitId, - currentUnitResult.errorContext?.message ?? "cancelled", - ); - // Provider-error: try to reselect a ready provider and continue rather - // than stopping autonomous mode. Only stop if no ready provider exists. - if (currentUnitResult.errorContext?.category === "provider") { - await emitCancelledUnitEnd( - ic, - unitType, - unitId, - unitStartSeq, - currentUnitResult.errorContext, - ); - const failedProvider = - s.currentUnitModel?.provider ?? ctx.model?.provider; - const allModels = ctx.modelRegistry?.getAvailable?.() ?? []; - const fallback = allModels.find( - (m) => - m.provider !== failedProvider && - ctx.modelRegistry?.isProviderRequestReady?.(m.provider), - ); - if (fallback) { - const ok = await pi.setModel(fallback, { persist: false }); - if (ok) { - s.currentUnitModel = fallback; - ctx.ui.notify( - `Autonomous mode: provider ${failedProvider} not ready — retrying with ${fallback.provider}/${fallback.id}`, - "warning", - ); - return { action: "continue" }; - } - } - const msg = `Autonomous mode stopped: ${currentUnitResult.errorContext.message ?? `provider ${failedProvider} not ready`}. Check your login/API key.`; - ctx.ui.notify(msg, "error"); - debugLog("autoLoop", { - phase: "exit", - reason: "provider-pause", - isTransient: currentUnitResult.errorContext.isTransient, - }); - return { action: "break", reason: "provider-pause" }; - } - // Timeout category covers two distinct scenarios: - // 1. Session creation timeout (120s) — transient, scheduled resume with backoff - // 2. Unit hard timeout (30min+) — stuck agent, pause for manual review - // Structural errors (TypeError, is not a function) are NOT transient - // and must hard-stop to avoid infinite retry loops. - if ( - currentUnitResult.errorContext?.isTransient && - currentUnitResult.errorContext?.category === "timeout" - ) { - // Session-timeout cancellations are resumable pauses: pauseAuto below preserves the auto session - // instead of routing the cancelled unit into the hard-stop path. - const isSessionCreationTimeout = - currentUnitResult.errorContext.message?.includes( - "Session creation timed out", - ); - if (isSessionCreationTimeout) { - consecutiveSessionTimeouts += 1; - const baseRetryAfterMs = 30_000; - const retryAfterMs = - baseRetryAfterMs * 2 ** Math.max(0, consecutiveSessionTimeouts - 1); - const allowAutoResume = - consecutiveSessionTimeouts <= MAX_SESSION_TIMEOUT_AUTO_RESUMES; - if (!allowAutoResume) { - ctx.ui.notify( - `Session creation timed out ${consecutiveSessionTimeouts} consecutive times for ${unitType} ${unitId}. Pausing for manual review.`, - "warning", - ); - } - debugLog("autoLoop", { - phase: "session-timeout-pause", - unitType, - unitId, - consecutiveSessionTimeouts, - retryAfterMs, - allowAutoResume, - }); - const errorDetail = ` for ${unitType} ${unitId}`; - await pauseAutoForProviderError( - ctx.ui, - errorDetail, - () => deps.pauseAuto(ctx, pi), - { - isRateLimit: false, - isTransient: allowAutoResume, - retryAfterMs, - resume: allowAutoResume - ? () => { - void resumeAutoAfterProviderDelay(pi, ctx).catch((err) => { - const message = - getErrorMessage(err); - ctx.ui.notify( - `Session timeout recovery failed: ${message}`, - "error", - ); - }); - } - : undefined, - }, - ); - if (!allowAutoResume) { - resetConsecutiveSessionTimeouts(); - } - await emitCancelledUnitEnd( - ic, - unitType, - unitId, - unitStartSeq, - currentUnitResult.errorContext, - ); - return { action: "break", reason: "session-timeout" }; - } - // Unit hard timeout (30min+): pause without scheduled resume — stuck agent - ctx.ui.notify( - `Unit timed out for ${unitType} ${unitId} (supervision may have failed). Pausing autonomous mode.`, - "warning", - ); - debugLog("autoLoop", { - phase: "unit-hard-timeout-pause", - unitType, - unitId, - }); - await deps.pauseAuto(ctx, pi); - await emitCancelledUnitEnd( - ic, - unitType, - unitId, - unitStartSeq, - currentUnitResult.errorContext, - ); - return { action: "break", reason: "unit-hard-timeout" }; - } - // All other cancelled states (structural errors, non-transient failures): hard stop - if (s.currentUnit) { - await deps.closeoutUnit( - ctx, - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(unitType, unitId), - ); - } - await emitCancelledUnitEnd( - ic, - unitType, - unitId, - unitStartSeq, - currentUnitResult.errorContext, - ); - ctx.ui.notify( - `Session creation failed for ${unitType} ${unitId}: ${currentUnitResult.errorContext?.message ?? "unknown"}. Stopping autonomous mode.`, - "warning", - ); - await deps.stopAuto( - ctx, - pi, - `Session creation failed: ${currentUnitResult.errorContext?.message ?? "unknown"}`, - ); - debugLog("autoLoop", { phase: "exit", reason: "session-failed" }); - return { action: "break", reason: "session-failed" }; - } - // ── Immediate unit closeout (metrics, activity log, memory) ──────── - // Run right after runUnit() returns so telemetry is never lost to a - // crash between iterations. - // Guard: stopAuto() may have nulled s.currentUnit via s.reset() while - // this coroutine was suspended at `await runUnit(...)` (#2939). - if (s.currentUnit) { - // Reset session timeout counter — any successful unit clears the slate - resetConsecutiveSessionTimeouts(); - await deps.closeoutUnit( - ctx, - s.basePath, - unitType, - unitId, - s.currentUnit.startedAt, - deps.buildSnapshotOpts(unitType, unitId), - ); - } - // ── Zero tool-call guard (#1833, #2653) ────────────────────────── - // Any unit that completes with 0 tool calls made no real progress — - // likely context exhaustion where all tool calls errored out. Treat - // as failed so the unit is retried in a fresh context instead of - // silently passing through to artifact verification (which loops - // forever when the unit never produced its artifact). - { - const currentLedger = deps.getLedger(); - if (currentLedger?.units) { - const lastUnit = [...currentLedger.units] - .reverse() - .find( - (u) => - u.type === unitType && - u.id === unitId && - u.startedAt === s.currentUnit?.startedAt, - ); - if (lastUnit && lastUnit.toolCalls === 0) { - if ( - USER_DRIVEN_DEEP_UNITS.has(unitType) && - isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined) - ) { - debugLog("runUnitPhase", { - phase: "zero-tool-calls-awaiting-user-input", - unitType, - unitId, - }); - } else { - debugLog("runUnitPhase", { - phase: "zero-tool-calls", - unitType, - unitId, - warning: - "Unit completed with 0 tool calls — likely context exhaustion, marking as failed", - }); - ctx.ui.notify( - `${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`, - "warning", - ); - recordLearningOutcomeForUnit( - ic, - unitType, - unitId, - s.currentUnit?.startedAt, - { - succeeded: false, - verificationPassed: null, - }, - ); - // Fall through to next iteration where dispatch will re-derive - // and re-dispatch this unit. - return { - action: "next", - data: { - unitStartedAt: s.currentUnit?.startedAt, - requestDispatchedAt: currentUnitResult.requestDispatchedAt, - }, - }; - } - } - } - } - if (s.currentUnitRouting) { - deps.recordOutcome(unitType, s.currentUnitRouting.tier, true); - } - const skipArtifactVerification = shouldSkipArtifactVerification(unitType); - let artifactVerified; - if ( - USER_DRIVEN_DEEP_UNITS.has(unitType) && - isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined) - ) { - // Skip artifact verification — unit is paused waiting for user input - artifactVerified = false; - } else { - artifactVerified = - skipArtifactVerification || - verifyExpectedArtifact(unitType, unitId, s.basePath); - } - if (artifactVerified) { - s.unitDispatchCount.delete(`${unitType}/${unitId}`); - s.unitRecoveryCount.delete(`${unitType}/${unitId}`); - } - // Write phase handoff anchor after successful research/planning completion - const anchorPhases = new Set([ - "research-milestone", - "research-slice", - "plan-milestone", - "plan-slice", - ]); - if (artifactVerified && mid && anchorPhases.has(unitType)) { - try { - const { writePhaseAnchor } = await import("../phase-anchor.js"); - writePhaseAnchor(s.basePath, mid, { - phase: unitType, - milestoneId: mid, - generatedAt: new Date().toISOString(), - intent: `Completed ${unitType} for ${unitId}`, - decisions: [], - blockers: [], - nextSteps: [], - }); - } catch (err) { - /* non-fatal — anchor is advisory */ - logWarning( - "engine", - `phase anchor failed: ${getErrorMessage(err)}`, - ); - } - } - if (currentUnitResult.status !== "completed" || !artifactVerified) { - recordLearningOutcomeForUnit( - ic, - unitType, - unitId, - s.currentUnit?.startedAt, - { - succeeded: false, - verificationPassed: null, - }, - ); - } - { - // Pull cost/token data from the ledger entry that snapshotUnitMetrics - // already wrote so the unit-end event carries billing context. - const unitEndLedger = deps.getLedger(); - const unitEndEntry = unitEndLedger?.units - ? [...unitEndLedger.units] - .reverse() - .find( - (u) => - u.type === unitType && - u.id === unitId && - u.startedAt === s.currentUnit?.startedAt, - ) - : undefined; - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "unit-end", - data: { - unitType, - unitId, - status: currentUnitResult.status, - artifactVerified, - ...(unitEndEntry - ? { - cost_usd: unitEndEntry.cost, - tokens: unitEndEntry.tokens.total, - tokens_input: unitEndEntry.tokens.input, - tokens_output: unitEndEntry.tokens.output, - } - : {}), - ...(currentUnitResult.errorContext - ? { errorContext: currentUnitResult.errorContext } - : {}), - }, - causedBy: { flowId: ic.flowId, seq: unitStartSeq }, - }); - if ( - currentUnitResult.status === "completed" || - currentUnitResult.status === "blocked" - ) { - const progressEvent = buildUokProgressEvent({ - eventType: - currentUnitResult.status === "completed" - ? "unit_completed" - : "unit_blocked", - unitType, - unitId, - role: "worker", - sessionId: ctx.sessionManager.getSessionId(), - traceId: ic.flowId, - data: { - status: currentUnitResult.status, - artifactVerified, - legacyEventType: "unit-end", - ...(unitEndEntry - ? { - cost_usd: unitEndEntry.cost, - tokens: unitEndEntry.tokens.total, - } - : {}), - }, - }); - deps.emitJournalEvent({ - ts: progressEvent.ts, - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: progressEvent.eventType, - data: progressEvent, - causedBy: { flowId: ic.flowId, seq: unitStartSeq }, - }); - } - } - { - const runtimeStatus = - currentUnitResult.status === "completed" - ? artifactVerified - ? "completed" - : "blocked" - : currentUnitResult.status === "error" - ? "failed" - : currentUnitResult.status; - const lineageStatus = - runtimeStatus === "completed" - ? "completed" - : runtimeStatus === "blocked" - ? "blocked" - : runtimeStatus === "cancelled" - ? "cancelled" - : "failed"; - writeUnitRuntimeRecord( - s.basePath, - unitType, - unitId, - s.currentUnit?.startedAt ?? Date.now(), - { - status: runtimeStatus, - lastProgressAt: Date.now(), - lastProgressKind: "unit-end", - lineageEvent: { - status: lineageStatus, - workerSessionId: ctx.sessionManager.getSessionId(), - note: `unit ended with ${currentUnitResult.status}`, - }, - }, - ); - } - { - const verdict = - currentUnitResult.status === "completed" - ? artifactVerified - ? "success" - : "blocked" - : currentUnitResult.status === "error" - ? "fail" - : currentUnitResult.status; - const ledger = deps.getLedger(); - const unitEntry = ledger?.units - ? [...ledger.units] - .reverse() - .find( - (u) => - u.type === unitType && - u.id === unitId && - u.startedAt === s.currentUnit?.startedAt, - ) - : undefined; - if (unitEntry) { - const costStr = deps.formatCost(unitEntry.cost); - ctx.ui.notify( - `[unit] ${unitType} ${unitId} ended -> ${verdict} (${costStr}, ${unitEntry.tokens.total} tokens, ${unitEntry.toolCalls} tool calls)`, - "info", - ); - } else { - ctx.ui.notify(`[unit] ${unitType} ${unitId} ended -> ${verdict}`, "info"); - } - const toolSummary = formatToolCallSummary(); - if (toolSummary) { - ctx.ui.notify(`[mcp] ${toolSummary}`, "info"); - } - } - // ── Safety harness: checkpoint cleanup or rollback ── - if (s.checkpointSha) { - if (currentUnitResult.status === "error" && safetyConfig.auto_rollback) { - const rolled = rollbackToCheckpoint(s.basePath, unitId, s.checkpointSha); - if (rolled) { - ctx.ui.notify( - `Rolled back to pre-unit checkpoint for ${unitId}`, - "info", - ); - debugLog("runUnitPhase", { phase: "checkpoint-rollback", unitId }); - } - } else if (currentUnitResult.status === "error") { - ctx.ui.notify( - `Unit ${unitId} failed. Pre-unit checkpoint available at ${s.checkpointSha.slice(0, 8)}`, - "warning", - ); - } else { - // Success — clean up checkpoint ref - cleanupCheckpoint(s.basePath, unitId); - debugLog("runUnitPhase", { phase: "checkpoint-cleaned", unitId }); - } - s.checkpointSha = null; - } - s.preUnitDirtyFiles = []; - return { - action: "next", - data: { - unitStartedAt: s.currentUnit?.startedAt, - requestDispatchedAt: currentUnitResult.requestDispatchedAt, - }, - }; -} -// ─── runFinalize ────────────────────────────────────────────────────────────── -/** - * Phase 5: Post-unit finalize — pre/post verification, UAT pause, step-wizard. - * Returns break/continue/next to control the outer loop. - */ -export async function runFinalize(ic, iterData, loopState, sidecarItem) { - const { ctx, pi, s, deps } = ic; - const { pauseAfterUatDispatch } = iterData; - debugLog("autoLoop", { phase: "finalize", iteration: ic.iteration }); - // Clear unit timeout (unit completed) - deps.clearUnitTimeout(); - // Post-unit context for pre/post verification - const postUnitCtx = { - s, - ctx, - pi, - buildSnapshotOpts: deps.buildSnapshotOpts, - lockBase: deps.lockBase, - stopAuto: deps.stopAuto, - pauseAuto: deps.pauseAuto, - updateProgressWidget: deps.updateProgressWidget, - }; - // Pre-verification processing (commit, doctor, state rebuild, etc.) - // Timeout guard: if postUnitPreVerification hangs (e.g., safety harness - // deadlock, browser teardown hang, worktree sync stall), force-continue - // after timeout so the auto-loop is not permanently frozen (#3757). - // - // On timeout, null out s.currentUnit so the timed-out task's late async - // mutations are harmless — postUnitPreVerification guards all side effects - // behind `if (s.currentUnit)`. The next iteration sets a fresh currentUnit. - // Sidecar items use lightweight pre-verification opts - const preVerificationOpts = sidecarItem - ? sidecarItem.kind === "hook" - ? { - skipSettleDelay: true, - skipWorktreeSync: true, - agentEndMessages: s.lastUnitAgentEndMessages ?? undefined, - } - : { - skipSettleDelay: true, - agentEndMessages: s.lastUnitAgentEndMessages ?? undefined, - } - : { agentEndMessages: s.lastUnitAgentEndMessages ?? undefined }; - const _preUnitSnapshot = s.currentUnit - ? { - type: s.currentUnit.type, - id: s.currentUnit.id, - startedAt: s.currentUnit.startedAt, - } - : null; - const preResultGuard = await withTimeout( - deps.postUnitPreVerification(postUnitCtx, preVerificationOpts), - FINALIZE_PRE_TIMEOUT_MS, - "postUnitPreVerification", - ); - if (preResultGuard.timedOut) { - // Detach session from the timed-out unit so late async completions - // cannot mutate state for the next unit (#3757). - const hadStagedPending = s.stagedPendingCommit; - const hadCommitted = s.lastGitActionStatus === "ok"; - s.stagedPendingCommit = false; // prevent orphaned deferred commit - s.currentUnit = null; - clearCurrentPhase(); - // Drop any logger entries from the timed-out unit so they don't bleed - // into the next iteration's drain. - drainLogs(); - loopState.consecutiveFinalizeTimeouts++; - if (hadStagedPending) { - ctx.ui.notify( - "postUnitPreVerification timed out with staged-but-uncommitted changes — staged files will be included in next unit's commit.", - "warning", - ); - logWarning( - "engine", - "finalize-timeout: staged-pending-commit orphaned — will be absorbed by next unit", - ); - } else if (hadCommitted) { - ctx.ui.notify( - "postUnitPreVerification timed out after git commit — changes are in history but verification was skipped.", - "warning", - ); - logWarning( - "engine", - "finalize-timeout: git commit completed before timeout — verification was not run", - ); - } - debugLog("autoLoop", { - phase: "pre-verification-timeout", - iteration: ic.iteration, - unitType: iterData.unitType, - unitId: iterData.unitId, - consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts, - }); - if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) { - ctx.ui.notify( - `postUnitPreVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping autonomous mode to prevent budget waste`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`, - ); - return { action: "break", reason: "finalize-timeout-escalation" }; - } - ctx.ui.notify( - `postUnitPreVerification timed out after ${FINALIZE_PRE_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`, - "warning", - ); - return { action: "next", data: undefined }; - } - const preResult = preResultGuard.value; - if (preResult === "dispatched") { - const dispatchedReason = s.lastGitActionFailure - ? "git-closeout-failure" - : "pre-verification-dispatched"; - debugLog("autoLoop", { - phase: "exit", - reason: dispatchedReason, - gitError: s.lastGitActionFailure ?? undefined, - }); - return { action: "break", reason: dispatchedReason }; - } - if (preResult === "retry") { - if (sidecarItem) { - // Sidecar artifact retries are skipped — just continue - debugLog("autoLoop", { - phase: "sidecar-artifact-retry-skipped", - iteration: ic.iteration, - }); - } else { - // s.pendingVerificationRetry was set by postUnitPreVerification. - // Emit a dedicated journal event so forensics can distinguish bounded - // verification retries from genuine stuck-loop dispatch repetitions (#4540). - const retryInfo = s.pendingVerificationRetry; - deps.emitJournalEvent({ - ts: new Date().toISOString(), - flowId: ic.flowId, - seq: ic.nextSeq(), - eventType: "artifact-verification-retry", - data: { - unitType: _preUnitSnapshot?.type, - unitId: retryInfo?.unitId, - attempt: retryInfo?.attempt, - }, - }); - // Continue the loop — next iteration will inject the retry context into the prompt. - debugLog("autoLoop", { - phase: "artifact-verification-retry", - iteration: ic.iteration, - }); - return { action: "continue" }; - } - } - if (pauseAfterUatDispatch) { - ctx.ui.notify( - "UAT requires human execution. Autonomous mode will pause after this unit writes the result file.", - "info", - ); - await deps.pauseAuto(ctx, pi); - debugLog("autoLoop", { phase: "exit", reason: "uat-pause" }); - return { action: "break", reason: "uat-pause" }; - } - // Verification gate - // Hook sidecar items skip verification entirely. - // Non-hook sidecar items run verification but skip retries (just continue). - const skipVerification = sidecarItem?.kind === "hook"; - const uokFlagsFinalize = resolveUokFlags(ic.prefs); - const runVerifyGate = - uokFlagsFinalize.gates && - iterData.unitType === "execute-task" && - !skipVerification; - if (!skipVerification) { - if (runVerifyGate) { - const vgRunner = new UokGateRunner(); - vgRunner.register({ - id: "unit-verification-gate", - type: "verification", - execute: async () => { - const result = await deps.runPostUnitVerification( - { s, ctx, pi }, - deps.pauseAuto, - ); - if (result === "pause") { - return { - outcome: "fail", - failureClass: "manual-attention", - rationale: - "Post-unit verification paused — requires human attention", - }; - } - if (result === "retry") { - return { - outcome: "fail", - failureClass: "verification", - rationale: "Post-unit verification failed — retrying unit", - }; - } - return { - outcome: "pass", - failureClass: "none", - rationale: "Post-unit verification passed", - }; - }, - }); - const gateResult = await vgRunner.run("unit-verification-gate", { - basePath: s.basePath, - traceId: `finalize:${ic.flowId}`, - turnId: `iter-${ic.iteration}`, - milestoneId: iterData.mid ?? undefined, - unitType: iterData.unitType, - unitId: iterData.unitId, - }); - if (gateResult.outcome !== "pass") { - recordLearningOutcomeForUnit( - ic, - iterData.unitType, - iterData.unitId, - s.currentUnit?.startedAt, - { - succeeded: false, - verificationPassed: false, - }, - ); - const reason = - gateResult.failureClass === "manual-attention" - ? "verification-pause" - : "verification-fail"; - debugLog("autoLoop", { phase: "exit", reason }); - return { action: "break", reason }; - } - } else { - const verificationResult = await deps.runPostUnitVerification( - { s, ctx, pi }, - deps.pauseAuto, - ); - if (verificationResult === "pause") { - recordLearningOutcomeForUnit( - ic, - iterData.unitType, - iterData.unitId, - s.currentUnit?.startedAt, - { - succeeded: false, - verificationPassed: false, - }, - ); - debugLog("autoLoop", { - phase: "exit", - reason: "verification-pause", - }); - return { action: "break", reason: "verification-pause" }; - } - if (verificationResult === "retry") { - recordLearningOutcomeForUnit( - ic, - iterData.unitType, - iterData.unitId, - s.currentUnit?.startedAt, - { - succeeded: false, - verificationPassed: false, - }, - ); - if (sidecarItem) { - // Sidecar verification retries are skipped — just continue - debugLog("autoLoop", { - phase: "sidecar-verification-retry-skipped", - iteration: ic.iteration, - }); - } else { - // s.pendingVerificationRetry was set by runPostUnitVerification. - // Continue the loop — next iteration will inject the retry context into the prompt. - debugLog("autoLoop", { - phase: "verification-retry", - iteration: ic.iteration, - }); - return { action: "continue" }; - } - } - } - } - // Post-verification processing (DB dual-write, hooks, triage, quick-tasks) - // Timeout guard: if postUnitPostVerification hangs (e.g., module import - // deadlock, SQLite transaction hang), force-continue after timeout so the - // auto-loop is not permanently frozen (#2344). - const postResultGuard = await withTimeout( - deps.postUnitPostVerification(postUnitCtx), - FINALIZE_POST_TIMEOUT_MS, - "postUnitPostVerification", - ); - if (postResultGuard.timedOut) { - // Detach session from the timed-out unit so late async completions - // cannot mutate state for the next unit (#3757). - s.currentUnit = null; - clearCurrentPhase(); - // Drop any logger entries from the timed-out unit so they don't bleed - // into the next iteration's drain. - drainLogs(); - loopState.consecutiveFinalizeTimeouts++; - debugLog("autoLoop", { - phase: "post-verification-timeout", - iteration: ic.iteration, - unitType: iterData.unitType, - unitId: iterData.unitId, - consecutiveTimeouts: loopState.consecutiveFinalizeTimeouts, - }); - if (loopState.consecutiveFinalizeTimeouts >= MAX_FINALIZE_TIMEOUTS) { - ctx.ui.notify( - `postUnitPostVerification timed out ${loopState.consecutiveFinalizeTimeouts} consecutive times — stopping autonomous mode to prevent budget waste`, - "error", - ); - await deps.stopAuto( - ctx, - pi, - `${loopState.consecutiveFinalizeTimeouts} consecutive finalize timeouts`, - ); - return { action: "break", reason: "finalize-timeout-escalation" }; - } - ctx.ui.notify( - `postUnitPostVerification timed out after ${FINALIZE_POST_TIMEOUT_MS / 1000}s for ${iterData.unitType} ${iterData.unitId} (${loopState.consecutiveFinalizeTimeouts}/${MAX_FINALIZE_TIMEOUTS}) — continuing to next iteration`, - "warning", - ); - return { action: "next", data: undefined }; - } - const postResult = postResultGuard.value; - if (postResult === "stopped") { - debugLog("autoLoop", { - phase: "exit", - reason: "post-verification-stopped", - }); - return { action: "break", reason: "post-verification-stopped" }; - } - if (postResult === "step-wizard") { - // Assisted mode — exit the loop (caller handles wizard) - debugLog("autoLoop", { phase: "exit", reason: "step-wizard" }); - return { action: "break", reason: "step-wizard" }; - } - // Both pre and post verification completed without timeout — reset counter - loopState.consecutiveFinalizeTimeouts = 0; - // Flush WAL to main DB file now that all unit DB writes are committed. - // wal_autocheckpoint=0 prevents SQLite from auto-checkpointing at random - // times — this explicit call at the end of a successful unit is the only - // point where the WAL is flushed, making crash recovery deterministic. - checkpointWal(); - // Surface accumulated workflow-logger issues for this unit to the user. - // Warnings/errors logged during the unit are buffered in the logger and - // drained here so the user sees a single consolidated post-unit alert. - const finalizedArtifactVerified = - shouldSkipArtifactVerification(iterData.unitType) || - verifyExpectedArtifact(iterData.unitType, iterData.unitId, s.basePath); - if (finalizedArtifactVerified) { - recordLearningOutcomeForUnit( - ic, - iterData.unitType, - iterData.unitId, - s.currentUnit?.startedAt, - { - succeeded: true, - verificationPassed: iterData.unitType === "execute-task" ? true : null, - }, - ); - // Clear the runtime unit record so it does not linger as a phantom - // "dispatched" unit across session restarts (#sf-moqv2k4g-kbg2nq). - clearUnitRuntimeRecord(s.basePath, iterData.unitType, iterData.unitId); - // Evict this unit from stuck-state recentUnits so a completed unit - // does not pollute the sliding window on restart. - const unitKey = `${iterData.unitType}/${iterData.unitId}`; - const prevLen = loopState.recentUnits.length; - loopState.recentUnits = loopState.recentUnits.filter( - (u) => u.key !== unitKey, - ); - if ( - loopState.recentUnits.length < prevLen && - loopState.stuckRecoveryAttempts > 0 - ) { - loopState.stuckRecoveryAttempts = 0; - } - } - if (hasAnyIssues()) { - const { logs } = drainAndSummarize(); - if (logs.length > 0) { - const severity = logs.some((e) => e.severity === "error") - ? "error" - : "warning"; - ctx.ui.notify(formatForNotification(logs), severity, { - kind: severity === "error" ? "notice" : "progress", - source: "workflow-logger", - dedupe_key: `workflow-issues:${iterData.unitType}:${iterData.unitId}`, - }); - } - } - // PhaseReview 3-pass (gated on uok.phase_review.enabled) - const uokFlagsForReview = resolveUokFlags(ic.prefs); - if (uokFlagsForReview.phaseReview) { - await runPhaseReview(ic, iterData); - } - return { action: "next", data: undefined }; -} - -/** - * PhaseReview 3-pass: optional post-unit review pipeline. - * - * Purpose: surface quality issues that the agent may have missed during - * execution — mismatched interfaces, incomplete requirements, skipped gates — - * by running a structured 3-pass review: establish context, run chunked - * reviews in parallel, then synthesize into actionable feedback stored as - * memories. Gated on `uok.phase_review.enabled: true` in preferences. - * - * Passes: - * 1. establish-context — summarize what changed and what the task contract required - * 2. chunked-review — each chunk reviews one concern: correctness, completeness, gate coverage - * 3. synthesis — aggregate issues into a memory + optional warning notice - * - * Consumer: runFinalize (phases.js) after post-unit verification passes. - */ -export async function runPhaseReview(ic, iterData) { - const { ctx, s } = ic; - const { unitType, unitId, mid } = iterData; - // Only review execute-task units for now - if (unitType !== "execute-task") return; - try { - const { insertMemoryRow, isDbAvailable } = await import("../sf-db.js"); - if (!isDbAvailable()) return; - const state = await import("../state.js").then((m) => - m.deriveState(s.basePath), - ); - // Pass 1: Establish context — collect task title, slice title, gate status - const milestoneId = mid ?? state.activeMilestone?.id ?? "unknown"; - const sid = state.activeSlice?.id ?? "unknown"; - const [taskId] = unitId.split("/").slice(-1); - const taskTitle = - state.activeTasks?.find((t) => t.id === taskId)?.title ?? taskId; - const sliceTitle = state.activeSlice?.title ?? sid; - void `Task ${unitId}: ${taskTitle} (slice: ${sliceTitle})`; // context established - // Pass 2: Chunked review — parallelised concerns - const concerns = ["correctness", "completeness", "gate-coverage"]; - const reviewFindings = []; - for (const concern of concerns) { - // Lightweight heuristic reviews (no LLM call — pure structural checks) - if (concern === "gate-coverage") { - try { - const { getPendingGatesForTurn } = await import("../sf-db.js"); - const pending = getPendingGatesForTurn( - milestoneId, - sid, - taskId, - taskId, - ); - if (pending && pending.length > 0) { - reviewFindings.push( - `gate-coverage: ${pending.length} gate(s) still pending after unit close — ${pending.map((g) => g.gate_id).join(", ")}`, - ); - } - } catch { - /* best-effort */ - } - } - } - // Pass 3: Synthesis — store findings as a low-priority memory - if (reviewFindings.length > 0) { - const content = `PhaseReview for ${unitId}:\n${reviewFindings.map((f) => `- ${f}`).join("\n")}`; - const now = new Date().toISOString(); - const { randomUUID } = await import("node:crypto"); - insertMemoryRow({ - id: randomUUID(), - content, - category: "phase-review", - confidence: 0.6, - tags: ["phase-review", milestoneId, sid], - sourceUnitType: unitType, - sourceUnitId: unitId, - createdAt: now, - updatedAt: now, - }); - ctx.ui.notify( - `PhaseReview: ${reviewFindings.length} finding(s) for ${unitId} stored as memories. Run /memory recent to inspect.`, - "info", - { - noticeKind: "SYSTEM_NOTICE", - dedupe_key: `phase-review:${unitId}`, - }, - ); - } - } catch { - // Best-effort — never fail the loop on a review error - } -} -export const resetSessionTimeoutState = resetConsecutiveSessionTimeouts; +export { assessUokDiagnosticsDispatchGate, runDispatch } from "./phases-dispatch.js"; +export { runGuards, requiresHumanProductionMutationApproval, _resolveDispatchGuardBasePath } from "./phases-guards.js"; +export { runPreDispatch } from "./phases-pre-dispatch.js"; +export { runUnitPhase, resetSessionTimeoutState } from "./phases-unit.js"; +export { runFinalize } from "./phases-finalize.js"; +export { _resolveReportBasePath } from "./phases-helpers.js"; diff --git a/src/resources/extensions/sf/commands-handlers.js b/src/resources/extensions/sf/commands-handlers.js index c09955654..e3b888d7e 100644 --- a/src/resources/extensions/sf/commands-handlers.js +++ b/src/resources/extensions/sf/commands-handlers.js @@ -61,6 +61,10 @@ export function dispatchDoctorHeal(pi, scope, reportText, structuredIssues) { const workflowPath = process.env.SF_WORKFLOW_PATH ?? join(sfHome(), "agent", "SF-WORKFLOW.md"); + const workflow = existsSync(workflowPath) + ? readFileSync(workflowPath, "utf-8") + : ""; + const prompt = loadPrompt("doctor-heal", { doctorSummary: reportText, structuredIssues, scopeLabel: scope ?? "active milestone / blocking scope", diff --git a/src/resources/extensions/sf/sf-db/sf-db-core.js b/src/resources/extensions/sf/sf-db/sf-db-core.js index c1918750b..db768b71a 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-core.js +++ b/src/resources/extensions/sf/sf-db/sf-db-core.js @@ -5,11 +5,11 @@ // Schema is initialized on first open with WAL mode for file-backed DBs. // // ─── Single-writer invariant ───────────────────────────────────────────── -// This file is the ONLY place in the codebase that issues write SQL +// The sf-db/ modules are the only place in the codebase that issue write SQL // (INSERT / UPDATE / DELETE / REPLACE / BEGIN-COMMIT transactions) against -// the engine database at `.sf/sf.db`. All other modules must call the -// typed wrappers exported here. The structural test -// `tests/single-writer-invariant.test.ts` fails CI if a new bypass appears. +// the engine database at `.sf/sf.db`. All other modules must call the typed +// wrappers exported through ../sf-db.js. Keep schema DDL in sf-db-schema.js and +// runtime writes in the relevant domain module. // // `_getAdapter()` is retained for read-only SELECTs in query modules // (context-store, memory-store queries, doctor checks, projections). @@ -19,12 +19,10 @@ // intentionally independent store for cross-worktree claim races and is // excluded from this invariant. import { - copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, - realpathSync, statSync, unlinkSync, writeFileSync, @@ -32,16 +30,14 @@ import { import { dirname, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { SF_STALE_STATE, SFError } from "../errors.js"; -import { getGateIdsForTurn } from "../gate-registry.js"; import { normalizeSchedulerStatus, - normalizeTaskStatus, taskFrontmatterFromRecord, withTaskFrontmatter, } from "../task-frontmatter.js"; -import { readTraceEvents } from "../uok/trace-writer.js"; import { logError, logWarning } from "../workflow-logger.js"; import { getErrorMessage } from "../error-utils.js"; +import { initSchema } from "./sf-db-schema.js"; let loadAttempted = false; function loadProvider() { @@ -247,1211 +243,6 @@ function performDatabaseMaintenance(rawDb, path) { ); } } -const SCHEMA_VERSION = 61; -function indexExists(db, name) { - return !!db - .prepare( - "SELECT 1 as present FROM sqlite_master WHERE type = 'index' AND name = ?", - ) - .get(name); -} -function dedupeVerificationEvidenceRows(db) { - db.exec(` - DELETE FROM verification_evidence - WHERE rowid NOT IN ( - SELECT MIN(rowid) - FROM verification_evidence - GROUP BY task_id, slice_id, milestone_id, command, verdict - ) - `); -} -function ensureVerificationEvidenceDedupIndex(db) { - if (indexExists(db, "idx_verification_evidence_dedup")) return; - dedupeVerificationEvidenceRows(db); - db.exec( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_evidence_dedup ON verification_evidence(task_id, slice_id, milestone_id, command, verdict)", - ); -} -function ensureRepoProfileTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS repo_profiles ( - profile_id TEXT PRIMARY KEY, - project_hash TEXT NOT NULL, - project_root TEXT NOT NULL DEFAULT '', - head TEXT DEFAULT NULL, - branch TEXT DEFAULT NULL, - remote_hash TEXT DEFAULT NULL, - dirty INTEGER NOT NULL DEFAULT 0, - profile_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS repo_file_observations ( - path TEXT PRIMARY KEY, - latest_profile_id TEXT NOT NULL, - git_status TEXT NOT NULL, - ownership TEXT NOT NULL, - language TEXT DEFAULT NULL, - size_bytes INTEGER NOT NULL DEFAULT 0, - content_hash TEXT DEFAULT NULL, - summary TEXT DEFAULT NULL, - first_seen_at TEXT NOT NULL, - last_seen_at TEXT NOT NULL, - adopted_at TEXT DEFAULT NULL, - adoption_unit_id TEXT DEFAULT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_repo_profiles_created ON repo_profiles(created_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_repo_file_observations_status ON repo_file_observations(git_status, ownership)", - ); -} -function ensureBacklogTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS backlog_items ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - note TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - triage_run_id TEXT DEFAULT NULL, - sequence INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - promoted_at TEXT DEFAULT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_backlog_items_status_sequence ON backlog_items(status, sequence, id)", - ); -} -function ensureScheduleTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS schedule_entries ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - scope TEXT NOT NULL DEFAULT 'project', - id TEXT NOT NULL, - schema_version INTEGER NOT NULL DEFAULT 1, - kind TEXT NOT NULL DEFAULT 'reminder', - status TEXT NOT NULL DEFAULT 'pending', - due_at TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT '', - snoozed_at TEXT DEFAULT NULL, - payload_json TEXT NOT NULL DEFAULT '{}', - created_by TEXT NOT NULL DEFAULT 'user', - autonomous_dispatch INTEGER NOT NULL DEFAULT 0, - full_json TEXT NOT NULL DEFAULT '{}', - imported_from TEXT DEFAULT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_schedule_entries_scope_id_created ON schedule_entries(scope, id, created_at DESC, seq DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_schedule_entries_scope_due ON schedule_entries(scope, status, due_at)", - ); - ensureColumn( - db, - "schedule_entries", - "autonomous_dispatch", - "ALTER TABLE schedule_entries ADD COLUMN autonomous_dispatch INTEGER NOT NULL DEFAULT 0", - ); -} -function ensureSolverEvalTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS solver_eval_runs ( - run_id TEXT PRIMARY KEY, - suite_source TEXT NOT NULL DEFAULT '', - cases_count INTEGER NOT NULL DEFAULT 0, - summary_json TEXT NOT NULL DEFAULT '{}', - report_path TEXT NOT NULL DEFAULT '', - results_path TEXT NOT NULL DEFAULT '', - db_recorded INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS solver_eval_case_results ( - run_id TEXT NOT NULL, - case_id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - mode TEXT NOT NULL, - passed INTEGER NOT NULL DEFAULT 0, - false_complete INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER DEFAULT NULL, - command_status INTEGER DEFAULT NULL, - solver_outcome TEXT DEFAULT NULL, - pdd_complete INTEGER DEFAULT NULL, - result_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - PRIMARY KEY (run_id, case_id, mode), - FOREIGN KEY (run_id) REFERENCES solver_eval_runs(run_id) ON DELETE CASCADE - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_solver_eval_runs_created ON solver_eval_runs(created_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_solver_eval_case_lookup ON solver_eval_case_results(run_id, case_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_solver_eval_case_false_complete ON solver_eval_case_results(false_complete, mode)", - ); -} -function ensureSessionTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - trace_id TEXT DEFAULT NULL, - mode TEXT NOT NULL DEFAULT 'interactive', - cwd TEXT NOT NULL DEFAULT '', - repo TEXT DEFAULT NULL, - branch TEXT DEFAULT NULL, - summary TEXT DEFAULT NULL, - summary_count INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS turns ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, - turn_index INTEGER NOT NULL, - user_message TEXT, - assistant_response TEXT, - ts TEXT NOT NULL, - UNIQUE(session_id, turn_index) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS session_file_touches ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, - path TEXT NOT NULL, - tool_name TEXT DEFAULT NULL, - turn_id INTEGER DEFAULT NULL REFERENCES turns(id), - first_seen_at TEXT NOT NULL, - UNIQUE(session_id, path) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS session_refs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, - ref_type TEXT NOT NULL, - ref_value TEXT NOT NULL, - turn_id INTEGER DEFAULT NULL REFERENCES turns(id), - created_at TEXT NOT NULL, - UNIQUE(session_id, ref_type, ref_value) - ) - `); - // FTS5 external-content table over turns for keyword recall. - // content_rowid links to turns.id; triggers below keep it in sync. - db.exec(` - CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5( - user_message, - assistant_response, - content='turns', - content_rowid='id' - ) - `); - db.exec(` - CREATE TRIGGER IF NOT EXISTS turns_fts_insert AFTER INSERT ON turns BEGIN - INSERT INTO turns_fts(rowid, user_message, assistant_response) - VALUES (new.id, new.user_message, new.assistant_response); - END - `); - db.exec(` - CREATE TRIGGER IF NOT EXISTS turns_fts_update AFTER UPDATE ON turns BEGIN - INSERT INTO turns_fts(turns_fts, rowid, user_message, assistant_response) - VALUES ('delete', old.id, old.user_message, old.assistant_response); - INSERT INTO turns_fts(rowid, user_message, assistant_response) - VALUES (new.id, new.user_message, new.assistant_response); - END - `); - db.exec(` - CREATE TRIGGER IF NOT EXISTS turns_fts_delete AFTER DELETE ON turns BEGIN - INSERT INTO turns_fts(turns_fts, rowid, user_message, assistant_response) - VALUES ('delete', old.id, old.user_message, old.assistant_response); - END - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repo, created_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index)", - ); - db.exec("CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC)"); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_session_file_touches_session ON session_file_touches(session_id, first_seen_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_session_file_touches_path ON session_file_touches(path, session_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_session_refs_session ON session_refs(session_id, created_at DESC)", - ); -} -function ensureSessionSnapshotTable(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS session_snapshots ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - -- Session that triggered this checkpoint. FK to sessions(session_id). - session_id TEXT NOT NULL, - -- Zero-based counter within the session (first snapshot = 0). - snapshot_index INTEGER NOT NULL DEFAULT 0, - -- Optional git stash ref so the snapshot can be restored exactly. - -- NULL when the working tree had no changes to stash. - git_stash_ref TEXT, - -- Free-text label for the snapshot (e.g. "before migration deploy"). - label TEXT, - ts TEXT NOT NULL, - UNIQUE(session_id, snapshot_index) - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_session_snapshots_session ON session_snapshots(session_id, snapshot_index)", - ); -} -function ensureHeadlessRunTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS headless_runs ( - run_id TEXT PRIMARY KEY, - command TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', - exit_code INTEGER NOT NULL DEFAULT 0, - timed_out INTEGER NOT NULL DEFAULT 0, - interrupted INTEGER NOT NULL DEFAULT 0, - restart_count INTEGER NOT NULL DEFAULT 0, - max_restarts INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER NOT NULL DEFAULT 0, - total_events INTEGER NOT NULL DEFAULT 0, - tool_calls INTEGER NOT NULL DEFAULT 0, - solver_eval_run_id TEXT DEFAULT NULL, - solver_eval_report_path TEXT DEFAULT NULL, - details_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_headless_runs_created ON headless_runs(created_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_headless_runs_status ON headless_runs(status, created_at DESC)", - ); -} -function ensureUokMessageTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS uok_messages ( - id TEXT PRIMARY KEY, - from_agent TEXT NOT NULL, - to_agent TEXT NOT NULL, - body TEXT NOT NULL DEFAULT '', - metadata_json TEXT NOT NULL DEFAULT '{}', - sent_at TEXT NOT NULL DEFAULT '', - delivered_at TEXT DEFAULT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS uok_message_reads ( - message_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - read_at TEXT NOT NULL DEFAULT '', - PRIMARY KEY (message_id, agent_id), - FOREIGN KEY (message_id) REFERENCES uok_messages(id) ON DELETE CASCADE - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_messages_to ON uok_messages(to_agent, sent_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_messages_conversation ON uok_messages(from_agent, to_agent, sent_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_messages_sent ON uok_messages(sent_at DESC)", - ); -} -function ensureDeployTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS deploy_runs ( - id TEXT PRIMARY KEY, - milestone_id TEXT NOT NULL, - target TEXT NOT NULL, - command TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - exit_code INTEGER DEFAULT NULL, - output TEXT DEFAULT NULL, - deployed_url TEXT DEFAULT NULL, - created_at TEXT NOT NULL, - finished_at TEXT DEFAULT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS smoke_results ( - id TEXT PRIMARY KEY, - deploy_run_id TEXT NOT NULL, - milestone_id TEXT NOT NULL, - url TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - verdict TEXT DEFAULT NULL, - checks_json TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - finished_at TEXT DEFAULT NULL, - FOREIGN KEY (deploy_run_id) REFERENCES deploy_runs(id) ON DELETE CASCADE - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS release_records ( - id TEXT PRIMARY KEY, - milestone_id TEXT NOT NULL, - version TEXT NOT NULL, - prev_version TEXT DEFAULT NULL, - changelog_entry TEXT DEFAULT NULL, - git_tag TEXT DEFAULT NULL, - published INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS rollback_runs ( - id TEXT PRIMARY KEY, - deploy_run_id TEXT NOT NULL, - milestone_id TEXT NOT NULL, - reason TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - output TEXT DEFAULT NULL, - created_at TEXT NOT NULL, - finished_at TEXT DEFAULT NULL, - FOREIGN KEY (deploy_run_id) REFERENCES deploy_runs(id) ON DELETE CASCADE - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_deploy_runs_milestone ON deploy_runs(milestone_id, created_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_smoke_results_deploy ON smoke_results(deploy_run_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_release_records_milestone ON release_records(milestone_id, created_at DESC)", - ); -} -function ensureSleeptimeQueueTable(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS sleeptime_consolidation_queue ( - id TEXT PRIMARY KEY, - conversation_agent TEXT NOT NULL, - memory_agent TEXT NOT NULL, - content TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL, - processed_at TEXT DEFAULT NULL, - result TEXT DEFAULT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_sleeptime_queue_status ON sleeptime_consolidation_queue(status, created_at ASC)", - ); -} -function ensureSelfFeedbackTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS self_feedback ( - id TEXT PRIMARY KEY, - ts TEXT NOT NULL, - kind TEXT NOT NULL, - severity TEXT NOT NULL, - blocking INTEGER NOT NULL DEFAULT 0, - repo_identity TEXT NOT NULL DEFAULT '', - sf_version TEXT NOT NULL DEFAULT '', - base_path TEXT NOT NULL DEFAULT '', - unit_type TEXT DEFAULT NULL, - milestone_id TEXT DEFAULT NULL, - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - summary TEXT NOT NULL DEFAULT '', - evidence TEXT NOT NULL DEFAULT '', - suggested_fix TEXT NOT NULL DEFAULT '', - full_json TEXT NOT NULL, - resolved_at TEXT DEFAULT NULL, - resolved_reason TEXT DEFAULT NULL, - resolved_by_sf_version TEXT DEFAULT NULL, - resolved_evidence_json TEXT DEFAULT NULL, - resolved_criteria_json TEXT DEFAULT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_self_feedback_open ON self_feedback(resolved_at, severity, ts)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_self_feedback_kind ON self_feedback(kind, ts)", - ); -} -function ensureRetrievalEvidenceTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS retrieval_evidence ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - backend TEXT NOT NULL, - source_kind TEXT NOT NULL DEFAULT 'code', - query TEXT NOT NULL DEFAULT '', - strategy TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT '', - project_root TEXT NOT NULL DEFAULT '', - git_head TEXT DEFAULT NULL, - git_branch TEXT DEFAULT NULL, - worktree_dirty INTEGER NOT NULL DEFAULT 0, - freshness TEXT NOT NULL DEFAULT 'unknown', - status TEXT NOT NULL DEFAULT 'ok', - hit_count INTEGER NOT NULL DEFAULT 0, - elapsed_ms INTEGER NOT NULL DEFAULT 0, - cache_path TEXT DEFAULT NULL, - error TEXT DEFAULT NULL, - result_json TEXT NOT NULL DEFAULT '{}', - recorded_at TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_backend_recorded ON retrieval_evidence(backend, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_scope_recorded ON retrieval_evidence(scope, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_status_recorded ON retrieval_evidence(status, recorded_at DESC)", - ); -} -function ensureTriageTables(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS triage_runs ( - id TEXT PRIMARY KEY, - source_file TEXT, - status TEXT NOT NULL DEFAULT 'complete', - result_summary_json TEXT, - created_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS triage_evals ( - id TEXT PRIMARY KEY, - run_id TEXT NOT NULL REFERENCES triage_runs(id), - task_input TEXT NOT NULL, - expected_behavior TEXT, - evidence TEXT, - failure_mode TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS triage_items ( - id TEXT PRIMARY KEY, - run_id TEXT NOT NULL REFERENCES triage_runs(id), - kind TEXT NOT NULL, - content TEXT NOT NULL, - evidence TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS triage_skills ( - id TEXT PRIMARY KEY, - run_id TEXT NOT NULL REFERENCES triage_runs(id), - name TEXT, - description TEXT, - trigger TEXT, - raw_json TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_triage_evals_run ON triage_evals(run_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_triage_items_run_kind ON triage_items(run_id, kind)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_triage_skills_run ON triage_skills(run_id)", - ); -} -function ensureRuntimeCounterTable(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS runtime_counters ( - key TEXT PRIMARY KEY, - value INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL - ) - `); -} -function ensureValidationAttentionMarkersTable(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS validation_attention_markers ( - milestone_id TEXT PRIMARY KEY, - created_at TEXT NOT NULL, - source TEXT, - remediation_round INTEGER, - revalidation_round INTEGER, - revalidation_requested_at TEXT - ) - `); -} -function ensureSpecSchemaTables(db) { - // Tier 1.3: Spec/Runtime/Evidence schema separation - // Creates 9 normalized tables for milestone, slice, task entities - // Each entity type has: _specs (immutable intent), (runtime state), _evidence (audit trail) - - // ── Milestone Spec Table (immutable record of intent) ─────────── - db.exec(` - CREATE TABLE IF NOT EXISTS milestone_specs ( - id TEXT NOT NULL, - vision TEXT NOT NULL DEFAULT '', - success_criteria TEXT DEFAULT '', - key_risks TEXT DEFAULT '', - proof_strategy TEXT DEFAULT '', - verification_contract TEXT DEFAULT '', - verification_integration TEXT DEFAULT '', - verification_operational TEXT DEFAULT '', - verification_uat TEXT DEFAULT '', - definition_of_done TEXT DEFAULT '', - requirement_coverage TEXT DEFAULT '', - boundary_map_markdown TEXT DEFAULT '', - vision_meeting_json TEXT DEFAULT '', - product_research_json TEXT DEFAULT '', - spec_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - PRIMARY KEY (id), - FOREIGN KEY (id) REFERENCES milestones(id) - ) - `); - - // ── Slice Spec Table (immutable record of intent) ─────────── - db.exec(` - CREATE TABLE IF NOT EXISTS slice_specs ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - goal TEXT NOT NULL DEFAULT '', - success_criteria TEXT DEFAULT '', - proof_level TEXT DEFAULT '', - integration_closure TEXT DEFAULT '', - observability_impact TEXT DEFAULT '', - adversarial_partner TEXT DEFAULT '', - adversarial_combatant TEXT DEFAULT '', - adversarial_architect TEXT DEFAULT '', - planning_meeting_json TEXT DEFAULT '', - spec_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - PRIMARY KEY (milestone_id, slice_id), - FOREIGN KEY (milestone_id) REFERENCES milestones(id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) - ) - `); - - // ── Task Spec Table (immutable record of intent) ─────────── - db.exec(` - CREATE TABLE IF NOT EXISTS task_specs ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - task_id TEXT NOT NULL, - verify TEXT NOT NULL DEFAULT '', - inputs TEXT DEFAULT '', - expected_output TEXT DEFAULT '', - risk TEXT NOT NULL DEFAULT 'low', - mutation_scope TEXT NOT NULL DEFAULT 'isolated', - verification_type TEXT NOT NULL DEFAULT 'self-check', - plan_approval TEXT NOT NULL DEFAULT 'not-required', - estimated_effort INTEGER DEFAULT NULL, - dependencies TEXT NOT NULL DEFAULT '[]', - blocks_parallel INTEGER NOT NULL DEFAULT 0, - requires_user_input INTEGER NOT NULL DEFAULT 0, - auto_retry INTEGER NOT NULL DEFAULT 1, - max_retries INTEGER NOT NULL DEFAULT 2, - spec_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - PRIMARY KEY (milestone_id, slice_id, task_id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), - FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) - ) - `); - - // ── Milestone Evidence Table (append-only audit trail) ─────────── - db.exec(` - CREATE TABLE IF NOT EXISTS milestone_evidence ( - milestone_id TEXT NOT NULL, - evidence_type TEXT NOT NULL, - content TEXT NOT NULL, - recorded_at TEXT NOT NULL, - phase_name TEXT DEFAULT '', - recorded_by TEXT DEFAULT '', - evidence_id TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))), - PRIMARY KEY (milestone_id, evidence_id), - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - - // ── Slice Evidence Table (append-only audit trail) ─────────── - db.exec(` - CREATE TABLE IF NOT EXISTS slice_evidence ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - evidence_type TEXT NOT NULL, - content TEXT NOT NULL, - recorded_at TEXT NOT NULL, - phase_name TEXT DEFAULT '', - recorded_by TEXT DEFAULT '', - evidence_id TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))), - PRIMARY KEY (milestone_id, slice_id, evidence_id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) - ) - `); - - // ── Task Evidence Table (append-only audit trail) ─────────── - db.exec(` - CREATE TABLE IF NOT EXISTS task_evidence ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - task_id TEXT NOT NULL, - evidence_type TEXT NOT NULL, - content TEXT NOT NULL, - recorded_at TEXT NOT NULL, - phase_name TEXT DEFAULT '', - recorded_by TEXT DEFAULT '', - evidence_id TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))), - PRIMARY KEY (milestone_id, slice_id, task_id, evidence_id), - FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) - ) - `); - - // Indices for efficient querying of evidence trails - db.exec(` - CREATE INDEX IF NOT EXISTS idx_milestone_evidence_type - ON milestone_evidence(milestone_id, evidence_type, recorded_at DESC) - `); - db.exec(` - CREATE INDEX IF NOT EXISTS idx_slice_evidence_type - ON slice_evidence(milestone_id, slice_id, evidence_type, recorded_at DESC) - `); - db.exec(` - CREATE INDEX IF NOT EXISTS idx_task_evidence_type - ON task_evidence(milestone_id, slice_id, task_id, evidence_type, recorded_at DESC) - `); -} -function initSchema(db, fileBacked) { - if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); - if (fileBacked) db.exec("PRAGMA busy_timeout = 5000"); - if (fileBacked) db.exec("PRAGMA synchronous = NORMAL"); - // Disable SQLite's automatic WAL checkpoint (default: every 1000 pages). - // Auto-checkpoint fires at unpredictable times — if the process is killed - // mid-checkpoint (e.g., OOM), the main DB is partially written with an - // empty WAL and cannot be recovered. Explicit checkpoints are issued at - // safe loop boundaries instead (post-unit finalize, close). - if (fileBacked) db.exec("PRAGMA wal_autocheckpoint=0"); - if (fileBacked) db.exec("PRAGMA auto_vacuum = INCREMENTAL"); - if (fileBacked) db.exec("PRAGMA cache_size = -8000"); // 8 MB page cache - if (fileBacked && process.platform !== "darwin") - db.exec("PRAGMA mmap_size = 67108864"); // 64 MB mmap - db.exec("PRAGMA temp_store = MEMORY"); - db.exec("PRAGMA foreign_keys = ON"); - db.exec("BEGIN"); - try { - db.exec(` - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER NOT NULL, - applied_at TEXT NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS decisions ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - when_context TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT '', - decision TEXT NOT NULL DEFAULT '', - choice TEXT NOT NULL DEFAULT '', - rationale TEXT NOT NULL DEFAULT '', - revisable TEXT NOT NULL DEFAULT '', - made_by TEXT NOT NULL DEFAULT 'agent', - superseded_by TEXT DEFAULT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS requirements ( - id TEXT PRIMARY KEY, - class TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', - why TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', - primary_owner TEXT NOT NULL DEFAULT '', - supporting_slices TEXT NOT NULL DEFAULT '', - validation TEXT NOT NULL DEFAULT '', - notes TEXT NOT NULL DEFAULT '', - full_content TEXT NOT NULL DEFAULT '', - superseded_by TEXT DEFAULT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS artifacts ( - path TEXT PRIMARY KEY, - artifact_type TEXT NOT NULL DEFAULT '', - milestone_id TEXT DEFAULT NULL, - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - full_content TEXT NOT NULL DEFAULT '', - imported_at TEXT NOT NULL DEFAULT '' - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS memories ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - category TEXT NOT NULL, - content TEXT NOT NULL, - confidence REAL NOT NULL DEFAULT 0.8, - source_unit_type TEXT, - source_unit_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - superseded_by TEXT DEFAULT NULL, - hit_count INTEGER NOT NULL DEFAULT 0, - tags TEXT NOT NULL DEFAULT '[]' - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS memory_processed_units ( - unit_key TEXT PRIMARY KEY, - activity_file TEXT, - processed_at TEXT NOT NULL - ) - `); - // memory_embeddings, memory_relations, memory_sources used to be referenced - // by helper functions and queries (memory-embeddings.ts, memory-relations.ts, - // memory-ingest.ts) without a corresponding CREATE TABLE — any actual write - // would have failed with "no such table". Creating them as IF NOT EXISTS so - // existing DBs that somehow have them survive, and fresh DBs work. - db.exec(` - CREATE TABLE IF NOT EXISTS memory_embeddings ( - memory_id TEXT PRIMARY KEY, - model TEXT NOT NULL, - dim INTEGER NOT NULL, - vector BLOB NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS memory_relations ( - from_id TEXT NOT NULL, - to_id TEXT NOT NULL, - rel TEXT NOT NULL, - confidence REAL NOT NULL DEFAULT 0.8, - created_at TEXT NOT NULL, - PRIMARY KEY (from_id, to_id, rel), - FOREIGN KEY (from_id) REFERENCES memories(id) ON DELETE CASCADE, - FOREIGN KEY (to_id) REFERENCES memories(id) ON DELETE CASCADE - ) - `); - // PK covers from_id as leading column already; reverse lookups - // (memory-relations.ts queries WHERE to_id = ?) need their own index - // to avoid a full table scan as the relation count grows. - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memory_relations_to ON memory_relations(to_id)", - ); - db.exec(` - CREATE TABLE IF NOT EXISTS memory_sources ( - id TEXT PRIMARY KEY, - kind TEXT NOT NULL, - uri TEXT, - title TEXT, - content TEXT NOT NULL, - content_hash TEXT NOT NULL, - imported_at TEXT NOT NULL, - scope TEXT NOT NULL DEFAULT 'project', - tags TEXT NOT NULL DEFAULT '[]' - ) - `); - // content_hash is queried on every insert for deduplication; without an - // index the lookup becomes a full table scan as ingestion volume grows. - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", - ); - // Category GROUP BY queries (e.g. /memory stats) need a covering - // index that filters active memories and groups by category. - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(superseded_by, category)", - ); - db.exec(` - CREATE TABLE IF NOT EXISTS judgments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - decision TEXT NOT NULL DEFAULT '', - alternatives_json TEXT NOT NULL DEFAULT '[]', - reasoning TEXT NOT NULL DEFAULT '', - confidence TEXT NOT NULL DEFAULT 'medium', - ts TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_judgments_unit_id ON judgments(unit_id, ts DESC)", - ); - db.exec(` - CREATE TABLE IF NOT EXISTS milestones ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - depends_on TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL DEFAULT '', - completed_at TEXT DEFAULT NULL, - vision TEXT NOT NULL DEFAULT '', - success_criteria TEXT NOT NULL DEFAULT '[]', - key_risks TEXT NOT NULL DEFAULT '[]', - proof_strategy TEXT NOT NULL DEFAULT '[]', - verification_contract TEXT NOT NULL DEFAULT '', - verification_integration TEXT NOT NULL DEFAULT '', - verification_operational TEXT NOT NULL DEFAULT '', - verification_uat TEXT NOT NULL DEFAULT '', - definition_of_done TEXT NOT NULL DEFAULT '[]', - requirement_coverage TEXT NOT NULL DEFAULT '', - boundary_map_markdown TEXT NOT NULL DEFAULT '', - vision_meeting_json TEXT NOT NULL DEFAULT '', - product_research_json TEXT NOT NULL DEFAULT '', - sequence INTEGER DEFAULT 0 - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS slices ( - milestone_id TEXT NOT NULL, - id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - risk TEXT NOT NULL DEFAULT 'medium', - depends TEXT NOT NULL DEFAULT '[]', - demo TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT '', - completed_at TEXT DEFAULT NULL, - full_summary_md TEXT NOT NULL DEFAULT '', - full_uat_md TEXT NOT NULL DEFAULT '', - goal TEXT NOT NULL DEFAULT '', - success_criteria TEXT NOT NULL DEFAULT '', - proof_level TEXT NOT NULL DEFAULT '', - integration_closure TEXT NOT NULL DEFAULT '', - observability_impact TEXT NOT NULL DEFAULT '', - adversarial_partner TEXT NOT NULL DEFAULT '', - adversarial_combatant TEXT NOT NULL DEFAULT '', - adversarial_architect TEXT NOT NULL DEFAULT '', - planning_meeting_json TEXT NOT NULL DEFAULT '', - sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order - replan_triggered_at TEXT DEFAULT NULL, - is_sketch INTEGER NOT NULL DEFAULT 0, -- SF ADR-011: 1 = slice is a sketch awaiting refine-slice - sketch_scope TEXT NOT NULL DEFAULT '', -- SF ADR-011: 2-3 sentence scope hint from plan-milestone - PRIMARY KEY (milestone_id, id), - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS tasks ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - one_liner TEXT NOT NULL DEFAULT '', - narrative TEXT NOT NULL DEFAULT '', - verification_result TEXT NOT NULL DEFAULT '', - duration TEXT NOT NULL DEFAULT '', - completed_at TEXT DEFAULT NULL, - blocker_discovered INTEGER DEFAULT 0, - deviations TEXT NOT NULL DEFAULT '', - known_issues TEXT NOT NULL DEFAULT '', - key_files TEXT NOT NULL DEFAULT '[]', - key_decisions TEXT NOT NULL DEFAULT '[]', - full_summary_md TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', - estimate TEXT NOT NULL DEFAULT '', - files TEXT NOT NULL DEFAULT '[]', - verify TEXT NOT NULL DEFAULT '', - inputs TEXT NOT NULL DEFAULT '[]', - expected_output TEXT NOT NULL DEFAULT '[]', - observability_impact TEXT NOT NULL DEFAULT '', - full_plan_md TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT '', - verification_status TEXT NOT NULL DEFAULT '', - risk TEXT NOT NULL DEFAULT 'low', - mutation_scope TEXT NOT NULL DEFAULT 'isolated', - verification_type TEXT NOT NULL DEFAULT 'self-check', - plan_approval TEXT NOT NULL DEFAULT 'not-required', - task_status TEXT NOT NULL DEFAULT 'todo', - estimated_effort INTEGER DEFAULT NULL, - dependencies TEXT NOT NULL DEFAULT '[]', - blocks_parallel INTEGER NOT NULL DEFAULT 0, - requires_user_input INTEGER NOT NULL DEFAULT 0, - auto_retry INTEGER NOT NULL DEFAULT 1, - max_retries INTEGER NOT NULL DEFAULT 2, - frontmatter_version INTEGER NOT NULL DEFAULT 1, - sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order - escalation_pending INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): pause-on-escalation flag - escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): continueWithDefault=true marker (no pause) - escalation_override_applied INTEGER NOT NULL DEFAULT 0, -- SF ADR-011 P2: 1 once carry-forward injected into a downstream prompt - escalation_artifact_path TEXT DEFAULT NULL, -- ADR-011 P2 (SF): path to T##-ESCALATION.json - PRIMARY KEY (milestone_id, slice_id, id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) - ) - `); - ensureTaskSchedulerTable(db); - if (columnExists(db, "tasks", "escalation_pending")) { - db.exec(` - CREATE INDEX IF NOT EXISTS idx_tasks_escalation_pending ON tasks(milestone_id, slice_id, escalation_pending) - `); - } - db.exec(` - CREATE TABLE IF NOT EXISTS verification_evidence ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL DEFAULT '', - slice_id TEXT NOT NULL DEFAULT '', - milestone_id TEXT NOT NULL DEFAULT '', - command TEXT NOT NULL DEFAULT '', - exit_code INTEGER DEFAULT 0, - verdict TEXT NOT NULL DEFAULT '', - duration_ms INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS replan_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - milestone_id TEXT NOT NULL DEFAULT '', - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - summary TEXT NOT NULL DEFAULT '', - previous_artifact_path TEXT DEFAULT NULL, - replacement_artifact_path TEXT DEFAULT NULL, - created_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS assessments ( - path TEXT PRIMARY KEY, - milestone_id TEXT NOT NULL DEFAULT '', - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - status TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT '', - full_content TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS quality_gates ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - gate_id TEXT NOT NULL, - scope TEXT NOT NULL DEFAULT 'slice', - task_id TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - verdict TEXT NOT NULL DEFAULT '', - rationale TEXT NOT NULL DEFAULT '', - findings TEXT NOT NULL DEFAULT '', - evaluated_at TEXT DEFAULT NULL, - PRIMARY KEY (milestone_id, slice_id, gate_id, task_id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) - ) - `); - // Slice dependency junction table (v14) - db.exec(` - CREATE TABLE IF NOT EXISTS slice_dependencies ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - depends_on_slice_id TEXT NOT NULL, - PRIMARY KEY (milestone_id, slice_id, depends_on_slice_id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), - FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS gate_circuit_breakers ( - gate_id TEXT PRIMARY KEY, - state TEXT NOT NULL DEFAULT 'closed', - failure_streak INTEGER NOT NULL DEFAULT 0, - last_failure_at TEXT DEFAULT NULL, - opened_at TEXT DEFAULT NULL, - half_open_attempts INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT '' - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS audit_turn_index ( - trace_id TEXT NOT NULL, - turn_id TEXT NOT NULL, - first_ts TEXT NOT NULL, - last_ts TEXT NOT NULL, - event_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (trace_id, turn_id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS llm_task_outcomes ( - model_id TEXT NOT NULL, - provider TEXT NOT NULL, - unit_type TEXT NOT NULL, - unit_id TEXT NOT NULL, - succeeded INTEGER NOT NULL DEFAULT 0, - retries INTEGER NOT NULL DEFAULT 0, - escalated INTEGER NOT NULL DEFAULT 0, - verification_passed INTEGER DEFAULT NULL, - blocker_discovered INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER DEFAULT NULL, - tokens_total INTEGER DEFAULT NULL, - cost_usd REAL DEFAULT NULL, - failure_mode TEXT DEFAULT NULL, - recorded_at INTEGER NOT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS uok_runs ( - run_id TEXT PRIMARY KEY, - session_id TEXT DEFAULT NULL, - path TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'started', - started_at TEXT NOT NULL, - ended_at TEXT DEFAULT NULL, - error TEXT DEFAULT NULL, - flags_json TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL - ) - `); - ensureSelfFeedbackTables(db); - ensureSolverEvalTables(db); - ensureRetrievalEvidenceTables(db); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)", - ); - // v13 indexes — hot-path dispatch queries - db.exec( - "CREATE INDEX IF NOT EXISTS idx_tasks_active ON tasks(milestone_id, slice_id, status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_slices_active ON slices(milestone_id, status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)", - ); - ensureVerificationEvidenceDedupIndex(db); - // v14 index — slice dependency lookups - db.exec( - "CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)", - ); - db.exec( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_task_outcomes_identity ON llm_task_outcomes(unit_type, unit_id, recorded_at)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_model_unit ON llm_task_outcomes(model_id, unit_type, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_unit ON llm_task_outcomes(unit_type, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_provider ON llm_task_outcomes(provider, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_runs_status_started ON uok_runs(status, started_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_runs_session ON uok_runs(session_id, started_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_self_feedback_open ON self_feedback(resolved_at, severity, ts)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_self_feedback_kind ON self_feedback(kind, ts)", - ); - ensureRepoProfileTables(db); - ensureBacklogTables(db); - ensureScheduleTables(db); - ensureSolverEvalTables(db); - ensureHeadlessRunTables(db); - ensureSessionTables(db); - ensureSessionSnapshotTable(db); - ensureUokMessageTables(db); - ensureDeployTables(db); - ensureSleeptimeQueueTable(db); - ensureSpecSchemaTables(db); - ensureTaskFrontmatterColumns(db); - ensureRetrievalEvidenceTables(db); - ensureTriageTables(db); - ensureRuntimeCounterTable(db); - ensureValidationAttentionMarkersTable(db); - db.exec( - `CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`, - ); - db.exec( - `CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`, - ); - db.exec( - `CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`, - ); - db.exec( - `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, - ); - db.exec(` - CREATE VIEW IF NOT EXISTS v_task_full AS - SELECT t.*, ts.spec_version, ts.verify AS spec_verify, - ts.inputs AS spec_inputs, ts.expected_output AS spec_expected_output - FROM tasks t - LEFT JOIN task_specs ts - ON t.milestone_id = ts.milestone_id - AND t.slice_id = ts.slice_id - AND t.id = ts.task_id - `); - const existing = db - .prepare("SELECT count(*) as cnt FROM schema_version") - .get(); - if (existing && existing["cnt"] === 0) { - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": SCHEMA_VERSION, - ":applied_at": new Date().toISOString(), - }); - } - db.exec("COMMIT"); - } catch (err) { - db.exec("ROLLBACK"); - throw err; - } - migrateSchema(db); -} -function columnExists(db, table, column) { - const rows = db.prepare(`PRAGMA table_info(${table})`).all(); - return rows.some((row) => row["name"] === column); -} -function tableExists(db, table) { - const row = db - .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`) - .get(table); - return row != null; -} -function ensureColumn(db, table, column, ddl) { - if (!columnExists(db, table, column)) db.exec(ddl); -} export function hasPlanningPayload(planning = {}) { return ( Boolean(planning.vision) || @@ -1495,1799 +286,6 @@ export function isEmptyMilestoneSpec(row) { (row["product_research_json"] ?? "") === "" ); } -function ensureTaskCreatedAtColumn(db) { - ensureColumn( - db, - "tasks", - "created_at", - `ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`, - ); -} -function ensureTaskFrontmatterColumns(db) { - ensureColumn( - db, - "tasks", - "risk", - `ALTER TABLE tasks ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`, - ); - ensureColumn( - db, - "tasks", - "mutation_scope", - `ALTER TABLE tasks ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`, - ); - ensureColumn( - db, - "tasks", - "verification_type", - `ALTER TABLE tasks ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`, - ); - ensureColumn( - db, - "tasks", - "plan_approval", - `ALTER TABLE tasks ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`, - ); - ensureColumn( - db, - "tasks", - "task_status", - `ALTER TABLE tasks ADD COLUMN task_status TEXT NOT NULL DEFAULT 'todo'`, - ); - ensureColumn( - db, - "tasks", - "estimated_effort", - `ALTER TABLE tasks ADD COLUMN estimated_effort INTEGER DEFAULT NULL`, - ); - ensureColumn( - db, - "tasks", - "dependencies", - `ALTER TABLE tasks ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "tasks", - "blocks_parallel", - `ALTER TABLE tasks ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`, - ); - ensureColumn( - db, - "tasks", - "requires_user_input", - `ALTER TABLE tasks ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`, - ); - ensureColumn( - db, - "tasks", - "auto_retry", - `ALTER TABLE tasks ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`, - ); - ensureColumn( - db, - "tasks", - "max_retries", - `ALTER TABLE tasks ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`, - ); - for (const table of ["task_specs"]) { - ensureColumn( - db, - table, - "risk", - `ALTER TABLE ${table} ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`, - ); - ensureColumn( - db, - table, - "mutation_scope", - `ALTER TABLE ${table} ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`, - ); - ensureColumn( - db, - table, - "verification_type", - `ALTER TABLE ${table} ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`, - ); - ensureColumn( - db, - table, - "plan_approval", - `ALTER TABLE ${table} ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`, - ); - ensureColumn( - db, - table, - "estimated_effort", - `ALTER TABLE ${table} ADD COLUMN estimated_effort INTEGER DEFAULT NULL`, - ); - ensureColumn( - db, - table, - "dependencies", - `ALTER TABLE ${table} ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - table, - "blocks_parallel", - `ALTER TABLE ${table} ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`, - ); - ensureColumn( - db, - table, - "requires_user_input", - `ALTER TABLE ${table} ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`, - ); - ensureColumn( - db, - table, - "auto_retry", - `ALTER TABLE ${table} ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`, - ); - ensureColumn( - db, - table, - "max_retries", - `ALTER TABLE ${table} ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`, - ); - } -} -function ensureTaskSchedulerTable(db) { - db.exec(` - CREATE TABLE IF NOT EXISTS task_scheduler ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - task_id TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'queued', - due_at TEXT DEFAULT NULL, - claimed_by TEXT DEFAULT NULL, - dispatched_at TEXT DEFAULT NULL, - consumed_at TEXT DEFAULT NULL, - expires_at TEXT DEFAULT NULL, - updated_at TEXT NOT NULL DEFAULT '', - PRIMARY KEY (milestone_id, slice_id, task_id), - FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) - ) - `); - db.exec(` - CREATE INDEX IF NOT EXISTS idx_task_scheduler_status - ON task_scheduler(status, due_at) - `); -} -function migrateCostUsdToMicroUsd(db) { - // Tier 2.7: Migrate cost_usd REAL to cost_micro_usd INTEGER - // Converts floating-point USD values to integer micro-USD (multiply by 1,000,000) - // Benefits: eliminates float drift on accumulated costs, easier reasoning about totals - // Purpose: Enable accurate cost tracking at scale without rounding errors - // Consumer: gate_runs cost tracking, cost analytics, budget checks - - // Guard: gate_runs may not exist in minimal legacy DBs (it will be dropped in v58) - if (!tableExists(db, "gate_runs")) return; - - // Add cost_micro_usd column if it doesn't exist - if (!columnExists(db, "gate_runs", "cost_micro_usd")) { - db.exec( - `ALTER TABLE gate_runs ADD COLUMN cost_micro_usd INTEGER DEFAULT NULL`, - ); - } - - // Migrate data: convert cost_usd to cost_micro_usd - // NULL values stay NULL; non-NULL values are multiplied by 1,000,000 - if (columnExists(db, "gate_runs", "cost_usd")) { - db.prepare(` - UPDATE gate_runs - SET cost_micro_usd = CAST(ROUND(cost_usd * 1000000) AS INTEGER) - WHERE cost_usd IS NOT NULL - AND cost_micro_usd IS NULL - `).run(); - } - - // Drop old cost_usd column (SQLite ALTER TABLE DROP is only available in 3.35.0+) - // For safety, we keep the old column as deprecated but unused - // Future: drop after confirming all queries use cost_micro_usd -} -function populateSpecTablesFromExisting(db) { - // Tier 1.3 Phase 2: Migrate existing spec data to new spec tables - // This populates milestone_specs, slice_specs, task_specs from existing columns - // Evidence tables are left empty; they populate as tools create new evidence. - - const now = new Date().toISOString(); - - // Migrate milestone specs - db.prepare(` - INSERT OR IGNORE INTO milestone_specs ( - id, vision, success_criteria, key_risks, proof_strategy, - verification_contract, verification_integration, verification_operational, verification_uat, - definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json, - spec_version, created_at - ) - SELECT - id, vision, success_criteria, key_risks, proof_strategy, - verification_contract, verification_integration, verification_operational, verification_uat, - definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, '', - 1, COALESCE(created_at, ?) - FROM milestones - WHERE id NOT IN (SELECT id FROM milestone_specs) - `).run(now); - - // Migrate slice specs - db.prepare(` - INSERT OR IGNORE INTO slice_specs ( - milestone_id, slice_id, goal, success_criteria, proof_level, - integration_closure, observability_impact, - adversarial_partner, adversarial_combatant, adversarial_architect, - planning_meeting_json, spec_version, created_at - ) - SELECT - milestone_id, id, goal, success_criteria, proof_level, - integration_closure, observability_impact, - adversarial_partner, adversarial_combatant, adversarial_architect, - planning_meeting_json, 1, COALESCE(created_at, ?) - FROM slices - WHERE (milestone_id, id) NOT IN (SELECT milestone_id, slice_id FROM slice_specs) - `).run(now); - - // Migrate task specs - db.prepare(` - INSERT OR IGNORE INTO task_specs ( - milestone_id, slice_id, task_id, verify, inputs, expected_output, - spec_version, created_at - ) - SELECT - milestone_id, slice_id, id, verify, inputs, expected_output, - 1, COALESCE(created_at, ?) - FROM tasks - WHERE (milestone_id, slice_id, id) NOT IN (SELECT milestone_id, slice_id, task_id FROM task_specs) - `).run(now); -} -function migrateSchema(db) { - const row = withQueryTimeout( - () => db.prepare("SELECT MAX(version) as v FROM schema_version").get(), - null, - ); - const currentVersion = row ? row["v"] : 0; - if (currentVersion >= SCHEMA_VERSION) return; - // Backup database before migration so a mid-migration crash doesn't - // leave a partially-migrated DB with no recovery path. - // WAL-safe: checkpoint first to flush WAL into the main DB file, then copy. - if (currentPath && currentPath !== ":memory:" && existsSync(currentPath)) { - try { - const backupPath = `${currentPath}.backup-v${currentVersion}`; - if (!existsSync(backupPath)) { - // Flush WAL to main DB file before copying — without this, the backup - // may be missing committed data that only exists in the -wal file. - try { - db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); - } catch { - /* checkpoint is best-effort */ - } - copyFileSync(currentPath, backupPath); - } - } catch (backupErr) { - // Log but proceed — blocking migration leaves the DB stuck at an old - // schema version permanently on read-only or full filesystems. - logWarning( - "db", - `Pre-migration backup failed: ${getErrorMessage(backupErr)}`, - ); - } - } - db.exec("BEGIN"); - try { - if (currentVersion < 2) { - db.exec(` - CREATE TABLE IF NOT EXISTS artifacts ( - path TEXT PRIMARY KEY, - artifact_type TEXT NOT NULL DEFAULT '', - milestone_id TEXT DEFAULT NULL, - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - full_content TEXT NOT NULL DEFAULT '', - imported_at TEXT NOT NULL DEFAULT '' - ) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 2, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 3) { - db.exec(` - CREATE TABLE IF NOT EXISTS memories ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - category TEXT NOT NULL, - content TEXT NOT NULL, - confidence REAL NOT NULL DEFAULT 0.8, - source_unit_type TEXT, - source_unit_id TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - superseded_by TEXT DEFAULT NULL, - hit_count INTEGER NOT NULL DEFAULT 0 - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS memory_processed_units ( - unit_key TEXT PRIMARY KEY, - activity_file TEXT, - processed_at TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)", - ); - db.exec("DROP VIEW IF EXISTS active_memories"); - db.exec( - "CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 3, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 4) { - ensureColumn( - db, - "decisions", - "made_by", - `ALTER TABLE decisions ADD COLUMN made_by TEXT NOT NULL DEFAULT 'agent'`, - ); - db.exec("DROP VIEW IF EXISTS active_decisions"); - db.exec( - "CREATE VIEW active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 4, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 5) { - db.exec(` - CREATE TABLE IF NOT EXISTS milestones ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - created_at TEXT NOT NULL, - completed_at TEXT DEFAULT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS slices ( - milestone_id TEXT NOT NULL, - id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - risk TEXT NOT NULL DEFAULT 'medium', - created_at TEXT NOT NULL DEFAULT '', - completed_at TEXT DEFAULT NULL, - PRIMARY KEY (milestone_id, id), - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS tasks ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - id TEXT NOT NULL, - title TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - one_liner TEXT NOT NULL DEFAULT '', - narrative TEXT NOT NULL DEFAULT '', - verification_result TEXT NOT NULL DEFAULT '', - duration TEXT NOT NULL DEFAULT '', - completed_at TEXT DEFAULT NULL, - blocker_discovered INTEGER DEFAULT 0, - deviations TEXT NOT NULL DEFAULT '', - known_issues TEXT NOT NULL DEFAULT '', - key_files TEXT NOT NULL DEFAULT '[]', - key_decisions TEXT NOT NULL DEFAULT '[]', - full_summary_md TEXT NOT NULL DEFAULT '', - PRIMARY KEY (milestone_id, slice_id, id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS verification_evidence ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL DEFAULT '', - slice_id TEXT NOT NULL DEFAULT '', - milestone_id TEXT NOT NULL DEFAULT '', - command TEXT NOT NULL DEFAULT '', - exit_code INTEGER DEFAULT 0, - verdict TEXT NOT NULL DEFAULT '', - duration_ms INTEGER DEFAULT 0, - created_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) - ) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 5, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 6) { - ensureColumn( - db, - "slices", - "full_summary_md", - `ALTER TABLE slices ADD COLUMN full_summary_md TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "full_uat_md", - `ALTER TABLE slices ADD COLUMN full_uat_md TEXT NOT NULL DEFAULT ''`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 6, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 7) { - ensureColumn( - db, - "slices", - "depends", - `ALTER TABLE slices ADD COLUMN depends TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "slices", - "demo", - `ALTER TABLE slices ADD COLUMN demo TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "depends_on", - `ALTER TABLE milestones ADD COLUMN depends_on TEXT NOT NULL DEFAULT '[]'`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 7, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 8) { - ensureColumn( - db, - "milestones", - "vision", - `ALTER TABLE milestones ADD COLUMN vision TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "success_criteria", - `ALTER TABLE milestones ADD COLUMN success_criteria TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "milestones", - "key_risks", - `ALTER TABLE milestones ADD COLUMN key_risks TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "milestones", - "proof_strategy", - `ALTER TABLE milestones ADD COLUMN proof_strategy TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "milestones", - "verification_contract", - `ALTER TABLE milestones ADD COLUMN verification_contract TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "verification_integration", - `ALTER TABLE milestones ADD COLUMN verification_integration TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "verification_operational", - `ALTER TABLE milestones ADD COLUMN verification_operational TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "verification_uat", - `ALTER TABLE milestones ADD COLUMN verification_uat TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "definition_of_done", - `ALTER TABLE milestones ADD COLUMN definition_of_done TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "milestones", - "requirement_coverage", - `ALTER TABLE milestones ADD COLUMN requirement_coverage TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestones", - "boundary_map_markdown", - `ALTER TABLE milestones ADD COLUMN boundary_map_markdown TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "goal", - `ALTER TABLE slices ADD COLUMN goal TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "success_criteria", - `ALTER TABLE slices ADD COLUMN success_criteria TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "proof_level", - `ALTER TABLE slices ADD COLUMN proof_level TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "integration_closure", - `ALTER TABLE slices ADD COLUMN integration_closure TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "observability_impact", - `ALTER TABLE slices ADD COLUMN observability_impact TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "uat_verdict", - `ALTER TABLE slices ADD COLUMN uat_verdict TEXT DEFAULT NULL`, - ); - ensureColumn( - db, - "tasks", - "description", - `ALTER TABLE tasks ADD COLUMN description TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "tasks", - "estimate", - `ALTER TABLE tasks ADD COLUMN estimate TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "tasks", - "files", - `ALTER TABLE tasks ADD COLUMN files TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "tasks", - "verify", - `ALTER TABLE tasks ADD COLUMN verify TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "tasks", - "inputs", - `ALTER TABLE tasks ADD COLUMN inputs TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "tasks", - "expected_output", - `ALTER TABLE tasks ADD COLUMN expected_output TEXT NOT NULL DEFAULT '[]'`, - ); - ensureColumn( - db, - "tasks", - "observability_impact", - `ALTER TABLE tasks ADD COLUMN observability_impact TEXT NOT NULL DEFAULT ''`, - ); - db.exec(` - CREATE TABLE IF NOT EXISTS replan_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - milestone_id TEXT NOT NULL DEFAULT '', - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - summary TEXT NOT NULL DEFAULT '', - previous_artifact_path TEXT DEFAULT NULL, - replacement_artifact_path TEXT DEFAULT NULL, - created_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS assessments ( - path TEXT PRIMARY KEY, - milestone_id TEXT NOT NULL DEFAULT '', - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - status TEXT NOT NULL DEFAULT '', - scope TEXT NOT NULL DEFAULT '', - full_content TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 8, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 9) { - ensureColumn( - db, - "slices", - "sequence", - `ALTER TABLE slices ADD COLUMN sequence INTEGER DEFAULT 0`, - ); - ensureColumn( - db, - "tasks", - "sequence", - `ALTER TABLE tasks ADD COLUMN sequence INTEGER DEFAULT 0`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 9, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 10) { - ensureColumn( - db, - "slices", - "replan_triggered_at", - `ALTER TABLE slices ADD COLUMN replan_triggered_at TEXT DEFAULT NULL`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 10, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 11) { - ensureColumn( - db, - "tasks", - "full_plan_md", - `ALTER TABLE tasks ADD COLUMN full_plan_md TEXT NOT NULL DEFAULT ''`, - ); - // Add unique constraint to replan_history for idempotency: - // one replan record per blocker task per slice per milestone. - db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_replan_history_unique - ON replan_history(milestone_id, slice_id, task_id) - WHERE slice_id IS NOT NULL AND task_id IS NOT NULL - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 11, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 12) { - db.exec(` - CREATE TABLE IF NOT EXISTS quality_gates ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - gate_id TEXT NOT NULL, - scope TEXT NOT NULL DEFAULT 'slice', - task_id TEXT DEFAULT NULL, - status TEXT NOT NULL DEFAULT 'pending', - verdict TEXT NOT NULL DEFAULT '', - rationale TEXT NOT NULL DEFAULT '', - findings TEXT NOT NULL DEFAULT '', - evaluated_at TEXT DEFAULT NULL, - PRIMARY KEY (milestone_id, slice_id, gate_id, COALESCE(task_id, '')), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) - ) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 12, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 13) { - // Hot-path indexes for auto-loop dispatch queries - db.exec( - "CREATE INDEX IF NOT EXISTS idx_tasks_active ON tasks(milestone_id, slice_id, status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_slices_active ON slices(milestone_id, status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)", - ); - ensureVerificationEvidenceDedupIndex(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 13, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 14) { - db.exec(` - CREATE TABLE IF NOT EXISTS slice_dependencies ( - milestone_id TEXT NOT NULL, - slice_id TEXT NOT NULL, - depends_on_slice_id TEXT NOT NULL, - PRIMARY KEY (milestone_id, slice_id, depends_on_slice_id), - FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), - FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id) - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 14, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 15) { - db.exec(` - CREATE TABLE IF NOT EXISTS gate_runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trace_id TEXT NOT NULL, - turn_id TEXT NOT NULL, - gate_id TEXT NOT NULL, - gate_type TEXT NOT NULL DEFAULT '', - unit_type TEXT DEFAULT NULL, - unit_id TEXT DEFAULT NULL, - milestone_id TEXT DEFAULT NULL, - slice_id TEXT DEFAULT NULL, - task_id TEXT DEFAULT NULL, - outcome TEXT NOT NULL DEFAULT 'pass', - failure_class TEXT NOT NULL DEFAULT 'none', - rationale TEXT NOT NULL DEFAULT '', - findings TEXT NOT NULL DEFAULT '', - attempt INTEGER NOT NULL DEFAULT 1, - max_attempts INTEGER NOT NULL DEFAULT 1, - retryable INTEGER NOT NULL DEFAULT 0, - evaluated_at TEXT NOT NULL DEFAULT '', - duration_ms INTEGER DEFAULT NULL, - cost_micro_usd INTEGER DEFAULT NULL - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS turn_git_transactions ( - trace_id TEXT NOT NULL, - turn_id TEXT NOT NULL, - unit_type TEXT DEFAULT NULL, - unit_id TEXT DEFAULT NULL, - stage TEXT NOT NULL DEFAULT 'turn-start', - action TEXT NOT NULL DEFAULT 'status-only', - push INTEGER NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'ok', - error TEXT DEFAULT NULL, - metadata_json TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL DEFAULT '', - PRIMARY KEY (trace_id, turn_id, stage) - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS audit_events ( - event_id TEXT PRIMARY KEY, - trace_id TEXT NOT NULL, - turn_id TEXT DEFAULT NULL, - caused_by TEXT DEFAULT NULL, - category TEXT NOT NULL, - type TEXT NOT NULL, - ts TEXT NOT NULL, - payload_json TEXT NOT NULL DEFAULT '{}' - ) - `); - db.exec(` - CREATE TABLE IF NOT EXISTS audit_turn_index ( - trace_id TEXT NOT NULL, - turn_id TEXT NOT NULL, - first_ts TEXT NOT NULL, - last_ts TEXT NOT NULL, - event_count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (trace_id, turn_id) - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 15, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 16) { - db.exec(` - CREATE TABLE IF NOT EXISTS llm_task_outcomes ( - model_id TEXT NOT NULL, - provider TEXT NOT NULL, - unit_type TEXT NOT NULL, - unit_id TEXT NOT NULL, - succeeded INTEGER NOT NULL DEFAULT 0, - retries INTEGER NOT NULL DEFAULT 0, - escalated INTEGER NOT NULL DEFAULT 0, - verification_passed INTEGER DEFAULT NULL, - blocker_discovered INTEGER NOT NULL DEFAULT 0, - duration_ms INTEGER DEFAULT NULL, - tokens_total INTEGER DEFAULT NULL, - cost_usd REAL DEFAULT NULL, - failure_mode TEXT DEFAULT NULL, - recorded_at INTEGER NOT NULL - ) - `); - db.exec( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_task_outcomes_identity ON llm_task_outcomes(unit_type, unit_id, recorded_at)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_model_unit ON llm_task_outcomes(model_id, unit_type, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_unit ON llm_task_outcomes(unit_type, recorded_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_provider ON llm_task_outcomes(provider, recorded_at DESC)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 16, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 17) { - ensureColumn( - db, - "tasks", - "verification_status", - `ALTER TABLE tasks ADD COLUMN verification_status TEXT NOT NULL DEFAULT ''`, - ); - // Backfill verification_status from existing verification_evidence rows so the - // prior-task guard works on databases upgraded mid-project (not just new ones). - db.exec(` - UPDATE tasks - SET verification_status = CASE - WHEN (SELECT COUNT(*) FROM verification_evidence ve - WHERE ve.milestone_id = tasks.milestone_id - AND ve.slice_id = tasks.slice_id - AND ve.task_id = tasks.id) = 0 - THEN '' - WHEN (SELECT COUNT(*) FROM verification_evidence ve - WHERE ve.milestone_id = tasks.milestone_id - AND ve.slice_id = tasks.slice_id - AND ve.task_id = tasks.id - AND ve.exit_code != 0) = 0 - THEN 'all_pass' - WHEN (SELECT COUNT(*) FROM verification_evidence ve - WHERE ve.milestone_id = tasks.milestone_id - AND ve.slice_id = tasks.slice_id - AND ve.task_id = tasks.id - AND ve.exit_code = 0) > 0 - THEN 'partial' - ELSE 'all_fail' - END - WHERE tasks.status IN ('complete', 'done') - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 17, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 18) { - ensureColumn( - db, - "slices", - "adversarial_partner", - `ALTER TABLE slices ADD COLUMN adversarial_partner TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "adversarial_combatant", - `ALTER TABLE slices ADD COLUMN adversarial_combatant TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "slices", - "adversarial_architect", - `ALTER TABLE slices ADD COLUMN adversarial_architect TEXT NOT NULL DEFAULT ''`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 18, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 19) { - ensureColumn( - db, - "slices", - "planning_meeting_json", - `ALTER TABLE slices ADD COLUMN planning_meeting_json TEXT NOT NULL DEFAULT ''`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 19, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 20) { - ensureColumn( - db, - "milestones", - "vision_meeting_json", - `ALTER TABLE milestones ADD COLUMN vision_meeting_json TEXT NOT NULL DEFAULT ''`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 20, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 21) { - ensureRepoProfileTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 21, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 22) { - // SF ADR-011: progressive planning. is_sketch=1 means the slice is a 2-3 - // sentence sketch awaiting refine-slice expansion; refine fills in the - // real plan and clears the flag. sketch_scope holds the milestone - // planner's stored scope hint that refine treats as a hard boundary. - ensureColumn( - db, - "slices", - "is_sketch", - `ALTER TABLE slices ADD COLUMN is_sketch INTEGER NOT NULL DEFAULT 0`, - ); - ensureColumn( - db, - "slices", - "sketch_scope", - `ALTER TABLE slices ADD COLUMN sketch_scope TEXT NOT NULL DEFAULT ''`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 22, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 23) { - // ADR-011 Phase 2 (SF ADR): mid-execution escalation. escalation_pending=1 - // marks a task that paused for a user decision; escalation_artifact_path - // points to the T##-ESCALATION.json file containing options + recommendation. - // State derivation will emit phase='escalating-task' when any task in the - // active slice has escalation_pending=1; dispatch returns 'stop' so the - // loop never bypasses a pending decision. - ensureColumn( - db, - "tasks", - "escalation_pending", - `ALTER TABLE tasks ADD COLUMN escalation_pending INTEGER NOT NULL DEFAULT 0`, - ); - ensureColumn( - db, - "tasks", - "escalation_artifact_path", - `ALTER TABLE tasks ADD COLUMN escalation_artifact_path TEXT DEFAULT NULL`, - ); - try { - db.exec( - "CREATE INDEX IF NOT EXISTS idx_tasks_escalation_pending ON tasks(milestone_id, slice_id, escalation_pending)", - ); - } catch { - /* index creation is opportunistic — fall through if backend lacks it */ - } - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 23, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 24) { - // ADR-011 P2 (SF ADR): the third escalation flag for the - // continueWithDefault=true case — an artifact is recorded for human - // review later, but the loop is NOT paused. Mutually exclusive with - // escalation_pending (the writer flips one or the other). - ensureColumn( - db, - "tasks", - "escalation_awaiting_review", - `ALTER TABLE tasks ADD COLUMN escalation_awaiting_review INTEGER NOT NULL DEFAULT 0`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 24, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 25) { - // SF ADR-011 P2 carry-forward: when an escalation is resolved, the user's - // choice should be visible to the next execute-task agent in the same - // slice. escalation_override_applied=0 marks "resolved but not yet - // injected into a downstream prompt"; the prompt builder calls - // claimEscalationOverride which atomically flips it to 1 (idempotent - // race-safe claim). Per-task granularity so multi-task slices can - // carry multiple resolved escalations forward independently. - ensureColumn( - db, - "tasks", - "escalation_override_applied", - `ALTER TABLE tasks ADD COLUMN escalation_override_applied INTEGER NOT NULL DEFAULT 0`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 25, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 26) { - db.exec(` - CREATE TABLE IF NOT EXISTS uok_runs ( - run_id TEXT PRIMARY KEY, - session_id TEXT DEFAULT NULL, - path TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'started', - started_at TEXT NOT NULL, - ended_at TEXT DEFAULT NULL, - error TEXT DEFAULT NULL, - flags_json TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_runs_status_started ON uok_runs(status, started_at DESC)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_uok_runs_session ON uok_runs(session_id, started_at DESC)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 26, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 27) { - ensureSolverEvalTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 27, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 28) { - // UOK observability: gate execution latency - // Guard: gate_runs table may not exist in minimal legacy DBs (it will be dropped in v58) - if (tableExists(db, "gate_runs")) { - ensureColumn( - db, - "gate_runs", - "duration_ms", - "ALTER TABLE gate_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL", - ); - } - // UOK circuit breaker state - db.exec(` - CREATE TABLE IF NOT EXISTS gate_circuit_breakers ( - gate_id TEXT PRIMARY KEY, - state TEXT NOT NULL DEFAULT 'closed', - failure_streak INTEGER NOT NULL DEFAULT 0, - last_failure_at TEXT DEFAULT NULL, - opened_at TEXT DEFAULT NULL, - half_open_attempts INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL DEFAULT '' - ) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 28, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 29) { - ensureHeadlessRunTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 29, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 30) { - ensureSelfFeedbackTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 30, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 31) { - ensureUokMessageTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 31, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 32) { - ensureTaskCreatedAtColumn(db); - ensureSpecSchemaTables(db); - // Populate spec tables from existing spec columns in runtime tables - populateSpecTablesFromExisting(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 32, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 33) { - ensureColumn( - db, - "milestones", - "sequence", - `ALTER TABLE milestones ADD COLUMN sequence INTEGER DEFAULT 0`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 33, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 34) { - ensureTaskCreatedAtColumn(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 34, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 35) { - ensureBacklogTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 35, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 36) { - migrateCostUsdToMicroUsd(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 36, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 37) { - ensureScheduleTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 37, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 38) { - try { - db.exec( - "ALTER TABLE memories ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'", - ); - } catch { - // Column may already exist on fresh DBs - } - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 38, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 39) { - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(superseded_by, category)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 39, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 40) { - db.exec(` - CREATE TABLE IF NOT EXISTS judgments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - decision TEXT NOT NULL DEFAULT '', - alternatives_json TEXT NOT NULL DEFAULT '[]', - reasoning TEXT NOT NULL DEFAULT '', - confidence TEXT NOT NULL DEFAULT 'medium', - ts TEXT NOT NULL - ) - `); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_judgments_unit_id ON judgments(unit_id, ts DESC)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 40, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 41) { - ensureRetrievalEvidenceTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 41, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 42) { - ensureColumn( - db, - "milestones", - "product_research_json", - `ALTER TABLE milestones ADD COLUMN product_research_json TEXT NOT NULL DEFAULT ''`, - ); - ensureColumn( - db, - "milestone_specs", - "product_research_json", - `ALTER TABLE milestone_specs ADD COLUMN product_research_json TEXT DEFAULT ''`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 42, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 43) { - db.exec(` - CREATE TABLE IF NOT EXISTS session_mode_state ( - id INTEGER PRIMARY KEY CHECK (id = 1), - work_mode TEXT NOT NULL DEFAULT 'chat', - run_control TEXT NOT NULL DEFAULT 'manual', - permission_profile TEXT NOT NULL DEFAULT 'restricted', - model_mode TEXT NOT NULL DEFAULT 'smart', - surface TEXT NOT NULL DEFAULT 'tui', - updated_at TEXT NOT NULL DEFAULT '' - ) - `); - db.exec(` - INSERT OR IGNORE INTO session_mode_state (id, work_mode, run_control, permission_profile, model_mode, surface, updated_at) - VALUES (1, 'chat', 'manual', 'restricted', 'smart', 'tui', datetime('now')) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 43, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 44) { - ensureSpecSchemaTables(db); - ensureTaskFrontmatterColumns(db); - db.exec(` - UPDATE tasks - SET task_status = CASE status - WHEN 'complete' THEN 'done' - WHEN 'completed' THEN 'done' - WHEN 'done' THEN 'done' - WHEN 'running' THEN 'running' - WHEN 'in_progress' THEN 'running' - WHEN 'blocked' THEN 'blocked' - WHEN 'failed' THEN 'failed' - WHEN 'cancelled' THEN 'cancelled' - ELSE COALESCE(NULLIF(task_status, ''), 'todo') - END - `); - db.exec(` - UPDATE task_specs - SET risk = COALESCE((SELECT tasks.risk FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), risk), - mutation_scope = COALESCE((SELECT tasks.mutation_scope FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), mutation_scope), - verification_type = COALESCE((SELECT tasks.verification_type FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), verification_type), - plan_approval = COALESCE((SELECT tasks.plan_approval FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), plan_approval), - estimated_effort = COALESCE((SELECT tasks.estimated_effort FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), estimated_effort), - dependencies = COALESCE((SELECT tasks.dependencies FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), dependencies), - blocks_parallel = COALESCE((SELECT tasks.blocks_parallel FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), blocks_parallel), - requires_user_input = COALESCE((SELECT tasks.requires_user_input FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), requires_user_input), - auto_retry = COALESCE((SELECT tasks.auto_retry FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), auto_retry), - max_retries = COALESCE((SELECT tasks.max_retries FROM tasks - WHERE tasks.milestone_id = task_specs.milestone_id - AND tasks.slice_id = task_specs.slice_id - AND tasks.id = task_specs.task_id), max_retries) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 44, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 45) { - ensureTaskSchedulerTable(db); - db.exec(` - INSERT OR IGNORE INTO task_scheduler ( - milestone_id, slice_id, task_id, status, updated_at - ) - SELECT milestone_id, slice_id, id, 'queued', datetime('now') - FROM tasks - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 45, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 46) { - // validation_runs: mirrors droid's validation-contract.md + validation-state.json - // pattern. Each run stores the contract spec inline and its execution state. - db.exec(` - CREATE TABLE IF NOT EXISTS validation_runs ( - run_id TEXT PRIMARY KEY, - milestone_id TEXT NOT NULL, - slice_id TEXT, - task_id TEXT, - contract TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - verdict TEXT NOT NULL DEFAULT '', - rationale TEXT NOT NULL DEFAULT '', - findings TEXT NOT NULL DEFAULT '', - started_at TEXT, - completed_at TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY (milestone_id) REFERENCES milestones(id) - ) - `); - db.exec(` - CREATE INDEX IF NOT EXISTS idx_validation_runs_scope - ON validation_runs(milestone_id, slice_id, task_id) - `); - db.exec(` - CREATE VIEW IF NOT EXISTS latest_validation_state AS - SELECT vr.* - FROM validation_runs vr - WHERE vr.rowid = ( - SELECT MAX(v2.rowid) - FROM validation_runs v2 - WHERE v2.milestone_id = vr.milestone_id - AND v2.slice_id IS vr.slice_id - AND v2.task_id IS vr.task_id - ) - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 46, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 47) { - // Drop unused superseded_by column from validation_runs. - // The column was never written or queried — dead schema from v46. - const cols = db - .prepare("PRAGMA table_info(validation_runs)") - .all() - .map((c) => c.name); - if (cols.includes("superseded_by")) { - db.exec("ALTER TABLE validation_runs DROP COLUMN superseded_by"); - } - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 47, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 48) { - // Session layer: create tables, backfill from existing headless_runs and - // audit_turn_index so historical data is queryable from day one. - // Message text will be NULL for backfilled turns — it was never stored. - ensureSessionTables(db); - // Backfill: one session per headless run. - db.exec(` - INSERT OR IGNORE INTO sessions (session_id, trace_id, mode, cwd, created_at, updated_at) - SELECT run_id, NULL, 'headless', '', created_at, updated_at - FROM headless_runs - `); - // Backfill: one session per distinct trace_id in audit_turn_index. - // Reconstruct created_at/updated_at from the min/max timestamps. - db.exec(` - INSERT OR IGNORE INTO sessions (session_id, trace_id, mode, cwd, created_at, updated_at) - SELECT trace_id, trace_id, 'interactive', - '', MIN(first_ts), MAX(last_ts) - FROM audit_turn_index - GROUP BY trace_id - `); - // Backfill: one turn row per (trace_id, turn_id) in audit_turn_index. - // turn_index derived from row order within trace; message text is NULL. - db.exec(` - INSERT OR IGNORE INTO turns (session_id, turn_index, user_message, assistant_response, ts) - SELECT - trace_id, - ROW_NUMBER() OVER (PARTITION BY trace_id ORDER BY first_ts) - 1, - NULL, NULL, - first_ts - FROM audit_turn_index - `); - // Rebuild FTS index from any turns that have text. - // None from backfill yet, but required so the FTS table is consistent. - db.exec(`INSERT INTO turns_fts(turns_fts) VALUES ('rebuild')`); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 48, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 49) { - // Add session_snapshots table — checkpoints before irreversible ops. - // Safe to call on fresh DBs too (CREATE TABLE IF NOT EXISTS). - ensureSessionSnapshotTable(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 49, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 50) { - // Add sleeptime_consolidation_queue — decouples memory consolidation - // from the conversation turn so the daemon can drain it asynchronously. - ensureSleeptimeQueueTable(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 50, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 51) { - // Add deploy/smoke/release/rollback tables — closes the vision→production loop. - // deploy_runs tracks each deployment attempt; smoke_results tracks live verification; - // release_records tracks version bumps and publishes; rollback_runs tracks reversions. - ensureDeployTables(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 51, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 52) { - // Add triage_runs/evals/items/skills, runtime_counters, and - // validation_attention_markers tables — migrate JSONL structured state to DB. - ensureTriageTables(db); - ensureRuntimeCounterTable(db); - ensureValidationAttentionMarkersTable(db); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 52, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 53) { - // Add routing_history and routing_feedback tables — migrate file-based - // routing history to DB-first storage. - db.exec(` - CREATE TABLE IF NOT EXISTS routing_history ( - pattern TEXT NOT NULL, - tier TEXT NOT NULL, - success_count INTEGER NOT NULL DEFAULT 0, - fail_count INTEGER NOT NULL DEFAULT 0, - updated_at TEXT NOT NULL, - PRIMARY KEY (pattern, tier) - ); - CREATE TABLE IF NOT EXISTS routing_feedback ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pattern TEXT NOT NULL, - tier TEXT NOT NULL, - feedback TEXT NOT NULL, - recorded_at TEXT NOT NULL - ); - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 53, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 54) { - // Migrate metrics ledger from .sf/runtime/metrics.json to DB-first - // unit_metrics and project_metrics_meta tables. - db.exec(` - CREATE TABLE IF NOT EXISTS unit_metrics ( - type TEXT NOT NULL, - id TEXT NOT NULL, - started_at INTEGER NOT NULL, - finished_at INTEGER NOT NULL, - model TEXT NOT NULL, - auto_session_key TEXT, - tokens_input INTEGER NOT NULL DEFAULT 0, - tokens_output INTEGER NOT NULL DEFAULT 0, - tokens_cache_read INTEGER NOT NULL DEFAULT 0, - tokens_cache_write INTEGER NOT NULL DEFAULT 0, - tokens_total INTEGER NOT NULL DEFAULT 0, - cost REAL NOT NULL DEFAULT 0, - tool_calls INTEGER NOT NULL DEFAULT 0, - assistant_messages INTEGER NOT NULL DEFAULT 0, - user_messages INTEGER NOT NULL DEFAULT 0, - api_requests INTEGER NOT NULL DEFAULT 0, - tier TEXT, - model_downgraded INTEGER, - context_window_tokens INTEGER, - truncation_sections INTEGER, - continue_here_fired INTEGER, - prompt_char_count INTEGER, - baseline_char_count INTEGER, - cache_hit_rate INTEGER, - skills TEXT, - PRIMARY KEY (type, id, started_at) - ); - CREATE TABLE IF NOT EXISTS project_metrics_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 54, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 55) { - // Schema v55: composite index for audit_events + task access-pattern views - // Guard: audit_events may not exist in minimal legacy DBs (it will be dropped in v58) - if (tableExists(db, "audit_events")) { - db.exec( - `CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`, - ); - } - db.exec( - `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, - ); - db.exec(` - CREATE VIEW IF NOT EXISTS v_task_full AS - SELECT t.*, ts.spec_version, ts.verify AS spec_verify, - ts.inputs AS spec_inputs, ts.expected_output AS spec_expected_output - FROM tasks t - LEFT JOIN task_specs ts - ON t.milestone_id = ts.milestone_id - AND t.slice_id = ts.slice_id - AND t.id = ts.task_id - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 55, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 56) { - // Schema v56: move metrics table to dedicated metrics.db — drop from main DB - // to eliminate WAL pressure from high-frequency telemetry writes. - db.exec(`DROP TABLE IF EXISTS metrics`); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 56, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 57) { - // Schema v57: add archived_at to sessions for soft-delete / archive support. - db.exec(`ALTER TABLE sessions ADD COLUMN archived_at TEXT DEFAULT NULL`); - db.exec( - `CREATE INDEX IF NOT EXISTS idx_sessions_archived ON sessions(archived_at) WHERE archived_at IS NOT NULL`, - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 57, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 58) { - // Schema v58: move trace data to JSONL files — drop gate_runs, turn_git_transactions, audit_events - db.exec("DROP TABLE IF EXISTS gate_runs"); - db.exec("DROP TABLE IF EXISTS turn_git_transactions"); - db.exec("DROP TABLE IF EXISTS audit_events"); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 58, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 59) { - // Schema v59: add failure_mode to llm_task_outcomes so the learning system - // can differentiate transient failures (rate_limit) from hard failures - // (quota_exhausted, auth_error) when weighting model demotions. - ensureColumn( - db, - "llm_task_outcomes", - "failure_mode", - "ALTER TABLE llm_task_outcomes ADD COLUMN failure_mode TEXT DEFAULT NULL", - ); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_failure_mode ON llm_task_outcomes(model_id, failure_mode, recorded_at DESC)", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 59, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 60) { - // Schema v60: add frontmatter_version to tasks table for future frontmatter - // schema migrations. Defaults to 1 for all existing rows. - ensureColumn( - db, - "tasks", - "frontmatter_version", - "ALTER TABLE tasks ADD COLUMN frontmatter_version INTEGER NOT NULL DEFAULT 1", - ); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 60, - ":applied_at": new Date().toISOString(), - }); - } - if (currentVersion < 61) { - // Schema v61: intent_chapters — crash-resume context for autonomous units. - // Each chapter records the agent's declared intent when a unit begins - // (chapter_open) and clears it on normal close (chapter_close). On - // crash-resume, the open chapter is surfaced to the prompt so the agent - // knows where it left off without replaying the full transcript. - db.exec(` - CREATE TABLE IF NOT EXISTS intent_chapters ( - id TEXT PRIMARY KEY, - unit_type TEXT NOT NULL, - unit_id TEXT NOT NULL, - milestone_id TEXT, - slice_id TEXT, - task_id TEXT, - intent TEXT NOT NULL, - opened_at TEXT NOT NULL, - closed_at TEXT, - outcome TEXT, - metadata_json TEXT - ); - CREATE INDEX IF NOT EXISTS idx_intent_chapters_unit - ON intent_chapters(unit_type, unit_id); - CREATE INDEX IF NOT EXISTS idx_intent_chapters_open - ON intent_chapters(closed_at, opened_at) - WHERE closed_at IS NULL; - `); - db.prepare( - "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", - ).run({ - ":version": 61, - ":applied_at": new Date().toISOString(), - }); - } - db.exec("COMMIT"); - } catch (err) { - db.exec("ROLLBACK"); - throw err; - } -} let currentDb = null; let currentPath = null; let currentPid = 0; @@ -3333,7 +331,7 @@ export function openDatabase(path) { const adapter = createAdapter(rawDb); const fileBacked = path !== ":memory:"; try { - initSchema(adapter, fileBacked); + initSchema(adapter, fileBacked, { currentPath: path, withQueryTimeout }); createDatabaseSnapshot(rawDb, path); performDatabaseMaintenance(rawDb, path); } catch (err) { @@ -3346,7 +344,7 @@ export function openDatabase(path) { ) { try { adapter.exec("VACUUM"); - initSchema(adapter, fileBacked); + initSchema(adapter, fileBacked, { currentPath: path, withQueryTimeout }); process.stderr.write("sf-db: recovered corrupt database via VACUUM\n"); } catch (retryErr) { try { diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js new file mode 100644 index 000000000..dc4266617 --- /dev/null +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -0,0 +1,3016 @@ +// sf-db-schema.js — SQLite schema creation and migration helpers. +// +// Purpose: keep schema DDL and historical migrations out of the runtime DB +// adapter module while preserving one DB-open path for SF state. + +import { copyFileSync, existsSync } from "node:fs"; +import { logWarning } from "../workflow-logger.js"; +import { getErrorMessage } from "../error-utils.js"; + +function defaultQueryTimeout(operation, fallbackValue) { + try { + return operation(); + } catch { + return fallbackValue; + } +} + +const SCHEMA_VERSION = 61; +function indexExists(db, name) { + return !!db + .prepare( + "SELECT 1 as present FROM sqlite_master WHERE type = 'index' AND name = ?", + ) + .get(name); +} +function dedupeVerificationEvidenceRows(db) { + db.exec(` + DELETE FROM verification_evidence + WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM verification_evidence + GROUP BY task_id, slice_id, milestone_id, command, verdict + ) + `); +} +function ensureVerificationEvidenceDedupIndex(db) { + if (indexExists(db, "idx_verification_evidence_dedup")) return; + dedupeVerificationEvidenceRows(db); + db.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_evidence_dedup ON verification_evidence(task_id, slice_id, milestone_id, command, verdict)", + ); +} +function ensureRepoProfileTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS repo_profiles ( + profile_id TEXT PRIMARY KEY, + project_hash TEXT NOT NULL, + project_root TEXT NOT NULL DEFAULT '', + head TEXT DEFAULT NULL, + branch TEXT DEFAULT NULL, + remote_hash TEXT DEFAULT NULL, + dirty INTEGER NOT NULL DEFAULT 0, + profile_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS repo_file_observations ( + path TEXT PRIMARY KEY, + latest_profile_id TEXT NOT NULL, + git_status TEXT NOT NULL, + ownership TEXT NOT NULL, + language TEXT DEFAULT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + content_hash TEXT DEFAULT NULL, + summary TEXT DEFAULT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + adopted_at TEXT DEFAULT NULL, + adoption_unit_id TEXT DEFAULT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_repo_profiles_created ON repo_profiles(created_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_repo_file_observations_status ON repo_file_observations(git_status, ownership)", + ); +} +function ensureBacklogTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS backlog_items ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + note TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + triage_run_id TEXT DEFAULT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + promoted_at TEXT DEFAULT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_backlog_items_status_sequence ON backlog_items(status, sequence, id)", + ); +} +function ensureScheduleTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS schedule_entries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL DEFAULT 'project', + id TEXT NOT NULL, + schema_version INTEGER NOT NULL DEFAULT 1, + kind TEXT NOT NULL DEFAULT 'reminder', + status TEXT NOT NULL DEFAULT 'pending', + due_at TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + snoozed_at TEXT DEFAULT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL DEFAULT 'user', + autonomous_dispatch INTEGER NOT NULL DEFAULT 0, + full_json TEXT NOT NULL DEFAULT '{}', + imported_from TEXT DEFAULT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_schedule_entries_scope_id_created ON schedule_entries(scope, id, created_at DESC, seq DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_schedule_entries_scope_due ON schedule_entries(scope, status, due_at)", + ); + ensureColumn( + db, + "schedule_entries", + "autonomous_dispatch", + "ALTER TABLE schedule_entries ADD COLUMN autonomous_dispatch INTEGER NOT NULL DEFAULT 0", + ); +} +function ensureSolverEvalTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS solver_eval_runs ( + run_id TEXT PRIMARY KEY, + suite_source TEXT NOT NULL DEFAULT '', + cases_count INTEGER NOT NULL DEFAULT 0, + summary_json TEXT NOT NULL DEFAULT '{}', + report_path TEXT NOT NULL DEFAULT '', + results_path TEXT NOT NULL DEFAULT '', + db_recorded INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS solver_eval_case_results ( + run_id TEXT NOT NULL, + case_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL, + passed INTEGER NOT NULL DEFAULT 0, + false_complete INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER DEFAULT NULL, + command_status INTEGER DEFAULT NULL, + solver_outcome TEXT DEFAULT NULL, + pdd_complete INTEGER DEFAULT NULL, + result_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + PRIMARY KEY (run_id, case_id, mode), + FOREIGN KEY (run_id) REFERENCES solver_eval_runs(run_id) ON DELETE CASCADE + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_solver_eval_runs_created ON solver_eval_runs(created_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_solver_eval_case_lookup ON solver_eval_case_results(run_id, case_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_solver_eval_case_false_complete ON solver_eval_case_results(false_complete, mode)", + ); +} +function ensureSessionTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + trace_id TEXT DEFAULT NULL, + mode TEXT NOT NULL DEFAULT 'interactive', + cwd TEXT NOT NULL DEFAULT '', + repo TEXT DEFAULT NULL, + branch TEXT DEFAULT NULL, + summary TEXT DEFAULT NULL, + summary_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, + turn_index INTEGER NOT NULL, + user_message TEXT, + assistant_response TEXT, + ts TEXT NOT NULL, + UNIQUE(session_id, turn_index) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS session_file_touches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, + path TEXT NOT NULL, + tool_name TEXT DEFAULT NULL, + turn_id INTEGER DEFAULT NULL REFERENCES turns(id), + first_seen_at TEXT NOT NULL, + UNIQUE(session_id, path) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS session_refs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(session_id) ON DELETE CASCADE, + ref_type TEXT NOT NULL, + ref_value TEXT NOT NULL, + turn_id INTEGER DEFAULT NULL REFERENCES turns(id), + created_at TEXT NOT NULL, + UNIQUE(session_id, ref_type, ref_value) + ) + `); + // FTS5 external-content table over turns for keyword recall. + // content_rowid links to turns.id; triggers below keep it in sync. + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5( + user_message, + assistant_response, + content='turns', + content_rowid='id' + ) + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS turns_fts_insert AFTER INSERT ON turns BEGIN + INSERT INTO turns_fts(rowid, user_message, assistant_response) + VALUES (new.id, new.user_message, new.assistant_response); + END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS turns_fts_update AFTER UPDATE ON turns BEGIN + INSERT INTO turns_fts(turns_fts, rowid, user_message, assistant_response) + VALUES ('delete', old.id, old.user_message, old.assistant_response); + INSERT INTO turns_fts(rowid, user_message, assistant_response) + VALUES (new.id, new.user_message, new.assistant_response); + END + `); + db.exec(` + CREATE TRIGGER IF NOT EXISTS turns_fts_delete AFTER DELETE ON turns BEGIN + INSERT INTO turns_fts(turns_fts, rowid, user_message, assistant_response) + VALUES ('delete', old.id, old.user_message, old.assistant_response); + END + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_sessions_created ON sessions(created_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_sessions_repo ON sessions(repo, created_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id, turn_index)", + ); + db.exec("CREATE INDEX IF NOT EXISTS idx_turns_ts ON turns(ts DESC)"); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_session_file_touches_session ON session_file_touches(session_id, first_seen_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_session_file_touches_path ON session_file_touches(path, session_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_session_refs_session ON session_refs(session_id, created_at DESC)", + ); +} +function ensureSessionSnapshotTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS session_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- Session that triggered this checkpoint. FK to sessions(session_id). + session_id TEXT NOT NULL, + -- Zero-based counter within the session (first snapshot = 0). + snapshot_index INTEGER NOT NULL DEFAULT 0, + -- Optional git stash ref so the snapshot can be restored exactly. + -- NULL when the working tree had no changes to stash. + git_stash_ref TEXT, + -- Free-text label for the snapshot (e.g. "before migration deploy"). + label TEXT, + ts TEXT NOT NULL, + UNIQUE(session_id, snapshot_index) + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_session_snapshots_session ON session_snapshots(session_id, snapshot_index)", + ); +} +function ensureHeadlessRunTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS headless_runs ( + run_id TEXT PRIMARY KEY, + command TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + exit_code INTEGER NOT NULL DEFAULT 0, + timed_out INTEGER NOT NULL DEFAULT 0, + interrupted INTEGER NOT NULL DEFAULT 0, + restart_count INTEGER NOT NULL DEFAULT 0, + max_restarts INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + total_events INTEGER NOT NULL DEFAULT 0, + tool_calls INTEGER NOT NULL DEFAULT 0, + solver_eval_run_id TEXT DEFAULT NULL, + solver_eval_report_path TEXT DEFAULT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_headless_runs_created ON headless_runs(created_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_headless_runs_status ON headless_runs(status, created_at DESC)", + ); +} +function ensureUokMessageTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS uok_messages ( + id TEXT PRIMARY KEY, + from_agent TEXT NOT NULL, + to_agent TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + sent_at TEXT NOT NULL DEFAULT '', + delivered_at TEXT DEFAULT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS uok_message_reads ( + message_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + read_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (message_id, agent_id), + FOREIGN KEY (message_id) REFERENCES uok_messages(id) ON DELETE CASCADE + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_messages_to ON uok_messages(to_agent, sent_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_messages_conversation ON uok_messages(from_agent, to_agent, sent_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_messages_sent ON uok_messages(sent_at DESC)", + ); +} +function ensureDeployTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS deploy_runs ( + id TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL, + target TEXT NOT NULL, + command TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + exit_code INTEGER DEFAULT NULL, + output TEXT DEFAULT NULL, + deployed_url TEXT DEFAULT NULL, + created_at TEXT NOT NULL, + finished_at TEXT DEFAULT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS smoke_results ( + id TEXT PRIMARY KEY, + deploy_run_id TEXT NOT NULL, + milestone_id TEXT NOT NULL, + url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT DEFAULT NULL, + checks_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + finished_at TEXT DEFAULT NULL, + FOREIGN KEY (deploy_run_id) REFERENCES deploy_runs(id) ON DELETE CASCADE + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS release_records ( + id TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL, + version TEXT NOT NULL, + prev_version TEXT DEFAULT NULL, + changelog_entry TEXT DEFAULT NULL, + git_tag TEXT DEFAULT NULL, + published INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS rollback_runs ( + id TEXT PRIMARY KEY, + deploy_run_id TEXT NOT NULL, + milestone_id TEXT NOT NULL, + reason TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + output TEXT DEFAULT NULL, + created_at TEXT NOT NULL, + finished_at TEXT DEFAULT NULL, + FOREIGN KEY (deploy_run_id) REFERENCES deploy_runs(id) ON DELETE CASCADE + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_deploy_runs_milestone ON deploy_runs(milestone_id, created_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_smoke_results_deploy ON smoke_results(deploy_run_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_release_records_milestone ON release_records(milestone_id, created_at DESC)", + ); +} +function ensureSleeptimeQueueTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS sleeptime_consolidation_queue ( + id TEXT PRIMARY KEY, + conversation_agent TEXT NOT NULL, + memory_agent TEXT NOT NULL, + content TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + processed_at TEXT DEFAULT NULL, + result TEXT DEFAULT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_sleeptime_queue_status ON sleeptime_consolidation_queue(status, created_at ASC)", + ); +} +function ensureSelfFeedbackTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS self_feedback ( + id TEXT PRIMARY KEY, + ts TEXT NOT NULL, + kind TEXT NOT NULL, + severity TEXT NOT NULL, + blocking INTEGER NOT NULL DEFAULT 0, + repo_identity TEXT NOT NULL DEFAULT '', + sf_version TEXT NOT NULL DEFAULT '', + base_path TEXT NOT NULL DEFAULT '', + unit_type TEXT DEFAULT NULL, + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + summary TEXT NOT NULL DEFAULT '', + evidence TEXT NOT NULL DEFAULT '', + suggested_fix TEXT NOT NULL DEFAULT '', + full_json TEXT NOT NULL, + resolved_at TEXT DEFAULT NULL, + resolved_reason TEXT DEFAULT NULL, + resolved_by_sf_version TEXT DEFAULT NULL, + resolved_evidence_json TEXT DEFAULT NULL, + resolved_criteria_json TEXT DEFAULT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_self_feedback_open ON self_feedback(resolved_at, severity, ts)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_self_feedback_kind ON self_feedback(kind, ts)", + ); +} +function ensureRetrievalEvidenceTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS retrieval_evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + backend TEXT NOT NULL, + source_kind TEXT NOT NULL DEFAULT 'code', + query TEXT NOT NULL DEFAULT '', + strategy TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT '', + project_root TEXT NOT NULL DEFAULT '', + git_head TEXT DEFAULT NULL, + git_branch TEXT DEFAULT NULL, + worktree_dirty INTEGER NOT NULL DEFAULT 0, + freshness TEXT NOT NULL DEFAULT 'unknown', + status TEXT NOT NULL DEFAULT 'ok', + hit_count INTEGER NOT NULL DEFAULT 0, + elapsed_ms INTEGER NOT NULL DEFAULT 0, + cache_path TEXT DEFAULT NULL, + error TEXT DEFAULT NULL, + result_json TEXT NOT NULL DEFAULT '{}', + recorded_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_backend_recorded ON retrieval_evidence(backend, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_scope_recorded ON retrieval_evidence(scope, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_retrieval_evidence_status_recorded ON retrieval_evidence(status, recorded_at DESC)", + ); +} +function ensureTriageTables(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS triage_runs ( + id TEXT PRIMARY KEY, + source_file TEXT, + status TEXT NOT NULL DEFAULT 'complete', + result_summary_json TEXT, + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS triage_evals ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES triage_runs(id), + task_input TEXT NOT NULL, + expected_behavior TEXT, + evidence TEXT, + failure_mode TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS triage_items ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES triage_runs(id), + kind TEXT NOT NULL, + content TEXT NOT NULL, + evidence TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS triage_skills ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES triage_runs(id), + name TEXT, + description TEXT, + trigger TEXT, + raw_json TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_triage_evals_run ON triage_evals(run_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_triage_items_run_kind ON triage_items(run_id, kind)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_triage_skills_run ON triage_skills(run_id)", + ); +} +function ensureRuntimeCounterTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS runtime_counters ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ) + `); +} +function ensureValidationAttentionMarkersTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS validation_attention_markers ( + milestone_id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + source TEXT, + remediation_round INTEGER, + revalidation_round INTEGER, + revalidation_requested_at TEXT + ) + `); +} +function ensureSpecSchemaTables(db) { + // Tier 1.3: Spec/Runtime/Evidence schema separation + // Creates 9 normalized tables for milestone, slice, task entities + // Each entity type has: _specs (immutable intent), (runtime state), _evidence (audit trail) + + // ── Milestone Spec Table (immutable record of intent) ─────────── + db.exec(` + CREATE TABLE IF NOT EXISTS milestone_specs ( + id TEXT NOT NULL, + vision TEXT NOT NULL DEFAULT '', + success_criteria TEXT DEFAULT '', + key_risks TEXT DEFAULT '', + proof_strategy TEXT DEFAULT '', + verification_contract TEXT DEFAULT '', + verification_integration TEXT DEFAULT '', + verification_operational TEXT DEFAULT '', + verification_uat TEXT DEFAULT '', + definition_of_done TEXT DEFAULT '', + requirement_coverage TEXT DEFAULT '', + boundary_map_markdown TEXT DEFAULT '', + vision_meeting_json TEXT DEFAULT '', + product_research_json TEXT DEFAULT '', + spec_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (id) REFERENCES milestones(id) + ) + `); + + // ── Slice Spec Table (immutable record of intent) ─────────── + db.exec(` + CREATE TABLE IF NOT EXISTS slice_specs ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + goal TEXT NOT NULL DEFAULT '', + success_criteria TEXT DEFAULT '', + proof_level TEXT DEFAULT '', + integration_closure TEXT DEFAULT '', + observability_impact TEXT DEFAULT '', + adversarial_partner TEXT DEFAULT '', + adversarial_combatant TEXT DEFAULT '', + adversarial_architect TEXT DEFAULT '', + planning_meeting_json TEXT DEFAULT '', + spec_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + PRIMARY KEY (milestone_id, slice_id), + FOREIGN KEY (milestone_id) REFERENCES milestones(id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + + // ── Task Spec Table (immutable record of intent) ─────────── + db.exec(` + CREATE TABLE IF NOT EXISTS task_specs ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + task_id TEXT NOT NULL, + verify TEXT NOT NULL DEFAULT '', + inputs TEXT DEFAULT '', + expected_output TEXT DEFAULT '', + risk TEXT NOT NULL DEFAULT 'low', + mutation_scope TEXT NOT NULL DEFAULT 'isolated', + verification_type TEXT NOT NULL DEFAULT 'self-check', + plan_approval TEXT NOT NULL DEFAULT 'not-required', + estimated_effort INTEGER DEFAULT NULL, + dependencies TEXT NOT NULL DEFAULT '[]', + blocks_parallel INTEGER NOT NULL DEFAULT 0, + requires_user_input INTEGER NOT NULL DEFAULT 0, + auto_retry INTEGER NOT NULL DEFAULT 1, + max_retries INTEGER NOT NULL DEFAULT 2, + spec_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + PRIMARY KEY (milestone_id, slice_id, task_id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), + FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) + ) + `); + + // ── Milestone Evidence Table (append-only audit trail) ─────────── + db.exec(` + CREATE TABLE IF NOT EXISTS milestone_evidence ( + milestone_id TEXT NOT NULL, + evidence_type TEXT NOT NULL, + content TEXT NOT NULL, + recorded_at TEXT NOT NULL, + phase_name TEXT DEFAULT '', + recorded_by TEXT DEFAULT '', + evidence_id TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))), + PRIMARY KEY (milestone_id, evidence_id), + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + + // ── Slice Evidence Table (append-only audit trail) ─────────── + db.exec(` + CREATE TABLE IF NOT EXISTS slice_evidence ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + evidence_type TEXT NOT NULL, + content TEXT NOT NULL, + recorded_at TEXT NOT NULL, + phase_name TEXT DEFAULT '', + recorded_by TEXT DEFAULT '', + evidence_id TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))), + PRIMARY KEY (milestone_id, slice_id, evidence_id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + + // ── Task Evidence Table (append-only audit trail) ─────────── + db.exec(` + CREATE TABLE IF NOT EXISTS task_evidence ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + task_id TEXT NOT NULL, + evidence_type TEXT NOT NULL, + content TEXT NOT NULL, + recorded_at TEXT NOT NULL, + phase_name TEXT DEFAULT '', + recorded_by TEXT DEFAULT '', + evidence_id TEXT NOT NULL DEFAULT (lower(hex(randomblob(16)))), + PRIMARY KEY (milestone_id, slice_id, task_id, evidence_id), + FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) + ) + `); + + // Indices for efficient querying of evidence trails + db.exec(` + CREATE INDEX IF NOT EXISTS idx_milestone_evidence_type + ON milestone_evidence(milestone_id, evidence_type, recorded_at DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_slice_evidence_type + ON slice_evidence(milestone_id, slice_id, evidence_type, recorded_at DESC) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_task_evidence_type + ON task_evidence(milestone_id, slice_id, task_id, evidence_type, recorded_at DESC) + `); +} +export function initSchema(db, fileBacked, options = {}) { + const { currentPath = null, withQueryTimeout = defaultQueryTimeout } = options; + if (fileBacked) db.exec("PRAGMA journal_mode=WAL"); + if (fileBacked) db.exec("PRAGMA busy_timeout = 5000"); + if (fileBacked) db.exec("PRAGMA synchronous = NORMAL"); + // Disable SQLite's automatic WAL checkpoint (default: every 1000 pages). + // Auto-checkpoint fires at unpredictable times — if the process is killed + // mid-checkpoint (e.g., OOM), the main DB is partially written with an + // empty WAL and cannot be recovered. Explicit checkpoints are issued at + // safe loop boundaries instead (post-unit finalize, close). + if (fileBacked) db.exec("PRAGMA wal_autocheckpoint=0"); + if (fileBacked) db.exec("PRAGMA auto_vacuum = INCREMENTAL"); + if (fileBacked) db.exec("PRAGMA cache_size = -8000"); // 8 MB page cache + if (fileBacked && process.platform !== "darwin") + db.exec("PRAGMA mmap_size = 67108864"); // 64 MB mmap + db.exec("PRAGMA temp_store = MEMORY"); + db.exec("PRAGMA foreign_keys = ON"); + db.exec("BEGIN"); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER NOT NULL, + applied_at TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS decisions ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + when_context TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT '', + decision TEXT NOT NULL DEFAULT '', + choice TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + revisable TEXT NOT NULL DEFAULT '', + made_by TEXT NOT NULL DEFAULT 'agent', + superseded_by TEXT DEFAULT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS requirements ( + id TEXT PRIMARY KEY, + class TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + why TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + primary_owner TEXT NOT NULL DEFAULT '', + supporting_slices TEXT NOT NULL DEFAULT '', + validation TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + full_content TEXT NOT NULL DEFAULT '', + superseded_by TEXT DEFAULT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS artifacts ( + path TEXT PRIMARY KEY, + artifact_type TEXT NOT NULL DEFAULT '', + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + full_content TEXT NOT NULL DEFAULT '', + imported_at TEXT NOT NULL DEFAULT '' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS memories ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + content TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0.8, + source_unit_type TEXT, + source_unit_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + superseded_by TEXT DEFAULT NULL, + hit_count INTEGER NOT NULL DEFAULT 0, + tags TEXT NOT NULL DEFAULT '[]' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS memory_processed_units ( + unit_key TEXT PRIMARY KEY, + activity_file TEXT, + processed_at TEXT NOT NULL + ) + `); + // memory_embeddings, memory_relations, memory_sources used to be referenced + // by helper functions and queries (memory-embeddings.ts, memory-relations.ts, + // memory-ingest.ts) without a corresponding CREATE TABLE — any actual write + // would have failed with "no such table". Creating them as IF NOT EXISTS so + // existing DBs that somehow have them survive, and fresh DBs work. + db.exec(` + CREATE TABLE IF NOT EXISTS memory_embeddings ( + memory_id TEXT PRIMARY KEY, + model TEXT NOT NULL, + dim INTEGER NOT NULL, + vector BLOB NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS memory_relations ( + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + rel TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0.8, + created_at TEXT NOT NULL, + PRIMARY KEY (from_id, to_id, rel), + FOREIGN KEY (from_id) REFERENCES memories(id) ON DELETE CASCADE, + FOREIGN KEY (to_id) REFERENCES memories(id) ON DELETE CASCADE + ) + `); + // PK covers from_id as leading column already; reverse lookups + // (memory-relations.ts queries WHERE to_id = ?) need their own index + // to avoid a full table scan as the relation count grows. + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memory_relations_to ON memory_relations(to_id)", + ); + db.exec(` + CREATE TABLE IF NOT EXISTS memory_sources ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + uri TEXT, + title TEXT, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + imported_at TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'project', + tags TEXT NOT NULL DEFAULT '[]' + ) + `); + // content_hash is queried on every insert for deduplication; without an + // index the lookup becomes a full table scan as ingestion volume grows. + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", + ); + // Category GROUP BY queries (e.g. /memory stats) need a covering + // index that filters active memories and groups by category. + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(superseded_by, category)", + ); + db.exec(` + CREATE TABLE IF NOT EXISTS judgments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + decision TEXT NOT NULL DEFAULT '', + alternatives_json TEXT NOT NULL DEFAULT '[]', + reasoning TEXT NOT NULL DEFAULT '', + confidence TEXT NOT NULL DEFAULT 'medium', + ts TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_judgments_unit_id ON judgments(unit_id, ts DESC)", + ); + db.exec(` + CREATE TABLE IF NOT EXISTS milestones ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + depends_on TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + vision TEXT NOT NULL DEFAULT '', + success_criteria TEXT NOT NULL DEFAULT '[]', + key_risks TEXT NOT NULL DEFAULT '[]', + proof_strategy TEXT NOT NULL DEFAULT '[]', + verification_contract TEXT NOT NULL DEFAULT '', + verification_integration TEXT NOT NULL DEFAULT '', + verification_operational TEXT NOT NULL DEFAULT '', + verification_uat TEXT NOT NULL DEFAULT '', + definition_of_done TEXT NOT NULL DEFAULT '[]', + requirement_coverage TEXT NOT NULL DEFAULT '', + boundary_map_markdown TEXT NOT NULL DEFAULT '', + vision_meeting_json TEXT NOT NULL DEFAULT '', + product_research_json TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0 + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS slices ( + milestone_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + risk TEXT NOT NULL DEFAULT 'medium', + depends TEXT NOT NULL DEFAULT '[]', + demo TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + full_summary_md TEXT NOT NULL DEFAULT '', + full_uat_md TEXT NOT NULL DEFAULT '', + goal TEXT NOT NULL DEFAULT '', + success_criteria TEXT NOT NULL DEFAULT '', + proof_level TEXT NOT NULL DEFAULT '', + integration_closure TEXT NOT NULL DEFAULT '', + observability_impact TEXT NOT NULL DEFAULT '', + adversarial_partner TEXT NOT NULL DEFAULT '', + adversarial_combatant TEXT NOT NULL DEFAULT '', + adversarial_architect TEXT NOT NULL DEFAULT '', + planning_meeting_json TEXT NOT NULL DEFAULT '', + sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order + replan_triggered_at TEXT DEFAULT NULL, + is_sketch INTEGER NOT NULL DEFAULT 0, -- SF ADR-011: 1 = slice is a sketch awaiting refine-slice + sketch_scope TEXT NOT NULL DEFAULT '', -- SF ADR-011: 2-3 sentence scope hint from plan-milestone + PRIMARY KEY (milestone_id, id), + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + one_liner TEXT NOT NULL DEFAULT '', + narrative TEXT NOT NULL DEFAULT '', + verification_result TEXT NOT NULL DEFAULT '', + duration TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + blocker_discovered INTEGER DEFAULT 0, + deviations TEXT NOT NULL DEFAULT '', + known_issues TEXT NOT NULL DEFAULT '', + key_files TEXT NOT NULL DEFAULT '[]', + key_decisions TEXT NOT NULL DEFAULT '[]', + full_summary_md TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + estimate TEXT NOT NULL DEFAULT '', + files TEXT NOT NULL DEFAULT '[]', + verify TEXT NOT NULL DEFAULT '', + inputs TEXT NOT NULL DEFAULT '[]', + expected_output TEXT NOT NULL DEFAULT '[]', + observability_impact TEXT NOT NULL DEFAULT '', + full_plan_md TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + verification_status TEXT NOT NULL DEFAULT '', + risk TEXT NOT NULL DEFAULT 'low', + mutation_scope TEXT NOT NULL DEFAULT 'isolated', + verification_type TEXT NOT NULL DEFAULT 'self-check', + plan_approval TEXT NOT NULL DEFAULT 'not-required', + task_status TEXT NOT NULL DEFAULT 'todo', + estimated_effort INTEGER DEFAULT NULL, + dependencies TEXT NOT NULL DEFAULT '[]', + blocks_parallel INTEGER NOT NULL DEFAULT 0, + requires_user_input INTEGER NOT NULL DEFAULT 0, + auto_retry INTEGER NOT NULL DEFAULT 1, + max_retries INTEGER NOT NULL DEFAULT 2, + frontmatter_version INTEGER NOT NULL DEFAULT 1, + sequence INTEGER DEFAULT 0, -- Ordering hint: tools may set this to control execution order + escalation_pending INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): pause-on-escalation flag + escalation_awaiting_review INTEGER NOT NULL DEFAULT 0, -- ADR-011 P2 (SF): continueWithDefault=true marker (no pause) + escalation_override_applied INTEGER NOT NULL DEFAULT 0, -- SF ADR-011 P2: 1 once carry-forward injected into a downstream prompt + escalation_artifact_path TEXT DEFAULT NULL, -- ADR-011 P2 (SF): path to T##-ESCALATION.json + PRIMARY KEY (milestone_id, slice_id, id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + ensureTaskSchedulerTable(db); + if (columnExists(db, "tasks", "escalation_pending")) { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_tasks_escalation_pending ON tasks(milestone_id, slice_id, escalation_pending) + `); + } + db.exec(` + CREATE TABLE IF NOT EXISTS verification_evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL DEFAULT '', + slice_id TEXT NOT NULL DEFAULT '', + milestone_id TEXT NOT NULL DEFAULT '', + command TEXT NOT NULL DEFAULT '', + exit_code INTEGER DEFAULT 0, + verdict TEXT NOT NULL DEFAULT '', + duration_ms INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS replan_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + milestone_id TEXT NOT NULL DEFAULT '', + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + summary TEXT NOT NULL DEFAULT '', + previous_artifact_path TEXT DEFAULT NULL, + replacement_artifact_path TEXT DEFAULT NULL, + created_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS assessments ( + path TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL DEFAULT '', + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + status TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT '', + full_content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS quality_gates ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'slice', + task_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + evaluated_at TEXT DEFAULT NULL, + PRIMARY KEY (milestone_id, slice_id, gate_id, task_id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + // Slice dependency junction table (v14) + db.exec(` + CREATE TABLE IF NOT EXISTS slice_dependencies ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + depends_on_slice_id TEXT NOT NULL, + PRIMARY KEY (milestone_id, slice_id, depends_on_slice_id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), + FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS gate_circuit_breakers ( + gate_id TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'closed', + failure_streak INTEGER NOT NULL DEFAULT 0, + last_failure_at TEXT DEFAULT NULL, + opened_at TEXT DEFAULT NULL, + half_open_attempts INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT '' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS audit_turn_index ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trace_id, turn_id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS llm_task_outcomes ( + model_id TEXT NOT NULL, + provider TEXT NOT NULL, + unit_type TEXT NOT NULL, + unit_id TEXT NOT NULL, + succeeded INTEGER NOT NULL DEFAULT 0, + retries INTEGER NOT NULL DEFAULT 0, + escalated INTEGER NOT NULL DEFAULT 0, + verification_passed INTEGER DEFAULT NULL, + blocker_discovered INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER DEFAULT NULL, + tokens_total INTEGER DEFAULT NULL, + cost_usd REAL DEFAULT NULL, + failure_mode TEXT DEFAULT NULL, + recorded_at INTEGER NOT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS uok_runs ( + run_id TEXT PRIMARY KEY, + session_id TEXT DEFAULT NULL, + path TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'started', + started_at TEXT NOT NULL, + ended_at TEXT DEFAULT NULL, + error TEXT DEFAULT NULL, + flags_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL + ) + `); + ensureSelfFeedbackTables(db); + ensureSolverEvalTables(db); + ensureRetrievalEvidenceTables(db); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)", + ); + // v13 indexes — hot-path dispatch queries + db.exec( + "CREATE INDEX IF NOT EXISTS idx_tasks_active ON tasks(milestone_id, slice_id, status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_slices_active ON slices(milestone_id, status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)", + ); + ensureVerificationEvidenceDedupIndex(db); + // v14 index — slice dependency lookups + db.exec( + "CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)", + ); + db.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_task_outcomes_identity ON llm_task_outcomes(unit_type, unit_id, recorded_at)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_model_unit ON llm_task_outcomes(model_id, unit_type, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_unit ON llm_task_outcomes(unit_type, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_provider ON llm_task_outcomes(provider, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_runs_status_started ON uok_runs(status, started_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_runs_session ON uok_runs(session_id, started_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_self_feedback_open ON self_feedback(resolved_at, severity, ts)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_self_feedback_kind ON self_feedback(kind, ts)", + ); + ensureRepoProfileTables(db); + ensureBacklogTables(db); + ensureScheduleTables(db); + ensureSolverEvalTables(db); + ensureHeadlessRunTables(db); + ensureSessionTables(db); + ensureSessionSnapshotTable(db); + ensureUokMessageTables(db); + ensureDeployTables(db); + ensureSleeptimeQueueTable(db); + ensureSpecSchemaTables(db); + ensureTaskFrontmatterColumns(db); + ensureRetrievalEvidenceTables(db); + ensureTriageTables(db); + ensureRuntimeCounterTable(db); + ensureValidationAttentionMarkersTable(db); + db.exec( + `CREATE VIEW IF NOT EXISTS active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL`, + ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_requirements AS SELECT * FROM requirements WHERE superseded_by IS NULL`, + ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL`, + ); + db.exec( + `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, + ); + db.exec(` + CREATE VIEW IF NOT EXISTS v_task_full AS + SELECT t.*, ts.spec_version, ts.verify AS spec_verify, + ts.inputs AS spec_inputs, ts.expected_output AS spec_expected_output + FROM tasks t + LEFT JOIN task_specs ts + ON t.milestone_id = ts.milestone_id + AND t.slice_id = ts.slice_id + AND t.id = ts.task_id + `); + const existing = db + .prepare("SELECT count(*) as cnt FROM schema_version") + .get(); + if (existing && existing["cnt"] === 0) { + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": SCHEMA_VERSION, + ":applied_at": new Date().toISOString(), + }); + } + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } + migrateSchema(db, { currentPath, withQueryTimeout }); +} +function columnExists(db, table, column) { + const rows = db.prepare(`PRAGMA table_info(${table})`).all(); + return rows.some((row) => row["name"] === column); +} +function tableExists(db, table) { + const row = db + .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`) + .get(table); + return row != null; +} +function ensureColumn(db, table, column, ddl) { + if (!columnExists(db, table, column)) db.exec(ddl); +} +function ensureTaskCreatedAtColumn(db) { + ensureColumn( + db, + "tasks", + "created_at", + `ALTER TABLE tasks ADD COLUMN created_at TEXT NOT NULL DEFAULT ''`, + ); +} +function ensureTaskFrontmatterColumns(db) { + ensureColumn( + db, + "tasks", + "risk", + `ALTER TABLE tasks ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`, + ); + ensureColumn( + db, + "tasks", + "mutation_scope", + `ALTER TABLE tasks ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`, + ); + ensureColumn( + db, + "tasks", + "verification_type", + `ALTER TABLE tasks ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`, + ); + ensureColumn( + db, + "tasks", + "plan_approval", + `ALTER TABLE tasks ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`, + ); + ensureColumn( + db, + "tasks", + "task_status", + `ALTER TABLE tasks ADD COLUMN task_status TEXT NOT NULL DEFAULT 'todo'`, + ); + ensureColumn( + db, + "tasks", + "estimated_effort", + `ALTER TABLE tasks ADD COLUMN estimated_effort INTEGER DEFAULT NULL`, + ); + ensureColumn( + db, + "tasks", + "dependencies", + `ALTER TABLE tasks ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "tasks", + "blocks_parallel", + `ALTER TABLE tasks ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + "tasks", + "requires_user_input", + `ALTER TABLE tasks ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + "tasks", + "auto_retry", + `ALTER TABLE tasks ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`, + ); + ensureColumn( + db, + "tasks", + "max_retries", + `ALTER TABLE tasks ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`, + ); + for (const table of ["task_specs"]) { + ensureColumn( + db, + table, + "risk", + `ALTER TABLE ${table} ADD COLUMN risk TEXT NOT NULL DEFAULT 'low'`, + ); + ensureColumn( + db, + table, + "mutation_scope", + `ALTER TABLE ${table} ADD COLUMN mutation_scope TEXT NOT NULL DEFAULT 'isolated'`, + ); + ensureColumn( + db, + table, + "verification_type", + `ALTER TABLE ${table} ADD COLUMN verification_type TEXT NOT NULL DEFAULT 'self-check'`, + ); + ensureColumn( + db, + table, + "plan_approval", + `ALTER TABLE ${table} ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'not-required'`, + ); + ensureColumn( + db, + table, + "estimated_effort", + `ALTER TABLE ${table} ADD COLUMN estimated_effort INTEGER DEFAULT NULL`, + ); + ensureColumn( + db, + table, + "dependencies", + `ALTER TABLE ${table} ADD COLUMN dependencies TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + table, + "blocks_parallel", + `ALTER TABLE ${table} ADD COLUMN blocks_parallel INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + table, + "requires_user_input", + `ALTER TABLE ${table} ADD COLUMN requires_user_input INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + table, + "auto_retry", + `ALTER TABLE ${table} ADD COLUMN auto_retry INTEGER NOT NULL DEFAULT 1`, + ); + ensureColumn( + db, + table, + "max_retries", + `ALTER TABLE ${table} ADD COLUMN max_retries INTEGER NOT NULL DEFAULT 2`, + ); + } +} +function ensureTaskSchedulerTable(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS task_scheduler ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + task_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + due_at TEXT DEFAULT NULL, + claimed_by TEXT DEFAULT NULL, + dispatched_at TEXT DEFAULT NULL, + consumed_at TEXT DEFAULT NULL, + expires_at TEXT DEFAULT NULL, + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (milestone_id, slice_id, task_id), + FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_task_scheduler_status + ON task_scheduler(status, due_at) + `); +} +function migrateCostUsdToMicroUsd(db) { + // Tier 2.7: Migrate cost_usd REAL to cost_micro_usd INTEGER + // Converts floating-point USD values to integer micro-USD (multiply by 1,000,000) + // Benefits: eliminates float drift on accumulated costs, easier reasoning about totals + // Purpose: Enable accurate cost tracking at scale without rounding errors + // Consumer: gate_runs cost tracking, cost analytics, budget checks + + // Guard: gate_runs may not exist in minimal legacy DBs (it will be dropped in v58) + if (!tableExists(db, "gate_runs")) return; + + // Add cost_micro_usd column if it doesn't exist + if (!columnExists(db, "gate_runs", "cost_micro_usd")) { + db.exec( + `ALTER TABLE gate_runs ADD COLUMN cost_micro_usd INTEGER DEFAULT NULL`, + ); + } + + // Migrate data: convert cost_usd to cost_micro_usd + // NULL values stay NULL; non-NULL values are multiplied by 1,000,000 + if (columnExists(db, "gate_runs", "cost_usd")) { + db.prepare(` + UPDATE gate_runs + SET cost_micro_usd = CAST(ROUND(cost_usd * 1000000) AS INTEGER) + WHERE cost_usd IS NOT NULL + AND cost_micro_usd IS NULL + `).run(); + } + + // Drop old cost_usd column (SQLite ALTER TABLE DROP is only available in 3.35.0+) + // For safety, we keep the old column as deprecated but unused + // Future: drop after confirming all queries use cost_micro_usd +} +function populateSpecTablesFromExisting(db) { + // Tier 1.3 Phase 2: Migrate existing spec data to new spec tables + // This populates milestone_specs, slice_specs, task_specs from existing columns + // Evidence tables are left empty; they populate as tools create new evidence. + + const now = new Date().toISOString(); + + // Migrate milestone specs + db.prepare(` + INSERT OR IGNORE INTO milestone_specs ( + id, vision, success_criteria, key_risks, proof_strategy, + verification_contract, verification_integration, verification_operational, verification_uat, + definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json, + spec_version, created_at + ) + SELECT + id, vision, success_criteria, key_risks, proof_strategy, + verification_contract, verification_integration, verification_operational, verification_uat, + definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, '', + 1, COALESCE(created_at, ?) + FROM milestones + WHERE id NOT IN (SELECT id FROM milestone_specs) + `).run(now); + + // Migrate slice specs + db.prepare(` + INSERT OR IGNORE INTO slice_specs ( + milestone_id, slice_id, goal, success_criteria, proof_level, + integration_closure, observability_impact, + adversarial_partner, adversarial_combatant, adversarial_architect, + planning_meeting_json, spec_version, created_at + ) + SELECT + milestone_id, id, goal, success_criteria, proof_level, + integration_closure, observability_impact, + adversarial_partner, adversarial_combatant, adversarial_architect, + planning_meeting_json, 1, COALESCE(created_at, ?) + FROM slices + WHERE (milestone_id, id) NOT IN (SELECT milestone_id, slice_id FROM slice_specs) + `).run(now); + + // Migrate task specs + db.prepare(` + INSERT OR IGNORE INTO task_specs ( + milestone_id, slice_id, task_id, verify, inputs, expected_output, + spec_version, created_at + ) + SELECT + milestone_id, slice_id, id, verify, inputs, expected_output, + 1, COALESCE(created_at, ?) + FROM tasks + WHERE (milestone_id, slice_id, id) NOT IN (SELECT milestone_id, slice_id, task_id FROM task_specs) + `).run(now); +} +function migrateSchema(db, { currentPath, withQueryTimeout }) { + const row = withQueryTimeout( + () => db.prepare("SELECT MAX(version) as v FROM schema_version").get(), + null, + ); + const currentVersion = row ? row["v"] : 0; + if (currentVersion >= SCHEMA_VERSION) return; + // Backup database before migration so a mid-migration crash doesn't + // leave a partially-migrated DB with no recovery path. + // WAL-safe: checkpoint first to flush WAL into the main DB file, then copy. + if (currentPath && currentPath !== ":memory:" && existsSync(currentPath)) { + try { + const backupPath = `${currentPath}.backup-v${currentVersion}`; + if (!existsSync(backupPath)) { + // Flush WAL to main DB file before copying — without this, the backup + // may be missing committed data that only exists in the -wal file. + try { + db.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } catch { + /* checkpoint is best-effort */ + } + copyFileSync(currentPath, backupPath); + } + } catch (backupErr) { + // Log but proceed — blocking migration leaves the DB stuck at an old + // schema version permanently on read-only or full filesystems. + logWarning( + "db", + `Pre-migration backup failed: ${getErrorMessage(backupErr)}`, + ); + } + } + db.exec("BEGIN"); + try { + if (currentVersion < 2) { + db.exec(` + CREATE TABLE IF NOT EXISTS artifacts ( + path TEXT PRIMARY KEY, + artifact_type TEXT NOT NULL DEFAULT '', + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + full_content TEXT NOT NULL DEFAULT '', + imported_at TEXT NOT NULL DEFAULT '' + ) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 2, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 3) { + db.exec(` + CREATE TABLE IF NOT EXISTS memories ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + content TEXT NOT NULL, + confidence REAL NOT NULL DEFAULT 0.8, + source_unit_type TEXT, + source_unit_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + superseded_by TEXT DEFAULT NULL, + hit_count INTEGER NOT NULL DEFAULT 0 + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS memory_processed_units ( + unit_key TEXT PRIMARY KEY, + activity_file TEXT, + processed_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(superseded_by)", + ); + db.exec("DROP VIEW IF EXISTS active_memories"); + db.exec( + "CREATE VIEW active_memories AS SELECT * FROM memories WHERE superseded_by IS NULL", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 3, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 4) { + ensureColumn( + db, + "decisions", + "made_by", + `ALTER TABLE decisions ADD COLUMN made_by TEXT NOT NULL DEFAULT 'agent'`, + ); + db.exec("DROP VIEW IF EXISTS active_decisions"); + db.exec( + "CREATE VIEW active_decisions AS SELECT * FROM decisions WHERE superseded_by IS NULL", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 4, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 5) { + db.exec(` + CREATE TABLE IF NOT EXISTS milestones ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + completed_at TEXT DEFAULT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS slices ( + milestone_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + risk TEXT NOT NULL DEFAULT 'medium', + created_at TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + PRIMARY KEY (milestone_id, id), + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + one_liner TEXT NOT NULL DEFAULT '', + narrative TEXT NOT NULL DEFAULT '', + verification_result TEXT NOT NULL DEFAULT '', + duration TEXT NOT NULL DEFAULT '', + completed_at TEXT DEFAULT NULL, + blocker_discovered INTEGER DEFAULT 0, + deviations TEXT NOT NULL DEFAULT '', + known_issues TEXT NOT NULL DEFAULT '', + key_files TEXT NOT NULL DEFAULT '[]', + key_decisions TEXT NOT NULL DEFAULT '[]', + full_summary_md TEXT NOT NULL DEFAULT '', + PRIMARY KEY (milestone_id, slice_id, id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS verification_evidence ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL DEFAULT '', + slice_id TEXT NOT NULL DEFAULT '', + milestone_id TEXT NOT NULL DEFAULT '', + command TEXT NOT NULL DEFAULT '', + exit_code INTEGER DEFAULT 0, + verdict TEXT NOT NULL DEFAULT '', + duration_ms INTEGER DEFAULT 0, + created_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (milestone_id, slice_id, task_id) REFERENCES tasks(milestone_id, slice_id, id) + ) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 5, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 6) { + ensureColumn( + db, + "slices", + "full_summary_md", + `ALTER TABLE slices ADD COLUMN full_summary_md TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "full_uat_md", + `ALTER TABLE slices ADD COLUMN full_uat_md TEXT NOT NULL DEFAULT ''`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 6, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 7) { + ensureColumn( + db, + "slices", + "depends", + `ALTER TABLE slices ADD COLUMN depends TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "slices", + "demo", + `ALTER TABLE slices ADD COLUMN demo TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "depends_on", + `ALTER TABLE milestones ADD COLUMN depends_on TEXT NOT NULL DEFAULT '[]'`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 7, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 8) { + ensureColumn( + db, + "milestones", + "vision", + `ALTER TABLE milestones ADD COLUMN vision TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "success_criteria", + `ALTER TABLE milestones ADD COLUMN success_criteria TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "milestones", + "key_risks", + `ALTER TABLE milestones ADD COLUMN key_risks TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "milestones", + "proof_strategy", + `ALTER TABLE milestones ADD COLUMN proof_strategy TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "milestones", + "verification_contract", + `ALTER TABLE milestones ADD COLUMN verification_contract TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "verification_integration", + `ALTER TABLE milestones ADD COLUMN verification_integration TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "verification_operational", + `ALTER TABLE milestones ADD COLUMN verification_operational TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "verification_uat", + `ALTER TABLE milestones ADD COLUMN verification_uat TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "definition_of_done", + `ALTER TABLE milestones ADD COLUMN definition_of_done TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "milestones", + "requirement_coverage", + `ALTER TABLE milestones ADD COLUMN requirement_coverage TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestones", + "boundary_map_markdown", + `ALTER TABLE milestones ADD COLUMN boundary_map_markdown TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "goal", + `ALTER TABLE slices ADD COLUMN goal TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "success_criteria", + `ALTER TABLE slices ADD COLUMN success_criteria TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "proof_level", + `ALTER TABLE slices ADD COLUMN proof_level TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "integration_closure", + `ALTER TABLE slices ADD COLUMN integration_closure TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "observability_impact", + `ALTER TABLE slices ADD COLUMN observability_impact TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "uat_verdict", + `ALTER TABLE slices ADD COLUMN uat_verdict TEXT DEFAULT NULL`, + ); + ensureColumn( + db, + "tasks", + "description", + `ALTER TABLE tasks ADD COLUMN description TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "tasks", + "estimate", + `ALTER TABLE tasks ADD COLUMN estimate TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "tasks", + "files", + `ALTER TABLE tasks ADD COLUMN files TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "tasks", + "verify", + `ALTER TABLE tasks ADD COLUMN verify TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "tasks", + "inputs", + `ALTER TABLE tasks ADD COLUMN inputs TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "tasks", + "expected_output", + `ALTER TABLE tasks ADD COLUMN expected_output TEXT NOT NULL DEFAULT '[]'`, + ); + ensureColumn( + db, + "tasks", + "observability_impact", + `ALTER TABLE tasks ADD COLUMN observability_impact TEXT NOT NULL DEFAULT ''`, + ); + db.exec(` + CREATE TABLE IF NOT EXISTS replan_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + milestone_id TEXT NOT NULL DEFAULT '', + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + summary TEXT NOT NULL DEFAULT '', + previous_artifact_path TEXT DEFAULT NULL, + replacement_artifact_path TEXT DEFAULT NULL, + created_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS assessments ( + path TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL DEFAULT '', + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + status TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT '', + full_content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_replan_history_milestone ON replan_history(milestone_id, created_at)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 8, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 9) { + ensureColumn( + db, + "slices", + "sequence", + `ALTER TABLE slices ADD COLUMN sequence INTEGER DEFAULT 0`, + ); + ensureColumn( + db, + "tasks", + "sequence", + `ALTER TABLE tasks ADD COLUMN sequence INTEGER DEFAULT 0`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 9, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 10) { + ensureColumn( + db, + "slices", + "replan_triggered_at", + `ALTER TABLE slices ADD COLUMN replan_triggered_at TEXT DEFAULT NULL`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 10, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 11) { + ensureColumn( + db, + "tasks", + "full_plan_md", + `ALTER TABLE tasks ADD COLUMN full_plan_md TEXT NOT NULL DEFAULT ''`, + ); + // Add unique constraint to replan_history for idempotency: + // one replan record per blocker task per slice per milestone. + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_replan_history_unique + ON replan_history(milestone_id, slice_id, task_id) + WHERE slice_id IS NOT NULL AND task_id IS NOT NULL + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 11, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 12) { + db.exec(` + CREATE TABLE IF NOT EXISTS quality_gates ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'slice', + task_id TEXT DEFAULT NULL, + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + evaluated_at TEXT DEFAULT NULL, + PRIMARY KEY (milestone_id, slice_id, gate_id, COALESCE(task_id, '')), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id) + ) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 12, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 13) { + // Hot-path indexes for auto-loop dispatch queries + db.exec( + "CREATE INDEX IF NOT EXISTS idx_tasks_active ON tasks(milestone_id, slice_id, status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_slices_active ON slices(milestone_id, status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_milestones_status ON milestones(status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_quality_gates_pending ON quality_gates(milestone_id, slice_id, status)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_verification_evidence_task ON verification_evidence(milestone_id, slice_id, task_id)", + ); + ensureVerificationEvidenceDedupIndex(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 13, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 14) { + db.exec(` + CREATE TABLE IF NOT EXISTS slice_dependencies ( + milestone_id TEXT NOT NULL, + slice_id TEXT NOT NULL, + depends_on_slice_id TEXT NOT NULL, + PRIMARY KEY (milestone_id, slice_id, depends_on_slice_id), + FOREIGN KEY (milestone_id, slice_id) REFERENCES slices(milestone_id, id), + FOREIGN KEY (milestone_id, depends_on_slice_id) REFERENCES slices(milestone_id, id) + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_slice_deps_target ON slice_dependencies(milestone_id, depends_on_slice_id)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 14, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 15) { + db.exec(` + CREATE TABLE IF NOT EXISTS gate_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + gate_id TEXT NOT NULL, + gate_type TEXT NOT NULL DEFAULT '', + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + milestone_id TEXT DEFAULT NULL, + slice_id TEXT DEFAULT NULL, + task_id TEXT DEFAULT NULL, + outcome TEXT NOT NULL DEFAULT 'pass', + failure_class TEXT NOT NULL DEFAULT 'none', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + attempt INTEGER NOT NULL DEFAULT 1, + max_attempts INTEGER NOT NULL DEFAULT 1, + retryable INTEGER NOT NULL DEFAULT 0, + evaluated_at TEXT NOT NULL DEFAULT '', + duration_ms INTEGER DEFAULT NULL, + cost_micro_usd INTEGER DEFAULT NULL + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS turn_git_transactions ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + unit_type TEXT DEFAULT NULL, + unit_id TEXT DEFAULT NULL, + stage TEXT NOT NULL DEFAULT 'turn-start', + action TEXT NOT NULL DEFAULT 'status-only', + push INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'ok', + error TEXT DEFAULT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT '', + PRIMARY KEY (trace_id, turn_id, stage) + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS audit_events ( + event_id TEXT PRIMARY KEY, + trace_id TEXT NOT NULL, + turn_id TEXT DEFAULT NULL, + caused_by TEXT DEFAULT NULL, + category TEXT NOT NULL, + type TEXT NOT NULL, + ts TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}' + ) + `); + db.exec(` + CREATE TABLE IF NOT EXISTS audit_turn_index ( + trace_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + first_ts TEXT NOT NULL, + last_ts TEXT NOT NULL, + event_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trace_id, turn_id) + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_gate_runs_turn ON gate_runs(trace_id, turn_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_gate_runs_lookup ON gate_runs(milestone_id, slice_id, task_id, gate_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_turn_git_tx_turn ON turn_git_transactions(trace_id, turn_id)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_audit_events_trace ON audit_events(trace_id, ts)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_audit_events_turn ON audit_events(trace_id, turn_id, ts)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 15, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 16) { + db.exec(` + CREATE TABLE IF NOT EXISTS llm_task_outcomes ( + model_id TEXT NOT NULL, + provider TEXT NOT NULL, + unit_type TEXT NOT NULL, + unit_id TEXT NOT NULL, + succeeded INTEGER NOT NULL DEFAULT 0, + retries INTEGER NOT NULL DEFAULT 0, + escalated INTEGER NOT NULL DEFAULT 0, + verification_passed INTEGER DEFAULT NULL, + blocker_discovered INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER DEFAULT NULL, + tokens_total INTEGER DEFAULT NULL, + cost_usd REAL DEFAULT NULL, + failure_mode TEXT DEFAULT NULL, + recorded_at INTEGER NOT NULL + ) + `); + db.exec( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_llm_task_outcomes_identity ON llm_task_outcomes(unit_type, unit_id, recorded_at)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_model_unit ON llm_task_outcomes(model_id, unit_type, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_unit ON llm_task_outcomes(unit_type, recorded_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_provider ON llm_task_outcomes(provider, recorded_at DESC)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 16, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 17) { + ensureColumn( + db, + "tasks", + "verification_status", + `ALTER TABLE tasks ADD COLUMN verification_status TEXT NOT NULL DEFAULT ''`, + ); + // Backfill verification_status from existing verification_evidence rows so the + // prior-task guard works on databases upgraded mid-project (not just new ones). + db.exec(` + UPDATE tasks + SET verification_status = CASE + WHEN (SELECT COUNT(*) FROM verification_evidence ve + WHERE ve.milestone_id = tasks.milestone_id + AND ve.slice_id = tasks.slice_id + AND ve.task_id = tasks.id) = 0 + THEN '' + WHEN (SELECT COUNT(*) FROM verification_evidence ve + WHERE ve.milestone_id = tasks.milestone_id + AND ve.slice_id = tasks.slice_id + AND ve.task_id = tasks.id + AND ve.exit_code != 0) = 0 + THEN 'all_pass' + WHEN (SELECT COUNT(*) FROM verification_evidence ve + WHERE ve.milestone_id = tasks.milestone_id + AND ve.slice_id = tasks.slice_id + AND ve.task_id = tasks.id + AND ve.exit_code = 0) > 0 + THEN 'partial' + ELSE 'all_fail' + END + WHERE tasks.status IN ('complete', 'done') + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 17, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 18) { + ensureColumn( + db, + "slices", + "adversarial_partner", + `ALTER TABLE slices ADD COLUMN adversarial_partner TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "adversarial_combatant", + `ALTER TABLE slices ADD COLUMN adversarial_combatant TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "slices", + "adversarial_architect", + `ALTER TABLE slices ADD COLUMN adversarial_architect TEXT NOT NULL DEFAULT ''`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 18, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 19) { + ensureColumn( + db, + "slices", + "planning_meeting_json", + `ALTER TABLE slices ADD COLUMN planning_meeting_json TEXT NOT NULL DEFAULT ''`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 19, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 20) { + ensureColumn( + db, + "milestones", + "vision_meeting_json", + `ALTER TABLE milestones ADD COLUMN vision_meeting_json TEXT NOT NULL DEFAULT ''`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 20, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 21) { + ensureRepoProfileTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 21, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 22) { + // SF ADR-011: progressive planning. is_sketch=1 means the slice is a 2-3 + // sentence sketch awaiting refine-slice expansion; refine fills in the + // real plan and clears the flag. sketch_scope holds the milestone + // planner's stored scope hint that refine treats as a hard boundary. + ensureColumn( + db, + "slices", + "is_sketch", + `ALTER TABLE slices ADD COLUMN is_sketch INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + "slices", + "sketch_scope", + `ALTER TABLE slices ADD COLUMN sketch_scope TEXT NOT NULL DEFAULT ''`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 22, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 23) { + // ADR-011 Phase 2 (SF ADR): mid-execution escalation. escalation_pending=1 + // marks a task that paused for a user decision; escalation_artifact_path + // points to the T##-ESCALATION.json file containing options + recommendation. + // State derivation will emit phase='escalating-task' when any task in the + // active slice has escalation_pending=1; dispatch returns 'stop' so the + // loop never bypasses a pending decision. + ensureColumn( + db, + "tasks", + "escalation_pending", + `ALTER TABLE tasks ADD COLUMN escalation_pending INTEGER NOT NULL DEFAULT 0`, + ); + ensureColumn( + db, + "tasks", + "escalation_artifact_path", + `ALTER TABLE tasks ADD COLUMN escalation_artifact_path TEXT DEFAULT NULL`, + ); + try { + db.exec( + "CREATE INDEX IF NOT EXISTS idx_tasks_escalation_pending ON tasks(milestone_id, slice_id, escalation_pending)", + ); + } catch { + /* index creation is opportunistic — fall through if backend lacks it */ + } + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 23, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 24) { + // ADR-011 P2 (SF ADR): the third escalation flag for the + // continueWithDefault=true case — an artifact is recorded for human + // review later, but the loop is NOT paused. Mutually exclusive with + // escalation_pending (the writer flips one or the other). + ensureColumn( + db, + "tasks", + "escalation_awaiting_review", + `ALTER TABLE tasks ADD COLUMN escalation_awaiting_review INTEGER NOT NULL DEFAULT 0`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 24, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 25) { + // SF ADR-011 P2 carry-forward: when an escalation is resolved, the user's + // choice should be visible to the next execute-task agent in the same + // slice. escalation_override_applied=0 marks "resolved but not yet + // injected into a downstream prompt"; the prompt builder calls + // claimEscalationOverride which atomically flips it to 1 (idempotent + // race-safe claim). Per-task granularity so multi-task slices can + // carry multiple resolved escalations forward independently. + ensureColumn( + db, + "tasks", + "escalation_override_applied", + `ALTER TABLE tasks ADD COLUMN escalation_override_applied INTEGER NOT NULL DEFAULT 0`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 25, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 26) { + db.exec(` + CREATE TABLE IF NOT EXISTS uok_runs ( + run_id TEXT PRIMARY KEY, + session_id TEXT DEFAULT NULL, + path TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'started', + started_at TEXT NOT NULL, + ended_at TEXT DEFAULT NULL, + error TEXT DEFAULT NULL, + flags_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_runs_status_started ON uok_runs(status, started_at DESC)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_uok_runs_session ON uok_runs(session_id, started_at DESC)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 26, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 27) { + ensureSolverEvalTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 27, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 28) { + // UOK observability: gate execution latency + // Guard: gate_runs table may not exist in minimal legacy DBs (it will be dropped in v58) + if (tableExists(db, "gate_runs")) { + ensureColumn( + db, + "gate_runs", + "duration_ms", + "ALTER TABLE gate_runs ADD COLUMN duration_ms INTEGER DEFAULT NULL", + ); + } + // UOK circuit breaker state + db.exec(` + CREATE TABLE IF NOT EXISTS gate_circuit_breakers ( + gate_id TEXT PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'closed', + failure_streak INTEGER NOT NULL DEFAULT 0, + last_failure_at TEXT DEFAULT NULL, + opened_at TEXT DEFAULT NULL, + half_open_attempts INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT '' + ) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 28, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 29) { + ensureHeadlessRunTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 29, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 30) { + ensureSelfFeedbackTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 30, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 31) { + ensureUokMessageTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 31, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 32) { + ensureTaskCreatedAtColumn(db); + ensureSpecSchemaTables(db); + // Populate spec tables from existing spec columns in runtime tables + populateSpecTablesFromExisting(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 32, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 33) { + ensureColumn( + db, + "milestones", + "sequence", + `ALTER TABLE milestones ADD COLUMN sequence INTEGER DEFAULT 0`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 33, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 34) { + ensureTaskCreatedAtColumn(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 34, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 35) { + ensureBacklogTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 35, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 36) { + migrateCostUsdToMicroUsd(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 36, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 37) { + ensureScheduleTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 37, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 38) { + try { + db.exec( + "ALTER TABLE memories ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'", + ); + } catch { + // Column may already exist on fresh DBs + } + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 38, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 39) { + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(superseded_by, category)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 39, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 40) { + db.exec(` + CREATE TABLE IF NOT EXISTS judgments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + decision TEXT NOT NULL DEFAULT '', + alternatives_json TEXT NOT NULL DEFAULT '[]', + reasoning TEXT NOT NULL DEFAULT '', + confidence TEXT NOT NULL DEFAULT 'medium', + ts TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_judgments_unit_id ON judgments(unit_id, ts DESC)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 40, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 41) { + ensureRetrievalEvidenceTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 41, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 42) { + ensureColumn( + db, + "milestones", + "product_research_json", + `ALTER TABLE milestones ADD COLUMN product_research_json TEXT NOT NULL DEFAULT ''`, + ); + ensureColumn( + db, + "milestone_specs", + "product_research_json", + `ALTER TABLE milestone_specs ADD COLUMN product_research_json TEXT DEFAULT ''`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 42, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 43) { + db.exec(` + CREATE TABLE IF NOT EXISTS session_mode_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + work_mode TEXT NOT NULL DEFAULT 'chat', + run_control TEXT NOT NULL DEFAULT 'manual', + permission_profile TEXT NOT NULL DEFAULT 'restricted', + model_mode TEXT NOT NULL DEFAULT 'smart', + surface TEXT NOT NULL DEFAULT 'tui', + updated_at TEXT NOT NULL DEFAULT '' + ) + `); + db.exec(` + INSERT OR IGNORE INTO session_mode_state (id, work_mode, run_control, permission_profile, model_mode, surface, updated_at) + VALUES (1, 'chat', 'manual', 'restricted', 'smart', 'tui', datetime('now')) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 43, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 44) { + ensureSpecSchemaTables(db); + ensureTaskFrontmatterColumns(db); + db.exec(` + UPDATE tasks + SET task_status = CASE status + WHEN 'complete' THEN 'done' + WHEN 'completed' THEN 'done' + WHEN 'done' THEN 'done' + WHEN 'running' THEN 'running' + WHEN 'in_progress' THEN 'running' + WHEN 'blocked' THEN 'blocked' + WHEN 'failed' THEN 'failed' + WHEN 'cancelled' THEN 'cancelled' + ELSE COALESCE(NULLIF(task_status, ''), 'todo') + END + `); + db.exec(` + UPDATE task_specs + SET risk = COALESCE((SELECT tasks.risk FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), risk), + mutation_scope = COALESCE((SELECT tasks.mutation_scope FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), mutation_scope), + verification_type = COALESCE((SELECT tasks.verification_type FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), verification_type), + plan_approval = COALESCE((SELECT tasks.plan_approval FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), plan_approval), + estimated_effort = COALESCE((SELECT tasks.estimated_effort FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), estimated_effort), + dependencies = COALESCE((SELECT tasks.dependencies FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), dependencies), + blocks_parallel = COALESCE((SELECT tasks.blocks_parallel FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), blocks_parallel), + requires_user_input = COALESCE((SELECT tasks.requires_user_input FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), requires_user_input), + auto_retry = COALESCE((SELECT tasks.auto_retry FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), auto_retry), + max_retries = COALESCE((SELECT tasks.max_retries FROM tasks + WHERE tasks.milestone_id = task_specs.milestone_id + AND tasks.slice_id = task_specs.slice_id + AND tasks.id = task_specs.task_id), max_retries) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 44, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 45) { + ensureTaskSchedulerTable(db); + db.exec(` + INSERT OR IGNORE INTO task_scheduler ( + milestone_id, slice_id, task_id, status, updated_at + ) + SELECT milestone_id, slice_id, id, 'queued', datetime('now') + FROM tasks + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 45, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 46) { + // validation_runs: mirrors droid's validation-contract.md + validation-state.json + // pattern. Each run stores the contract spec inline and its execution state. + db.exec(` + CREATE TABLE IF NOT EXISTS validation_runs ( + run_id TEXT PRIMARY KEY, + milestone_id TEXT NOT NULL, + slice_id TEXT, + task_id TEXT, + contract TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + verdict TEXT NOT NULL DEFAULT '', + rationale TEXT NOT NULL DEFAULT '', + findings TEXT NOT NULL DEFAULT '', + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (milestone_id) REFERENCES milestones(id) + ) + `); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_validation_runs_scope + ON validation_runs(milestone_id, slice_id, task_id) + `); + db.exec(` + CREATE VIEW IF NOT EXISTS latest_validation_state AS + SELECT vr.* + FROM validation_runs vr + WHERE vr.rowid = ( + SELECT MAX(v2.rowid) + FROM validation_runs v2 + WHERE v2.milestone_id = vr.milestone_id + AND v2.slice_id IS vr.slice_id + AND v2.task_id IS vr.task_id + ) + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 46, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 47) { + // Drop unused superseded_by column from validation_runs. + // The column was never written or queried — dead schema from v46. + const cols = db + .prepare("PRAGMA table_info(validation_runs)") + .all() + .map((c) => c.name); + if (cols.includes("superseded_by")) { + db.exec("ALTER TABLE validation_runs DROP COLUMN superseded_by"); + } + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 47, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 48) { + // Session layer: create tables, backfill from existing headless_runs and + // audit_turn_index so historical data is queryable from day one. + // Message text will be NULL for backfilled turns — it was never stored. + ensureSessionTables(db); + // Backfill: one session per headless run. + db.exec(` + INSERT OR IGNORE INTO sessions (session_id, trace_id, mode, cwd, created_at, updated_at) + SELECT run_id, NULL, 'headless', '', created_at, updated_at + FROM headless_runs + `); + // Backfill: one session per distinct trace_id in audit_turn_index. + // Reconstruct created_at/updated_at from the min/max timestamps. + db.exec(` + INSERT OR IGNORE INTO sessions (session_id, trace_id, mode, cwd, created_at, updated_at) + SELECT trace_id, trace_id, 'interactive', + '', MIN(first_ts), MAX(last_ts) + FROM audit_turn_index + GROUP BY trace_id + `); + // Backfill: one turn row per (trace_id, turn_id) in audit_turn_index. + // turn_index derived from row order within trace; message text is NULL. + db.exec(` + INSERT OR IGNORE INTO turns (session_id, turn_index, user_message, assistant_response, ts) + SELECT + trace_id, + ROW_NUMBER() OVER (PARTITION BY trace_id ORDER BY first_ts) - 1, + NULL, NULL, + first_ts + FROM audit_turn_index + `); + // Rebuild FTS index from any turns that have text. + // None from backfill yet, but required so the FTS table is consistent. + db.exec(`INSERT INTO turns_fts(turns_fts) VALUES ('rebuild')`); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 48, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 49) { + // Add session_snapshots table — checkpoints before irreversible ops. + // Safe to call on fresh DBs too (CREATE TABLE IF NOT EXISTS). + ensureSessionSnapshotTable(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 49, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 50) { + // Add sleeptime_consolidation_queue — decouples memory consolidation + // from the conversation turn so the daemon can drain it asynchronously. + ensureSleeptimeQueueTable(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 50, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 51) { + // Add deploy/smoke/release/rollback tables — closes the vision→production loop. + // deploy_runs tracks each deployment attempt; smoke_results tracks live verification; + // release_records tracks version bumps and publishes; rollback_runs tracks reversions. + ensureDeployTables(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 51, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 52) { + // Add triage_runs/evals/items/skills, runtime_counters, and + // validation_attention_markers tables — migrate JSONL structured state to DB. + ensureTriageTables(db); + ensureRuntimeCounterTable(db); + ensureValidationAttentionMarkersTable(db); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 52, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 53) { + // Add routing_history and routing_feedback tables — migrate file-based + // routing history to DB-first storage. + db.exec(` + CREATE TABLE IF NOT EXISTS routing_history ( + pattern TEXT NOT NULL, + tier TEXT NOT NULL, + success_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (pattern, tier) + ); + CREATE TABLE IF NOT EXISTS routing_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pattern TEXT NOT NULL, + tier TEXT NOT NULL, + feedback TEXT NOT NULL, + recorded_at TEXT NOT NULL + ); + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 53, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 54) { + // Migrate metrics ledger from .sf/runtime/metrics.json to DB-first + // unit_metrics and project_metrics_meta tables. + db.exec(` + CREATE TABLE IF NOT EXISTS unit_metrics ( + type TEXT NOT NULL, + id TEXT NOT NULL, + started_at INTEGER NOT NULL, + finished_at INTEGER NOT NULL, + model TEXT NOT NULL, + auto_session_key TEXT, + tokens_input INTEGER NOT NULL DEFAULT 0, + tokens_output INTEGER NOT NULL DEFAULT 0, + tokens_cache_read INTEGER NOT NULL DEFAULT 0, + tokens_cache_write INTEGER NOT NULL DEFAULT 0, + tokens_total INTEGER NOT NULL DEFAULT 0, + cost REAL NOT NULL DEFAULT 0, + tool_calls INTEGER NOT NULL DEFAULT 0, + assistant_messages INTEGER NOT NULL DEFAULT 0, + user_messages INTEGER NOT NULL DEFAULT 0, + api_requests INTEGER NOT NULL DEFAULT 0, + tier TEXT, + model_downgraded INTEGER, + context_window_tokens INTEGER, + truncation_sections INTEGER, + continue_here_fired INTEGER, + prompt_char_count INTEGER, + baseline_char_count INTEGER, + cache_hit_rate INTEGER, + skills TEXT, + PRIMARY KEY (type, id, started_at) + ); + CREATE TABLE IF NOT EXISTS project_metrics_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 54, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 55) { + // Schema v55: composite index for audit_events + task access-pattern views + // Guard: audit_events may not exist in minimal legacy DBs (it will be dropped in v58) + if (tableExists(db, "audit_events")) { + db.exec( + `CREATE INDEX IF NOT EXISTS idx_audit_events_category ON audit_events(category, type, ts DESC)`, + ); + } + db.exec( + `CREATE VIEW IF NOT EXISTS active_tasks AS SELECT * FROM tasks WHERE status NOT IN ('done','complete','completed','cancelled')`, + ); + db.exec(` + CREATE VIEW IF NOT EXISTS v_task_full AS + SELECT t.*, ts.spec_version, ts.verify AS spec_verify, + ts.inputs AS spec_inputs, ts.expected_output AS spec_expected_output + FROM tasks t + LEFT JOIN task_specs ts + ON t.milestone_id = ts.milestone_id + AND t.slice_id = ts.slice_id + AND t.id = ts.task_id + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 55, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 56) { + // Schema v56: move metrics table to dedicated metrics.db — drop from main DB + // to eliminate WAL pressure from high-frequency telemetry writes. + db.exec(`DROP TABLE IF EXISTS metrics`); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 56, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 57) { + // Schema v57: add archived_at to sessions for soft-delete / archive support. + db.exec(`ALTER TABLE sessions ADD COLUMN archived_at TEXT DEFAULT NULL`); + db.exec( + `CREATE INDEX IF NOT EXISTS idx_sessions_archived ON sessions(archived_at) WHERE archived_at IS NOT NULL`, + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 57, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 58) { + // Schema v58: move trace data to JSONL files — drop gate_runs, turn_git_transactions, audit_events + db.exec("DROP TABLE IF EXISTS gate_runs"); + db.exec("DROP TABLE IF EXISTS turn_git_transactions"); + db.exec("DROP TABLE IF EXISTS audit_events"); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 58, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 59) { + // Schema v59: add failure_mode to llm_task_outcomes so the learning system + // can differentiate transient failures (rate_limit) from hard failures + // (quota_exhausted, auth_error) when weighting model demotions. + ensureColumn( + db, + "llm_task_outcomes", + "failure_mode", + "ALTER TABLE llm_task_outcomes ADD COLUMN failure_mode TEXT DEFAULT NULL", + ); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_llm_task_outcomes_failure_mode ON llm_task_outcomes(model_id, failure_mode, recorded_at DESC)", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 59, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 60) { + // Schema v60: add frontmatter_version to tasks table for future frontmatter + // schema migrations. Defaults to 1 for all existing rows. + ensureColumn( + db, + "tasks", + "frontmatter_version", + "ALTER TABLE tasks ADD COLUMN frontmatter_version INTEGER NOT NULL DEFAULT 1", + ); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 60, + ":applied_at": new Date().toISOString(), + }); + } + if (currentVersion < 61) { + // Schema v61: intent_chapters — crash-resume context for autonomous units. + // Each chapter records the agent's declared intent when a unit begins + // (chapter_open) and clears it on normal close (chapter_close). On + // crash-resume, the open chapter is surfaced to the prompt so the agent + // knows where it left off without replaying the full transcript. + db.exec(` + CREATE TABLE IF NOT EXISTS intent_chapters ( + id TEXT PRIMARY KEY, + unit_type TEXT NOT NULL, + unit_id TEXT NOT NULL, + milestone_id TEXT, + slice_id TEXT, + task_id TEXT, + intent TEXT NOT NULL, + opened_at TEXT NOT NULL, + closed_at TEXT, + outcome TEXT, + metadata_json TEXT + ); + CREATE INDEX IF NOT EXISTS idx_intent_chapters_unit + ON intent_chapters(unit_type, unit_id); + CREATE INDEX IF NOT EXISTS idx_intent_chapters_open + ON intent_chapters(closed_at, opened_at) + WHERE closed_at IS NULL; + `); + db.prepare( + "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", + ).run({ + ":version": 61, + ":applied_at": new Date().toISOString(), + }); + } + db.exec("COMMIT"); + } catch (err) { + db.exec("ROLLBACK"); + throw err; + } +}