Round 1's patch on the new base, no change of its own (decision 127). Manifest 6b58085b, 8 paths; only the two bundle files' digests move, by row 90's lines. checkout.mjs stays out of RUNNER_FILES: it runs on the host. Gate 00:07:08Z to 00:10:09Z, seat 83, cli 125, bus 111, harness 67, tasks 89, every scripts/test-*.sh rc=0 with Docker off. Co-Authored-By: Claude Opus 5.5 <[email protected]>
13 KiB
Row 91, a project clone in each role workspace, build (Darkwing)
Issue #1585. Brief: docs/plans/2026-10-12_pm-task-flow.md, "A project
checkout in each role workspace", with Sage's decision 126 (brief blob
844832c3). Reviewer: Filbert.
Round 2
Round 2 is round 1 rebased onto 6b0215aa, after row 90 landed
(e918a915), with no change of its own. Sage ruled it (decision 127):
row 91 lands second, so it gets a new manifest and a full gate on the new
base. Filbert's static review of round 1 carries over; the test run and
the verdict go on this manifest.
Round 1 was manifest 39d6eb7d on base c974cafa (request comment
28067). From c974cafa to 6b0215aa, one commit touches this row's
paths: e918a915 adds two lines to packages/harness/src/bundle.mjs and
two to packages/harness/tests/bundle.test.mjs. Round 1's build.patch
applies to 6b0215aa with git apply --index and no fuzz. Six of the
eight manifest lines are the same as round 1; the two bundle files differ
by row 90's lines only. git diff 6b0215aa of the two bundle files is
the same hunks as round 1's.
e918a915 also adds packages/tasks/src/requirements.mjs to
RUNNER_FILES in packages/seat/src/sandbox.mjs. I checked that this row
needs no line there. RUNNER_FILES lists what the runner loads inside
the sandbox. checkout.mjs runs on the host: launcher.mjs imports it
(and launcher.test.mjs imports gitEnv from it). bundle.mjs, which
is a runner file, gains no import; it only reads session.checkouts. The
seat suite's "RUNNER_FILES is exactly what the runner and the harness
sources it starts import" test walks those imports and passes in the gate
below.
Candidate and base
Base 6b0215aa. build.patch is git diff --binary 6b0215aa of eight
paths, the two new files included. Apply it with git apply --index to a
detached worktree of 6b0215aa; sha256sum -c candidate-manifest.sha256
then gives 8/8 OK (checked on a fresh worktree).
packages/cli/README.mdpackages/cli/src/launcher.mjspackages/cli/tests/launcher.test.mjspackages/harness/src/bundle.mjspackages/harness/tests/bundle.test.mjspackages/seat/README.mdpackages/seat/src/checkout.mjs(new)packages/seat/tests/checkout.test.mjs(new)
In prompt() the clones line comes after the Workspace line; row 90's
stop line is after the token line. Round 1's notes on its own drift
(built on 6e1f9335, moved to c974cafa with a README reflow) are in
round 1's build.md (agents/darkwing/work/queue-91/, f759432e).
What changed
packages/seat/src/checkout.mjs, new.ensureCheckouts({ workspace, projects, env })makes sure each project of the business file has a clone at<workspace>/<project id>/. No clone:git clone -q --no-hardlinks --template= --branch <root's branch>into a temp directory beside it, renamed into place, removed if anything fails. A clean clone on the root's branch: fetch from origin,merge --ff-only. Anything else is kept as it is, with a reason. A clone that can't be made throwsCheckoutErrorwith the project id.packages/cli/src/launcher.mjs,start()and the header comment. The clones come aftercredentialStatusand beforenewRun, so a refusal leaves no run.CheckoutErrorbecomesRefusal("checkout-failed", "<project>: <message>"). Each kept clone is acheckout-keptline in the launch log with instance, project and reason. The session getscheckouts.packages/harness/src/bundle.mjs,prompt()and thesessiondoc comment. One line after the Workspace line names each clone, its path, and its branch or why it wasn't updated. No checkouts, no line.packages/seat/README.md, a new section "The project clones (#1585)" with the outcomes, the reasons, the guards and the instance-running rule.packages/cli/README.md, the launch checks table gainscheckout-failed; the paragraph under it and the on-disk sentence name the clones.- Tests:
packages/seat/tests/checkout.test.mjs(new),packages/cli/tests/launcher.test.mjs,packages/harness/tests/bundle.test.mjs.
Decision 126's three additions
Sage named the allowlist the main risk. Each guard below has a rigged clone in the "a clone the session rigged" test, and each case checks that the clone is kept with its reason, that the marker file the bait would write is absent, and that the root's fingerprint (every path, mode, link count, contents) is unchanged.
- The lstat walk of all of
.git: every entry is a directory or a regular file with one link. A symlink (.git/logs,.git/refs/heads,.git/objects,.git/config), a fifoHEADand a reflog hard-linked to a file outside each givenot-a-clone. The clone directory and.gitmust be real directories: a symlinked clone, agitdir:file and a symlinked.gitgivenot-a-clone.commondirandobjects/info/alternatesgivenot-a-clone. .git/configmust be a regular file of at most 64 KiB. Over the cap givesconfig-changed. Then every key must be onegit clonewrites, with the value a clone writes (allowed()):remote.origin.urlequal to the root, the default fetch line,branch.*.remote= origin,branch.*.mergeunderrefs/heads/. A duplicate key or a missing origin url isconfig-changed. Cases:core.fsmonitor,core.hooksPath,include.path,includeIf, a filter driver, origin moved, a second origin url, the origin url twice with the root both times, anext::origin, a branch tracking another remote, a branch merging a tag, a fetch line onto local branches, no origin. A non-empty.git/hooksishooks-present.- The check and the git calls run only while no session holds the
workspace. I didn't add a lock, because the launcher already has the
rule.
check()(packages/cli/src/launcher.mjs, the lineif (registry.running(b.id, instance)) throw new Refusal("instance-running")) runs insideserialize()for bothfromSessionandlaunchPm, beforestart(). A workspace is one instance's (workspaceDir(dataRoot, business, instance),packages/seat/src/session.mjs). So while a session of the instance runs, no launch of it reachesensureCheckouts. The launcher test pins it: with the PM running, a commit to the root, then a second--pmlaunch refusesinstance-runningand the clone's HEAD has not moved.
Second-line guards: every git call also gets -c core.hooksPath=/dev/null -c core.fsmonitor=false -c protocol.allow=never -c protocol.file.allow=always, an environment with only PATH from the
host, no system or global config and HOME at /nonexistent. The root's
reads set GIT_CEILING_DIRECTORIES to the root's parent, so a root that
isn't a repository is not mistaken for the one above it.
Choices
- Git runs async (
spawnwith a 100 s timeout and SIGKILL), so a slow clone doesn't block the bus host's other work. Launches are serialized anyway. - The prompt line says the host fetches at each launch and the origin is outside the sandbox, so the session doesn't try to fetch or push.
- A kept clone does not refuse the launch. The brief says "left exactly as it is", and the session may be mid-change in it.
--template=on clone, so no sample hooks land in.git/hooksand the empty-hooks rule holds for a fresh clone.
The tests
packages/seat/tests/checkout.test.mjs, new, 50 tests counting each
rigged and kept case:
- a first launch clones the root at its branch, with the root's path as origin
- the clone is not at its place until git clone has finished (a
giton PATH runs the real one and notes whether<workspace>/stackexists when clone returns) - a later launch fast-forwards a clean clone after a commit to the root, then finds it current
- a clone ahead of the root keeps its commits and counts as current
- a dirty clone (modified, untracked, staged), one on another branch, one diverged and a detached HEAD are kept as they are
- a clone the session rigged is kept, and git runs nothing and writes nothing outside it (the cases under decision 126 above, plus the clone a file and the clone a plain directory)
- a root that can't be cloned refuses with the project id and leaves nothing behind (a plain directory inside another repository, on no branch, missing an object)
- a clone whose root is on no branch, or gone, is kept and named
- no credential or forge remote of the root reaches a clone
- every project in the business file gets its own clone at its own branch
packages/cli/tests/launcher.test.mjs, two tests:
- each launch makes sure the workspace has a clone of the project:
cloned, fast-forwarded, or kept and logged; it also pins the
instance-running rule. Each host releases its own cto claim
(
role.release) before it closes, as the existing launcher tests do, so the next host's setup can claim it. - a project clone that can't be made refuses
checkout-failed, from--pmand from a session, with no run and no clone left
packages/harness/tests/bundle.test.mjs, one test: the prompt names the
project clones in the workspace, and none when there are none.
Mutants
The mutants ran on round 1, on 6e1f9335. Round 2 changes none of
the lines they edit; I did not rerun them.
mutants.sh (31 mutants, with mut.mjs, which edits exactly one match
or exits 3). First run 23:32:16Z to 23:35:26Z, mutants.txt: 26
killed, 5 survived. I added two tests for two of the survivors and reran
those three lines, 23:36:40Z to 23:36:46Z, mutants-rerun.txt. In all,
28 of 31 are killed.
| mutant | first run | rerun |
|---|---|---|
| duplicate-key-ok | survived | killed, "the origin url twice, both the root" |
| clone-in-place | survived | killed, "the clone is not at its place until git clone has finished" |
| merge-not-ff-only | survived | survived |
| safe-no-hookspath | survived | not rerun |
| safe-no-fsmonitor | survived | not rerun |
merge-not-ff-only is equivalent on this host. Without --ff-only, a
diverged clone needs a merge commit. Under gitEnv() git has no
identity: no config and HOME at /nonexistent, and this host's name has
no domain, so git refuses with "unable to auto-detect email address (got
'jwoltje@dragon-lin.(none)')". The merge fails and the clone is kept as
not-fast-forward, the same as with --ff-only. On a host whose name
resolves to a domain, git could form an identity and the "a diverged
branch" case would kill it.
safe-no-hookspath and safe-no-fsmonitor are second-line guards and were
expected to survive (the comment in mutants.sh says so): the hooks
check and the allowlist stop each of those cases before git runs. The
!st.isFile() half of the config check was not mutated; a symlinked
config is stopped by the walk first.
Sage's row 69 gate overlapped the first run from 23:34:36Z. Sage ruled that no mutant rerun is needed, since each result is explained.
Gate
gate.sh, gate-summary.txt. 00:07:08Z to 00:10:09Z on 2026-10-13, in the slot Rocko
cleared, after the busy check. Base 6b0215aa with this build.patch
staged; the script checks the manifest 8/8 OK before it starts.
Sequential in a clean worktree with node_modules linked and
DOCKER_HOST=unix:///nonexistent for the whole run, as Sage asked. All
14 rc=0:
| suite | result |
|---|---|
seat node --test tests/*.test.mjs |
83 pass, 0 fail, 0 skipped |
| cli | 125 pass, 0 fail, 0 skipped |
| bus | 111 pass, 0 fail, 0 skipped |
| harness | 67 pass, 0 fail, 0 skipped |
| tasks | 89 pass, 0 fail, 0 skipped |
test-auth.sh |
15/0 |
test-conductor.sh |
17/0 |
test-config.sh |
24/0 |
test-discord.sh |
66/0 |
test-extension-package.sh |
18/0 |
test-foundation.sh |
44/0 |
test-queue.sh |
27/0 |
test-release.sh |
4/0 |
test-task.sh |
26/0 |
With Docker off, the scripts skip what needs a daemon: test-release.sh
its state-machine cases (4 run here, 14 in round 1's gate),
test-task.sh its adapter seam, workspace, live task and live recall
cases. test-queue.sh skips queue verify and render --check, since
the worktree isn't the queue's canonical root. This row touches none of
what those cases cover. No Docker network was made, and no process was
left with a cwd in my worktree.
The round 1 gate (6e1f9335, 23:37:00Z to 23:39:19Z, 13 suites green)
is in round 1's packet.
Found, not fixed
recover()in the launcher: a leftover session whose SIGKILL has not taken effect within 5 s still has its run ended and its role released. A launch of that instance could then reachensureCheckoutswhile the old process still writes the workspace. Outside this row's lines.
Secrets scan
I scanned the packet for token, secret, password, api key and private key
markers. The matches are test stand-ins: CLAUDE_CODE_OAUTH_TOKEN = "stand-in" and a comment in the launcher test context, and the fake
bot:[email protected] origin and http.extraHeader in the
"no credential or forge remote" test, which checks that they never reach
a clone.