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, base 2b2a9e14). 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 on fc6d5822 plus 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:
2026-10-13 09:23:10 -05:00
co-authored by Claude Opus 5.5
parent fc6d58220e
commit d240645df0
8 changed files with 782 additions and 10 deletions
+11 -4
View File
@@ -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
+20 -3
View File
@@ -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,
});
+90 -2
View File
@@ -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.