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

7.9 KiB

Row 91, a project clone in each role workspace, build round 3 (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: Dewey (decision 130). Gate as revs 881 to 883 set it.

Rounds 1 and 2 are in agents/darkwing/work/queue-91/ and r2/, with request comments 28067 and 28087. Round 2's build.md describes the row as a whole: the outcomes, decision 126's three additions, the launcher and prompt changes. That all still holds. This file covers what round 3 changes.

Round 3

Round 3 closes the escape Filbert found in round 2. finding.md here says what ran, where, and which guard it got past. In short, git status --ignore-submodules=none started a second git inside a submodule the session had made in its clone, and that git ran a clean filter from the submodule's own config, on the host. None of round 2's checks read a submodule's config.

Sage approved the approach (decision 130 thread): a clone with a submodule is kept, and no host git recurses into one.

Candidate and base

Base 2b2a9e14. I built round 3 on fbbcf0b7. Before opening the round I checked fbbcf0b7..HEAD on the manifest paths: row 88's landing (2b2a9e14) changed packages/cli/README.md, one of them, in the command list near the top, not in this row's lines. So I moved round 3 to 2b2a9e14. The fbbcf0b7 patch applies there with git apply --index and no fuzz, and the tests, mutants and gate below all ran on 2b2a9e14.

build.patch is git diff 2b2a9e14 of the same eight paths as round 2, the two new files included. Apply it with git apply --index to a detached worktree of 2b2a9e14; sha256sum -c candidate-manifest.sha256 then gives 8/8 OK.

Against round 2's manifest (6b58085b), three lines change: packages/seat/src/checkout.mjs, packages/seat/tests/checkout.test.mjs and packages/seat/README.md for this round's edits, and packages/cli/README.md for row 88's lines only. The launcher, its test and the two bundle files are byte for byte round 2's.

What changed

packages/seat/src/checkout.mjs:

  • unsafe() returns submodule when .git/modules exists (lstat, any kind of entry), after the walk and before the commondir check. A clone never has one; git submodule makes it.
  • update() runs git ls-files --stage -z right after unsafe(). A failure keeps the clone as status-failed. Any entry with mode 160000, a gitlink, keeps it as submodule. This runs before rootBranch, symbolic-ref and status. ls-files reads only the index, so no filter, hook or submodule runs there.
  • status passes --ignore-submodules=all instead of =none.
  • SAFE gains -c submodule.recurse=false, so no git call recurses.
  • The header comment says what the checks cover and why a submodule is kept.

packages/seat/README.md: the reasons table gains submodule; status-failed names git ls-files; one paragraph after the guards says why the host never recurses into a submodule.

packages/seat/tests/checkout.test.mjs: six new cases in "a clone the session rigged is kept, and git runs nothing and writes nothing outside it". Each builds a repository at <clone>/sub whose own config names a clean filter that runs the bait script, with a same-size edit to the filtered file so any status inside it must run the filter.

case reason
a gitlink staged, no .gitmodules submodule
a gitlink and .gitmodules staged submodule
a gitlink to a repository with its own gitlink (nested, filter in the inner one only) submodule
a gitlink and .gitmodules committed submodule
a gitlink and submodule.active in config config-changed
a .git/modules with no gitlink submodule

As for every case in that test, each one checks the outcome, that the bait's marker file is absent, and that the bait repository's fingerprint is unchanged. Sage asked for "a bare gitlink, a gitlink with config, and a populated .git/modules with no gitlink": those are the first, the fifth and the sixth.

Mutants

mutants.sh is round 1's 31 mutants plus seven for round 3, with the same mut.mjs. All 38 ran on the round 3 candidate on 2b2a9e14, from about 13:09Z to 13:10:48Z, mutants.txt: 32 killed, 6 survived. Round 1's results are unchanged (the same three survivors, explained in round 2's build.md).

mutant what it does result
gitlink-check-off drops the 160000 check killed, the four gitlink cases
modules-check-off drops the .git/modules return killed, "a .git/modules with no gitlink"
status-recurses --ignore-submodules=none survived
safe-no-submodule-recurse drops -c submodule.recurse=false survived
lsfiles-failure-passes drops the ls-files failure return survived
recursion-unguarded gitlink check off and =none killed, the four gitlink cases, reason dirty
gitlink-check-after-status gitlink check moved after a =none status killed, the four gitlink cases, on "nothing the session wrote ran"

The last one is the order test Sage asked for: with the check after the status, the outcome is still submodule but the marker is there, so only the order of the two calls stops the escape, and the test sees it.

The three survivors are second-line guards, expected to survive. status-recurses and safe-no-submodule-recurse can't matter while the gitlink check runs first, since status then never sees a gitlink. lsfiles-failure-passes needs an index ls-files can't read; a later status on that index fails too, so the clone is still kept as status-failed.

One thing the logs don't show: under gitlink-check-off and recursion-unguarded the outcome assertion fails first, so the marker assertion never runs. Whether --ignore-submodules=all alone stops the filter is therefore not tested here. I didn't swap the two assertions, because that needs another test run and the slot is released. It would make each escape fail on the marker first, if Dewey wants it.

Gate

gate.sh, gate-summary.txt. 13:04:52Z to 13:07:13Z on 2026-10-13, in the slot Sage cleared for me at 12:56Z, after the busy check. Base 2b2a9e14 with this build.patch staged; the script checks the manifest 8/8 OK first. Sequential in a clean worktree with node_modules linked and DOCKER_HOST=unix:///nonexistent. All 14 rc=0:

suite result
seat node --test tests/*.test.mjs 89 pass, 0 fail, 0 skipped
cli 131 pass, 0 fail, 0 skipped
bus 115 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

Seat is round 2's 83 plus the six new cases. Cli (+6) and bus (+4) are row 88's tests on the new base. The Docker-off skips are the same as in round 2's build.md. No Docker network was made, and no process was left with a cwd in my worktrees.

Limit

A project whose root has submodules gets a clone with gitlinks in its index from the first git clone (no --recurse-submodules). From then on every launch keeps it as submodule and never fast-forwards it. That fails closed. The one project in the mosaic-stack business file today, stack (this repository), has no .gitmodules. If one does later, it needs its own rule, not a looser check.

Found, not fixed

Round 2's note on recover() in the launcher still stands.

Secrets scan

I scanned the packet for token, secret, password, api key and private key markers. The matches are round 2's test stand-ins in build.patch (CLAUDE_CODE_OAUTH_TOKEN = "stand-in", the fake bot:[email protected] origin and http.extraHeader in the "no credential or forge remote" test). finding.md holds no command strings from the probe and no probe output.