Compare commits

...
Author SHA1 Message Date
dev-driverandClaude Opus 5.5 841817845e fix(cli): daemon start --wait-for-kernel says why a skipped kernel isn't up
When daemon start skipped the kernel on purpose, --wait-for-kernel
printed a timeout header and a runtime-auth fix, both pointing at the
wrong cause. It now prints the same reason and way back rig start
gives (moved into one helper, skippedKernelMessage), and still exits 1,
since the kernel it waited for isn't ready. Other states keep their
wording.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:29:25 +00:00
dev-driverandClaude Opus 5.5 4109a13145 test(cli): pin what daemon start --wait-for-kernel says for a skipped kernel
A kernel daemon start skipped on purpose (already down before this start,
or turned off) gets its own reason and the way back, not the timeout
header and the auth fix. A kernel still booting at the deadline keeps
them.

Tests only; the fix follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:28:33 +00:00
dev-driverandClaude Opus 5.5 d73bece8fc fix(daemon,cli): say why a stale kernel stays down, and count a detached session before notifying
- kernel boot: a kernel that was already down before this daemon start
  is skipped with a detail naming the cause and the way back
  (rig up kernel --existing). Other skips keep their detail.
- rig start: prints that detail, instead of the --no-kernel
  explanation it gave for any detail it didn't recognize.
- reconcile: record a detached session as soon as its transaction
  commits, before notifying, so an error while notifying can't leave a
  committed detach uncounted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:11:42 +00:00
dev-driverandClaude Opus 5.5 a703016381 test(daemon,cli): pin why a stale kernel stays down, and count a detached session before notifying
- kernel-lost-restore: a kernel already down before this daemon start
  reports a skip detail that names the cause and the way back.
- start-kernel-evidence: rig start prints that detail instead of the
  --no-kernel explanation.
- reconciler: a session counts as detached once its transaction commits,
  even if notifying afterwards throws.

Tests only; the fix follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:10:52 +00:00
9 changed files with 115 additions and 9 deletions

No files matched your search

+5 -1
View File
@@ -264,7 +264,7 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// know whether the kernel itself reached ready. Operators who
// need a kernel-ready signal opt in here.
if (opts.waitForKernel) {
const { waitForKernelReady } = await import("../daemon-lifecycle.js");
const { waitForKernelReady, skippedKernelMessage } = await import("../daemon-lifecycle.js");
const timeoutMs = opts.waitForKernelMs && /^\d+$/.test(opts.waitForKernelMs)
? parseInt(opts.waitForKernelMs, 10)
: 60_000;
@@ -272,6 +272,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
const result = await waitForKernelReady(baseUrl, timeoutMs);
if (result.ok) {
console.log(`Kernel ${result.kernelState}; variant=${result.variant ?? "(none)"}`);
} else if (result.kernelState === "skipped") {
// Skipped on purpose, so neither a timeout nor an auth problem: say why, and the way back.
console.error(skippedKernelMessage(result.detail));
process.exitCode = 1;
} else {
// Honest 3-part error per banked discipline.
console.error(
+3 -5
View File
@@ -10,6 +10,7 @@ import {
getDaemonUrl,
startDaemon,
waitForKernelReady,
skippedKernelMessage,
type LifecycleDeps, daemonStatusGuard } from "../daemon-lifecycle.js";
import { shouldAutoStartDaemon } from "../daemon-reachability.js";
import { freshSeatCommand, launchFreshSeat } from "../fresh-seat-command.js";
@@ -286,11 +287,8 @@ status); a restore that returned without a ready kernel exits 1.
if (!opts.json) console.log("Continuing with your other rigs.");
} else if (!kernelResult.ok) {
if (kernelResult.kernelState === "skipped") {
// The daemon also skips its boot when a kernel rig already exists (kernel-boot.ts).
// That rig need not be running, after a reboot it is not, and this command does not restore it.
if (!opts.json) console.log(kernelResult.detail === "kernel rig already managed"
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.");
// This command does not restore a kernel the daemon skipped; say why, and the way back.
if (!opts.json) console.log(skippedKernelMessage(kernelResult.detail));
} else {
const terminalFailure = ["auth_blocked", "spec_missing", "bootstrap_failed", "degraded"].includes(kernelResult.kernelState ?? "");
console.error(`${terminalFailure ? "Kernel failed to start" : "Kernel did not report ready before the deadline"}: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
+12
View File
@@ -1318,6 +1318,18 @@ export interface KernelReadyResult {
existingRestore?: "in_progress" | "finished";
}
/** What to tell a person when daemon start skipped the kernel on purpose (kernel_state `skipped`).
* The daemon also skips when a kernel rig already exists (kernel-boot.ts); that rig need not be running.
* A longer detail with the same start is the daemon's own reason, such as a kernel already down before it started. */
export function skippedKernelMessage(detail: string | null): string {
const d = detail ?? "";
return d === "kernel rig already managed"
? "Kernel auto-boot skipped (kernel rig already managed). If its seats are not running, for example after a reboot, bring it back with: rig up kernel --existing"
: d.startsWith("kernel rig already managed")
? `Kernel auto-boot skipped: ${d}`
: "Kernel auto-boot skipped (--no-kernel or test mode), including the operator that helps you start a team. To start it later, run rig, set up or select kernel, then start its operator seat.";
}
export async function waitForKernelReady(
baseUrl: string,
timeoutMs: number,
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { waitForKernelReady, type KernelReadyResult } from "../src/daemon-lifecycle.js";
import { daemonCommand } from "../src/commands/daemon.js";
vi.mock("../src/daemon-lifecycle.js", async (original) => {
const actual = await original<typeof import("../src/daemon-lifecycle.js")>();
return {
...actual,
getDaemonStatus: vi.fn(async () => ({ state: "stopped" })),
startDaemon: vi.fn(async () => ({ pid: 4242, port: 7433, host: "127.0.0.1" })),
waitForKernelReady: vi.fn(),
};
});
vi.mock("../src/system-preflight.js", () => ({ SystemPreflight: class { run = async () => ({ ready: true, checks: [] }); }, quietPreflightExec: async () => "" }));
let home: string;
let errors: string[];
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), "daemon-start-wait-for-kernel-"));
vi.stubEnv("OPENRIG_HOME", home);
errors = [];
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { errors.push(args.join(" ")); });
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(home, { recursive: true, force: true }); process.exitCode = undefined; });
async function startWaiting(result: Partial<KernelReadyResult>) {
vi.mocked(waitForKernelReady).mockResolvedValue({ ok: false, kernelState: null, variant: null, detail: null, ...result });
await daemonCommand({} as never).parseAsync(["start", "--wait-for-kernel"], { from: "user" });
return errors.join("\n");
}
it("a kernel already down before this start: says so and how to bring it back, not a timeout or an auth fix", async () => {
const out = await startWaiting({ kernelState: "skipped",
detail: "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing" });
expect(out).toContain("Kernel auto-boot skipped: kernel rig already managed; it was already down before this daemon start");
expect(out).toContain("rig up kernel --existing");
expect(out).not.toContain("did not reach ready");
expect(out).not.toContain("auth status");
expect(process.exitCode).toBe(1);
});
it("a kernel skipped on purpose: names the opt-out, not a timeout or an auth fix", async () => {
const out = await startWaiting({ kernelState: "skipped", detail: "OPENRIG_NO_KERNEL=1" });
expect(out).toContain("Kernel auto-boot skipped (--no-kernel or test mode)");
expect(out).not.toContain("did not reach ready");
expect(out).not.toContain("auth status");
expect(process.exitCode).toBe(1);
});
it("a kernel still booting at the deadline keeps the timeout and its fix", async () => {
const out = await startWaiting({ kernelState: "booting" });
expect(out).toContain("Error: kernel did not reach ready / partial_ready within 60000ms.");
expect(out).toContain("Reason: kernel_state=booting");
expect(out).toContain("Fix: inspect `rig ps --rig kernel` for stalled agents");
expect(process.exitCode).toBe(1);
});
@@ -72,6 +72,14 @@ describe("rig start kernel wait evidence", () => {
expect(result.errors).toBe(""); expect(result.exitCode).toBe(0);
expect(result.received).toContain("/api/rigs/summary");
});
it("says a kernel that was already down before this daemon start stays down, and how to bring it back", async () => {
const result = await run("skipped", 200, 200, {}, false,
"kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing");
expect(result.logs).toContain("Kernel auto-boot skipped: kernel rig already managed; it was already down before this daemon start");
expect(result.logs).toContain("rig up kernel --existing");
expect(result.logs).not.toContain("--no-kernel or test mode");
expect(result.errors).toBe(""); expect(result.exitCode).toBe(0);
});
it("names an already-managed kernel and how to bring it back, instead of blaming --no-kernel", async () => {
const result = await run("skipped", 200, 200, {}, false, "kernel rig already managed");
expect(result.logs).not.toContain("--no-kernel or test mode");
+4 -1
View File
@@ -116,7 +116,10 @@ export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBo
return tracker;
}
log("info", `kernel-boot: kernel rig already managed (${managed.kind}); skipping builtin boot`);
tracker.setSkipped("kernel rig already managed");
// A stale kernel stays down on purpose; say why and how to bring it back (`rig start` prints it).
tracker.setSkipped(managed.kind === "stale"
? "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing"
: "kernel rig already managed");
if (managed.kind === "live") tracker.observeManagedKernel();
return tracker;
}
+2 -1
View File
@@ -108,9 +108,10 @@ export class Reconciler {
});
});
const persistedEvent = txn();
this.eventBus.notifySubscribers(persistedEvent);
// Detached once the transaction commits, even if notifying afterwards throws.
detached++;
detachedSessionIds.push(session.id);
this.eventBus.notifySubscribers(persistedEvent);
} catch (err) {
errors.push({
sessionId: session.id,
@@ -133,7 +133,11 @@ describe("daemon start with an existing kernel rig", () => {
const restore = vi.fn(async () => ({ errors: [] }));
const tracker = await h.daemonStart(restore);
expect(restore).not.toHaveBeenCalled();
expect(tracker.getStatus()).toMatchObject({ kernelState: "skipped", detail: "kernel rig already managed" });
// The person is told why it stayed down and how to bring it back.
expect(tracker.getStatus()).toMatchObject({
kernelState: "skipped",
detail: "kernel rig already managed; it was already down before this daemon start, so it was not restored. Bring it back with: rig up kernel --existing",
});
expect(classifyManagedKernel(h.rigRepo, h.sessionRegistry, await h.reconcile())).toEqual({ kind: "stale" });
expect(h.bootstrap.bootstrap).not.toHaveBeenCalled();
expect(h.statuses()).toEqual(["detached", "detached"]);
+17
View File
@@ -119,6 +119,23 @@ describe("Reconciler", () => {
expect(notifications[0]!.type).toBe("session.detached");
});
it("a session counts as detached once its transaction commits, even if notifying afterwards throws", async () => {
const { rig } = seedRigWithSessions([
{ logicalId: "dev1-impl", sessionName: "r01-dev1-impl", status: "running" },
]);
// The bus catches a subscriber's own error; this stands for a database error in its post-commit drain.
vi.spyOn(eventBus, "notifySubscribers").mockImplementation(() => { throw new Error("post-commit notify failed"); });
const reconciler = createReconciler(mockTmuxAdapter({ "r01-dev1-impl": false }));
const result = await reconciler.reconcile(rig.id);
const session = sessionRegistry.getSessionsForRig(rig.id)[0]!;
expect(session.status).toBe("detached");
expect(result.detached).toBe(1);
expect(result.detachedSessionIds).toEqual([session.id]);
expect(result.errors).toEqual([{ sessionId: session.id, error: "post-commit notify failed" }]);
});
it("no sessions in DB: no changes, no events", async () => {
const rig = rigRepo.createRig("r01");
const reconciler = createReconciler(mockTmuxAdapter({}));