diff --git a/src/headless-query.ts b/src/headless-query.ts index 1ae495dac..3b4fc3776 100644 --- a/src/headless-query.ts +++ b/src/headless-query.ts @@ -374,6 +374,10 @@ export async function buildQuerySnapshot( // Non-fatal — schedule data is best-effort in query output. } + const uokDiagnostics = writeUokDiagnostics(basePath, { + expectedNext: next, + repairStaleRuntimeProjection: true, + }); const snapshot: QuerySnapshot = { schemaVersion: 1, state, @@ -387,7 +391,7 @@ export async function buildQuerySnapshot( isTerminalUnitRuntimeStatus, }), }, - uokDiagnostics: writeUokDiagnostics(basePath, { expectedNext: next }), + uokDiagnostics, schedule: scheduleEntries, }; diff --git a/src/resource-loader.ts b/src/resource-loader.ts index 75e79c788..2d729f531 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -73,9 +73,9 @@ interface ManagedResourceManifest { */ installedExtensionRootFiles?: string[]; /** - * Subdirectory extension names installed in extensions/ by this SF version. - * Used on the next upgrade to detect and prune subdirectory extensions that - * were removed from the bundle. + * Subdirectories installed in extensions/ by this SF version. + * Includes extension packages and shared support modules so a missing support + * module invalidates the manifest-gated startup skip. */ installedExtensionDirs?: string[]; } @@ -110,7 +110,7 @@ function getBundledSfVersion(): string { } function writeManagedResourceManifest(agentDir: string): void { - // Record root-level files and subdirectory extension names currently in the + // Record root-level files and subdirectory names currently in the // bundled extensions source so that future upgrades can detect and prune any // that get removed or moved. let installedExtensionRootFiles: string[] = []; @@ -125,17 +125,6 @@ function writeManagedResourceManifest(agentDir: string): void { .map((e) => e.name); installedExtensionDirs = entries .filter((e) => e.isDirectory()) - .filter((e) => { - // Track directories that are actual extensions — identified by an - // index.js/index.ts entry point OR an extension-manifest.json (e.g. - // remote-questions which uses mod.ts instead of index.ts). - const dirPath = join(bundledExtensionsDir, e.name); - return ( - existsSync(join(dirPath, "index.js")) || - existsSync(join(dirPath, "index.ts")) || - existsSync(join(dirPath, "extension-manifest.json")) - ); - }) .map((e) => e.name); } } catch { @@ -694,6 +683,25 @@ function verifyManifestFilesExist( } } } + try { + if (existsSync(bundledExtensionsDir)) { + for (const entry of readdirSync(bundledExtensionsDir, { + withFileTypes: true, + })) { + if (entry.isFile() && !existsSync(join(extensionsDir, entry.name))) { + return false; + } + if ( + entry.isDirectory() && + !existsSync(join(extensionsDir, entry.name)) + ) { + return false; + } + } + } + } catch { + return false; + } return true; } diff --git a/src/resources/extensions/sf/tests/uok-diagnostic-synthesis.test.mjs b/src/resources/extensions/sf/tests/uok-diagnostic-synthesis.test.mjs index ccd12e744..bec3d365a 100644 --- a/src/resources/extensions/sf/tests/uok-diagnostic-synthesis.test.mjs +++ b/src/resources/extensions/sf/tests/uok-diagnostic-synthesis.test.mjs @@ -22,7 +22,10 @@ import { writeUokDiagnostics, } from "../uok/diagnostic-synthesis.js"; import { MessageBus } from "../uok/message-bus.js"; -import { writeUnitRuntimeRecord } from "../uok/unit-runtime.js"; +import { + listUnitRuntimeRecords, + writeUnitRuntimeRecord, +} from "../uok/unit-runtime.js"; const NOW = Date.parse("2026-05-06T00:00:00.000Z"); const tmpRoots = []; @@ -209,6 +212,36 @@ test("synthesizeUokDiagnostics_when_db_next_differs_from_projection_reports_mism assert.ok(issueCodes(diagnostics).includes("db-projection-unit-mismatch")); }); +test("writeUokDiagnostics_when_repair_enabled_clears_mismatched_stale_projection", () => { + const root = makeProject(); + writeUnitRuntimeRecord( + root, + "rewrite-docs", + "M001/validation-attention", + NOW - 10_000, + { + status: "running", + lastHeartbeatAt: NOW - 5_000, + lastProgressAt: NOW - 5_000, + }, + ); + + const diagnostics = writeUokDiagnostics(root, { + nowMs: NOW, + processRows: [], + expectedNext: { + action: "dispatch", + unitType: "validate-milestone", + unitId: "M001", + }, + repairStaleRuntimeProjection: true, + }); + + assert.equal(diagnostics.signals.runtimeProjection, "ok"); + assert.ok(!issueCodes(diagnostics).includes("db-projection-unit-mismatch")); + assert.deepEqual(listUnitRuntimeRecords(root), []); +}); + test("writeUokDiagnostics_persists_report_for_status_widget_and_doctor", () => { const root = makeProject(); openDatabase(":memory:"); diff --git a/src/resources/extensions/sf/uok/diagnostic-synthesis.js b/src/resources/extensions/sf/uok/diagnostic-synthesis.js index 59add9e4b..54faf7e83 100644 --- a/src/resources/extensions/sf/uok/diagnostic-synthesis.js +++ b/src/resources/extensions/sf/uok/diagnostic-synthesis.js @@ -7,6 +7,7 @@ import { getUokRuns, isDbAvailable } from "../sf-db.js"; import { MessageBus } from "./message-bus.js"; import { summarizeParityHealth, writeParityReport } from "./parity-report.js"; import { + clearUnitRuntimeRecord, decideUnitRuntimeDispatch, getUnitRuntimeState, isTerminalUnitRuntimeStatus, @@ -142,13 +143,11 @@ export function synthesizeUokDiagnostics(basePath, options = {}) { const childPids = lockAlive ? descendantPids(processRows, Number(lock.pid)) : []; - const records = listUnitRuntimeRecords(basePath); - const runtimeUnits = records.map((record) => + let records = listUnitRuntimeRecords(basePath); + let runtimeUnits = records.map((record) => classifyRuntimeRecord(record, lockAlive, nowMs, staleMs), ); - const activeRuntimeUnits = runtimeUnits.filter( - (unit) => unit.projectionActive, - ); + let activeRuntimeUnits = runtimeUnits.filter((unit) => unit.projectionActive); const preParityRuns = isDbAvailable() ? getUokRuns(20) : []; const preParityOpenRuns = preParityRuns.filter( (run) => run.status === "started" || !run.endedAt, @@ -168,6 +167,31 @@ export function synthesizeUokDiagnostics(basePath, options = {}) { const lastRun = runs[0] ?? null; const lastEnded = latestEndedRun(runs); const expectedUnit = normalizeExpectedUnit(options.expectedNext); + if ( + options.repairStaleRuntimeProjection && + !lockAlive && + expectedUnit && + activeRuntimeUnits.length > 0 + ) { + const mismatchedRecords = records.filter((record) => { + const unit = classifyRuntimeRecord(record, false, nowMs, staleMs); + if (!unit.projectionActive) return false; + return ( + unit.unitType !== expectedUnit.unitType || + unit.unitId !== expectedUnit.unitId + ); + }); + for (const record of mismatchedRecords) { + clearUnitRuntimeRecord(basePath, record.unitType, record.unitId); + } + if (mismatchedRecords.length > 0) { + records = listUnitRuntimeRecords(basePath); + runtimeUnits = records.map((record) => + classifyRuntimeRecord(record, lockAlive, nowMs, staleMs), + ); + activeRuntimeUnits = runtimeUnits.filter((unit) => unit.projectionActive); + } + } const currentRuntimeUnit = lock ? runtimeUnits.find( (unit) => diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index 2c0ff4227..41329eba5 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -320,6 +320,10 @@ test("initResources syncs extensions, agents, and skills to target dir", async ( assertExtensionIndexExists(fakeAgentDir, "browser-tools"); assertExtensionIndexExists(fakeAgentDir, "search-the-web"); assertExtensionIndexExists(fakeAgentDir, "context7"); + assert.ok( + existsSync(join(fakeAgentDir, "extensions", "shared")), + "shared extension support modules synced", + ); // Agents synced assert.ok( @@ -385,6 +389,26 @@ test("initResources skips copy when managed version matches current version", as ); }); +test("initResources repairs missing shared support modules on manifest-matched launch", async (_t) => { + const { initResources } = await import("../resource-loader.ts"); + const tmp = mkdtempSync(join(tmpdir(), "sf-resources-shared-")); + const fakeAgentDir = join(tmp, "agent"); + + afterEach(() => rmSync(tmp, { recursive: true, force: true })); + + initResources(fakeAgentDir); + const sharedDir = join(fakeAgentDir, "extensions", "shared"); + assert.ok(existsSync(sharedDir), "shared support modules synced initially"); + + rmSync(sharedDir, { recursive: true, force: true }); + + initResources(fakeAgentDir); + assert.ok( + existsSync(sharedDir), + "missing shared support modules force a resync despite matching manifest", + ); +}); + // ═══════════════════════════════════════════════════════════════════════════ // 4. wizard loadStoredEnvKeys hydration // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/tests/integration/web-command-parity-contract.test.ts b/src/tests/integration/web-command-parity-contract.test.ts index 2e314d08b..38ac272e9 100644 --- a/src/tests/integration/web-command-parity-contract.test.ts +++ b/src/tests/integration/web-command-parity-contract.test.ts @@ -41,7 +41,7 @@ const EXPECTED_BUILTIN_OUTCOMES = new Map( ["thinking", "surface"], ["edit-mode", "reject"], ["terminal", "reject"], - ["stop", "reject"], + ["stop", "prompt"], ["exit", "reject"], ["quit", "reject"], ],