feat(daemon,cli): kernel auto-boot path + --no-kernel flag + listDestinations operator-seat fallback

0.3.1 slice 05 kernel-rig-as-default — Phase 05d. Adds the daemon
startup hook that auto-boots the kernel rig on `rig daemon start`,
per IMPL-PRD §6.1 + amended §16.2. Forensic finding from the 05d
opener confirmed current daemon-restart already does NOT auto-restore
any rig (reconciler marks-stale-as-detached only; restoreOrchestrator
.restore() invoked only from explicit operator paths). HG-20
kernel-only-auto-restart is satisfied by-construction once the
kernel-boot path lands — no existing auto-restore-all to remove.

Founder OQ decisions captured per HG-18/HG-19:
- OQ-A = Option B (migration ceremony at 0.3.1 upgrade)
- OQ-B = L2 (rig daemon start owns kernel boot; rig setup invokes it)

Behavior:

- New module packages/daemon/src/domain/kernel-boot.ts exports
  bootKernelIfNeeded(deps): KernelBootResult with 6 outcomes
  (booted / skipped_no_kernel_flag / skipped_already_managed /
  auth_blocked / spec_missing / bootstrap_failed). Composed in
  startup.ts after BootstrapOrchestrator construction.
- Short-circuits:
  - OPENRIG_NO_KERNEL=1 → skipped_no_kernel_flag (CLI --no-kernel
    flag projected via daemon-lifecycle.ts buildDaemonEnv).
  - Existing rig named `kernel` in rigs table →
    skipped_already_managed (covers the OQ-A=B migration path where
    operator copied the substrate kernel into ~/.openrig/specs/rigs/
    before the V0.3.1 upgrade).
- Variant selection: probeRuntimes() (default shells out to
  `claude auth status` + `codex login status` with 5s timeout) →
  selectVariant() returns rig.yaml / rig-claude-only.yaml /
  rig-codex-only.yaml. Both-unavailable → auth_blocked with the
  3-part-error message (Error: / Reason: / Fix:) per the
  building-agent-software skill discipline (HG-3).
- Cold boot: BootstrapOrchestrator.bootstrap({mode:"apply",
  sourceRef: <variant-path>, sourceKind:"rig_spec",
  autoApprove:true}). Errors during bootstrap are logged but do NOT
  crash the daemon — the operator can retry manually.
- 3 rig variants now declare `runtime: terminal` on the
  operator.human builtin:terminal member (validation requires it).

CLI surface:

- `rig daemon start --no-kernel` skips kernel auto-boot. Commander's
  --no-kernel inverts to opts.kernel === false; thread through to
  StartOptions.skipKernelBoot → buildDaemonEnv emits
  OPENRIG_NO_KERNEL=1 in the daemon process env.

Mission-control fallback (HG-10):

- packages/daemon/src/routes/mission-control.ts listDestinations now
  accepts an optional operatorSeatFallback parameter. The GET
  /destinations route handler reads the configured operator seat
  via MissionControlReadLayer.getDefaultOperatorSession() (NEW
  public getter) and includes it in the destinations list even when
  the kernel hasn't booted yet — picker always offers the configured
  seat. Deduplicated: when the seat is already in topology or queue
  history, no duplicate emitted.

Tests (16 new):

- packages/daemon/test/kernel-boot.test.ts covers selectVariant
  (3 cases), kernelAlreadyManaged (3 cases), authBlockMessage
  3-part-error contract, bootKernelIfNeeded short-circuit branches
  (no-kernel-flag, already-managed, auth-blocked, spec-missing),
  bootKernelIfNeeded happy path with all 3 variants + bootstrap
  errors (orchestrator-reported + thrown).

Verification:

- npx tsc --noEmit on daemon + cli: CLEAN
- npx vitest run packages/daemon full suite: 3152 PASS / 1 skipped
  / 0 failures. No regressions in existing startup, mission-control,
  user-settings, or read-layer test surfaces.

SC-29 scope: no SQL migrations / new endpoints / new event types.
The new daemon-side code uses existing tables + existing route
handlers + existing event sinks. Workspace.operator_seat_name setting
extension lives in Phase 05b's existing allowlist plumbing. SC-29
EXCEPTION ledger unchanged.

Next phase per IMPL-PRD §11 + §16.5:
- 05e/05f — end-to-end disposable Tart VM lifecycle dogfood per
  HG-23 (consolidated v0 success criterion). Founder-flagged VM
  rehearsal of the OQ-A=B migration ceremony per the earlier
  reinforcement note. velocity-qa owns the sustained VM loop;
  driver hands off after this commit per established cadence.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-10 23:00:45 -07:00
1 parent 575f2b8ca0
commit 4bf1fce9d0
10 files changed
+488 -3

No files matched your search

+13 -1
View File
@@ -77,7 +77,13 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
.option("--port <port>", "Port to listen on")
.option("--host <host>", "Host to bind on")
.option("--db <path>", "Database path")
.action(async (opts: { port?: string; host?: string; db?: string }) => {
// V0.3.1 slice 05 kernel-rig-as-default — skip the kernel auto-boot
// path. Used by test fixtures, headless CI, and operators who want
// a no-kernel daemon for ad-hoc topology work. The daemon proceeds
// and serves its HTTP API normally; just doesn't materialize the
// kernel rig.
.option("--no-kernel", "Skip kernel auto-boot (daemon serves without the kernel rig)")
.action(async (opts: { port?: string; host?: string; db?: string; kernel?: boolean }) => {
try {
const { ConfigStore } = await import("../config-store.js");
const { SystemPreflight } = await import("../system-preflight.js");
@@ -105,6 +111,8 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
return;
}
// V0.3.1 slice 05 — Commander's --no-kernel inverts to opts.kernel === false.
const skipKernel = opts.kernel === false;
const state = await startDaemon(
{
port: effectivePort,
@@ -120,6 +128,10 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
// reach the rotation hook.
transcriptsLines: config.transcripts.lines,
transcriptsPollIntervalSeconds: config.transcripts.pollIntervalSeconds,
// V0.3.1 slice 05 kernel-rig-as-default — propagated via
// OPENRIG_NO_KERNEL env var so the daemon's kernel-boot
// check in startup.ts honors the flag.
skipKernelBoot: skipKernel,
},
getDeps(),
);
+12
View File
@@ -39,6 +39,11 @@ export interface StartOptions {
// picks up file-stored ConfigStore values, not just shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
// V0.3.1 slice 05 kernel-rig-as-default — when true, daemon startup
// skips the kernel auto-boot path (kernel rig is not materialized).
// Exposed at the CLI as `rig daemon start --no-kernel`; projected
// into the daemon process env as OPENRIG_NO_KERNEL.
skipKernelBoot?: boolean;
}
export interface LifecycleDeps {
@@ -251,6 +256,9 @@ export function buildDaemonEnv(
// shell env.
transcriptsLines?: number;
transcriptsPollIntervalSeconds?: number;
// V0.3.1 slice 05 — projected via OPENRIG_NO_KERNEL env var so
// the daemon's startup.ts kernel-boot path honors the flag.
skipKernelBoot?: boolean;
},
): Record<string, string> {
const env: Record<string, string> = {};
@@ -277,6 +285,9 @@ export function buildDaemonEnv(
if (opts.transcriptsPollIntervalSeconds !== undefined) {
env["OPENRIG_TRANSCRIPTS_POLL_INTERVAL_SECONDS"] = String(opts.transcriptsPollIntervalSeconds);
}
if (opts.skipKernelBoot) {
env["OPENRIG_NO_KERNEL"] = "1";
}
return env;
}
@@ -330,6 +341,7 @@ export async function startDaemon(opts: StartOptions, deps: LifecycleDeps): Prom
transcriptsPath: opts.transcriptsPath,
transcriptsLines: opts.transcriptsLines,
transcriptsPollIntervalSeconds: opts.transcriptsPollIntervalSeconds,
skipKernelBoot: opts.skipKernelBoot,
}),
stdio: ["ignore", logFd, logFd],
detached: true,
@@ -22,6 +22,7 @@ pods:
members:
- id: human
agent_ref: "builtin:terminal"
runtime: terminal
profile: default
cwd: "."
- id: agent
@@ -22,6 +22,7 @@ pods:
members:
- id: human
agent_ref: "builtin:terminal"
runtime: terminal
profile: default
cwd: "."
- id: agent
@@ -28,6 +28,7 @@ pods:
# startup actions. Real human interaction routes through the
# operator agent + the host shell.
agent_ref: "builtin:terminal"
runtime: terminal
profile: default
cwd: "."
- id: agent
+169
View File
@@ -0,0 +1,169 @@
// V0.3.1 slice 05 kernel-rig-as-default — kernel auto-boot path.
//
// Composes into rig daemon start per IMPL-PRD §6.1: probes runtime
// auth state, selects a variant (dual / claude-only / codex-only),
// and bootstraps the kernel rig via the in-process bootstrap pipeline.
// Short-circuits cleanly when:
// - OPENRIG_NO_KERNEL=1 is set (operator opt-out / test fixture)
// - A managed rig named `kernel` already exists in the DB
// Surfaces a 3-part-error (fact / reason / fix) when neither Claude
// Code nor Codex is authenticated. Failures during the bootstrap
// itself are logged but do NOT crash the daemon — the daemon serves
// without the kernel and the operator can retry manually.
import nodePath from "node:path";
import { existsSync } from "node:fs";
import type { RigRepository } from "./rig-repository.js";
import type { BootstrapOrchestrator } from "./bootstrap-orchestrator.js";
export type RuntimeAuthStatus = "ok" | "unavailable";
export interface RuntimeProbeResult {
claudeCode: RuntimeAuthStatus;
codex: RuntimeAuthStatus;
}
export interface KernelBootDeps {
rigRepo: RigRepository;
bootstrapOrchestrator: BootstrapOrchestrator;
/** Absolute path to the daemon's specs dir (passed in so tests can
* inject a fixture root). Production callsite resolves to the
* packaged `packages/daemon/specs/` location. */
specsDir: string;
/** Runtime auth probe — defaults to live shellouts; tests inject. */
probeRuntimes?: () => Promise<RuntimeProbeResult>;
/** Logger sink; defaults to console.log/warn so daemon stdout/stderr
* carry the boot trace. */
log?: (level: "info" | "warn" | "error", message: string) => void;
}
export interface KernelBootResult {
outcome:
| "booted"
| "skipped_no_kernel_flag"
| "skipped_already_managed"
| "auth_blocked"
| "spec_missing"
| "bootstrap_failed";
/** Variant filename used when outcome=booted; null otherwise. */
variant?: string | null;
/** Human-readable detail for logs + tests. */
detail?: string;
}
/** Entry point. Idempotent: safe to call on every daemon startup. */
export async function bootKernelIfNeeded(deps: KernelBootDeps): Promise<KernelBootResult> {
const log = deps.log ?? defaultLog;
// 1. --no-kernel opt-out (CLI flag projected via OPENRIG_NO_KERNEL).
if (process.env["OPENRIG_NO_KERNEL"] === "1") {
log("info", "kernel-boot: OPENRIG_NO_KERNEL=1 — skipping kernel auto-boot");
return { outcome: "skipped_no_kernel_flag" };
}
// 2. Already-managed short-circuit. If a rig named `kernel` exists
// (e.g., from a prior boot or operator-migrated substrate kernel),
// the builtin path stays out of the way.
if (kernelAlreadyManaged(deps.rigRepo)) {
log("info", "kernel-boot: kernel rig already managed; skipping builtin boot");
return { outcome: "skipped_already_managed" };
}
// 3. Probe runtime auth state to pick a variant.
const probe = await (deps.probeRuntimes ?? defaultProbeRuntimes)();
if (probe.claudeCode === "unavailable" && probe.codex === "unavailable") {
log("error", authBlockMessage());
return { outcome: "auth_blocked", detail: authBlockMessage() };
}
const variant = selectVariant(probe);
const specPath = nodePath.join(deps.specsDir, "rigs/launch/kernel", variant);
if (!existsSync(specPath)) {
log("warn", `kernel-boot: spec missing at ${specPath} — skipping`);
return { outcome: "spec_missing", detail: specPath };
}
// 4. Cold boot via the in-process bootstrap pipeline.
try {
log("info", `kernel-boot: booting kernel rig from ${variant}`);
const result = await deps.bootstrapOrchestrator.bootstrap({
mode: "apply",
sourceRef: specPath,
sourceKind: "rig_spec",
autoApprove: true,
});
if (result.errors && result.errors.length > 0) {
log("warn", `kernel-boot: bootstrap finished with errors: ${result.errors.join("; ")}`);
return { outcome: "bootstrap_failed", detail: result.errors.join("; ") };
}
return { outcome: "booted", variant };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log("warn", `kernel-boot: bootstrap threw: ${msg}`);
return { outcome: "bootstrap_failed", detail: msg };
}
}
/** True when a rig named `kernel` already exists in the DB. */
export function kernelAlreadyManaged(rigRepo: RigRepository): boolean {
const rigs = rigRepo.listRigs();
return rigs.some((r) => r.name === "kernel");
}
/** Choose a rig variant from the auth probe. */
export function selectVariant(probe: RuntimeProbeResult): string {
if (probe.claudeCode === "ok" && probe.codex === "ok") return "rig.yaml";
if (probe.claudeCode === "ok") return "rig-claude-only.yaml";
if (probe.codex === "ok") return "rig-codex-only.yaml";
// Caller is expected to short-circuit before reaching here on the
// both-unavailable path; defensive default keeps the type narrow.
return "rig.yaml";
}
/** Default auth probe — shells out to the runtime CLIs. */
async function defaultProbeRuntimes(): Promise<RuntimeProbeResult> {
const { exec } = await import("node:child_process");
const { promisify } = await import("node:util");
const execAsync = promisify(exec);
async function tryProbe(cmd: string): Promise<RuntimeAuthStatus> {
try {
const { stdout, stderr } = await execAsync(cmd, { timeout: 5000 });
const out = `${stdout}\n${stderr}`.toLowerCase();
// Conservative parse: any indicator the CLI considers itself
// unauthenticated marks the runtime unavailable. Tighter parsing
// can land in a follow-up.
if (out.includes("not logged") || out.includes("not authenticated") || out.includes("login required")) {
return "unavailable";
}
return "ok";
} catch {
return "unavailable";
}
}
const [claudeCode, codex] = await Promise.all([
tryProbe("claude auth status"),
tryProbe("codex login status"),
]);
return { claudeCode, codex };
}
/** Honest 3-part-error message for the auth-block path. Per IMPL-PRD
* §6.3 + the building-agent-software skill discipline. */
export function authBlockMessage(): string {
return [
"Error: Kernel rig cannot boot — no AI runtime is authenticated.",
"Reason: Kernel rig requires at least one of Claude Code or Codex authenticated. Both are unavailable.",
"Fix: Run `claude auth login` to authenticate Claude Code, OR `codex login` to authenticate Codex. Then run `rig daemon start` (or `rig setup`) again.",
].join("\n");
}
function defaultLog(level: "info" | "warn" | "error", message: string): void {
if (level === "error") console.error(message);
else if (level === "warn") console.warn(message);
else console.log(message);
}
@@ -135,6 +135,14 @@ export class MissionControlReadLayer {
this.defaultOperatorSession = deps.defaultOperatorSession ?? `operator-${userInfo().username}@kernel`;
}
/** V0.3.1 slice 05 — public getter for the resolved operator seat
* session. Route handlers (listDestinations, audit history) read
* this so the picker + audit filter always offer the configured
* operator seat even when the kernel rig isn't booted yet. */
getDefaultOperatorSession(): string {
return this.defaultOperatorSession;
}
async readView(
viewName: MissionControlViewName,
opts?: { operatorSession?: string },
+29 -2
View File
@@ -101,7 +101,10 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
return Boolean(row);
}
function listDestinations(db: Database.Database): MissionControlDestination[] {
function listDestinations(
db: Database.Database,
operatorSeatFallback?: string | null,
): MissionControlDestination[] {
const destinations = new Map<string, MissionControlDestination>();
const addDestination = (candidate: MissionControlDestination) => {
const sessionName = candidate.sessionName.trim();
@@ -184,6 +187,24 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
}
}
// V0.3.1 slice 05 — belt-and-suspenders for kernel-down state: if
// the configured operator seat isn't in the topology + queue
// history yet (fresh install, kernel hasn't booted, or kernel
// crashed), still include it so the picker can route to the
// seat the rest of the daemon's mission-control read layer will
// resolve. Source label intentionally stays "queue" so the picker
// sort order keeps live topology entries on top.
if (operatorSeatFallback) {
const trimmed = operatorSeatFallback.trim();
if (trimmed && !destinations.has(trimmed)) {
destinations.set(trimmed, {
sessionName: trimmed,
label: trimmed,
source: "queue",
});
}
}
return [...destinations.values()].sort((a, b) => {
if (a.source !== b.source) return a.source === "topology" ? -1 : 1;
return a.label.localeCompare(b.label);
@@ -233,10 +254,16 @@ export function missionControlRoutes(opts?: MissionControlRoutesOpts): Hono {
// GET /destinations — phone-friendly route/handoff candidates. MUST precede
// /views/:view-name catchall with the other Mission Control literal routes.
// V0.3.1 slice 05 — resolve the operator-seat fallback from the
// mission-control read layer's defaultOperatorSession (which itself
// tracks the workspace.operator_seat_name setting) so the picker
// always offers the configured operator seat even when the kernel
// hasn't booted yet.
app.get("/destinations", (c) => {
const db = getDb(c);
if (!db) return c.json({ destinations: [] });
return c.json({ destinations: listDestinations(db) });
const operatorSeat = getReadLayer(c).getDefaultOperatorSession();
return c.json({ destinations: listDestinations(db, operatorSeat) });
});
// SSE for mission_control.* events. MUST precede /views/:view-name
+27
View File
@@ -481,6 +481,33 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
serviceOrchestrator, rigRepo,
});
// V0.3.1 slice 05 kernel-rig-as-default — auto-boot the kernel rig
// on daemon-start. Idempotent: --no-kernel (OPENRIG_NO_KERNEL=1)
// skips; an already-managed `kernel` rig short-circuits; the
// variant (dual / claude-only / codex-only) is selected by probing
// `claude auth status` + `codex login status`; both-unavailable
// surfaces a 3-part-error and the daemon continues without the
// kernel. The kernel rig is the only rig the daemon auto-boots —
// other rigs require explicit operator-initiated `rig up` /
// `rig restore` (per amended IMPL-PRD §16.2).
try {
const { bootKernelIfNeeded } = await import("./domain/kernel-boot.js");
const kernelBootResult = await bootKernelIfNeeded({
rigRepo,
bootstrapOrchestrator,
specsDir: nodePath.resolve(nodePath.dirname(new URL(import.meta.url).pathname), "..", "specs"),
});
try {
// eslint-disable-next-line no-console
console.log(`kernel-boot: outcome=${kernelBootResult.outcome}${kernelBootResult.variant ? ` variant=${kernelBootResult.variant}` : ""}`);
} catch { /* logging must never throw */ }
} catch (err) {
try {
// eslint-disable-next-line no-console
console.warn(`kernel-boot: skipped due to error: ${err instanceof Error ? err.message : String(err)}`);
} catch { /* logging must never throw */ }
}
// Discovery services
const tmuxScanner = new TmuxDiscoveryScanner({ tmuxAdapter });
const sessionFingerprinter = new SessionFingerprinter({
+227
View File
@@ -0,0 +1,227 @@
// V0.3.1 slice 05 kernel-rig-as-default — kernel auto-boot tests.
//
// Covers HG-2 (variant selection), HG-3 (auth-block 3-part error),
// HG-4 (already-managed short-circuit), HG-6 (--no-kernel flag via
// OPENRIG_NO_KERNEL env). Lifecycle in production is bootstrap-driven
// (BootstrapOrchestrator.bootstrap()); these tests inject a mocked
// orchestrator + probe so the logic is exercised without invoking the
// in-process bootstrap pipeline.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
bootKernelIfNeeded,
selectVariant,
kernelAlreadyManaged,
authBlockMessage,
type KernelBootDeps,
type RuntimeProbeResult,
} from "../src/domain/kernel-boot.js";
import type { RigRepository } from "../src/domain/rig-repository.js";
import type { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
function makeRigRepo(existingRigs: Array<{ name: string }>): RigRepository {
return { listRigs: () => existingRigs } as unknown as RigRepository;
}
function makeBootstrapMock(result?: { errors?: string[]; throwError?: Error }) {
return {
bootstrap: vi.fn(async () => {
if (result?.throwError) throw result.throwError;
return {
runId: "test",
status: "ok",
stages: [],
errors: result?.errors ?? [],
warnings: [],
};
}),
} as unknown as BootstrapOrchestrator;
}
let tmpSpecsDir: string;
beforeEach(() => {
tmpSpecsDir = mkdtempSync(join(tmpdir(), "kernel-boot-"));
// Populate the 3 variant files so existsSync(specPath) succeeds.
const kernelDir = join(tmpSpecsDir, "rigs/launch/kernel");
mkdirSync(kernelDir, { recursive: true });
writeFileSync(join(kernelDir, "rig.yaml"), "name: kernel\n");
writeFileSync(join(kernelDir, "rig-claude-only.yaml"), "name: kernel\n");
writeFileSync(join(kernelDir, "rig-codex-only.yaml"), "name: kernel\n");
});
afterEach(() => {
delete process.env.OPENRIG_NO_KERNEL;
if (tmpSpecsDir) rmSync(tmpSpecsDir, { recursive: true, force: true });
});
describe("selectVariant — auth-state → variant mapping", () => {
it("picks rig.yaml when both runtimes available", () => {
expect(selectVariant({ claudeCode: "ok", codex: "ok" })).toBe("rig.yaml");
});
it("picks rig-claude-only.yaml when only Claude available", () => {
expect(selectVariant({ claudeCode: "ok", codex: "unavailable" })).toBe("rig-claude-only.yaml");
});
it("picks rig-codex-only.yaml when only Codex available", () => {
expect(selectVariant({ claudeCode: "unavailable", codex: "ok" })).toBe("rig-codex-only.yaml");
});
});
describe("kernelAlreadyManaged — short-circuit predicate", () => {
it("returns true when a rig named 'kernel' exists", () => {
expect(kernelAlreadyManaged(makeRigRepo([{ name: "kernel" }, { name: "other" }]))).toBe(true);
});
it("returns false when no rig is named 'kernel' (case-sensitive)", () => {
expect(kernelAlreadyManaged(makeRigRepo([{ name: "Kernel" }, { name: "other" }]))).toBe(false);
});
it("returns false on an empty rig list", () => {
expect(kernelAlreadyManaged(makeRigRepo([]))).toBe(false);
});
});
describe("authBlockMessage — 3-part error contract", () => {
it("includes Error/Reason/Fix lines per building-agent-software skill discipline", () => {
const msg = authBlockMessage();
expect(msg).toMatch(/^Error:/m);
expect(msg).toMatch(/^Reason:/m);
expect(msg).toMatch(/^Fix:/m);
expect(msg).toContain("claude auth login");
expect(msg).toContain("codex login");
});
});
describe("bootKernelIfNeeded — short-circuit branches", () => {
it("skips with outcome=skipped_no_kernel_flag when OPENRIG_NO_KERNEL=1", async () => {
process.env.OPENRIG_NO_KERNEL = "1";
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: makeBootstrapMock(),
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("skipped_no_kernel_flag");
expect((deps.bootstrapOrchestrator as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap).not.toHaveBeenCalled();
});
it("skips with outcome=skipped_already_managed when a kernel rig already exists", async () => {
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([{ name: "kernel" }]),
bootstrapOrchestrator: makeBootstrapMock(),
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("skipped_already_managed");
expect((deps.bootstrapOrchestrator as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap).not.toHaveBeenCalled();
});
it("returns auth_blocked when neither runtime is available", async () => {
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: makeBootstrapMock(),
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "unavailable", codex: "unavailable" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("auth_blocked");
expect(result.detail).toMatch(/^Error: Kernel rig cannot boot/);
expect((deps.bootstrapOrchestrator as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap).not.toHaveBeenCalled();
});
it("returns spec_missing when the chosen variant file doesn't exist", async () => {
rmSync(join(tmpSpecsDir, "rigs/launch/kernel/rig.yaml"));
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: makeBootstrapMock(),
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("spec_missing");
});
});
describe("bootKernelIfNeeded — happy path", () => {
it("invokes BootstrapOrchestrator.bootstrap with the dual variant when both runtimes available", async () => {
const bootstrap = makeBootstrapMock();
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: bootstrap,
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("booted");
expect(result.variant).toBe("rig.yaml");
const bootstrapMock = (bootstrap as unknown as { bootstrap: ReturnType<typeof vi.fn> }).bootstrap;
expect(bootstrapMock).toHaveBeenCalledOnce();
const opts = bootstrapMock.mock.calls[0]![0];
expect(opts.mode).toBe("apply");
expect(opts.sourceRef).toBe(join(tmpSpecsDir, "rigs/launch/kernel", "rig.yaml"));
expect(opts.sourceKind).toBe("rig_spec");
expect(opts.autoApprove).toBe(true);
});
it("returns bootstrap_failed and surfaces error detail when orchestrator reports errors", async () => {
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: makeBootstrapMock({ errors: ["preflight: tmux missing"] }),
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("bootstrap_failed");
expect(result.detail).toContain("tmux missing");
});
it("returns bootstrap_failed and surfaces thrown error message", async () => {
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: makeBootstrapMock({ throwError: new Error("network blip") }),
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("bootstrap_failed");
expect(result.detail).toBe("network blip");
});
it("uses the claude-only variant when only Claude is available", async () => {
const bootstrap = makeBootstrapMock();
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: bootstrap,
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "ok", codex: "unavailable" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("booted");
expect(result.variant).toBe("rig-claude-only.yaml");
});
it("uses the codex-only variant when only Codex is available", async () => {
const bootstrap = makeBootstrapMock();
const deps: KernelBootDeps = {
rigRepo: makeRigRepo([]),
bootstrapOrchestrator: bootstrap,
specsDir: tmpSpecsDir,
probeRuntimes: async () => ({ claudeCode: "unavailable", codex: "ok" }),
log: () => {},
};
const result = await bootKernelIfNeeded(deps);
expect(result.outcome).toBe("booted");
expect(result.variant).toBe("rig-codex-only.yaml");
});
});