@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 +5 -5
- package/src/cache/cache.ts +37 -4
- package/src/cli/watch-judge.ts +180 -0
- package/src/cli/watch.ts +16 -160
- package/src/exec/runner.ts +13 -3
- package/src/exec/sandbox-runtime.ts +24 -0
- package/src/orchestrator/execute-task.ts +4 -2
- package/src/workspace/migrate-scripts.ts +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cache/cache.ts
CHANGED
|
@@ -478,7 +478,7 @@ export class Cache implements CacheLayer {
|
|
|
478
478
|
} | null
|
|
479
479
|
)?.value
|
|
480
480
|
} finally {
|
|
481
|
-
db
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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 (!
|
|
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 (
|
|
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(
|
|
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 `
|
|
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.
|
package/src/exec/runner.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
),
|