@vzn/vx 0.0.591 → 0.0.595

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 (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -318,7 +318,7 @@ export function defaultLogger(
318
318
  // for as long as vx holds it: `vx run dev --all` showed none of its
319
319
  // servers' logs while it ran (C-56). So a ready server's output streams
320
320
  // from then on, a line at a time under its id; the partial line each
321
- // holds waits for its newline, a second runEnd, or `settle`.
321
+ // holds waits for its newline, a status line, a second runEnd, or `settle`.
322
322
  const KEPT_LINE_CAP = 64 * 1024
323
323
  const keptLines = new Map<string, { node: TaskNode; rest: string }>()
324
324
  const streamKept = (node: TaskNode, chunk: string): void => {
@@ -450,6 +450,9 @@ export function defaultLogger(
450
450
  writer.settle()
451
451
  },
452
452
  status(line) {
453
+ // A kept server's held partial line is older than this line: held
454
+ // past it, a server's last words printed below its own exit notice.
455
+ flushKept()
453
456
  writer.write(`${line}\n`)
454
457
  },
455
458
  runStart(info) {
@@ -17,7 +17,14 @@ import {
17
17
  WORKSPACE_OUTPUT_PREFIX,
18
18
  } from '../cache/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
- import { asTrees, span, staticPrefix, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
20
+ import {
21
+ asTrees,
22
+ relPosix,
23
+ span,
24
+ staticPrefix,
25
+ taskGlob,
26
+ wholeSubtreePrefixes,
27
+ } from '../util/index.js'
21
28
  import type { Logger } from './logger.js'
22
29
  import type { TaskInputComponent } from './task-hash.js'
23
30
 
@@ -272,7 +279,7 @@ function markWritten(
272
279
  if (outputFiles.length > 0) {
273
280
  a.gitFilesCache?.markOutputsChanged(
274
281
  projectDir,
275
- outputFiles.map((p) => path.relative(projectDir, p).split(path.sep).join('/')),
282
+ outputFiles.map((p) => relPosix(projectDir, p)),
276
283
  )
277
284
  }
278
285
  // Declared workspace outputs may have landed inside OTHER projects' dirs
@@ -281,7 +288,7 @@ function markWritten(
281
288
  if (wsOutputFiles.length > 0) {
282
289
  a.gitFilesCache?.markWorkspaceOutputsChanged(
283
290
  a.workspaceRoot,
284
- wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
291
+ wsOutputFiles.map((f) => relPosix(a.workspaceRoot, f)),
285
292
  )
286
293
  }
287
294
  // The workspace-wide partition (when one exists) spans this project's
@@ -281,13 +281,27 @@ export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
281
281
  return executors.some((e) => e.capacity !== undefined)
282
282
  }
283
283
 
284
+ /**
285
+ * Each pooled executor's pool, under a name no other executor's pool
286
+ * shares: the scheduler counts a pool by its name, and one package declared
287
+ * twice (`@vzn/vx-reapi` against two clusters) names both executors alike,
288
+ * so their two capacities were one. A repeat takes `#2`, `#3` in placement
289
+ * order.
290
+ */
284
291
  export function poolOfPlacement(
285
292
  placements: Placements,
286
293
  ): (id: string) => { name: string; capacity: number } | undefined {
294
+ const pools = new Map<TaskExecutor, { name: string; capacity: number }>()
295
+ const taken = new Set<string>()
296
+ for (const executor of placements.executors.values()) {
297
+ if (executor.capacity === undefined || pools.has(executor)) continue
298
+ let name = executor.name
299
+ for (let n = 2; taken.has(name); n++) name = `${executor.name}#${n}`
300
+ taken.add(name)
301
+ pools.set(executor, { name, capacity: executor.capacity })
302
+ }
287
303
  return (id) => {
288
304
  const executor = placements.executors.get(id)
289
- return executor?.capacity === undefined
290
- ? undefined
291
- : { name: executor.name, capacity: executor.capacity }
305
+ return executor === undefined ? undefined : pools.get(executor)
292
306
  }
293
307
  }
@@ -13,7 +13,13 @@
13
13
 
14
14
  import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { FULL_CACHE_POLICY } from '../cache/index.js'
16
- import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
16
+ import {
17
+ idleServers,
18
+ isGroupTask,
19
+ runGraph,
20
+ type TaskNode,
21
+ type TaskOutcome,
22
+ } from '../graph/index.js'
17
23
  import type { HistoryProvider } from './history.js'
18
24
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
19
25
  import { keyUpstream } from './upstream.js'
@@ -24,6 +30,7 @@ export type CacheStatus =
24
30
  | 'miss' // caching enabled but no entry — would execute
25
31
  | 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
26
32
  | 'group' // no `exec`; just an aggregator
33
+ | 'not-started' // a server every dependant of which restores early: the run never spawns it
27
34
 
28
35
  export interface PlannedTask {
29
36
  node: TaskNode
@@ -108,6 +115,12 @@ export interface PlanArgs {
108
115
  history?: HistoryProvider
109
116
  /** Placement lookup (`placement.ts`): >1 executor declared, or a `noop` task. */
110
117
  executorOf?: (id: string) => string | undefined
118
+ /**
119
+ * The tasks the run's short-circuit would restore ahead of their deps on
120
+ * a local hit (stable key, no workspace-output reach). A server whose
121
+ * every dependant is one of those and a local hit is never spawned.
122
+ */
123
+ restorable?: ReadonlySet<string>
111
124
  }
112
125
 
113
126
  /**
@@ -171,6 +184,17 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
171
184
  },
172
185
  })
173
186
 
187
+ if (args.restorable !== undefined) {
188
+ const restoreTier = new Set(
189
+ [...args.restorable].filter((id) => cacheStatusById.get(id) === 'hit-local'),
190
+ )
191
+ const dependents = new Map<string, string[]>()
192
+ for (const node of args.nodes.values())
193
+ for (const dep of node.deps) dependents.set(dep, [...(dependents.get(dep) ?? []), node.id])
194
+ for (const id of idleServers(args.nodes, dependents, restoreTier))
195
+ cacheStatusById.set(id, 'not-started')
196
+ }
197
+
174
198
  const tasks: PlannedTask[] = []
175
199
  for (const id of args.nodes.keys()) {
176
200
  const o = outcomes.get(id)
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // See docs/design/pipeline-2026-09.md.
9
9
 
10
+ import path from 'node:path'
10
11
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
12
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
12
13
  import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
@@ -106,6 +107,25 @@ export function hasHook(
106
107
  return false
107
108
  }
108
109
 
110
+ /**
111
+ * An in-place stage reads nothing a hook returns: Vite's `config` hook
112
+ * returns a partial config, and one written that way here loaded, changed
113
+ * nothing and said nothing. Returning the object it was handed is harmless.
114
+ */
115
+ function assertEditedInPlace(
116
+ plugin: VxPlugin,
117
+ hook: 'config' | 'project' | 'graph',
118
+ returned: unknown,
119
+ edited: unknown,
120
+ what: string,
121
+ ): void {
122
+ if (returned === undefined || returned === edited) return
123
+ throw new UserError(
124
+ `plugin '${plugin.name}' failed in ${hook}: returned ${describeValue(returned)}, which core ` +
125
+ `ignores — edit ${what} in place`,
126
+ )
127
+ }
128
+
109
129
  /** `config` stage: every plugin edits the workspace config in place. */
110
130
  export async function applyConfigHooks(
111
131
  plugins: readonly VxPlugin[],
@@ -116,7 +136,8 @@ export async function applyConfigHooks(
116
136
  ): Promise<void> {
117
137
  for (const plugin of plugins) {
118
138
  if (plugin.config === undefined) continue
119
- await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
139
+ const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
140
+ assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
120
141
  afterEach?.(plugin)
121
142
  }
122
143
  }
@@ -157,7 +178,8 @@ export async function applyProjectHooks(
157
178
  ): Promise<void> {
158
179
  for (const plugin of plugins) {
159
180
  if (plugin.project === undefined) continue
160
- await safe(plugin, 'project', () => plugin.project!(config, ctx))
181
+ const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
182
+ assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
161
183
  afterEach?.(plugin)
162
184
  }
163
185
  }
@@ -183,7 +205,8 @@ export async function applyGraphHooks(
183
205
  let last: VxPlugin | undefined
184
206
  for (const plugin of plugins) {
185
207
  if (plugin.graph === undefined) continue
186
- await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
208
+ const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
209
+ assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
187
210
  await safe(plugin, 'graph', () => checkNodeShapes(nodes))
188
211
  afterEach?.(plugin)
189
212
  last = plugin
@@ -207,6 +230,21 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
207
230
  if (!Array.isArray(deps)) {
208
231
  throw new Error(`${key}'s deps is ${what(deps)}, not an array of task ids`)
209
232
  }
233
+ // A node a hook adds is read as it wrote it: one with no `projectDir`
234
+ // ran as `internal error in a#x: TypeError: The "path" property must
235
+ // be of type string`, and a relative one keyed and ran against
236
+ // whatever directory vx was started in.
237
+ const fields = node as { projectName?: unknown; taskName?: unknown; projectDir?: unknown }
238
+ const shown = (v: unknown): string => (typeof v === 'string' ? JSON.stringify(v) : what(v))
239
+ for (const field of ['projectName', 'taskName'] as const) {
240
+ if (typeof fields[field] !== 'string' || fields[field] === '') {
241
+ throw new Error(`${key}'s ${field} is ${shown(fields[field])}, not a name`)
242
+ }
243
+ }
244
+ const dir = fields.projectDir
245
+ if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
246
+ throw new Error(`${key}'s projectDir is ${shown(dir)}, not an absolute path`)
247
+ }
210
248
  }
211
249
  }
212
250
 
@@ -227,7 +227,14 @@ interface BaseContext {
227
227
  warn(message: string): void
228
228
  }
229
229
 
230
- export interface PluginSetupContext extends BaseContext {}
230
+ /** What `setup` receives: the run's lifecycle, observe-only. */
231
+ export interface PluginSetupContext extends BaseContext {
232
+ /**
233
+ * Subscribe a lifecycle handler; it leaves with the run. A throw or a
234
+ * rejection warns once and switches the plugin's handlers off for the run.
235
+ */
236
+ on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
237
+ }
231
238
 
232
239
  /** One CLI verb contributed by a plugin. */
233
240
  export interface PluginCommand {
@@ -357,23 +364,12 @@ export interface ExecutorContext extends BaseContext {
357
364
  readonly concurrency: number
358
365
  }
359
366
 
360
- export interface PluginContext {
361
- /** Where the workspace lives on disk. */
362
- readonly workspaceRoot: string
363
- /** Where vx's cache lives — read-only as far as the plugin is concerned. */
364
- readonly cacheDir: string
365
- /** Funnel warnings into the run:status channel, as every hook's context does. */
366
- warn(message: string): void
367
+ export interface PluginContext extends PluginSetupContext {
367
368
  /**
368
369
  * The run event bus. A plugin can subscribe directly if its needs exceed
369
370
  * the hooks; the subscription ends with the run, as a hook's does.
370
371
  */
371
372
  readonly bus: EventBus
372
- /**
373
- * Convenience: register a typed handler keyed off `RunEvent.kind`.
374
- * Multiple hooks can chain via repeated calls.
375
- */
376
- on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
377
373
  }
378
374
 
379
375
  export type PluginHookName =
@@ -444,7 +440,8 @@ function pluginPackageName(dir: string): string {
444
440
  for (let d = dir; ;) {
445
441
  let text: string | undefined
446
442
  try {
447
- text = readFileSync(path.join(d, 'package.json'), 'utf8')
443
+ // A byte-order mark is stripped, as npm, Node and discovery strip it.
444
+ text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
448
445
  } catch {
449
446
  /* not here; look one level up */
450
447
  }
@@ -43,16 +43,25 @@ export async function loadWorkspacePlugins(
43
43
  workspaceRoot: string,
44
44
  warn: (message: string) => void,
45
45
  ): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
46
- const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
47
- const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
48
- if (workspaceConfig !== null && hasHook(plugins, 'config')) {
49
- // Re-validated after each plugin, as the `project` stage is: an edit
50
- // the loader would refuse from a user (`concurrency: -3`) hung the run,
51
- // and `cacheDir: 42` reached `path.resolve` as a TypeError.
52
- await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
53
- validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
54
- )
46
+ const loaded = await loadWorkspaceConfig(workspaceRoot)
47
+ const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
48
+ if (loaded === null || !hasHook(plugins, 'config')) return { workspaceConfig: loaded, plugins }
49
+ // Bun keeps one module per specifier, so the file's export is one object
50
+ // per process: the CLI's selection pass, the run and each `vx watch`
51
+ // cycle handed the hooks what the last load's hooks had edited
52
+ // (`concurrency *= 2` doubled per load). The hooks edit a copy; the file
53
+ // holds data and plugins, and validation refused anything else.
54
+ const { plugins: list, ...data } = loaded
55
+ const workspaceConfig: WorkspaceConfig = {
56
+ ...structuredClone(data),
57
+ ...(list !== undefined ? { plugins: [...list] } : {}),
55
58
  }
59
+ // Re-validated after each plugin, as the `project` stage is: an edit
60
+ // the loader would refuse from a user (`concurrency: -3`) hung the run,
61
+ // and `cacheDir: 42` reached `path.resolve` as a TypeError.
62
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
63
+ validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
64
+ )
56
65
  return { workspaceConfig, plugins }
57
66
  }
58
67
 
@@ -13,7 +13,7 @@
13
13
  import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
- import { executablePath, ulid, xxh3hex } from '../util/index.js'
16
+ import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
17
17
 
18
18
  export interface GitContext {
19
19
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -410,7 +410,7 @@ function workTreePath(root: string): string | undefined {
410
410
  const abs = path.resolve(root)
411
411
  for (let dir = abs; ;) {
412
412
  if (fs.existsSync(path.join(dir, '.git'))) {
413
- return path.relative(dir, abs).split(path.sep).join('/') || '.'
413
+ return relPosix(dir, abs) || '.'
414
414
  }
415
415
  const up = path.dirname(dir)
416
416
  if (up === dir) return undefined
@@ -41,8 +41,8 @@
41
41
  // A holder's pid can come back: the temp directory outlives a container
42
42
  // restart, and the restarted container's vx got the dead run's pid (1) and
43
43
  // waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
44
- // lock naming OUR pid is stale — a run of this process would be in
45
- // `heldHere` — and on Linux the entry also carries the holder's start
44
+ // lock naming OUR pid is stale unless this process placed that entry and
45
+ // has not left it (`placed`) — and on Linux the entry also carries the holder's start
46
46
  // time, so a pid another process now wears is stale too. Where the lock
47
47
  // cannot be made or read for any reason but "held" (a temp directory this
48
48
  // user cannot write), the run says so once and proceeds unlocked: the
@@ -71,6 +71,12 @@ import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, xxh3hex } from '../util/inde
71
71
  const heldHere = new Map<string, number>()
72
72
  /** The entry this process's taking of each held lock wrote. */
73
73
  const takers = new Map<string, string>()
74
+ /**
75
+ * Every entry this process has placed and not yet left, listed before its
76
+ * rename: a concurrent taking here reads it before `heldHere` names it, and
77
+ * must wait for it, not reclaim it as a dead run of this pid.
78
+ */
79
+ const placed = new Set<string>()
74
80
 
75
81
  /**
76
82
  * A signal exit is `process.exit` (signals.ts): no finally runs and nothing
@@ -248,7 +254,7 @@ function nextEntry(): string {
248
254
  * procfs once per holder, not once per poll.
249
255
  */
250
256
  function holderLive(h: Holder, sameAsLast: boolean): boolean {
251
- if (h.pid === process.pid) return false
257
+ if (h.pid === process.pid) return placed.has(h.entry)
252
258
  if (!alive(h.pid)) return false
253
259
  if (h.start === null || sameAsLast) return true
254
260
  const now = startTime(h.pid)
@@ -278,11 +284,13 @@ async function place(lockDir: string): Promise<string | null> {
278
284
  // directory a killed pid 1 left, and refuse every run after it.
279
285
  const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
280
286
  await mkdir(staging)
287
+ placed.add(entry)
281
288
  try {
282
289
  await writeFile(path.join(staging, entry), '')
283
290
  await rename(staging, lockDir)
284
291
  return entry
285
292
  } catch (err) {
293
+ placed.delete(entry)
286
294
  await rm(staging, { recursive: true, force: true })
287
295
  const code = (err as NodeJS.ErrnoException).code
288
296
  if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
@@ -327,6 +335,8 @@ export async function acquireRunLock(
327
335
  }
328
336
  heldHere.delete(lockDir)
329
337
  const taker = takers.get(lockDir)
338
+ // Leaving: a taking here from now on may reclaim it, as before its unlink.
339
+ if (taker !== undefined) placed.delete(taker)
330
340
  // The unlink of this taking's own entry is the proof no later run
331
341
  // reclaimed the lock: had one, the entry is gone and nothing else is
332
342
  // touched. A run that joined another's taking in this process leaves
@@ -388,6 +398,6 @@ export async function acquireRunLock(
388
398
  opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
389
399
  }
390
400
  }
391
- await new Promise((r) => setTimeout(r, POLL_MS))
401
+ await Bun.sleep(POLL_MS)
392
402
  }
393
403
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
+ import { realpathSync } from 'node:fs'
7
8
  import path from 'node:path'
8
9
  import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
9
10
  import { VERSION } from '../version.js'
@@ -18,6 +19,7 @@ import { resolveDownloadModes } from './download-policy.js'
18
19
  import type { TaskExecutor } from '../exec/index.js'
19
20
  import {
20
21
  deadServerBehind,
22
+ declaredTask,
21
23
  isGroupTask,
22
24
  markSurfacedDeps,
23
25
  runGraph,
@@ -85,8 +87,12 @@ import {
85
87
  detectCi,
86
88
  } from './run-context.js'
87
89
  import { startRemotePrefetch } from './remote-prefetch.js'
88
- import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
89
- import { probesAfterWrites } from './stable-keys.js'
90
+ import {
91
+ restoreTierExclusions,
92
+ startLocalShortCircuit,
93
+ type ShortCircuit,
94
+ } from './local-shortcircuit.js'
95
+ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
90
96
 
91
97
  import { assembleRunRecords } from './run-records.js'
92
98
  import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
@@ -366,8 +372,10 @@ async function runOnBus(
366
372
  // its tasks escape the schedule, the concurrency budget and this task's
367
373
  // cache key. The markers `taskEnv` sets on every child (exec/env.ts)
368
374
  // name the task; the check is on the root so both shapes are caught.
375
+ // Compared canonical: a root reached through a symlink (`run({ cwd })`,
376
+ // macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
369
377
  const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
370
- if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
378
+ if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
371
379
  await teardown()
372
380
  prepared.cache.close()
373
381
  throw new UserError(
@@ -395,7 +403,8 @@ async function runOnBus(
395
403
  const reachedNone =
396
404
  options.affected !== undefined &&
397
405
  [...prepared.projects.values()].some(
398
- (p) => inScope.has(p.name) && options.tasks.some((t) => p.config.tasks?.[t] !== undefined),
406
+ (p) =>
407
+ inScope.has(p.name) && options.tasks.some((t) => declaredTask(p.config, t) !== undefined),
399
408
  )
400
409
  log.status(
401
410
  reachedNone
@@ -427,7 +436,7 @@ async function runOnBus(
427
436
  let telemetry: TelemetryHandle | undefined
428
437
  try {
429
438
  disposePlugins = await installPlugins({
430
- plugins: prepared.plugins as never,
439
+ plugins: prepared.plugins,
431
440
  bus,
432
441
  workspaceRoot: prepared.workspaceRoot,
433
442
  cacheDir: prepared.cacheDir,
@@ -930,6 +939,9 @@ async function runOnBus(
930
939
  const explainMiss =
931
940
  telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
932
941
  const lateProbes = probesAfterWrites(nodes, workspaceRoot)
942
+ // The scheduler's fail-fast stop, as the retry loop of a task already
943
+ // in flight sees it.
944
+ const failFast = new AbortController()
933
945
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
934
946
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
935
947
  const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
@@ -977,6 +989,7 @@ async function runOnBus(
977
989
  deferSave: saveLane.defer,
978
990
  deferredSaves,
979
991
  stopSignal: stopRun.signal,
992
+ failFast: failFast.signal,
980
993
  }
981
994
  }
982
995
 
@@ -1025,6 +1038,7 @@ async function runOnBus(
1025
1038
  ...(holders.size > 0 ? { exclusive: holders } : {}),
1026
1039
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
1027
1040
  serverDied,
1041
+ onFailFast: () => failFast.abort(),
1028
1042
  signal: stopRun.signal,
1029
1043
  onStart: (node) => {
1030
1044
  log.taskStart?.(node)
@@ -1076,8 +1090,6 @@ async function runOnBus(
1076
1090
  ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
1077
1091
 
1078
1092
  mark('run graph')
1079
- // Clear the status line for good before the summary prints.
1080
- log.runEnd?.()
1081
1093
 
1082
1094
  // A server that died on its own before the stop failed, whatever its
1083
1095
  // outcome said when it became ready: the footer counted it a success
@@ -1087,8 +1099,11 @@ async function runOnBus(
1087
1099
  const failServer = (id: string, code: number | string): void => {
1088
1100
  const o = outcomes.get(id)
1089
1101
  if (o === undefined) return
1090
- const exitCode = typeof code === 'number' ? code : signalExitCode(code)
1091
- outcomes.set(id, { ...o, status: 'failed', exitCode })
1102
+ // In place: the renderer holds this outcome (events carry live refs)
1103
+ // and closes the server's output block from it at runEnd, where a
1104
+ // copy left it reading `running` under its exit line (WD-10).
1105
+ o.status = 'failed'
1106
+ o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
1092
1107
  }
1093
1108
  for (const c of crashedPersistent) failServer(c.id, c.code)
1094
1109
  keepAlive.nodes.forEach((n, i) => {
@@ -1097,6 +1112,8 @@ async function runOnBus(
1097
1112
  if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
1098
1113
  failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
1099
1114
  })
1115
+ // Clear the status line for good before the summary prints.
1116
+ log.runEnd?.()
1100
1117
  const list = [...outcomes.values()]
1101
1118
  const ok = list.every((o) => isPassStatus(o.status))
1102
1119
 
@@ -1371,11 +1388,24 @@ async function runOnBus(
1371
1388
  }
1372
1389
  }
1373
1390
  if (keepAlive.children.length > 0) {
1374
- const first = await Promise.race(
1375
- keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
1376
- )
1391
+ // A server kept only as a dependency that exits 0 is a daemon that
1392
+ // forked and returned (schema.md), not the end of the session: a
1393
+ // `db: 'docker compose up -d'` under `app#dev` stopped the dev server
1394
+ // right after the summary (WD-3). It ends the wait only as the last.
1395
+ const first = await new Promise<{ code: number; i: number }>((resolve) => {
1396
+ let left = keepAlive.children.length
1397
+ keepAlive.children.forEach((c, i) => {
1398
+ void c.exited.then((code) => {
1399
+ left--
1400
+ const n = keepAlive.nodes[i]!
1401
+ if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
1402
+ })
1403
+ })
1404
+ })
1377
1405
  const node = keepAlive.nodes[first.i]!
1378
- const others = keepAlive.nodes.length - 1
1406
+ // Only the ones still up are stopped: a kept server already dead was
1407
+ // counted as one (WD-11).
1408
+ const others = keepAlive.children.filter((c, j) => j !== first.i && !hasEnded(c)).length
1379
1409
  // Not when the run was stopped: the server ended because the user
1380
1410
  // stopped it, and "exited with code 130" read as a crash after every
1381
1411
  // Ctrl-C once a stop let run() finish its own path (item 852).
@@ -1387,7 +1417,11 @@ async function runOnBus(
1387
1417
  : ''),
1388
1418
  )
1389
1419
  }
1390
- await terminateChildren(() => keepAlive.children)
1420
+ // Under a stop the abort is already taking them down with the stop's
1421
+ // signal; a second SIGTERM in its grace cut short the graceful
1422
+ // shutdown of a server that reads it as "quit now" (WD-16).
1423
+ if (stopRun.signal.aborted) await aborting?.catch(() => {})
1424
+ else await terminateChildren(() => keepAlive.children)
1391
1425
  // The server that ended the session on its own, not cleanly, failed:
1392
1426
  // the rewritten summary said `ok: false` over every task `success`
1393
1427
  // and `failed: 0`, and so did the outcomes `--report` renders (C-53).
@@ -1501,12 +1535,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1501
1535
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1502
1536
  // the history p50s) was booked under `close`, the next mark, and read
1503
1537
  // as 105 ms of closing a cache at 1,000 projects (item 601).
1538
+ const policy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
1504
1539
  const planned = await plan({
1505
1540
  nodes: prepared.nodes,
1506
1541
  workspaceRoot: prepared.workspaceRoot,
1507
1542
  workspaceFingerprint: prepared.workspaceFingerprint,
1508
1543
  cache: prepared.cache,
1509
- cachePolicy: effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer),
1544
+ cachePolicy: policy,
1510
1545
  forwardArgs: options.forwardArgs,
1511
1546
  nestedDirsByProject: prepared.nestedDirsByProject,
1512
1547
  gitFilesCache: prepared.gitFilesCache,
@@ -1523,6 +1558,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1523
1558
  // the same plugin-factory call `prepareRun` already makes for the
1524
1559
  // cache capability, so plan mode gains no new class of side effect.
1525
1560
  ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
1561
+ ...(await planRestorable(prepared, policy, options.forwardArgs)),
1526
1562
  })
1527
1563
  mark('plan')
1528
1564
  return planned
@@ -1539,6 +1575,39 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1539
1575
  }
1540
1576
  }
1541
1577
 
1578
+ /**
1579
+ * What the run's short-circuit would restore ahead of its deps, so the plan
1580
+ * can tell a server nobody needs from one that starts: `--dry` called a
1581
+ * server every dependant of which was a local hit `would exec`, and the run
1582
+ * never spawned it. The run's own gates (`shouldShortCircuit`, stable keys,
1583
+ * workspace-output reach), asked only when a server could idle.
1584
+ */
1585
+ async function planRestorable(
1586
+ prepared: PreparedRun,
1587
+ policy: CachePolicy,
1588
+ forwardArgs: readonly string[] | undefined,
1589
+ ): Promise<{ restorable?: Set<string> }> {
1590
+ const { nodes } = prepared
1591
+ const serves = [...nodes.values()].some(
1592
+ (n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
1593
+ )
1594
+ if (!serves || !shouldShortCircuit(nodes, policy, prepared.cache)) return {}
1595
+ const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
1596
+ const stable = await deriveStableKeys({
1597
+ nodes,
1598
+ cache: prepared.cache,
1599
+ workspaceRoot: prepared.workspaceRoot,
1600
+ workspaceFingerprint: prepared.workspaceFingerprint,
1601
+ forwardArgs,
1602
+ nestedDirsByProject: prepared.nestedDirsByProject,
1603
+ gitFilesCache: prepared.gitFilesCache,
1604
+ hashCache: prepared.hashCache,
1605
+ })
1606
+ return {
1607
+ restorable: new Set(stable.map((k) => k.node.id).filter((id) => !keptOut.has(id))),
1608
+ }
1609
+ }
1610
+
1542
1611
  /** The names a project outside the run's selection declares, and how to reach them. */
1543
1612
  function elsewhereHint(names: readonly string[]): string {
1544
1613
  if (names.length === 0) return ''
@@ -1652,7 +1721,7 @@ export function nxProjectTarget(
1652
1721
  if (colon <= 0 || spec.includes('#')) return undefined
1653
1722
  const [typed, task] = [spec.slice(0, colon), spec.slice(colon + 1)]
1654
1723
  const project = projectNamed(typed, projects)
1655
- return project === undefined || projects.get(project)?.config.tasks?.[task] === undefined
1724
+ return project === undefined || declaredTask(projects.get(project)?.config, task) === undefined
1656
1725
  ? undefined
1657
1726
  : `${project}#${task}`
1658
1727
  }
@@ -1692,3 +1761,12 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
1692
1761
  }
1693
1762
  return out
1694
1763
  }
1764
+
1765
+ /** `dir` with its links resolved; as written when it is gone (a stale marker). */
1766
+ function canonical(dir: string): string {
1767
+ try {
1768
+ return realpathSync(dir)
1769
+ } catch {
1770
+ return path.resolve(dir)
1771
+ }
1772
+ }