Compare commits

..
Author SHA1 Message Date
dev-driver b7e5a411bd fix(daemon): settle proof observations and yield remaining project reads 2026-11-11 09:36:03 +00:00
dev-driver 40adbf4ded fix(daemon): yield between missions during project proof reads 2026-11-11 08:29:26 +00:00
Devulapalli Naga Sri Vaishnavi d382e5b317 fix(cli): find attention for rigs with @ in their name (#2949)
The seat attention checker split a seat address at the last @, so a seat in a rig whose name contains @ was looked up in the wrong rig and reported unknown. It now splits at the first @, matching the address contract (member@rig, where the rig name may contain @). A regression test covers lead@team@east in rig team@east.
2026-11-10 23:46:13 -08:00
Mike Schwarz b8e7318480 fix(daemon): whoami says the team is still being built during rig up (#2955)
For #2871. rig up on a pod-aware spec creates and launches each seat in turn and writes the edges only after the last seat, so a seat that ran rig whoami early got a partial roster worded as the whole team. The daemon now marks a rig in memory while the pod instantiator creates it (from the rig-level recordCreated callback until instantiate returns or throws). While marked, whoami adds rosterState "building" and opens peersNote with a sentence saying the peers and edges are only those created so far. The CLI keeps the field in compact output, keeps the opening in the bounded note and calls the edges "the ones written so far", and plain output prints a Team line. Nothing waits or is refused, launch order is unchanged, and an unmarked rig answers as today. Limits: a daemon restart during rig up, or a failed launch that keeps the rig, shows the partial roster with today's wording.
2026-11-10 23:41:10 -08:00
Mike Schwarz 25d4e711fc fix(daemon,tui): follow-ups to runner permission answers (#2934) (#2950)
Follow-ups to #2934's runner permission answers. The answer path sets a message for a runner request from its state after the answer, so the TUI tells the person their approve, deny or words went to the receiving agent, or were kept with the direct rig seat decide-permission route, instead of saying the request closed. permissionDeciderFor keeps an ignored agents.operator_session's note when the kernel operator decides. A decision on a done or failed request persists a queue.updated event in the same transaction, so a long-polling runner and Steering see it at once. EventBus.discardOnThrow removes the event tokens a failed nested writer persisted, so a handoff that throws inside its savepoint no longer rolls back the person's kept answer with a token mismatch.
2026-11-10 23:30:25 -08:00
30 changed files with 610 additions and 127 deletions

No files matched your search

+2 -2
View File
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
## Released in 0.7.11
## Released in 0.7.10
[0.7.11 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.11): OpenRig 0.7.11 is a patch for people running their own agent teams. An ordinary send or work notice now goes into a person's typed draft by default instead of being refused (see Behaviour that changed). See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
## How to help
+15 -2
View File
@@ -64,6 +64,8 @@ interface WhoamiResult {
resolvedBy: string;
identity: WhoamiIdentity & Record<string, unknown>;
peers: WhoamiPeer[];
/** #2871: present only while `rig up` is still creating the team. */
rosterState?: "building";
edges: { outgoing: WhoamiEdge[]; incoming: WhoamiEdge[] };
transcript: { enabled: boolean; path: string | null; tailCommand: string | null };
contextUsage?: {
@@ -103,6 +105,8 @@ function projectCompactWhoami(data: Record<string, unknown>, bounded?: BoundedPe
}),
// KEEP: openrig-user SKILL.md documents peersNote as a required recovery field.
peersNote: bounded ? bounded.note : data["peersNote"],
// #2871: while `rig up` is still creating the team, say so, as the daemon does.
...(data["rosterState"] !== undefined ? { rosterState: data["rosterState"] } : {}),
...(bounded ? { peersOmitted: bounded.omitted } : {}),
// edges already carry only kind + to/from {logicalId, sessionName}.
edges: data["edges"],
@@ -143,9 +147,14 @@ export function boundPeers(data: Record<string, unknown>, bound: number, allPeer
const ordered = [0, 1, 2].flatMap((t) => peers.filter((peer) => tier(peer) === t));
const shown = ordered.slice(0, bound);
const omitted = peers.length - shown.length;
const note = `peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
// #2871: while `rig up` is still building the team, the daemon's note opens with that; keep it first, and don't call
// the edges complete.
const daemonNote = typeof data["peersNote"] === "string" ? data["peersNote"] : "";
const building = data["rosterState"] === "building";
const opening = building && daemonNote.includes("peers = ") ? daemonNote.slice(0, daemonNote.indexOf("peers = ")) : "";
const note = `${opening}peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
+ `bound output.whoami_peers=${bound}); ${omitted} not shown; all: ${allPeersCommand}; `
+ "edges = directional relationships (always complete); use `rig ps --nodes` for node inventory including self + live state";
+ `edges = directional relationships (${building ? "the ones written so far" : "always complete"}); use \`rig ps --nodes\` for node inventory including self + live state`;
return { peers: shown, omitted, note };
}
@@ -415,6 +424,10 @@ left out. --all-peers lists every one (today's compact output).`)
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
if (data.rosterState === "building") {
console.log("");
console.log("Team: still being built by `rig up`: the peers and edges below are the ones created so far, and more may come. Run `rig whoami` again once it finishes.");
}
if (data.peers.length > 0) {
console.log("");
// OPR.99.0.6.1: name the contract on the header so peers cannot be
+1 -1
View File
@@ -31,7 +31,7 @@ export async function checkSeatAttention(client: DaemonClient, seats: readonly s
const check: AttentionCheck = { attention: [], unknown: [] };
const byRig = new Map<string, Set<string>>();
for (const seat of seats) {
const at = seat.lastIndexOf("@");
const at = seat.indexOf("@");
if (at <= 0 || at === seat.length - 1) { check.unknown.push({ seat, why: "not a seat@rig name" }); continue; }
const rig = seat.slice(at + 1);
if (!byRig.has(rig)) byRig.set(rig, new Set());
+12
View File
@@ -98,6 +98,18 @@ it("a seat that isn't in this daemon's inventory is unknown, never 'fine'", asyn
{ seat: "x@elsewhere", why: "no rig named elsewhere on this daemon" }]);
});
it("finds attention for a seat whose rig name contains @", async () => {
const { client: c } = client({
"/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "team@east" }] },
"/api/rigs/r1/nodes": { status: 200, data: [
{ canonicalSessionName: "lead@team@east", needsAttention: true, startupStatus: "attention_required", latestError: LOGIN },
] },
});
expect(await checkSeatAttention(c, ["lead@team@east"])).toEqual({
attention: [{ seat: "lead@team@east", reason: LOGIN, waitingOnPerson: false }], unknown: [],
});
});
it("an unreadable kernel status is reported, not read as no held seat", async () => {
const { client: c } = client({ "/api/kernel/status": { status: 503, data: { error: "kernel_boot_tracker_unavailable" } } });
expect(await checkKernelAttention(c)).toEqual({ attention: [],
@@ -214,6 +214,30 @@ describe("rig whoami --json: bounded peers (output.compact, output.whoami_peers)
for (const p of BIG_PEERS) expect(out).toContain(p.logicalId);
});
// #2871: while `rig up` is still building the team, every form of the answer keeps saying so.
it("a team still being built: rosterState and the daemon's opening survive bounding, and edges aren't called complete", async () => {
const BUILDING = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
served = { ...response(BIG_PEERS), rosterState: "building", peersNote: BUILDING + DAEMON_NOTE };
// Switch off: the daemon's own note and state.
let out = JSON.parse(await run("--json"));
expect(out.rosterState).toBe("building");
expect(out.peersNote).toBe(BUILDING + DAEMON_NOTE);
// Switch on: the bounded note opens the same way, and names the edges as the ones written so far.
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "1");
out = JSON.parse(await run("--json"));
expect(out.rosterState).toBe("building");
expect(out.peersOmitted).toBe(15);
expect(out.peersNote).toBe(
BUILDING
+ "peers = 8 of this rig's 23 (roster excluding self; edge peers first, then your pod, then roster order; "
+ "bound output.whoami_peers=8); 15 not shown; all: rig whoami --json --all-peers --node-id 'node-1'; "
+ "edges = directional relationships (the ones written so far); use `rig ps --nodes` for node inventory including self + live state",
);
// Plain text says it too.
expect(await run()).toContain("Team: still being built by `rig up`");
});
it("the full-list command names the identity the call used: --session, quoted", () => {
const bounded = boundPeers(response(BIG_PEERS), 2, "rig whoami --json --all-peers --session 'dev-impl@big'");
expect(bounded.note).toContain("all: rig whoami --json --all-peers --session 'dev-impl@big';");
@@ -89,5 +89,6 @@ export function permissionDeciderFor(
const kernel = seatState(db, KERNEL_OPERATOR_SESSION);
if (kernel.state === "missing" || (kernel.state === "unknown" && !kernel.recorded)) return null;
return { session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", ...kernel,
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : undefined };
// A setting that's set but ignored keeps saying so, rather than reading as unset.
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : receiver.note };
}
+20
View File
@@ -110,6 +110,26 @@ export class EventBus {
return true;
}
/**
* Run a nested transactional writer (a savepoint inside the active envelope) whose failure the caller handles. If it
* throws, its savepoint has rolled back the event rows it persisted, so their tokens leave the envelope too, and the
* caller's transaction can still commit. Outside an envelope it just runs.
*/
discardOnThrow<T>(writer: () => T): T {
const envelope = this.activeEnvelope;
const persisted = envelope ? new Set(envelope.persisted) : null;
const registered = envelope ? new Set(envelope.registered) : null;
try {
return writer();
} catch (err) {
if (envelope && persisted && registered) {
for (const token of [...envelope.persisted]) if (!persisted.has(token)) envelope.persisted.delete(token);
for (const token of [...envelope.registered]) if (!registered.has(token)) envelope.registered.delete(token);
}
throw err;
}
}
/**
* Run one caller-owned synchronous transaction under the W2b notification
* envelope. Every token persisted through EventBus during the callback must
+2 -2
View File
@@ -2,7 +2,7 @@ import { physicalSessionName } from "./session-registry.js";
import { resolveNodeFile } from "./scope/node-file.js";
import { belongsToProject } from "./workspace/project-catalog.js";
import { inspectGraph } from "./workflow-reconciliation.js";
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
import { createProofPolicyRead, readMissionReadiness, readProjectReadinessAsync } from "./proof/judgments.js";
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
import { QueueWakeRepository } from "./queue-wake-repository.js";
import { isTypedGateBlocker } from "./queue-repository.js";
@@ -1361,7 +1361,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
view: "execution",
readiness,
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
project_readiness: missionsRoot ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
mission,
@@ -818,6 +818,9 @@ export class MissionControlWriteContract {
}
throw err;
}
// A runner's permission request isn't closed by an answer: it's handed to the receiving agent, or kept. Say which.
const final = after as QueueItem | null;
const runnerNotice = !answer.message && final && runnerPermissionOrigin(final) ? runnerAnswerNotice(final) : undefined;
return {
actionId: actionEntry!.actionId,
verb: actionVerb,
@@ -827,7 +830,7 @@ export class MissionControlWriteContract {
notifyAttempted: false,
notifyResult: null,
auditedAt: evaluatedAt,
answer,
answer: runnerNotice ? { ...answer, message: runnerNotice } : answer,
};
}
@@ -960,6 +963,18 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
}
}
/** What became of a person's answer on a runner's permission request: handed to the receiving agent, or kept (the
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
function runnerAnswerNotice(ask: QueueItem): string | undefined {
if (ask.state === "handed-off" && ask.handedOffTo) {
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run.`;
}
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
}
return undefined;
}
function snapshotQitem(q: QueueItem): Record<string, unknown> {
return {
qitemId: q.qitemId,
@@ -389,6 +389,22 @@ function declaredNodeId(dir: string): string | null {
* 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, observe?: (file: string) => void) {
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, observe) }));
return projectReadiness(missions);
}
/** The same semantic read, yielding between missions for daemon requests and source-watch refreshes.
* A single mission (including its evidence) is still synchronous; this is not a hard time bound or atomic snapshot. */
export async function readProjectReadinessAsync(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
const names = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory());
const missions: Array<MissionReadiness & { name: string }> = [];
for (const name of names) {
await new Promise<void>(resolve => setImmediate(resolve));
missions.push({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) });
}
return projectReadiness(missions);
}
function projectReadiness(missions: Array<MissionReadiness & { name: string }>) {
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" };
}
@@ -3,7 +3,7 @@ import * as path from "node:path";
import { createHash } from "node:crypto";
import type { EventBus } from "../event-bus.js";
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
import { createProofPolicyRead, proofFs, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
export interface ProofSourceWatch {
close(): void;
@@ -76,16 +76,15 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
* 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, scopeChanged?: () => void): ProofSourceWatch {
export async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
missionsRoot = path.resolve(missionsRoot);
let projectRoot = path.dirname(missionsRoot);
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
const top = path.dirname(path.resolve(projectRoot));
// The authority boundary in real terms: what the readers' containment check accepts.
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
// 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 = () => {
// Keep the full semantic read, but let queue/send requests run between missions.
const basis = async () => {
const read = new Set<string>();
const note = (p: string) => { read.add(p); return p; };
const io: ScopeFsDeps = {
@@ -95,7 +94,8 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
listDir: (p) => proofFs.listDir(note(p)),
isDirectory: (p) => proofFs.isDirectory(note(p)),
};
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
const result = await readProjectReadinessAsync(missionsRoot, createProofPolicyRead(), undefined, io, note);
const hash = createHash("sha256").update(JSON.stringify(result.missions.map(m => [m.name, m.revision]))).digest("hex");
// A file read through a link inside the project depends on its real location too: observe both.
for (const file of [...read]) {
const real = canonical(file);
@@ -107,6 +107,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
let closed = false, failed = false, covered = false, readable = false;
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
let scopeDirty = false;
let reading = true, dirty = false;
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
let wanted: Shallow = new Map();
const shallow = new Map<string, fs.FSWatcher>();
@@ -163,25 +164,32 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
* Coverage is certified only by a read whose every dependency was already watched before it; if the dependencies
* keep moving past a small budget, the sources are reported unobserved rather than read forever. */
const evaluate = (): string => {
let read = basis();
const evaluate = async (): Promise<{ hash: string; covered: boolean }> => {
let read = await basis();
for (let i = 0; ; i++) {
if (closed) return { hash: revision, covered: false };
// A root whose real location is unknown or outside the project has no anchors there: never certified.
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
if (i === 2) { covered = false; return read.hash; }
read = basis();
// Coverage belongs to this candidate, just like the hash; neither escapes a discarded read.
if (!resubscribe(read.watches)) return { hash: read.hash, covered: recursiveReal !== null && within(recursiveReal, project) };
if (i === 2) return { hash: read.hash, covered: false };
read = await basis();
}
};
const observed = () => !closed && !failed && covered && readable;
const schedule = () => {
if (timer || closed) return;
timer = setTimeout(() => {
dirty = true;
if (timer || reading || closed) return;
timer = setTimeout(async () => {
timer = undefined;
if (closed) return;
reading = true; dirty = false;
try {
if (scopeDirty) { scopeDirty = false; notifyScope(); }
const before = observed();
const next = evaluate();
const candidate = await evaluate();
if (closed || dirty) return;
const next = candidate.hash;
covered = candidate.covered && !failed;
readable = true;
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
// coverage is news even when the proof result itself did not change.
@@ -190,12 +198,16 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
invalidate();
notify(observed() ? revision : "unavailable");
} catch (e) {
if (closed) return;
// 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");
} finally {
reading = false;
if (dirty && !closed) schedule();
}
}, 40);
timer.unref();
@@ -203,7 +215,10 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
try {
recursiveReal = canonical(missionsRoot);
recursive = watchDir(missionsRoot, true);
try { revision = evaluate(); readable = true; } catch (e) {
try {
const candidate = await evaluate();
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
} catch (e) {
if (e instanceof WatchFailure) throw e;
// Direct reads name unavailable inputs; watching may recover them.
covered = true;
@@ -214,6 +229,9 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
for (const watcher of shallow.values()) watcher.close();
invalidate(); notify("unavailable"); notifyScope();
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
} finally {
reading = false;
if (dirty && !closed) schedule();
}
return {
observation: () => ({ state: observed() ? "watching" : "unavailable", revision }),
@@ -4257,7 +4257,9 @@ export class QueueRepository {
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
: "answered without a choice or words";
const person = input.actorSession;
try { this.handoffNow({
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
try { this.eventBus.discardOnThrow(() => this.handoffNow({
qitemId: item.qitemId, fromSession: person, toSession: decider,
summary: `Decide: ${item.summary ?? "a runner's permission request"} (${person} answered)`,
transitionNote: `runner-permission-answer-passed: ${person}'s answer goes to ${decider}, the receiving agent, to decide while the runner waits`,
@@ -4273,7 +4275,7 @@ export class QueueRepository {
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
].join("\n"),
}); } catch (error) {
})); } catch (error) {
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
// the person is told it couldn't be passed on. Never a default permit.
console.error(`[queue] a runner permission answer couldn't be handed to ${decider}; it's kept and the request stays open`, error);
@@ -4305,7 +4307,11 @@ export class QueueRepository {
if (item.state === "done" || item.state === "failed") {
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
this.transitionLog.append({ qitemId: item.qitemId, state: item.state, actorSession: input.actorSession, transitionNote: input.transitionNote });
return { persistedEvents: [] as PersistedEvent[] };
// The row's state doesn't change, but its decision does: a runner waiting on this request reads it now, and
// Steering refreshes.
return { persistedEvents: [this.eventBus.persistWithinTransaction({ type: "queue.updated", qitemId: item.qitemId,
fromState: item.state, toState: item.state, closureReason: null, closureTarget: null,
actorSession: input.actorSession, summary: item.summary ?? null })] };
}
if (item.state !== "pending" && item.state !== "in-progress" && item.state !== "blocked") return null;
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
@@ -0,0 +1,21 @@
import type Database from "better-sqlite3";
// The rigs `rig up` is still building on this daemon: created, with their seats being created and launched one by one
// and their edges still to be written. An agent that asks who's on its team in that window is told so, rather than
// reading a partial roster as the whole team (#2871). In memory only, like the import guard beside it: after a daemon
// restart nothing is marked, so the answer is today's, never a stuck "still being built".
const building = new WeakMap<Database.Database, Set<string>>();
export function markRigBuilding(db: Database.Database, rigId: string): void {
let rigs = building.get(db);
if (!rigs) building.set(db, (rigs = new Set()));
rigs.add(rigId);
}
export function unmarkRigBuilding(db: Database.Database, rigId: string): void {
building.get(db)?.delete(rigId);
}
export function rigStillBeingBuilt(db: Database.Database, rigId: string): boolean {
return building.get(db)?.has(rigId) ?? false;
}
@@ -21,6 +21,7 @@ import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO
import type { LegacyRigSpec as RigSpec, LegacyRigSpecEdge as RigSpecEdge, InstantiateOutcome, InstantiateResult } from "./types.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
import { resolveLaunchCwd } from "./cwd-resolution.js";
import type { RecordCreated } from "./operation-correlation.js";
import { markRigBuilding, unmarkRigBuilding } from "./rigs-being-built.js";
// Only these edge kinds constrain launch order
const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
@@ -1412,7 +1413,7 @@ export class PodRigInstantiator {
name = typeof parsed?.name === "string" ? parsed.name : null;
} catch { /* the import below reports the parse error */ }
}
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
if (!name) return this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
let inFlight = importsInFlight.get(this.db);
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
if (inFlight.has(name)) {
@@ -1420,12 +1421,27 @@ export class PodRigInstantiator {
}
inFlight.add(name);
try {
return await this.instantiateOnce(rigSpecYaml, rigRoot, opts);
return await this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
} finally {
inFlight.delete(name);
}
}
/** {@link instantiateOnce}, with the new rig marked as still being built from its creation until every seat and edge
* has been written (#2871), so a seat that asks who's on its team in between is told the team isn't complete yet. */
private async instantiateBuilding(rigSpecYaml: string, rigRoot: string, opts?: Parameters<PodRigInstantiator["instantiate"]>[2]) {
const marked: string[] = [];
const recordCreated: RecordCreated = (rigId, nodeId) => {
if (nodeId === undefined) { markRigBuilding(this.db, rigId); marked.push(rigId); }
opts?.recordCreated?.(rigId, nodeId);
};
try {
return await this.instantiateOnce(rigSpecYaml, rigRoot, { ...opts, recordCreated });
} finally {
for (const rigId of marked) unmarkRigBuilding(this.db, rigId);
}
}
private async instantiateOnce(rigSpecYaml: string, rigRoot: string, opts?: { nonInterruptive?: boolean; cwdOverride?: string; env?: SeatEnv; installRoot?: string; force?: boolean; recordCreated?: RecordCreated; prelaunchHook?: (rigId: string, replacedRigIds: readonly string[]) => Promise<{ ok: true; rollback?: () => Promise<void> } | { ok: false; code: string; message: string; retainRig?: boolean }> }): Promise<InstantiateOutcome> {
// 1. Parse + validate
let rigSpec: PodRigSpec;
@@ -366,7 +366,7 @@ export class RunnerPermissions {
const words = typed && typeof typed === "object" && typed.kind === "typed-reply" ? String(typed.text ?? "").trim() || null : null;
const recorded = this.deps.queueRepo.recordPermissionDecision({ qitemId: live.qitemId, actorSession: decider, questionId: QUESTION_ID, optionId,
transitionNote: `runner-permission-decided by ${decider}, an agent, not the person: ${optionId}${reason ? `, because ${clip(reason, MAX_AGENT_TEXT)}` : ""}${words ? `; the person wrote "${words}"` : typeof typed === "string" ? `; the person chose ${typed}` : ""}` });
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided; the request is still waiting.` };
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided. Read the request again.` };
return this.read(null, askId);
}
+13 -1
View File
@@ -4,6 +4,7 @@ import type { RigRepository } from "./rig-repository.js";
import type { SessionRegistry } from "./session-registry.js";
import type { TranscriptStore } from "./transcript-store.js";
import { resolveWorkspaceContext, type WhoamiWorkspaceBlock } from "./workspace/workspace-resolver.js";
import { rigStillBeingBuilt } from "./rigs-being-built.js";
export interface WhoamiResult {
resolvedBy: "node_id" | "session_name";
@@ -43,6 +44,9 @@ export interface WhoamiResult {
* Additive note only — `peers[]` name and shape are unchanged.
*/
peersNote: string;
/** Present only while `rig up` is still creating this rig's seats and edges (#2871): the peers and edges are the ones
* created so far, and more may come. Absent once the rig is built, and for every rig not being built now. */
rosterState?: "building";
edges: {
outgoing: Array<{ kind: string; to: { logicalId: string; sessionName: string | null } }>;
incoming: Array<{ kind: string; from: { logicalId: string; sessionName: string | null } }>;
@@ -141,6 +145,11 @@ interface WhoamiDeps {
contextUsageStore?: import("./context-usage-store.js").ContextUsageStore;
}
/** Said first in `peersNote` while the rig is still being built, so a seat doesn't plan around a teammate that hasn't been
* created yet. Exported for the CLI's bounded note. */
export const ROSTER_BUILDING_NOTE = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
export class WhoamiService {
private db: Database.Database;
private rigRepo: RigRepository;
@@ -356,11 +365,14 @@ export class WhoamiService {
envOverride: query.targetRepoOverride ?? process.env["OPENRIG_TARGET_REPO"] ?? null,
});
const building = rigStillBeingBuilt(this.db, nodeRow.rig_id);
return {
resolvedBy,
identity,
peers,
peersNote: "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
...(building ? { rosterState: "building" as const } : {}),
peersNote: (building ? ROSTER_BUILDING_NOTE : "")
+ "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
edges: { outgoing, incoming },
transcript,
commands: { sendExamples, captureExamples },
+6 -3
View File
@@ -3,7 +3,7 @@ import { proofSourceObservation } from "../domain/proof/source-watch.js";
import * as path from "node:path";
import type { SliceIndexer } from "../domain/slices/slice-indexer.js";
import type { EventBus } from "../domain/event-bus.js";
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadiness, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadinessAsync, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
import { requireSenderIdentity, resolveRecordedProvenance } from "./require-sender-identity.js";
import { projectById, sameProjectRoot } from "../domain/workspace/project-read.js";
import { ProjectReadError } from "../domain/workspace/project-catalog.js";
@@ -78,12 +78,15 @@ export function proofRoutes(): Hono {
// freshness is reported as unverified rather than borrowing the selected workspace's state.
const observation = (c: Parameters<typeof indexer>[0], project: { id: string; root: string } | null) =>
project ? { state: "unavailable" as const, revision: "unverified" } : proofSourceObservation(c);
app.get("/", c => {
app.get("/", async c => {
const { root, evidenceRoot, scope, project } = target(c, c.req.query("scope"), c.req.query("project"), c.req.query("projectRoot"));
const common = { sourceObservation: observation(c, project), ...(project ? { project } : {}) };
// A catalog project's own root bounds policy, scope identity and evidence (never an ancestor's project.yaml).
const bound = project?.root;
if (!scope) return c.json({ ...readProjectReadiness(root, undefined, bound), ...common });
if (!scope) {
const result = await readProjectReadinessAsync(root, undefined, bound);
return c.json({ ...result, ...common, sourceObservation: observation(c, project) });
}
const dir = resolveProofScope(root, scope);
if (path.basename(path.dirname(dir)) !== "slices") return c.json({ ...readMissionReadiness(dir, undefined, bound), ...common });
const refs = c.req.queries("evidence") ?? [];
+14 -10
View File
@@ -18,7 +18,7 @@ import { inferCatalogProject, ProjectReadError } from "../domain/workspace/proje
// avoid `/proof-asset` being parsed as a slice-name.
import { Hono, type Context } from "hono";
import { readSliceReadiness, readProjectReadiness } from "../domain/proof/judgments.js";
import { readSliceReadiness, readProjectReadinessAsync } from "../domain/proof/judgments.js";
import * as fs from "node:fs";
import * as path from "node:path";
import { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
@@ -37,7 +37,7 @@ export function slicesRoutes(): Hono {
// 1) Static literal `/` BEFORE dynamic `/:name` so the list isn't
// shadowed by a slice named "list" or similar.
app.get("/", (c) => {
app.get("/", async (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
if (!deps.indexer.isReady()) {
@@ -61,7 +61,7 @@ export function slicesRoutes(): Hono {
// operation: the list rebuild, the boundToWorkflow per-slice get loop,
// and the mission sidecar share ONE membership batch (pre-scope, each
// uncached get built its own 2-scan batch: 2+2N total queue scans).
return deps.indexer.withMembershipBatch(() => {
const listing = deps.indexer.withMembershipBatch(() => {
const all = deps.indexer.list();
let filtered = filter === "all" ? all : all.filter((s) => s.status === filter);
// Workflows in Spec Library v0: optional lens filter — narrow to
@@ -102,7 +102,7 @@ export function slicesRoutes(): Hono {
complete: projectLookup.complete,
});
const authored = deps.indexer.missionAuthoredStatuses();
return c.json({
return {
slices: filtered.map((s) => ({
...s,
queueCounts: queueCountsByPath.get(s.slicePath) ?? null,
@@ -114,13 +114,17 @@ export function slicesRoutes(): Hono {
// VM-005 (release-0.4.7): additive authored mission-status sidecar so
// chip surfaces can honor authored-wins precedence without a second
// round-trip. The `slices` array itself is byte-untouched.
missions: { ...authored, ...Object.fromEntries(readProjectReadiness(deps.indexer.slicesRoot).missions.map(m => [m.name, {
...authored[m.name],
authoredStatus: m.historicalStatus ?? authored[m.name]?.authoredStatus ?? null,
readiness: m,
}])) },
});
missions: authored,
};
});
if (listing instanceof Response) return listing;
// The membership batch is synchronous and request-local; finish it before yielding to other requests.
const readiness = await readProjectReadinessAsync(deps.indexer.slicesRoot);
return c.json({ ...listing, missions: { ...listing.missions, ...Object.fromEntries(readiness.missions.map(m => [m.name, {
...listing.missions[m.name],
authoredStatus: m.historicalStatus ?? listing.missions[m.name]?.authoredStatus ?? null,
readiness: m,
}])) } });
});
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (walk item 8 — Explorer auto-show): explicit cache invalidation surface.
+1 -1
View File
@@ -2118,7 +2118,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.sliceIndexer = sliceIndexer;
if (sliceIndexer.isReady()) {
const { watchProofSources } = await import("./domain/proof/source-watch.js");
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
deps.proofSourceWatch = await watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
sliceIndexer.invalidate();
eventBus.emit({ type: "scope.sources_changed", scope: sliceIndexer.slicesRoot });
});
@@ -183,5 +183,10 @@ describe("who decides a runner's answered permission request", () => {
// A chosen seat this host doesn't have can't decide: the kernel operator, when here, else nobody.
expect(permissionDeciderFor(db([]), "runner@team", () => "ghost@team")).toBeNull();
expect(permissionDeciderFor(both, "runner@team", () => "ghost@team")).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: "ghost@team, the receiving agent, isn't on this host" });
// A setting that's set but ignored keeps its note when the kernel operator decides: it never reads as unset.
const ignored = { session: null, problem: 'agents.operator_session is "alex@external", which isn\'t an agent\'s session, so it\'s ignored' };
const kernelDecides = permissionDeciderFor(both, "runner@team", () => ignored)!;
expect(kernelDecides).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: ignored.problem });
expect(describeDefaultReceiver(kernelDecides)).not.toContain("unset");
});
});
@@ -24,6 +24,7 @@ import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { buildExecutionView, type ExecutionViewDeps } from "../src/domain/execution-view.js";
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
const SHA = "a".repeat(40);
const REGISTRY_FILES = 450;
@@ -120,4 +121,25 @@ describe("execution view review registry", () => {
expect(turnsAtRead[REGISTRY_FILES - 1]! - turnsAtRead[0]!).toBeGreaterThan(0);
expect(turnsAtRead).toHaveLength(REGISTRY_FILES);
});
it("also yields inside the later whole-project readiness read, preserving its projection", async () => {
const deps = fixture("turns", 2), missions = deps.slicesRoot()!;
for (const name of ["beta", "gamma"]) {
fs.mkdirSync(path.join(missions, name));
fs.writeFileSync(path.join(missions, name, "mission.yaml"), "kind: mission\ncomposition: { slices: [] }\n");
}
const first = fs.readdirSync(missions).slice(0, -1).find(name => name !== "turns")!;
let started = false, yielded = false;
hooks.onRead = file => {
if (!started && file === path.join(missions, first, "mission.yaml")) {
started = true;
setImmediate(() => { yielded = true; });
}
};
const doc = await buildExecutionView(deps, { mission: "turns" });
expect(started).toBe(true);
expect(yielded, "earlier registry yields must not hide a later synchronous project walk").toBe(true);
hooks.onRead = null;
expect(doc.project_readiness).toEqual(readProjectReadiness(missions));
});
});
@@ -13,6 +13,8 @@ import { NodeLauncher } from "../src/domain/node-launcher.js";
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
import { reconcileSkillLoadout } from "../src/domain/skill-catalog.js";
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
import { WhoamiService } from "../src/domain/whoami-service.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
import { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
import { ServiceOrchestrator } from "../src/domain/service-orchestrator.js";
import { ComposeServicesAdapter } from "../src/adapters/compose-services-adapter.js";
@@ -108,6 +110,34 @@ describe("PodRigInstantiator", () => {
return { db, rigRepo, podRepo, sessionRegistry, eventBus, inst, adapter, codexAdapter, tmux };
}
// #2871: a seat that asks who's on its team while `rig up` is still building it is told so; once built, the answer is today's.
it("whoami says the team is still being built during instantiate, and answers as before once it's built", async () => {
const files = { [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"), [`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa") };
const { db, rigRepo, sessionRegistry, inst } = setup(files);
const whoami = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore: new TranscriptStore({ transcriptsRoot: "/tmp/transcripts", enabled: true }) });
const spec = makeRigSpec({ pods: [{ id: "dev", label: "Dev", members: [
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
], edges: [{ from: "impl", to: "qa", kind: "delegates_to" }] }] });
const early: Array<ReturnType<WhoamiService["resolve"]>> = [];
let firstNode: string | undefined;
const result = await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT, { recordCreated: (_rigId, nodeId) => {
if (!nodeId) return;
firstNode ??= nodeId;
early.push(whoami.resolve({ nodeId: firstNode }));
} });
expect(result.ok).toBe(true);
// Mid-launch: the first seat sees only what exists so far, and is told the team isn't complete.
expect(early[0]).toMatchObject({ rosterState: "building", peers: [], edges: { outgoing: [], incoming: [] } });
expect(early[0]!.peersNote).toMatch(/^rosterState = building: `rig up` is still creating this team's seats and edges/);
// Built: the full roster and edges, with no building state and today's note.
const done = whoami.resolve({ nodeId: firstNode! });
expect(done).not.toHaveProperty("rosterState");
expect(done.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
expect(done.peers.map((peer) => peer.logicalId)).toEqual(["dev.qa"]);
expect(done.edges.outgoing).toMatchObject([{ kind: "delegates_to", to: { logicalId: "dev.qa" } }]);
});
// T1: valid rig instantiates pods + nodes + edges
it("instantiates pods + nodes correctly", async () => {
const { db, inst } = setup();
+6 -3
View File
@@ -265,7 +265,7 @@ describe("retained authority negative controls", () => {
const sliceRevision = readSliceReadiness(f.alpha).revision;
const events: Array<{ type: string; revision: string }> = [];
let invalidated = 0;
const watch = watchProofSources(f.missions, () => { invalidated++; }, {
const watch = await watchProofSources(f.missions, () => { invalidated++; }, {
emit: (event: { type: string; revision: string }) => events.push(event),
} as unknown as EventBus);
try {
@@ -296,8 +296,11 @@ describe("retained authority negative controls", () => {
it("pushes changed source truth, keeps sibling basis, and ignores unchanged source bytes", async () => {
const f = fixture(); f.judge(); const sibling = readSliceReadiness(f.beta).revision;
const events: Array<{ type: string; revision: string }> = []; let invalidated = 0;
const watch = watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
const watch = await watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
try {
// Creation events may interrupt asynchronous setup; count changes only after the initial basis settles.
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
events.length = 0; invalidated = 0;
const file = join(f.alpha, "proof/evidence.md"); fs.writeFileSync(file, fs.readFileSync(file));
await new Promise(r => setTimeout(r, 100)); expect(events).toEqual([]);
fs.writeFileSync(file, "Measured outcome changed.\n");
@@ -450,7 +453,7 @@ it("notifies scope followers on a node-header change even when proof readiness i
const f = fixture(), file = join(f.alpha, "SPEC.md");
const original = fs.readFileSync(file, "utf8"), before = readMissionReadiness(f.mission).revision;
let scopeChanges = 0;
const watch = watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
const watch = await watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
try {
const updated = original.replace("id: 01-alpha", "id: renamed-alias");
fs.writeFileSync(file, updated);
+54 -47
View File
@@ -153,8 +153,15 @@ describe("request-local proof-policy reuse", () => {
});
});
async function startWatch(...args: Parameters<typeof watchProofSources>) {
const opening = watchProofSources(...args);
// Startup itself now yields; settle its cooperative turns under the fixture clock.
await vi.runAllTimersAsync();
return opening;
}
describe("watch-local proof-policy reuse", () => {
function observe(missions: string) {
async function observe(missions: string) {
vi.useFakeTimers();
// Each watched directory's listener, by path: "a change" is delivered by the missions root's own watch.
const listeners = new Map<string, () => void>();
@@ -163,13 +170,13 @@ describe("watch-local proof-policy reuse", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn(), emit = vi.fn();
const watch = watchProofSources(missions, invalidate, { emit } as unknown as EventBus);
const watch = await startWatch(missions, invalidate, { emit } as unknown as EventBus);
const changed = () => listeners.get(missions)!();
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(missions).missions.map(m => [m.name, m.revision]))).digest("hex");
const event = () => {
const event = async () => {
const previous = watch.observation().revision, calls = invalidate.mock.calls.length;
const expected = oracle();
changed(); vi.advanceTimersByTime(50);
changed(); await vi.advanceTimersByTimeAsync(50);
expect(watch.observation()).toEqual({ state: "watching", revision: expected });
expect(invalidate).toHaveBeenCalledTimes(calls + Number(previous !== expected));
expect(emit).toHaveBeenCalledTimes(invalidate.mock.calls.length);
@@ -177,17 +184,17 @@ describe("watch-local proof-policy reuse", () => {
return { watch, changed, event, oracle, invalidate };
}
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", () => {
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", async () => {
const f = fixture();
fs.cpSync(f.mission, path.join(f.missions, "second"), { recursive: true });
const o = observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
const o = await observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
const policy = fs.readFileSync(f.projectFile, "utf8");
try {
for (const text of ["changed outcome", "second outcome"]) {
f.write(path.join(f.dirs[0]!, "SPEC.md"), `# Outcome\n\n## Proof contract\n- [ ] ${text}.\n`);
const expected = o.oracle();
read.mockClear(); parse.mockClear();
o.changed(); vi.advanceTimersByTime(50);
o.changed(); await vi.advanceTimersByTimeAsync(50);
expect(o.watch.observation()).toEqual({ state: "watching", revision: expected });
expect.soft(read.mock.calls.filter(([file]) => file === f.projectFile)).toHaveLength(1);
expect.soft(parse.mock.calls.filter(([text]) => text === policy)).toHaveLength(1);
@@ -196,37 +203,37 @@ describe("watch-local proof-policy reuse", () => {
expect.soft(read.mock.calls.filter(([file]) => file === f.missionFile)).toHaveLength(2);
}
expect(o.invalidate).toHaveBeenCalledTimes(2);
o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
await o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
} finally { o.watch.close(); }
});
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", () => {
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", async () => {
const f = fixture(), sliceFile = path.join(f.dirs[0]!, "slice.yaml");
const project = fs.readFileSync(f.projectFile, "utf8"), mission = fs.readFileSync(f.missionFile, "utf8"), slice = fs.readFileSync(sliceFile, "utf8");
const o = observe(f.missions);
const o = await observe(f.missions);
try {
for (const [file, base, actor] of [[f.projectFile, project, "new-project"], [f.missionFile, mission, "new-mission"], [sliceFile, slice, "new-slice"]]) {
f.write(file!, { ...YAML.parse(base!), proofPolicy: { judges: [actor] } });
o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
}
f.write(sliceFile, slice); f.write(f.missionFile, mission); o.event();
fs.unlinkSync(f.projectFile); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
f.write(f.projectFile, project); o.event();
f.write(sliceFile, slice); f.write(f.missionFile, mission); await o.event();
fs.unlinkSync(f.projectFile); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
f.write(f.projectFile, project); await o.event();
for (const bad of ["proofPolicy: [unclosed", { proofPolicy: { judges: [] } }]) {
f.write(f.projectFile, bad); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
f.write(f.projectFile, project); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
f.write(f.projectFile, bad); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
f.write(f.projectFile, project); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
}
const original = fs.readFileSync, read = vi.spyOn(fs, "readFileSync");
read.mockImplementation(((file: fs.PathOrFileDescriptor, ...args: any[]) => {
if (file === f.projectFile) throw Object.assign(new Error("policy denied"), { code: "EACCES" });
return (original as any)(file, ...args);
}) as typeof fs.readFileSync);
o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
read.mockImplementation(original); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
read.mockImplementation(original); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
} finally { o.watch.close(); }
});
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", () => {
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", async () => {
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]!;
@@ -238,7 +245,7 @@ describe("watch-local proof-policy reuse", () => {
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 watch = await startWatch(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() });
@@ -246,34 +253,34 @@ describe("watch-local proof-policy reuse", () => {
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);
listeners.get(outside)!("change", "report.md"); await vi.advanceTimersByTimeAsync(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);
listeners.get(outside)!("rename", "report.md"); await vi.advanceTimersByTimeAsync(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", () => {
it("rechecks accepted evidence between events without reusing judgment inputs", async () => {
const f = fixture(), dir = f.dirs[0]!, evidence = path.join(dir, "proof/evidence.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: ["proof/evidence.md"], expectedRevision: item.revision, expectedPrevious: null }, "judge@trial", "fixture");
const o = observe(f.missions);
const o = await observe(f.missions);
try {
expect(readSliceReadiness(dir).state).toBe("ready");
f.write(evidence, "changed observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
f.write(evidence, "accepted observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
f.write(evidence, "changed observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
f.write(evidence, "accepted observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
expect(o.invalidate).toHaveBeenCalledTimes(2);
} finally { o.watch.close(); }
});
});
describe("QA bounded adverse coverage at 8b236e06", () => {
function adverseFixture() {
async function adverseFixture() {
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]!;
@@ -284,46 +291,46 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
const h = { event: listener, error: undefined as (() => void) | undefined }; handles.set(root, h);
return { on: (_kind: string, cb: () => void) => { h.error = cb; }, close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn(), emit = vi.fn(), watch = watchProofSources(f.missions, invalidate, { emit } as unknown as EventBus);
const invalidate = vi.fn(), emit = vi.fn(), watch = await startWatch(f.missions, invalidate, { emit } as unknown as EventBus);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
return { f, dir, outside, evidence, handles, watch, oracle, invalidate, emit };
}
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", () => {
const t = adverseFixture();
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", async () => {
const t = await adverseFixture();
try {
expect(t.watch.observation()).toEqual({ state: "watching", revision: t.oracle() });
const failed = t.handles.get(t.outside)!; failed.error!();
expect(t.watch.observation().state).toBe("unavailable");
t.f.write(t.evidence, "changed observation");
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); vi.advanceTimersByTime(50);
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.watch.observation().revision).toBe(t.oracle());
// A fresh semantic read is not repaired notification coverage for the errored, unreplaced handle.
expect(t.watch.observation().state).toBe("unavailable");
} finally { t.watch.close(); }
});
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", () => {
const t = adverseFixture();
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", async () => {
const t = await adverseFixture();
try {
const opened = () => vi.mocked(fs.watch).mock.calls.filter(([root]) => root === t.outside).length;
expect(opened()).toBe(1);
fs.renameSync(t.outside, `${t.outside}.old`); t.f.write(t.evidence, "changed observation");
// The platform names nothing: only the structural parent of the replaced directory reports a change.
t.handles.get(path.dirname(t.outside))!.event("rename", null); vi.advanceTimersByTime(50);
t.handles.get(path.dirname(t.outside))!.event("rename", null); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.watch.observation().revision).toBe(t.oracle());
// The old same-path handle cannot certify coverage: it is replaced, or the sources are reported unobserved.
expect(opened() === 2 || t.watch.observation().state === "unavailable").toBe(true);
} finally { t.watch.close(); }
});
it("watches the real location of evidence reached through a link inside the project", () => {
const t = adverseFixture();
it("watches the real location of evidence reached through a link inside the project", async () => {
const t = await adverseFixture();
try {
// The evidence directory becomes a link to another directory inside the project, with the same accepted bytes.
const real = path.join(t.f.root, "real"), live = path.join(real, "live");
t.f.write(path.join(live, "report.md"), "accepted observation");
fs.renameSync(t.outside, `${t.outside}.old`); fs.symlinkSync(live, t.outside);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); vi.advanceTimersByTime(50);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("ready");
// Replacing the real directory is seen at its own parent, or the sources are reported unobserved.
const watched = vi.mocked(fs.watch).mock.calls.map(([root]) => root);
@@ -331,7 +338,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
expect(watched.some((root) => !String(root).startsWith(t.f.root) && root !== path.dirname(t.f.root))).toBe(false);
} finally { t.watch.close(); }
});
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, () => {
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, async () => {
const f = fixture(), real = path.join(f.root, "real"), linked = path.join(f.root, "linked-missions");
fs.mkdirSync(real); fs.renameSync(f.missions, path.join(real, "missions")); fs.symlinkSync(path.join(real, "missions"), linked);
vi.useFakeTimers();
@@ -342,22 +349,22 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(linked).missions.map(m => [m.name, m.revision]))).digest("hex");
const watch = watchProofSources(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const watch = await startWatch(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
try {
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
fs.renameSync(path.join(real, "missions"), path.join(real, "missions.old"));
fs.cpSync(path.join(real, "missions.old"), path.join(real, "missions"), { recursive: true });
const before = opened.length;
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); vi.advanceTimersByTime(50);
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); await vi.advanceTimersByTimeAsync(50);
// A new mission in the replacement tree reaches clients through the live watches only.
fs.cpSync(path.join(real, "missions", "trial"), path.join(real, "missions", "new-mission"), { recursive: true });
for (const h of opened.slice(before)) h.event("rename", h.recursive ? "new-mission/mission.yaml" : "new-mission");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(watch.observation().state === "unavailable" || watch.observation().revision === oracle()).toBe(true);
expect(opened.some((h) => !h.root.startsWith(f.root) && h.root !== path.dirname(f.root))).toBe(false);
} finally { watch.close(); }
});
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, () => {
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, async () => {
const f = fixture(), linked = path.join(f.root, "linked-missions"), inside = path.join(f.root, "real", "missions");
const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "policy-reuse-other-"))); roots.push(other);
const outside = path.join(other, "missions");
@@ -370,21 +377,21 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
opened.push({ root, event: listener });
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const emit = vi.fn(), watch = watchProofSources(linked, vi.fn(), { emit } as unknown as EventBus);
const emit = vi.fn(), watch = await startWatch(linked, vi.fn(), { emit } as unknown as EventBus);
try {
if (start === "inside") {
expect(watch.observation().state).toBe("watching");
// The supported alias is re-pointed outside the project; the link's own parent sees it.
fs.unlinkSync(linked); fs.symlinkSync(outside, linked);
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); vi.advanceTimersByTime(50);
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); await vi.advanceTimersByTimeAsync(50);
expect(emit.mock.calls.map(([e]) => e.revision)).toContain("unavailable");
}
expect(watch.observation().state).toBe("unavailable");
expect(opened.some((h) => h.root.startsWith(other))).toBe(false);
} finally { watch.close(); }
});
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
const t = adverseFixture();
it("QA observes replacement of an external evidence parent or honestly reports unavailable", async () => {
const t = await adverseFixture();
try {
const previous = t.watch.observation().revision;
expect(readSliceReadiness(t.dir).state).toBe("ready");
@@ -394,7 +401,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
if (root === path.dirname(t.outside)) h.event("rename", path.basename(t.outside));
if (root === t.f.root) h.event("rename", "build");
}
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.oracle()).not.toBe(previous);
expect(t.watch.observation().state === "unavailable" || t.watch.observation().revision === t.oracle()).toBe(true);
@@ -0,0 +1,52 @@
import { afterEach, expect, it } from "vitest";
import * as fs from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Hono } from "hono";
import { proofRoutes } from "../src/routes/proof.js";
import { slicesRoutes } from "../src/routes/slices.js";
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
it.each(["proof", "slices"])("serves another event-loop turn during the %s project read without changing the result", async target => {
const root = fs.mkdtempSync(join(tmpdir(), "proof-project-yield-")); roots.push(root);
const missions = join(root, "missions");
fs.mkdirSync(missions);
fs.writeFileSync(join(root, "project.yaml"), "kind: project\nmetadata: { id: trial }\n");
for (const name of ["alpha", "beta", "gamma"]) {
fs.mkdirSync(join(missions, name));
fs.writeFileSync(join(missions, name, "mission.yaml"), "kind: mission\nmetadata: { status: active }\ncomposition: { slices: [] }\n");
}
const readiness = readProjectReadiness(missions);
const expected = target === "proof" ? readiness : {
slices: [], totalCount: 0, filter: "all", boundToWorkflow: null,
missions: Object.fromEntries(readiness.missions.map(m => [m.name, { authoredStatus: m.historicalStatus, readiness: m }])),
};
let sourceReady = true;
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, { isReady: () => true, slicesRoot: missions,
withMembershipBatch: (fn: () => unknown) => fn(), list: () => [],
observedQueueCounts: () => new Map(), missionAuthoredStatuses: () => ({}),
} as never);
c.set("sliceDetailProjector" as never, {} as never);
c.set("proofSourceWatch" as never, { observation: () => ({ state: sourceReady ? "watching" : "unavailable", revision: "fixture" }) } as never);
await next();
});
app.route("/api/proof", proofRoutes());
app.route("/api/slices", slicesRoutes());
let completed = false;
const read = app.request(`/api/${target}`).then(response => { completed = true; return response; });
await new Promise<void>(resolve => setImmediate(resolve));
if (completed) {
const early = await read; expect(early.status).toBe(200);
expect(await early.clone().json()).toMatchObject(expected);
}
expect(completed, "the proof read must yield before finishing the whole tree").toBe(false);
sourceReady = false;
const response = await read;
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject(target === "proof" ? { ...expected, sourceObservation: { state: "unavailable" } } : expected);
});
@@ -6,7 +6,7 @@ const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot,
}));
// Real-location checks see every fixture path as itself (no links in these mocked trees).
vi.mock("node:fs", () => ({ watch, existsSync: () => true, realpathSync: (p: string) => p }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, readProjectReadinessAsync: async (...args: unknown[]) => readProjectReadiness(...args), 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(); proofFs.exists.mockImplementation(() => true); });
@@ -21,14 +21,14 @@ function reads(paths: (missions: string) => Array<[keyof Io, string]>) {
}
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", () => {
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
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);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
// Inputs at their parents, plus shallow structural anchors up to the project's parent (for replaced directories).
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[missions, true], [project, false], [path.join(project, "build", "out"), false],
@@ -43,23 +43,23 @@ it("watches only the missions root recursively; the project and evidence outside
const notifyProject = watch.mock.calls[1]![2], notifyEvidence = watch.mock.calls[2]![2];
notifyProject("change", "unrelated.txt");
notifyEvidence("change", Buffer.from("other.md"));
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notifyProject("rename", Buffer.from("project.yaml"));
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
notifyEvidence("rename", "report.md");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(4);
// No filename: that directory's watch is rebound (closed, reattached by path) and the read verified again.
notifyEvidence("change", null);
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(6);
instance.close();
expect(close).toHaveBeenCalledTimes(6);
});
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", () => {
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -67,26 +67,26 @@ it("watches the nearest existing ancestor of a missing input, filtered to the ne
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);
const instance = await 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], [path.dirname(project), false]]);
const notify = watch.mock.calls[1]![2];
notify("rename", "unrelated");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notify("rename", "build");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
instance.close();
});
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", () => {
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", async () => {
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("ENOSPC"); });
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(instance.observation().state).toBe("unavailable");
expect(close).toHaveBeenCalledOnce();
expect(watch).toHaveBeenCalledTimes(2);
@@ -94,12 +94,12 @@ it("reports unavailable and closes what it opened when a required watch fails, w
expect(close).toHaveBeenCalledOnce();
});
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", () => {
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", async () => {
const missions = path.resolve("fixture/missions");
resolveProjectRoot.mockReturnValue(path.dirname(missions));
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);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
// Only the missions root's structural anchors are added, shallow.
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[missions, true], [path.dirname(missions), false], [path.dirname(path.dirname(missions)), false],
@@ -108,7 +108,7 @@ it("watches nothing else recursively when the evaluation reads nothing outside t
expect(close).toHaveBeenCalledTimes(3);
});
it("does not report watching when the inputs keep changing past the reconciliation budget", () => {
it("does not report watching when the inputs keep changing past the reconciliation budget", async () => {
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
@@ -120,7 +120,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
return { missions: [] };
});
handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const attached = new Set(watch.mock.calls.map(([root]) => root));
// Either the final dependency set is actually watched and verified, or the monitor says it is not watching.
const finalDir = path.dirname(sets[2]!);
@@ -129,7 +129,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
instance.close();
});
it("tells clients when coverage is lost or regained even though the proof result is unchanged", () => {
it("tells clients when coverage is lost or regained even though the proof result is unchanged", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -146,19 +146,19 @@ it("tells clients when coverage is lost or regained even though the proof result
});
handle();
const emit = vi.fn();
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
expect(instance.observation().state).toBe("watching");
const notifyProject = watch.mock.calls[1]![2];
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable"]);
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable", instance.observation().revision]);
instance.close();
});
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
it("after close, a queued callback neither re-reads, reopens nor emits", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -166,14 +166,141 @@ it("after close, a queued callback neither re-reads, reopens nor emits", () => {
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const instance = await 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);
await vi.advanceTimersByTimeAsync(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
expect(watch).toHaveBeenCalledTimes(3);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});
it.each(["changed", "unchanged"])("coalesces mid-read events and publishes only a current %s basis", async mode => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: Array<{ name: string; revision: string }> }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const notify = watch.mock.calls[0]![2];
notify("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(50);
const settledRevision = instance.observation().revision;
const calls = readProjectReadiness.mock.calls.length;
notify("change", "SPEC.md"); notify("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(calls);
finish({ missions: [{ name: "trial", revision: "superseded" }] });
await vi.advanceTimersByTimeAsync(0);
expect(instance.observation().revision).toBe(settledRevision);
expect(emit).not.toHaveBeenCalled();
readProjectReadiness.mockReturnValue({ missions: mode === "changed" ? [{ name: "trial", revision: "current" }] : [] });
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit).toHaveBeenCalledTimes(mode === "changed" ? 1 : 0);
instance.close();
});
it("publishes unchanged-revision coverage recovery only after an interrupted read settles cleanly", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
let call = 0;
readProjectReadiness.mockImplementation((_m: string, _r: unknown, _root: unknown, io?: Io) => {
io?.exists(path.join(project, "project.yaml"));
io?.readBytes(path.join(project, `input-${Math.min(call++, 2)}`, "report.md"));
return { missions: [] };
});
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const settled = instance.observation();
expect(settled.state).toBe("unavailable"); // all three setup passes attached a previously unwatched dependency
const read = readProjectReadiness.getMockImplementation()!;
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
read(...args);
return new Promise(resolve => { finish = resolve; });
});
const notify = watch.mock.calls[0]![2];
notify("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
notify("change", "report.md");
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
expect.soft(instance.observation()).toEqual(settled);
expect(emit).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation()).toEqual({ state: "watching", revision: settled.revision });
expect.soft(emit.mock.calls.map(([event]) => event.revision)).toEqual([settled.revision]);
instance.close();
});
it("reports watch errors immediately while a refresh is still awaiting its read", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
let error!: () => void;
watch.mockImplementation(() => ({ on: (_event: string, callback: () => void) => { error = callback; }, close: vi.fn() }));
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
watch.mock.calls[0]![2]("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
error();
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
expect(emit.mock.calls.at(-1)![0].revision).toBe("unavailable");
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.every(([event]) => event.revision === "unavailable")).toBe(true);
instance.close();
});
it("does not publish an interrupted setup candidate before a clean refresh", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
const read = readProjectReadiness.getMockImplementation()!;
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
read(...args);
return new Promise(resolve => { finish = resolve; });
});
handle();
const emit = vi.fn(), opening = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
watch.mock.calls[0]![2]("change", "report.md");
finish({ missions: [] });
const instance = await opening;
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
expect(emit).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit).toHaveBeenCalledTimes(1);
instance.close();
});
it("does not reattach watches or publish when an in-flight read finishes after close", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
watch.mock.calls[0]![2]("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(50);
const watches = watch.mock.calls.length;
instance.close(); finish({ missions: [] });
await vi.advanceTimersByTimeAsync(100);
expect(watch).toHaveBeenCalledTimes(watches);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});
@@ -25,10 +25,13 @@ it("watches the owning project policy above a nested missions root", async () =>
expect(readSliceReadiness(slice).state).toBe("ready");
const events: unknown[] = [];
let invalidated = 0;
const watch = watchProofSources(missions, () => invalidated++, {
const watch = await watchProofSources(missions, () => invalidated++, {
emit: (event: unknown) => events.push(event),
} as unknown as EventBus);
try {
// A creation event can dirty setup before it returns; start assertions at the settled observed basis.
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
events.length = 0; invalidated = 0;
await new Promise(resolve => setTimeout(resolve, 250));
expect(events).toHaveLength(0);
const before = watch.observation().revision;
@@ -43,11 +46,11 @@ it("watches the owning project policy above a nested missions root", async () =>
} finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
});
it("retains the existing watch boundary for roots without project.yaml", () => {
it("retains the existing watch boundary for roots without project.yaml", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "proof-legacy-root-"));
const missions = path.join(root, "missions");
fs.mkdirSync(missions);
const watch = watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
const watch = await watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
try { expect(watch.observation().state).toBe("watching"); }
finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
});
@@ -717,7 +717,9 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
it("Steering's Approve or Deny on the request is the person's yes or no: it goes to the agent, or, with none, to the request as before", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
expect(await f.act(askId, "approve")).toMatchObject({ status: 200 });
// The result says what became of the answer (the TUI prints it), never that the request closed.
expect(await f.act(askId, "approve")).toMatchObject({ status: 200,
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run.` } } });
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(handedFrom(f, askId)!.body).toContain(`they wrote "yes"`);
@@ -725,7 +727,8 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
for (const verb of ["approve", "deny"] as const) {
const g = fixture({ decider: null });
const id = String((await g.submit()).body.askId);
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb } });
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb,
answer: { message: expect.stringContaining(`Your answer is kept on ${id}, and the request is still waiting: the tool hasn't run`) } } });
expect(await g.read(id)).toMatchObject({ body: { state: "pending" } });
// The audit keeps what the person did: their verb, and their reason.
expect(g.db.prepare("SELECT action_verb, reason FROM mission_control_actions WHERE qitem_id = ?").all(id))
@@ -926,8 +929,37 @@ describe("an answered request: review-r2's round-1 questions", () => {
await f.answer(askId, { decision: "only the test file" });
const spy = vi.spyOn(f.queueRepo, "recordPermissionDecision").mockReturnValue(false);
try {
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded" } });
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded",
error: expect.stringContaining("Nothing was decided. Read the request again.") } });
} finally { spy.mockRestore(); }
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
});
});
describe("an answered request: #2934's follow-ups", () => {
it("a decision on a row closed without one persists queue.updated, so a waiting runner and Steering hear it", async () => {
const f = fixture({ decider: null });
const askId = String((await f.submit()).body.askId);
await f.answer(askId, { decision: "only the test file" });
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
const updates = () => (f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'queue.updated' AND json_extract(payload, '$.qitemId') = ?").get(askId) as { n: number }).n;
const before = updates();
expect(await f.decide(askId, "allow")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
expect(updates()).toBe(before + 1);
});
it("a handoff that fails after persisting its events still keeps the person's words (the envelope drops the rolled-back tokens)", async () => {
const f = fixture({ decider: DECIDER });
const askId = String((await f.submit()).body.askId);
// The real handoff persists its two events, then fails at its last check: its savepoint rolls them back.
const spy = vi.spyOn(f.queueRepo as unknown as { assertTerminalClosureHasIntent: () => void }, "assertTerminalClosureHasIntent")
.mockImplementationOnce(() => { throw new Error("closure check failed"); });
try {
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
} finally { spy.mockRestore(); }
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
expect(f.permits()).toHaveLength(0);
});
});
+21 -1
View File
@@ -17,7 +17,8 @@ import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_c
import { RigRepository } from "../src/domain/rig-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { TranscriptStore } from "../src/domain/transcript-store.js";
import { WhoamiService } from "../src/domain/whoami-service.js";
import { WhoamiService, ROSTER_BUILDING_NOTE } from "../src/domain/whoami-service.js";
import { markRigBuilding, unmarkRigBuilding } from "../src/domain/rigs-being-built.js";
import { createFullTestDb } from "./helpers/test-app.js";
function setupDb(): Database.Database {
@@ -143,6 +144,25 @@ describe("WhoamiService", () => {
expect(result["podRoster"]).toBeUndefined();
});
// #2871: the building state belongs to the rig being built, by id, never to another rig with the same name.
it("only the rig being built says so; a running rig with the same name answers as before", () => {
const { nodeA } = seedRig();
const newRig = rigRepo.createRig("my-rig");
const newNode = rigRepo.addNode(newRig.id, "dev.impl", { role: "worker", runtime: "claude-code" });
markRigBuilding(db, newRig.id);
const running = svc.resolve({ nodeId: nodeA.id })!;
expect(running).not.toHaveProperty("rosterState");
expect(running.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
const building = svc.resolve({ nodeId: newNode.id })!;
expect(building.rosterState).toBe("building");
expect(building.peersNote.startsWith(ROSTER_BUILDING_NOTE)).toBe(true);
unmarkRigBuilding(db, newRig.id);
expect(svc.resolve({ nodeId: newNode.id })).not.toHaveProperty("rosterState");
});
it("unknown nodeId returns null", () => {
seedRig();
const result = svc.resolve({ nodeId: "nonexistent" });
+2 -1
View File
@@ -471,7 +471,8 @@ async function run(): Promise<void> {
const actor = actingAs(snapshot.attentionRead, action.actorSession);
view.dispatch({ type: "notice", message: action.verb === "resolve" ? `Resolved ${action.qitemId} as ${actor}: it's unparked for its owner, with your decision.`
: action.verb === "approve"
? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
// A runner's permission request says what became of the answer: handed to its receiving agent, or kept.
? result.answer?.message ?? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
: result.answer?.message ?? (result.answer?.outcome === "recorded" ? `Recorded on ${action.qitemId}. Still to answer: ${result.answer.unanswered.join("; ")}`
: `Answered ${action.qitemId} as ${actor}: the answer went back to the asker, and the request is closed.`) });
}