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:
@@ -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