fix: repair headless runtime self-healing
This commit is contained in:
parent
72c3811a7b
commit
3ac5aede1e
6 changed files with 116 additions and 23 deletions
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:");
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue