@north-light/crouter 0.3.305 → 0.3.306

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,81 @@
1
+ // Run with: npm run test:integration
2
+ //
3
+ // Regression: crouter's read-only git calls must not take `.git/index.lock`.
4
+ //
5
+ // `git status` refreshes the index stat cache and writes the index back, which
6
+ // means creating `.git/index.lock` and renaming it over `.git/index`. A process
7
+ // killed between those two steps orphans the lock, and it then blocks every
8
+ // later writer in that checkout with no owning process left to name. Agent
9
+ // processes are killed mid-command routinely — a closed node, a cancelled daemon
10
+ // tick, a timed-out shell — so the residue accumulates in a shared checkout
11
+ // until someone removes it by hand.
12
+ //
13
+ // `GIT_OPTIONAL_LOCKS=0` suppresses exactly that optional lock. These tests pin
14
+ // the two paths that reach git: the shared spawner in `core/git.ts`, and the
15
+ // bearings project-context block, which renders on every node boot and revive
16
+ // and is therefore the most frequent git caller in the runtime.
17
+ import { test } from 'node:test';
18
+ import assert from 'node:assert/strict';
19
+ import { mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { gitSync } from '../../git.js';
24
+ import { buildProjectContextBlock } from '../../runtime/bearings-render.js';
25
+ /** A repo with one committed file whose stat cache is deliberately stale, so a
26
+ * status that refreshes the index has a reason to write it back. Without the
27
+ * stale stat there is nothing to refresh and the assertion passes vacuously. */
28
+ function repoWithStaleStatCache() {
29
+ const dir = mkdtempSync(join(tmpdir(), 'crtr-git-locks-'));
30
+ const git = (args) => {
31
+ const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
32
+ assert.equal(result.status, 0, `git ${args.join(' ')}: ${result.stderr}`);
33
+ };
34
+ writeFileSync(join(dir, 'f.txt'), 'a\n', 'utf8');
35
+ git(['init', '-q', '.']);
36
+ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'add', 'f.txt']);
37
+ git(['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init']);
38
+ // Move the file's mtime forward so git's cached stat no longer matches.
39
+ const future = new Date(Date.now() + 10_000);
40
+ utimesSync(join(dir, 'f.txt'), future, future);
41
+ return dir;
42
+ }
43
+ function indexMtime(dir) {
44
+ return statSync(join(dir, '.git', 'index')).mtimeMs;
45
+ }
46
+ test('gitSync status does not rewrite the index', (t) => {
47
+ if (spawnSync('git', ['--version']).status !== 0) {
48
+ t.skip('git is unavailable');
49
+ return;
50
+ }
51
+ const dir = repoWithStaleStatCache();
52
+ try {
53
+ const before = indexMtime(dir);
54
+ const result = gitSync(['status', '--porcelain'], dir);
55
+ assert.equal(result.status, 0, result.stderr);
56
+ // Suppressing the lock must not change what the command reports.
57
+ assert.equal(result.stdout, '');
58
+ assert.equal(indexMtime(dir), before, 'status took .git/index.lock and rewrote the index');
59
+ }
60
+ finally {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ });
64
+ test('the bearings project-context block does not rewrite the index', (t) => {
65
+ if (spawnSync('git', ['--version']).status !== 0) {
66
+ t.skip('git is unavailable');
67
+ return;
68
+ }
69
+ const dir = repoWithStaleStatCache();
70
+ try {
71
+ const before = indexMtime(dir);
72
+ const block = buildProjectContextBlock(dir);
73
+ // The snapshot still reports real git state; it just does not lock to do it.
74
+ assert.match(block, /Git: /);
75
+ assert.match(block, /clean/);
76
+ assert.equal(indexMtime(dir), before, 'the bearings snapshot took .git/index.lock');
77
+ }
78
+ finally {
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
package/dist/core/git.js CHANGED
@@ -1,7 +1,21 @@
1
1
  import { spawn, spawnSync } from 'node:child_process';
2
2
  import { network } from './errors.js';
3
+ /** Git takes `.git/index.lock` to refresh the index stat cache even during
4
+ * otherwise read-only commands — `status` is the common one. Killed between
5
+ * creating that lock and renaming it over the index, the lock orphans and
6
+ * blocks every later writer in the checkout with no owning process left to
7
+ * name. Agent processes are killed mid-command routinely (a closed node, a
8
+ * cancelled daemon tick, a timed-out shell), so the residue accumulates.
9
+ *
10
+ * `GIT_OPTIONAL_LOCKS=0` suppresses exactly those optional locks and nothing
11
+ * else: a commit, rebase, or `add` still takes the lock it requires, and
12
+ * command output is byte-identical. Built per call rather than snapshotted at
13
+ * module load, so a later change to the ambient environment still reaches git. */
14
+ function gitEnv(base = process.env) {
15
+ return { ...base, GIT_OPTIONAL_LOCKS: '0' };
16
+ }
3
17
  export function gitSync(args, cwd, input, options = {}) {
4
- const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, timeout: options.timeoutMs });
18
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', input, env: gitEnv(), timeout: options.timeoutMs });
5
19
  const status = typeof res.status === 'number' ? res.status : 1;
6
20
  const completed = typeof res.status === 'number';
7
21
  const stdout = typeof res.stdout === 'string' ? res.stdout : '';
@@ -12,7 +26,7 @@ export function gitSync(args, cwd, input, options = {}) {
12
26
  }
13
27
  export async function gitAsync(args, cwd, input, options = {}) {
14
28
  return new Promise((resolve) => {
15
- const child = spawn('git', args, { cwd, ...(options.env === undefined ? {} : { env: options.env }) });
29
+ const child = spawn('git', args, { cwd, env: gitEnv(options.env) });
16
30
  if (input === undefined)
17
31
  child.stdin.end();
18
32
  else
@@ -57,8 +57,8 @@ function directoryListingLines(cwd) {
57
57
  return ['Directory listing: unavailable (cwd missing or unreadable).'];
58
58
  }
59
59
  }
60
- function runCommand(cwd, cmd, args) {
61
- const result = spawnSync(cmd, args, { cwd, encoding: 'utf8' });
60
+ function runCommand(cwd, cmd, args, env) {
61
+ const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', ...(env === undefined ? {} : { env }) });
62
62
  if (result.error !== undefined) {
63
63
  return { ok: false, stdout: '', stderr: result.error.message };
64
64
  }
@@ -68,8 +68,17 @@ function runCommand(cwd, cmd, args) {
68
68
  stderr: (result.stderr ?? '').trim(),
69
69
  };
70
70
  }
71
+ /** This snapshot renders on every node boot and every revive, which makes it the
72
+ * most frequent git caller in the runtime — and boot is exactly when a process
73
+ * gets killed. `git status` would take `.git/index.lock` to refresh the stat
74
+ * cache; suppressing that optional lock keeps an interrupted render from
75
+ * orphaning a lock that then blocks every writer in the user's checkout.
76
+ * Only optional locks are affected, and the output is unchanged. */
77
+ function gitEnv() {
78
+ return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
79
+ }
71
80
  function runGit(cwd, args) {
72
- const result = runCommand(cwd, 'git', args);
81
+ const result = runCommand(cwd, 'git', args, gitEnv());
73
82
  return result.ok ? result.stdout : '';
74
83
  }
75
84
  function parseWorktrees(lines) {
@@ -98,7 +107,7 @@ function parseWorktrees(lines) {
98
107
  * benign state. Every other probe here is genuinely optional and already
99
108
  * falls back to 'unknown'/'HEAD'/a zero count on failure, which stays as-is. */
100
109
  function gitSnapshotLines(cwd) {
101
- const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree']);
110
+ const probe = runCommand(cwd, 'git', ['rev-parse', '--is-inside-work-tree'], gitEnv());
102
111
  if (!probe.ok) {
103
112
  if (/not a git repository/i.test(probe.stderr)) {
104
113
  return ['Git: not a git repository.'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.305",
3
+ "version": "0.3.306",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.305",
3
+ "version": "0.3.306",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.305",
9
+ "version": "0.3.306",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "workspaces": [
@@ -5206,17 +5206,17 @@
5206
5206
  },
5207
5207
  "packages/crouter-api": {
5208
5208
  "name": "@north-light/crouter-api",
5209
- "version": "0.3.305",
5209
+ "version": "0.3.306",
5210
5210
  "license": "UNLICENSED"
5211
5211
  },
5212
5212
  "packages/crouter-env-docker": {
5213
5213
  "name": "@north-light/crouter-env-docker",
5214
- "version": "0.3.305",
5214
+ "version": "0.3.306",
5215
5215
  "license": "UNLICENSED"
5216
5216
  },
5217
5217
  "packages/crouter-sdk": {
5218
5218
  "name": "@north-light/crouter-sdk",
5219
- "version": "0.3.305",
5219
+ "version": "0.3.306",
5220
5220
  "license": "UNLICENSED",
5221
5221
  "dependencies": {
5222
5222
  "@north-light/crouter-api": "^0.3.295"