mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
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:
1 parent
3650524101
commit
39ee4751dc
2 files changed
+137
-5
No files matched your search
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user