fix(proof): watch the missions root, not the whole workspace, for proof source changes (#1813)

The proof source watcher recursively watched the missions root's parent, so a workspace holding large checkouts or build trees beside it registered native watches across all of them (ENOSPC in #1813). It now watches the configured missions root recursively, and everything else the readiness evaluation reads (project and policy files, including absent nearer ones, and accepted evidence elsewhere in the project) at its parent directory, shallow and filtered to the names read; a missing location is watched at its nearest existing ancestor. The subscriptions come from the same evaluation (readProjectReadiness now takes an optional reader that records what it reads; its default is unchanged) and are refreshed after every evaluation, attaching new watches before releasing old ones and reading again after new ones are attached. A watch that cannot be attached reports the sources unavailable, with no wider fallback; after close, queued callbacks neither re-read nor emit. The debounce, fresh policy reader per read, semantic revision and unchanged-revision dedup are as before.
This commit is contained in:
dev-engineer committed 2026-10-24 20:26:37 +00:00
1 parent e69ede7a49
commit 8b236e0627
4 files changed
+240 -56

No files matched your search

@@ -295,25 +295,25 @@ export function recordJudgment(missionsRoot: string, input: JudgeInput, actor: s
* @param readPolicy - Reader function for determining applicable proof policy.
* @returns Evaluated mission readiness, including revision, state, slices, and any issues.
*/
export function readMissionReadiness(missionDir: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string): MissionReadiness {
export function readMissionReadiness(missionDir: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs): MissionReadiness {
const issues: string[] = [], slices: MissionReadiness["slices"] = [];
// #1280: each member's directory, for reading its declared node identity only when a dependency needs it.
const dirs = new Map<MissionReadiness["slices"][number], string>();
let historicalStatus: string | null = null;
try {
const doc = manifest(proofFs, path.join(missionDir, "mission.yaml"));
const doc = manifest(io, path.join(missionDir, "mission.yaml"));
if (!doc) return { revision: hash("legacy"), state: "legacy", slices, issues, historicalStatus };
const metadata = doc.metadata != null ? mapping(doc.metadata, "mission metadata") : {};
historicalStatus = typeof metadata.status === "string" ? metadata.status : null;
const members = validateMissionComposition(doc, path.join(missionDir, "mission.yaml")).filter(m => m.active);
for (const member of members) {
const dir = path.dirname(member.path), data = manifest(proofFs, member.path);
const dir = path.dirname(member.path), data = manifest(io, member.path);
const execution = data?.execution as { depends_on?: unknown } | undefined;
const depends = execution?.depends_on ?? [];
if (!Array.isArray(depends) || depends.some(x => typeof x !== "string")) throw new JudgmentError("dependencies_invalid", `${member.path}: execution.depends_on must be a list`);
const id = (data?.metadata as { id?: unknown } | undefined)?.id ?? path.basename(dir);
if (typeof id !== "string" || !id || slices.some(s => s.id === id)) throw new JudgmentError("dependency_identity", `${member.path}: missing or repeated slice identity`);
const slice = { scope: path.basename(dir), id, readiness: readSliceReadiness(dir, proofFs, readPolicy, root), dependsOn: depends as string[], eligible: null };
const slice = { scope: path.basename(dir), id, readiness: readSliceReadiness(dir, io, readPolicy, root), dependsOn: depends as string[], eligible: null };
slices.push(slice);
dirs.set(slice, dir);
}
@@ -360,8 +360,10 @@ function declaredNodeId(dir: string): string | null {
}
}
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string) {
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root) }));
/** `io` lets a caller observe which files the evaluation reads (the proof source watcher derives its subscriptions
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs) {
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io) }));
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
}
+114 -23
View File
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
import * as path from "node:path";
import { createHash } from "node:crypto";
import type { EventBus } from "../event-bus.js";
import { createProofPolicyRead, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
import { createProofPolicyRead, proofFs, readProjectReadiness } from "./judgments.js";
export interface ProofSourceWatch {
close(): void;
@@ -12,31 +13,111 @@ export function proofSourceObservation(c: { get: (key: never) => unknown }) {
return (c.get("proofSourceWatch" as never) as ProofSourceWatch | undefined)?.observation() ?? { state: "unavailable", revision: "unverified" };
}
/** Push invalidation over the existing bus; existing client quiet refresh is the missed-event repair. */
/** A needed watch could not be attached: the sources are not observed (distinct from an input that can't be read). */
class WatchFailure extends Error {}
/** Per directory outside the missions root, the entry names a readiness evaluation read there. */
type Shallow = Map<string, Set<string>>;
/** Where each path the evaluation read outside the missions root is observed: its parent, shallow, filtered to its
* name; for a missing parent, the nearest existing ancestor, filtered to the next segment toward it. */
function shallowWatches(missionsRoot: string, read: Iterable<string>): Shallow {
const out: Shallow = new Map();
for (const file of read) {
const target = path.resolve(file);
if (target === missionsRoot || target.startsWith(missionsRoot + path.sep)) continue;
let child = target, dir = path.dirname(target);
while (dir !== child && !proofFs.exists(dir)) { child = dir; dir = path.dirname(dir); }
if (dir === child) continue;
const names = out.get(dir) ?? new Set<string>();
names.add(path.basename(child));
out.set(dir, names);
}
return out;
}
/**
* Push invalidation over the existing bus; existing client quiet refresh is the missed-event repair.
* The missions root is watched recursively. Anything else the readiness evaluation reads (project and policy files,
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
* not recursively, so sibling checkouts and build trees are never subscribed to.
*/
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus): ProofSourceWatch {
const workspace = path.dirname(missionsRoot);
let projectRoot = workspace;
try { projectRoot = resolveProjectRoot(missionsRoot); } catch { /* Legacy roots retain their existing watch boundary. */ }
missionsRoot = path.resolve(missionsRoot);
// ponytail: bounded local workspaces use a full semantic read after a file burst.
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
const basis = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead()).missions.map(m => [m.name, m.revision]))).digest("hex");
let state: "watching" | "unavailable" = "unavailable";
const basis = () => {
const read = new Set<string>();
const note = (p: string) => { read.add(p); return p; };
const io: ScopeFsDeps = {
exists: (p) => proofFs.exists(note(p)),
readFile: (p) => proofFs.readFile(note(p)),
...(proofFs.readBytes ? { readBytes: (p: string) => proofFs.readBytes!(note(p)) } : {}),
listDir: (p) => proofFs.listDir(note(p)),
isDirectory: (p) => proofFs.isDirectory(note(p)),
};
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io).missions.map(m => [m.name, m.revision]))).digest("hex");
return { hash, watches: shallowWatches(missionsRoot, read) };
};
let closed = false, covered = false, readable = false;
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
try { revision = basis(); state = "watching"; } catch { /* Direct reads name unavailable inputs; watching may recover them. */ }
let wanted: Shallow = new Map();
const shallow = new Map<string, fs.FSWatcher>();
let recursive: fs.FSWatcher | undefined;
const notify = (next: string) => { try { bus.emit({ type: "proof.sources_changed", scope: missionsRoot, revision: next }); } catch { /* Quiet refresh remains the repair path. */ } };
const watchers: fs.FSWatcher[] = [];
const onEvent = (dir: string | null) => (_event: string, filename: string | Buffer | null) => {
if (closed) return;
// No filename (null, or none given) means "something here changed": re-read rather than guess.
if (dir !== null && filename != null && !wanted.get(dir)?.has(filename.toString())) return;
schedule();
};
const onError = () => {
if (closed) return;
covered = false; revision = "unavailable";
invalidate(); notify("unavailable");
};
/** Attach watches for directories newly needed, then release ones no longer needed (new coverage first).
* Returns whether any were added. */
const resubscribe = (next: Shallow): boolean => {
let added = false;
for (const dir of next.keys()) {
if (shallow.has(dir)) continue;
let watcher: fs.FSWatcher;
try { watcher = fs.watch(dir, { recursive: false, persistent: false }, onEvent(dir)); }
catch (e) { throw new WatchFailure(`${dir}: ${(e as Error).message}`); }
watcher.on("error", onError);
shallow.set(dir, watcher);
added = true;
}
wanted = next;
for (const [dir, watcher] of shallow) if (!next.has(dir)) { watcher.close(); shallow.delete(dir); }
return added;
};
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
* After attaching new watches it reads again, so a change between the read and the watch is not missed. */
const evaluate = (): string => {
let read = basis();
for (let i = 0; i < 2 && resubscribe(read.watches); i++) read = basis();
covered = true;
return read.hash;
};
const schedule = () => {
if (timer) return;
if (timer || closed) return;
timer = setTimeout(() => {
timer = undefined;
if (closed) return;
try {
const next = basis();
const next = evaluate();
readable = true;
if (next === revision) return;
revision = next; state = "watching";
revision = next;
invalidate();
notify(revision);
} catch {
state = "unavailable"; revision = "unavailable";
} catch (e) {
// Never report the sources as watched after a failed read or a watch that could not be attached.
readable = false;
if (e instanceof WatchFailure) covered = false;
revision = "unavailable";
invalidate();
notify("unavailable");
}
@@ -44,18 +125,28 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
timer.unref();
};
try {
watchers.push(fs.watch(workspace, { recursive: true, persistent: false }, schedule));
if (projectRoot !== workspace) {
watchers.push(fs.watch(projectRoot, { recursive: false, persistent: false }, (_event, filename) => {
if (filename === null || filename.toString() === "project.yaml") schedule();
}));
recursive = fs.watch(missionsRoot, { recursive: true, persistent: false }, onEvent(null));
recursive.on("error", onError);
try { revision = evaluate(); readable = true; } catch (e) {
if (e instanceof WatchFailure) throw e;
// Direct reads name unavailable inputs; watching may recover them.
covered = true;
}
} catch {
for (const watcher of watchers) watcher.close();
if (timer) clearTimeout(timer);
closed = true;
recursive?.close();
for (const watcher of shallow.values()) watcher.close();
invalidate(); notify("unavailable");
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
}
for (const watcher of watchers) watcher.on("error", () => { state = "unavailable"; revision = "unavailable"; invalidate(); notify("unavailable"); });
return { observation: () => ({ state, revision }), close: () => { state = "unavailable"; if (timer) clearTimeout(timer); for (const watcher of watchers) watcher.close(); } };
return {
observation: () => ({ state: !closed && covered && readable ? "watching" : "unavailable", revision }),
close: () => {
closed = true;
if (timer) clearTimeout(timer);
recursive?.close();
for (const watcher of shallow.values()) watcher.close();
shallow.clear();
},
};
}
@@ -224,6 +224,37 @@ describe("watch-local proof-policy reuse", () => {
} finally { o.watch.close(); }
});
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", () => {
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
recordJudgment(f.missions, { scope: "trial/slices/slice-0", item: item.id, verdict: "accept", reason: "observed", evidence: [evidence], expectedRevision: item.revision, expectedPrevious: null }, "judge@trial", "fixture");
vi.useFakeTimers();
const listeners = new Map<string, (event: string, name: string | null) => void>(), recursive = new Map<string, boolean>();
vi.spyOn(fs, "watch").mockImplementation(((root: string, options: { recursive: boolean }, listener: (event: string, name: string | null) => void) => {
listeners.set(root, listener); recursive.set(root, options.recursive);
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn();
const watch = watchProofSources(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
try {
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
expect(recursive.get(f.missions)).toBe(true);
expect(recursive.get(outside)).toBe(false);
expect([...recursive].filter(([root, deep]) => deep && root !== f.missions)).toEqual([]);
f.write(evidence, "changed observation");
listeners.get(outside)!("change", "report.md"); vi.advanceTimersByTime(50);
expect(readSliceReadiness(dir).state).toBe("unknown");
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
fs.writeFileSync(`${evidence}.tmp`, "accepted observation"); fs.renameSync(`${evidence}.tmp`, evidence);
listeners.get(outside)!("rename", "report.md"); vi.advanceTimersByTime(50);
expect(readSliceReadiness(dir).state).toBe("ready");
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
expect(invalidate).toHaveBeenCalledTimes(2);
} finally { watch.close(); }
});
it("rechecks accepted evidence between events without reusing judgment inputs", () => {
const f = fixture(), dir = f.dirs[0]!, evidence = path.join(dir, "proof/evidence.md");
f.write(evidence, "accepted observation");
@@ -1,62 +1,122 @@
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot } = vi.hoisted(() => ({
const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs } = vi.hoisted(() => ({
watch: vi.fn(), createProofPolicyRead: vi.fn(() => vi.fn()), readProjectReadiness: vi.fn(), resolveProjectRoot: vi.fn(),
proofFs: { exists: vi.fn(() => true), readFile: vi.fn(() => null), readBytes: vi.fn(() => null), listDir: vi.fn(() => []), isDirectory: vi.fn(() => true) },
}));
vi.mock("node:fs", () => ({ watch }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs }));
import { watchProofSources } from "../src/domain/proof/source-watch.js";
import type { EventBus } from "../src/domain/event-bus.js";
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); });
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); proofFs.exists.mockImplementation(() => true); });
it("keeps recursive watching local and filters the ancestor policy watch", () => {
type Io = { exists(p: string): boolean; readBytes(p: string): unknown };
/** The readiness evaluation, as the watcher sees it: the paths it reads through the supplied reader. */
function reads(paths: (missions: string) => Array<[keyof Io, string]>) {
readProjectReadiness.mockImplementation((missions: string, _read: unknown, _root: unknown, io?: Io) => {
for (const [method, file] of paths(missions)) io?.[method](file);
return { missions: [] };
});
}
const handle = () => { const close = vi.fn(); watch.mockImplementation(() => ({ on: vi.fn(), close })); return close; };
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", () => {
vi.useFakeTimers();
const missions = path.resolve("fixture/ancestor/work/initiatives");
const project = path.resolve("fixture/ancestor");
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
readProjectReadiness.mockReturnValue({ missions: [] });
const close = vi.fn();
watch.mockImplementation(() => ({ on: vi.fn(), close }));
reads(() => [["exists", path.join(project, "project.yaml")], ["readBytes", path.join(project, "build", "out", "report.md")]]);
const close = handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[path.dirname(missions), true], [project, false],
[missions, true], [project, false], [path.join(project, "build", "out"), false],
]);
const notifyProject = watch.mock.calls[1]![2];
// Neither the project (with its sibling checkouts and build trees) nor any parent is watched recursively.
expect(watch.mock.calls.filter(([root, options]) => options.recursive && root !== missions)).toEqual([]);
expect(instance.observation().state).toBe("watching");
// Setup reads once more after attaching the new watches, so nothing changed in between is missed.
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
const notifyProject = watch.mock.calls[1]![2], notifyEvidence = watch.mock.calls[2]![2];
notifyProject("change", "unrelated.txt");
notifyProject("change", Buffer.from("unrelated.txt"));
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(1);
notifyProject("rename", Buffer.from("project.yaml"));
notifyEvidence("change", Buffer.from("other.md"));
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notifyProject("change", null);
notifyProject("rename", Buffer.from("project.yaml"));
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
notifyEvidence("rename", "report.md");
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(4);
notifyEvidence("change", null);
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(5);
instance.close();
expect(close).toHaveBeenCalledTimes(3);
});
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
proofFs.exists.mockImplementation((p: string) => !p.startsWith(path.join(project, "build")));
reads(() => [["readBytes", path.join(project, "build", "out", "report.md")]]);
handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([[missions, true], [project, false]]);
const notify = watch.mock.calls[1]![2];
notify("rename", "unrelated");
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notify("rename", "build");
vi.advanceTimersByTime(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
instance.close();
expect(close).toHaveBeenCalledTimes(2);
});
it("closes the recursive watcher when opening the extra policy watcher fails", () => {
const missions = path.resolve("fixture/ancestor/work/initiatives");
resolveProjectRoot.mockReturnValue(path.resolve("fixture/ancestor"));
readProjectReadiness.mockReturnValue({ missions: [] });
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", () => {
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
const close = vi.fn();
watch.mockReturnValueOnce({ on: vi.fn(), close }).mockImplementationOnce(() => { throw new Error("watch unavailable"); });
watch.mockReturnValueOnce({ on: vi.fn(), close }).mockImplementationOnce(() => { throw new Error("ENOSPC"); });
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(instance.observation().state).toBe("unavailable");
expect(close).toHaveBeenCalledOnce();
expect(watch).toHaveBeenCalledTimes(2);
instance.close();
expect(close).toHaveBeenCalledOnce();
});
it("uses only the existing recursive watcher when the policy root is the same directory", () => {
it("uses only the missions watcher when the evaluation reads nothing outside it", () => {
const missions = path.resolve("fixture/missions");
resolveProjectRoot.mockReturnValue(path.dirname(missions));
readProjectReadiness.mockReturnValue({ missions: [] });
const close = vi.fn();
watch.mockReturnValue({ on: vi.fn(), close });
reads((root) => [["exists", path.join(root, "m1", "mission.yaml")]]);
const close = handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(watch).toHaveBeenCalledOnce();
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([[missions, true]]);
instance.close();
expect(close).toHaveBeenCalledOnce();
});
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const notifyMissions = watch.mock.calls[0]![2];
notifyMissions("change", "x");
instance.close();
notifyMissions("change", "y");
vi.advanceTimersByTime(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
expect(watch).toHaveBeenCalledTimes(2);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});