Compare commits

...
Author SHA1 Message Date
dev60-review 90a01c499a test(cli): a neutral fixture rig name in the never-started set-runtime cell 2026-11-02 05:28:22 +00:00
dev60-review 3a783b9c3d fix(daemon): set-runtime changes a materialized seat's runtime before its first start
A seat from rig import --materialize-only that has never started has no conversation, occupant or native history for
a switch to carry, and its first start already builds the seat from its saved node row. So set-runtime now changes
what it starts as and switches nothing: the node's runtime, with the source runtime's settings cleared as a switch
clears them (or set from the given flags), the account home, the native permission choice, the member in its launch
recipe that a restore's first start reads, and a snapshot. There's no successor or switch id, and the CLI says so.

A seat with history keeps today's refusal. A never-started seat that wasn't materialized takes its runtime from its
rig spec at first start, so its refusal now says to declare the other runtime there and create the seat again.
2026-11-02 05:19:18 +00:00
dev60-review 9bd5b957e1 test: a never-started seat's runtime, as the person sees it
Red at main:
- a materialized seat saved as Claude Code, never started, can't move to Codex: set-runtime refuses with "it has no
  bound tmux pane";
- a never-started seat that wasn't materialized gets that refusal with no word on what to do;
- the CLI has no output for a change that switched nothing.

A seat with history keeps today's refusal; that cell passes on main as the control.
2026-11-02 05:19:18 +00:00
4 changed files with 152 additions and 8 deletions

No files matched your search

+11
View File
@@ -1110,6 +1110,17 @@ A seat can switch itself (its CLI may end when it retires; the daemon finishes).
console.log(`${name} already runs ${String(data["to"])}. Nothing changed.`);
return;
}
if (data["firstStart"] === true) {
const from = String(data["from"]), to = String(data["to"]);
console.log(data["dryRun"]
? `${name} has never started: its saved runtime would change from ${from} to ${to}, and its first start would use it. Nothing changes until you run it without --dry-run.`
: `${name} has never started, so nothing was switched (no switch id): its saved runtime changed from ${from} to ${to}, and its first start uses it.`);
const permission = data["permission"] as { mode: string; source: string } | undefined;
if (permission) console.log(` permissions: ${permission.source === "explicit" ? `${permission.mode} (as given)` : "the seat's member and rig policy"}`);
const dropped = (data["dropped"] as Array<{ setting: string; value: string }> | undefined) ?? [];
if (dropped.length) console.log(`Cleared from ${from}: ${dropped.map((d) => `${d.setting} ${d.value}`).join("; ")}. Its first start takes the rest from its agent spec for ${to}.`);
return;
}
const label = (source: string) => source === "explicit" ? "as given" : source === "agent_spec" ? "from the seat's agent spec" : "the runtime's default";
const settings = (data["settings"] ?? {}) as Record<string, { value: string | null; source: string }>;
if (data["dryRun"]) console.log(`Runtime switch plan for ${name}: ${String(data["from"])} -> ${String(data["to"])}. Nothing changes until you run it without --dry-run.`);
@@ -189,4 +189,13 @@ describe("rig seat set-runtime", () => {
expect(errors.join("\n")).toMatch(message);
expect(exitCode).toBe(1);
});
it("a seat that has never started says nothing was switched, with no switch id, and that its first start uses the new runtime", async () => {
const { logs, exitCode } = await run({ status: 200, data: { ok: true, dryRun: false, changed: true, firstStart: true,
seat: { ref: "dev.build@team" }, from: "claude-code", to: "codex", permission: { mode: "inherit", source: "member_or_rig_policy" },
dropped: [{ setting: "model", value: "opus" }] } }, ["dev.build@team", "--runtime", "codex", "--reason", "start it on Codex"]);
expect(logs).toContain("dev.build@team has never started, so nothing was switched (no switch id): its saved runtime changed from claude-code to codex, and its first start uses it.");
expect(logs).toContain("Cleared from claude-code: model opus. Its first start takes the rest from its agent spec for codex.");
expect(logs.join("\n")).not.toMatch(/switch undefined|Queue:/);
expect(exitCode).toBeUndefined();
});
});
@@ -125,7 +125,14 @@ export type SetRuntimeResult =
/** The seat already runs that runtime and no settings were given: nothing to do. */
| ({ ok: true; dryRun: boolean; changed: false } & Pick<RuntimeSwitchPlan, "seat" | "from" | "to">)
| ({ ok: true; dryRun: false; changed: true; switchId: string; generation: string; previousGeneration: string;
claimsMoved: number; openRows: number; confirmation: { note: string; at: string }; recoveryPending: boolean } & RuntimeSwitchPlan);
claimsMoved: number; openRows: number; confirmation: { note: string; at: string }; recoveryPending: boolean } & RuntimeSwitchPlan)
/** A materialized seat that has never started: its saved runtime changed, and nothing was switched. */
| ({ ok: true; dryRun: boolean; changed: true; firstStart: true }
& Pick<RuntimeSwitchPlan, "seat" | "from" | "to" | "accountHome" | "accountToken" | "permission" | "dropped">);
/** A seat that has never started and wasn't materialized takes its runtime from the rig spec it was created from. */
const DECLARED_FIRST_START = "It has never started, so its first start takes the runtime its rig spec declares: declare the other runtime there "
+ "and create the seat again. A seat from rig import --materialize-only can change runtime before its first start.";
interface SwitchRow {
id: string; node_id: string; canonical_session: string; staging_session: string;
@@ -245,14 +252,18 @@ export class SeatRuntimeSwitchService {
}
const predecessorGeneration = sessionRegistry.currentOccupantTenure(node.id)?.generationUuid;
const running = sessionRegistry.getSessionsForRig(node.rigId).filter((session) => session.nodeId === node.id && session.status === "running");
const unsupported = !sessionName || !binding?.tmuxPane ? "it has no bound tmux pane"
// A seat that has never started has no conversation, occupant or native history for a switch to carry.
const firstStart = this.neverStarted(node.id, node.rigId);
const unsupported = firstStart === "materialized" ? null
: !sessionName || !binding?.tmuxPane ? "it has no bound tmux pane"
: binding.attachmentType && binding.attachmentType !== "tmux" ? `it's attached through ${binding.attachmentType}, not tmux`
: !predecessorGeneration ? "its current occupant generation isn't recorded"
: running.length !== 1 || running[0]!.sessionName !== sessionName ? "it doesn't have exactly one running session at its address"
: !node.cwd || !nodePath.isAbsolute(node.cwd) ? "its working directory isn't an absolute path"
: null;
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`);
try {
if (unsupported) return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: ${unsupported}.`,
firstStart === "declared" ? DECLARED_FIRST_START : undefined);
if (firstStart !== "materialized") try {
const [panes, windows] = await Promise.all([tmux.listPanes(sessionName!), tmux.listWindows(sessionName!)]);
if (windows.length !== 1 || panes.length !== 1 || panes[0]!.id !== binding!.tmuxPane) {
return refuse("runtime_switch_unsupported", `${seatRef} can't switch runtime: only a single-window, single-pane seat can, and its session has ${windows.length} window(s) and ${panes.length} pane(s).`);
@@ -277,6 +288,9 @@ export class SeatRuntimeSwitchService {
: validateNativePermissionSelection(target, mode);
}
} catch (error) { return refuse("invalid_runtime_settings", (error as Error).message); }
if (firstStart === "materialized") {
return this.saveFirstStartRuntime({ input: { ...input, reason }, node, seatRef, target, explicit, selection, sourceSelection, base, refuse });
}
let materials: RuntimeSwitchMaterials;
try {
@@ -327,6 +341,71 @@ export class SeatRuntimeSwitchService {
}
/** Stage and confirm the successor before commit; failed preparation retains the predecessor and records cleanup needs. */
/** "materialized": a seat from rig import --materialize-only that has never started, exactly as its first start checks
* it (no binding, session, occupant or saved startup context). "declared": one that has never started but has no
* materialized source. null: a seat with history. */
private neverStarted(nodeId: string, rigId: string): "materialized" | "declared" | null {
const { db, rigRepo, sessionRegistry } = this.deps;
if (sessionRegistry.getBindingForNode(nodeId) || sessionRegistry.currentOccupantTenure(nodeId)
|| sessionRegistry.getSessionsForRig(rigId).some((session) => session.nodeId === nodeId)
|| db.prepare("SELECT 1 FROM node_startup_context WHERE node_id = ?").get(nodeId)) return null;
return rigRepo.getRigStartupLayers(rigId)?.materializedSources?.some((entry) => entry.nodeIds.includes(nodeId)) ? "materialized" : "declared";
}
/** A materialized seat that has never started: nothing runs, so nothing is switched (no successor, switch record or
* switch id). Its saved runtime changes, and the source runtime's settings are cleared as a switch clears them; its
* first start resolves the rest for the new runtime. The node row, and the member in its launch recipe that a
* restore's first start reads, both change, with a snapshot, so every way of starting it agrees. */
private saveFirstStartRuntime(ctx: {
input: SetRuntimeInput & { reason: string }; node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number]; seatRef: string;
target: SwitchRuntime; explicit: { model?: string; effort?: string; codexConfigProfile?: string };
selection: NativePermissionSelection | null; sourceSelection: { runtime: string; mode: string } | null;
base: Pick<RuntimeSwitchPlan, "seat" | "from" | "to" | "accountHome">; refuse: (code: string, message: string) => RuntimeSwitchRefusal;
}): SetRuntimeResult {
const { db, rigRepo, sessionRegistry, eventBus } = this.deps;
const { input, node, target, explicit, selection } = ctx;
const dropped = [...(["model", "effort", "advisor", "codexConfigProfile"] as const)
.filter((key) => node[key] && !(key !== "advisor" && explicit[key]))
.map((key) => ({ setting: key === "codexConfigProfile" ? "Codex profile" : key, value: node[key]! })),
...(ctx.sourceSelection ? [{ setting: "native permission choice", value: `${ctx.sourceSelection.runtime}:${ctx.sourceSelection.mode}` }] : [])];
const result = (dryRun: boolean): SetRuntimeResult => ({ ok: true, dryRun, changed: true, firstStart: true, ...ctx.base,
...(input.accountToken ? { accountToken: input.accountToken } : {}),
permission: selection ? { mode: selection.mode, source: "explicit" } : { mode: "inherit", source: "member_or_rig_policy" }, dropped });
if (input.dryRun) return result(true);
const notifications: PersistedEvent[] = [];
try {
db.transaction(() => {
if (this.neverStarted(node.id, node.rigId) !== "materialized"
|| (db.prepare("SELECT runtime FROM nodes WHERE id = ?").get(node.id) as { runtime: string } | undefined)?.runtime !== node.runtime) {
throw new Error("it started or changed meanwhile");
}
db.prepare("UPDATE nodes SET runtime = ?, model = ?, effort = ?, effort_choice = NULL, advisor = NULL, codex_config_profile = ? WHERE id = ?")
.run(target, explicit.model ?? null, explicit.effort ?? null, target === "codex" ? explicit.codexConfigProfile ?? null : null, node.id);
db.prepare("UPDATE nodes SET account_home = ?, claude_token_ref = CASE WHEN ? THEN ? ELSE claude_token_ref END WHERE id = ?")
.run(input.accountHome ?? null, input.accountHome !== undefined || node.accountHome ? 1 : 0,
input.accountToken ? JSON.stringify(input.accountToken) : null, node.id);
new NativePermissionStore(db).write(node.id, selection, input.requestedBy?.trim() || ctx.seatRef, input.reason);
if (target === "codex" && selection) clearCodexMemberPermissions(db, node.id);
// A restore's first start builds the member from this recipe, not the node row.
const recipe = rigRepo.getNodeLaunchRecipe(node.id);
const dot = node.logicalId.indexOf(".");
const member = recipe?.rigSpec.pods.find((pod) => pod.id === node.logicalId.slice(0, dot))?.members
.find((candidate) => candidate.id === node.logicalId.slice(dot + 1));
if (recipe && member) {
member.runtime = target;
delete member.model; delete member.effort; delete member.advisor; delete member.codexConfigProfile;
db.prepare("UPDATE nodes SET launch_recipe_json = ? WHERE id = ?").run(JSON.stringify(recipe), node.id);
}
new SnapshotCapture({ db, rigRepo, sessionRegistry, eventBus, snapshotRepo: new SnapshotRepository(db), checkpointStore: new CheckpointStore(db) })
.captureSnapshot(node.rigId, "runtime-switch", { deferNotify: (persisted) => notifications.push(persisted) });
})();
} catch (error) {
return ctx.refuse("runtime_switch_unsupported", `${ctx.seatRef} can't change runtime: ${(error as Error).message}. Nothing changed.`);
}
for (const event of notifications) eventBus.notifySubscribers(event);
return result(false);
}
private async execute(ctx: {
input: SetRuntimeInput & { reason: string }; node: NonNullable<ReturnType<RigRepository["getRig"]>>["nodes"][number]; seatRef: string; sessionName: string;
predecessorPane: string; predecessorGeneration: string; target: SwitchRuntime; materials: RuntimeSwitchMaterials;
@@ -11,13 +11,15 @@ async function fixture(captureSnapshot = true, memberPatch: Record<string, unkno
[`${root}/agent/agent.yaml`]: 'name: worker\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []\nstartup:\n files:\n - path: role.md\n required: true\n delivery_hint: send_text\n',
[`${root}/agent/role.md`]: "Read your role and work with the team.",
};
const adapter: RuntimeAdapter = { runtime: "pi", listInstalled: vi.fn(async () => []),
const runtimeAdapter = (runtime: string): RuntimeAdapter => ({ runtime, listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 1, failed: [] })),
launchHarness: vi.fn(async () => ({ ok: true })), checkReady: vi.fn(async () => ({ ready: true })),
};
});
const adapter = runtimeAdapter("pi");
const adapters = { pi: adapter, "claude-code": runtimeAdapter("claude-code"), codex: runtimeAdapter("codex") };
const tmux = mockTmuxAdapter(); tmux.probeSession = vi.fn(async () => ({ state: "absent" as const }));
const options = { tmux, adapters: { pi: adapter }, podInstantiatorFsOps: {
const options = { tmux, adapters, podInstantiatorFsOps: {
exists: (path: string) => path in files, readFile: (path: string) => { if (!(path in files)) throw new Error(`Missing ${path}`); return files[path]!; },
} };
const app = createTestApp(db, options);
@@ -29,7 +31,7 @@ async function fixture(captureSnapshot = true, memberPatch: Record<string, unkno
if (!materialized.ok) throw new Error("fixture materialization failed");
const rigId = materialized.result.rigId;
if (captureSnapshot) app.snapshotCapture.captureSnapshot(rigId, "manual");
return { ...app, db, adapter, tmux, files, rigId, reopen: () => createTestApp(db, options) };
return { ...app, db, adapter, adapters, tmux, files, rigId, reopen: () => createTestApp(db, options) };
}
it.each([[false, true], [true, true], [true, false]])("first-starts only the selected seat with authored startup and four records intact (reopened=%s, snapshot=%s)", async (reopened, snapshot) => {
const f = await fixture(snapshot);
@@ -148,3 +150,46 @@ it.each(["policy", "history", "rig name"])("does not launch a stale %s after an
expect(await f.podInstantiator.launchFirstMaterializedNode(f.rigId, node.id)).toMatchObject({ ok: false, code: "selection_changed" });
expect(f.adapter.launchHarness).not.toHaveBeenCalled();
});
// A seat saved as Claude Code and never started moves to Codex before its first start, keeping its address and edges.
async function setRuntime(f: Awaited<ReturnType<typeof fixture>>, seat: string, body: Record<string, unknown> = {}) {
const response = await f.app.request(`/api/seat/set-runtime/${encodeURIComponent(seat)}`, { method: "POST",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runtime: "codex", reason: "start it on Codex", ...body }) });
return { status: response.status, body: await response.json() as Record<string, any> };
}
it("moves a materialized seat that has never started to the other runtime, and its first start uses it", async () => {
const f = await fixture(true, { runtime: "claude-code", model: "opus" });
const node = () => f.rigRepo.getRig(f.rigId)!.nodes.find(node => node.logicalId === "dev.build")!;
const before = node();
const plan = await setRuntime(f, "dev-build@materialized", { dryRun: true });
expect(plan.body).toMatchObject({ ok: true, dryRun: true, changed: true, firstStart: true, from: "claude-code", to: "codex" });
expect(node().runtime).toBe("claude-code");
const saved = await setRuntime(f, "dev-build@materialized");
expect(saved, JSON.stringify(saved.body)).toMatchObject({ status: 200, body: { ok: true, dryRun: false, changed: true, firstStart: true,
from: "claude-code", to: "codex", dropped: [{ setting: "model", value: "opus" }] } });
expect(saved.body.switchId).toBeUndefined();
expect(node()).toMatchObject({ id: before.id, logicalId: "dev.build", runtime: "codex", model: null });
expect(f.sessionRegistry.getSessionsForRig(f.rigId)).toEqual([]);
// A restore's first start reads the launch recipe and the newest snapshot: both say Codex now.
const recipe = f.rigRepo.getNodeLaunchRecipe(before.id)!.rigSpec.pods[0]!.members.find(member => member.id === "build")!;
expect(recipe).toMatchObject({ runtime: "codex" }); expect(recipe.model).toBeUndefined();
expect(f.snapshotRepo.getLatestSnapshot(f.rigId)!.data.nodes.find((entry: { id: string }) => entry.id === before.id)).toMatchObject({ runtime: "codex" });
const launched = await f.app.request(`/api/rigs/${f.rigId}/nodes/dev.build/launch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
expect(launched.status, await launched.text()).toBe(201);
expect(f.adapters.codex.launchHarness).toHaveBeenCalledTimes(1);
expect(f.adapters["claude-code"].launchHarness).not.toHaveBeenCalled();
});
it("keeps today's refusal for a seat with history, and names the path for one that was never materialized", async () => {
const f = await fixture(true, { runtime: "claude-code" });
const build = f.rigRepo.getRig(f.rigId)!.nodes.find(node => node.logicalId === "dev.build")!;
f.sessionRegistry.registerSession(build.id, "dev-build@materialized");
const history = await setRuntime(f, "dev-build@materialized");
expect(history.body).toMatchObject({ ok: false, code: "runtime_switch_unsupported", message: "dev.build@materialized can't switch runtime: it has no bound tmux pane." });
expect(history.body.guidance).toBeUndefined();
const declared = f.rigRepo.createRig("declared"), seat = f.rigRepo.addNode(declared.id, "dev.build", { runtime: "claude-code", cwd: root });
const refusal = await setRuntime(f, "dev.build@declared");
expect(refusal.body).toMatchObject({ ok: false, code: "runtime_switch_unsupported",
guidance: expect.stringContaining("declare the other runtime there and create the seat again") });
expect(f.rigRepo.getRig(declared.id)!.nodes.find(node => node.id === seat.id)).toMatchObject({ runtime: "claude-code" });
expect(f.rigRepo.getRig(f.rigId)!.nodes.find(node => node.id === build.id)).toMatchObject({ runtime: "claude-code" });
});