@vzn/vx 0.0.50 → 0.0.51

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 (54) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +1 -0
  22. package/src/cli/select.ts +29 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +35 -18
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/orchestrator/admission.ts +72 -17
  35. package/src/orchestrator/execute-task.ts +39 -25
  36. package/src/orchestrator/hit-restore.ts +7 -1
  37. package/src/orchestrator/miss-save.ts +29 -1
  38. package/src/orchestrator/placement.ts +23 -3
  39. package/src/orchestrator/plugin-host.ts +1 -1
  40. package/src/orchestrator/plugin.ts +9 -1
  41. package/src/orchestrator/prepare.ts +1 -0
  42. package/src/orchestrator/run.ts +75 -12
  43. package/src/orchestrator/sandbox-request.ts +19 -10
  44. package/src/orchestrator/summary.ts +26 -15
  45. package/src/orchestrator/task-hash.ts +7 -2
  46. package/src/orchestrator/telemetry.ts +14 -4
  47. package/src/util/paths.ts +16 -9
  48. package/src/workspace/affected.ts +201 -38
  49. package/src/workspace/config-eval.ts +4 -1
  50. package/src/workspace/config-schema.ts +70 -10
  51. package/src/workspace/fingerprint.ts +6 -0
  52. package/src/workspace/migration.ts +1 -1
  53. package/src/workspace/project-loader.ts +53 -1
  54. package/src/workspace/workspace.ts +54 -14
@@ -25,32 +25,87 @@ import { executeTask, type ExecuteArgs } from './execute-task.js'
25
25
  import type { ShortCircuit } from './local-shortcircuit.js'
26
26
  import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
27
27
 
28
+ /** What `taintTracker` answers per dispatch, and what it learns per finish. */
29
+ export interface TaintTracker {
30
+ /** Whether `node`, dispatched with these `upstream` outcomes, builds on bytes nothing vouches for. */
31
+ judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
32
+ /** Every settled outcome, the scheduler's `onFinish`. */
33
+ settled: (outcome: TaskOutcome) => void
34
+ }
35
+
36
+ const UNTAINTED: TaintTracker = { judge: () => false, settled: () => {} }
37
+
28
38
  /**
29
39
  * Records which tasks ran behind a failure, or on the key of a dependency
30
40
  * that did not run (`seeds`), and answers, per task, whether this one does.
31
41
  * Disabled (always `false`, nothing recorded) unless the run's
32
42
  * `continueMode` is `'always'` or there is a seed.
43
+ *
44
+ * A task's taint is read from its deps' SETTLED outcomes, not from what it
45
+ * saw at dispatch: a restore-tier hit dispatches before its deps settle,
46
+ * so it saw holes where the failure would be, and a dependent that asked
47
+ * "was that hit tainted?" heard no. Under `--continue=always` the
48
+ * dependent then saved the partial tree on its healthy key, and the next
49
+ * healthy run replayed it (C-1). A dependent is released only once the
50
+ * hit's own deps have settled too (scheduler.ts, item 963), so by the time
51
+ * anyone asks, the answer is complete.
33
52
  */
34
53
  export function taintTracker(
35
54
  continueAlways: boolean,
36
55
  seeds: ReadonlySet<string>,
37
- ): (node: TaskNode, upstream: TaskOutcome[]) => boolean {
38
- if (!continueAlways && seeds.size === 0) return () => false
39
- const tainted = new Set<string>()
40
- return (node, upstream) => {
41
- const taint =
42
- seeds.has(node.id) ||
43
- upstream.some(
44
- // A restore-tier task may run before its deps and see holes here;
45
- // it never saves anyway (a hit restores), so a hole is not taint.
46
- (u) =>
47
- u !== undefined &&
48
- (tainted.has(u.node.id) ||
49
- (continueAlways &&
50
- (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped'))),
51
- )
52
- if (taint) tainted.add(node.id)
53
- return taint
56
+ nodes: ReadonlyMap<string, TaskNode>,
57
+ ): TaintTracker {
58
+ if (!continueAlways && seeds.size === 0) return UNTAINTED
59
+ const outcomes = new Map<string, TaskOutcome>()
60
+ const memo = new Map<string, boolean>()
61
+ const failed = (u: TaskOutcome): boolean =>
62
+ continueAlways && (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped')
63
+ const through = (u: TaskOutcome | undefined): boolean =>
64
+ u !== undefined && (failed(u) || taintOf(u.node.id))
65
+ // Post-order over the settled deps with an explicit stack: a chain of
66
+ // restore-tier hits is judged in one ask, and a recursion would take a
67
+ // frame per hop. Memoized once every dep has settled; until then the
68
+ // answer may still grow.
69
+ const taintOf = (root: string): boolean => {
70
+ const known = memo.get(root)
71
+ if (known !== undefined) return known
72
+ const local = new Map<string, boolean>()
73
+ const valueOf = (id: string): boolean | undefined => memo.get(id) ?? local.get(id)
74
+ const stack = [root]
75
+ while (stack.length > 0) {
76
+ const id = stack[stack.length - 1]!
77
+ if (valueOf(id) !== undefined) {
78
+ stack.pop()
79
+ continue
80
+ }
81
+ const deps = seeds.has(id) ? [] : (nodes.get(id)?.deps ?? [])
82
+ const next = deps.find((d) => {
83
+ const u = outcomes.get(d)
84
+ return u !== undefined && !failed(u) && valueOf(d) === undefined
85
+ })
86
+ if (next !== undefined) {
87
+ stack.push(next)
88
+ continue
89
+ }
90
+ stack.pop()
91
+ const taint =
92
+ seeds.has(id) ||
93
+ deps.some((d) => {
94
+ const u = outcomes.get(d)
95
+ return u !== undefined && (failed(u) || valueOf(d) === true)
96
+ })
97
+ local.set(id, taint)
98
+ if (taint || deps.every((d) => outcomes.has(d))) memo.set(id, taint)
99
+ }
100
+ return valueOf(root)!
101
+ }
102
+ return {
103
+ // A restore-tier task may run before its deps and see holes here; it
104
+ // never saves anyway (a hit restores), so a hole is not taint.
105
+ judge: (node, upstream) => seeds.has(node.id) || upstream.some(through),
106
+ settled: (outcome) => {
107
+ outcomes.set(outcome.node.id, outcome)
108
+ },
54
109
  }
55
110
  }
56
111
 
@@ -18,7 +18,7 @@ import {
18
18
  VX_RUN_TASK_ENV,
19
19
  VX_RUN_WORKSPACE_ENV,
20
20
  runPersistent,
21
- shellQuote,
21
+ withForwardArgs,
22
22
  releaseBridges,
23
23
  wrapSandboxedCommand,
24
24
  signalExitCode,
@@ -260,16 +260,12 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
260
260
  const env = taskEnv(node, step, args.workspaceRoot)
261
261
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
262
262
 
263
- // When readyWhen is set we leave the command untouched so the
264
- // regex matcher sees the unmodified output. When it's absent the
265
- // task is "ready on spawn" — we can safely append forwardArgs in
266
- // the same way runCommand does.
267
- const plainCommand =
268
- step.persistent.readyWhen !== undefined
269
- ? step.command
270
- : effectiveForwardArgs.length > 0
271
- ? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
272
- : step.command
263
+ // The args after `--` reach a server as they reach any task. A readyWhen
264
+ // server once got none — "so the matcher sees the unmodified output",
265
+ // though the args change the command, not what the matcher reads — and
266
+ // `vx run dev -- --port 4000` on the documented Vite task dropped the
267
+ // port in silence (item 1060).
268
+ const plainCommand = withForwardArgs(step.command, effectiveForwardArgs)
273
269
  // A persistent task declaring `exec.sandbox` runs inside it like any
274
270
  // other: the same grants, the same walls. What it cannot have is the
275
271
  // violation REPORT — that reads the trace after the child exits, and a
@@ -616,6 +612,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
616
612
  let attempt = 0
617
613
  let result: ExecuteResult
618
614
  let effectiveExitCode: number
615
+ // What THIS run spent on the task, every attempt (`TaskOutcome.durationMs`):
616
+ // the last attempt alone read `max 407ms` beside a footer of 837 (item
617
+ // 1101). A saved entry keeps the attempt that produced it, what a hit saves.
618
+ let spentMs = 0
619
619
 
620
620
  // One task attempt: clean the declared outputs (before EVERY attempt — so a
621
621
  // stale prior-build artifact can't survive into a fresh run, and a failed
@@ -716,9 +716,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
716
716
  // whole contract. `userSandbox` is the only way a task is sandboxed,
717
717
  // so this reads as "sandboxed and it tripped".
718
718
  // A child we SIGTERMed for exceeding the timeout is a genuine failure —
719
- // stream a clear line so the 143 exit reads as a timeout.
719
+ // stream a clear line so the 143 exit reads as a timeout. One that outlived
720
+ // the grace died of the SIGKILL, and the line said SIGTERM over an exit
721
+ // 137 (item 1063).
720
722
  if (res.timedOut) {
721
- log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (SIGTERM)\n`)
723
+ const how = res.signal === 'SIGKILL' ? 'SIGKILL after the SIGTERM grace' : 'SIGTERM'
724
+ log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`)
722
725
  // Force a non-zero classification even if the child TRAPPED SIGTERM and
723
726
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
724
727
  // pattern). Without this a timed-out task is classified `success` and its
@@ -781,25 +784,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
781
784
  if (writesFingerprint) args.fingerprintWatch?.wrote()
782
785
  result = a.result
783
786
  effectiveExitCode = a.exitCode
787
+ spentMs += result.durationMs
784
788
 
785
789
  // An attempt that ended in a shutdown never finished on its own terms —
786
790
  // it is aborted, so it is neither cached, counted, shown, nor RETRIED.
787
- // Two ways to know: the child died of SIGINT / SIGTERM (Ctrl-C, or a
788
- // `kill` vx never saw; a timeout also SIGTERMs, but `timedOut` marks it
789
- // as our own deadline, a real and retryable failure), or the RUN is
790
- // stopping. The child alone was not enough: one that traps the
791
- // forwarded SIGINT and exits 0 was cached as a success with its partial
792
- // outputs, one that exits 1 was retried after Ctrl-C, and one SIGKILLed
793
- // at the end of the grace read as an OOM failure (item 962).
794
- const shutdown =
795
- args.stopSignal?.aborted === true ||
796
- ((result.signal === 'SIGINT' || result.signal === 'SIGTERM') && !result.timedOut)
791
+ // The RUN stopping is what says so. A child that traps the forwarded
792
+ // SIGINT and exits 0 was cached as a success with its partial outputs,
793
+ // one that exits 1 was retried after Ctrl-C, and one SIGKILLed at the
794
+ // end of the grace read as an OOM failure (item 962). A child that died
795
+ // of SIGINT / SIGTERM while the run was NOT stopping (a supervisor, a
796
+ // `kill` from another shell) is a failure: it was labelled a shutdown
797
+ // abort, never retried, its output hidden, and fail-fast never tripped
798
+ // (item 1100). A terminal's Ctrl-C reaches the child and vx together,
799
+ // and the child's exit can be seen before vx's own handler has run, so
800
+ // a signal death waits one event-loop turn for a pending handler before
801
+ // it is judged; a timeout's SIGTERM is our own deadline and judged now.
802
+ let shutdown = args.stopSignal?.aborted === true
803
+ if (
804
+ !shutdown &&
805
+ (result.signal === 'SIGINT' || result.signal === 'SIGTERM') &&
806
+ !result.timedOut
807
+ ) {
808
+ await new Promise((resolve) => setTimeout(resolve, 0))
809
+ shutdown = args.stopSignal?.aborted === true
810
+ }
797
811
  if (shutdown) {
798
812
  return {
799
813
  node,
800
814
  status: 'aborted',
801
815
  exitCode: effectiveExitCode,
802
- durationMs: result.durationMs,
816
+ durationMs: spentMs,
803
817
  hash,
804
818
  wallclockStartNs,
805
819
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
@@ -977,7 +991,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
977
991
  node,
978
992
  status: effectiveExitCode === 0 ? 'success' : 'failed',
979
993
  exitCode: effectiveExitCode,
980
- durationMs: result.durationMs,
994
+ durationMs: spentMs,
981
995
  hash,
982
996
  ...(attempt > 1 ? { attempts: attempt } : {}),
983
997
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
@@ -22,6 +22,7 @@ import {
22
22
  import type { TaskOutcome } from '../graph/index.js'
23
23
  import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
24
24
  import type { ExecuteArgs } from './execute-task.js'
25
+ import { entryHolds } from './miss-save.js'
25
26
 
26
27
  export interface RestoreHitArgs {
27
28
  args: ExecuteArgs
@@ -232,7 +233,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
232
233
  // ~75 ms of the no-op's CPU (2026-09-11). A caller with no list gets
233
234
  // no snapshot (item 637: recording here was that same refusal).
234
235
  if (dirPrefixes !== null && args.outputDirSnapshots !== undefined) {
235
- args.outputDirSnapshots.push({ hash, projectDir: node.projectDir, prefixes: dirPrefixes })
236
+ args.outputDirSnapshots.push({
237
+ hash,
238
+ projectDir: node.projectDir,
239
+ prefixes: dirPrefixes,
240
+ holds: entryHolds(node),
241
+ })
236
242
  }
237
243
  // Restored outputs changed the project's tree — but on this
238
244
  // path we know the EXACT changed paths (wiped declared
@@ -13,9 +13,10 @@ import {
13
13
  type GitFilesCache,
14
14
  resolveOutputs,
15
15
  resolveWorkspaceOutputs,
16
+ WORKSPACE_OUTPUT_PREFIX,
16
17
  } from '../cache/index.js'
17
18
  import type { TaskNode } from '../graph/index.js'
18
- import { span, wholeSubtreePrefixes } from '../util/index.js'
19
+ import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
19
20
  import type { Logger } from './logger.js'
20
21
  import type { TaskInputComponent } from './task-hash.js'
21
22
 
@@ -24,6 +25,32 @@ export interface OutputDirSnapshot {
24
25
  hash: string
25
26
  projectDir: string
26
27
  prefixes: readonly string[]
28
+ /** Whether the files the run-end walk saw are the entry's rows (`recordOutputDirs`). */
29
+ holds: (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean
30
+ }
31
+
32
+ /**
33
+ * The `holds` of a run-end snapshot: the files under the prefixes are the
34
+ * entry's project rows, by the rule the next hit's walk applies. An
35
+ * additive task's rows need only be present (the upstream's files sit
36
+ * beside them); an upstream's tree may also hold what a dependant's glob
37
+ * adds. A stray a later task or another process wrote there before run
38
+ * end refuses the snapshot, and the next hit walks and restores
39
+ * (item 1087). The run reads every snapshot's rows in one batch.
40
+ */
41
+ export function entryHolds(
42
+ node: TaskNode,
43
+ ): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
44
+ return (files, rows) => {
45
+ const expected = new Set(
46
+ rows.map((r) => r.path).filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
47
+ )
48
+ const seen = new Set(files)
49
+ if ((node.addsToOutputsOf?.length ?? 0) > 0) return [...expected].every((p) => seen.has(p))
50
+ const added = (node.outputsAddedToBy ?? []).flatMap((g) => asTrees([g])).map(taskGlob)
51
+ const kept = files.filter((f) => expected.has(f) || !added.some((g) => g.match(f)))
52
+ return kept.length === expected.size && kept.every((f) => expected.has(f))
53
+ }
27
54
  }
28
55
 
29
56
  export interface SaveMissArgs {
@@ -151,6 +178,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
151
178
  hash: a.hash,
152
179
  projectDir: node.projectDir,
153
180
  prefixes: savedDirPrefixes,
181
+ holds: entryHolds(node),
154
182
  })
155
183
  }
156
184
  }
@@ -1,7 +1,8 @@
1
1
  // Where each task runs: the placement of a graph over the resolved
2
2
  // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
3
  // (pure motion). A task is pinned to this machine when it is persistent,
4
- // depends on a persistent task, is sandboxed, or says `exec.remote: false`;
4
+ // depends on a persistent task, is sandboxed, says `exec.remote: false`, or
5
+ // folds a runtime probe into its key;
5
6
  // everything else asks the executors in declaration order, and the local
6
7
  // floor takes what nothing claimed.
7
8
 
@@ -19,7 +20,8 @@ import type { prepareRun } from './prepare.js'
19
20
  * submitter), declares `exec.sandbox` (the sandbox is this machine's
20
21
  * machinery — a worker has none of it, and a boundary "verified" where it
21
22
  * is not enforced passes vacuously), or declares `exec.remote: false`. A
22
- * dependant of a pinned task is pinned with it.
23
+ * dependant of a pinned task is pinned with it. A task whose key folds a
24
+ * runtime probe is pinned alone (`withProbedRuntime`).
23
25
  */
24
26
  export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
25
27
  const pinned = new Set<string>()
@@ -32,7 +34,7 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
32
34
  pinned.add(node.id)
33
35
  }
34
36
  }
35
- if (pinned.size === 0) return pinned
37
+ if (pinned.size === 0) return withProbedRuntime(nodes, pinned)
36
38
  // Pinning flows up the dependant edges from what pins itself, one walk on
37
39
  // an explicit stack: a chain is as deep as the graph, and a recursion
38
40
  // per edge threw `RangeError` on the 50,000 the builder takes (item 737).
@@ -52,6 +54,24 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
52
54
  stack.push(up)
53
55
  }
54
56
  }
57
+ return withProbedRuntime(nodes, pinned)
58
+ }
59
+
60
+ /**
61
+ * A key that folds `cache.inputs.runtime` / `workspaceRuntime` holds what
62
+ * THIS machine answered (`node -v`); a worker runs its own runtime, which no
63
+ * executor can prove equal, and its output saved under this key is a stale
64
+ * hit here (C-2). Such a task runs here. Its dependants are not pinned with
65
+ * it: they fold the probe through its input key, and their own output does
66
+ * not depend on this machine's runtime.
67
+ */
68
+ function withProbedRuntime(nodes: Map<string, TaskNode>, pinned: Set<string>): Set<string> {
69
+ for (const node of nodes.values()) {
70
+ const inputs = node.config.cache?.inputs
71
+ if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
72
+ pinned.add(node.id)
73
+ }
74
+ }
55
75
  return pinned
56
76
  }
57
77
 
@@ -151,7 +151,7 @@ export async function applyGraphHooks(
151
151
  last = plugin
152
152
  }
153
153
  if (last === undefined) return
154
- await safe(last, 'graph', () => checkGraph(nodes))
154
+ await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot))
155
155
  }
156
156
 
157
157
  /**
@@ -224,7 +224,15 @@ export interface PluginCommand {
224
224
  run(argv: readonly string[], ctx: CommandContext): number | Promise<number>
225
225
  }
226
226
 
227
- export interface CommandContext extends BaseContext {}
227
+ export interface CommandContext extends BaseContext {
228
+ /**
229
+ * The worker count a `vx run` here uses unless `--concurrency` says
230
+ * otherwise: `vx.workspace`'s `concurrency`, else what the machine allows.
231
+ * A verb that budgets a run (`vx history`) reads it here; its own
232
+ * `machineParallelism()` said 4 cores beside `concurrency: 1` (item 1068).
233
+ */
234
+ readonly concurrency: number
235
+ }
228
236
 
229
237
  /** `config` runs before the cache dir is known — it may be what the hook changes. */
230
238
  export interface WorkspaceHookContext {
@@ -405,6 +405,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
405
405
  projects,
406
406
  packageGraph,
407
407
  requested,
408
+ workspaceRoot,
408
409
  ...(projects.size < projectsWithConfigs.length
409
410
  ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
410
411
  : {}),
@@ -7,7 +7,12 @@ import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/in
7
7
  import path from 'node:path'
8
8
  import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
9
9
  import { VERSION } from '../version.js'
10
- import { resetSandbox, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
10
+ import {
11
+ resetSandbox,
12
+ signalExitCode,
13
+ VX_RUN_TASK_ENV,
14
+ VX_RUN_WORKSPACE_ENV,
15
+ } from '../exec/index.js'
11
16
  import { DeferredOutputs } from './deferred-outputs.js'
12
17
  import { resolveDownloadModes } from './download-policy.js'
13
18
  import type { TaskExecutor } from '../exec/index.js'
@@ -173,6 +178,20 @@ export function shouldShortCircuit(
173
178
  return nodes.size > 0
174
179
  }
175
180
 
181
+ /**
182
+ * The command line a run hands to its telemetry sinks, with what follows
183
+ * `--` counted, not quoted. Those are the task's arguments,
184
+ * often a token (`-- --token=…`), and the line reached an OTLP span, the
185
+ * GitHub job summary and a check-run posted over the API verbatim (item
186
+ * 1057). Local history (`vx last`) keeps the whole line, on this machine.
187
+ */
188
+ export function invocationCommand(argv: readonly string[]): string {
189
+ const sep = argv.indexOf('--')
190
+ if (sep === -1) return argv.join(' ')
191
+ const rest = argv.length - sep - 1
192
+ return [...argv.slice(0, sep), '--', `<${rest} argument${rest === 1 ? '' : 's'}>`].join(' ')
193
+ }
194
+
176
195
  export async function run(options: RunOptions): Promise<RunSummary> {
177
196
  // Color decision: a custom logger (tests, embedders) handles its
178
197
  // own formatting and asserts on plain strings, so we suppress
@@ -465,7 +484,11 @@ async function runOnBus(
465
484
  // SIGKILL after the grace, and a run that returned first left that child
466
485
  // running past vx's exit (turborepo#14043's class).
467
486
  let aborting: Promise<void> | undefined
487
+ // The servers that had ended when the stop landed: those crashed. One that
488
+ // ends after it is the stop's own kill, and was named a crash (item 1061).
489
+ let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
468
490
  const onAbort = (): void => {
491
+ endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
469
492
  aborting = terminateChildren(
470
493
  () => [...liveChildren, ...persistentRegistry.values()],
471
494
  forwardedSignal(stopRun.signal.reason),
@@ -566,7 +589,7 @@ async function runOnBus(
566
589
  runContextRecord = {
567
590
  runId,
568
591
  vxVersion: VERSION,
569
- command: options.command ?? process.argv.slice(1).join(' '),
592
+ command: options.command ?? invocationCommand(process.argv.slice(1)),
570
593
  requestedTasks: [...options.tasks],
571
594
  cachePolicy: compactCachePolicy(policy),
572
595
  concurrency,
@@ -707,13 +730,13 @@ async function runOnBus(
707
730
  `[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
708
731
  )
709
732
  }
710
- const isTainted = taintTracker(options.continueMode === 'always', excluded.seeds)
733
+ const taint = taintTracker(options.continueMode === 'always', excluded.seeds, nodes)
711
734
  const dependedOn = new Set<string>()
712
735
  for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
713
736
 
714
737
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
715
738
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
716
- const taint = isTainted(node, upstream)
739
+ const tainted = taint.judge(node, upstream)
717
740
  return {
718
741
  node,
719
742
  upstream,
@@ -737,7 +760,7 @@ async function runOnBus(
737
760
  gitFilesCache,
738
761
  hashCache,
739
762
  ...(probe !== undefined ? { preProbed: probe } : {}),
740
- ...(taint ? { taintedUpstream: true } : {}),
763
+ ...(tainted ? { taintedUpstream: true } : {}),
741
764
  ...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
742
765
  fingerprintWatch,
743
766
  ...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
@@ -778,6 +801,7 @@ async function runOnBus(
778
801
  log.taskStart?.(node)
779
802
  },
780
803
  onFinish: (o) => {
804
+ taint.settled(o)
781
805
  log.taskComplete(o.node, o)
782
806
  narrowDemand(o.node.id)
783
807
  },
@@ -801,14 +825,34 @@ async function runOnBus(
801
825
  // An aborted run's children are already being torn down: nothing to hold.
802
826
  const hold = options.holdPersistent === true && !stopRun.signal.aborted
803
827
  const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
804
- const crashedPersistent = await shutdownPersistent(persistentRegistry, keepAlive.children)
828
+ const crashedPersistent = (
829
+ await shutdownPersistent(persistentRegistry, keepAlive.children)
830
+ ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
805
831
 
806
832
  mark('run graph')
807
833
  // Clear the status line for good before the summary prints.
808
834
  log.runEnd?.()
809
835
 
836
+ // A server that died on its own before the stop failed, whatever its
837
+ // outcome said when it became ready: the footer counted it a success
838
+ // over the run's red exit (item 1071). A kept one that has already
839
+ // died is the same; the keep-alive wait below names it. Its code is its
840
+ // own, or the shell's report of the signal that took it.
841
+ const failServer = (id: string, code: number | string): void => {
842
+ const o = outcomes.get(id)
843
+ if (o === undefined) return
844
+ const exitCode = typeof code === 'number' ? code : signalExitCode(code)
845
+ outcomes.set(id, { ...o, status: 'failed', exitCode })
846
+ }
847
+ for (const c of crashedPersistent) failServer(c.id, c.code)
848
+ keepAlive.nodes.forEach((n, i) => {
849
+ const child = keepAlive.children[i]!
850
+ if (!hasEnded(child) || child.exitCode === 0) return
851
+ if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
852
+ failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
853
+ })
810
854
  const list = [...outcomes.values()]
811
- const ok = list.every((o) => isPassStatus(o.status)) && crashedPersistent.length === 0
855
+ const ok = list.every((o) => isPassStatus(o.status))
812
856
 
813
857
  // The summary + artifact writers + recordRun pass all exclude group
814
858
  // tasks via the shared tallyOutcomes helper. We pass the full
@@ -827,10 +871,15 @@ async function runOnBus(
827
871
  // A task killed by a shutdown signal is in no bucket above, yet it makes
828
872
  // `ok` false — name it, or the red exit is undiagnosable.
829
873
  for (const line of formatAbortedSection(list)) log.status(line)
830
- // Likewise a dependency-only server that died before the end of the
831
- // graph stopped it: its outcome says `success` (it became ready).
832
- for (const c of crashedPersistent)
833
- log.status(`vx: ${c.id} exited with code ${c.code} before the run stopped it`)
874
+ // And a dependency-only server that died before the end of the graph
875
+ // stopped it: the footer counts it failed, this says why.
876
+ // A signal death is named as its exit code, as the keep-alive wait
877
+ // names it: `code SIGTERM` read beside `code 143` for one event (item
878
+ // 1102).
879
+ for (const c of crashedPersistent) {
880
+ const code = typeof c.code === 'number' ? c.code : signalExitCode(c.code)
881
+ log.status(`vx: ${c.id} exited with code ${code} before the run stopped it`)
882
+ }
834
883
  // The footer's "N skipped" names no task; this names each under the
835
884
  // failure that blocked it.
836
885
  for (const line of formatSkippedSection(list)) log.status(line)
@@ -971,11 +1020,25 @@ async function runOnBus(
971
1020
  // directories are old enough for the snapshot's racy window (see
972
1021
  // miss-save.ts). A few at a time: each is an lstat + readdir per
973
1022
  // prefix; the rows land together at close, in one transaction.
1023
+ // Each walk is held to its entry's rows (item 1087), read here in one
1024
+ // batch: read per snapshot, each read also flushed the snapshots before
1025
+ // it, one transaction apiece (+110 ms at 1,000).
1026
+ const snapshotRows = new Map<string, ReadonlyArray<{ path: string }>>()
1027
+ for (let i = 0; i < outputDirSnapshots.length; i += 900) {
1028
+ const batch = cache.loadOutputFilesBatch(
1029
+ outputDirSnapshots.slice(i, i + 900).map((s) => s.hash),
1030
+ )
1031
+ for (const [hash, rows] of batch) snapshotRows.set(hash, rows)
1032
+ }
974
1033
  for (let i = 0; i < outputDirSnapshots.length; i += 32) {
975
1034
  await Promise.all(
976
1035
  outputDirSnapshots
977
1036
  .slice(i, i + 32)
978
- .map((s) => cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes)),
1037
+ .map((s) =>
1038
+ cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
1039
+ s.holds(files, snapshotRows.get(s.hash) ?? []),
1040
+ ),
1041
+ ),
979
1042
  )
980
1043
  }
981
1044
  mark('output dir snapshots')
@@ -96,7 +96,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
96
96
  * The domain union is computed here from every sandboxed node, because
97
97
  * SRT runs ONE filtering proxy per run and checks every request against
98
98
  * the allowlist given to `initialize()` — never the per-call one
99
- * (`sandbox-manager.js` 0.0.75). A task that declares no domains still
99
+ * (`sandbox-manager.js`, 0.0.75 and 0.0.76). A task that declares no domains still
100
100
  * reaches nothing: its profile is not given the proxy's port at all.
101
101
  * The unix-socket allowance is per run the same way: SRT's Linux seccomp
102
102
  * filter on `socket(AF_UNIX)` is all-or-nothing and read at
@@ -183,11 +183,14 @@ export async function sandboxRequestFor(
183
183
  // Pre-create what the task said it will write — after the grants are
184
184
  // resolved, which refuses one that leaves the project through a link
185
185
  // before anything is created along it (item 1003).
186
- const config = wallOff(resolveSandboxConfig(sandbox, node.projectDir), workspaceRoot, [
187
- ...nested,
188
- path.join(workspaceRoot, '.git'),
189
- path.join(workspaceRoot, '.vx'),
190
- ])
186
+ const walls = [...nested, path.join(workspaceRoot, '.git'), path.join(workspaceRoot, '.vx')].map(
187
+ toRealPath,
188
+ )
189
+ const config = wallOff(
190
+ resolveSandboxConfig(sandbox, node.projectDir, walls),
191
+ workspaceRoot,
192
+ walls,
193
+ )
191
194
  const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
192
195
  const request: NonNullable<ExecuteRequest['sandbox']> = {
193
196
  // Only what the task declared, plus node_modules. Write paths are
@@ -197,7 +200,13 @@ export async function sandboxRequestFor(
197
200
  // Enforcement anchors at the WORKSPACE ROOT: a task may not leave
198
201
  // its project, so every sibling and every root file is denied.
199
202
  // Reporting is a different question — see `reportWithin` below.
200
- baseDenyRead: [workspaceRoot],
203
+ // On macOS the walls are denied too: seatbelt does not mount, so the
204
+ // punch cannot apply, and SRT emits a deny strictly inside a literal
205
+ // read grant AFTER the grant, where it wins (`lateReadDenyFilters`,
206
+ // 0.0.76). A grant naming a wall is not strictly outside it and wins.
207
+ // Without it a root task's `read: ['.']` read its nested projects under
208
+ // seatbelt (B-4).
209
+ baseDenyRead: process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot],
201
210
  // …but only denials INSIDE the project are worth reporting. A task
202
211
  // bumping into the wall is the sandbox working, not a finding: the
203
212
  // walk `bun build --compile` makes from `/` down to its cwd lists
@@ -422,14 +431,14 @@ async function prepareOutputsForBind(
422
431
  * punched (`punchWalls`); a write grant whose bind would hold one — a file
423
432
  * grant binds its directory on Linux, so `write: ['out.txt']` in a root
424
433
  * project bound the whole workspace writable, `.git` included — is
425
- * refused, as there is no writable bind that leaves a wall out.
434
+ * refused, as there is no writable bind that leaves a wall out. A glob's
435
+ * hits already stop at the walls (`resolveSandboxConfig` takes them).
426
436
  */
427
437
  function wallOff(
428
438
  config: ResolvedSandboxConfig,
429
439
  workspaceRoot: string,
430
- raw: readonly string[],
440
+ walls: readonly string[],
431
441
  ): ResolvedSandboxConfig {
432
- const walls = raw.map(toRealPath)
433
442
  const home = toRealPath(workspaceRoot)
434
443
  for (const bind of bindableWrites(config.allowWrite)) {
435
444
  // A bind outside the workspace is the user's own path (`/tmp/x`,