Files
stack/agents/darkwing/work/queue-104/r1/build.md
T
jason.woltjeandClaude Opus 5.5 b2ae135524 docs: Darkwing row 104 round 1 packet, seats commit under their own name (#1598)
Candidate against 817ddfea6: git identity in the sandbox environment,
commit field in checkout results, one prompt line. Gate green, 19 of 19
mutants killed. The candidate itself is not committed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 17:17:53 -05:00

12 KiB
Raw Blame History

Row 104 (#1598) round 1: seats commit under their own name

Brief: docs/plans/2026-10-13_retest-faults.md, section "Seats commit under their own name" (last changed in 18c29c28d; blob 523e9e07 at BASE). BASE: 817ddfea65. The gate and the mutants ran on it. Canonical HEAD was ae4ae7102 when I wrote this packet; git diff 817ddfea6 ae4ae7102 on the manifest paths is empty. Candidate: build.patch against BASE. candidate-manifest.sha256 lists the 11 files, and applying the patch to a fresh worktree at BASE reproduces every digest in it.

Files beyond the brief's list

Sage approved these in their row 104 decision. Dewey, please review them with the rest:

  • packages/seat/src/checkout.mjs and packages/seat/tests/checkout.test.mjs. They add the commit field the prompt line needs. allowed() is byte-identical. A kept clone gets no commit field and no extra git run.
  • packages/cli/src/launcher.mjs and packages/seat/src/session.mjs. These are "whichever launch file passes it the role and business". The launcher passes business: b.id, role: instance to spawnSession, which passes both to sandboxArgv.
  • packages/seat/tests/session.test.mjs and packages/cli/tests/launcher.test.mjs, their tests.

Deviation from the brief

The brief says the name is the role. The name is the role instance's id, and the email is <instance>@<business>.mosaic.invalid. That way, two instances of one role commit as different authors. Sage accepted this in their row 104 decision. The README line says so too.

What changed

  • packages/seat/src/sandbox.mjs.
    • New gitIdentity({ business, role }). Both values must match ID_PATTERN (packages/business/src/vocabulary.mjs); anything else throws git identity: <what> <value> isn't an id. It returns GIT_AUTHOR_NAME and GIT_COMMITTER_NAME set to the instance, and GIT_AUTHOR_EMAIL and GIT_COMMITTER_EMAIL set to <instance>@<business>.mosaic.invalid. .invalid is a reserved domain that can't receive mail.
    • sandboxArgv now takes a required { business, role, bwrap } and adds the four values as --setenv pairs after HOME and TMPDIR. With no ids it throws. There is no session without an identity.
    • The header comment names the identity.
  • packages/seat/src/session.mjs. spawnSession takes business and role and passes them on. The comment says why.
  • packages/cli/src/launcher.mjs:251. Passes business: b.id, role: instance.
  • packages/seat/src/checkout.mjs.
    • clone() reads the new clone's commit with rev-parse --verify HEAD before the rename. It returns { outcome: "cloned", branch, commit }. If that read fails, it refuses with CheckoutError the new clone of <root> has no readable HEAD: ... and removes the temp directory, like a failed clone.
    • update() reads HEAD after the fast-forward. It returns commit for updated and current, or null when the read fails.
    • Kept results are unchanged. allowed() is unchanged.
    • The ensureCheckouts comment lists the new fields.
  • packages/harness/src/bundle.mjs, checkoutLines, still one prompt line. For each clone that isn't kept, it gives the branch and either commit <sha> or "a commit the host couldn't read", at session start. Then it says:
    • the host fast-forwards a clean clone only at a launch, never during a session;
    • don't set git config in a clone, because any key that git clone didn't write, user.name and user.email included, makes every later launch keep it;
    • commits already carry the instance's name and address from the environment.
  • packages/seat/README.md.
    • In the sandbox section: where the identity comes from, the deviation, and why it isn't in the clone's config.
    • In the clones section: the branch and commit in each result, null when unreadable, none for kept, and what the prompt says.

Tests

Brief item Test
the sandbox sets the four variables sandbox.test: "the git identity is the instance's, at @.mosaic.invalid, and sandboxArgv refuses without it". It checks gitIdentity's output, refuses undefined, "", "Acme", "coder\nx", "coder>" and 7, and checks that the --setenv pairs before -- are exactly HOME, TMPDIR and the four. sandboxArgv with {} throws.
a commit in a sandboxed clone with no config reads the author back sandbox.test checks() row "commit in a fresh clone as the instance", run by a shell inside the sandbox. It runs git init, clone, add and commit with no user.* key in the clone's config (asserted), then git log -1 gives coder <[email protected]> for both author and committer.
the launch side passes the instance and business launcher.test, "the PM launches a coder...". The GIT_* entries in /proc/<runnerPid>/environ are exactly the four, for pm and for coder. No host GIT_* gets in.
spawnSession passes them session.test's spawnSession call, which refuses without them (mutant m16).
commit in results checkout.test: cloned, updated, current, ahead-of-root and the multi-project test each check commit against the right HEAD. New: "a commit git can't read after the fast-forward is null" and "a new clone whose commit can't be read refuses the launch and leaves nothing behind".
a kept clone gets nothing more checkout.test, new: "a clone with user.name or user.email in its config is kept, and git runs nothing in it" (subtests user.name and user.email). A git on PATH logs every call. The only call naming the clone is unsafe()'s config --file <clone>/.git/config --list --null, which reads the file and runs nothing in the repo. The existing kept deepEquals still have no commit.
the prompt line bundle.test, "the prompt names the project clones...". It checks the whole line for an updated clone with a commit, a kept one and a current one with commit null. launcher.test checks the live prompt for on branch main at commit <sha> when this session started.

Mutants

mutants.py in the packet: 19 mutants, each one text substitution that must match exactly once. It runs the named test files, each in its own process group (killed after 600 s; none reached it). A mutant counts as killed only if every named file fails. Each file is restored from its .orig, and the candidate's diff hash was the same before and after (b36d6bbff68e42f9). The run went from 22:10:20 to 22:15:08 UTC on BASE. No kill came from a SyntaxError or another load error; each ✖ below is the test that failed.

m01 sandboxArgv drops the identity setenv: killed  sandbox fail 3, launcher fail 1 (git identity unit test; confined shell checks)
m02 gitIdentity checks nothing: killed  sandbox fail 1 (git identity unit test)
m03 gitIdentity checks the type only: killed  sandbox fail 1 (git identity unit test)
m04 the email drops the business: killed  sandbox fail 3, launcher fail 1
m05 clone() returns no commit: killed  checkout fail 2 (first launch clones...; every project...)
m06 clone() skips the HEAD check: killed  checkout fail 1 (a new clone whose commit can't be read...)
m07 update() returns no commit: killed  checkout fail 3 (fast-forwards a clean clone...; a clone ahead of the root...)
m08 update() has no null fallback: killed  checkout fail 1 (a commit git can't read after the fast-forward is null)
m09 a kept clone gets a commit field: killed  checkout fail 34
m10 a kept clone gets a git run: killed  checkout fail 3 (kept, and git runs nothing in it: user.name, user.email)
m11 the prompt drops the commit: killed  bundle fail 1, launcher fail 1
m12 the prompt has no unreadable-commit text: killed  bundle fail 1
m13 the prompt drops the branch: killed  bundle fail 1, launcher fail 1
m14 the prompt drops the config warning: killed  bundle fail 1
m15 the prompt drops the launch-only refresh: killed  bundle fail 1
m16 spawnSession passes no identity: killed  session fail 1, launcher fail 8
m17 spawnSession fixes the role: killed  launcher fail 1 (the PM launches a coder...)
m18 the launcher passes the definition role: killed  launcher fail 1
m19 the launcher passes another business: killed  launcher fail 1

m17 lists only launcher.test. session.test launches a coder, so a role fixed at "coder" passes it, and the launcher's pm check is the one that catches it.

Gate

gate.sh in the packet runs node --test tests/*.test.mjs in every package with a tests/ directory, ledger included, then every scripts/test-*.sh. They run one after another, each teed to its own log, and the script stops at the first failure. TMPDIR was in my scratch. DOCKER_HOST was unset for every suite, test-task included, with Docker 29.7.2 up. The run started at 22:04:39 UTC on BASE:

pkg-bus rc=0 22:04:39-22:04:43 ℹ pass 128 ℹ fail 0 ℹ skipped 0
pkg-business rc=0 22:04:43-22:04:44 ℹ pass 67 ℹ fail 0 ℹ skipped 0
pkg-cli rc=0 22:04:44-22:05:03 ℹ pass 165 ℹ fail 0 ℹ skipped 0
pkg-control-board rc=0 22:05:03-22:05:04 ℹ pass 124 ℹ fail 0 ℹ skipped 0
pkg-conversation rc=0 22:05:04-22:05:52 ℹ pass 182 ℹ fail 0 ℹ skipped 0
pkg-discord rc=0 22:05:52-22:05:54 ℹ pass 178 ℹ fail 0 ℹ skipped 0
pkg-harness rc=0 22:05:54-22:06:05 ℹ pass 73 ℹ fail 0 ℹ skipped 0
pkg-ledger rc=0 22:06:05-22:06:24 ℹ pass 78 ℹ fail 0 ℹ skipped 0
pkg-mosaic rc=0 22:06:24-22:06:26 ℹ pass 69 ℹ fail 0 ℹ skipped 0
pkg-queue rc=0 22:06:26-22:06:54 ℹ pass 155 ℹ fail 0 ℹ skipped 0
pkg-runs rc=0 22:06:54-22:06:55 ℹ pass 43 ℹ fail 0 ℹ skipped 0
pkg-seat rc=0 22:06:55-22:07:00 ℹ pass 119 ℹ fail 0 ℹ skipped 0
pkg-tasks rc=0 22:07:00-22:07:05 ℹ pass 92 ℹ fail 0 ℹ skipped 0
pkg-webui rc=0 22:07:05-22:08:00 ℹ pass 108 ℹ fail 0 ℹ skipped 0
script-auth rc=0 22:08:00-22:08:01 selftest: 15 passed, 0 failed
script-conductor rc=0 22:08:01-22:08:07 selftest: 17 passed, 0 failed
script-config rc=0 22:08:07-22:08:08 selftest: 24 passed, 0 failed
script-discord rc=0 22:08:08-22:08:15 discord suite: 66 passed, 0 failed
script-extension-package rc=0 22:08:15-22:08:16 extension package selftest: 18 passed, 0 failed
script-foundation rc=0 22:08:16-22:08:52 selftest: 44 passed, 0 failed
script-queue rc=0 22:08:52-22:09:22 queue suite: 28 passed, 0 failed
script-release rc=0 22:09:22-22:09:31 selftest: 14 passed, 0 failed
script-task rc=0 22:09:31-22:10:02 selftest: 98 passed, 0 failed
GATE_PASSED 22:10:02

cli 165 and task 98 match Sage's last gate. The seat suite has 113 tests at BASE (run at 22:17 UTC in a fresh worktree) and 119 here. The 6 new ones are the identity unit test, the kept-clone test with its user.name and user.email subtests, the null commit test and the unreadable-clone test. harness stays at 73, because the bundle change edits an existing test. Afterwards I removed the empty wt_default network (created at 22:09:22 UTC, during test-release), and no process of mine was left.

Choices and limits

  • A fresh clone with no readable HEAD refuses the launch, but an existing clone whose HEAD can't be read after the fast-forward reports commit: null. A new clone that git can't read is broken, and its temp directory can still go. An existing clone is already in place, and its fetch and merge succeeded, so a failed read shouldn't stop the launch. The prompt then says "a commit the host couldn't read".
  • The environment wins over config for authorship. A session that sets user.name in its clone still commits as its instance. The config key would still make later launches keep the clone, so the prompt says not to.
  • What the tests prove. The runner's own environ carries the identity (launcher.test), and a shell inside the sandbox commits under it (sandbox.test). runner.mjs adapterEnv spreads the session env into each turn's adapter. No test runs a live harness's Bash tool, so a harness that scrubbed GIT_* from its shells would get past these tests. Neither pi nor Claude Code does that today, as far as I know, but I haven't run one to check.
  • sessionEnv's allowlist already keeps host GIT_* out. launcher.test asserts that the four are the only GIT_* in the runner's environ.
  • Out of scope, as the brief says: allowed(), signing, and the coder's existing clone. That clone still holds user.name and user.email, so it stays config-changed until Sage unsets them with Mos's go.

Going live (decision 109)

The seat, harness and CLI changes reach the live bus only through a new pinned copy and re-pin under Mos's go. The launch side (launcher.mjs, session.mjs, sandbox.mjs) goes live only at a bus restart that Mos approves. Mos gets a notice before and after the landing. The live bus, Console and CLI run from the pinned copy at 465bbdfbb.