# AUTOPILOT-107 — deterministic dependency state for worker/reviewer worktrees

Filed 2026-09-15. Reproduced and fixed the same day.

## The problem, measured

The acceptance gate (`bun test`, and the repo's full gate `bun run gate`) was
runnable in some worktrees and not others — the same repo, the same hour:

| tree | result |
| --- | --- |
| main checkout, deps present | 901 pass / 8 skip / 1 todo / **0 fail** |
| a fresh `git worktree add`, no deps | 792 pass / 8 skip / **43 fail / 4 errors** |
| AUTOPILOT-103's run (filed) | 792 / 43 fail / 4 errors |
| AUTOPILOT-106's run (filed) | 903 / 8 / 1 / 0 clean |

Every one of the 43 failures is `Cannot find package '<x>'` (`typebox`,
`@earendil-works/pi-tui`, `@opencode-ai/plugin`, `@opentui/solid/jsx-runtime`)
— an environment gap that reads exactly like a regression at the summary line.

## The mechanism (why it varies)

Nothing in this repo provisions a worktree's `node_modules`. It is created —
sometimes — by the **pi-subagents runtime**, in the installed package at
`~/.pi/personal/npm/node_modules/pi-subagents/src/runs/shared/worktree.ts`
(v0.66.0), and only on some paths:

- `linkNodeModulesIfPresent` (line 715) symlinks the repo toplevel's
  `node_modules` into a worktree at **create**. It is called only by
  `finalizeCreatedWorktree` (line 856), i.e. the **native** `git worktree add`
  path.
- It is **silently skipped** whenever anything already sits at the worktree's
  `node_modules`: `if (!fs.existsSync(nodeModulesPath) || fs.existsSync(nodeModulesLinkPath)) return false;`.
  A **dangling symlink** is the trap — `existsSync()` follows the link and
  returns `false`, so the guard passes, `symlinkSync()` then throws `EEXIST`,
  and the `catch {}` swallows it. The worktree is left with a broken link and no
  dependency resolution.
- The **`worktrunk` provider path** never links at all: it returns
  `nodeModulesLinked: false` (line 977).
- The synthetic symlink is **deleted again** before diff capture
  (`removeSyntheticPathsBeforeDiff` at line 1068) and during cleanup (line
  1145). This is the asymmetry: a real directory would survive, the symlink
  does not. So every **preserved** worktree — and anything a reviewer is later
  pointed at — has no `node_modules`.
- A plain `git worktree add` (the fallback the worker prompt itself documents)
  and a reviewer's temp extraction never get one at all. `node_modules` is
  untracked, so a new worktree from the same object database starts empty.

The pi-subagents runtime is an external package; this repo cannot make its
linking deterministic. The remedy therefore lives here.

Two further facts, established by experiment, shape the fix:

- **`bun run test` already self-provisions; a BARE `bun test` does not.** The
  `test` script chains `bun run lockfile`, whose `bun install
  --frozen-lockfile` materialises a real install in a deps-less tree. Workers
  run the gate command the acceptance contract names — bare `bun test` — which
  does no install.
- **A `bun test` preload cannot provision.** bun resolves a test file's imports
  *before* the preload runs; creating `node_modules` from a preload is too late
  (verified — the import still fails). Provisioning must be its own process.

## The fix

`scripts/deps-preflight.ts` — a cause-first preflight in the style of
`scripts/lockfile.ts`:

1. **Resolve** every package in `dependencies` + `devDependencies` +
   `peerDependencies` the way bun and tsc actually resolve it — a
   `node_modules` walk-up from the tree. Deliberately **not**
   `Bun.resolveSync`, which falls back to bun's global install cache and would
   call a deps-less tree fine.
2. **Provision** only when nothing resolves (missing, or only a **dangling**
   symlink, the state the runtime's EEXIST leaves): materialise a real, isolated
   `bun install --frozen-lockfile`; fall back to linking the parent checkout's
   install when the install itself cannot run (offline, no cache). A resolution
   that already works — including the runtime's working symlink — is **never
   torn down**. Depending on a real directory rather than the runtime's
   create-then-delete symlink is what closes the asymmetry.
3. **Report**: if the tree still cannot resolve, print one greppable line and
   exit with a **distinct code**.

```
GATE-CANNOT-RUN: the acceptance gate cannot run in <tree> — N declared package(s)
do not resolve from this tree (<names>). Provisioning did not help (<reason>).
Run `bun install` here, or run the gate in a dependency-provisioned checkout.
This is an ENVIRONMENT limitation, NOT a test failure.
```

Exit code **3** (`EXIT_CANNOT_RUN`), distinct from bun's failure exit (1). The
preflight **never reports success for a deps-less tree** — it either resolves
the tree (tests then run for real, and can still fail) or it stops the gate
with the marker. It does not loosen any acceptance criterion.

### Where it is wired in

- `package.json` —
  - `preflight: bun scripts/deps-preflight.ts` — the standalone provisioning
    stage.
  - `test: bun run preflight && bun run lockfile && bun test` — dependencies
    are materialised before the lockfile stage or any test output.
  - `gate: bun run test && bun run typecheck` — the full gate.
- `bunfig.toml` — `[test] preload = ["./test/dependency-preflight.ts", …]`.
  The preload **detects** (it cannot provision — see above). In a deps-less tree
  it prints `GATE-CANNOT-RUN: …` and exits 3 **before** the 43 misleading
  failures can appear, instead of leaving the run to be misread as a
  regression. This is the run-environment marker the ticket asks for: a
  worker/reviewer can tell "the gate cannot run here" from "the gate failed"
  without guessing, and is told exactly what to run.

So a worker/reviewer gets one of exactly two outcomes: a real, provisioned
result, or an explicit "the gate could not run here".

### The related symlink-commit symptom

The same symlink policy produced a second symptom: `git add -A` in a worktree
could commit the `node_modules` symlink (AUTOPILOT-103). `.gitignore` already
carries the fix from commit `2ee9b11` — the bare `node_modules` line, because a
trailing-slash pattern (`node_modules/`) matches directories only and silently
misses a symlink. That is now locked by a test
(`test/dependency-preflight.test.ts`, "`.gitignore` ignores a bare `node_modules`
entry"). Both symptoms now come from the same policy: the ignore rule handles
the symlink form, and the preflight means the gate no longer depends on it.

## Residual risks (not papered over)

- **Shared-install concurrency.** A tree the runtime already linked resolves and
  is left alone; a later `bun install` (`bun run lockfile`) running in several
  such worktrees at once can still collide with `Failed to link <bin>: EEXIST`
  (observed once during this work, then non-reproducible). That is inherent to
  the runtime's shared-symlink design, independent of whether the tree had deps,
  and the preflight does not mask it — a real gate failure still fails.
- **Disk.** Provisioning materialises a real install (~158 MB) rather than a
  symlink; it happens only for trees that had no working resolution, and the
  worktree-preservation sweep reclaims finished worktrees
  (`src/framework/worktree-preservation.ts`).
- **The `.git`-less reviewer extraction** (AUTOPILOT-93) is a different defect —
  a temp dir with no `.git` still cannot run git-fixture tests. The preflight
  reports that it cannot provision without a parent checkout; it does not fix it.

## Verification

Run from a **freshly created worktree** of this change's branch (no
`node_modules` present):

```
git worktree add <fresh>
cd <fresh>
bun test          # deps-less: preload prints GATE-CANNOT-RUN and exits 3
bun run preflight # materialises the dependency resolution
bun run gate      # test + typecheck, for real
```

The numbers are recorded in the ticket/PR report. Parity target is the
main-checkout baseline measured on the same commit: **901 pass / 8 skip /
1 todo / 0 fail / 196 typecheck errors** (plus this change's own tests).
