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
+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.