mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90a01c499a | ||
|
|
3a783b9c3d | ||
|
|
9bd5b957e1 |
No files matched your search
@@ -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" });
|
||||
});
|
||||
Reference in new issue
Block a user