diff --git a/src/resources/extensions/sf/autonomous-solver.js b/src/resources/extensions/sf/autonomous-solver.js index bb94a79a8..934be6631 100644 --- a/src/resources/extensions/sf/autonomous-solver.js +++ b/src/resources/extensions/sf/autonomous-solver.js @@ -17,6 +17,7 @@ import { import { dirname, join } from "node:path"; import { atomicWriteSync } from "./atomic-write.js"; import { sfRoot } from "./paths.js"; +import { emitJournalEvent } from "./journal.js"; export const AUTONOMOUS_SOLVER_OUTCOMES = [ "continue", @@ -30,6 +31,7 @@ const DEFAULT_SOLVER_MAX_ITERATIONS = 30000; const MIN_SOLVER_MAX_ITERATIONS = 1; const MAX_SOLVER_MAX_ITERATIONS = 100000; const DEFAULT_MISSING_CHECKPOINT_REPAIR_ATTEMPTS = 4; +const MAX_CHECKPOINTS_PER_ITERATION = 5; const SOLVER_CHECKPOINT_SCHEMA_VERSION = 1; const SOLVER_STEERING_SCHEMA_VERSION = 1; const STALL_THRESHOLD_ITERATIONS = 3; @@ -265,6 +267,8 @@ export function beginAutonomousSolverIteration( ? existing.recentCheckpointSummaries : [] : [], + // Safety cap: how many checkpoints have been written this iteration + checkpointCountThisIteration: 0, }; writeState(basePath, state); return state; @@ -463,11 +467,13 @@ export function buildSolverPassPrompt( "", "## Classification Rubric", "", - "- `executor-refused`: The executor emitted a generic refusal ('I'm sorry', 'I cannot help', 'I don't have the necessary tools'). → checkpoint outcome=`blocked`, blockerReason=`executor-refused`.", - "- `executor-noop`: The executor emitted prose but made zero tool calls, zero file edits, and zero measurable progress. → checkpoint outcome=`blocked` (or `continue` ONLY if the executor explicitly states it is waiting for an external event).", - "- `progress`: The executor made concrete progress (file edits, tests run, tools called). → checkpoint outcome=`continue` with accurate completedItems/remainingItems.", - "- `complete`: The executor finished the unit's required artifact AND called any mandatory completion tool. → checkpoint outcome=`complete`.", - "- `blocker-other`: The executor hit a hard blocker (missing credentials, broken environment). → checkpoint outcome=`blocked` with a precise blockerReason.", + "Apply these in order; emit the FIRST one that matches.", + "", + "1. `executor-refused`: The executor emitted a generic refusal ('I'm sorry', 'I cannot help', 'I don't have the necessary tools', 'outside my capabilities'). → checkpoint outcome=`blocked`, blockerReason=`executor-refused`.", + "2. `executor-noop`: The executor emitted prose but made zero tool calls, zero file edits, and zero measurable progress. → checkpoint outcome=`blocked`, blockerReason=`executor-noop`. There is no `continue` escape hatch for this case — synthesizing forward progress over a no-op iteration is the exact bug ADR-0079 closes. If the executor genuinely needs an external event, that is a `blocker-external-wait` (rule 5), not a continue.", + "3. `progress`: The executor made concrete progress (file edits, tests run, tools called). → checkpoint outcome=`continue` with accurate completedItems/remainingItems.", + "4. `complete`: The executor finished the unit's required artifact AND called any mandatory completion tool. → checkpoint outcome=`complete`.", + "5. `blocker-other`: The executor hit a hard blocker (missing credentials, broken environment, external wait). → checkpoint outcome=`blocked` with a precise blockerReason naming the cause.", "", "## Executor Transcript", "", @@ -500,6 +506,29 @@ export function buildSolverPassPrompt( * must not satisfy the repair gate. * * Consumer: assessAutonomousSolverTurn to reject no-op continues. + * + * Implementation: structural inspection only. We look for evidence that the + * executor actually invoked tools, in either of the two message shapes used + * across SF's provider runtimes: + * + * 1. Anthropic-style: `msg.content` is an array of blocks; tool activity + * shows as `{ type: "tool_use", name: ... }` (assistant) or + * `{ type: "tool_result", ... }` (user/tool role). This is the shape + * Claude messages take when stored in pi's agent_end events (see + * undo.js:431-447 which uses the same pattern to extract tool_result + * content). + * 2. OpenAI-style: `msg.tool_calls` array on the assistant message and + * `msg.role === "tool"` (or "tool_result") with `msg.name` on the + * reply. Used by OpenAI-compatible providers. + * + * A `checkpoint` tool call by itself doesn't count as work — that's the + * protocol step, not the unit deliverable. Any other named tool counts. + * + * We deliberately do NOT grep prose ("File edited", "```diff", …). Prose + * patterns are runtime-specific and produce false negatives that mark real + * work as no-op, which would synthesize a blocker over completed iterations. + * If a transcript has zero structural tool activity, it really is a no-op + * even if its prose is plausible. */ export function isNoOpExecutorTranscript(messages) { if (!Array.isArray(messages) || messages.length === 0) return true; @@ -507,38 +536,57 @@ export function isNoOpExecutorTranscript(messages) { // Refusal is always a no-op if (classifyExecutorRefusal(messages)) return true; + const isWorkToolName = (name) => { + if (!name || typeof name !== "string") return false; + // `checkpoint` is the protocol; the executor calling it is not unit work. + // (Per ADR-0079 the executor isn't even supposed to call it.) Anything + // else — reads, writes, bash, complete_task, save_summary — counts. + return name !== "checkpoint"; + }; + for (const msg of messages) { if (!msg || typeof msg !== "object") continue; - // Assistant requested non-checkpoint tool calls - if (Array.isArray(msg.tool_calls)) { - for (const tc of msg.tool_calls) { - const name = tc?.function?.name ?? tc?.name ?? ""; - if (name && name !== "checkpoint") { + // ── Anthropic-style: content is an array of typed blocks ── + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (!block || typeof block !== "object") continue; + if (block.type === "tool_use" && isWorkToolName(block.name)) { return false; } + if (block.type === "tool_result") { + // tool_result has no name on the block itself; presence of a + // non-checkpoint tool_result implies a non-checkpoint tool_use + // preceded it. The pair-match would require backward scan; for + // robustness, treat ANY tool_result as evidence of work unless + // it's a checkpoint result (which would have been emitted by + // the assistant's checkpoint tool_use earlier in this same + // transcript — but that's protocol, not work). Without the + // block name we can't distinguish, so be conservative: a + // tool_result is non-no-op work UNLESS the entire transcript's + // only tool_use was `checkpoint`. We carry that check via the + // tool_use scan above — if a non-checkpoint tool_use exists, + // we've already returned false. If only `checkpoint` was used, + // the tool_result here is the checkpoint reply and we should + // keep scanning. + // Simpler approach: ignore tool_result blocks for the + // classification; the tool_use scan is authoritative. + continue; + } } } - // Tool results from non-checkpoint tools - if (msg.role === "tool" || msg.role === "tool_result") { - const name = msg.name ?? ""; - if (name && name !== "checkpoint") { - return false; + // ── OpenAI-style: msg.tool_calls on assistant ── + if (Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + const name = tc?.function?.name ?? tc?.name ?? ""; + if (isWorkToolName(name)) return false; } } - // Content that shows concrete work was done - const content = typeof msg.content === "string" ? msg.content : ""; - if ( - content.includes("File edited") || - content.includes("File written") || - content.includes("File created") || - content.includes("```diff") || - content.includes("--- a/") || - content.includes("+++ b/") - ) { - return false; + // ── OpenAI-style: tool reply rows ── + if (msg.role === "tool" || msg.role === "tool_result") { + if (isWorkToolName(msg.name)) return false; } } @@ -554,17 +602,61 @@ export function isNoOpExecutorTranscript(messages) { * Consumer: checkpoint tool. */ export function appendAutonomousSolverCheckpoint(basePath, params) { + const persisted = readJson(statePath(basePath)); const state = - readJson(statePath(basePath)) ?? + persisted ?? beginAutonomousSolverIteration(basePath, params.unitType, params.unitId); + // ── Sticky identity guard ── + // The orchestrator owns the active unit identity (it called + // beginAutonomousSolverIteration with the canonical unitType/unitId). + // If the agent's checkpoint call passes a *different* unitType/unitId + // because it guessed wrong (real-world: minimax/M2.1 stuck at + // 2026-05-13 calling checkpoint with `parallel-research` / + // `1-ci-build-pipeline/parallel-research` / `research-slice 1-...` + // — three different strings — none matching the orchestrator's + // active identity), the previous implementation silently overwrote + // state.unitType/unitId with the wrong claim. assessAutonomousSolverTurn + // then failed sameUnit() against the orchestrator's identity, fired + // missing-checkpoint-retry, the agent re-checkpointed with another + // wrong guess, and the loop ran indefinitely (60+ wasted calls). + // + // Fix: when there is an active running/paused state, pin the + // checkpoint to the active state's identity instead of trusting the + // agent's claim. Surface the mismatch on the checkpoint payload so it + // is visible in traces. + const hasActiveIdentity = + persisted && + persisted.unitType && + persisted.unitId && + persisted.status !== "complete"; + const isMismatch = + hasActiveIdentity && + !sameUnit(persisted, params.unitType, params.unitId); + if (isMismatch) { + emitJournalEvent(basePath, { + flowId: `${state.unitType}-${state.unitId}-${Date.now()}`, + seq: 1, + ts: nowIso(), + eventType: "checkpoint-identity-mismatch", + data: { + claimedUnitType: params.unitType, + claimedUnitId: params.unitId, + pinnedToUnitType: state.unitType, + pinnedToUnitId: state.unitId, + }, + }); + } + const effectiveUnitType = isMismatch ? state.unitType : params.unitType; + const effectiveUnitId = isMismatch ? state.unitId : params.unitId; const checkpoint = { schemaVersion: SOLVER_CHECKPOINT_SCHEMA_VERSION, ts: nowIso(), - unitType: params.unitType, - unitId: params.unitId, - iteration: sameUnit(state, params.unitType, params.unitId) - ? state.iteration - : 1, + unitType: effectiveUnitType, + unitId: effectiveUnitId, + // Iteration must match the orchestrator's current iteration so + // assessAutonomousSolverTurn's hasCurrentCheckpoint check passes + // and the outcome (especially `complete`) is honored. + iteration: state.iteration, outcome: params.outcome, summary: String(params.summary ?? "").trim(), completedItems: sanitizeList(params.completedItems), @@ -586,11 +678,22 @@ export function appendAutonomousSolverCheckpoint(basePath, params) { invariants: String(params.pdd?.invariants ?? "").trim(), assumptions: String(params.pdd?.assumptions ?? "").trim(), }, + // Diagnostic: when the agent's claim differs from the active unit, + // record both so trace consumers can flag the model's confusion. + ...(isMismatch + ? { + mismatchedIdentity: { + claimedUnitType: String(params.unitType ?? ""), + claimedUnitId: String(params.unitId ?? ""), + pinnedToActive: { unitType: state.unitType, unitId: state.unitId }, + }, + } + : {}), }; const nextState = { ...state, - unitType: params.unitType, - unitId: params.unitId, + unitType: effectiveUnitType, + unitId: effectiveUnitId, status: params.outcome === "complete" ? "complete" @@ -630,6 +733,9 @@ export function appendAutonomousSolverCheckpoint(basePath, params) { : []), checkpoint.summary, ].slice(-ROLLING_SUMMARY_WINDOW), + // Increment checkpoint count for this iteration (safety cap) + checkpointCountThisIteration: + (state.checkpointCountThisIteration || 0) + 1, }; mkdirSync(dirname(historyPath(basePath)), { recursive: true }); writeFileSync(historyPath(basePath), `${JSON.stringify(checkpoint)}\n`, { @@ -985,6 +1091,20 @@ export function assessAutonomousSolverTurn( maxRepairAttempts: DEFAULT_MISSING_CHECKPOINT_REPAIR_ATTEMPTS, }; } + // Hard cap on excessive checkpoints within a single iteration + if ( + (state.checkpointCountThisIteration || 0) >= + MAX_CHECKPOINTS_PER_ITERATION + ) { + return { + action: "pause", + reason: "solver-excessive-checkpoints", + state, + checkpoint, + checkpointCount: state.checkpointCountThisIteration, + maxCheckpointCount: MAX_CHECKPOINTS_PER_ITERATION, + }; + } if ( state.iteration >= state.maxIterations && checkpoint.outcome !== "complete" diff --git a/src/resources/extensions/sf/routing-history.js b/src/resources/extensions/sf/routing-history.js index dfa78f8ac..6c58c7fed 100644 --- a/src/resources/extensions/sf/routing-history.js +++ b/src/resources/extensions/sf/routing-history.js @@ -15,15 +15,45 @@ const FAILURE_THRESHOLD = 0.2; // >20% failure rate triggers tier bump const FEEDBACK_WEIGHT = 2; // feedback signals count 2x vs automatic // ─── In-Memory State ───────────────────────────────────────────────────────── let history = null; +// Latches to false when the `routing_history` table is observed missing at +// init time. Subsequent DB writes from recordOutcome/recordFeedback are then +// skipped so a stale-schema project doesn't repeatedly throw on every +// dispatch. The next openDatabase that successfully runs the v53 migration +// will let a later initRoutingHistory call flip this back to true. +let _dbTableAvailable = true; // ─── Public API ────────────────────────────────────────────────────────────── /** * Initialize routing history for a project. + * + * Resilient to a missing `routing_history` table: a project DB whose schema + * version predates the routing_history migration (v53) will throw `no such + * table: routing_history` on the underlying SELECT. We swallow that one + * specific case so auto-start is not blocked by a stale schema; the + * in-memory history simply starts empty and accumulates from scratch. + * Anything else (corrupt DB, permission errors) re-throws so it remains + * visible. */ export function initRoutingHistory(_base) { history = createEmptyHistory(); const db = getDatabase(); if (!db) return; - const rows = getAllRoutingHistory(db); + let rows; + try { + rows = getAllRoutingHistory(db); + _dbTableAvailable = true; + } catch (err) { + const message = err?.message ? String(err.message) : ""; + if (/no such table:\s*routing_history/i.test(message)) { + // Schema lags the code — fresh project, or a project whose DB never + // migrated past v52. Start with empty in-memory state and latch the + // flag so recordOutcome/recordFeedback skip their DB writes for the + // remainder of the session instead of crashing on every dispatch. + rows = []; + _dbTableAvailable = false; + } else { + throw err; + } + } for (const row of rows) { if (!history.patterns[row.pattern]) { history.patterns[row.pattern] = { @@ -62,13 +92,14 @@ export function resetRoutingHistory() { export function recordOutcome(unitType, tier, success, tags) { if (!history) return; const db = getDatabase(); + const canWriteDb = db && _dbTableAvailable; // Record for the base unit type const basePattern = unitType; ensurePattern(basePattern); const outcome = history.patterns[basePattern][tier]; if (success) outcome.success++; else outcome.fail++; - if (db) upsertRoutingOutcome(db, basePattern, tier, success); + if (canWriteDb) upsertRoutingOutcome(db, basePattern, tier, success); // Record for tag-specific patterns (e.g. "execute-task:docs") if (tags && tags.length > 0) { for (const tag of tags) { @@ -77,7 +108,7 @@ export function recordOutcome(unitType, tier, success, tags) { const tagOutcome = history.patterns[tagPattern][tier]; if (success) tagOutcome.success++; else tagOutcome.fail++; - if (db) upsertRoutingOutcome(db, tagPattern, tier, success); + if (canWriteDb) upsertRoutingOutcome(db, tagPattern, tier, success); } } // Apply rolling window — cap total entries per tier per pattern @@ -111,7 +142,8 @@ export function recordFeedback(unitType, _unitId, tier, rating) { history.feedback = history.feedback.slice(-200); } const db = getDatabase(); - if (db) insertRoutingFeedback(db, unitType, tier, rating); + const canWriteDb = db && _dbTableAvailable; + if (canWriteDb) insertRoutingFeedback(db, unitType, tier, rating); // Apply feedback as weighted outcome const pattern = unitType; ensurePattern(pattern); @@ -122,7 +154,7 @@ export function recordFeedback(unitType, _unitId, tier, rating) { if (lower) { const outcomes = history.patterns[pattern][lower]; outcomes.success += FEEDBACK_WEIGHT; - if (db) { + if (canWriteDb) { for (let i = 0; i < FEEDBACK_WEIGHT; i++) { upsertRoutingOutcome(db, pattern, lower, true); } @@ -132,7 +164,7 @@ export function recordFeedback(unitType, _unitId, tier, rating) { // User says this needed a better model → record as failure at current tier const outcomes = history.patterns[pattern][tier]; outcomes.fail += FEEDBACK_WEIGHT; - if (db) { + if (canWriteDb) { for (let i = 0; i < FEEDBACK_WEIGHT; i++) { upsertRoutingOutcome(db, pattern, tier, false); } @@ -165,7 +197,7 @@ export function getAdaptiveTierAdjustment(unitType, currentTier, tags) { export function clearRoutingHistory(_base) { history = createEmptyHistory(); const db = getDatabase(); - if (db) dbClearRoutingHistory(db); + if (db && _dbTableAvailable) dbClearRoutingHistory(db); } /** * Get current history data (for display/debugging). diff --git a/src/resources/extensions/sf/sf-db/sf-db-schema.js b/src/resources/extensions/sf/sf-db/sf-db-schema.js index 6f693526d..1dd57bed9 100644 --- a/src/resources/extensions/sf/sf-db/sf-db-schema.js +++ b/src/resources/extensions/sf/sf-db/sf-db-schema.js @@ -4,8 +4,8 @@ // 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"; +import { logWarning } from "../workflow-logger.js"; function defaultQueryTimeout(operation, fallbackValue) { try { @@ -712,7 +712,8 @@ function ensureSpecSchemaTables(db) { `); } export function initSchema(db, fileBacked, options = {}) { - const { currentPath = null, withQueryTimeout = defaultQueryTimeout } = 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"); @@ -1499,50 +1500,79 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ); } } - db.exec("BEGIN"); - try { - if (currentVersion < 2) { + + // Per-migration transaction boundaries: each migration step commits + // independently so a failure in a late migration does not roll back + // already-successful earlier migrations. This prevents the "stuck at + // v53 forever" scenario where a v62 failure rolled back v53 DDL. + // (See self-feedback sf-mp36kfqm-rjrzju.) + function runMigrationStep(label, fn) { + db.exec("BEGIN"); + try { + fn(); + db.exec("COMMIT"); + return true; + } catch (err) { + try { + db.exec("ROLLBACK"); + } catch { + /* rollback is best-effort */ + } + logWarning( + "db", + `Schema migration step ${label} failed: ${getErrorMessage(err)}. Database remains at its previously committed version.`, + ); + return false; + } + } + + let appliedVersion = currentVersion; + if (appliedVersion < 2) { + const ok = runMigrationStep("v2", () => { 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 '' - ) - `); + 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) { + }); + if (ok) appliedVersion = 2; + } + if (appliedVersion < 3) { + const ok = runMigrationStep("v3", () => { 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 - ) - `); + 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 - ) - `); + 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)", ); @@ -1556,8 +1586,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 3, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 4) { + }); + if (ok) appliedVersion = 3; + } + if (appliedVersion < 4) { + const ok = runMigrationStep("v4", () => { ensureColumn( db, "decisions", @@ -1574,74 +1607,80 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 4, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 5) { + }); + if (ok) appliedVersion = 4; + } + if (appliedVersion < 5) { + const ok = runMigrationStep("v5", () => { 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 - ) - `); + 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) - ) - `); + 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) - ) - `); + 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) - ) - `); + 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) { + }); + if (ok) appliedVersion = 5; + } + if (appliedVersion < 6) { + const ok = runMigrationStep("v6", () => { ensureColumn( db, "slices", @@ -1660,8 +1699,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 6, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 7) { + }); + if (ok) appliedVersion = 6; + } + if (appliedVersion < 7) { + const ok = runMigrationStep("v7", () => { ensureColumn( db, "slices", @@ -1686,8 +1728,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 7, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 8) { + }); + if (ok) appliedVersion = 7; + } + if (appliedVersion < 8) { + const ok = runMigrationStep("v8", () => { ensureColumn( db, "milestones", @@ -1833,31 +1878,31 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { `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) - ) - `); + 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) - ) - `); + 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)", ); @@ -1867,8 +1912,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 8, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 9) { + }); + if (ok) appliedVersion = 8; + } + if (appliedVersion < 9) { + const ok = runMigrationStep("v9", () => { ensureColumn( db, "slices", @@ -1887,8 +1935,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 9, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 10) { + }); + if (ok) appliedVersion = 9; + } + if (appliedVersion < 10) { + const ok = runMigrationStep("v10", () => { ensureColumn( db, "slices", @@ -1901,8 +1952,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 10, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 11) { + }); + if (ok) appliedVersion = 10; + } + if (appliedVersion < 11) { + const ok = runMigrationStep("v11", () => { ensureColumn( db, "tasks", @@ -1912,42 +1966,48 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { // 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 - `); + 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) { + }); + if (ok) appliedVersion = 11; + } + if (appliedVersion < 12) { + const ok = runMigrationStep("v12", () => { 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) - ) - `); + 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) { + }); + if (ok) appliedVersion = 12; + } + if (appliedVersion < 13) { + const ok = runMigrationStep("v13", () => { // 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)", @@ -1971,18 +2031,21 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 13, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 14) { + }); + if (ok) appliedVersion = 13; + } + if (appliedVersion < 14) { + const ok = runMigrationStep("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) - ) - `); + 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)", ); @@ -1992,70 +2055,73 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 14, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 15) { + }); + if (ok) appliedVersion = 14; + } + if (appliedVersion < 15) { + const ok = runMigrationStep("v15", () => { 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 - ) - `); + 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) - ) - `); + 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 '{}' - ) - `); + 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) - ) - `); + 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)", ); @@ -2077,26 +2143,29 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 15, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 16) { + }); + if (ok) appliedVersion = 15; + } + if (appliedVersion < 16) { + const ok = runMigrationStep("v16", () => { 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 - ) - `); + 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)", ); @@ -2115,8 +2184,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 16, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 17) { + }); + if (ok) appliedVersion = 16; + } + if (appliedVersion < 17) { + const ok = runMigrationStep("v17", () => { ensureColumn( db, "tasks", @@ -2126,37 +2198,40 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { // 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') - `); + 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) { + }); + if (ok) appliedVersion = 17; + } + if (appliedVersion < 18) { + const ok = runMigrationStep("v18", () => { ensureColumn( db, "slices", @@ -2181,8 +2256,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 18, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 19) { + }); + if (ok) appliedVersion = 18; + } + if (appliedVersion < 19) { + const ok = runMigrationStep("v19", () => { ensureColumn( db, "slices", @@ -2195,8 +2273,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 19, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 20) { + }); + if (ok) appliedVersion = 19; + } + if (appliedVersion < 20) { + const ok = runMigrationStep("v20", () => { ensureColumn( db, "milestones", @@ -2209,8 +2290,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 20, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 21) { + }); + if (ok) appliedVersion = 20; + } + if (appliedVersion < 21) { + const ok = runMigrationStep("v21", () => { ensureRepoProfileTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2218,8 +2302,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 21, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 22) { + }); + if (ok) appliedVersion = 21; + } + if (appliedVersion < 22) { + const ok = runMigrationStep("v22", () => { // 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 @@ -2242,8 +2329,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 22, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 23) { + }); + if (ok) appliedVersion = 22; + } + if (appliedVersion < 23) { + const ok = runMigrationStep("v23", () => { // 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. @@ -2275,8 +2365,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 23, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 24) { + }); + if (ok) appliedVersion = 23; + } + if (appliedVersion < 24) { + const ok = runMigrationStep("v24", () => { // 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 @@ -2293,8 +2386,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 24, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 25) { + }); + if (ok) appliedVersion = 24; + } + if (appliedVersion < 25) { + const ok = runMigrationStep("v25", () => { // 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 @@ -2314,21 +2410,24 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 25, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 26) { + }); + if (ok) appliedVersion = 25; + } + if (appliedVersion < 26) { + const ok = runMigrationStep("v26", () => { 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 - ) - `); + 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)", ); @@ -2341,8 +2440,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 26, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 27) { + }); + if (ok) appliedVersion = 26; + } + if (appliedVersion < 27) { + const ok = runMigrationStep("v27", () => { ensureSolverEvalTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2350,8 +2452,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 27, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 28) { + }); + if (ok) appliedVersion = 27; + } + if (appliedVersion < 28) { + const ok = runMigrationStep("v28", () => { // 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")) { @@ -2364,24 +2469,27 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { } // 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 '' - ) - `); + 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) { + }); + if (ok) appliedVersion = 28; + } + if (appliedVersion < 29) { + const ok = runMigrationStep("v29", () => { ensureHeadlessRunTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2389,8 +2497,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 29, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 30) { + }); + if (ok) appliedVersion = 29; + } + if (appliedVersion < 30) { + const ok = runMigrationStep("v30", () => { ensureSelfFeedbackTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2398,8 +2509,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 30, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 31) { + }); + if (ok) appliedVersion = 30; + } + if (appliedVersion < 31) { + const ok = runMigrationStep("v31", () => { ensureUokMessageTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2407,8 +2521,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 31, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 32) { + }); + if (ok) appliedVersion = 31; + } + if (appliedVersion < 32) { + const ok = runMigrationStep("v32", () => { ensureTaskCreatedAtColumn(db); ensureSpecSchemaTables(db); // Populate spec tables from existing spec columns in runtime tables @@ -2419,8 +2536,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 32, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 33) { + }); + if (ok) appliedVersion = 32; + } + if (appliedVersion < 33) { + const ok = runMigrationStep("v33", () => { ensureColumn( db, "milestones", @@ -2433,8 +2553,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 33, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 34) { + }); + if (ok) appliedVersion = 33; + } + if (appliedVersion < 34) { + const ok = runMigrationStep("v34", () => { ensureTaskCreatedAtColumn(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2442,8 +2565,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 34, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 35) { + }); + if (ok) appliedVersion = 34; + } + if (appliedVersion < 35) { + const ok = runMigrationStep("v35", () => { ensureBacklogTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2451,8 +2577,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 35, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 36) { + }); + if (ok) appliedVersion = 35; + } + if (appliedVersion < 36) { + const ok = runMigrationStep("v36", () => { migrateCostUsdToMicroUsd(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2460,8 +2589,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 36, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 37) { + }); + if (ok) appliedVersion = 36; + } + if (appliedVersion < 37) { + const ok = runMigrationStep("v37", () => { ensureScheduleTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2469,8 +2601,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 37, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 38) { + }); + if (ok) appliedVersion = 37; + } + if (appliedVersion < 38) { + const ok = runMigrationStep("v38", () => { try { db.exec( "ALTER TABLE memories ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'", @@ -2484,8 +2619,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 38, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 39) { + }); + if (ok) appliedVersion = 38; + } + if (appliedVersion < 39) { + const ok = runMigrationStep("v39", () => { db.exec( "CREATE INDEX IF NOT EXISTS idx_memory_sources_content_hash ON memory_sources(content_hash)", ); @@ -2498,19 +2636,22 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 39, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 40) { + }); + if (ok) appliedVersion = 39; + } + if (appliedVersion < 40) { + const ok = runMigrationStep("v40", () => { 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 - ) - `); + 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)", ); @@ -2520,8 +2661,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 40, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 41) { + }); + if (ok) appliedVersion = 40; + } + if (appliedVersion < 41) { + const ok = runMigrationStep("v41", () => { ensureRetrievalEvidenceTables(db); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", @@ -2529,8 +2673,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 41, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 42) { + }); + if (ok) appliedVersion = 41; + } + if (appliedVersion < 42) { + const ok = runMigrationStep("v42", () => { ensureColumn( db, "milestones", @@ -2549,157 +2696,172 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 42, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 43) { + }); + if (ok) appliedVersion = 42; + } + if (appliedVersion < 43) { + const ok = runMigrationStep("v43", () => { 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 '' - ) - `); + 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')) - `); + 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) { + }); + if (ok) appliedVersion = 43; + } + if (appliedVersion < 44) { + const ok = runMigrationStep("v44", () => { 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 - `); + 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) - `); + 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) { + }); + if (ok) appliedVersion = 44; + } + if (appliedVersion < 45) { + const ok = runMigrationStep("v45", () => { 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 - `); + 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) { + }); + if (ok) appliedVersion = 45; + } + if (appliedVersion < 46) { + const ok = runMigrationStep("v46", () => { // 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) - ) - `); + 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) - `); + 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 - ) - `); + 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) { + }); + if (ok) appliedVersion = 46; + } + if (appliedVersion < 47) { + const ok = runMigrationStep("v47", () => { // Drop unused superseded_by column from validation_runs. // The column was never written or queried — dead schema from v46. const cols = db @@ -2715,38 +2877,41 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 47, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 48) { + }); + if (ok) appliedVersion = 47; + } + if (appliedVersion < 48) { + const ok = runMigrationStep("v48", () => { // 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 - `); + 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 - `); + 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 - `); + 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')`); @@ -2756,8 +2921,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 48, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 49) { + }); + if (ok) appliedVersion = 48; + } + if (appliedVersion < 49) { + const ok = runMigrationStep("v49", () => { // Add session_snapshots table — checkpoints before irreversible ops. // Safe to call on fresh DBs too (CREATE TABLE IF NOT EXISTS). ensureSessionSnapshotTable(db); @@ -2767,8 +2935,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 49, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 50) { + }); + if (ok) appliedVersion = 49; + } + if (appliedVersion < 50) { + const ok = runMigrationStep("v50", () => { // Add sleeptime_consolidation_queue — decouples memory consolidation // from the conversation turn so the daemon can drain it asynchronously. ensureSleeptimeQueueTable(db); @@ -2778,8 +2949,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 50, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 51) { + }); + if (ok) appliedVersion = 50; + } + if (appliedVersion < 51) { + const ok = runMigrationStep("v51", () => { // 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. @@ -2790,8 +2964,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 51, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 52) { + }); + if (ok) appliedVersion = 51; + } + if (appliedVersion < 52) { + const ok = runMigrationStep("v52", () => { // Add triage_runs/evals/items/skills, runtime_counters, and // validation_attention_markers tables — migrate JSONL structured state to DB. ensureTriageTables(db); @@ -2803,79 +2980,88 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 52, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 53) { + }); + if (ok) appliedVersion = 52; + } + if (appliedVersion < 53) { + const ok = runMigrationStep("v53", () => { // 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 - ); - `); + 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) { + }); + if (ok) appliedVersion = 53; + } + if (appliedVersion < 54) { + const ok = runMigrationStep("v54", () => { // 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 - ); - `); + 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) { + }); + if (ok) appliedVersion = 54; + } + if (appliedVersion < 55) { + const ok = runMigrationStep("v55", () => { // 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")) { @@ -2887,23 +3073,26 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { `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 - `); + 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) { + }); + if (ok) appliedVersion = 55; + } + if (appliedVersion < 56) { + const ok = runMigrationStep("v56", () => { // 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`); @@ -2913,8 +3102,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 56, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 57) { + }); + if (ok) appliedVersion = 56; + } + if (appliedVersion < 57) { + const ok = runMigrationStep("v57", () => { // 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( @@ -2926,8 +3118,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 57, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 58) { + }); + if (ok) appliedVersion = 57; + } + if (appliedVersion < 58) { + const ok = runMigrationStep("v58", () => { // 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"); @@ -2938,8 +3133,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 58, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 59) { + }); + if (ok) appliedVersion = 58; + } + if (appliedVersion < 59) { + const ok = runMigrationStep("v59", () => { // 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. @@ -2958,8 +3156,11 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 59, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 60) { + }); + if (ok) appliedVersion = 59; + } + if (appliedVersion < 60) { + const ok = runMigrationStep("v60", () => { // Schema v60: add frontmatter_version to tasks table for future frontmatter // schema migrations. Defaults to 1 for all existing rows. ensureColumn( @@ -2974,65 +3175,99 @@ function migrateSchema(db, { currentPath, withQueryTimeout }) { ":version": 60, ":applied_at": new Date().toISOString(), }); - } - if (currentVersion < 61) { + }); + if (ok) appliedVersion = 60; + } + if (appliedVersion < 61) { + const ok = runMigrationStep("v61", () => { // 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; - `); + 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(), }); - } - if (currentVersion < 62) { + }); + if (ok) appliedVersion = 61; + } + if (appliedVersion < 62) { + const ok = runMigrationStep("v62", () => { // Schema v62: tracked_md_files — sha-track source-of-truth markdown files // so SF can detect external edits (hand-edits, git pulls, cross-agent edits) // and surface diffs at session start. db.exec(` - CREATE TABLE IF NOT EXISTS tracked_md_files ( - relpath TEXT PRIMARY KEY, - sha256 TEXT NOT NULL, - size_bytes INTEGER NOT NULL DEFAULT 0, - last_seen_at TEXT NOT NULL, - last_seen_commit TEXT DEFAULT NULL, - category TEXT NOT NULL DEFAULT 'meta', - active INTEGER NOT NULL DEFAULT 1 - ); - `); + CREATE TABLE IF NOT EXISTS tracked_md_files ( + relpath TEXT PRIMARY KEY, + sha256 TEXT NOT NULL, + size_bytes INTEGER NOT NULL DEFAULT 0, + last_seen_at TEXT NOT NULL, + last_seen_commit TEXT DEFAULT NULL, + category TEXT NOT NULL DEFAULT 'meta', + active INTEGER NOT NULL DEFAULT 1 + ); + `); db.prepare( "INSERT INTO schema_version (version, applied_at) VALUES (:version, :applied_at)", ).run({ ":version": 62, ":applied_at": new Date().toISOString(), }); - } - db.exec("COMMIT"); - } catch (err) { - db.exec("ROLLBACK"); - throw err; + }); + if (ok) appliedVersion = 62; + } + + // Post-migration assertion: ensure critical tables created by historical + // migrations are actually present. If a prior migration claimed success but + // the table is missing (e.g., due to a rolled-back transaction that failed + // silently), recreate it now and log a warning so silent drift is visible. + // (See self-feedback sf-mp36kfqm-rjrzju.) + if (!tableExists(db, "routing_history")) { + 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) + ) + `); + db.exec(` + 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 + ) + `); + logWarning( + "db", + "Post-migration assertion: routing_history was missing despite schema_version >= 53. Recreated tables. This indicates a prior migration rollback that left schema_version out of sync with actual DDL state.", + ); } } diff --git a/src/resources/extensions/sf/tests/autonomous-solver.test.mjs b/src/resources/extensions/sf/tests/autonomous-solver.test.mjs index e80288906..a48aabad7 100644 --- a/src/resources/extensions/sf/tests/autonomous-solver.test.mjs +++ b/src/resources/extensions/sf/tests/autonomous-solver.test.mjs @@ -317,6 +317,36 @@ describe("autonomous solver", () => { expect(result.reason).toBe("solver-max-iterations"); }); + test("assessAutonomousSolverTurn_excessive_checkpoints_pauses_after_cap", () => { + // Fail-fast when the agent calls checkpoint 5+ times within a single + // iteration without making other tool progress. Prevents the 60+ + // no-op checkpoint loop from sf-mp37kjmo-1mfuru. + const project = makeProject(); + beginAutonomousSolverIteration(project, "execute-task", "M001/S01/T01"); + + for (let i = 0; i < 5; i++) { + appendAutonomousSolverCheckpoint(project, { + unitType: "execute-task", + unitId: "M001/S01/T01", + outcome: "continue", + summary: `Checkpoint ${i + 1} — still stuck.`, + completedItems: [], + remainingItems: ["need help"], + verificationEvidence: [], + pdd: pdd(), + }); + } + + const result = assessAutonomousSolverTurn( + project, + "execute-task", + "M001/S01/T01", + ); + expect(result.action).toBe("pause"); + expect(result.reason).toBe("solver-excessive-checkpoints"); + expect(result.checkpointCount).toBe(5); + }); + test("steering_append_consume_is_idempotent", () => { const project = makeProject(); appendAutonomousSolverSteering(project, "Prefer runtime enforcement."); @@ -934,3 +964,114 @@ describe("assessAutonomousSolverTurn no-op detection", () => { expect(result.reason).toBe("solver-noop-continue"); }); }); + +describe("appendAutonomousSolverCheckpoint sticky identity", () => { + test("pins to orchestrator unit identity when agent passes a different unitId", () => { + // Real-world bug (2026-05-13): minimax/M2.1 stuck in 60+ checkpoint + // loop because each call passed a guessed unitId + // ("parallel-research" / "research-slice 1-ci-build-pipeline/..."), + // silently overwriting state.unitId. assessAutonomousSolverTurn then + // failed sameUnit() against the orchestrator's identity and re-fired + // repair forever. The active state's identity must be sticky. + const project = makeProject(); + beginAutonomousSolverIteration( + project, + "execute-task", + "M001/S04/T02", + ); + appendAutonomousSolverCheckpoint(project, { + unitType: "execute-task", + unitId: "parallel-research", // <-- agent guesses wrong + outcome: "complete", + summary: "Done.", + completedItems: ["work"], + remainingItems: [], + verificationEvidence: ["ls -la"], + pdd: pdd(), + }); + const state = readAutonomousSolverState(project); + // State identity must NOT shift to the agent's wrong claim. + expect(state.unitType).toBe("execute-task"); + expect(state.unitId).toBe("M001/S04/T02"); + // Checkpoint payload itself is pinned to the orchestrator's identity. + expect(state.latestCheckpoint.unitType).toBe("execute-task"); + expect(state.latestCheckpoint.unitId).toBe("M001/S04/T02"); + // Mismatch is surfaced diagnostically. + expect(state.latestCheckpoint.mismatchedIdentity).toEqual({ + claimedUnitType: "execute-task", + claimedUnitId: "parallel-research", + pinnedToActive: { + unitType: "execute-task", + unitId: "M001/S04/T02", + }, + }); + }); + + test("assessAutonomousSolverTurn honors complete after sticky-pin rescue", () => { + // End-to-end: agent passes wrong unitId, checkpoint stickys to + // orchestrator's identity, assess sees outcome=complete and returns + // action=complete (NOT missing-checkpoint-retry). + const project = makeProject(); + beginAutonomousSolverIteration( + project, + "execute-task", + "M001/S04/T02", + ); + appendAutonomousSolverCheckpoint(project, { + unitType: "execute-task", + unitId: "wrong-guess", + outcome: "complete", + summary: "Done.", + completedItems: ["work"], + remainingItems: [], + verificationEvidence: ["ls -la"], + pdd: pdd(), + }); + const assessment = assessAutonomousSolverTurn( + project, + "execute-task", + "M001/S04/T02", + ); + expect(assessment.action).toBe("complete"); + }); + + test("matching unitId does not flag mismatch", () => { + const project = makeProject(); + beginAutonomousSolverIteration( + project, + "execute-task", + "M001/S04/T02", + ); + appendAutonomousSolverCheckpoint(project, { + unitType: "execute-task", + unitId: "M001/S04/T02", + outcome: "continue", + summary: "Progress", + completedItems: ["read files"], + remainingItems: ["edit code"], + verificationEvidence: ["grep -n"], + pdd: pdd(), + }); + const state = readAutonomousSolverState(project); + expect(state.latestCheckpoint.mismatchedIdentity).toBeUndefined(); + }); + + test("fresh project with no active state accepts agent-provided identity", () => { + // Bootstrap case: state is null on first call; the agent's claim + // initializes the state. (Same behavior as before the sticky fix.) + const project = makeProject(); + appendAutonomousSolverCheckpoint(project, { + unitType: "execute-task", + unitId: "M001/S01/T01", + outcome: "continue", + summary: "First iteration", + completedItems: [], + remainingItems: ["plan"], + verificationEvidence: [], + pdd: pdd(), + }); + const state = readAutonomousSolverState(project); + expect(state.unitId).toBe("M001/S01/T01"); + expect(state.latestCheckpoint.mismatchedIdentity).toBeUndefined(); + }); +}); diff --git a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs index c58b71e47..27064c59b 100644 --- a/src/resources/extensions/sf/tests/sf-db-migration.test.mjs +++ b/src/resources/extensions/sf/tests/sf-db-migration.test.mjs @@ -29,6 +29,7 @@ import { openDatabase, reconcileWorktreeDb, } from "../sf-db.js"; +import { initRoutingHistory } from "../routing-history.js"; const tmpDirs = []; @@ -149,6 +150,56 @@ function makeLegacyV27Db() { return dbPath; } +function makeLegacyV52Db() { + const dir = mkdtempSync(join(tmpdir(), "sf-legacy-v52-")); + tmpDirs.push(dir); + const sfDir = join(dir, ".sf"); + mkdirSync(sfDir, { recursive: true }); + const dbPath = join(sfDir, "sf.db"); + const db = new DatabaseSync(dbPath); + db.exec(` + CREATE TABLE schema_version ( + version INTEGER NOT NULL, + applied_at TEXT NOT NULL + ); + INSERT INTO schema_version (version, applied_at) + VALUES (52, '2026-05-06T00:00:00.000Z'); + + CREATE TABLE 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 + ); + + CREATE TABLE 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, + PRIMARY KEY (milestone_id, id) + ); + + CREATE TABLE 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', + PRIMARY KEY (milestone_id, slice_id, id) + ); + `); + db.close(); + return dbPath; +} + function makeLegacyV35GateRunsDb() { const dir = mkdtempSync(join(tmpdir(), "sf-legacy-v35-gates-")); tmpDirs.push(dir); @@ -262,6 +313,35 @@ test("openDatabase_migrates_v27_tasks_without_created_at_through_spec_backfill", assert.deepEqual(schedulerRow, { status: "queued" }); }); +test("openDatabase_v52_db_heals_routing_history_and_auto_start_path_works", () => { + const dbPath = makeLegacyV52Db(); + + assert.equal(openDatabase(dbPath), true); + const db = getDatabase(); + + // ensurePostBootstrapTables should have created routing_history + const routingTable = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='routing_history'", + ) + .get(); + assert.ok( + routingTable, + "routing_history table should exist after ensurePostBootstrapTables", + ); + + // initRoutingHistory (auto-start path) must not crash on a v52 DB + assert.doesNotThrow(() => { + initRoutingHistory(dbPath); + }, "initRoutingHistory should not throw on a v52 DB"); + + // Schema should have migrated to v62 + const version = db + .prepare("SELECT MAX(version) AS version FROM schema_version") + .get(); + assert.equal(version.version, 62); +}); + test("openDatabase_when_fresh_db_supports_schedule_entries", () => { assert.equal(openDatabase(":memory:"), true);