Files
stack/agents/darkwing/work/queue-91/build.md
T

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

Candidate and base

Base c974cafa. build.patch is git diff --binary c974cafa of eight paths, the two new files included. Apply it with git apply --index to a detached worktree of c974cafa; sha256sum -c candidate-manifest.sha256 then gives 8/8 OK (checked on a fresh worktree).

  • packages/cli/README.md
  • packages/cli/src/launcher.mjs
  • packages/cli/tests/launcher.test.mjs
  • packages/harness/src/bundle.mjs
  • packages/harness/tests/bundle.test.mjs
  • packages/seat/README.md
  • packages/seat/src/checkout.mjs (new)
  • packages/seat/tests/checkout.test.mjs (new)

I built on 6e1f9335. Row 69's landing, 90d7ed66, then changed packages/cli/README.md in the launch section. My patch applied to 7f0f54ab without conflict, and c974cafa touches none of the eight paths. After the move I reflowed three lines of that README paragraph that my insert had left ragged ("directory, spawns the runner" stood alone). The other seven files are byte-identical to the ones the gate below ran on. The gate ran on 6e1f9335, so it predates row 69's cli changes (app.mjs, screens.mjs, app.test.mjs). Neither touches the launcher or this row's tests. Filbert's review run on c974cafa is the first run on the new base.

Row 90 overlap (decision 127)

Row 90 round 2 (Dewey, manifest digest 95611274, build.patch sha256 77de6251) also edits packages/harness/src/bundle.mjs and packages/harness/tests/bundle.test.mjs, on other lines. Both orders, each on a fresh worktree of c974cafa:

order result
row 90 applied, then git apply --check of row 91 OK
row 91 applied, then git apply --check of row 90 OK

After applying both in each order, the two bundle files are byte-identical between orders. In prompt() the clones line comes after the Workspace line and row 90's stop line goes after the token line. I did not run the harness suite on the combined tree. Whichever row lands second reruns the rebase check and the full gate.

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 throws CheckoutError with the project id.
  • packages/cli/src/launcher.mjs, start() and the header comment. The clones come after credentialStatus and before newRun, so a refusal leaves no run. CheckoutError becomes Refusal("checkout-failed", "<project>: <message>"). Each kept clone is a checkout-kept line in the launch log with instance, project and reason. The session gets checkouts.
  • packages/harness/src/bundle.mjs, prompt() and the session doc 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 gains checkout-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.

  1. 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 fifo HEAD and a reflog hard-linked to a file outside each give not-a-clone. The clone directory and .git must be real directories: a symlinked clone, a gitdir: file and a symlinked .git give not-a-clone. commondir and objects/info/alternates give not-a-clone.
  2. .git/config must be a regular file of at most 64 KiB. Over the cap gives config-changed. Then every key must be one git clone writes, with the value a clone writes (allowed()): remote.origin.url equal to the root, the default fetch line, branch.*.remote = origin, branch.*.merge under refs/heads/. A duplicate key or a missing origin url is config-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, an ext:: origin, a branch tracking another remote, a branch merging a tag, a fetch line onto local branches, no origin. A non-empty .git/hooks is hooks-present.
  3. 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 line if (registry.running(b.id, instance)) throw new Refusal("instance-running")) runs inside serialize() for both fromSession and launchPm, before start(). 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 reaches ensureCheckouts. The launcher test pins it: with the PM running, a commit to the root, then a second --pm launch refuses instance-running and 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 (spawn with 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/hooks and 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 git on PATH runs the real one and notes whether <workspace>/stack exists 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 --pm and 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

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. 23:37:00Z to 23:39:19Z, after the mutant rerun, so with both new tests. Base 6e1f9335 plus the candidate as it was before the move to c974cafa (see "Candidate and base"). Sequential in a clean worktree with node_modules linked, all 13 rc=0, 0 skipped:

suite result
seat node --test tests/*.test.mjs 83 pass, 0 fail
cli 122 pass, 0 fail
bus 108 pass, 0 fail
harness 66 pass, 0 fail
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 14/0
test-task.sh (DOCKER_HOST=unix:///nonexistent) 26/0

After the gate I removed the wt_default Docker network it left (created 23:39:07Z, 0 containers).

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 reach ensureCheckouts while 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.