@vzn/vx 0.0.595 → 0.0.597

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 (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. package/src/workspace/workspace.ts +53 -24
@@ -70,8 +70,8 @@ export interface SaveMissArgs {
70
70
  nestedProjectDirs: string[]
71
71
  gitFilesCache?: GitFilesCache | undefined
72
72
  /** Declared `cache.outputs.files` / `.workspaceFiles`. */
73
- outputs: string[]
74
- wsOutputs: string[]
73
+ outputs: readonly string[]
74
+ wsOutputs: readonly string[]
75
75
  /**
76
76
  * An ADDITIVE task's own set (item 588): the files its run added or
77
77
  * changed under its declared outputs, resolved by execute-task against
@@ -148,11 +148,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
148
148
  // `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
149
149
  const sandboxed = node.config.exec?.sandbox !== undefined
150
150
  const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
151
- // The workspace side too: a `workspaceFiles` directory linked out of
152
- // the workspace drops every file the same way (M-65).
153
- const linkedOut =
154
- outputDirLinkedOut(node.projectDir, a.outputs, 'the project') ??
155
- outputDirLinkedOut(a.workspaceRoot, a.wsOutputs, 'the workspace')
151
+ // A `workspaceFiles` directory linked out of the workspace drops every
152
+ // file (M-65); a project one is refused by the resolver (X-88).
153
+ const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
156
154
  log.status(
157
155
  `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
158
156
  `an empty artifact is saved; a later hit restores nothing` +
@@ -300,15 +298,13 @@ function markWritten(
300
298
  }
301
299
 
302
300
  /**
303
- * The first output directory a pattern names that is a symlink resolving
304
- * outside `baseDir` (the project, or the workspace root for
305
- * `workspaceFiles`): every file under it is dropped as outside, and the
306
- * empty-artifact warning blamed the glob (M-61, M-65).
301
+ * The first `workspaceFiles` directory that is a symlink resolving outside
302
+ * the workspace root: every file under it is dropped as outside, and the
303
+ * empty-artifact warning blamed the glob (M-65).
307
304
  */
308
305
  function outputDirLinkedOut(
309
306
  baseDir: string,
310
307
  outputs: readonly string[],
311
- base: string,
312
308
  ): { dir: string; target: string; base: string } | undefined {
313
309
  const realProject = realpathOrNull(baseDir) ?? baseDir
314
310
  // `dist` names the tree `dist/**`: the same reading the resolver gives.
@@ -326,7 +322,7 @@ function outputDirLinkedOut(
326
322
  }
327
323
  const target = realpathOrNull(abs)
328
324
  if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
329
- return { dir, target, base }
325
+ return { dir, target, base: 'the workspace' }
330
326
  }
331
327
  }
332
328
  }
@@ -171,6 +171,13 @@ export interface RunOptions {
171
171
  * else is treated as the literal file path (cwd-relative).
172
172
  */
173
173
  summarize?: string
174
+ /**
175
+ * Text printed just above the run's footer, given the outcomes the
176
+ * footer counts, after the `summaryTable`: nothing prints below the
177
+ * footer (owner), so a caller's end-of-run report (`--report`) rides
178
+ * here. Not called on a refused run.
179
+ */
180
+ beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
174
181
  /**
175
182
  * If set, write a Chrome-trace JSON profile of the run's wallclock
176
183
  * spans. Path is cwd-relative. Default `profile.json` is selected
@@ -12,17 +12,12 @@
12
12
  // side-effect-free pure probes.
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
- import { FULL_CACHE_POLICY } from '../cache/index.js'
16
- import {
17
- idleServers,
18
- isGroupTask,
19
- runGraph,
20
- type TaskNode,
21
- type TaskOutcome,
22
- } from '../graph/index.js'
15
+ import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
16
+ import { ranNoCache } from './events.js'
17
+ import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
23
18
  import type { HistoryProvider } from './history.js'
24
19
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
25
- import { keyUpstream } from './upstream.js'
20
+ import { keyedOutcome, keyUpstream } from './upstream.js'
26
21
 
27
22
  export type CacheStatus =
28
23
  | 'hit-local' // entry exists in local cache
@@ -156,7 +151,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
156
151
  }
157
152
  if (isGroupTask(node)) {
158
153
  cacheStatusById.set(node.id, 'group')
159
- return planOutcome(node, await computeGroupKey(hashArgs))
154
+ return keyedOutcome(node, await computeGroupKey(hashArgs))
160
155
  }
161
156
 
162
157
  const hash = await computeTaskHash(hashArgs)
@@ -169,9 +164,8 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
169
164
  // policy-aware cache layer, which itself respects read gating.
170
165
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
171
166
  const reads = policy.localRead || policy.remoteRead
172
- const writes = policy.localWrite || policy.remoteWrite
173
167
  let status: CacheStatus
174
- if (node.config.cache === undefined || (!reads && !writes)) {
168
+ if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
175
169
  status = 'no-cache'
176
170
  } else if (!reads) {
177
171
  status = 'miss'
@@ -180,7 +174,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
180
174
  status = where === null ? 'miss' : where === 'remote' ? 'hit-remote' : 'hit-local'
181
175
  }
182
176
  cacheStatusById.set(node.id, status)
183
- return planOutcome(node, hash)
177
+ return keyedOutcome(node, hash)
184
178
  },
185
179
  })
186
180
 
@@ -303,13 +297,3 @@ function predictPlan(tasks: PlannedTask[]): PlanPrediction {
303
297
  }
304
298
  return { wallMs, workMs, unknownCount }
305
299
  }
306
-
307
- function planOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
308
- return {
309
- node,
310
- status: 'success',
311
- exitCode: 0,
312
- durationMs: 0,
313
- ...(hash !== undefined ? { hash } : {}),
314
- }
315
- }
@@ -142,9 +142,9 @@ export interface VxPlugin {
142
142
  * many times per run, so it must be cheap and synchronous; a throw, or
143
143
  * an answer that is a Promise, is reported once and the plugin admits
144
144
  * from then on — a policy never
145
- * breaks a run. Restore-tier hits and tasks on an executor pool hold no
146
- * local resources and are never asked. When several plugins answer, all
147
- * must admit. Core keeps no notion of what a task needs: a plugin that
145
+ * breaks a run. Restore-tier hits, tasks on an executor pool and groups
146
+ * hold no local resources: never asked, never in `ctx.running`. When
147
+ * several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
148
148
  * packs memory or CPU learns or declares the numbers itself
149
149
  * (`@vzn/vx-schedule-history` packs what past executions used).
150
150
  */
@@ -341,7 +341,12 @@ export interface FingerprintContext extends BaseContext {
341
341
  }
342
342
 
343
343
  export interface AdmitContext {
344
- /** The tasks executing on this machine right now, in dispatch order. */
344
+ /**
345
+ * The tasks holding a worker on this machine right now, in dispatch
346
+ * order. A persistent task leaves at ready, when it gives its worker
347
+ * back: it runs until the graph ends, so a policy that counted it would
348
+ * hold its dependants with no completion left to ask again.
349
+ */
345
350
  readonly running: readonly TaskNode[]
346
351
  /** The run's worker count — the ceiling the count gate already applies. */
347
352
  readonly concurrency: number
@@ -601,7 +606,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
601
606
  const { plugins, bus, workspaceRoot, cacheDir } = args
602
607
  const warn = args.warn ?? ((m) => console.error(m))
603
608
  const disposers: Array<() => void> = []
604
- const disabled = new Set<string>()
609
+ // By object, not name: one package can export two plugins.
610
+ const disabled = new Set<Plugin>()
605
611
 
606
612
  for (const plugin of plugins) {
607
613
  if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
@@ -615,6 +621,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
615
621
  throw new UserError(`plugin '${plugin.name}' setup is not a function`)
616
622
  }
617
623
 
624
+ const fail = (where: string, err: unknown): void => {
625
+ if (disabled.has(plugin)) return
626
+ disabled.add(plugin)
627
+ warn(
628
+ `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
629
+ )
630
+ }
618
631
  const ctx: PluginContext = {
619
632
  workspaceRoot,
620
633
  cacheDir,
@@ -625,7 +638,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
625
638
  bus: {
626
639
  emit: (event) => bus.emit(event),
627
640
  subscribe(subscriber) {
628
- const dispose = bus.subscribe(subscriber)
641
+ // Isolated like an `on` handler: the bus itself swallows a throw.
642
+ const dispose = bus.subscribe((event) => {
643
+ if (disabled.has(plugin)) return
644
+ let ret: unknown
645
+ try {
646
+ ret = subscriber(event)
647
+ } catch (err) {
648
+ fail('a bus subscriber', err)
649
+ return
650
+ }
651
+ if (ret instanceof Promise) ret.catch((err: unknown) => fail('a bus subscriber', err))
652
+ })
629
653
  disposers.push(dispose)
630
654
  return dispose
631
655
  },
@@ -638,15 +662,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
638
662
  `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
639
663
  )
640
664
  }
641
- const fail = (err: unknown): void => {
642
- if (disabled.has(plugin.name)) return
643
- disabled.add(plugin.name)
644
- warn(
645
- `[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
646
- )
647
- }
665
+ const onFail = (err: unknown): void => fail(hook, err)
648
666
  const dispose = bus.subscribe((event) => {
649
- if (disabled.has(plugin.name)) return
667
+ if (disabled.has(plugin)) return
650
668
  // Not awaited: the bus is synchronous and plugin work runs off the
651
669
  // critical path. A handler's rejection is caught like its throw.
652
670
  let ret: unknown
@@ -681,10 +699,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
681
699
  break
682
700
  }
683
701
  } catch (err) {
684
- fail(err)
702
+ onFail(err)
685
703
  return
686
704
  }
687
- if (ret instanceof Promise) ret.catch(fail)
705
+ if (ret instanceof Promise) ret.catch(onFail)
688
706
  })
689
707
  disposers.push(dispose)
690
708
  },
@@ -693,6 +711,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
693
711
  try {
694
712
  await plugin.setup(ctx)
695
713
  } catch (err) {
714
+ // Earlier plugins' subscriptions too: the caller never gets the
715
+ // disposer, and a bus that outlives the run would keep them.
716
+ for (const d of disposers) d()
696
717
  throw new PluginSetupError(
697
718
  plugin,
698
719
  `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
@@ -368,8 +368,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
368
368
  ? options.projects.filter((p) => projects.has(p))
369
369
  : [...projects.keys()]
370
370
 
371
- const requested = expandRequested(tasks, candidateProjects, projects)
372
- let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects)
371
+ // A bare name skips the default `build`: it runs nothing, and matched,
372
+ // `vx run build` in a project declaring none ran 0 tasks, exit 0 (X-102).
373
+ // A dependant's `^build` still reaches it, and `lib#build` names it.
374
+ const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
375
+ let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
373
376
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
374
377
  // A name a loaded project declares is no typo, whether or not the load
375
378
  // was whole: an unaffected dependency loaded for the closure declared
@@ -750,14 +753,20 @@ async function declaredNowhere(
750
753
  return undeclaredIn(unresolved, all.projects)
751
754
  }
752
755
 
753
- /** The bare names in `unresolved` no project in `projects` declares; `a#b` forms pass through. */
756
+ /**
757
+ * The bare names in `unresolved` no project in `projects` declares; `a#b`
758
+ * forms pass through. Every project has a `build`, its default one if
759
+ * nothing declares it, and a bare name does not select that (X-102).
760
+ */
754
761
  function undeclaredIn(
755
762
  unresolved: readonly string[],
756
763
  projects: LoadedProjects['projects'],
757
764
  ): string[] {
758
765
  const declared = new Set<string>()
759
766
  for (const p of projects.values()) {
760
- for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
767
+ for (const [t, task] of Object.entries(p.config.tasks ?? {})) {
768
+ if (!isDefaultBuild(task)) declared.add(t)
769
+ }
761
770
  }
762
771
  return unresolved.filter((t) => t.includes('#') || !declared.has(t))
763
772
  }
@@ -18,6 +18,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
18
18
  import { tallyOutcomes, type Tally } from './tally.js'
19
19
  import type { FlakyFinding } from './failure-mode.js'
20
20
  import { isCacheHit } from './telemetry.js'
21
+ import { ranNoCache } from './events.js'
21
22
 
22
23
  export interface SummarizeArgs {
23
24
  /** Empty string → default path; otherwise the explicit file path. */
@@ -92,9 +93,9 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
92
93
  // the flag a consumer computing a hit rate cannot tell a miss from a
93
94
  // task that could never hit (its `hash` is still set — dependents fold
94
95
  // it). Present only when true, so every other row is byte-identical.
95
- ...(o.node.config.cache === undefined ? { noCache: true } : {}),
96
- // Present only when the run proved the task flaky: this exact key has
97
- // both passed and failed on record (this run counted), or the task
96
+ ...(ranNoCache(o) ? { noCache: true } : {}),
97
+ // Present only when the run proved the task flaky: this exact key
98
+ // failed after it had passed (this run counted), or the task
98
99
  // needed a retry. A consumer gating on `failed` can tell a break from a
99
100
  // flake without the history; every other row is byte-identical.
100
101
  ...(flaky !== undefined
@@ -43,7 +43,8 @@
43
43
  // waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
44
44
  // lock naming OUR pid is stale unless this process placed that entry and
45
45
  // has not left it (`placed`) — and on Linux the entry also carries the holder's start
46
- // time, so a pid another process now wears is stale too. Where the lock
46
+ // time, so a pid another process now wears is stale too, and so is a
47
+ // holder procfs shows as a zombie (killed, not yet reaped). Where the lock
47
48
  // cannot be made or read for any reason but "held" (a temp directory this
48
49
  // user cannot write), the run says so once and proceeds unlocked: the
49
50
  // lock is a courtesy between cooperating runs, and refusing to run would
@@ -183,7 +184,7 @@ function ownLockRoot(lockDir: string): void {
183
184
 
184
185
  interface Holder {
185
186
  pid: number
186
- /** The holder's start time as `startTime` read it; null where the lock names none. */
187
+ /** The holder's start time as `procStat` read it; null where the lock names none. */
187
188
  start: string | null
188
189
  /** The entry in the lock directory that names it. */
189
190
  entry: string
@@ -223,17 +224,18 @@ async function holder(lockDir: string): Promise<Holder | 'free' | null> {
223
224
  }
224
225
 
225
226
  /**
226
- * When a process started, in clock ticks since boot: field 22 of
227
- * `/proc/<pid>/stat`. Two processes that wore one pid differ here. Null
228
- * off Linux, where the answer costs a `ps` spawn per run, under a procfs
229
- * mounted for another pid namespace, and for a pid procfs does not show.
227
+ * `/proc/<pid>/stat` from field 3 on: the state first, and at [19] field
228
+ * 22, the start time in clock ticks since boot, where two processes that
229
+ * wore one pid differ. Null off Linux, where the answer costs a `ps`
230
+ * spawn, under a procfs mounted for another pid namespace, and for a pid
231
+ * procfs does not show.
230
232
  */
231
- function startTime(pid: number | 'self'): string | null {
233
+ function procStat(pid: number | 'self'): string[] | null {
232
234
  if (!procfsIsOwn()) return null
233
235
  try {
234
236
  const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
235
237
  // Fields after the LAST ')' start at field 3; comm may hold spaces.
236
- return stat.slice(stat.lastIndexOf(')') + 2).split(' ')[19] ?? null
238
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ')
237
239
  } catch {
238
240
  return null
239
241
  }
@@ -244,21 +246,24 @@ let ownId: string | undefined
244
246
  /** Tells this process's takings of a lock apart, and their staging names. */
245
247
  let takings = 0
246
248
  function nextEntry(): string {
247
- ownId ??= `h-${process.pid}-${startTime('self') ?? 'x'}`
249
+ ownId ??= `h-${process.pid}-${procStat('self')?.[19] ?? 'x'}`
248
250
  return `${ownId}-${++takings}`
249
251
  }
250
252
 
251
253
  /**
252
- * Is the lock's holder a live run of another process? `sameAsLast`: this
253
- * holder's start time already matched on an earlier poll, so a wait reads
254
- * procfs once per holder, not once per poll.
254
+ * Is the lock's holder a live run of another process? Read on every poll:
255
+ * a holder killed whose parent does not reap it (a container's pid 1 that
256
+ * never waits) is a zombie, which signal 0 still reaches and whose start
257
+ * time still matches, and a wait that trusted either waited for it until
258
+ * the reaper came, or for good.
255
259
  */
256
- function holderLive(h: Holder, sameAsLast: boolean): boolean {
260
+ function holderLive(h: Holder): boolean {
257
261
  if (h.pid === process.pid) return placed.has(h.entry)
258
262
  if (!alive(h.pid)) return false
259
- if (h.start === null || sameAsLast) return true
260
- const now = startTime(h.pid)
261
- return now === null || now === h.start
263
+ const stat = procStat(h.pid)
264
+ if (stat === null) return true
265
+ if (stat[0] === 'Z' || stat[0] === 'X') return false
266
+ return h.start === null || stat[19] === h.start
262
267
  }
263
268
 
264
269
  function alive(pid: number): boolean {
@@ -326,7 +331,6 @@ export async function acquireRunLock(
326
331
  const lockDir = runLockPath(workspaceRoot, opts.dir)
327
332
  const started = Date.now()
328
333
  let said = false
329
- let lastLive: Holder | undefined
330
334
  const release = async (): Promise<void> => {
331
335
  const left = (heldHere.get(lockDir) ?? 1) - 1
332
336
  if (left > 0) {
@@ -377,7 +381,7 @@ export async function acquireRunLock(
377
381
  await rm(lockDir, { recursive: true, force: true })
378
382
  continue
379
383
  }
380
- } else if (!holderLive(h, lastLive?.entry === h.entry && lastLive.start === h.start)) {
384
+ } else if (!holderLive(h)) {
381
385
  if (h.entry === LEGACY_PID) await rm(lockDir, { recursive: true, force: true })
382
386
  else await leave(lockDir, h.entry)
383
387
  continue
@@ -391,12 +395,9 @@ export async function acquireRunLock(
391
395
  )
392
396
  return async () => {}
393
397
  }
394
- if (h !== null) {
395
- lastLive = h
396
- if (!said && Date.now() - started >= SAY_AFTER_MS) {
397
- said = true
398
- opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
399
- }
398
+ if (h !== null && !said && Date.now() - started >= SAY_AFTER_MS) {
399
+ said = true
400
+ opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
400
401
  }
401
402
  await Bun.sleep(POLL_MS)
402
403
  }
@@ -6,7 +6,13 @@ import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import { realpathSync } from 'node:fs'
8
8
  import path from 'node:path'
9
- import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
9
+ import {
10
+ Cache,
11
+ type CacheLayer,
12
+ type CachePolicy,
13
+ cachesNothing,
14
+ stopRuntimeProbes,
15
+ } from '../cache/index.js'
10
16
  import { VERSION } from '../version.js'
11
17
  import {
12
18
  resetSandbox,
@@ -44,6 +50,7 @@ import {
44
50
  secretMask,
45
51
  } from '../util/index.js'
46
52
  import { keyedProjects } from './keyed-projects.js'
53
+ import { isDefaultBuild } from './projects.js'
47
54
  import { prepareSandbox } from './sandbox-request.js'
48
55
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
49
56
  import { admitTasks, taintTracker } from './admission.js'
@@ -624,6 +631,7 @@ async function runOnBus(
624
631
  stopRun.abort(signal)
625
632
  },
626
633
  done: runLeft,
634
+ handsTerminal: holders.size > 0,
627
635
  // The grace, then one flush and each teardown at their own bound, and
628
636
  // slack for the summary and the cache close.
629
637
  boundMs:
@@ -1017,6 +1025,7 @@ async function runOnBus(
1017
1025
  // is one probe of the failed-row index.
1018
1026
  const flaky: FlakyFinding[] = []
1019
1027
  const historyDb = prepared.localCache.dbHandle()
1028
+ const cacheOff = cachesNothing(policy)
1020
1029
  const judgeFlaky = (o: TaskOutcome): void => {
1021
1030
  const candidates = flakyCandidates([o])
1022
1031
  if (candidates.length === 0) return
@@ -1044,6 +1053,7 @@ async function runOnBus(
1044
1053
  log.taskStart?.(node)
1045
1054
  },
1046
1055
  onFinish: (o) => {
1056
+ if (cacheOff) o.cacheOff = true
1047
1057
  taint.settled(o)
1048
1058
  judgeFlaky(o)
1049
1059
  log.taskComplete(o.node, o)
@@ -1346,6 +1356,8 @@ async function runOnBus(
1346
1356
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1347
1357
  // own output follows.
1348
1358
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1359
+ const above = options.beforeFooter?.(list, ok)
1360
+ if (above) log.status(above.replace(/\n$/, ''))
1349
1361
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1350
1362
 
1351
1363
  // Edge case the summary already reported: the user requested a
@@ -1430,7 +1442,9 @@ async function runOnBus(
1430
1442
  await summarize(ok && first.code === 0, final)
1431
1443
  if (historyAfterWait) {
1432
1444
  recordHistory(() => {
1433
- const local = Cache.inspect(prepared.cacheDir)
1445
+ // A writer's open: another vx version may have reset the index
1446
+ // to its schema during the session, and a mismatch resets silently.
1447
+ const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1434
1448
  try {
1435
1449
  local.recordRunBundle(recordsOf(final, ok && first.code === 0))
1436
1450
  } finally {
@@ -1632,7 +1646,7 @@ async function initHint(prepared: {
1632
1646
  // Single-project mode with packages the root's missing `workspaces`
1633
1647
  // never reaches: `vx init` would find no scripts either (item 248).
1634
1648
  const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1635
- if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
1649
+ if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
1636
1650
  return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1637
1651
  }
1638
1652
 
@@ -1649,8 +1663,11 @@ function didYouMean(
1649
1663
  elsewhere: readonly string[] = [],
1650
1664
  ): string {
1651
1665
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1666
+ // What a bare name can select: a default `build` is no match (X-102).
1652
1667
  const allTasks = new Set<string>()
1653
- for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1668
+ for (const p of projects.values()) {
1669
+ for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
1670
+ }
1654
1671
  // A Set: two typos of the same task hint it once, not once per typo.
1655
1672
  const hints = new Set<string>()
1656
1673
  // A typo past two edits named nothing to pick from (M-56): with no near
@@ -1746,7 +1763,7 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
1746
1763
  const out: FlakyCandidate[] = []
1747
1764
  for (const o of outcomes) {
1748
1765
  if (o.status !== 'success' && o.status !== 'failed') continue
1749
- // "Same inputs, different outcome" is a claim only a task with declared
1766
+ // "Same inputs, failed after passing" is a claim only a task with declared
1750
1767
  // inputs can make: a task with no `cache` block keys on its config alone
1751
1768
  // and runs every time, so one bad network day would read as a flake for
1752
1769
  // thirty days. Groups do no work.