@vzn/vx 0.0.597 → 0.0.598

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.
@@ -4,7 +4,8 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
- import { existsSync, readFileSync } from 'node:fs'
7
+ import type { Pointer } from 'bun:ffi'
8
+ import { closeSync, existsSync, readFileSync } from 'node:fs'
8
9
  import { constants as osConstants } from 'node:os'
9
10
  import {
10
11
  shellArgv,
@@ -98,11 +99,134 @@ export interface RunOptions {
98
99
  terminal?: boolean
99
100
  }
100
101
 
102
+ type Fd = 'inherit' | 'ignore' | 'pipe' | number
103
+
101
104
  /** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
102
- function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
103
- return terminal === true
104
- ? (['inherit', 'inherit', 'inherit'] as const)
105
- : ([stdin, 'pipe', 'pipe'] as const)
105
+ function taskStdio(
106
+ terminal: boolean | undefined,
107
+ stdin: 'ignore' | 'pipe',
108
+ pipes: TaskPipes,
109
+ ): [Fd, Fd, Fd] {
110
+ return terminal === true ? ['inherit', 'inherit', 'inherit'] : [stdin, ...pipes.stdio]
111
+ }
112
+
113
+ const O_NONBLOCK = 0o4000
114
+ const O_CLOEXEC = 0o2000000
115
+ const F_SETFL = 4
116
+ const F_SETPIPE_SZ = 1031
117
+ /**
118
+ * A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
119
+ * through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
120
+ * at 256 KiB it drained in 230. Past the user's pipe quota the kernel
121
+ * refuses the resize and the pipe keeps its 64 KiB.
122
+ */
123
+ const PIPE_BYTES = 256 * 1024
124
+
125
+ type PipeCalls = {
126
+ pipe2: (fds: Pointer, flags: number) => number
127
+ fcntl: (fd: number, cmd: number, arg: number) => number
128
+ ptr: (view: Int32Array, byteOffset?: number) => Pointer
129
+ }
130
+ let pipeCalls: PipeCalls | null | undefined
131
+
132
+ /** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
133
+ function linuxPipeCalls(): PipeCalls | null {
134
+ if (pipeCalls !== undefined) return pipeCalls
135
+ pipeCalls = null
136
+ if (process.platform !== 'linux') return null
137
+ try {
138
+ // Loaded at the first spawn, not with the module: a static import cost
139
+ // every vx start 0.3 ms, a warm run that spawns nothing included.
140
+ const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
141
+ // glibc's soname; musl's loader answers any `libc.` name with itself
142
+ // (ldso/dynlink.c's reserved names).
143
+ const { symbols } = dlopen('libc.so.6', {
144
+ pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
145
+ fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
146
+ })
147
+ pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
148
+ } catch {
149
+ // Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
150
+ }
151
+ return pipeCalls
152
+ }
153
+
154
+ /**
155
+ * Two pipes, `[out read, out write, err read, err write]`, or undefined
156
+ * when libc is not bound or a descriptor is not to be had (Bun's own
157
+ * socketpair then fails the spawn with the reason).
158
+ */
159
+ function realPipes(): Int32Array | undefined {
160
+ const calls = linuxPipeCalls()
161
+ if (calls === null) return undefined
162
+ const fds = new Int32Array(4)
163
+ // Bun's spawn closes every descriptor it is not handed (probed, Bun
164
+ // 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
165
+ if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
166
+ if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
167
+ closeSync(fds[0]!)
168
+ closeSync(fds[1]!)
169
+ return undefined
170
+ }
171
+ // vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
172
+ calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
173
+ calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
174
+ calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
175
+ calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
176
+ return fds
177
+ }
178
+
179
+ /**
180
+ * A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
181
+ * opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
182
+ * (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
183
+ * every task, where a terminal or Turbo hands real pipes (X-113). So on
184
+ * Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
185
+ * the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
186
+ * which a socket allows, so Bun's pipe stays there.
187
+ */
188
+ export class TaskPipes {
189
+ /** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
190
+ readonly stdio: readonly [number | 'pipe', number | 'pipe']
191
+ readonly #read: readonly number[]
192
+
193
+ constructor(terminal?: boolean) {
194
+ const fds = terminal === true ? undefined : realPipes()
195
+ this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
196
+ this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
197
+ }
198
+
199
+ /**
200
+ * Close vx's copies of the write ends once the spawn has returned or
201
+ * thrown: the child holds its own, and one left here is a writer the
202
+ * reader's EOF would wait for.
203
+ */
204
+ spawned(): void {
205
+ for (const fd of this.stdio) if (typeof fd === 'number') closeSync(fd)
206
+ }
207
+
208
+ /** The child's stdout and stderr. */
209
+ streams(
210
+ child: ReturnType<typeof Bun.spawn>,
211
+ ): [
212
+ ReadableStream<Uint8Array> | number | undefined,
213
+ ReadableStream<Uint8Array> | number | undefined,
214
+ ] {
215
+ const [out, err] = this.#read
216
+ return out === undefined
217
+ ? [child.stdout, child.stderr]
218
+ : [Bun.file(out).stream(), Bun.file(err!).stream()]
219
+ }
220
+
221
+ /**
222
+ * Close the read ends, once both streams have ended or been cancelled.
223
+ * Bun reads a dup of each, but takes it on the stream's first pull: a
224
+ * number closed before then was reused by the next spawn's pipe, and
225
+ * the stream read THAT (probed, Bun 1.4.2).
226
+ */
227
+ close(): void {
228
+ for (const fd of this.#read) closeSync(fd)
229
+ }
106
230
  }
107
231
 
108
232
  /**
@@ -598,6 +722,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
598
722
  const readyRe = opts.readyWhen !== undefined ? new RegExp(opts.readyWhen) : undefined
599
723
 
600
724
  let child: ReturnType<typeof Bun.spawn>
725
+ const pipes = new TaskPipes(opts.terminal)
601
726
  const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
602
727
  try {
603
728
  const signalFd = opts.signalChannel === true
@@ -619,7 +744,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
619
744
  // The one-shot spawn below keeps 'ignore', so a task that reads
620
745
  // stdin can never hang CI.
621
746
  stdio: [
622
- ...taskStdio(opts.terminal, 'pipe'),
747
+ ...taskStdio(opts.terminal, 'pipe', pipes),
623
748
  ...(signalFd ? ['pipe' as const] : []),
624
749
  ...(guard === undefined ? [] : [guard]),
625
750
  ],
@@ -637,6 +762,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
637
762
  void spawned.exited.then(() => closeSignalChannel(spawned))
638
763
  }
639
764
  } catch (err) {
765
+ pipes.close()
640
766
  const message = err instanceof Error ? err.message : String(err)
641
767
  return {
642
768
  child: undefined as unknown as ReturnType<typeof Bun.spawn>,
@@ -645,6 +771,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
645
771
  ),
646
772
  readyMs: () => Date.now() - start,
647
773
  }
774
+ } finally {
775
+ pipes.spawned()
648
776
  }
649
777
 
650
778
  let resolveReady!: () => void
@@ -738,8 +866,10 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
738
866
 
739
867
  // Wire up readers. We deliberately don't await them — they run for
740
868
  // the child's lifetime. The `ready` promise resolves out-of-band.
741
- void consumeChunks(child.stdout, false)
742
- void consumeChunks(child.stderr, true)
869
+ const [stdout, stderr] = pipes.streams(child)
870
+ void Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(() =>
871
+ pipes.close(),
872
+ )
743
873
 
744
874
  opts.liveChildren?.add(child)
745
875
  opts.onSpawn?.(child.pid)
@@ -834,6 +964,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
834
964
  const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
835
965
 
836
966
  let proc: ReturnType<typeof Bun.spawn>
967
+ const pipes = new TaskPipes(opts.terminal)
837
968
  const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
838
969
  try {
839
970
  proc = spawnGuarded((guard) =>
@@ -841,7 +972,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
841
972
  ...SH_ARGV0,
842
973
  cwd: opts.cwd,
843
974
  env: opts.env as Record<string, string>,
844
- stdio: [...taskStdio(opts.terminal, 'ignore'), ...(guard === undefined ? [] : [guard])],
975
+ stdio: [
976
+ ...taskStdio(opts.terminal, 'ignore', pipes),
977
+ ...(guard === undefined ? [] : [guard]),
978
+ ],
845
979
  // Its own session and process group, so a kill reaches what it
846
980
  // forked (kill-tree.ts). A terminal handed over is not this
847
981
  // session's controlling one, so no read stops the group.
@@ -849,19 +983,23 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
849
983
  }),
850
984
  )
851
985
  } catch (err) {
986
+ pipes.close()
852
987
  const stderr = spawnFailureText(err, opts.cwd)
853
988
  opts.onStderr?.(stderr)
854
989
  return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
990
+ } finally {
991
+ pipes.spawned()
855
992
  }
856
993
 
857
994
  opts.liveChildren?.add(proc)
858
995
  opts.onSpawn?.(proc.pid)
859
996
  const timeout = armTimeout(proc, opts.timeoutMs)
860
997
  const ac = new AbortController()
998
+ const [out, err] = pipes.streams(proc)
861
999
  const streams = Promise.all([
862
- streamToString(proc.stdout, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
863
- streamToString(proc.stderr, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
864
- ])
1000
+ streamToString(out, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
1001
+ streamToString(err, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
1002
+ ]).finally(() => pipes.close())
865
1003
  // Gate on the child's own exit, not on stream EOF: an orphaned grandchild
866
1004
  // can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
867
1005
  // grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
@@ -55,6 +55,7 @@ import {
55
55
  spawnFailureText,
56
56
  streamToString,
57
57
  resourceUsageToCpuRss,
58
+ TaskPipes,
58
59
  type CaptureConfig,
59
60
  type RunResult,
60
61
  } from './runner.js'
@@ -1281,6 +1282,11 @@ async function wrapIn(
1281
1282
  customConfig!.filesystem!.denyRead!.push(baselines.cwd)
1282
1283
  }
1283
1284
  customConfig!.filesystem!.allowWrite!.push(toRealPath(tmp))
1285
+ // Seatbelt judges `/dev/stdout` by the path the kernel resolves it to,
1286
+ // `/dev/fd/1`, which SRT's default `/dev/stdout` grant does not name, so
1287
+ // `echo x > /dev/stdout` failed EPERM (X-113). `/dev/fd/N` reopens only a
1288
+ // descriptor the task already holds.
1289
+ if (process.platform === 'darwin') customConfig!.filesystem!.allowWrite!.push('/dev/fd')
1284
1290
  if (scopedDenyScan) {
1285
1291
  customConfig!.filesystem!.denyWrite!.push(
1286
1292
  ...scopedMandatoryDenies(
@@ -1545,7 +1551,9 @@ function literalReadPaths(
1545
1551
  * baseline reads: a linked dependency under `packages/[legacy]/` was a
1546
1552
  * class too, and as an exact regex even `[[]` grants the directory's entry
1547
1553
  * and none of its files, so its subtree is granted beside it (a trailing
1548
- * `/**` is stripped before the compile).
1554
+ * `/**` is stripped before the compile). A task's read grant whose only
1555
+ * brackets are escaped names one path too, and Linux binds it whole, so
1556
+ * it gets the same subtree: `out/\[id\]` read `out/[id]` and none of it.
1549
1557
  */
1550
1558
  export function seatbeltBrackets(
1551
1559
  config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
@@ -1556,8 +1564,9 @@ export function seatbeltBrackets(
1556
1564
  const literal = (p: string): string => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']')
1557
1565
  const bracketed = new Set(names.filter((n) => /[[\]]/.test(n)))
1558
1566
  const read = (p: string): string[] => {
1559
- if (!bracketed.has(p)) return [literal(p)]
1560
- const at = p.replaceAll('[', '[[]')
1567
+ const name = bracketed.has(p) ? p : /\\[[\]]/.test(p) ? namedPath(p) : undefined
1568
+ if (name === undefined) return [literal(p)]
1569
+ const at = name.replaceAll('[', '[[]')
1561
1570
  return [at, `${at}/**/*`]
1562
1571
  }
1563
1572
  return {
@@ -1566,7 +1575,9 @@ export function seatbeltBrackets(
1566
1575
  ...fs,
1567
1576
  denyRead: fs.denyRead.map((p) => p.replaceAll('[', '[[]')),
1568
1577
  allowWrite: fs.allowWrite.map(literal),
1569
- ...(fs.allowRead !== undefined ? { allowRead: fs.allowRead.flatMap(read) } : {}),
1578
+ ...(fs.allowRead !== undefined
1579
+ ? { allowRead: [...new Set(fs.allowRead.flatMap(read))] }
1580
+ : {}),
1570
1581
  },
1571
1582
  }
1572
1583
  }
@@ -1977,6 +1988,7 @@ async function runSandboxedOnce(
1977
1988
  if (straceLog) unlinkOnExit(straceLog)
1978
1989
  let proc: ReturnType<typeof Bun.spawn>
1979
1990
  let traceFd: number | undefined
1991
+ const pipes = new TaskPipes()
1980
1992
  try {
1981
1993
  // Resolved on vx's own PATH (util/which.ts), not the task's, where a
1982
1994
  // project's node_modules/.bin comes first.
@@ -1991,8 +2003,7 @@ async function runSandboxedOnce(
1991
2003
  env: args.env as Record<string, string>,
1992
2004
  stdio: [
1993
2005
  'ignore',
1994
- 'pipe',
1995
- 'pipe',
2006
+ ...pipes.stdio,
1996
2007
  forwardsSignals ? 'pipe' : 'ignore',
1997
2008
  guard ?? 'ignore',
1998
2009
  ...(traceFd === undefined ? [] : [traceFd]),
@@ -2003,6 +2014,7 @@ async function runSandboxedOnce(
2003
2014
  )
2004
2015
  if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
2005
2016
  } catch (err) {
2017
+ pipes.close()
2006
2018
  if (straceLog) {
2007
2019
  rmSync(straceLog, { force: true })
2008
2020
  liveTempFiles.delete(straceLog)
@@ -2025,6 +2037,7 @@ async function runSandboxedOnce(
2025
2037
  } finally {
2026
2038
  // The child holds its own copy; ours would keep nothing but a descriptor.
2027
2039
  if (traceFd !== undefined) closeSync(traceFd)
2040
+ pipes.spawned()
2028
2041
  }
2029
2042
 
2030
2043
  args.liveChildren?.add(proc)
@@ -2035,10 +2048,11 @@ async function runSandboxedOnce(
2035
2048
  // The unfinished last line of stderr, and whether a line was strace's.
2036
2049
  let partial = ''
2037
2050
  let straceSpoke = false
2051
+ const [out, err] = pipes.streams(proc)
2038
2052
  const streams = Promise.all([
2039
- streamToString(proc.stdout, args.onStdout, ac.signal, args.capture?.stdout ?? true),
2053
+ streamToString(out, args.onStdout, ac.signal, args.capture?.stdout ?? true),
2040
2054
  streamToString(
2041
- proc.stderr,
2055
+ err,
2042
2056
  (chunk) => {
2043
2057
  // Read whatever the capture setting: a line of strace's own says
2044
2058
  // the trace stopped short.
@@ -2050,7 +2064,7 @@ async function runSandboxedOnce(
2050
2064
  ac.signal,
2051
2065
  args.capture?.stderr ?? true,
2052
2066
  ),
2053
- ])
2067
+ ]).finally(() => pipes.close())
2054
2068
  // See runCommand: gate on child exit; a lingering grandchild pipe (timeout
2055
2069
  // OR a clean exit that backgrounds a process) can't hang the run — timeout
2056
2070
  // aborts at once, otherwise drainOrAbort bounds the post-exit drain.
@@ -152,8 +152,7 @@ export function mergePriorities(
152
152
  // it sorts above the baseline for any node it covers, and add the
153
153
  // baseline as a tie-break for parity within the override set.
154
154
  const SCALE = 1 << 20
155
- const out = new Map<string, number>()
156
- for (const [id, w] of baseline) out.set(id, w)
155
+ const out = new Map(baseline)
157
156
  for (const [id, w] of overrides) {
158
157
  const b = baseline.get(id) ?? 0
159
158
  out.set(id, w * SCALE + b)
package/src/index.ts CHANGED
@@ -34,7 +34,9 @@ export { isLiteralPattern, normalizeGlob } from './util/index.js'
34
34
  // A task's command as vx prints it: values of its `exec.env.secret` names
35
35
  // masked. `vx show` and telemetry show this; a plugin that hands a command
36
36
  // to someone else (`@vzn/vx-mcp`'s `listTasks`, an AI agent) does too (L-26).
37
- export { maskedCommand } from './util/index.js'
37
+ // `maskedLine` is the same for a line no one task owns: the error `vx mcp`
38
+ // hands an agent (L-11).
39
+ export { maskedCommand, maskedLine } from './util/index.js'
38
40
 
39
41
  // Schema types and helpers (used by user vx.config files and presets).
40
42
  export type {
@@ -16,6 +16,7 @@ import {
16
16
  isUnsupportedBun,
17
17
  machineMemoryBytes,
18
18
  machineParallelism,
19
+ maskedLine,
19
20
  relPosix,
20
21
  } from '../util/index.js'
21
22
  import { VERSION } from '../version.js'
@@ -397,7 +398,8 @@ async function countLoadableTasks(
397
398
  : raw
398
399
  errors.push({
399
400
  path: relPosix(root, where),
400
- message,
401
+ // A config's own throw is its author's text, a secret too (L-11).
402
+ message: maskedLine(message),
401
403
  })
402
404
  }
403
405
  }),
@@ -462,7 +462,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
462
462
  const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
463
463
  const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
464
464
  log.status(
465
- `vx: ${node.id} not ready after ${after}: waiting for a line matching /${readyWhen}/ (readyWhen)${unbounded}`,
465
+ `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
466
466
  )
467
467
  }, noticeMs)
468
468
  if (notice !== undefined) {
@@ -867,6 +867,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
867
867
  for (const c of captured)
868
868
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
869
869
  c.hash = MASKED + c.hash
870
+ // A runtime probe's row is named by its command, which a TS config may
871
+ // build from `process.env`: the run history stored it and `vx why`
872
+ // printed it (L-11).
873
+ const nameSecrets =
874
+ captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
875
+ if (nameSecrets !== null) {
876
+ // Two probes that differ only by a secret mask to one name, and a row
877
+ // is stored once per name: numbered, the second's change still shows.
878
+ const seen = new Set(captured.map((c) => `${c.kind}\0${c.name}`))
879
+ for (const c of captured) {
880
+ const masked = nameSecrets.mask(c.name)
881
+ if (masked === c.name) continue
882
+ let name = masked
883
+ for (let n = 2; seen.has(`${c.kind}\0${name}`); n++) name = `${masked} (${n})`
884
+ seen.add(`${c.kind}\0${name}`)
885
+ c.name = name
886
+ }
887
+ }
870
888
  const inputs: TaskInputs | undefined = described?.inputs
871
889
  const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
872
890
  // A plugin may keep the request past the run (the cache closed): it gets
@@ -1493,7 +1511,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1493
1511
  ? {
1494
1512
  // vx's own notes ride with the lines and are no denial (B-20).
1495
1513
  sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
1496
- sandboxViolationLines: finalViolations.map((v) => v.line),
1514
+ // A denied path is the task's own spelling, a secret in it too.
1515
+ sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
1497
1516
  }
1498
1517
  : {}),
1499
1518
  }
@@ -130,7 +130,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
130
130
  // set that shrank would otherwise vouch for a tree it never saw,
131
131
  // and the failure it would let through is a skipped restore.
132
132
  const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
133
- setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
133
+ setKnown = covers && (await args.cache.outputDirsCurrent(node.projectDir, dirRows))
134
134
  endDirs()
135
135
  }
136
136
  let treeMatches: boolean
@@ -71,6 +71,7 @@ interface Digests {
71
71
 
72
72
  interface Memo {
73
73
  version: number
74
+ claimant: string
74
75
  lock: string
75
76
  /** `extraFiles` → content hash when the digests were computed. */
76
77
  extras?: Record<string, string>
@@ -120,6 +121,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
120
121
  const { file, digest, version } = options
121
122
  const scope = options.scope ?? 'project'
122
123
  const part = options.part ?? 'deps'
124
+ // `version` is one claimant's counter, and another claimant of the file
125
+ // at the same number read this one's memo: one that named no extra file
126
+ // left the other's patch edits out of its key (X-130). The functions'
127
+ // source stands for the claimant; a rebuild that changes it is a miss.
128
+ const claimant = xxh3hex([part, String(digest), String(options.extraFiles ?? '')].join('\0'))
123
129
  if (scope !== 'project' && scope !== 'workspace') {
124
130
  throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
125
131
  }
@@ -159,7 +165,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
159
165
  const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
160
166
  // The extra files the last digest read, and their hashes now: a warm run
161
167
  // re-hashes the files the memo names and never parses to learn them.
162
- const memo = await readMemo(memoFile, version, lockHash)
168
+ const memo = await readMemo(memoFile, version, claimant, lockHash)
163
169
  let extras: Record<string, string> = {}
164
170
  let extraStats: Record<string, string> = {}
165
171
  let importers: ReadonlyMap<string, string> | undefined
@@ -182,8 +188,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
182
188
  ? new Map<string, string>()
183
189
  : await computeAndMemo(
184
190
  memoFile,
185
- version,
186
- lockHash,
191
+ { version, claimant, lock: lockHash },
187
192
  extras,
188
193
  digest(text, new Map(Object.entries(extras))),
189
194
  )
@@ -273,11 +278,14 @@ function importerOf(workspaceRoot: string, projectDir: string): string {
273
278
  async function readMemo(
274
279
  memoFile: string,
275
280
  version: number,
281
+ claimant: string,
276
282
  lock: string,
277
283
  ): Promise<{ extras: Record<string, string>; importers: Map<string, string> } | undefined> {
278
284
  try {
279
285
  const memo = (await Bun.file(memoFile).json()) as Memo
280
- if (memo.version !== version || memo.lock !== lock) return undefined
286
+ if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
287
+ return undefined
288
+ }
281
289
  return { extras: memo.extras ?? {}, importers: new Map(Object.entries(memo.importers)) }
282
290
  } catch {
283
291
  return undefined
@@ -286,12 +294,11 @@ async function readMemo(
286
294
 
287
295
  async function computeAndMemo(
288
296
  memoFile: string,
289
- version: number,
290
- lock: string,
297
+ identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
291
298
  extras: Record<string, string>,
292
299
  importers: ReadonlyMap<string, string>,
293
300
  ): Promise<ReadonlyMap<string, string>> {
294
- const memo: Memo = { version, lock, extras, importers: Object.fromEntries(importers) }
301
+ const memo: Memo = { ...identity, extras, importers: Object.fromEntries(importers) }
295
302
  // Write-then-rename: a reader never sees a half-written memo, and two
296
303
  // concurrent runs each land a whole one.
297
304
  const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
@@ -518,9 +518,11 @@ export function defaultLogger(
518
518
  killStatus()
519
519
  // Persistent tails first: they are context for whatever ran against
520
520
  // the server, so they read above the failures they explain. `none`
521
- // and `errors-only` state their contracts absolutely (no per-task
522
- // output / only failed tasks print), so they stay silent — the tail
523
- // is still captured and still bounded, just never printed. Guarded
521
+ // prints no per-task output and `errors-only` only failed tasks: a
522
+ // server that died on its own before the run stopped it is failed by
523
+ // now (run.ts sets its outcome in place), and under `errors-only` its
524
+ // tail was dropped with the healthy ones', so the crash read only as a
525
+ // count (WD-25). The rest is still captured and bounded. Guarded
524
526
  // like the failures below: run() calls runEnd twice on the success
525
527
  // path (once before the summary, once in its finally), and though the
526
528
  // bus delivers `run:end` once (`busLogger`), a renderer an embedder
@@ -528,12 +530,13 @@ export function defaultLogger(
528
530
  if (flushedPersistent) flushKept()
529
531
  if (!flushedPersistent) {
530
532
  flushedPersistent = true
531
- if (view.mode !== 'none' && view.mode !== 'errors-only') {
533
+ if (view.mode !== 'none') {
532
534
  for (const t of persistentTails.values()) {
533
535
  // No outcome means the task never completed — it is still
534
536
  // pre-ready, so there is no "since ready" window to render and
535
537
  // its buffered output belongs to the frame it never got.
536
538
  if (t.outcome === undefined) continue
539
+ if (view.mode === 'errors-only' && t.outcome.status !== 'failed') continue
537
540
  emitBlock(
538
541
  fenced(
539
542
  formatPersistentTailBlock(
@@ -10,7 +10,7 @@
10
10
  import path from 'node:path'
11
11
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
12
12
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
13
- import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
+ import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
14
14
  import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
15
15
  import { checkGraph, type TaskNode } from '../graph/index.js'
16
16
  import type {
@@ -86,8 +86,13 @@ async function safe<T>(plugin: VxPlugin, hook: string, fn: () => T | Promise<T>)
86
86
  try {
87
87
  return await fn()
88
88
  } catch (err) {
89
+ // Masked here, not by whoever prints it: an embedder of `run()` and
90
+ // `vx mcp` print it as they get it, and the throw quotes what the
91
+ // plugin was told (a remote's reply, a header) (L-11).
89
92
  throw new UserError(
90
- `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
93
+ maskedLine(
94
+ `plugin '${plugin.name}' failed in ${hook}: ${err instanceof Error ? err.message : String(err)}`,
95
+ ),
91
96
  )
92
97
  }
93
98
  }
@@ -15,7 +15,7 @@ import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
15
15
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
16
16
  import type { TaskExecutor } from '../exec/index.js'
17
17
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
18
- import { nearest, UserError } from '../util/index.js'
18
+ import { maskedLine, nearest, UserError } from '../util/index.js'
19
19
  import type { ProjectMeta } from '../workspace/index.js'
20
20
  import type { EventBus, RunStartInfo } from './events.js'
21
21
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
@@ -716,7 +716,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
716
716
  for (const d of disposers) d()
717
717
  throw new PluginSetupError(
718
718
  plugin,
719
- `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
719
+ // Masked at the source, as plugin-host.ts's `safe()` does (L-11).
720
+ maskedLine(
721
+ `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
722
+ ),
720
723
  )
721
724
  }
722
725
  }
@@ -379,9 +379,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
379
379
  // it, and the run said no project did (C-3). Only what is still
380
380
  // unjudged pays for the rest of the workspace.
381
381
  unresolvedTasks = undeclaredIn(unresolvedTasks, projects)
382
+ // By name, not by count: a config-less project in the scope counts as
383
+ // loaded, and a changed one hid an unloaded `test` declarer (X-129).
382
384
  if (
383
- projects.size < projectsWithConfigs.length &&
384
- unresolvedTasks.some((t) => !t.includes('#'))
385
+ unresolvedTasks.some((t) => !t.includes('#')) &&
386
+ projectsWithConfigs.some((m) => !projects.has(m.name))
385
387
  ) {
386
388
  unresolvedTasks = await declaredNowhere(unresolvedTasks, () =>
387
389
  loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
@@ -321,7 +321,7 @@ const DEFAULT_BUILD: TaskConfig = Object.freeze({
321
321
  inputs: Object.freeze({ files: Object.freeze(['**']) }),
322
322
  outputs: Object.freeze({ files: Object.freeze([]) }),
323
323
  }),
324
- }) as unknown as TaskConfig
324
+ })
325
325
 
326
326
  /** The default `build`, which a menu of the tasks a user declared leaves out. */
327
327
  export function isDefaultBuild(task: TaskConfig | undefined): boolean {
@@ -379,7 +379,7 @@ function ciLinks(provider: string, env: Record<string, string | undefined>): CiL
379
379
  default:
380
380
  return {}
381
381
  }
382
- return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined)) as CiLinks
382
+ return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined))
383
383
  }
384
384
 
385
385
  /** Host name (null on failure) + platform + arch. */
@@ -66,7 +66,8 @@ import { rmdirSync, unlinkSync } from 'node:fs'
66
66
  import { mkdir, readdir, readFile, rename, rm, rmdir, unlink, writeFile } from 'node:fs/promises'
67
67
  import os from 'node:os'
68
68
  import path from 'node:path'
69
- import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, xxh3hex } from '../util/index.js'
69
+ import { VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
70
+ import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, UserError, xxh3hex } from '../util/index.js'
70
71
 
71
72
  /** Runs in this process currently holding the lock, per lock directory. */
72
73
  const heldHere = new Map<string, number>()
@@ -329,6 +330,14 @@ export async function acquireRunLock(
329
330
  opts: RunLockOptions,
330
331
  ): Promise<() => Promise<void>> {
331
332
  const lockDir = runLockPath(workspaceRoot, opts.dir)
333
+ // A task's own process (`vx cache prune` in a task) would wait for the
334
+ // run that started it, which holds the lock until that task ends.
335
+ const outer = process.env[VX_RUN_WORKSPACE_ENV]
336
+ if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
337
+ throw new UserError(
338
+ `this vx runs inside task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} of a vx run on this workspace, which holds its run lock until it ends — run it before or after that run, not from one of its tasks`,
339
+ )
340
+ }
332
341
  const started = Date.now()
333
342
  let said = false
334
343
  const release = async (): Promise<void> => {