fix(compact-plan): reject incomplete daemon inventory (#579)

rig compact-plan no longer treats an incomplete daemon inventory as a complete one. For a fleet plan, a rig whose node read fails is left out and reported in incompleteRigs with its HTTP status, invalid_inventory or request_failed; the plan is built from the rigs that read successfully, text output prints a partial-plan warning on stderr, JSON always carries incompleteRigs, and the exit code is 1 when anything is incomplete. A failed read for an explicit --rig, or of the process list, still fails with exit 2 and no plan.
This commit is contained in:
Rudy Mizrahi Celekli authored and GitHub committed 2026-10-03 05:21:51 -07:00
1 parent 3650524101
commit 39ee4751dc
2 files changed
+137 -5

No files matched your search

+37 -5
View File
@@ -91,7 +91,13 @@ interface SkippedEntry extends BasePlanEntry {
reason: string;
}
interface IncompleteRig {
rig: string;
status: number | "invalid_inventory" | "request_failed";
}
interface CompactPlanResult {
incompleteRigs: IncompleteRig[];
summary: {
totalSeats: number;
claudeSeats: number;
@@ -296,7 +302,7 @@ function analyzeNode(node: NodeEntry, thresholds: CompactPlanThresholds): Candid
};
}
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): CompactPlanResult {
function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds(), incompleteRigs: IncompleteRig[] = []): CompactPlanResult {
const candidates: CandidateEntry[] = [];
const blocked: BlockedEntry[] = [];
const skipped: SkippedEntry[] = [];
@@ -315,6 +321,7 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): Compac
});
return {
incompleteRigs,
summary: {
totalSeats: nodes.length,
claudeSeats: nodes.filter(isClaude).length,
@@ -344,6 +351,9 @@ function buildPlan(nodes: NodeEntry[], thresholds = defaultThresholds()): Compac
function printHuman(plan: CompactPlanResult): void {
console.log("READ-ONLY PLAN - does not compact");
if (plan.incompleteRigs.length > 0) {
console.error(`Warning: partial plan; incomplete rigs: ${plan.incompleteRigs.map(({ rig, status }) => `${rig} (${typeof status === "number" ? `HTTP ${status}` : status})`).join(", ")}`);
}
console.log("Policy: read_only_plan; one-seat-at-a-time marshal triage; autoCompactAllowed=false; explicit authorization required.");
console.log(`Thresholds: ${plan.policy.thresholdTokens} estimated tokens; ${plan.policy.thresholdPercent}% when context window size is missing.`);
console.log(`Summary: ${plan.summary.candidateCount} candidates | ${plan.summary.blockedCount} blocked | ${plan.summary.skippedCount} skipped`);
@@ -420,7 +430,13 @@ Examples:
try {
const psResult = await client.get<RigEntry[]>("/api/ps");
const rigs = psResult.data ?? [];
if (psResult.status !== 200) {
throw new Error(`Rig inventory returned HTTP ${psResult.status}`);
}
if (!Array.isArray(psResult.data)) {
throw new Error("Rig inventory is not an array");
}
const rigs = psResult.data;
const targetRigs = opts.rig ? rigs.filter((rig) => rig.name === opts.rig) : rigs;
if (opts.rig && targetRigs.length === 0) {
@@ -450,19 +466,35 @@ Examples:
}
const allNodes: NodeEntry[] = [];
const incompleteRigs: IncompleteRig[] = [];
for (const rig of targetRigs) {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
if (Array.isArray(nodesResult.data)) {
let failureStatus: IncompleteRig["status"] = "request_failed";
try {
const nodesResult = await client.get<NodeEntry[]>(`/api/rigs/${rig.rigId}/nodes`);
failureStatus = nodesResult.status;
if (nodesResult.status !== 200) {
throw new Error(`Node inventory for rig "${rig.name}" returned HTTP ${nodesResult.status}`);
}
failureStatus = "invalid_inventory";
if (!Array.isArray(nodesResult.data)) {
throw new Error(`Node inventory for rig "${rig.name}" is not an array`);
}
allNodes.push(...nodesResult.data);
} catch (err) {
// An explicitly selected rig has no useful partial inventory. For
// fleet plans, keep healthy seats while making every omission visible.
if (opts.rig) throw err;
incompleteRigs.push({ rig: rig.name, status: failureStatus });
}
}
const plan = buildPlan(allNodes, thresholds.value);
const plan = buildPlan(allNodes, thresholds.value, incompleteRigs);
if (opts.json) {
console.log(JSON.stringify(plan, null, 2));
} else {
printHuman(plan);
}
if (incompleteRigs.length > 0) process.exitCode = 1;
} catch (err) {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
console.error("Fix: check daemon status with: rig daemon status");
@@ -0,0 +1,100 @@
import { afterEach, expect, it, vi } from "vitest";
import http from "node:http";
import { compactPlanCommand } from "../src/commands/compact-plan.js";
import { DaemonClient } from "../src/client.js";
import { STATE_FILE } from "../src/daemon-lifecycle.js";
let server: http.Server | undefined;
afterEach(async () => {
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
}
vi.restoreAllMocks();
process.exitCode = undefined;
});
it("marks partial native HTTP inventory while preserving healthy rigs", async () => {
let failedRoute = "/api/ps";
let status = 503;
let malformedNodes = false;
let malformedRigs = false;
const requests: Array<{ method: string; path: string }> = [];
const candidate = {
rigId: "rig-a", rigName: "alpha", logicalId: "orch.lead", canonicalSessionName: "orch-lead@alpha",
runtime: "claude-code", sessionStatus: "running", startupStatus: "ready",
tmuxAttachCommand: "tmux attach -t orch-lead@alpha", resumeCommand: null,
contextUsage: { usedPercentage: 90, remainingPercentage: 10, contextWindowSize: 1_000_000,
source: "claude_statusline_json", availability: "known", sampledAt: new Date().toISOString(), fresh: true },
};
server = http.createServer((req, res) => {
requests.push({ method: req.method!, path: req.url! });
if (req.url === failedRoute && status === 0) { req.socket.destroy(); return; }
const code = req.url === failedRoute ? status : 200;
const data = req.url === "/api/ps"
? code === 200 ? malformedRigs ? null : [{ rigId: "rig-a", name: "alpha" }, { rigId: "rig-b", name: "beta" }] : []
: req.url === "/api/rigs/rig-a/nodes" ? [candidate] : code === 200 && !malformedNodes ? [] : { error: "inventory unavailable" };
res.writeHead(code, { "Content-Type": "application/json" });
res.end(JSON.stringify(data));
});
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
const port = (server.address() as { port: number }).port;
const deps = { clientFactory: (base: string) => new DaemonClient(base), lifecycleDeps: {
exists: (p: string) => p === STATE_FILE,
readFile: () => JSON.stringify({ pid: process.pid, port, db: "fixture.sqlite", startedAt: new Date().toISOString() }),
isProcessAlive: () => true, fetch: async () => ({ ok: true }),
} as never };
const log = vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
for (const output of [[], ["--json"]]) {
for (const route of ["/api/ps", "/api/rigs/rig-b/nodes"]) {
for (const code of [401, 503]) {
failedRoute = route; status = code; process.exitCode = undefined;
log.mockClear(); error.mockClear(); requests.length = 0;
await compactPlanCommand(deps).parseAsync(output, { from: "user" });
expect.soft(process.exitCode).toBe(route === "/api/ps" ? 2 : 1);
if (route === "/api/ps") {
expect.soft(error.mock.calls.flat().join(" ")).toContain(`HTTP ${code}`);
expect.soft(log).not.toHaveBeenCalled();
} else if (output.length) {
const plan = JSON.parse(String(log.mock.calls[0]?.[0] ?? "{}"));
expect.soft(plan.incompleteRigs).toEqual([{ rig: "beta", status: code }]);
expect.soft(plan.candidates?.[0]?.session).toBe("orch-lead@alpha");
} else {
expect.soft(log.mock.calls.flat().join(" ")).toContain("orch-lead@alpha");
expect.soft(error.mock.calls.flat().join(" ")).toContain(`beta (HTTP ${code})`);
}
expect(requests.every((request) => request.method === "GET")).toBe(true);
}
}
}
failedRoute = ""; malformedRigs = true; process.exitCode = undefined; log.mockClear(); error.mockClear();
await compactPlanCommand(deps).parseAsync(["--json"], { from: "user" });
expect(process.exitCode).toBe(2); expect(error.mock.calls.flat().join(" ")).toContain("Rig inventory is not an array");
expect(log).not.toHaveBeenCalled();
malformedRigs = false; malformedNodes = true; process.exitCode = undefined; log.mockClear(); error.mockClear();
await compactPlanCommand(deps).parseAsync(["--json"], { from: "user" });
expect(process.exitCode).toBe(1);
expect(JSON.parse(String(log.mock.calls[0]![0])).incompleteRigs).toEqual([{ rig: "beta", status: "invalid_inventory" }]);
process.exitCode = undefined; log.mockClear(); error.mockClear();
await compactPlanCommand(deps).parseAsync(["--rig", "beta", "--json"], { from: "user" });
expect(process.exitCode).toBe(2); expect(log).not.toHaveBeenCalled();
expect(error.mock.calls.flat().join(" ")).toContain("is not an array");
malformedNodes = false; failedRoute = "/api/rigs/rig-b/nodes"; status = 503;
process.exitCode = undefined; log.mockClear(); error.mockClear();
await compactPlanCommand(deps).parseAsync(["--rig", "beta", "--json"], { from: "user" });
expect(process.exitCode).toBe(2); expect(log).not.toHaveBeenCalled();
expect(error.mock.calls.flat().join(" ")).toContain("HTTP 503");
status = 0; process.exitCode = undefined; log.mockClear(); error.mockClear();
await compactPlanCommand(deps).parseAsync(["--json"], { from: "user" });
const partial = JSON.parse(String(log.mock.calls[0]![0]));
expect(process.exitCode).toBe(1);
expect(partial.incompleteRigs).toEqual([{ rig: "beta", status: "request_failed" }]);
expect(partial.candidates[0].session).toBe("orch-lead@alpha");
failedRoute = ""; process.exitCode = undefined; log.mockClear(); error.mockClear();
await compactPlanCommand(deps).parseAsync(["--json"], { from: "user" });
expect(process.exitCode).toBeUndefined(); expect(error).not.toHaveBeenCalled();
expect(JSON.parse(String(log.mock.calls[0]![0])).summary.candidateCount).toBe(1);
expect(JSON.parse(String(log.mock.calls[0]![0])).incompleteRigs).toEqual([]);
});