@vzn/vx 0.0.115 → 0.0.117

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.115",
3
+ "version": "0.0.117",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.115",
21
- "@vzn/vx-linux-arm64": "0.0.115",
22
- "@vzn/vx-darwin-x64": "0.0.115",
23
- "@vzn/vx-darwin-arm64": "0.0.115"
20
+ "@vzn/vx-linux-x64": "0.0.117",
21
+ "@vzn/vx-linux-arm64": "0.0.117",
22
+ "@vzn/vx-darwin-x64": "0.0.117",
23
+ "@vzn/vx-darwin-arm64": "0.0.117"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -478,7 +478,7 @@ export class Cache implements CacheLayer {
478
478
  } | null
479
479
  )?.value
480
480
  } finally {
481
- db.close()
481
+ closeDb(db)
482
482
  }
483
483
  } catch {
484
484
  return null
@@ -545,13 +545,23 @@ export class Cache implements CacheLayer {
545
545
  return read()
546
546
  } catch (err) {
547
547
  if (!unreadableIndex(err)) throw err
548
- this.db.close()
548
+ try {
549
+ closeDb(this.db)
550
+ } catch {
551
+ // The refusal below is the error worth reporting.
552
+ }
549
553
  throw unreadableIndexError(dbFile, err)
550
554
  }
551
555
  }
552
556
  readable(() => {
553
557
  this.db.exec('PRAGMA busy_timeout = 5000')
554
558
  this.db.exec('PRAGMA journal_mode = WAL')
559
+ // Keep `-wal` and `-shm` when the last connection closes. A closed
560
+ // index otherwise deletes them, and a cache directory this user may
561
+ // not write (a CI cache restored read-only) cannot make them again:
562
+ // every open there failed `attempt to write a readonly database`
563
+ // before the not-writable refusal could name the directory (O-10).
564
+ if (!absent) this.db.fileControl(SQLITE_FCNTL_PERSIST_WAL, 1)
555
565
  this.db.exec('PRAGMA synchronous = NORMAL')
556
566
  this.db.exec('PRAGMA foreign_keys = ON')
557
567
  this.db.exec(`
@@ -622,7 +632,11 @@ export class Cache implements CacheLayer {
622
632
  })
623
633
  .immediate()
624
634
  } catch (err) {
625
- this.db.close()
635
+ try {
636
+ closeDb(this.db)
637
+ } catch {
638
+ // The migration's own error is the one worth reporting.
639
+ }
626
640
  throw err
627
641
  }
628
642
  }
@@ -1885,7 +1899,7 @@ export class Cache implements CacheLayer {
1885
1899
  // `rm -rf .vx/cache`): macOS answers SQLITE_IOERR_VNODE on a
1886
1900
  // write to an unlinked file where Linux happily writes on.
1887
1901
  }
1888
- this.db.close()
1902
+ closeDb(this.db)
1889
1903
  }
1890
1904
 
1891
1905
  private tarPath(hash: string): string {
@@ -1893,6 +1907,25 @@ export class Cache implements CacheLayer {
1893
1907
  }
1894
1908
  }
1895
1909
 
1910
+ /**
1911
+ * SQLite's file-control opcode (sqlite3.h). Spelled here, not imported from
1912
+ * `bun:sqlite`'s `constants`: the playground bundles this file against a
1913
+ * shim that exports only `Database`.
1914
+ */
1915
+ const SQLITE_FCNTL_PERSIST_WAL = 10
1916
+
1917
+ /**
1918
+ * Close for real. A plain `close()` leaves the connection open while any
1919
+ * statement from `db.prepare()` lives (bun:sqlite 1.4.2 defers it, as
1920
+ * `sqlite3_close_v2` does), so `cache.db` and its `-wal` and `-shm` stayed
1921
+ * open after `Cache.close()`: a leaked descriptor per run for an embedder,
1922
+ * and on Windows a cache directory nothing could delete (O-10).
1923
+ * `close(true)` finalizes them and closes.
1924
+ */
1925
+ function closeDb(db: Database): void {
1926
+ db.close(true)
1927
+ }
1928
+
1896
1929
  /**
1897
1930
  * Refuse an artifact whose file names no restore can materialise as one
1898
1931
  * tree, or (given the task's declared outputs) that names a file outside
@@ -0,0 +1,180 @@
1
+ // Which settled paths are changes: `vx watch`'s judgement, run one debounce
2
+ // window after events stop, on what the tree settled to. The loop in
3
+ // watch.ts owns when to judge and what to run; this owns what counts.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { xxh3 } from '../util/index.js'
8
+ import { gitIgnored } from './watch-filter.js'
9
+ import { modifiedBefore } from './watch-fs.js'
10
+
11
+ const ABSENT = -1n
12
+
13
+ function settledState(abs: string): bigint {
14
+ let st: fs.Stats
15
+ try {
16
+ st = fs.statSync(abs)
17
+ } catch {
18
+ return ABSENT
19
+ }
20
+ if (!st.isDirectory()) {
21
+ try {
22
+ return xxh3(fs.readFileSync(abs))
23
+ } catch {
24
+ return ABSENT
25
+ }
26
+ }
27
+ const entries: string[] = []
28
+ try {
29
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
30
+ let size = 0
31
+ if (e.isFile()) {
32
+ try {
33
+ size = fs.statSync(path.join(abs, e.name)).size
34
+ } catch {
35
+ size = -1
36
+ }
37
+ }
38
+ entries.push(`${e.name}\0${e.isDirectory() ? 'd' : size}`)
39
+ }
40
+ } catch {
41
+ return ABSENT
42
+ }
43
+ entries.sort()
44
+ return xxh3(Buffer.from(entries.join('\n')))
45
+ }
46
+
47
+ /** What the judgement reads from the loop, which owns it and may replace it. */
48
+ export interface JudgeContext {
49
+ workspaceRoot: string
50
+ /** The instant the watchers went live, on the mtime clock (`fsClockNow`). */
51
+ armedAt: number
52
+ /** Whether a persistent server the last cycle started is still running. */
53
+ held(): boolean
54
+ /** Projects whose uncached task may read a git-ignored file. */
55
+ uncached(): ReadonlySet<string>
56
+ }
57
+
58
+ export class ChangeJudge {
59
+ // Paths that fired since the last judgement, first label wins. The state
60
+ // check runs when the timer fires, on SETTLED state: per event it is
61
+ // wrong on Linux, where a shell redirect truncates the file (one event,
62
+ // empty) and then writes it (another, full), so consecutive events never
63
+ // agree and a self-write loops anyway (CI, 2026-09-04: 9 re-runs where
64
+ // macOS, which coalesces the two, saw 2). While a cycle runs, nothing is
65
+ // judged: the run's own writes are mid-flight (a `dist` deleted and not
66
+ // yet rebuilt is a state the tree will not keep), so the paths wait and
67
+ // are judged one window after the run ends, all together — an edit made
68
+ // meanwhile still differs from what the loop last saw and re-runs.
69
+ readonly pending = new Map<string, string>()
70
+ /** The last cycle's wall window; a write inside it is the run's own. */
71
+ lastCycle: { start: number; end: number } | undefined
72
+ // Declared outputs are ignored by PATH above. A task with no `cache`
73
+ // block declares none and still writes into its project, and the
74
+ // watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
75
+ // writes the same bytes, the event re-runs — forever (the init
76
+ // walkthrough, 2026-09-04: every fresh workspace, since `init` emits no
77
+ // cache block). An undeclared write is caught by STATE, judged on what
78
+ // has settled (see `trigger`): a path whose settled state equals what
79
+ // this loop last saw for it is not a change. A file's state is its
80
+ // bytes; a directory's is its entries' names and sizes (a nested edit
81
+ // arrives as that path's own event); a path that is gone is one more
82
+ // state. A real edit changes the state; a first sighting passes through.
83
+ // So `rm -rf dist && tsc` — the shape of most build scripts — settles
84
+ // to the same `dist` it left and is one redundant cycle, not a loop
85
+ // (2026-09-10: 780 executions in two minutes from one edit, when a
86
+ // deletion and a directory each passed the gate unconditionally).
87
+ private readonly lastState = new Map<string, bigint>()
88
+ // A path that starts cycle after cycle from the run's own writes is a
89
+ // task rewriting a file with different bytes every run (a pid file, a
90
+ // timestamped log): the state gate cannot settle it, and nothing here
91
+ // can tell the third such write from a user's third save mid-run — so
92
+ // watch names it once, with the remedy, and keeps going. "The run's
93
+ // own write" is read off the path itself: its mtime falls inside the
94
+ // previous cycle's window. Not off which judgement started the cycle:
95
+ // macOS delivers a run's writes late, after the loop's own post-run
96
+ // judgement found nothing and broke out, so there every such cycle
97
+ // starts from the idle timer (CI, 2026-09-16: the storm ran, the
98
+ // notice never came).
99
+ private readonly streak = { abs: '', n: 0 }
100
+ private readonly noticed = new Set<string>()
101
+
102
+ constructor(private readonly ctx: JudgeContext) {}
103
+
104
+ // A path this loop has never judged is a change only if it moved since
105
+ // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
106
+ // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
107
+ // made — FSEvents hands a stream what landed just before it started),
108
+ // and a first sighting used to pass unconditionally; the path's mtime
109
+ // says which side of the arm it belongs to. A path already gone is a
110
+ // change: a deletion has no date to read.
111
+ private sameState(abs: string): boolean {
112
+ const state = settledState(abs)
113
+ const prev = this.lastState.get(abs)
114
+ this.lastState.set(abs, state)
115
+ if (prev !== undefined) return prev === state
116
+ return state !== ABSENT && modifiedBefore(abs, this.ctx.armedAt)
117
+ }
118
+
119
+ private writtenDuringLastCycle(abs: string, openWhileHeld = false): boolean {
120
+ // A server the last cycle left running is still that cycle's: its
121
+ // writes land after the cycle ended, and with a closed window a dev
122
+ // server that rewrites a log in its project restarted itself forever
123
+ // with no word of it, 12 restarts in 8 s (item 948). The initial run's
124
+ // server is one too, from the arm on.
125
+ const open = openWhileHeld && this.ctx.held()
126
+ if (this.lastCycle === undefined && !open) return false
127
+ try {
128
+ const m = fs.statSync(abs).mtimeMs
129
+ const start = this.lastCycle?.start ?? this.ctx.armedAt
130
+ return m >= start && (open || m <= this.lastCycle!.end)
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ judge(): string | undefined {
137
+ const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
138
+ let first: string | undefined
139
+ let firstAbs: string | undefined
140
+ // `sameState` before the `first` test, never after it: it is what
141
+ // RECORDS a path's settled state, and every path this judgement saw
142
+ // must be recorded even though only the first change names the cycle.
143
+ // Short-circuiting after the winner leaves the rest unjudged, and
144
+ // their next event is a first sighting stamped after the arm — so a
145
+ // batch edit (a `git checkout`) makes the same bytes written to any
146
+ // of the others a change.
147
+ // A git-ignored path keys nothing, but a task with no cache has no key:
148
+ // it reads what it likes, and its `.env.local` edit re-ran nothing
149
+ // (item 947). Under such a project an ignored path is judged when the
150
+ // user wrote it; one the last cycle wrote (the pid file an uncached
151
+ // task rewrites every run, the loop the filter exists for) stays out.
152
+ const editForUncached = (p: string): boolean =>
153
+ [...this.ctx.uncached()].some((dir) => p.startsWith(dir + path.sep)) &&
154
+ !this.writtenDuringLastCycle(p)
155
+ for (const [p, l] of this.pending) {
156
+ if (ignored.has(p) && !editForUncached(p)) continue
157
+ if (!this.sameState(p) && first === undefined) {
158
+ first = l
159
+ firstAbs = p
160
+ }
161
+ }
162
+ this.pending.clear()
163
+ const byServer = firstAbs !== undefined && this.ctx.held()
164
+ if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, true)) {
165
+ this.streak.n = 0
166
+ return first
167
+ }
168
+ this.streak.n = this.streak.abs === firstAbs ? this.streak.n + 1 : 1
169
+ this.streak.abs = firstAbs
170
+ if (this.streak.n >= 3 && !this.noticed.has(firstAbs)) {
171
+ this.noticed.add(firstAbs)
172
+ process.stdout.write(
173
+ byServer
174
+ ? `vx watch: ${first} has started 3 cycles in a row, written while a server the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
175
+ : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
176
+ )
177
+ }
178
+ return first
179
+ }
180
+ }
package/src/cli/watch.ts CHANGED
@@ -14,7 +14,6 @@
14
14
 
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
- import { xxh3 } from '../util/index.js'
18
17
  import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
19
18
  import { seeHelp } from './help.js'
20
19
  import {
@@ -35,7 +34,6 @@ import {
35
34
  } from '../workspace/index.js'
36
35
  import { type CliLoadOptions, loadCliWorkspace } from './workspace-config.js'
37
36
  import {
38
- gitIgnored,
39
37
  isIgnoredWatchPath,
40
38
  isWorkspaceConfigFile,
41
39
  isWorkspaceFingerprintFile,
@@ -46,12 +44,12 @@ import {
46
44
  import {
47
45
  armWatcher,
48
46
  fsClockNow,
49
- modifiedBefore,
50
47
  pollWatcher,
51
48
  POLL_INTERVAL_MS,
52
49
  WATCH_PROBE_TIMEOUT_MS,
53
50
  type WatchHandle,
54
51
  } from './watch-fs.js'
52
+ import { ChangeJudge } from './watch-judge.js'
55
53
  import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
56
54
 
57
55
  /** One line for a watcher or re-read the OS refused; the loop goes on without it. */
@@ -336,170 +334,21 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
336
334
  let held = args.held
337
335
 
338
336
  // Reentrancy guard — never two orchestrator runs in flight. Events that
339
- // land while one is running wait in `pendingPaths` and are judged, on
337
+ // land while one is running wait in `changes.pending` and are judged, on
340
338
  // settled bytes, one debounce window after it ends.
341
339
  let running = false
342
340
  /** The cycle in flight, so the stop path can wait for its teardown before resolving. */
343
341
  let inFlight: Promise<void> = Promise.resolve()
344
342
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
345
343
 
346
- // Declared outputs are ignored by PATH above. A task with no `cache`
347
- // block declares none and still writes into its project, and the
348
- // watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
349
- // writes the same bytes, the event re-runs — forever (the init
350
- // walkthrough, 2026-09-04: every fresh workspace, since `init` emits no
351
- // cache block). An undeclared write is caught by STATE, judged on what
352
- // has settled (see `trigger`): a path whose settled state equals what
353
- // this loop last saw for it is not a change. A file's state is its
354
- // bytes; a directory's is its entries' names and sizes (a nested edit
355
- // arrives as that path's own event); a path that is gone is one more
356
- // state. A real edit changes the state; a first sighting passes through.
357
- // So `rm -rf dist && tsc` — the shape of most build scripts — settles
358
- // to the same `dist` it left and is one redundant cycle, not a loop
359
- // (2026-09-10: 780 executions in two minutes from one edit, when a
360
- // deletion and a directory each passed the gate unconditionally).
361
- const ABSENT = -1n
362
- const lastState = new Map<string, bigint>()
363
- const settledState = (abs: string): bigint => {
364
- let st: fs.Stats
365
- try {
366
- st = fs.statSync(abs)
367
- } catch {
368
- return ABSENT
369
- }
370
- if (!st.isDirectory()) {
371
- try {
372
- return xxh3(fs.readFileSync(abs))
373
- } catch {
374
- return ABSENT
375
- }
376
- }
377
- const entries: string[] = []
378
- try {
379
- for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
380
- let size = 0
381
- if (e.isFile()) {
382
- try {
383
- size = fs.statSync(path.join(abs, e.name)).size
384
- } catch {
385
- size = -1
386
- }
387
- }
388
- entries.push(`${e.name}\0${e.isDirectory() ? 'd' : size}`)
389
- }
390
- } catch {
391
- return ABSENT
392
- }
393
- entries.sort()
394
- return xxh3(Buffer.from(entries.join('\n')))
395
- }
396
- // A path this loop has never judged is a change only if it moved since
397
- // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
398
- // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
399
- // made — FSEvents hands a stream what landed just before it started),
400
- // and a first sighting used to pass unconditionally; the path's mtime
401
- // says which side of the arm it belongs to. A path already gone is a
402
- // change: a deletion has no date to read.
403
- const sameState = (abs: string): boolean => {
404
- const state = settledState(abs)
405
- const prev = lastState.get(abs)
406
- lastState.set(abs, state)
407
- if (prev !== undefined) return prev === state
408
- return state !== ABSENT && modifiedBefore(abs, armedAt)
409
- }
410
-
411
- // Paths that fired since the last judgement, first label wins. The state
412
- // check runs when the timer fires, on SETTLED state: per event it is
413
- // wrong on Linux, where a shell redirect truncates the file (one event,
414
- // empty) and then writes it (another, full), so consecutive events never
415
- // agree and a self-write loops anyway (CI, 2026-09-04: 9 re-runs where
416
- // macOS, which coalesces the two, saw 2). While a cycle runs, nothing is
417
- // judged: the run's own writes are mid-flight (a `dist` deleted and not
418
- // yet rebuilt is a state the tree will not keep), so the paths wait and
419
- // are judged one window after the run ends, all together — an edit made
420
- // meanwhile still differs from what the loop last saw and re-runs.
421
- const pendingPaths = new Map<string, string>()
422
- // A path that starts cycle after cycle from the run's own writes is a
423
- // task rewriting a file with different bytes every run (a pid file, a
424
- // timestamped log): the state gate cannot settle it, and nothing here
425
- // can tell the third such write from a user's third save mid-run — so
426
- // watch names it once, with the remedy, and keeps going. "The run's
427
- // own write" is read off the path itself: its mtime falls inside the
428
- // previous cycle's window. Not off which judgement started the cycle:
429
- // macOS delivers a run's writes late, after the loop's own post-run
430
- // judgement found nothing and broke out, so there every such cycle
431
- // starts from the idle timer (CI, 2026-09-16: the storm ran, the
432
- // notice never came).
433
- const streak = { abs: '', n: 0 }
434
- const noticed = new Set<string>()
435
- let lastCycle: { start: number; end: number } | undefined
436
- const writtenDuringLastCycle = (abs: string, openWhileHeld = false): boolean => {
437
- // A server the last cycle left running is still that cycle's: its
438
- // writes land after the cycle ended, and with a closed window a dev
439
- // server that rewrites a log in its project restarted itself forever
440
- // with no word of it, 12 restarts in 8 s (item 948). The initial run's
441
- // server is one too, from the arm on.
442
- const open = openWhileHeld && held !== undefined
443
- if (lastCycle === undefined && !open) return false
444
- try {
445
- const m = fs.statSync(abs).mtimeMs
446
- const start = lastCycle?.start ?? armedAt
447
- return m >= start && (open || m <= lastCycle!.end)
448
- } catch {
449
- return false
450
- }
451
- }
452
- const judge = (): string | undefined => {
453
- const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
454
- let first: string | undefined
455
- let firstAbs: string | undefined
456
- // `sameState` before the `first` test, never after it: it is what
457
- // RECORDS a path's settled state, and every path this judgement saw
458
- // must be recorded even though only the first change names the cycle.
459
- // Short-circuiting after the winner leaves the rest unjudged, and
460
- // their next event is a first sighting stamped after the arm — so a
461
- // batch edit (a `git checkout`) makes the same bytes written to any
462
- // of the others a change.
463
- // A git-ignored path keys nothing, but a task with no cache has no key:
464
- // it reads what it likes, and its `.env.local` edit re-ran nothing
465
- // (item 947). Under such a project an ignored path is judged when the
466
- // user wrote it; one the last cycle wrote (the pid file an uncached
467
- // task rewrites every run, the loop the filter exists for) stays out.
468
- const editForUncached = (p: string): boolean =>
469
- [...uncached].some((dir) => p.startsWith(dir + path.sep)) && !writtenDuringLastCycle(p)
470
- for (const [p, l] of pendingPaths) {
471
- if (ignored.has(p) && !editForUncached(p)) continue
472
- if (!sameState(p) && first === undefined) {
473
- first = l
474
- firstAbs = p
475
- }
476
- }
477
- pendingPaths.clear()
478
- const byServer = firstAbs !== undefined && held !== undefined
479
- if (firstAbs === undefined || !writtenDuringLastCycle(firstAbs, true)) {
480
- streak.n = 0
481
- return first
482
- }
483
- streak.n = streak.abs === firstAbs ? streak.n + 1 : 1
484
- streak.abs = firstAbs
485
- if (streak.n >= 3 && !noticed.has(firstAbs)) {
486
- noticed.add(firstAbs)
487
- process.stdout.write(
488
- byServer
489
- ? `vx watch: ${first} has started 3 cycles in a row, written while a server the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
490
- : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
491
- )
492
- }
493
- return first
494
- }
495
344
  const trigger = (label: string, abs: string): void => {
496
- if (!pendingPaths.has(abs)) pendingPaths.set(abs, label)
345
+ if (!changes.pending.has(abs)) changes.pending.set(abs, label)
497
346
  if (running) return
498
347
  if (debounceTimer) clearTimeout(debounceTimer)
499
348
  debounceTimer = setTimeout(() => {
500
349
  debounceTimer = null
501
350
  if (running) return
502
- const first = judge()
351
+ const first = changes.judge()
503
352
  if (first !== undefined) void cycle(first)
504
353
  }, DEBOUNCE_MS)
505
354
  }
@@ -520,7 +369,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
520
369
  held = undefined
521
370
  const start = Date.now()
522
371
  held = (await runOrchestrator(opts)).persistent
523
- lastCycle = { start, end: Date.now() }
372
+ changes.lastCycle = { start, end: Date.now() }
524
373
  if (reread && !stop.aborted) {
525
374
  reread = false
526
375
  await rearm()
@@ -538,15 +387,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
538
387
  }
539
388
  // What landed mid-run is judged on settled state, one window
540
389
  // after the run, under the label of what actually arrived.
541
- if (pendingPaths.size === 0 || stop.aborted) break
390
+ if (changes.pending.size === 0 || stop.aborted) break
542
391
  await Bun.sleep(DEBOUNCE_MS)
543
- label = judge()
392
+ label = changes.judge()
544
393
  }
545
394
  } finally {
546
395
  running = false
547
396
  // Anything that landed after the last judgement waits for a timer
548
397
  // like any other event.
549
- const next = pendingAfterCycle(pendingPaths, stop.aborted)
398
+ const next = pendingAfterCycle(changes.pending, stop.aborted)
550
399
  if (next !== undefined) trigger(next[1], next[0])
551
400
  }
552
401
  }
@@ -635,6 +484,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
635
484
 
636
485
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
637
486
  const armedAt = fsClockNow(cacheDir)
487
+ /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
488
+ const changes = new ChangeJudge({
489
+ workspaceRoot,
490
+ armedAt,
491
+ held: () => held !== undefined,
492
+ uncached: () => uncached,
493
+ })
638
494
  /** Per-project arms by directory, so `rearm` can add and drop them. */
639
495
  const perProject = new Map<string, WatchHandle>()
640
496
  /** The root arm: recursive when workspace-wide, else the root's own files only. */
@@ -940,7 +796,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
940
796
  *
941
797
  * The inner loop re-runs while anything is pending, so this covers only the
942
798
  * narrow gap between its LAST judgement and the loop going idle: an event
943
- * landing there would otherwise sit in `pendingPaths` with no timer armed
799
+ * landing there would otherwise sit in `changes.pending` with no timer armed
944
800
  * and no cycle to notice it — watch quietly idle over an edit the user
945
801
  * made. Insertion order, because the map is "first label wins" and the
946
802
  * label is what the cycle announces.
@@ -6,7 +6,13 @@
6
6
 
7
7
  import { existsSync, readFileSync } from 'node:fs'
8
8
  import { constants as osConstants } from 'node:os'
9
- import { shellArgv, isExecutableMissing, killGraceMs } from '../util/index.js'
9
+ import {
10
+ shellArgv,
11
+ isExecutableMissing,
12
+ isOutOfFds,
13
+ killGraceMs,
14
+ OUT_OF_FDS_HINT,
15
+ } from '../util/index.js'
10
16
  import {
11
17
  closeSignalChannel,
12
18
  holdGroups,
@@ -40,6 +46,9 @@ export interface RunResult {
40
46
  cpuMs?: number
41
47
  /** Peak resident set size for the child, in bytes. */
42
48
  peakRssBytes?: number
49
+ /** `Bun.spawn` threw: no shell ran, so its 127 is vx's and says nothing
50
+ * about the command (A-41). */
51
+ spawnFailed?: true
43
52
  }
44
53
 
45
54
  /**
@@ -664,7 +673,8 @@ export function spawnFailureText(err: unknown, cwd: string, what = 'task'): stri
664
673
  return `\n[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
665
674
  }
666
675
  const message = err instanceof Error ? err.message : String(err)
667
- return `\n[vx] failed to spawn ${what}: ${message}\n`
676
+ const hint = isOutOfFds(err) ? ` — ${OUT_OF_FDS_HINT}` : ''
677
+ return `\n[vx] failed to spawn ${what}: ${message}${hint}\n`
668
678
  }
669
679
 
670
680
  export async function runCommand(opts: RunOptions): Promise<RunResult> {
@@ -689,7 +699,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
689
699
  } catch (err) {
690
700
  const stderr = spawnFailureText(err, opts.cwd)
691
701
  opts.onStderr?.(stderr)
692
- return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr }
702
+ return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
693
703
  }
694
704
 
695
705
  opts.liveChildren?.add(proc)
@@ -446,6 +446,28 @@ async function unlinkStaleMuxSockets(): Promise<void> {
446
446
  * B-19). A tool vx cannot find is left to SRT's own dependency check,
447
447
  * whose refusal names it.
448
448
  */
449
+ let javaAgent: { javaAgentJarPath?: string } | undefined
450
+
451
+ /**
452
+ * The JVM proxy agent SRT ships, named so SRT need not look for it. SRT's
453
+ * search lists `npm root -g` among its candidates before trying any, so it
454
+ * spawned npm on every init (~110 ms, the bulk of `vx info`'s sandbox
455
+ * probe) even though the jar sits beside it. Absent (a compiled vx), SRT
456
+ * searches as before.
457
+ */
458
+ function bundledJavaAgent(): { javaAgentJarPath?: string } {
459
+ if (javaAgent) return javaAgent
460
+ javaAgent = {}
461
+ try {
462
+ const dist = path.dirname(require.resolve('@anthropic-ai/sandbox-runtime'))
463
+ const jar = path.join(dist, '..', 'vendor', 'java-proxy-agent', 'srt-proxy-agent.jar')
464
+ if (existsSync(jar)) javaAgent = { javaAgentJarPath: jar }
465
+ } catch {
466
+ // unresolvable: SRT searches
467
+ }
468
+ return javaAgent
469
+ }
470
+
449
471
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
450
472
  if (process.platform !== 'linux') return {}
451
473
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -526,6 +548,7 @@ export async function initSandbox(opts?: {
526
548
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
527
549
  ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
528
550
  ...linuxToolPaths(),
551
+ ...bundledJavaAgent(),
529
552
  }
530
553
  if (!srtUp) await unlinkStaleMuxSockets()
531
554
  await SandboxManager.initialize(
@@ -1341,6 +1364,7 @@ async function runSandboxedOnce(
1341
1364
  durationMs: Date.now() - start,
1342
1365
  stdout: '',
1343
1366
  stderr,
1367
+ spawnFailed: true,
1344
1368
  violations: [],
1345
1369
  tracerFailed: false,
1346
1370
  }
@@ -852,8 +852,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
852
852
  // The shell's 127 and 126 name the word and nothing about why — the
853
853
  // PATH vx built, or a `#!` line the file itself carries (items 257,
854
854
  // 258) — and an exit above 128 is a signal's number and nothing
855
- // about what sent it (259). One frame line names the rule.
856
- if (!res.timedOut) {
855
+ // about what sent it (259). One frame line names the rule. A spawn that
856
+ // threw ran no shell: its 127 is vx's, and the line would send the
857
+ // reader after a command that exists (A-41).
858
+ if (!res.timedOut && res.spawnFailed !== true) {
857
859
  const verdict = shellVerdict({
858
860
  code,
859
861
  command: step.command,
@@ -67,20 +67,28 @@ export function delegatedScript(command: string): string | null {
67
67
  }
68
68
 
69
69
  /** What `<manager> <name>`, with no `run`, runs as the manager's own command. */
70
+ // Listing a name that runs the script only keeps the command verbatim, so
71
+ // each set leans wide; missing one of the manager's own made a group over
72
+ // a script the manager never runs (D-32: `pnpm docs` opened npm's docs page,
73
+ // `bun deploy` answered "reserved", `yarn check` verified dependencies).
70
74
  const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
71
- // `bun --help`, Bun 1.4.2, with the short aliases.
75
+ // `bun --help`, Bun 1.4.2, the short aliases, and the names it reserves
76
+ // or runs itself (probed: `bun lint` runs the script).
72
77
  bun: new Set(
73
- 'run test x repl exec install i add a remove rm update audit outdated link unlink publish patch pm info why build init create c upgrade feedback lint'.split(
78
+ 'run test x repl exec install i add a remove rm update audit dedupe prune outdated link unlink publish patch pm info why build init create c upgrade feedback list deploy config login logout whoami help'.split(
74
79
  ' ',
75
80
  ),
76
81
  ),
82
+ // pnpm 10.33.0's `commandNames`, and the npm commands it passes through
83
+ // to npm whatever the scripts say.
77
84
  pnpm: new Set(
78
- 'add install i update up remove rm link unlink import rebuild prune fetch patch audit list ls outdated why exec dlx create init publish pack store env setup config root bin licenses deploy doctor'.split(
85
+ 'add approve-builds audit bin c cache cat-file cat-index ci clean-install completion config create dedupe deploy dislink dlx doctor env exec fetch find-hash get i ic ignored-builds import init install install-clean install-test it la licenses link list ll ln ls m multi outdated pack patch patch-commit patch-remove prune publish rb rebuild recursive remove restart rm root run run-script self-update server set setup store un uni uninstall unlink up update upgrade why access adduser bugs deprecate dist-tag docs edit find home info issues login logout owner ping prefix profile pkg repo s se search set-script show star stars team token unpublish unstar v version view whoami xmas help'.split(
79
86
  ' ',
80
87
  ),
81
88
  ),
89
+ // `yarn help`, Yarn 1.22.22 and Yarn 4.5.0: one set for both.
82
90
  yarn: new Set(
83
- 'add install remove upgrade up info init link unlink pack publish why workspace workspaces config cache dlx exec bin node plugin set version constraints npm patch explain dedupe'.split(
91
+ 'access add audit autoclean bin cache check config constraints create dedupe dlx exec explain files generate-lock-entry global help import info init install licenses link list login logout node npm outdated owner pack patch patch-commit plugin policies publish rebuild remove run search set stage tag team unlink unplug up upgrade upgrade-interactive version versions why workspace workspaces'.split(
84
92
  ' ',
85
93
  ),
86
94
  ),