fix: repair headless runtime self-healing

This commit is contained in:
Mikael Hugo 2026-05-15 03:33:29 +02:00
parent 72c3811a7b
commit 3ac5aede1e
6 changed files with 116 additions and 23 deletions

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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:");

View file

@ -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) =>

View file

@ -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
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -41,7 +41,7 @@ const EXPECTED_BUILTIN_OUTCOMES = new Map<string, "rpc" | "surface" | "reject">(
["thinking", "surface"],
["edit-mode", "reject"],
["terminal", "reject"],
["stop", "reject"],
["stop", "prompt"],
["exit", "reject"],
["quit", "reject"],
],