Files
stack/agents/darkwing/work/queue-117/r1/build.md
T
jason.woltjeandClaude Opus 5.5 17d801f482 work: row 117 round 1 packet, fetch a clone on another branch or dirty (#1611)
Candidate manifest, patch against 559ef2287, build notes, five killed
mutants and the targeted gate (seat, cli, harness, two scripts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:31:50 -05:00

10 KiB

Row 117 (#1611) round 1: fetch a role clone that is on another branch or dirty

Brief: docs/plans/2026-10-14_clone-fetch-off-branch.md (559ef2287), "Fetch a role clone that is on another branch or dirty". On 2026-10-14 the coder sat on a work branch and its clone's origin/refactor stayed several landings old, because update() ran no git at all in a clone it kept for other-branch or dirty.

BASE: 559ef2287. The candidate is build.patch against BASE, and candidate-manifest.sha256 lists the 8 files. I applied the patch to a fresh git archive 559ef2287 export and every digest matched (DIGESTS_OK). The worktree still matched the manifest after the mutants and before the gate.

What changed

update(), packages/seat/src/checkout.mjs

Everything up to the root-detached check is unchanged: unsafe(), the ls-files gitlink check and the root reads. Nothing in unsafe(), allowed(), gitEnv() or SAFE changed. After the root checks, the new tail does this:

  1. It reads the branch with symbolic-ref -q --short HEAD. A detached HEAD fails that call, so branch is null, and null is never the root's branch, so the reason is other-branch.
  2. On the root's branch it runs the same status call as before. A failed status still returns kept, status-failed, with no fetch. Output from status means dirty.
  3. It reads HEAD, then runs the clean path's fetch with the same arguments and environment. A failed fetch is kept, fetch-failed, for any clone.
  4. With a reason set, it reads refs/remotes/origin/<root branch> and returns fetched. No merge runs.
  5. A clean clone on the root's branch merges --ff-only exactly as before.

The fetched result is { outcome: "fetched", reason, branch, commit, origin: { branch, commit } }:

Field Value
reason other-branch or dirty
branch the clone's branch, null on a detached HEAD
commit the clone's HEAD, read before the fetch (a fetch doesn't move HEAD), null when unreadable
origin.branch the root's branch
origin.commit refs/remotes/origin/<branch> after the fetch, null when unreadable

Choices I made:

  • I used the brief's shape. origin is always an object, because by this point the root's branch is known. Only origin.commit can be null.
  • commit is now null when rev-parse HEAD fails. Before this change the code used .out.trim() without checking .ok, and git prints the unresolved argument on a failure. That could only matter for an unborn HEAD, which a clone made by git clone of a root with a commit doesn't have.
  • A failed status stays kept with no fetch. It isn't one of the brief's two reasons, and "Only those two reasons fetch".

The launcher, packages/cli/src/launcher.mjs

After ensureCheckouts, a fetched clone writes { event: "checkout-fetched", instance, project, reason } to the launch log, next to checkout-kept. The header comment says so.

The prompt, packages/harness/src/bundle.mjs

checkoutLines() gives a fetched clone the started line, then , fetched but not fast-forwarded at this launch (<reason>), with origin/<branch> at commit <sha>. A null branch reads "on no branch", and a null origin commit reads "at a commit the host couldn't read", using the same at() helper as before. The shared sentence now says the host fetches each clone it can safely run git in and fast-forwards only a clean clone on the root's branch. The rest of it (never during a session, the origin is outside the sandbox, no git config, identity from the environment) is unchanged.

READMEs

  • packages/seat/README.md, "The project clones". The outcome table gets the fetched row, and the result paragraph describes origin. The reason table marks other-branch and dirty as fetched and says every other reason runs no fetch. A failed fetch on either of those clones is kept, fetch-failed.
  • packages/cli/README.md, "Sessions". A checkout-fetched line with the project and the reason, then the existing checkout-kept sentence.

packages/cli/tests/host.test.mjs:826 reads packages/cli/README.md, so that README is not doc-only under decision 137. unslop-check is clean on both READMEs.

Tests

packages/seat/tests/checkout.test.mjs

The old "a dirty clone, one on another branch and one diverged are kept as they are" test becomes the first test below, as the brief asks. Its diverged case moves to its own test, because a diverged clone is still kept.

Test Checks
a dirty clone and one on another branch are fetched, and nothing but refs/remotes moves 5 cases: a modified, an untracked and a staged file (dirty, branch main), another branch (other-branch, work), a detached HEAD (other-branch, null). After a commit to the root: the whole result by deepEqual, refs/remotes/origin/main is the root's new commit, and work() is unchanged. work() hashes .git/HEAD, every refs/heads/* ref, .git/index and every file outside .git, the brief's narrowed fingerprint
a diverged clone gets the fetch into refs/remotes and is kept, not-fast-forward kept, not-fast-forward, and refs/remotes/origin/main is the root's new commit
a clone kept before the branch check, or for a failed status, gets no fetch 8 cases through a PATH git wrapper that logs each call run in the clone. not-a-clone, hooks-present and config-changed run nothing in the clone. submodule, root-unreadable and root-detached run only ls-files. Both status-failed cases, clean and dirty, run ls-files, symbolic-ref, status. Each case checks the reason and the exact call list, and that refs/remotes/origin/main didn't move
a dirty clone whose fetch fails is kept, fetch-failed, exactly as it was the wrapper fails fetch. kept, fetch-failed, no merge call, and the full fingerprint, .git included, is unchanged

The rigged-clone test is unchanged and passes.

How I read the brief on "still runs no git, the fetch included": the gitlink check is itself the ls-files call, so submodule can't be found without it. root-unreadable and root-detached come after it, and their own reads run in the root, not the clone. Those three run ls-files and nothing else, as they did at BASE. The test pins the exact call list so a fetch, or any other call, fails it. If Dewey reads the brief as asking for literally zero calls in those three cases, that's a change to the check order, which the brief rules out.

The fingerprint helper gets a skip list of top-level names, so work() can hash the files outside .git.

packages/cli/tests/launcher.test.mjs

"each launch makes sure the workspace has a clone of the project" now ends "cloned, fast-forwarded, or fetched and logged". The first two launches log no checkout-fetched. The third launch has a dirty notes.txt. It now checks that HEAD stays at two while refs/remotes/origin/main is three, that the log has checkout-fetched [{ instance: "pm", project: "stack", reason: "dirty" }] and no checkout-kept, and that the prompt has the fetched line with origin/main at three.

packages/harness/tests/bundle.test.mjs

The checkout test adds two entries. lib is fetched, dirty, on main, with an origin commit. app is fetched, other-branch, with a null branch and a null origin commit. It checks the new shared sentence and both new lines exactly.

Mutants

mutants.py runs each mutant against the full seat suite (node --test tests/*.test.mjs in packages/seat, 130 tests) and restores checkout.mjs from a .orig copy after each. Log: mutants-run.log. Each mutant's full output is in mutants/mutant-.log, and the failing test names are listed under each mutant in mutants-run.log.

Mutant Change Result
m1, fetch for a third reason a failed status sets reason = "status-failed" instead of returning kept killed, 3 fail: both status-failed cases of "gets no fetch" (they see fetch) and the parent test
m2, merge after the fetch inside if (reason), the clean path's merge --ff-only runs before the return killed, 5 fail: untracked, staged, another branch and detached, plus the parent. The modified-file case passes, because git refuses that merge over a local change to README
m3, drop the fetch the fetch runs only when reason is unset killed, 7 fail: all 5 fetched cases and the parent (origin/main stays at the old commit), and "whose fetch fails" (no fetch, so the result is fetched)
m4, wrong origin commit origin.commit is the clone's HEAD (before) killed, 6 fail: all 5 fetched cases and the parent, on the deepEqual
m5, fetch before the gitlink check the fetch runs right after unsafe(), before ls-files killed, 6 fail: submodule, root-unreadable, root-detached, both status-failed cases and the parent, on the call list

All five were restored, and git diff --stat matched the candidate afterwards (8 files, 183+, 52-).

Gate

This is a targeted builder round under Sage's rule (#271, sage-0180): the changed packages, the packages that import them, and every scripts/test-*.sh that reads a changed path. Sage's landing gate stays full.

  • Changed packages: seat, cli, harness.
  • Importers: none outside those three. Nothing else references checkout.mjs, launcher.mjs or bundle.mjs (grep over packages//src, packages//tests and scripts; Sage checked this too, sage-0188).
  • Scripts: no scripts/test-*.sh reads a changed path. test-task.sh says "seat" only inside skill names. test-conductor.sh and test-extension-package.sh match the basename README.md, but only for their own sandbox files. Sage asked me to keep those two anyway, so they ran.
  • No pkg-queue step, per #271.

gate.sh ran these steps in order, each teed. GATE_PASSED at 17:30:33Z, run 17:29:52 to 17:30:33. Log: gate-run.log, per-step logs under gate/.

Step Pass
seat 130 (120 at BASE: the old test and its 6 cases are 7, the four new tests and their cases are 17)
cli 184
harness 81
scripts/test-conductor.sh 17
scripts/test-extension-package.sh 18

There were no failures and no flakes. The busy check was clear before the mutants and before the gate. Afterwards no process had a cwd under my r117 directory, and nothing created a Docker network.

Going live

This change runs in the host at launch, so decision 109 applies. It goes live only through a stack copy and a re-pin on Mos's go. I start, stop and restart nothing.