Skip to content

Commit f4280a3

Browse files
committed
A worktree's node_modules is its own directory of links, never a link to the parent's: an agent's install stays in its checkout (#1262)
Linking the parent's node_modules directory made it the worktree's install in pnpm's eyes: an agent that changed a dependency and ran pnpm install had pnpm resolve through the link and rewrite the parent checkout's workspace links — or, under CI=true, purge its node_modules outright — and every later agent died at boot. The worktree now gets a real directory per dependency tree, holding one link per entry, with the package manager's private state (.pnpm, .modules.yaml — every dot-entry but .bin) left out. Packages still resolve: an entry is a relative link into .pnpm, and a link to it resolves where the target lives. A real directory is covered by the repo's own node_modules/ ignore rule, so the slash-free exclude (#738) goes with it.
1 parent bfcb642 commit f4280a3

8 files changed

Lines changed: 146 additions & 144 deletions

File tree

FEATURES-SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ happens while nobody is at the keyboard.
103103
## Handoff and what lands in git
104104

105105
- Every agent gets its own git worktree and branch; your checkout is never touched
106-
- Dependency directories shared from the parent checkout instead of reinstalled
106+
- Dependency directories shared from the parent checkout instead of reinstalled — as directories of links, so an agent's own install stays in its checkout and never rewrites or purges the parent's
107107
- A checkout whose work is not on the remote is kept — and a publish-nothing (`handoff: local`) agent's is kept until you publish or delete it
108108
- Commit what the agent left uncommitted
109109
- Push the branch (on by default)

packages/framework/src/daemon-runtime.SPEC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ The user runs several agents on the same project at once, and keeps working in t
4848

4949
Each agent is given its own git worktree under the project's `.the-framework/branches/`, on its own `tf-agent-<agent id>` branch. Concurrent agents on one project therefore never fight over a working tree, and the user's own checkout — uncommitted work included — is left untouched.
5050

51-
A fresh worktree has no installed dependencies, since those are not tracked by git, so the project's are linked in and the links are made invisible to git.
51+
A fresh worktree has no installed dependencies, since those are not tracked by git, so the project's are mirrored in (`store/worktree-deps`).
5252

5353
#### Rationale
5454

packages/framework/src/daemon-runtime.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
addWorktree,
99
agentBranchName,
1010
linkDependencies,
11-
excludeDependencyLinks,
1211
archiveWorktreeAgent,
1312
restoreArchivedAgent,
1413
attachWorktree,
@@ -540,10 +539,8 @@ export function createProjectRuntime({ cwd, env, binPath, retryDelayMs, driverPr
540539
): Promise<{ ok: true; workspace: { cwd: string; agentId?: string } } | { ok: false; error: string }> => {
541540
try {
542541
const worktree = await addWorktree(projectCwd, { agentId, branch: agentBranchName(agentId) })
543-
// `node_modules` is gitignored, so a fresh worktree has none: link the parent's in, and
544-
// make git ignore the links (a `node_modules/` rule does not match a symlink, #738).
542+
// `node_modules` is gitignored, so a fresh worktree has none: link the parent's in.
545543
await linkDependencies(projectCwd, worktree.path).catch(() => [])
546-
await excludeDependencyLinks(projectCwd).catch(() => {})
547544
// The branches view (#1580) learns about this checkout now rather than at the next tick.
548545
void reconcileBranchLinks(projectCwd).catch(() => {})
549546
return { ok: true, workspace: { cwd: worktree.path, agentId } }

packages/framework/src/store/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ export {
6565
} from './worktree.js'
6666
export {
6767
linkDependencies,
68-
excludeDependencyLinks,
6968
findDependencyDirs,
7069
nodeLinkFs,
7170
type LinkFs,

packages/framework/src/store/worktree-deps.SPEC.md

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
Gives a fresh worktree a working dependency tree, so an agent can run the project's commands the moment it starts, and keeps that tree invisible to git so the agent never commits it.
1+
Gives a fresh worktree a working dependency tree, so an agent can run the project's commands the moment it starts — shared with the parent checkout, yet safe for the agent to install into.
2+
3+
## User story
4+
5+
- The user starts an agent and it works immediately: it can run the tests, the build, the linter. It does not sit for a minute installing, and starting ten agents does not fill the disk.
6+
- An agent changes a dependency and installs. That install must land in the agent's own checkout — never rewrite, and never delete, the dependencies of the user's checkout, which every later agent is handed.
27

38
## Business logic — TL;DR
49

5-
- **Dependencies are shared, not copied** - the parent checkout's `node_modules` directories are linked into the worktree at the same relative paths, instantly and at no extra disk cost.
6-
- **Workspace packages get their own link** - every `node_modules` down to two levels below the repo root is linked, so a monorepo's packages work too.
7-
- **The links are hidden from git** - the repo is told to ignore them, or the agent would commit dangling links onto its branch and into its pull request.
10+
- **Dependencies are shared, not copied** - each of the parent checkout's dependency directories is mirrored into the worktree at the same relative path, instantly and at no extra disk cost.
11+
- **A real directory of links, never a linked directory** - the worktree gets a directory of its own holding one link per dependency entry, so an install inside the worktree writes into the worktree.
12+
- **The package manager's private state stays behind** - the entries that mark a tree as the package manager's own install are not linked; the packages resolve without them.
13+
- **Workspace packages get their own tree** - every dependency directory down to two levels below the repo root is mirrored, so a monorepo's packages work too.
814
- **Never fatal** - a link that cannot be made is skipped; a worktree with missing dependencies is a worse agent, not a failed one.
915

1016
## Business logic
@@ -13,33 +19,35 @@ Gives a fresh worktree a working dependency tree, so an agent can run the projec
1319

1420
#### User story
1521

16-
The user starts an agent and it works immediately: it can run the tests, the build, the linter. It does not sit for a minute installing, and starting ten agents does not fill the disk.
22+
See `## User story`: the agent works immediately, and ten agents cost no extra disk.
1723

1824
#### Business logic
1925

20-
Dependency directories are ignored by git, so a new worktree is handed an empty one and every command in it fails. Instead of copying or installing, the parent checkout's dependency directories are linked into the worktree at the same relative paths. Anything already present at a link's location is left alone, since the agent may have installed for itself already, and any missing parent directory is created first.
26+
Dependency directories are ignored by git, so a new worktree is handed an empty one and every command in it fails. Instead of copying or installing, each of the parent checkout's dependency directories is mirrored into the worktree at the same relative path: a real directory is created there, and inside it one link per entry of the parent's directory, pointing at that entry. A tree already present in the worktree is left alone, since the agent may have installed for itself already, and any missing parent directory is created first.
2127

22-
The scan looks for a dependency directory at the repo root and at every directory down to two levels below it, which covers a workspace's per-package dependencies without walking the whole tree. Dependency directories, the git directory, the framework's own directory, build outputs and dot-directories are never descended into. The result is ordered, so linking happens in a stable order.
28+
The scan looks for a dependency directory at the repo root and at every directory down to two levels below it, which covers a workspace's per-package dependencies without walking the whole tree. Dependency directories, the git directory, the framework's own directory, build outputs and dot-directories are never descended into. The result is ordered, so mirroring happens in a stable order.
2329

2430
#### Rationale
2531

26-
Three options existed: copy the tree (correct, but gigabytes per agent), install into each worktree (correct, but real waiting on every start), or link the parent checkout's trees in (instant, no extra disk, one store shared by all agents). Linking wins. The one case it is wrong for is an agent that changes the dependency manifest — and that agent needs its own install anyway, which it runs itself.
27-
28-
Whole directories are linked, rather than their contents, because that is what makes a workspace resolve: the links inside a package's dependency directory still point at their real location in the parent checkout.
32+
Three options existed: copy the tree (correct, but gigabytes per agent), install into each worktree (correct, but real waiting on every start), or link the parent checkout's trees in (instant, no extra disk, one store shared by all agents). Linking wins.
2933

30-
### The links are hidden from git
34+
### A real directory of links, never a linked directory
3135

3236
#### User story
3337

34-
The agent's pull request contains its work and nothing else — no dependency directories, no broken links pointing at a path that only exists on the machine the agent ran on.
38+
See `## User story`: an agent's install must stay in the agent's checkout.
3539

3640
#### Business logic
3741

38-
A repo's ignore rules normally name the dependency directory with a trailing slash, which matches a real directory only. The linked trees are links, not directories, so those rules do not cover them and they show up as untracked in every agent's worktree. That matters because the agent stages everything it changed, so it would commit those links onto its branch and into its pull request.
42+
The worktree's dependency directory is a directory of its own, not a link to the parent's. When an agent installs in its worktree — which an agent that changes a dependency must — the package manager rewrites the entries of the worktree's directory and leaves the parent checkout's untouched. After the worktree is removed, the parent checkout's dependencies are exactly as they were.
43+
44+
The package manager's private state — every dot-entry of a dependency directory except the executables directory — is not linked. The executables directory is, because an agent runs the project's tools.
45+
46+
#### Rationale
3947

40-
A rule without the trailing slash is therefore added to the repo's own local exclusions, covering the link form in every worktree. The main checkout is unaffected, because its dependency directory is a real directory already ignored under the same name.
48+
Linking the directory itself made the parent's tree the worktree's install in the package manager's eyes: it resolved through the link, rewrote the parent checkout's workspace links to point into the worktree — which dangled once the worktree was removed — or, when told it was running unattended, purged the parent's tree outright. Either way every later agent died at boot. The private state is what tells the package manager "this tree is mine, installed here", so it stays out of the worktree; the packages resolve without it, because a package entry is itself a relative link into the package manager's store, and a link to that link resolves where the target lives — in the parent checkout.
4149

42-
This is best-effort as well: on a project that is not a git repo, or where the exclusion cannot be written, the links simply stay visible to git status.
50+
Since the worktree's dependency directory is a real directory, the repo's own ignore rule for dependency directories covers it, and git never sees the links.
4351

4452
## Before modifying/creating SPEC.md files
4553

packages/framework/src/store/worktree-deps.test.SPEC.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
What the tests cover: giving a worktree its dependencies and hiding them from git.
1+
What the tests cover: giving a worktree its dependencies.
22

33
- The scan finds the repo root's dependency tree and each workspace package's, and never descends into a dependency tree, the git directory, or the framework's own directory.
4-
- Each tree is linked into the worktree at the same relative path, creating any missing parent directory; a tree already present in the worktree is left alone, and linking twice adds nothing.
5-
- A filesystem that refuses to make a link is tolerated: the agent still starts.
6-
- Against a real repo and a real worktree, the linked tree resolves — a dependency file is readable through it, and it is a link rather than a copy.
7-
- Against real git: the repo's own ignore rule does not cover the links and leaves the worktree dirty, adding the exclusion makes the worktree clean, and adding it a second time does not duplicate the rule.
4+
- Each tree is mirrored into the worktree at the same relative path as a real directory holding one link per entry, creating any missing parent directory; a tree already present in the worktree is left alone, and mirroring twice adds nothing.
5+
- The package manager's private state — every dot-entry but the executables directory — is not linked.
6+
- A filesystem that refuses to make a link, or the directory, is tolerated: the agent still starts.
7+
- Against a real repo and a real worktree with a pnpm-shaped tree (a package entry that is a relative link into the store), the mirrored tree resolves — a dependency file is readable through the chain, the worktree's directory is real and holds only the package entries — and replacing an entry in the worktree, as an install would, leaves the parent's tree and its link exactly as they were.
88

99
## Before modifying/creating SPEC.md files
1010

0 commit comments

Comments
 (0)