252 lines
12 KiB
Markdown
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.
|