@williamthorsen/nmr 0.46.2 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # Delegating to another machine
2
+
3
+ nmr runs a command on another machine over SSH, against the working tree on this one, and reports the command's output and exit status here:
4
+
5
+ ```console
6
+ $ nmr --delegate imac2 ci
7
+ # ... the host's own output and verdicts ...
8
+ ✅ my-repo: ci: passed on imac2 in 1m 48s
9
+ ```
10
+
11
+ A machine that runs several worktrees' checks at once runs out of memory, and the [machine lock](machine-lock.md) protects it by making the checks wait for one another. Delegation moves a check to a machine that has the room for it instead.
12
+
13
+ ## Choosing where a command runs
14
+
15
+ | Step | Effect |
16
+ | ----------------------------- | ----------------------------------------------------------------------- |
17
+ | `nmr --delegate <host> <cmd>` | Runs the command on `<host>`. `--delegate=<host>` is the same. |
18
+ | `NMR_DELEGATE=<host>` | Runs every command that can be delegated on `<host>`. |
19
+ | `nmr --no-delegate <cmd>` | Runs the command here, whatever `NMR_DELEGATE` names. It outranks both. |
20
+
21
+ `<host>` is anything that `ssh` accepts: a name from `~/.ssh/config`, `user@host`, or an address. Like every nmr flag, these belong before the command name; nmr warns about one written after it, because the command then runs here.
22
+
23
+ `NMR_DELEGATE` is how an agent session's checks reach the host without any change to the commands that the agent runs: Set it in the session's environment.
24
+
25
+ ## What can be delegated
26
+
27
+ Every command whose contribution is its exit status: `ci`, `prepush`, `check`, the `test` commands, `typecheck`, and a custom script such as `test:e2e`. nmr decides on the name that the invocation gives, so `ci` runs on the host as one unit, `build` step included.
28
+
29
+ These run here, and `--delegate` refuses them: `build`, `clean`, `compile`, `fix`, `fmt`, `lint`, `test:watch`, and `upgrade`, with their `root:` forms, which rewrite the working tree or run until stopped; and `--log`, which reads this machine's recordings. Under `NMR_DELEGATE`, they run here without comment.
30
+
31
+ ## What the host runs
32
+
33
+ nmr records the working tree as it is on disk as a commit: tracked changes, staged or not, and untracked files that git does not ignore. This is the tree that a local run checks. The commit is built in a temporary index, so the repository's index, refs, and stash do not change.
34
+
35
+ nmr pushes the commit to a checkout on the host under `~/.cache/nmr/delegate/`, without running the repository's `pre-push` hook, then runs `pnpm install --frozen-lockfile` and the command from the directory that corresponds to the local one, with nmr's own flags as they were given. The host keeps one checkout for each repository on each machine that delegates to it, so concurrent delegations from different worktrees do not share one, and `node_modules` and build output persist between runs. nmr does not remove these checkouts.
36
+
37
+ - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
38
+ - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. `--delegate` refuses such a tree; under `NMR_DELEGATE`, the command runs here, and nmr prints one line saying why.
39
+ - **One worktree delegates one command at a time.** Two delegations from the same worktree at once share a checkout on the host.
40
+
41
+ On the host, the command runs under the host's own [machine lock](machine-lock.md) and [check-result cache](check-cache.md), so a host that serves several worktrees runs their heavy commands one at a time, and a tree that already passed there skips. A delegated run does not take the lock here, and it neither consults nor records this machine's cache, whose key is specific to the machine.
42
+
43
+ Ctrl-C ends the run on the host as well as here.
44
+
45
+ ## Reporting
46
+
47
+ The host's nmr prints its output and verdicts, and nmr here adds one verdict for the whole delegation, its transfer and install included, which names the host. `--json` adds a `host` field to that object; see [reporting for a machine](reporting.md#reporting-for-a-machine).
48
+
49
+ A failure of the delegation itself, rather than of the command, exits 255, the status with which `ssh` reports its own failures, and names the step that failed, after what that step printed:
50
+
51
+ ```console
52
+ ❌ my-repo: ci: failed on imac2 in 10.0s (exit 255) — could not prepare imac2
53
+ ```
54
+
55
+ The steps are `prepare`, which reaches the host and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
56
+
57
+ ## Setting up the host
58
+
59
+ - **SSH that does not prompt.** nmr runs `ssh` with `BatchMode=yes`, so a key that needs a passphrase must be in the agent, and the host key must already be known. An unreachable host fails within 10 seconds.
60
+ - **git, Node, and pnpm on the interactive login shell's `PATH`.** nmr reads `PATH` from the host's login shell started as an interactive one, which loads `~/.zshrc` or `~/.bashrc` as well as the login profile: That is where pnpm's installer and the common version managers put Node and pnpm. What the startup files print does not reach the output, but whatever else they do runs before each step. When pnpm is not on that `PATH`, the install step fails, naming `PATH`. The login shell must accept `-ilc`: bash and zsh do.
61
+ - **A sandboxed agent session needs network access to the host.** A sandbox that allows only HTTP egress refuses `ssh`, and the delegation fails at `prepare`.
@@ -0,0 +1,62 @@
1
+ # Machine lock
2
+
3
+ nmr runs one heavy command at a time on a machine. A heavy command started while another is running, from any repository or worktree, waits for it to finish rather than competing with it for memory:
4
+
5
+ ```console
6
+ $ nmr check
7
+ nmr: waiting for test in /Users/me/repos/other-repo (pid 41872) to finish
8
+ ✅ my-repo: check: passed in 1m 12s
9
+ ```
10
+
11
+ The waiting line goes to stderr in every verbosity and report format. The time spent waiting does not count in the verdict's duration.
12
+
13
+ ## Which commands take the lock
14
+
15
+ `check`, `check:strict`, `ci`, `root:check`, `test`, `test:all`, `test:coverage`, `test:tool`, `test:unit`, `root:test`, `root:test:all`, `root:test:tool`, and `root:test:unit`, at any scope. The set is fixed.
16
+
17
+ - **`test:watch` does not take the lock.** It runs until it is stopped, and would block every other run for as long.
18
+ - **A composite takes the lock when it reaches a heavy step.** `prepush` runs its audit unlocked, then waits, if it has to, before `ci`.
19
+ - **A command nested in a heavy one runs under its hold.** The holder passes its token to the processes that it starts, so `ci` running `check:strict` running `test` takes the lock once.
20
+ - **A fan-out takes the lock once.** `nmr -R test` and `nmr -F <pattern> test` hold it in the process that fans out, so the packages that pnpm runs in parallel do not wait on one another.
21
+ - **A cached pass does not wait.** nmr consults the [check-result cache](check-cache.md) before it takes the lock, and a command that the cache serves never waits. A run that did wait consults the cache again, because the run that it waited on may have recorded a pass for the same tree.
22
+ - **`--log` does not take the lock**, because it runs nothing.
23
+ - **A [delegated](delegate.md) command takes the host's lock**, not this machine's.
24
+
25
+ ## Where the lock lives
26
+
27
+ | Platform | Directory |
28
+ | ------------ | ----------------------- |
29
+ | Linux, macOS | `/tmp/nmr-<uid>/` |
30
+ | Windows | `<os.tmpdir()>/nmr/` |
31
+ | Any | `$NMR_LOCK_DIR`, if set |
32
+
33
+ The lock is the file `heavy.lock` in that directory. It names its holder's command, directory, and pid. The directory is `/tmp` rather than `TMPDIR` because a sandboxed agent session and a terminal shell set `TMPDIR` differently, and would not see each other's lock. `NMR_LOCK_DIR` changes the location for every invocation that sees it, so a machine that sets it sets it in every environment that runs nmr.
34
+
35
+ **A sandbox needs write access to the directory.** When nmr cannot write it, nmr warns and runs the command without the lock:
36
+
37
+ ```console
38
+ nmr: running without the machine lock: cannot write /tmp/nmr-501 (EPERM). Grant write access to it, or set NMR_LOCK_DIR to a writable directory.
39
+ ```
40
+
41
+ On macOS `/tmp` is a link to `/private/tmp`, and a sandbox that matches resolved paths, such as Claude Code's, needs the grant for `/private/tmp/nmr-<uid>`.
42
+
43
+ ## A lock that its holder left behind
44
+
45
+ A holder removes the lock when its command ends, whether the command passed or failed. A holder that is interrupted or killed leaves the lock behind, and the next run reclaims it: at once when the holder's pid no longer exists, and otherwise once the holder has stopped refreshing the file for a minute, which covers a pid that another process has since taken.
46
+
47
+ ## Bypassing the lock
48
+
49
+ | Step | Effect |
50
+ | ------------------------- | ---------------------------------------------------------- |
51
+ | `nmr --no-lock <command>` | Runs without waiting. Applies to the whole chain. |
52
+ | `NMR_NO_LOCK=1` | The same, for every nmr invocation that sees the variable. |
53
+
54
+ `--no-lock` belongs before the command name. After it, it is an argument to the command rather than a flag to nmr; nmr reports this rather than silently ignoring the bypass.
55
+
56
+ **CI.** A GitHub-hosted runner runs one job per machine, so the lock never makes it wait. A self-hosted machine that runs several runner instances serializes their heavy jobs, which protects its memory as the lock does on a workstation, and a job's wait counts against its timeout. Set `NMR_NO_LOCK=1` in the runners' environment to run them in parallel instead.
57
+
58
+ **A test suite that runs nmr.** A suite run by `nmr test` runs under that command's hold only while the processes that it starts inherit `NMR_MACHINE_LOCK_TOKEN`. A test that starts a heavy command with an environment of its own waits on the suite that started it, so it sets `NMR_NO_LOCK=1`.
59
+
60
+ ## Reserved environment variables
61
+
62
+ `NMR_MACHINE_LOCK_TOKEN` is nmr's own: The holder passes it to the processes that it starts, which run under its hold. nmr honors an inherited token only while the lock file still contains it, so a process that outlives its run waits like any other.
package/docs/reporting.md CHANGED
@@ -29,7 +29,7 @@ $ nmr --json check
29
29
  {"command":"check","scope":"nmr-core","outcome":"passed","durationMs":31204}
30
30
  ```
31
31
 
32
- Every object contains `command`, `scope`, and `outcome`, and then the fields that only that outcome includes:
32
+ Every object contains `command`, `scope`, and `outcome`, and then the fields that only that outcome includes. A [delegated](delegate.md) command's own verdict also contains `host`, the host on which it ran:
33
33
 
34
34
  | `outcome` | Also contains |
35
35
  | ---------- | ----------------------------------------------------------------------------------------------------------------------- |
package/docs/vitest.md CHANGED
@@ -125,11 +125,25 @@ export default defineVitestConfig({ shouldIsolateGit: false, shouldResolveFromSo
125
125
 
126
126
  **The boundary is Vite's resolution, not the package's role.** Because Node does not apply the `source` condition, a workspace package that an externalized dependency imports resolves through Node to its build output. Nothing else in a suite resolves that way.
127
127
 
128
- ### Worker count on small machines
128
+ ### Worker count
129
129
 
130
- On a machine with 2 or fewer cores, the factory gives every project `maxWorkers: 3`. Vitest's run-mode default is one fewer worker than the core count, so a 2-core CI runner, GitHub's standard runner for a private repo, would otherwise run every test file serially on one worker. Three workers are faster there, because the main process transforms files while the workers run them. On a machine with more cores, the projects do not set `maxWorkers`, and Vitest's own default applies.
130
+ The factory gives every project a worker count derived from the machine, one worker per 4 GiB of total memory, bounded by one fewer than the core count:
131
131
 
132
- Where the default applies, Vitest's `--maxWorkers` flag has no effect, because Vitest does not forward that flag to projects. Override the default with the `VITEST_MAX_WORKERS` environment variable, with `--no-file-parallelism` for a single worker, or with `maxWorkers` in the `project` seam.
132
+ ```
133
+ min(availableParallelism - 1, max(1, floor(totalmem / 4 GiB)))
134
+ ```
135
+
136
+ Vitest's own default, one fewer worker than the core count, does not take memory into account. On a 10-core, 16 GB machine it starts 9 workers per run, and several concurrent runs push the machine into swap; the derived count there is 4. The 4 GiB budget leaves room for concurrent runs and other workloads, rather than measuring one worker's footprint. The divisor is total memory, because `os.freemem()` on macOS excludes cached pages and reports near zero.
137
+
138
+ On a machine with 2 or fewer cores, every project gets 3 workers, whatever the memory. A 2-core CI runner, GitHub's standard runner for a private repo, would otherwise run every test file serially on one worker. Three workers are faster there, because the main process transforms files while the workers run them.
139
+
140
+ Each of these overrides the derived count, the highest first:
141
+
142
+ 1. The `VITEST_MAX_WORKERS` environment variable, which Vitest applies after the config.
143
+ 2. `--maxWorkers` (or `--max-workers`) on the command line, as a count or a percentage of the cores. Vitest does not forward the flag to projects, so the factory reads it from the command line and applies it to every project.
144
+ 3. `maxWorkers` in the `project` seam.
145
+
146
+ `--no-file-parallelism` runs a single worker whatever the count.
133
147
 
134
148
  ## Resolving through tsconfig paths
135
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "0.46.2",
3
+ "version": "0.48.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -77,7 +77,7 @@
77
77
  "!docs/*.v*.md"
78
78
  ],
79
79
  "dependencies": {
80
- "@williamthorsen/nmr-core": "0.13.3",
80
+ "@williamthorsen/nmr-core": "0.13.4",
81
81
  "@williamthorsen/toolbelt.errors": "0.8.3",
82
82
  "@williamthorsen/toolbelt.filesystem": "0.9.3",
83
83
  "glob": "13.0.6",
@@ -90,10 +90,10 @@
90
90
  "@williamthorsen/toolbelt.testing": "0.6.3",
91
91
  "@williamthorsen/toolbelt.vitest": "0.10.3",
92
92
  "esbuild": "0.28.2",
93
- "readyup": "0.40.0",
93
+ "readyup": "0.41.0",
94
94
  "tinyglobby": "0.2.17",
95
95
  "typescript": "6.0.3",
96
- "vite": "8.3.3",
96
+ "vite": "8.3.4",
97
97
  "vitest": "4.1.11",
98
98
  "yaml": "2.9.1"
99
99
  },