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

252 lines
12 KiB
Markdown

# 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:s3cr3t-token@git.example` origin and `http.extraHeader` in the
"no credential or forge remote" test, which checks that they never reach
a clone.