@williamthorsen/nmr 2.1.0 → 2.2.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.
@@ -1,4 +1,26 @@
1
1
  [
2
+ {
3
+ "version": "2.2.0",
4
+ "date": "2026-11-12",
5
+ "sections": [
6
+ {
7
+ "title": "🎉 Features",
8
+ "audience": "all",
9
+ "items": [
10
+ {
11
+ "description": "Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)",
12
+ "hash": "529687fecf5111851cb369c5ee9554d9711dad62",
13
+ "entry": 1
14
+ },
15
+ {
16
+ "description": "Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)",
17
+ "hash": "529687fecf5111851cb369c5ee9554d9711dad62",
18
+ "entry": 2
19
+ }
20
+ ]
21
+ }
22
+ ]
23
+ },
2
24
  {
3
25
  "version": "2.1.0",
4
26
  "date": "2026-11-12",
@@ -9662,7 +9662,7 @@ function hasSupportedStrictLintVersion() {
9662
9662
  });
9663
9663
  }
9664
9664
  function getMinVersion() {
9665
- const pickedFields = { "version": "2.1.0" };
9665
+ const pickedFields = { "version": "2.2.0" };
9666
9666
  if (typeof pickedFields["version"] !== "string") {
9667
9667
  throw new TypeError("nmr/package.json: 'version' must be a string");
9668
9668
  }
@@ -31,7 +31,7 @@
31
31
  "checklist": "nmr",
32
32
  "checks": [
33
33
  "@williamthorsen/nmr in devDependencies",
34
- "@williamthorsen/nmr >= 2.1.0",
34
+ "@williamthorsen/nmr >= 2.2.0",
35
35
  "pnpm-workspace.yaml exists",
36
36
  "catalogued dependencies use catalog:",
37
37
  "package.json has packageManager field",
@@ -235,7 +235,7 @@
235
235
  "path": "lib/workspace-build.ts"
236
236
  },
237
237
  {
238
- "hash": "5277f03b",
238
+ "hash": "7015d006",
239
239
  "kind": "inline",
240
240
  "path": "../package.json",
241
241
  "paths": [
@@ -363,7 +363,7 @@
363
363
  "readyupVersion": "0.46.1",
364
364
  "source": "kits/default.ts",
365
365
  "sourceHash": "9fd61bf4",
366
- "targetHash": "70e833f9"
366
+ "targetHash": "177bd0ca"
367
367
  }
368
368
  ]
369
369
  }
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 2.2.0 — 2026-11-12
6
+
7
+ ### 🎉 Features
8
+
9
+ - Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)
10
+ - Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)
11
+
5
12
  ## 2.1.0 — 2026-11-12
6
13
 
7
14
  ### 🎉 Features
package/README.md CHANGED
@@ -5,15 +5,12 @@
5
5
  Context-aware script runner for pnpm monorepos. Includes an `nmr` (node-monorepo run) binary that provides centralized, consistent script execution across workspace packages and the monorepo root.
6
6
 
7
7
  <!-- section:release-notes -->
8
- ## Release notes — v2.1.0 (2026-11-12)
8
+ ## Release notes — v2.2.0 (2026-11-12)
9
9
 
10
10
  ### 🎉 Features
11
11
 
12
- - Adds the `fanOut.extraSortedCommands` root-config key, which lists commands besides `build` whose fan-out keeps the topological order, and nmr rejects a name in it that does not resolve to any command when it loads the config. (#1306)
13
-
14
- ### ⚡ Performance
15
-
16
- - Drops pnpm's topological sort, which runs each package only after its workspace dependencies finish, from every `-R` and `-F` fan-out except one of `build`, so that `nmr test`, the `test:*` commands, and `nmr typecheck` start each package without waiting for its workspace dependencies. (#1306)
12
+ - Makes each `nmr` delegation remove, before it pushes, the host's other checkouts that have gone unused for longer than the new user-config key `delegate.removeCheckoutsAfterDays` allows (7 days by default, `false` to keep them), while keeping any checkout whose lock has a live holder. (#1317)
13
+ - Adds to the failure message of a delegation step that fails with `No space left on device` whether the host's filesystem ran out of inodes or of space. (#1317)
17
14
  <!-- /section:release-notes -->
18
15
 
19
16
  ## Installation
@@ -13,7 +13,7 @@ import { createSnapshot } from "./createSnapshot.js";
13
13
  import { chooseHost, probeHost } from "./remote-probe.js";
14
14
  import { DELEGATE_ENV_VAR, DELEGATE_FLAG, DELEGATE_TO_FLAG, DELEGATION_FAILURE_EXIT_CODE, deriveCheckoutPath, formatMisplacedDelegationWarning, readLastLine, runRemotely, } from "./remote-run.js";
15
15
  import { resolveDelegationTarget } from "./resolveDelegationTarget.js";
16
- import { formatDisplayPath, loadUserConfig } from "./user-config.js";
16
+ import { DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS, formatDisplayPath, loadUserConfig } from "./user-config.js";
17
17
  export async function resolveExecutionTarget(invocation) {
18
18
  const { command, context, env, parsedArgs, stderr, styles } = invocation;
19
19
  if (parsedArgs.passthrough.some(isDelegationFlag)) {
@@ -69,6 +69,7 @@ export async function runPlanOnHost(plan, target) {
69
69
  target,
70
70
  });
71
71
  }
72
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
72
73
  const PROBE_RESULT_PHRASES = {
73
74
  busy: 'is busy',
74
75
  free: 'is free',
@@ -133,6 +134,7 @@ async function delegate(options) {
133
134
  }
134
135
  const { host } = choice;
135
136
  const retained = { stderr: createBoundedBuffer(), stdout: createBoundedBuffer() };
137
+ const removeCheckoutsAfterMs = snapshot.ok ? await readRemoveCheckoutsAfterMs(env) : null;
136
138
  const result = snapshot.ok
137
139
  ? await runRemotely({
138
140
  command,
@@ -142,6 +144,7 @@ async function delegate(options) {
142
144
  monorepoRoot: context.monorepoRoot,
143
145
  nmrArgs: composeForwardedArgs(command, parsedArgs),
144
146
  remoteEnv,
147
+ removeCheckoutsAfterMs,
145
148
  snapshot,
146
149
  stderr: createTee(stderr, retained.stderr),
147
150
  stdout: createTee(stdout, retained.stdout),
@@ -178,6 +181,10 @@ async function delegate(options) {
178
181
  function formatRunHereLine(command, listedHosts, reason) {
179
182
  return `nmr: running ${command} here rather than on ${listedHosts}: ${reason}\n`;
180
183
  }
184
+ async function readRemoveCheckoutsAfterMs(env) {
185
+ const days = (await loadUserConfig(env))?.config.delegate?.removeCheckoutsAfterDays ?? DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS;
186
+ return days === false ? null : days * MS_PER_DAY;
187
+ }
181
188
  async function resolveHost(hosts, toplevel, env, stderr) {
182
189
  const [first, ...rest] = hosts;
183
190
  if (rest.length === 0) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Appends to a failed step's message the resource that the host's filesystem has run out of, when the step's output
3
+ * reports a full filesystem and the counts that `runOnHost` reads say which. A write fails alike for lack of space and
4
+ * of inodes.
5
+ */
6
+ export declare function appendFilesystemReason(message: string, output: string, runOnHost: (script: string) => Promise<{
7
+ exitCode: number;
8
+ output: string;
9
+ }>): Promise<string>;
10
+ /** Composes the shell command that prints, on the host, the free counts of the filesystem that holds its home. */
11
+ export declare function composeFilesystemCheckScript(): string;
12
+ /**
13
+ * Names the resource that the host's filesystem has run out of, from what `composeFilesystemCheckScript` printed, or
14
+ * returns `undefined` when neither is exhausted or the output does not include the counts. A filesystem that reports
15
+ * a total of zero inodes, as btrfs does, allocates them as it goes, so it cannot run out of them.
16
+ */
17
+ export declare function describeExhaustedResource(output: string): string | undefined;
18
+ /**
19
+ * Runs on the host and prints, as one JSON line, the total and free inodes and the blocks available to this user on
20
+ * the filesystem that holds the home directory. It is sent as source, so it refers to nothing outside its own body.
21
+ *
22
+ * @internal
23
+ */
24
+ export declare function readHostFilesystem(): void;
@@ -0,0 +1,45 @@
1
+ import { quoteForShell } from "../helpers/quoteForShell.js";
2
+ export async function appendFilesystemReason(message, output, runOnHost) {
3
+ if (!FULL_FILESYSTEM_PATTERN.test(output))
4
+ return message;
5
+ const check = await runOnHost(composeFilesystemCheckScript());
6
+ const reason = check.exitCode === 0 ? describeExhaustedResource(check.output) : undefined;
7
+ return reason === undefined ? message : `${message}: ${reason}`;
8
+ }
9
+ export function composeFilesystemCheckScript() {
10
+ return `node -e ${quoteForShell(`(${readHostFilesystem.toString()})();`)}`;
11
+ }
12
+ export function describeExhaustedResource(output) {
13
+ const counts = output
14
+ .split('\n')
15
+ .map(parseCounts)
16
+ .findLast((value) => value !== undefined);
17
+ if (counts !== undefined && counts.files > 0 && counts.ffree === 0)
18
+ return "the host's filesystem has run out of inodes";
19
+ if (counts?.bavail === 0)
20
+ return "the host's filesystem has run out of space";
21
+ return undefined;
22
+ }
23
+ export function readHostFilesystem() {
24
+ const fs = process.getBuiltinModule('node:fs');
25
+ const os = process.getBuiltinModule('node:os');
26
+ const { bavail, ffree, files } = fs.statfsSync(os.homedir());
27
+ process.stdout.write(`${JSON.stringify({ bavail, ffree, files })}\n`);
28
+ }
29
+ const FULL_FILESYSTEM_PATTERN = /no space left on device|ENOSPC/i;
30
+ function parseCounts(line) {
31
+ try {
32
+ const value = JSON.parse(line);
33
+ if (typeof value !== 'object' || value === null)
34
+ return undefined;
35
+ if (!('bavail' in value) || !('ffree' in value) || !('files' in value))
36
+ return undefined;
37
+ const { bavail, ffree, files } = value;
38
+ return typeof bavail === 'number' && typeof ffree === 'number' && typeof files === 'number'
39
+ ? { bavail, ffree, files }
40
+ : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Writable } from 'node:stream';
2
2
  import { type OutputStyle } from '@williamthorsen/nmr-core';
3
+ /** The directory on the host, relative to its home directory, that contains one directory per checkout key. */
4
+ export declare const CHECKOUTS_DIR = ".cache/nmr/delegate";
3
5
  /** Names the hosts to which nmr delegates a command when the invocation does not say otherwise. */
4
6
  export declare const DELEGATE_ENV_VAR = "NMR_DELEGATE";
5
7
  /** The flag that runs the command on a host that `NMR_DELEGATE` or the user config names. */
@@ -56,9 +58,11 @@ export declare function composeGitSshCommand(env: NodeJS.ProcessEnv): string;
56
58
  * @internal
57
59
  */
58
60
  export declare function composeSshArgv(env: NodeJS.ProcessEnv, host: string, remoteCommand: string): string[];
61
+ /** Returns the key of the checkout on the host that serves the worktree at `toplevel` on the machine `hostname`. */
62
+ export declare function deriveCheckoutKey(hostname: string, toplevel: string): string;
59
63
  /**
60
- * Returns the checkout's path on the host, relative to its home directory: a key derived from this machine and the
61
- * repository, followed by the repository's own directory name, which nmr on the host reports as the root's scope.
64
+ * Returns the checkout's path on the host, relative to its home directory: the checkout's key, followed by the
65
+ * repository's own directory name, which nmr on the host reports as the root's scope.
62
66
  *
63
67
  * @internal
64
68
  */
@@ -74,11 +78,12 @@ export declare function readLastLine(output: string): string;
74
78
  * Runs an nmr invocation on `host` against a snapshot of the local working tree, streaming the command's output to
75
79
  * `stdout` and `stderr` and returning its exit status.
76
80
  *
77
- * The host keeps one checkout per local repository and host name, under `~/.cache/nmr/delegate/`, so concurrent
81
+ * The host keeps one checkout per worktree on each delegating machine, under `~/.cache/nmr/delegate/`, so concurrent
78
82
  * delegations from different worktrees do not share a checkout, and `node_modules` and build output persist
79
- * between runs. Check out, install, and run take one connection, whose session holds the checkout's lock, so that two
80
- * delegations from one worktree run one after the other; what a phase other than `run` prints is returned rather than
81
- * streamed, because it matters only when the phase fails.
83
+ * between runs. Before the push, the delegation removes the host's other checkouts that have not been used for
84
+ * longer than `removeCheckoutsAfterMs`, unless it is `null`. Check out, install, and run take one connection, whose
85
+ * session holds the checkout's lock, so that two delegations from one worktree run one after the other; what a phase
86
+ * other than `run` prints is returned rather than streamed, because it matters only when the phase fails.
82
87
  */
83
88
  export declare function runRemotely(options: {
84
89
  command: string;
@@ -88,6 +93,7 @@ export declare function runRemotely(options: {
88
93
  monorepoRoot: string;
89
94
  nmrArgs: readonly string[];
90
95
  remoteEnv: Readonly<Record<string, string>>;
96
+ removeCheckoutsAfterMs: number | null;
91
97
  snapshot: {
92
98
  sha: string;
93
99
  toplevel: string;
@@ -9,8 +9,11 @@ import { createBoundedBuffer } from "../helpers/createBoundedBuffer.js";
9
9
  import { quoteForShell } from "../helpers/quoteForShell.js";
10
10
  import { STALE_AFTER_MS } from "../machine-lock.js";
11
11
  import { formatFlagSpelling } from "../nmr-flags.js";
12
+ import { appendFilesystemReason } from "./host-filesystem.js";
12
13
  import { CHECKOUT_LOCK_PATH } from "./remote-host-session.js";
13
14
  import { composeSessionScript, createSessionDemultiplexer, formatDelegationWaitingLine } from "./remote-session.js";
15
+ import { CHECKOUT_STAMP_PATH, composeCheckoutRemovalScript } from "./removeStaleCheckouts.js";
16
+ export const CHECKOUTS_DIR = '.cache/nmr/delegate';
14
17
  export const DELEGATE_ENV_VAR = 'NMR_DELEGATE';
15
18
  export const DELEGATE_FLAG = formatFlagSpelling('delegate');
16
19
  export const DELEGATE_TO_FLAG = formatFlagSpelling('delegateTo');
@@ -28,9 +31,11 @@ export function composeSshArgv(env, host, remoteCommand) {
28
31
  ? ['ssh', ...sshArgs]
29
32
  : ['/bin/sh', '-c', `${sshCommand} "$@"`, sshCommand, ...sshArgs];
30
33
  }
34
+ export function deriveCheckoutKey(hostname, toplevel) {
35
+ return createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
36
+ }
31
37
  export function deriveCheckoutPath(hostname, toplevel) {
32
- const key = createHash('sha256').update(`${hostname}\0${toplevel}`).digest('hex').slice(0, 16);
33
- return path.posix.join('.cache/nmr/delegate', key, path.basename(toplevel));
38
+ return path.posix.join(CHECKOUTS_DIR, deriveCheckoutKey(hostname, toplevel), path.basename(toplevel));
34
39
  }
35
40
  export function formatMisplacedDelegationWarning(command, style) {
36
41
  return (`${STATUS_GLYPHS[style].warning.text} ${DELEGATE_FLAG}, ${DELEGATE_TO_FLAG}, and ${NO_DELEGATE_FLAG} after the ` +
@@ -44,15 +49,30 @@ export function readLastLine(output) {
44
49
  .findLast((line) => line !== '') ?? '');
45
50
  }
46
51
  export async function runRemotely(options) {
47
- const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, snapshot, stderr, stdout } = options;
52
+ const { command, cwd, env, host, monorepoRoot, nmrArgs, remoteEnv, removeCheckoutsAfterMs, snapshot, stderr, stdout, } = options;
48
53
  const checkoutPath = deriveCheckoutPath(os.hostname(), snapshot.toplevel);
49
54
  const checkoutDir = `"$HOME"/${quoteForShell(checkoutPath)}`;
50
- const prepare = await runSsh(host, `mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; }`, env);
55
+ function runOnHost(script) {
56
+ return runSsh(host, script, env);
57
+ }
58
+ const removal = composeCheckoutRemovalScript({
59
+ checkoutsDir: CHECKOUTS_DIR,
60
+ lockPath: CHECKOUT_LOCK_PATH,
61
+ ownKey: deriveCheckoutKey(os.hostname(), snapshot.toplevel),
62
+ removeAfterMs: removeCheckoutsAfterMs,
63
+ staleAfterMs: STALE_AFTER_MS,
64
+ stampPath: CHECKOUT_STAMP_PATH,
65
+ });
66
+ const prepare = await runSsh(host, `{ ${removal} || :; }; mkdir -p ${checkoutDir} && cd ${checkoutDir} && { [ -d .git ] || git init --quiet; } && ` +
67
+ `{ touch ${quoteForShell(CHECKOUT_STAMP_PATH)} || :; }`, env);
51
68
  if (prepare.exitCode !== 0) {
69
+ const isUnreachable = prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE;
52
70
  return {
53
71
  ok: false,
54
- isUnreachable: prepare.exitCode === DELEGATION_FAILURE_EXIT_CODE,
55
- message: `could not prepare ${host}`,
72
+ isUnreachable,
73
+ message: isUnreachable
74
+ ? `could not prepare ${host}`
75
+ : await appendFilesystemReason(`could not prepare ${host}`, prepare.output, runOnHost),
56
76
  output: prepare.output,
57
77
  phase: 'prepare',
58
78
  waitedMs: 0,
@@ -69,7 +89,8 @@ export async function runRemotely(options) {
69
89
  `${snapshot.sha}:${SNAPSHOT_REF}`,
70
90
  ], { cwd: snapshot.toplevel, env: { ...env, [SSH_COMMAND_ENV_VAR]: composeGitSshCommand(env) } });
71
91
  if (push.exitCode !== 0) {
72
- return { ok: false, message: `could not push to ${host}`, output: push.output, phase: 'push', waitedMs: 0 };
92
+ const message = await appendFilesystemReason(`could not push to ${host}`, push.output, runOnHost);
93
+ return { ok: false, message, output: push.output, phase: 'push', waitedMs: 0 };
73
94
  }
74
95
  const nonce = randomUUID();
75
96
  const marker = `__NMR_DELEGATE_${nonce}__`;
@@ -127,7 +148,8 @@ export async function runRemotely(options) {
127
148
  demultiplexer.flush();
128
149
  if (failure !== undefined) {
129
150
  const output = [demultiplexer.readOutput(), failure.output].filter((text) => text !== '').join('\n');
130
- return { ok: false, message: describeFailedPhase(failure, host), output, phase: failure.phase, waitedMs };
151
+ const message = await appendFilesystemReason(describeFailedPhase(failure, host), output, runOnHost);
152
+ return { ok: false, message, output, phase: failure.phase, waitedMs };
131
153
  }
132
154
  if (!demultiplexer.hasStarted()) {
133
155
  return {
@@ -0,0 +1,33 @@
1
+ /** The file whose mtime records a checkout's last use, relative to the checkout. */
2
+ export declare const CHECKOUT_STAMP_PATH = ".git/nmr-delegate-used";
3
+ /** What the removal needs, sent to it as JSON. */
4
+ export interface CheckoutRemovalParams {
5
+ /** The directory that contains one directory per checkout key, relative to the host's home directory. */
6
+ checkoutsDir: string;
7
+ /** The checkout's lock file, relative to the checkout. */
8
+ lockPath: string;
9
+ /** The key of the delegation's own checkout, which is never removed. */
10
+ ownKey: string;
11
+ /** Age past which an unused checkout is removed, or `null` to keep every checkout. */
12
+ removeAfterMs: number | null;
13
+ /** Age at which an unrefreshed lock file is abandoned. */
14
+ staleAfterMs: number;
15
+ /** The file whose mtime records the checkout's last use, relative to the checkout. */
16
+ stampPath: string;
17
+ }
18
+ /** Composes the shell command that runs `removeStaleCheckouts` on the host. */
19
+ export declare function composeCheckoutRemovalScript(params: CheckoutRemovalParams): string;
20
+ /**
21
+ * Runs on the host and removes every checkout other than the delegation's own that has not been used for longer than
22
+ * `removeAfterMs` and whose lock does not have a live holder. A checkout's last use is the newest mtime of its stamp,
23
+ * `.git/index`, and `.git/HEAD`, so that a checkout made by an nmr that does not write the stamp still has one.
24
+ *
25
+ * Each checkout is renamed at once to a `.removing-` sibling, which needs no new inode, so that removal works on a
26
+ * filesystem without any free inodes and a delegation that starts later creates a fresh checkout. A detached `rm`
27
+ * then deletes every `.removing-` sibling, including those that an interrupted removal left, so that the delegation
28
+ * does not wait for it. An entry that cannot be read or moved is skipped. It is sent as source, so it refers to
29
+ * nothing outside its own body.
30
+ *
31
+ * @internal
32
+ */
33
+ export declare function removeStaleCheckouts(params: CheckoutRemovalParams): void;
@@ -0,0 +1,98 @@
1
+ import { quoteForShell } from "../helpers/quoteForShell.js";
2
+ export const CHECKOUT_STAMP_PATH = '.git/nmr-delegate-used';
3
+ export function composeCheckoutRemovalScript(params) {
4
+ const source = `(${removeStaleCheckouts.toString()})(JSON.parse(process.argv[1]));`;
5
+ return `node -e ${quoteForShell(source)} ${quoteForShell(JSON.stringify(params))}`;
6
+ }
7
+ export function removeStaleCheckouts(params) {
8
+ const fs = process.getBuiltinModule('node:fs');
9
+ const childProcess = process.getBuiltinModule('node:child_process');
10
+ const crypto = process.getBuiltinModule('node:crypto');
11
+ const os = process.getBuiltinModule('node:os');
12
+ const path = process.getBuiltinModule('node:path');
13
+ const removeAfterMs = params.removeAfterMs;
14
+ if (removeAfterMs === null)
15
+ return;
16
+ const rootDir = path.join(os.homedir(), params.checkoutsDir);
17
+ const REMOVING_PREFIX = '.removing-';
18
+ const now = Date.now();
19
+ for (const key of readNames(rootDir)) {
20
+ if (key.startsWith('.') || key === params.ownKey)
21
+ continue;
22
+ const keyDir = path.join(rootDir, key);
23
+ const checkouts = readNames(keyDir);
24
+ if (checkouts.length !== 1)
25
+ continue;
26
+ const checkoutDir = path.join(keyDir, checkouts[0] ?? '');
27
+ const lastUsedMs = readLastUsedMs(checkoutDir);
28
+ if (lastUsedMs === undefined || now - lastUsedMs <= removeAfterMs)
29
+ continue;
30
+ if (isLockHeld(path.join(checkoutDir, params.lockPath)))
31
+ continue;
32
+ try {
33
+ fs.renameSync(keyDir, path.join(rootDir, `${REMOVING_PREFIX}${crypto.randomUUID()}`));
34
+ }
35
+ catch {
36
+ }
37
+ }
38
+ const moved = readNames(rootDir)
39
+ .filter((name) => name.startsWith(REMOVING_PREFIX))
40
+ .map((name) => path.join(rootDir, name));
41
+ if (moved.length > 0) {
42
+ childProcess.spawn('rm', ['-rf', ...moved], { detached: true, stdio: 'ignore' }).unref();
43
+ }
44
+ function isLockHeld(lockPath) {
45
+ let content;
46
+ let mtimeMs;
47
+ try {
48
+ content = fs.readFileSync(lockPath, 'utf8');
49
+ ({ mtimeMs } = fs.statSync(lockPath));
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ if (Date.now() - mtimeMs > params.staleAfterMs)
55
+ return false;
56
+ const pid = readHolderPid(content);
57
+ return pid === undefined || !isProcessGone(pid);
58
+ }
59
+ function isProcessGone(pid) {
60
+ try {
61
+ process.kill(pid, 0);
62
+ return false;
63
+ }
64
+ catch (error) {
65
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH';
66
+ }
67
+ }
68
+ function readHolderPid(content) {
69
+ try {
70
+ const value = JSON.parse(content);
71
+ if (typeof value !== 'object' || value === null || !('pid' in value))
72
+ return undefined;
73
+ return typeof value.pid === 'number' ? value.pid : undefined;
74
+ }
75
+ catch {
76
+ return undefined;
77
+ }
78
+ }
79
+ function readLastUsedMs(checkoutDir) {
80
+ let newestMs;
81
+ for (const file of [params.stampPath, '.git/index', '.git/HEAD']) {
82
+ try {
83
+ newestMs = Math.max(newestMs ?? 0, fs.statSync(path.join(checkoutDir, file)).mtimeMs);
84
+ }
85
+ catch {
86
+ }
87
+ }
88
+ return newestMs;
89
+ }
90
+ function readNames(dir) {
91
+ try {
92
+ return fs.readdirSync(dir);
93
+ }
94
+ catch {
95
+ return [];
96
+ }
97
+ }
98
+ }
@@ -2,6 +2,8 @@ import type { HostList } from './remote-run.js';
2
2
  /** The values that `delegate.when` accepts, each naming the runs that delegate without being asked. */
3
3
  declare const DELEGATE_WHEN_VALUES: readonly ["agent", "always"];
4
4
  export type DelegateWhen = (typeof DELEGATE_WHEN_VALUES)[number];
5
+ /** The age, in days, past which a delegation removes a host's unused checkouts when the user config does not set one. */
6
+ export declare const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
5
7
  /**
6
8
  * Renders a path for a message, with `~` standing for the home directory that `env` names when the path lies under
7
9
  * it.
@@ -30,6 +32,8 @@ export interface UserConfig {
30
32
  /** This machine's delegation hosts, and which runs delegate to them without being asked. */
31
33
  export interface UserDelegateConfig {
32
34
  hosts: HostList;
35
+ /** The age, in days, past which a delegation removes the host's unused checkouts; `false` keeps them. */
36
+ removeCheckoutsAfterDays?: number | false;
33
37
  when?: DelegateWhen;
34
38
  }
35
39
  export {};
@@ -5,6 +5,7 @@ import { assertRecognizedKeys } from "../config-validation.js";
5
5
  import { importConfigDefault } from "../helpers/importConfigDefault.js";
6
6
  import { resolveXdgBaseDir } from "../helpers/resolveXdgBaseDir.js";
7
7
  const DELEGATE_WHEN_VALUES = ['agent', 'always'];
8
+ export const DEFAULT_REMOVE_CHECKOUTS_AFTER_DAYS = 7;
8
9
  export function formatDisplayPath(filePath, env) {
9
10
  const home = env['HOME'];
10
11
  if (home === undefined || !path.isAbsolute(home)) {
@@ -30,7 +31,7 @@ export function resolveUserConfigPath(env) {
30
31
  }
31
32
  const USER_CONFIG_RELATIVE_PATH = path.join('nmr', 'config.ts');
32
33
  const RECOGNIZED_KEYS = ['delegate'];
33
- const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'when'];
34
+ const RECOGNIZED_DELEGATE_KEYS = ['hosts', 'removeCheckoutsAfterDays', 'when'];
34
35
  function isDelegateWhen(value) {
35
36
  const values = DELEGATE_WHEN_VALUES;
36
37
  return values.includes(value);
@@ -45,16 +46,18 @@ function validateDelegateField(value, configPath) {
45
46
  }
46
47
  assertRecognizedKeys(delegate, RECOGNIZED_DELEGATE_KEYS, configPath, 'delegate.');
47
48
  const hosts = validateHosts(delegate['hosts'], configPath);
49
+ const removeCheckoutsAfterDays = validateRemoveCheckoutsAfterDays(delegate['removeCheckoutsAfterDays'], configPath);
50
+ const removal = removeCheckoutsAfterDays === undefined ? {} : { removeCheckoutsAfterDays };
48
51
  const when = delegate['when'];
49
52
  if (when === undefined) {
50
- return { hosts };
53
+ return { hosts, ...removal };
51
54
  }
52
55
  if (!isDelegateWhen(when)) {
53
56
  const renderedValue = typeof when === 'string' ? when : JSON.stringify(when);
54
57
  throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.when\` is \`${renderedValue}\`, which is not one of: ` +
55
58
  DELEGATE_WHEN_VALUES.join(', '));
56
59
  }
57
- return { hosts, when };
60
+ return { hosts, ...removal, when };
58
61
  }
59
62
  function validateHosts(value, configPath) {
60
63
  const names = Array.isArray(value) && value.every((host) => typeof host === 'string')
@@ -67,6 +70,13 @@ function validateHosts(value, configPath) {
67
70
  }
68
71
  return [first, ...rest];
69
72
  }
73
+ function validateRemoveCheckoutsAfterDays(value, configPath) {
74
+ if (value === undefined || value === false || (typeof value === 'number' && Number.isFinite(value) && value > 0)) {
75
+ return value;
76
+ }
77
+ throw new UserError(`Invalid nmr config at ${configPath}: \`delegate.removeCheckoutsAfterDays\` must be a positive number of days, ` +
78
+ 'or `false` to keep every checkout');
79
+ }
70
80
  function validateUserConfig(value, configPath) {
71
81
  if (!isRecord(value)) {
72
82
  throw new UserError(`Invalid nmr config at ${configPath}: expected an object, got ${typeof value}`);
package/docs/delegate.md CHANGED
@@ -12,18 +12,19 @@ A machine that runs several worktrees' checks at once runs out of memory, and th
12
12
 
13
13
  ## The user config
14
14
 
15
- `~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts and when to delegate to them without being asked. It honors one key, `delegate`:
15
+ `~/.config/nmr/config.ts`, or `$XDG_CONFIG_HOME/nmr/config.ts` when `XDG_CONFIG_HOME` is set, declares this machine's hosts, when to delegate to them without being asked, and how long a host keeps an unused checkout. It honors one key, `delegate`:
16
16
 
17
17
  ```ts
18
18
  export default {
19
- delegate: { hosts: ['imac2', 'imac3'], when: 'agent' },
19
+ delegate: { hosts: ['imac2', 'imac3'], removeCheckoutsAfterDays: 14, when: 'agent' },
20
20
  };
21
21
  ```
22
22
 
23
23
  - **`hosts`** lists the hosts in order of preference. It is required whenever `delegate` is present.
24
+ - **`removeCheckoutsAfterDays`** sets how long a checkout on a host may go unused before a delegation from this machine removes it, in days; `false` keeps every checkout. It defaults to 7. See [unused checkouts](#unused-checkouts).
24
25
  - **`when`** turns on delegation by default: `'agent'` for every run whose environment contains an agent-harness marker, the variables that also select [quiet output](reporting.md#configuring), extended by the repo's `output.extraAgentEnvVars`; `'always'` for every run. Without `when`, nmr delegates only on request, and `hosts` serves `--delegate`.
25
26
 
26
- nmr reads the file only when a decision reaches it, and an unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
27
+ nmr reads the file when a run reaches rule 7 of [choosing where a command runs](#choosing-where-a-command-runs), and for every delegation, however it was requested, to learn `removeCheckoutsAfterDays`; a run that an earlier rule keeps here does not read it. An unrecognized key, a malformed `hosts`, or an unknown `when` fails that run with an error naming the file and the key. The file cannot import anything from a repo, so it is a plain object rather than a `defineConfig` call. When a directory above it contains a `package.json` without `"type"`, such as a stray `~/package.json`, Node warns on every load that the file's module type is not specified; a `~/.config/nmr/package.json` containing `{ "type": "module" }` silences it.
27
28
 
28
29
  A pass that a host earns satisfies a later delegation to that host, but not a run here: Under `when`, a run that `--no-delegate` keeps here re-runs a check that the host already passed. See [consulting the cache first](#consulting-the-cache-first).
29
30
 
@@ -100,11 +101,21 @@ $ nmr --delegate-to imac2 ci
100
101
 
101
102
  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.
102
103
 
103
- 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. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts. nmr does not remove these checkouts.
104
+ 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 worktree 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. When the workspace packages' `bin` entries differ from those of the checkout's last successful install, nmr first removes pnpm's workspace state, so that the install links the new bins, which an unchanged lockfile would otherwise let pnpm skip; that install reruns the workspace's `prepare` scripts.
104
105
 
105
106
  - **Ignored files stay here.** A command that needs a gitignored file, such as a local `.env`, does not find it on the host.
106
107
  - **A repository with submodules cannot be delegated**, nor can a tree containing a nested repository, because the snapshot does not include their content. A delegation flag refuses such a tree; under `NMR_DELEGATE` or the user config, the command runs here, and nmr prints one line saying why.
107
108
 
109
+ ### Unused checkouts
110
+
111
+ A worktree adds a checkout on each host to which it delegates, with its own `node_modules`, so the checkouts accumulate as worktrees come and go. Before it pushes, each delegation removes the host's other checkouts that have not been used for longer than the delegating machine's `removeCheckoutsAfterDays`, whichever machine created them:
112
+
113
+ - **A checkout's last use** is the last delegation to it. A checkout made by an nmr that does not record its use is dated by its last check out.
114
+ - **A checkout in use is kept**: one whose lock, described below, has a live holder, whatever its age. The delegation's own checkout is never removed.
115
+ - **The delegation does not wait for the deletion.** It moves each checkout aside at once and deletes it in the background, so a delegation that starts later creates a fresh checkout and installs it from scratch.
116
+ - **The removal runs before the push**, so a host whose filesystem the checkouts have filled recovers without anyone deleting files by hand: The delegation that removes the checkouts may still fail, and the next one finds the room. Removal never fails a delegation.
117
+ - **Each machine applies its own age.** A machine whose user config says `false` removes nothing, while another machine delegating to the same host still removes the checkouts that are older than its own age.
118
+
108
119
  Delegations from the same worktree share its checkout on the host, so they run one at a time. A delegation that starts while another holds the checkout waits for it, saying so on stderr, then runs:
109
120
 
110
121
  ```console
@@ -128,7 +139,13 @@ A failure of the delegation itself, rather than of the command, exits 255, the s
128
139
  ❌ my-repo: ci: failed on 📡 imac2 in 10.0s (exit 255) — could not prepare imac2
129
140
  ```
130
141
 
131
- 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>`.
142
+ The steps are `prepare`, which reaches the host, removes its [unused checkouts](#unused-checkouts), and creates the checkout, `push`, `check out`, and `install`. A connection lost while the command runs is reported as `lost connection to <host>`.
143
+
144
+ A step that fails with `No space left on device` reports which resource the host's filesystem has run out of, because a write fails the same way for both:
145
+
146
+ ```console
147
+ ❌ my-repo: ci: failed on 📡 imac2 in 3.1s (exit 255) — could not push to imac2: the host's filesystem has run out of inodes
148
+ ```
132
149
 
133
150
  ### When no host answers
134
151
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/nmr",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "description": "Context-aware script runner for pnpm monorepos",
6
6
  "keywords": [
@@ -84,7 +84,7 @@
84
84
  "@williamthorsen/toolbelt.errors": "0.10.0",
85
85
  "@williamthorsen/toolbelt.filesystem": "0.10.2",
86
86
  "glob": "14.0.4",
87
- "prettier": "3.9.10",
87
+ "prettier": "3.10.0",
88
88
  "prettier-plugin-sh": "0.20.2",
89
89
  "taze": "21.3.0",
90
90
  "unconfig": "7.5.0"