@vzn/vx 0.0.590 → 0.0.594

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.
Files changed (55) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/cache/archive.ts +16 -5
  4. package/src/cache/cache.ts +41 -11
  5. package/src/cache/git-inputs.ts +14 -9
  6. package/src/cache/inputs.ts +47 -17
  7. package/src/cache/tar-stream.ts +3 -1
  8. package/src/cli/cache.ts +3 -5
  9. package/src/cli/foreign-flags.ts +33 -4
  10. package/src/cli/help.ts +22 -5
  11. package/src/cli/info.ts +4 -6
  12. package/src/cli/init.ts +3 -3
  13. package/src/cli/last.ts +49 -20
  14. package/src/cli/lock.ts +3 -3
  15. package/src/cli/run.ts +65 -37
  16. package/src/cli/select.ts +14 -2
  17. package/src/cli/show.ts +5 -6
  18. package/src/cli/upgrade.ts +2 -3
  19. package/src/cli/watch-filter.ts +7 -6
  20. package/src/cli/watch-fs.ts +17 -1
  21. package/src/cli/watch.ts +22 -4
  22. package/src/cli/why.ts +4 -13
  23. package/src/cli/workspace-config.ts +1 -1
  24. package/src/exec/env.ts +3 -5
  25. package/src/exec/runner.ts +28 -7
  26. package/src/exec/sandbox-deny-scan.ts +2 -2
  27. package/src/exec/sandbox-runtime.ts +69 -19
  28. package/src/exec/sandbox-violations.ts +23 -9
  29. package/src/graph/scheduler.ts +13 -3
  30. package/src/graph/task-graph.ts +3 -2
  31. package/src/index.ts +3 -0
  32. package/src/orchestrator/affected-tasks.ts +34 -10
  33. package/src/orchestrator/doctor.ts +2 -1
  34. package/src/orchestrator/execute-task.ts +14 -3
  35. package/src/orchestrator/hit-restore.ts +3 -2
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/lockfile-claim.ts +2 -3
  38. package/src/orchestrator/logger.ts +4 -1
  39. package/src/orchestrator/miss-save.ts +10 -3
  40. package/src/orchestrator/placement.ts +17 -3
  41. package/src/orchestrator/plugin-host.ts +16 -0
  42. package/src/orchestrator/plugin.ts +9 -13
  43. package/src/orchestrator/projects.ts +18 -9
  44. package/src/orchestrator/run-context.ts +2 -2
  45. package/src/orchestrator/run-lock.ts +14 -4
  46. package/src/orchestrator/run.ts +42 -8
  47. package/src/orchestrator/sandbox-request.ts +6 -10
  48. package/src/workspace/affected.ts +17 -11
  49. package/src/workspace/config-eval.ts +4 -0
  50. package/src/workspace/config-imports.ts +1 -1
  51. package/src/workspace/config-schema.ts +38 -7
  52. package/src/workspace/filter.ts +5 -4
  53. package/src/workspace/migrate-scripts.ts +22 -5
  54. package/src/workspace/project-loader.ts +17 -2
  55. package/src/workspace/workspace.ts +40 -4
@@ -483,7 +483,7 @@ export interface PersistentSpawn {
483
483
  * Rejects with the spawn error if the child fails to start.
484
484
  */
485
485
  ready: Promise<void>
486
- /** ms elapsed from spawn to ready (or to current time if not yet ready). */
486
+ /** ms from spawn to ready, to the readiness timeout giving up, or to now. */
487
487
  readyMs: () => number
488
488
  }
489
489
 
@@ -522,6 +522,9 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
522
522
  export function runPersistent(opts: PersistentOptions): PersistentSpawn {
523
523
  const start = Date.now()
524
524
  let readyAt: number | undefined
525
+ // When the readiness wait gave up: the wait's length, not the kill grace
526
+ // a TERM-trapping server runs out after it.
527
+ let gaveUpAt: number | undefined
525
528
 
526
529
  // Pattern compiled once; thrown errors surface synchronously so the
527
530
  // caller can wrap with a user-facing message.
@@ -679,6 +682,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
679
682
  if (readyRe && opts.timeoutMs !== undefined) {
680
683
  readyTimer = setTimeout(() => {
681
684
  if (readyAt === undefined) {
685
+ gaveUpAt = Date.now()
682
686
  rejectReady(
683
687
  new PersistentReadyError(
684
688
  `persistent task not ready within ${opts.timeoutMs}ms — ` +
@@ -730,7 +734,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
730
734
  return {
731
735
  child,
732
736
  ready,
733
- readyMs: () => (readyAt ?? Date.now()) - start,
737
+ readyMs: () => (readyAt ?? gaveUpAt ?? Date.now()) - start,
734
738
  }
735
739
  }
736
740
 
@@ -807,7 +811,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
807
811
  stderr,
808
812
  ...(proc.signalCode ? { signal: proc.signalCode } : {}),
809
813
  ...(timeout.timedOut() ? { timedOut: true } : {}),
810
- ...resourceUsageToCpuRss(proc.resourceUsage(), ownRssHighWater()),
814
+ ...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
811
815
  }
812
816
  }
813
817
 
@@ -832,7 +836,7 @@ export function ownRssHighWater(): number {
832
836
  if (process.platform === 'linux') {
833
837
  try {
834
838
  const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
835
- if (m !== null) return Number(m[1]) * 1024
839
+ if (m !== null) return (highWaterSeen = Number(m[1]) * 1024)
836
840
  } catch {
837
841
  // /proc unreadable: fall through to the current RSS.
838
842
  }
@@ -840,6 +844,22 @@ export function ownRssHighWater(): number {
840
844
  return process.memoryUsage.rss()
841
845
  }
842
846
 
847
+ /** The last `VmHWM` read, in bytes; 0 until one is. */
848
+ let highWaterSeen = 0
849
+
850
+ /**
851
+ * The floor for a child's `peak`, read only when the last `VmHWM` cannot
852
+ * decide it: the mark never falls, so a peak within the slack of an older
853
+ * reading is within the slack of the current one, and goes unreported
854
+ * either way. A light task reads vx's own mark as its peak, so after the
855
+ * first such task the rest skip the read, which cost ~35 µs of the
856
+ * scheduler's thread per task (500 cold tasks, 2026-10-07). A current-RSS
857
+ * fallback is not monotonic and is never reused.
858
+ */
859
+ function rssFloorFor(peak: number): number {
860
+ return peak <= highWaterSeen + RSS_FLOOR_SLACK_BYTES ? highWaterSeen : ownRssHighWater()
861
+ }
862
+
843
863
  /**
844
864
  * What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
845
865
  * the last `CAPTURE_TAIL_CHARS` characters, the dropped middle counted and
@@ -1014,8 +1034,8 @@ export function peakRssBytes(maxRSS: number): number {
1014
1034
  */
1015
1035
  export function resourceUsageToCpuRss(
1016
1036
  usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
1017
- /** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
1018
- floorBytes = 0,
1037
+ /** The parent's own high-water mark (`ownRssHighWater`), or a function of the peak that answers it; a peak at or under it is inherited, not the child's, and is not reported. */
1038
+ floorBytes: number | ((peak: number) => number) = 0,
1019
1039
  ): { cpuMs?: number; peakRssBytes?: number } {
1020
1040
  if (!usage) return {}
1021
1041
  // cpuTime.total is microseconds as a bigint → ms.
@@ -1028,5 +1048,6 @@ export function resourceUsageToCpuRss(
1028
1048
  // parent's (see `ownRssHighWater`, `RSS_FLOOR_SLACK_BYTES`): the child's
1029
1049
  // peak is unknown, bounded by it.
1030
1050
  const peak = peakRssBytes(usage.maxRSS)
1031
- return peak > floorBytes + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
1051
+ const floor = typeof floorBytes === 'number' ? floorBytes : floorBytes(peak)
1052
+ return peak > floor + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
1032
1053
  }
@@ -16,7 +16,7 @@
16
16
  import { lstatSync, readdirSync } from 'node:fs'
17
17
  import { homedir } from 'node:os'
18
18
  import path from 'node:path'
19
- import { UserError } from '../util/index.js'
19
+ import { relPosix, UserError } from '../util/index.js'
20
20
  import { atOrUnder, MOUNT_WILDCARDS } from './sandbox-paths.js'
21
21
 
22
22
  /** SRT's `rg --max-depth` for the scan (`DEFAULT_MANDATORY_DENY_SEARCH_DEPTH`). */
@@ -126,7 +126,7 @@ export function scopedMandatoryDenies(
126
126
  }
127
127
  }
128
128
  for (const root of roots) {
129
- const rel = path.relative(cwd, root).split(path.sep).join('/')
129
+ const rel = relPosix(cwd, root)
130
130
  const depth = rel === '' ? 0 : rel.split('/').length
131
131
  if (depth > SEARCH_DEPTH) continue
132
132
  let dir: boolean
@@ -108,9 +108,13 @@ import {
108
108
 
109
109
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
110
110
  let srtPromise: Promise<SrtModule> | undefined
111
+ /** Set once loaded, for `releaseBridges`, which a sync exit handler calls. */
112
+ let srtLoaded: SrtModule | undefined
111
113
 
112
114
  async function loadSrt(): Promise<SrtModule> {
113
- if (!srtPromise) srtPromise = import('@anthropic-ai/sandbox-runtime')
115
+ if (!srtPromise) {
116
+ srtPromise = import('@anthropic-ai/sandbox-runtime').then((m) => (srtLoaded = m))
117
+ }
114
118
  return srtPromise
115
119
  }
116
120
 
@@ -1151,6 +1155,23 @@ export async function wrapSandboxedCommand(
1151
1155
  const tmp = taskTmpdir(tag)
1152
1156
  mkdirSync(tmp, { mode: 0o700 })
1153
1157
  trackTaskTmpdir(tmp)
1158
+ try {
1159
+ return await wrapIn(args, SandboxManager, userCommand, tag, tmp)
1160
+ } catch (err) {
1161
+ // Nothing spawned, so nothing releases it on exit: a `vx watch` kept
1162
+ // one task directory per refused wrap (a held port) until it quit.
1163
+ releaseBridges(tag)
1164
+ throw err
1165
+ }
1166
+ }
1167
+
1168
+ async function wrapIn(
1169
+ args: Parameters<typeof wrapSandboxedCommand>[0],
1170
+ SandboxManager: SrtModule['SandboxManager'],
1171
+ userCommand: string,
1172
+ tag: string,
1173
+ tmp: string,
1174
+ ): ReturnType<typeof wrapSandboxedCommand> {
1154
1175
  // After the tag: SRT keys violations by the command's first 100 chars.
1155
1176
  const inTmp = `export TMPDIR=${shellQuote(tmp)}; ${javaToolOptionsFix(
1156
1177
  process.env['JAVA_TOOL_OPTIONS'],
@@ -1188,6 +1209,16 @@ export async function wrapSandboxedCommand(
1188
1209
  // user command, so it goes INTO the sandboxed command; the host side is
1189
1210
  // spawned here and released when the task's process ends.
1190
1211
  const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
1212
+ // Before the wrap, which holds the runtime's stub cleanup until `afterCommand`.
1213
+ const held = portsHeld(ports)
1214
+ if (held.length > 0) {
1215
+ throw new UserError(
1216
+ `sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
1217
+ `${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
1218
+ `cannot be exposed there and a client would reach the other listener; stop what ` +
1219
+ `holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
1220
+ )
1221
+ }
1191
1222
  const grouped =
1192
1223
  process.platform === 'linux'
1193
1224
  ? ownGroupCommand(tag, inTmp, args.trace, args.tracePaths === true)
@@ -1240,15 +1271,6 @@ export async function wrapSandboxedCommand(
1240
1271
  }
1241
1272
  if (process.platform === 'linux' && !hostHasIpv6())
1242
1273
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
1243
- const held = portsHeld(ports)
1244
- if (held.length > 0) {
1245
- throw new UserError(
1246
- `sandbox: localBinding port${held.length === 1 ? '' : 's'} ${held.join(', ')} ` +
1247
- `${held.length === 1 ? 'is' : 'are'} already in use on this machine, so the task's own ` +
1248
- `cannot be exposed there and a client would reach the other listener; stop what ` +
1249
- `holds ${held.length === 1 ? 'it' : 'them'} or list another port`,
1250
- )
1251
- }
1252
1274
  if (args.server === true) liveServers.add(tag)
1253
1275
  if (ports.length > 0) {
1254
1276
  spawnHostBridges(ports, tag)
@@ -1409,9 +1431,11 @@ function literalReadPaths(
1409
1431
  * it the pattern, and SRT compiles any spelling holding `[` as a regex in
1410
1432
  * which a backslash is a literal one, so `pages/\[id\].tsx` matched no
1411
1433
  * file and the route could not be granted. `[[]` is a class of one `[`; a
1412
- * lone `]` is plain text (B-65).
1434
+ * lone `]` is plain text (B-65). A deny path is a real directory, never a
1435
+ * pattern: a nested project's wall under `[legacy]/` compiled as a class,
1436
+ * matched nothing, and the root task read it.
1413
1437
  */
1414
- function seatbeltBrackets(
1438
+ export function seatbeltBrackets(
1415
1439
  config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
1416
1440
  ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
1417
1441
  const fs = config?.filesystem
@@ -1422,6 +1446,7 @@ function seatbeltBrackets(
1422
1446
  ...config,
1423
1447
  filesystem: {
1424
1448
  ...fs,
1449
+ denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
1425
1450
  allowWrite: literal(fs.allowWrite),
1426
1451
  ...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
1427
1452
  },
@@ -1620,8 +1645,17 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1620
1645
  export function releaseBridges(tag: string): void {
1621
1646
  const tmp = taskTmpdir(tag)
1622
1647
  if (liveTaskTmpdirs.delete(tmp)) rmSync(tmp, { recursive: true, force: true })
1623
- if (liveServers.delete(tag) && liveServers.size === 0 && resetDeferred) {
1624
- void resetSandbox().catch(() => {})
1648
+ if (liveServers.delete(tag)) {
1649
+ // A server's wrap counts as a live sandbox in SRT until this, and SRT
1650
+ // removes bwrap's host stubs (`.bashrc`, `.vscode`, … under a write
1651
+ // grant) only at a count of 0: one stopped server kept every later
1652
+ // task's stubs in the workspace until the reset.
1653
+ try {
1654
+ srtLoaded!.SandboxManager.cleanupAfterCommand()
1655
+ } catch {
1656
+ // best-effort, as a one-shot task's
1657
+ }
1658
+ if (liveServers.size === 0 && resetDeferred) void resetSandbox().catch(() => {})
1625
1659
  }
1626
1660
  const bridges = hostBridges.get(tag)
1627
1661
  if (bridges === undefined) return
@@ -1775,6 +1809,7 @@ async function runSandboxedOnce(
1775
1809
  if (args.signal?.aborted === true) {
1776
1810
  releaseBridges(tag)
1777
1811
  takeRecords()
1812
+ afterCommand(SandboxManager)
1778
1813
  const signal = stopSignal(args.signal.reason)
1779
1814
  return {
1780
1815
  exitCode: signalExitCode(signal),
@@ -1819,10 +1854,15 @@ async function runSandboxedOnce(
1819
1854
  )
1820
1855
  if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
1821
1856
  } catch (err) {
1857
+ if (straceLog) {
1858
+ rmSync(straceLog, { force: true })
1859
+ liveTempFiles.delete(straceLog)
1860
+ }
1822
1861
  const stderr = spawnFailureText(err, args.cwd, 'sandboxed task')
1823
1862
  args.onStderr?.(stderr)
1824
1863
  releaseBridges(tag)
1825
1864
  takeRecords()
1865
+ afterCommand(SandboxManager)
1826
1866
  return {
1827
1867
  exitCode: 127,
1828
1868
  durationMs: Date.now() - start,
@@ -2012,11 +2052,7 @@ async function runSandboxedOnce(
2012
2052
  if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
2013
2053
  }
2014
2054
 
2015
- try {
2016
- SandboxManager.cleanupAfterCommand()
2017
- } catch {
2018
- // ignore; bwrap mount-point cleanup is best-effort
2019
- }
2055
+ afterCommand(SandboxManager)
2020
2056
 
2021
2057
  return {
2022
2058
  exitCode,
@@ -2042,6 +2078,20 @@ async function runSandboxedOnce(
2042
2078
  }
2043
2079
  }
2044
2080
 
2081
+ /**
2082
+ * The runtime counts every wrap as a live sandbox until this, and removes
2083
+ * the empty files bwrap made on the host as mount points (`.bashrc`,
2084
+ * `.vscode`, … under a write grant) only at a count of 0: a wrap with no
2085
+ * call here kept every later task's stubs on the host until the reset.
2086
+ */
2087
+ function afterCommand(SandboxManager: SrtModule['SandboxManager']): void {
2088
+ try {
2089
+ SandboxManager.cleanupAfterCommand()
2090
+ } catch {
2091
+ // ignore; bwrap mount-point cleanup is best-effort
2092
+ }
2093
+ }
2094
+
2045
2095
  /** The hint for writes refused outside the project, a few paths named. */
2046
2096
  function outsideWritesHint(
2047
2097
  paths: readonly string[],
@@ -44,8 +44,8 @@ const SYSCALLS = 'openat|access|statx|newfstatat'
44
44
  const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
45
45
  // The directory descriptor a call names, `-y`'s path included: taken up to
46
46
  // the `, "` that opens the path argument, since a directory's name may hold
47
- // a quote (`4</ws/q"d>`).
48
- const DIRFD = '(?:AT_FDCWD|\\d+)(?:<.*?>)?, '
47
+ // a quote (`4</ws/q"d>`). Captured: a relative path is in that directory.
48
+ const DIRFD = '(?:AT_FDCWD|\\d+)(?:<(.*?)>)?, '
49
49
  const STRACE_DONE_RE = new RegExp(
50
50
  `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
51
51
  )
@@ -163,7 +163,7 @@ type Op = { chdir: string } | { lost: true } | { call: number }
163
163
  export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCall[] {
164
164
  const pending = new Map<
165
165
  string,
166
- { syscall: string; rawPath: string; read?: true; dirfd?: string }
166
+ { syscall: string; rawPath: string; read?: true; dirfd?: string; dir?: string }
167
167
  >()
168
168
  const out: DeniedCall[] = []
169
169
  const ops = new Map<string, Op[]>()
@@ -210,19 +210,22 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
210
210
  continue
211
211
  }
212
212
  const done = STRACE_DONE_RE.exec(line)
213
- if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
214
- denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
213
+ if (done?.[2] !== undefined && done[4] !== undefined && done[5] !== undefined) {
214
+ const rawPath = cStringPath(done[4])
215
+ denied(done[1]!, { syscall: done[2], rawPath, errno: done[5], ...within(done[3], rawPath) })
215
216
  continue
216
217
  }
217
218
  const unfinished = STRACE_UNFINISHED_RE.exec(line)
218
219
  if (
219
220
  unfinished?.[1] !== undefined &&
220
221
  unfinished[2] !== undefined &&
221
- unfinished[3] !== undefined
222
+ unfinished[4] !== undefined
222
223
  ) {
224
+ const rawPath = cStringPath(unfinished[4])
223
225
  pending.set(unfinished[1], {
224
226
  syscall: unfinished[2],
225
- rawPath: cStringPath(unfinished[3]),
227
+ rawPath,
228
+ ...within(unfinished[3], rawPath),
226
229
  ...(reads && OPEN_READ_UNFINISHED_RE.test(line) ? { read: true as const } : {}),
227
230
  ...(reads ? { dirfd: OPEN_READ_UNFINISHED_RE.exec(line)?.[2] ?? 'AT_FDCWD' } : {}),
228
231
  })
@@ -237,7 +240,12 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
237
240
  // resume just retires the pending entry.
238
241
  const fd = held?.read === true ? RESUMED_FD_RE.exec(line) : null
239
242
  if (held !== undefined && resumed?.[3] !== undefined) {
240
- denied(resumedOk[1], { syscall: held.syscall, rawPath: held.rawPath, errno: resumed[3] })
243
+ denied(resumedOk[1], {
244
+ syscall: held.syscall,
245
+ rawPath: held.rawPath,
246
+ errno: resumed[3],
247
+ ...(held.dir !== undefined ? { dir: held.dir } : {}),
248
+ })
241
249
  } else if (held !== undefined && fd !== null) {
242
250
  const at = readPath(held.dirfd ?? 'AT_FDCWD', held.rawPath, fd[1])
243
251
  if (at !== undefined) {
@@ -250,6 +258,12 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
250
258
  // without the cwd tracking.
251
259
  follow(line)
252
260
  }
261
+ // `-y` names the directory a relative path was opened in: a denial
262
+ // through a descriptor (`openat(4</ws/p/sub>, "g")`) was placed in the
263
+ // cwd, naming a file the task never asked for.
264
+ function within(dir: string | undefined, rawPath: string): { dir?: string } {
265
+ return dir !== undefined && !rawPath.startsWith('/') ? { dir: cStringPath(dir) } : {}
266
+ }
253
267
  function follow(line: string): void {
254
268
  let m: RegExpExecArray | null
255
269
  if ((m = CHDIR_DENIED_RE.exec(line)) !== null) {
@@ -303,7 +317,7 @@ export function deniedCalls(text: string, cwd?: string, reads = false): DeniedCa
303
317
  }
304
318
  for (const [pid, list] of ops) {
305
319
  list.forEach((op, i) => {
306
- if (!('call' in op)) return
320
+ if (!('call' in op) || out[op.call]!.dir !== undefined) return
307
321
  const dir = dirsOf(pid)[i]
308
322
  if (dir !== undefined && dir !== cwd) out[op.call]!.dir = dir
309
323
  })
@@ -208,7 +208,7 @@ export interface ScheduleOptions {
208
208
  * - 'deps-ok': a failed/skipped/aborted upstream skips its
209
209
  * dependents; independent siblings keep running.
210
210
  * - 'never': the first failure stops dispatch — in-flight tasks
211
- * finish naturally, everything not yet started completes as
211
+ * finish their attempt but start no retry, everything not yet started completes as
212
212
  * skipped (restore-tier included: a fail-fast run stops
213
213
  * restoring too).
214
214
  * - 'always': dependents run even when an upstream failed. Their
@@ -227,6 +227,11 @@ export interface ScheduleOptions {
227
227
  * 'never' it stops dispatch.
228
228
  */
229
229
  serverDied?: (id: string) => boolean
230
+ /**
231
+ * Called once, when 'never' stops dispatch: a task in flight then
232
+ * finishes its attempt but starts no retry.
233
+ */
234
+ onFailFast?: () => void
230
235
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
231
236
  onStart?: (node: TaskNode) => void
232
237
  onFinish?: (outcome: TaskOutcome) => void
@@ -424,6 +429,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
424
429
  // dequeued task is skipped instead of dispatched (in-flight tasks
425
430
  // finish naturally and their dependents drain through the same path).
426
431
  let failFastTripped = false
432
+ const tripFailFast = (): void => {
433
+ if (failFastTripped) return
434
+ failFastTripped = true
435
+ options.onFailFast?.()
436
+ }
427
437
  const outcomes = new Map<string, TaskOutcome>()
428
438
 
429
439
  // Reverse adjacency + pending dep counts. Built once. A task becomes
@@ -588,7 +598,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
588
598
 
589
599
  return new Promise<Map<string, TaskOutcome>>((resolve) => {
590
600
  const finishOne = (id: string, outcome: TaskOutcome): void => {
591
- if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
601
+ if (continueMode === 'never' && outcome.status === 'failed') tripFailFast()
592
602
  outcomes.set(id, outcome)
593
603
  // A dormant server is asked again: the dispatch settles it once its
594
604
  // last dependant is in.
@@ -686,7 +696,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
686
696
  const willSkip = (id: string): boolean => {
687
697
  if (failFastTripped || aborted()) return true
688
698
  if (continueMode === 'never' && servers.some(serverDied)) {
689
- failFastTripped = true
699
+ tripFailFast()
690
700
  return true
691
701
  }
692
702
  if (inRestoreTier(id)) return false
@@ -3,6 +3,7 @@ import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
3
3
  import {
4
4
  asTrees,
5
5
  isLiteralPattern,
6
+ relPosix,
6
7
  splitNegations,
7
8
  staticPrefix,
8
9
  taskGlob,
@@ -872,7 +873,7 @@ function detectOutputCollisions(
872
873
  const entries: Entry[] = wsDeclarers.map((node) => ({ node, globs: wsFilesOf(node) ?? [] }))
873
874
  for (const bucket of byProject.values()) {
874
875
  for (const node of bucket) {
875
- const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
876
+ const rel = relPosix(workspaceRoot, node.projectDir)
876
877
  const globs = (filesOf(node) ?? []).map((g) => (rel === '' ? g : `${rel}/${g}`))
877
878
  entries.push({ node, globs, rel })
878
879
  }
@@ -1243,7 +1244,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1243
1244
  if (!rootReaders) return
1244
1245
  if (workspaceRoot !== undefined) {
1245
1246
  for (const node of filesWriters) {
1246
- const rel = path.relative(workspaceRoot, node.projectDir).split(path.sep).join('/')
1247
+ const rel = relPosix(workspaceRoot, node.projectDir)
1247
1248
  const globs = splitNegations(node.config.cache!.outputs.files).positive.map((g) =>
1248
1249
  rel === '' ? g : `${rel}/${g}`,
1249
1250
  )
package/src/index.ts CHANGED
@@ -168,6 +168,9 @@ export type {
168
168
  DiscoverContext,
169
169
  NamedProject,
170
170
  PluginSetupContext,
171
+ PluginHookName,
172
+ PluginHookHandlers,
173
+ RunStartInfo,
171
174
  } from './orchestrator/index.js'
172
175
  // The per-task execution contract a plugin's `executor` capability returns.
173
176
  // (`runCommand` / `runSandboxed`, the local executor's own primitives, left
@@ -105,16 +105,40 @@ export function affectedRoots(
105
105
  return (changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))
106
106
  }
107
107
  const reached = new Map<string, boolean>()
108
- const reaches = (id: string): boolean => {
109
- const known = reached.get(id)
110
- if (known !== undefined) return known
111
- // The graph is acyclic (the builder refuses a cycle); the mark only
112
- // stops a diamond being walked twice.
113
- reached.set(id, false)
114
- const n = nodes.get(id)
115
- const hit = n !== undefined && (seeded(n) || passesChanged(n) || n.deps.some(reaches))
116
- reached.set(id, hit)
117
- return hit
108
+ // Post-order on an explicit stack: a closure is as deep as the graph, and
109
+ // a recursion per edge threw `RangeError` on a chain the builder takes
110
+ // (item 737's 50,000; ~15,000 sufficed here). A frame holds the next dep
111
+ // to ask, -1 before the task itself is.
112
+ const reaches = (root: string): boolean => {
113
+ const stack: Array<[id: string, next: number]> = [[root, -1]]
114
+ while (stack.length > 0) {
115
+ const frame = stack[stack.length - 1]!
116
+ const id = frame[0]
117
+ if (frame[1] === -1) {
118
+ if (reached.has(id)) {
119
+ stack.pop()
120
+ continue
121
+ }
122
+ const n = nodes.get(id)
123
+ if (n === undefined || seeded(n) || passesChanged(n)) {
124
+ reached.set(id, n !== undefined)
125
+ stack.pop()
126
+ continue
127
+ }
128
+ frame[1] = 0
129
+ }
130
+ const deps = nodes.get(id)!.deps
131
+ let i = frame[1]
132
+ while (i < deps.length && reached.get(deps[i]!) === false) i++
133
+ frame[1] = i
134
+ if (i === deps.length || reached.get(deps[i]!) === true) {
135
+ reached.set(id, i < deps.length)
136
+ stack.pop()
137
+ continue
138
+ }
139
+ stack.push([deps[i]!, -1])
140
+ }
141
+ return reached.get(root)!
118
142
  }
119
143
  return ids.filter(reaches)
120
144
  }
@@ -16,6 +16,7 @@ import {
16
16
  isUnsupportedBun,
17
17
  machineMemoryBytes,
18
18
  machineParallelism,
19
+ relPosix,
19
20
  } from '../util/index.js'
20
21
  import { VERSION } from '../version.js'
21
22
  import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'
@@ -395,7 +396,7 @@ async function countLoadableTasks(
395
396
  ? bare.slice(where.length + 1).trimStart()
396
397
  : raw
397
398
  errors.push({
398
- path: path.relative(root, where).split(path.sep).join('/'),
399
+ path: relPosix(root, where),
399
400
  message,
400
401
  })
401
402
  }
@@ -51,7 +51,7 @@ import {
51
51
  span,
52
52
  UserError,
53
53
  } from '../util/index.js'
54
- import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
54
+ import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
55
55
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
56
56
  import {
57
57
  mayWriteFingerprint,
@@ -133,6 +133,8 @@ export interface ExecuteArgs {
133
133
  * terms (item 962).
134
134
  */
135
135
  stopSignal?: AbortSignal
136
+ /** `continueMode: 'never'` stopped dispatch: no further attempt starts. */
137
+ failFast?: AbortSignal
136
138
  /**
137
139
  * Registry the orchestrator owns. For each persistent task we
138
140
  * spawn, we stash the subprocess handle here so the orchestrator
@@ -464,6 +466,11 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
464
466
  try {
465
467
  await spawn.ready
466
468
  } catch (err) {
469
+ // A shell that exited before ready can leave its group running
470
+ // (`server & …; exit`): never registered, so no teardown reached it,
471
+ // and it held its port against the next `vx watch` cycle's server.
472
+ if (err instanceof PersistentReadyError && err.reason === 'exited')
473
+ await terminateChildren(() => [spawn.child])
467
474
  // A server the run's stop killed while it started is aborted, as any
468
475
  // task the stop kills (item 962): it read `failed (never ready:
469
476
  // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
@@ -512,6 +519,10 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
512
519
  // One the readiness timeout is killing reports the signal's, as an
513
520
  // ordinary timeout does (X-24).
514
521
  const ready = err instanceof PersistentReadyError ? err : undefined
522
+ // The timer's SIGKILL is a grace away and the shell may die on the
523
+ // TERM first: a server that ignores it held its port past run() into
524
+ // the next `vx watch` cycle. Return once the group is gone.
525
+ if (ready?.reason === 'timeout') await terminateChildren(() => [spawn.child])
515
526
  return {
516
527
  node,
517
528
  status: 'failed',
@@ -1058,7 +1069,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1058
1069
  // no edge to the producer fails naming only the file (A-48).
1059
1070
  if (code !== 0) {
1060
1071
  for (const c of (args.gitFilesCache?.trackedCleansMissing(node.id) ?? []).slice(0, 3)) {
1061
- const rel = path.relative(args.workspaceRoot, c.path).split(path.sep).join('/')
1072
+ const rel = relPosix(args.workspaceRoot, c.path)
1062
1073
  log.taskStderr(
1063
1074
  node,
1064
1075
  `\n[vx] ${rel} is tracked by git, and ${c.by} removed it as an output before its run; it is still missing. A task that reads it needs dependsOn on ${c.by}.\n`,
@@ -1153,7 +1164,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1153
1164
  }
1154
1165
  }
1155
1166
 
1156
- if (effectiveExitCode === 0 || attempt >= maxAttempts) break
1167
+ if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
1157
1168
  failedAttempts.push({
1158
1169
  endedAt: Date.now(),
1159
1170
  exitCode: effectiveExitCode,
@@ -22,6 +22,7 @@ import {
22
22
  import type { TaskOutcome } from '../graph/index.js'
23
23
  import {
24
24
  asTrees,
25
+ relPosix,
25
26
  secretMask,
26
27
  span,
27
28
  splitNegations,
@@ -165,10 +166,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
165
166
  const expectedRels = new Set(projExpected.map((e) => e.path))
166
167
  const expectedWsRels = new Set(wsExpected.map((e) => e.path))
167
168
  const actualRels = actualAbs
168
- .map((p) => path.relative(node.projectDir, p).split(path.sep).join('/'))
169
+ .map((p) => relPosix(node.projectDir, p))
169
170
  .filter((rel) => expectedRels.has(rel) || !isAddition(rel))
170
171
  const actualWsRels = actualWsAbs
171
- .map((p) => path.relative(args.workspaceRoot, p).split(path.sep).join('/'))
172
+ .map((p) => relPosix(args.workspaceRoot, p))
172
173
  .filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
173
174
  const rowsPresent = (
174
175
  actual: readonly string[],
@@ -43,6 +43,7 @@ export type {
43
43
  EventBus,
44
44
  RunEvent,
45
45
  RunEventSubscriber,
46
+ RunStartInfo,
46
47
  WireEvent,
47
48
  TaskView,
48
49
  OutcomeView,
@@ -15,7 +15,7 @@
15
15
 
16
16
  import path from 'node:path'
17
17
  import { rename, stat } from 'node:fs/promises'
18
- import { xxh3, xxh3hex } from '../util/index.js'
18
+ import { relPosix, xxh3, xxh3hex } from '../util/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
20
  import type {
21
21
  FingerprintChange,
@@ -270,8 +270,7 @@ function digestFor(importers: ReadonlyMap<string, string>, importer: string): st
270
270
 
271
271
  /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
272
272
  function importerOf(workspaceRoot: string, projectDir: string): string {
273
- const rel = path.relative(workspaceRoot, projectDir)
274
- return rel === '' ? '.' : rel.split(path.sep).join('/')
273
+ return relPosix(workspaceRoot, projectDir) || '.'
275
274
  }
276
275
 
277
276
  async function readMemo(
@@ -318,7 +318,7 @@ export function defaultLogger(
318
318
  // for as long as vx holds it: `vx run dev --all` showed none of its
319
319
  // servers' logs while it ran (C-56). So a ready server's output streams
320
320
  // from then on, a line at a time under its id; the partial line each
321
- // holds waits for its newline, a second runEnd, or `settle`.
321
+ // holds waits for its newline, a status line, a second runEnd, or `settle`.
322
322
  const KEPT_LINE_CAP = 64 * 1024
323
323
  const keptLines = new Map<string, { node: TaskNode; rest: string }>()
324
324
  const streamKept = (node: TaskNode, chunk: string): void => {
@@ -450,6 +450,9 @@ export function defaultLogger(
450
450
  writer.settle()
451
451
  },
452
452
  status(line) {
453
+ // A kept server's held partial line is older than this line: held
454
+ // past it, a server's last words printed below its own exit notice.
455
+ flushKept()
453
456
  writer.write(`${line}\n`)
454
457
  },
455
458
  runStart(info) {