Files
openrig/packages/cli/test/context-auto-start.test.ts
Mike Schwarz bf579d5f89 fix(cli): send the real request to a busy daemon; refuse only one confirmed down (#1650)
Commands no longer refuse a daemon that is busy but alive. The CLI's status check before each command refused whenever it couldn't confirm the daemon was healthy, so right after a slow request an ordinary command such as `rig ps --nodes -A`, `rig capture` or `rig context profile` failed in about 128 ms with "Daemon did not respond — it may be busy or stopped". Now a daemon that isn't confirmed down gets the real request, under its normal timeout. A daemon confirmed down (stopped, stale, or a refused probe) keeps the clear "not running" message and start hint, and a probe the OS blocks and a live sibling home keep their own guidance.

When a write then times out, it says the outcome is unknown and to check before any retry, including in the commands that catch their own errors (context, agent-image, specs, scope and fork). Each write is sent once and never retried. `rig start`'s UI health read is bounded at 5 seconds.

`rig up` and `rig start` start a daemon when one is stopped or stale, or for a live sibling home or a blocked probe, as before. They no longer start one for a busy daemon of this home, which used to get a false "already running", or past an explicit HOME-MOVED marker. GitHub bundle imports still require a verified local daemon, and `rig status --json` is unchanged.
2026-10-20 22:06:43 -07:00

188 lines
8.7 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Command } from "commander";
import { contextCommand } from "../src/commands/context.js";
import type { DaemonClient } from "../src/client.js";
import { STATE_FILE, type LifecycleDeps } from "../src/daemon-lifecycle.js";
describe("context add local auto-start", () => {
let root: string;
let source: string;
let previousExit: typeof process.exitCode;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "context-auto-start-"));
source = join(root, "source");
mkdirSync(source);
writeFileSync(join(source, "manifest.yaml"), "name: fixture-pack\nversion: 1.0.0\ntaxonomy: world\nfiles:\n - path: NOTES.md\n role: instruction\n");
writeFileSync(join(source, "NOTES.md"), "fixture context\n");
vi.stubEnv("OPENRIG_HOME", join(root, "state"));
vi.stubEnv("OPENRIG_CONTEXT_ROOT", join(root, "context"));
vi.stubEnv("OPENRIG_DB", join(root, "state", "fixture.sqlite"));
vi.stubEnv("OPENRIG_TRANSCRIPTS_PATH", join(root, "transcripts"));
vi.stubEnv("OPENRIG_PORT", "42761");
vi.stubEnv("OPENRIG_HOST", "127.0.0.1");
vi.stubEnv("RIGGED_HOST", "");
previousExit = process.exitCode;
process.exitCode = undefined;
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
process.exitCode = previousExit;
vi.unstubAllEnvs();
vi.restoreAllMocks();
rmSync(root, { recursive: true, force: true });
});
function fixture(initial: "stopped" | "running" | "unknown" = "stopped", stale = false) {
let started = initial === "running";
let state: string | null = stale
? JSON.stringify({ pid: 900001, port: 42760, host: "127.0.0.1", db: "fixture.sqlite", startedAt: "2026-01-01T00:00:00Z" })
: null;
const statePath = join(root, "state", "daemon.json");
if (state) {
mkdirSync(join(root, "state"), { recursive: true });
writeFileSync(statePath, state);
}
const lifecycleDeps: LifecycleDeps = {
acquireStartLock: () => ({ recordChild: vi.fn(), release: vi.fn() }),
spawn: vi.fn(() => {
started = true;
return Object.assign(new EventEmitter(), { pid: 4321, exitCode: null, signalCode: null, unref: vi.fn() }) as never;
}),
fetch: vi.fn(async () => {
if (initial === "unknown") throw Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
if (!started) throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" });
return { ok: true, json: async () => ({ pid: 4321, bind: { mode: "explicit", hosts: ["127.0.0.1"], tailscaleDetected: false } }) };
}),
kill: vi.fn(() => true),
readFile: vi.fn((p) => p === STATE_FILE ? state : null),
writeFile: vi.fn((p, content) => { if (p === STATE_FILE) state = content; }),
removeFile: vi.fn(),
exists: vi.fn((p) => p === STATE_FILE && state !== null),
mkdirp: vi.fn(),
openForAppend: vi.fn(() => 3),
isProcessAlive: vi.fn(() => started),
sleep: async () => {},
};
const post = vi.fn(async () => ({ status: 200, data: { count: 1, entries: [], errors: [] } }));
const clientFactory = vi.fn(() => ({ post }) as unknown as DaemonClient);
const preflightExec = vi.fn(async (cmd: string) => cmd === "tmux -V" ? "tmux 3.6a" : "");
const command = () => new Command().exitOverride().addCommand(contextCommand({ lifecycleDeps, clientFactory, preflightExec }));
const add = () => command().parseAsync(["node", "rig", "context", "add", source, "--json"]);
return { lifecycleDeps, clientFactory, preflightExec, post, command, add };
}
it("starts a stopped local daemon with configured paths then syncs the installed pack", async () => {
const f = fixture();
await f.add();
expect(process.exitCode).toBeUndefined();
expect(f.lifecycleDeps.spawn).toHaveBeenCalledOnce();
const options = vi.mocked(f.lifecycleDeps.spawn).mock.calls[0]![2];
expect(options.env.OPENRIG_PORT).toBe("42761");
expect(options.env.OPENRIG_DB).toBe(join(root, "state", "fixture.sqlite"));
expect(options.env.OPENRIG_HOST).toBeUndefined(); // Routing is not bind intent.
expect(f.clientFactory).toHaveBeenCalledWith("http://127.0.0.1:42761");
expect(f.post).toHaveBeenCalledWith("/api/context-packs/library/sync");
expect(readFileSync(join(root, "context", "fixture-pack", "NOTES.md"), "utf8")).toBe("fixture context\n");
expect(JSON.parse(vi.mocked(console.log).mock.calls[0]![0])).toMatchObject({ count: 1 });
});
it("uses a running daemon without a start or preflight", async () => {
const f = fixture("running");
await f.add();
expect(process.exitCode).toBeUndefined();
expect(f.post).toHaveBeenCalledOnce();
expect(f.lifecycleDeps.spawn).not.toHaveBeenCalled();
expect(f.preflightExec).not.toHaveBeenCalled();
});
it.each(["OPENRIG_URL", "RIGGED_URL"])("does not auto-start for an explicit %s endpoint", async (key) => {
vi.stubEnv(key, "http://127.0.0.1:42762");
const f = fixture();
await f.add();
expect(process.exitCode).toBe(1);
expect(f.lifecycleDeps.spawn).not.toHaveBeenCalled();
expect(f.post).not.toHaveBeenCalled();
});
it("default host selection retains recorded local startup", async () => {
vi.stubEnv("OPENRIG_HOST", "");
const f = fixture("stopped", true);
await f.add();
expect(process.exitCode).toBeUndefined();
expect(f.lifecycleDeps.spawn).toHaveBeenCalledOnce();
expect(f.post).toHaveBeenCalledWith("/api/context-packs/library/sync");
});
it.each([
["OPENRIG_HOST", false], ["OPENRIG_HOST", true],
["RIGGED_HOST", false], ["RIGGED_HOST", true],
["config-file", false], ["config-file", true],
] as const)("remote selection %s with stale state=%s does not spawn locally", async (source, stale) => {
vi.stubEnv("OPENRIG_HOST", "");
if (source === "config-file") {
mkdirSync(join(root, "state"), { recursive: true });
writeFileSync(join(root, "state", "config.json"), JSON.stringify({ daemon: { host: "remote.example.invalid" } }));
} else {
vi.stubEnv(source, "remote.example.invalid");
}
const f = fixture("stopped", stale);
await f.add();
expect(f.lifecycleDeps.spawn).not.toHaveBeenCalled();
expect(f.preflightExec).not.toHaveBeenCalled();
expect(f.post).not.toHaveBeenCalled();
expect(process.exitCode).toBe(1);
});
it.each([
["OPENRIG_HOST", "LOCALHOST"], ["config-file", "LOCALHOST"],
["OPENRIG_HOST", "::1"], ["config-file", "[::1]"],
["RIGGED_HOST", "0:0:0:0:0:0:0:1"],
] as const)("selected loopback %s=%s retains local startup", async (source, host) => {
vi.stubEnv("OPENRIG_HOST", "");
if (source === "config-file") {
mkdirSync(join(root, "state"), { recursive: true });
writeFileSync(join(root, "state", "config.json"), JSON.stringify({ daemon: { host } }));
} else {
vi.stubEnv(source, host);
}
const f = fixture();
await f.add();
expect(process.exitCode).toBeUndefined();
expect(f.lifecycleDeps.spawn).toHaveBeenCalledOnce();
expect(f.post).toHaveBeenCalledWith("/api/context-packs/library/sync");
const options = vi.mocked(f.lifecycleDeps.spawn).mock.calls[0]![2];
expect(options.env.OPENRIG_HOST).toBe(source === "config-file" ? host : undefined);
});
it("preserves an unverified daemon result without spawning a replacement", async () => {
// Changed: an unverified daemon used to refuse (exit 1). It may be alive and busy, so the sync
// request now goes to the endpoint it was probed at; still no replacement daemon is spawned.
const f = fixture("unknown");
await f.add();
expect(f.lifecycleDeps.spawn).not.toHaveBeenCalled();
expect(f.clientFactory).toHaveBeenCalledWith("http://127.0.0.1:42761");
expect(f.post).toHaveBeenCalledWith("/api/context-packs/library/sync");
});
it("keeps preflight failures visible and does not spawn", async () => {
const f = fixture();
f.preflightExec.mockRejectedValue(new Error("tmux missing"));
await f.add();
expect(process.exitCode).toBe(1);
expect(vi.mocked(console.error).mock.calls.flat().join("\n")).toContain("tmux was not found");
expect(f.lifecycleDeps.spawn).not.toHaveBeenCalled();
});
it("leaves the daemon-free trace command independent", async () => {
const f = fixture();
await f.command().parseAsync(["node", "rig", "context", "trace", "--rig", "missing-fixture", "--name", "NOTES.md"]);
expect(f.lifecycleDeps.fetch).not.toHaveBeenCalled();
expect(f.lifecycleDeps.spawn).not.toHaveBeenCalled();
});
});