cli, seat, harness: a project clone in each role workspace (#1585)
Row 91, round 3. Darkwing built it, Dewey approved it (comment 28150) on manifest ca570607 (8 paths, base2b2a9e14). At each launch the host makes or updates a git clone of the project in the role's workspace, and the session prompt names each clone. Round 3 closes Filbert's round 2 escape: a clone with a submodule is kept as it is, and no host git recurses into one (decision 130 thread). Live at the next bus restart; the prompt lines appear only when the launcher passes checkouts. Sage gate onfc6d5822plus the patch, 14:18:25Z to 14:22:27Z: bus 115, business 60, cli 131, control-board 124, conversation 182, discord 178, harness 67, ledger 78, mosaic 69, queue 150, runs 43, seat 89, tasks 89, webui 104, all fail 0. Every scripts/test-*.sh exited 0; test-release ran 14 cases. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+11
-4
@@ -237,14 +237,20 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
|
||||
| its model names exactly one `launch.max` family | `unknown-model-family` |
|
||||
| that family has room; every running session counts, the PM's too | `capacity-full` |
|
||||
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
|
||||
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
|
||||
| the bundle builds and the runner starts | `launch-failed` |
|
||||
|
||||
Every refusal is an `action.refused` event (the broker's `refuse`, or its
|
||||
own refusal for the last broker check) and a line in the launch log. Then
|
||||
the launcher reads the instance's credential status from the broker
|
||||
(metadata only), builds the bundle in the run directory, spawns the runner
|
||||
in its bwrap sandbox (`packages/seat/README.md`, "The sandbox"), binds the
|
||||
outer bwrap process's pid and start time, adds the
|
||||
(metadata only) and makes sure the instance's workspace has a clone of each
|
||||
project in the business file (`packages/seat/README.md`, "The project
|
||||
clones"). It makes a missing clone and fast-forwards a clean one. A clone
|
||||
it leaves alone is a `checkout-kept` line in the launch log with the
|
||||
project and the reason, and the launch goes on. The bundle's prompt names
|
||||
each clone's path. Then the launcher builds the bundle in the run
|
||||
directory, spawns the runner in its bwrap sandbox (`packages/seat/README.md`,
|
||||
"The sandbox"), binds the outer bwrap process's pid and start time, adds the
|
||||
session to `sessions.json`, and writes the capability as one line on the
|
||||
runner's stdin. The capability is never on disk, in argv or in the
|
||||
environment. When the runner exits, the launcher sends `endLaunch` with the
|
||||
@@ -277,7 +283,8 @@ and, for Claude Code, `--version` from the binary sessions run:
|
||||
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
|
||||
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
|
||||
refusal and end; `workspaces/<business>/<instance>/` (0700) is kept across
|
||||
launches; `bus-host/sessions.json` (0600) lists the running sessions.
|
||||
launches and holds the instance's project clones; `bus-host/sessions.json`
|
||||
(0600) lists the running sessions.
|
||||
|
||||
When the host closes, the launcher stops taking requests and drops every
|
||||
open `launch.sock` connection, so a client that never ends its side can't
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
//
|
||||
// Steps 2 to 8 refuse through the broker's refuse(), so every refusal is an
|
||||
// action.refused event; step 9's refusals the broker records itself. Then
|
||||
// the host builds the bundle, spawns the runner in its sandbox
|
||||
// the host makes sure the workspace has a clone of each project
|
||||
// (packages/seat/src/checkout.mjs): one it can't make refuses
|
||||
// checkout-failed, and one it leaves alone is a checkout-kept record in the
|
||||
// launch log. Then it builds the bundle, spawns the runner in its sandbox
|
||||
// (packages/seat/src/sandbox.mjs) with the paths it may see, binds the run's
|
||||
// process identity (the outer bwrap pid) and writes the new capability on
|
||||
// the runner's stdin. When the runner exits, the host ends the launch.
|
||||
@@ -57,6 +60,7 @@ import {
|
||||
writeSessionFile,
|
||||
} from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { CheckoutError, ensureCheckouts } from "../../seat/src/checkout.mjs";
|
||||
import { cmdlineOf, startTimeOf } from "../../seat/src/proc.mjs";
|
||||
import { REPO, rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -169,9 +173,22 @@ export function createLauncher({
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, fam }) {
|
||||
const credentials = await host.op({ op: "credentialStatus", business: b.id, role: instance });
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
// The clones come before the run, so one that can't be made leaves no
|
||||
// run behind. The host's git treats each clone as hostile, and its
|
||||
// checks hold only while no session writes the workspace: a workspace
|
||||
// is one instance's, check() refused instance-running, and launches are
|
||||
// serialized, so none does.
|
||||
const workspace = workspaceDir(dataRoot, b.id, instance);
|
||||
mkdirSync(workspace, { recursive: true, mode: 0o700 });
|
||||
let checkouts;
|
||||
try {
|
||||
checkouts = await ensureCheckouts({ workspace, projects: b.projects, env });
|
||||
} catch (e) {
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
for (const c of checkouts) if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = join(runDir, "session");
|
||||
mkdirSync(sessionDir, { mode: 0o700 });
|
||||
const toolSocket = join(runDir, "tools.sock");
|
||||
@@ -184,7 +201,7 @@ export function createLauncher({
|
||||
arbiter: Object.values(b.arbiters).includes(instance),
|
||||
tracker,
|
||||
credentials,
|
||||
session: { run, launchedBy, workspace, toolSocket, turnMarker },
|
||||
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
|
||||
piVersion: v.pi,
|
||||
claudeVersion: v.claude,
|
||||
});
|
||||
|
||||
@@ -9,12 +9,13 @@ import { spawn, spawnSync } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { createInterface } from "node:readline";
|
||||
import { connect } from "node:net";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile } from "../../seat/src/session.mjs";
|
||||
import { launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { claudeVersion, createLauncher } from "../src/launcher.mjs";
|
||||
@@ -44,6 +45,19 @@ async function until(fn, ms = 15000) {
|
||||
throw new Error("timed out waiting");
|
||||
}
|
||||
|
||||
// git in a test repository, with nothing of the shell's git config.
|
||||
function gitIn(cwd, ...args) {
|
||||
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(dir, text) {
|
||||
writeFileSync(join(dir, "file.txt"), `${text}\n`);
|
||||
gitIn(dir, "add", "file.txt");
|
||||
gitIn(dir, "commit", "-q", "-m", text);
|
||||
return gitIn(dir, "rev-parse", "HEAD");
|
||||
}
|
||||
|
||||
// A config and data root with the fake adapter; setup() starts a host on it.
|
||||
// The session credential is a stand-in, and the fake adapter never uses it.
|
||||
// Every credential variable of the shell running the tests is dropped.
|
||||
@@ -53,6 +67,10 @@ function prepare(t, more = (doc) => doc) {
|
||||
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
|
||||
f.env.CLAUDE_CODE_OAUTH_TOKEN = "stand-in";
|
||||
mkdirSync(f.dataRoot, { mode: 0o700 });
|
||||
// The project root is a repository on main, so each launch can clone it.
|
||||
const project = f.doc.projects.stack.root;
|
||||
gitIn(project, "init", "-q", "-b", "main");
|
||||
commit(project, "one");
|
||||
const adapter = join(root, "adapter.sh");
|
||||
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
|
||||
return { root, f, adapter };
|
||||
@@ -244,6 +262,76 @@ test("the PM launches a coder through its launch tool; the coder answers; refusa
|
||||
assert.ok(readSessions(f.dataRoot).some((s) => s.run === pm.run));
|
||||
});
|
||||
|
||||
// Row 91 (#1585): the project clone in each workspace.
|
||||
test("each launch makes sure the workspace has a clone of the project: cloned, fast-forwarded, or kept and logged", { skip, timeout: 60000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const project = given.f.doc.projects.stack.root;
|
||||
const clone = join(workspaceDir(given.f.dataRoot, "acme", "pm"), "stack");
|
||||
const prompt = (run) => readFileSync(join(given.f.dataRoot, "launches", "acme", run, "bundle", "prompt.md"), "utf8");
|
||||
const kept = () => log(given.f).filter((e) => e.event === "checkout-kept");
|
||||
|
||||
// The first launch clones the root's branch, with the root as origin.
|
||||
const first = await setup(t, undefined, given);
|
||||
const one = gitIn(project, "rev-parse", "HEAD");
|
||||
const pm1 = await first.launcher.launchPm();
|
||||
assert.equal(gitIn(clone, "rev-parse", "HEAD"), one);
|
||||
assert.equal(gitIn(clone, "symbolic-ref", "--short", "HEAD"), "main");
|
||||
assert.equal(gitIn(clone, "config", "--get-all", "remote.origin.url"), project);
|
||||
assert.ok(prompt(pm1.run).includes(`- Project clones: stack at ${clone}, on branch main. `));
|
||||
|
||||
// While the PM runs, a second launch is refused before the host runs git
|
||||
// in its workspace: the root moved on, and the clone didn't.
|
||||
const two = commit(project, "two");
|
||||
await assert.rejects(first.launcher.launchPm(), /PM launch refused: instance-running/);
|
||||
assert.equal(gitIn(clone, "rev-parse", "HEAD"), one);
|
||||
await first.client.call("role.release"); // the test's own cto claim, so the next host's can claim again
|
||||
assert.equal(await first.close(), 0);
|
||||
|
||||
// The next launch fast-forwards the clean clone.
|
||||
const second = await setup(t, undefined, given);
|
||||
const pm2 = await second.launcher.launchPm();
|
||||
assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
|
||||
assert.ok(prompt(pm2.run).includes(`- Project clones: stack at ${clone}, on branch main. `));
|
||||
assert.deepEqual(kept(), []);
|
||||
await second.client.call("role.release");
|
||||
assert.equal(await second.close(), 0);
|
||||
|
||||
// A clone with the session's own work in it is kept, and the log says why.
|
||||
writeFileSync(join(clone, "notes.txt"), "mine\n");
|
||||
const three = commit(project, "three");
|
||||
const third = await setup(t, undefined, given);
|
||||
const pm3 = await third.launcher.launchPm();
|
||||
assert.equal(gitIn(clone, "rev-parse", "HEAD"), two);
|
||||
assert.equal(readFileSync(join(clone, "notes.txt"), "utf8"), "mine\n");
|
||||
assert.deepEqual(kept().map(({ instance, project: p, reason }) => ({ instance, project: p, reason })), [{ instance: "pm", project: "stack", reason: "dirty" }]);
|
||||
assert.ok(prompt(pm3.run).includes(`- Project clones: stack at ${clone}, not updated at this launch (dirty). `));
|
||||
assert.equal(await third.close(), 0);
|
||||
|
||||
// The root was never written: its branch, head and tree are the test's.
|
||||
assert.equal(gitIn(project, "rev-parse", "HEAD"), three);
|
||||
assert.equal(gitIn(project, "for-each-ref", "--format=%(refname)"), "refs/heads/main");
|
||||
assert.equal(gitIn(project, "status", "--porcelain"), "");
|
||||
});
|
||||
|
||||
test("a project clone that can't be made refuses checkout-failed, from --pm and from a session, with no run and no clone left", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
renameSync(join(given.f.doc.projects.stack.root, ".git"), join(given.root, "away.git"));
|
||||
const { f, launcher, host } = await setup(t, undefined, given);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: checkout-failed \(stack: the root .* can't be read: /.test(e.message));
|
||||
|
||||
// A pm capability bound to this process asks for the coder.
|
||||
const pm = await host.bindLaunch({ business: "acme", role: "pm", run: "pm-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
|
||||
await new Client({ path: host.path, cap: pm.cap }).call("role.claim");
|
||||
assert.deepEqual(await launcher.fromSession(pm.cap, "coder"), { ok: false, error: "checkout-failed" });
|
||||
|
||||
const entries = log(f);
|
||||
assert.deepEqual(entries.map((e) => [e.event, e.instance, e.code]), [["refused", "pm", "checkout-failed"], ["refused", "coder", "checkout-failed"]]);
|
||||
for (const e of entries) assert.match(e.why, /^stack: the root /);
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
assert.deepEqual(readdirSync(join(f.dataRoot, "launches")), ["acme.jsonl"], "no run was made");
|
||||
for (const instance of ["pm", "coder"]) assert.deepEqual(readdirSync(workspaceDir(f.dataRoot, "acme", instance)), [], `${instance}: no clone, no temp`);
|
||||
});
|
||||
|
||||
test("a runner that stops at once ends its launch with the runner's reason", { skip, timeout: 30000 }, async (t) => {
|
||||
// pm's tracker token has expired: the runner's founder-credential stop
|
||||
// exits 20 before role.claim, and the host records founder-credentials.
|
||||
|
||||
Reference in New Issue
Block a user