@vzn/vx 0.0.596 → 0.0.598

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/launcher.cjs +7 -3
  2. package/package.json +5 -5
  3. package/src/bin.ts +9 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +60 -31
  6. package/src/cache/git-inputs.ts +23 -0
  7. package/src/cache/index.ts +2 -0
  8. package/src/cache/inputs.ts +87 -32
  9. package/src/cache/key-fold.ts +1 -1
  10. package/src/cache/layer.ts +2 -1
  11. package/src/cache/layered-cache.ts +14 -9
  12. package/src/cache/output-index.ts +12 -7
  13. package/src/cache/run-history.ts +16 -8
  14. package/src/cli/cache.ts +2 -2
  15. package/src/cli/foreign-flags.ts +56 -2
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +6 -3
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +26 -20
  21. package/src/cli/run.ts +42 -15
  22. package/src/cli/select.ts +19 -5
  23. package/src/cli/show.ts +21 -27
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-cycle.ts +43 -0
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch.ts +50 -11
  28. package/src/cli/workspace-config.ts +3 -1
  29. package/src/config.ts +21 -0
  30. package/src/exec/executor.ts +1 -1
  31. package/src/exec/kill-tree.ts +121 -20
  32. package/src/exec/runner.ts +224 -48
  33. package/src/exec/sandbox-paths.ts +11 -0
  34. package/src/exec/sandbox-runtime.ts +187 -54
  35. package/src/exec/sandbox-violations.ts +59 -6
  36. package/src/graph/priorities.ts +1 -2
  37. package/src/graph/scheduler.ts +2 -2
  38. package/src/graph/task-graph.ts +145 -33
  39. package/src/index.ts +3 -1
  40. package/src/orchestrator/affected-tasks.ts +6 -2
  41. package/src/orchestrator/deferred-outputs.ts +3 -2
  42. package/src/orchestrator/doctor.ts +5 -3
  43. package/src/orchestrator/excluded-keys.ts +2 -13
  44. package/src/orchestrator/execute-task.ts +70 -28
  45. package/src/orchestrator/failure-mode.ts +38 -29
  46. package/src/orchestrator/framed-output.ts +5 -5
  47. package/src/orchestrator/hit-restore.ts +1 -1
  48. package/src/orchestrator/index.ts +0 -1
  49. package/src/orchestrator/keyed-projects.ts +6 -3
  50. package/src/orchestrator/lockfile-claim.ts +14 -7
  51. package/src/orchestrator/logger.ts +17 -7
  52. package/src/orchestrator/miss-save.ts +7 -11
  53. package/src/orchestrator/plan.ts +4 -20
  54. package/src/orchestrator/plugin-host.ts +7 -2
  55. package/src/orchestrator/plugin.ts +5 -2
  56. package/src/orchestrator/prepare.ts +4 -2
  57. package/src/orchestrator/projects.ts +1 -1
  58. package/src/orchestrator/run-artifacts.ts +2 -2
  59. package/src/orchestrator/run-context.ts +1 -1
  60. package/src/orchestrator/run-lock.ts +35 -25
  61. package/src/orchestrator/run.ts +2 -1
  62. package/src/orchestrator/sandbox-request.ts +31 -9
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +5 -9
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/upstream.ts +11 -0
  68. package/src/util/hangup.ts +42 -0
  69. package/src/util/index.ts +2 -0
  70. package/src/util/paths.ts +1 -1
  71. package/src/workspace/affected.ts +79 -42
  72. package/src/workspace/config-cache.ts +15 -1
  73. package/src/workspace/config-eval.ts +47 -7
  74. package/src/workspace/config-imports.ts +8 -55
  75. package/src/workspace/config-schema.ts +30 -6
  76. package/src/workspace/lockfile.ts +2 -1
  77. package/src/workspace/migrate-scripts.ts +1 -1
  78. package/src/workspace/project-loader.ts +33 -6
  79. package/src/workspace/workspace.ts +36 -14
@@ -334,7 +334,8 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
334
334
 
335
335
  function add(node: TaskNode): void {
336
336
  nodes.set(node.id, node)
337
- if ((node.config.dependsOn?.length ?? 0) > 0) {
337
+ if (isKeyedGroup(node.config) && onCycle(node.projectName)) deferred.push(node)
338
+ else if ((node.config.dependsOn?.length ?? 0) > 0) {
338
339
  stack.push({ node, entry: 0, added: false, pending: null, next: 0 })
339
340
  }
340
341
  }
@@ -444,10 +445,43 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
444
445
  }
445
446
  return component.get(start)!
446
447
  }
448
+ // Default builds on a package cycle, expanded once the rest is built.
449
+ const deferred: TaskNode[] = []
447
450
  const onCycle = (name: string): boolean => cyclic.has(componentOf(name))
448
451
  const sameCycle = (a: string, b: string): boolean =>
449
452
  onCycle(a) && componentOf(a) === componentOf(b)
450
453
 
454
+ // `^name` from `start`: edges to the nearest holders of `name` among the
455
+ // packages below it, passing through the ones `visited` holds and the
456
+ // ones that declare no `name`, and on past a default build on a cycle.
457
+ // True when it found a holder.
458
+ function walkHolders(frame: Frame, name: string, visited: Set<string>, start: string): boolean {
459
+ const frontier = [...packageGraph.directDeps(start)]
460
+ let held = false
461
+ while (frontier.length > 0) {
462
+ const target = frontier.pop()!
463
+ if (visited.has(target)) continue
464
+ visited.add(target)
465
+ if (visit(frame, target, name, false)) {
466
+ held = true
467
+ const task = declaredTask(projects.get(target)!.config, name)!
468
+ if (isKeyedGroup(task) && onCycle(target)) {
469
+ frontier.push(...packageGraph.directDeps(target))
470
+ }
471
+ } else frontier.push(...packageGraph.directDeps(target))
472
+ }
473
+ return held
474
+ }
475
+
476
+ // An edge by name (`build`, `pkg#build`) to a default build on a package
477
+ // cycle goes on past it as a `^build` walk does: that build folds nothing
478
+ // of the builds on its cycle, so `a#test` on `build` with `a` ↔ `b` both
479
+ // on the default build folded none of `b` and hit after `b` changed.
480
+ function goPast(frame: Frame, project: string, name: string, visited: Set<string>): void {
481
+ const task = declaredTask(projects.get(project)!.config, name)!
482
+ if (isKeyedGroup(task) && onCycle(project)) walkHolders(frame, name, visited, project)
483
+ }
484
+
451
485
  // Resolves one `dependsOn` entry of `frame`'s task into its edges.
452
486
  function resolveEntry(frame: Frame, raw: string): void {
453
487
  const { node } = frame
@@ -497,6 +531,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
497
531
  `Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
498
532
  )
499
533
  }
534
+ goPast(frame, projectName, spec.task, new Set([projectName]))
500
535
  }
501
536
  } else if (spec.kind === 'deps') {
502
537
  // Nearest-holder frontier (Turbo/Nx direct-deps parity +
@@ -521,45 +556,35 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
521
556
  // depend on itself.
522
557
  //
523
558
  // The default `build` sits on every project, so on a package cycle it
524
- // would make a task cycle no config declares. Its own walk passes
525
- // through the projects on its cycle (each of their builds reaches it),
526
- // and a walk that meets it on a cycle takes the edge and goes on past
527
- // it, so the builds it could not depend on still come first.
528
- const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
529
- const keyed = isKeyedGroup(node.config)
559
+ // would make a task cycle no config declares. One on a cycle walks
560
+ // last (`resolveDeferred`), and a walk that meets it on a cycle takes
561
+ // the edge and goes on past it, so the builds it could not depend on
562
+ // still come first.
530
563
  const visited = new Set<string>([projectName])
564
+ if (!isTaskPattern(spec.task)) {
565
+ const held = walkHolders(frame, spec.task, visited, projectName)
566
+ if (!held && !declaredAnywhere(projectName, spec.task)) {
567
+ if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
568
+ undeclaredDeps(id, spec.task)
569
+ }
570
+ return
571
+ }
572
+ // A pattern that matches nothing stays legal, as `build.*` does.
573
+ const re = compileTaskPattern(spec.task)
531
574
  const frontier = [...packageGraph.directDeps(projectName)]
532
- let held = false
533
575
  while (frontier.length > 0) {
534
576
  const target = frontier.pop()!
535
577
  if (visited.has(target)) continue
536
578
  visited.add(target)
537
- if (re === null) {
538
- if (keyed && sameCycle(projectName, target)) {
539
- frontier.push(...packageGraph.directDeps(target))
540
- } else if (visit(frame, target, spec.task, false)) {
541
- held = true
542
- const task = declaredTask(projects.get(target)!.config, spec.task)!
543
- if (isKeyedGroup(task) && onCycle(target)) {
544
- frontier.push(...packageGraph.directDeps(target))
545
- }
546
- } else frontier.push(...packageGraph.directDeps(target))
579
+ const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
580
+ re.test(n),
581
+ )
582
+ if (names.length > 0) {
583
+ for (const name of names) visit(frame, target, name, false)
547
584
  } else {
548
- const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
549
- re.test(n),
550
- )
551
- if (names.length > 0) {
552
- for (const name of names) visit(frame, target, name, false)
553
- } else {
554
- frontier.push(...packageGraph.directDeps(target))
555
- }
585
+ frontier.push(...packageGraph.directDeps(target))
556
586
  }
557
587
  }
558
- // A pattern that matches nothing stays legal, as `build.*` does.
559
- if (re === null && !held && !declaredAnywhere(projectName, spec.task)) {
560
- if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
561
- undeclaredDeps(id, spec.task)
562
- }
563
588
  } else {
564
589
  // Cross-project edge: pkg#task. Missing target is a hard error
565
590
  // because the user named the package + task explicitly.
@@ -573,11 +598,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
573
598
  `Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
574
599
  )
575
600
  }
601
+ goPast(frame, spec.project, spec.task, new Set([projectName, spec.project]))
576
602
  }
577
603
  }
578
604
 
579
- for (const { project, task } of requested) {
580
- visit(null, project, task, true)
605
+ const drain = (): void => {
581
606
  while (stack.length > 0) {
582
607
  const frame = stack[stack.length - 1]!
583
608
  const { node, pending } = frame
@@ -604,10 +629,97 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
604
629
  }
605
630
  }
606
631
 
632
+ for (const { project, task } of requested) {
633
+ visit(null, project, task, true)
634
+ drain()
635
+ }
636
+
637
+ // A default build on a package cycle: its `^build` walk, run once the
638
+ // graph holds everything else. A build on its cycle that reaches it is
639
+ // passed through (an edge to it would close a task cycle no config
640
+ // declares); one that does not is a holder like any other, so the default
641
+ // build waits for it and folds its key. Passed through unasked, `p#test`
642
+ // on `p#build` ran beside a `t#build` on no `^build` and hit after `t`
643
+ // changed. A build no other edge brought is expanded to ask, and pruned
644
+ // again when it reaches back. Each walk sees the edges the ones before it
645
+ // took, so no edge closes a cycle.
646
+ let expanded = false
647
+ const resolveDeferred = (x: TaskNode): void => {
648
+ // The default build's one entry is `^` and its own name.
649
+ const name = x.taskName
650
+ const frame: Frame = { node: x, entry: 0, added: false, pending: null, next: 0 }
651
+ let reaching: Set<string> | null = null
652
+ const visited = new Set<string>([x.projectName])
653
+ const frontier = [...packageGraph.directDeps(x.projectName)]
654
+ while (frontier.length > 0) {
655
+ const target = frontier.pop()!
656
+ if (visited.has(target)) continue
657
+ visited.add(target)
658
+ const task = declaredTask(projects.get(target)?.config, name)
659
+ if (task === undefined) {
660
+ frontier.push(...packageGraph.directDeps(target))
661
+ continue
662
+ }
663
+ const to = taskId(target, name)
664
+ if (sameCycle(x.projectName, target)) {
665
+ if (!isKeyedGroup(task) && !nodes.has(to)) {
666
+ visit(null, target, name, false)
667
+ drain()
668
+ expanded = true
669
+ reaching = null
670
+ }
671
+ if (isKeyedGroup(task) || (reaching ??= reachingTo(nodes, x.id)).has(to)) {
672
+ frontier.push(...packageGraph.directDeps(target))
673
+ continue
674
+ }
675
+ }
676
+ const size = nodes.size
677
+ frame.added = false
678
+ visit(frame, target, name, false)
679
+ drain()
680
+ if (nodes.size !== size) reaching = null
681
+ if (isKeyedGroup(task) && onCycle(target)) frontier.push(...packageGraph.directDeps(target))
682
+ }
683
+ x.deps = [...new Set(x.deps)].sort()
684
+ }
685
+ for (let i = 0; i < deferred.length; i++) resolveDeferred(deferred[i]!)
686
+ if (expanded) {
687
+ const keep = new Set<string>()
688
+ const roots = [...nodes.values()].filter((n) => n.requested).map((n) => n.id)
689
+ while (roots.length > 0) {
690
+ const id = roots.pop()!
691
+ if (keep.has(id)) continue
692
+ keep.add(id)
693
+ roots.push(...nodes.get(id)!.deps)
694
+ }
695
+ for (const id of nodes.keys()) if (!keep.has(id)) nodes.delete(id)
696
+ }
697
+
607
698
  checkGraph(nodes, options.workspaceRoot, options.rules)
608
699
  return nodes
609
700
  }
610
701
 
702
+ /** The ids of the tasks that reach `target` along `deps`, itself included. */
703
+ function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
704
+ const dependants = new Map<string, string[]>()
705
+ for (const n of nodes.values()) {
706
+ for (const d of n.deps) {
707
+ const list = dependants.get(d)
708
+ if (list === undefined) dependants.set(d, [n.id])
709
+ else list.push(n.id)
710
+ }
711
+ }
712
+ const out = new Set<string>()
713
+ const stack = [target]
714
+ while (stack.length > 0) {
715
+ const id = stack.pop()!
716
+ if (out.has(id)) continue
717
+ out.add(id)
718
+ stack.push(...(dependants.get(id) ?? []))
719
+ }
720
+ return out
721
+ }
722
+
611
723
  /**
612
724
  * What a task graph must hold before it is scheduled: each node under its
613
725
  * own id, every dep a node, no cycle, and no two tasks deleting each
package/src/index.ts CHANGED
@@ -34,7 +34,9 @@ export { isLiteralPattern, normalizeGlob } from './util/index.js'
34
34
  // A task's command as vx prints it: values of its `exec.env.secret` names
35
35
  // masked. `vx show` and telemetry show this; a plugin that hands a command
36
36
  // to someone else (`@vzn/vx-mcp`'s `listTasks`, an AI agent) does too (L-26).
37
- export { maskedCommand } from './util/index.js'
37
+ // `maskedLine` is the same for a line no one task owns: the error `vx mcp`
38
+ // hands an agent (L-11).
39
+ export { maskedCommand, maskedLine } from './util/index.js'
38
40
 
39
41
  // Schema types and helpers (used by user vx.config files and presets).
40
42
  export type {
@@ -4,7 +4,7 @@
4
4
  // behind `^build` runs when `ui#build` (or what it depends on) is reached,
5
5
  // and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
6
6
 
7
- import { declaresInput } from '../cache/index.js'
7
+ import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
8
8
  import {
9
9
  compileTaskPattern,
10
10
  isGroupTask,
@@ -91,7 +91,11 @@ export function affectedRoots(
91
91
  const cache = n.config.cache
92
92
  // Asked of every node, not only the changed projects' (the
93
93
  // `workspaceFiles` owners): a `graph` hook may have given the glob.
94
- if (cache !== undefined && changes.changed.some((rel) => declaresInput(cache, null, rel))) {
94
+ if (
95
+ cache !== undefined &&
96
+ (changes.changed.some((rel) => declaresInput(cache, null, rel)) ||
97
+ (changes.nested ?? []).some((dir) => workspaceFilesReachInto(cache, dir)))
98
+ ) {
95
99
  return true
96
100
  }
97
101
  if (!changes.projects.has(n.projectName)) return false
@@ -23,7 +23,8 @@ import type { TaskNode } from '../graph/index.js'
23
23
  export interface DeferredEntry {
24
24
  /** Fetches this task's outputs onto disk. At-most-once by construction. */
25
25
  materialize: () => Promise<void>
26
- hash: string
26
+ /** The key a fetch saves the bytes under; absent when the inputs moved after it was taken. */
27
+ hash?: string
27
28
  entry: { taskId: string; command: string; durationMs: number; stdout: string }
28
29
  }
29
30
 
@@ -149,7 +150,7 @@ export class DeferredOutputs {
149
150
  // Convergence: an ordinary entry, so the next run is a plain local hit
150
151
  // and nothing downstream learns a second shape. Policy still governs —
151
152
  // a run that writes no local entries writes none here either.
152
- if (this.args.localWrite) {
153
+ if (this.args.localWrite && entry.hash !== undefined) {
153
154
  await this.args.cache.save({
154
155
  hash: entry.hash,
155
156
  projectDir: producer.projectDir,
@@ -16,6 +16,7 @@ import {
16
16
  isUnsupportedBun,
17
17
  machineMemoryBytes,
18
18
  machineParallelism,
19
+ maskedLine,
19
20
  relPosix,
20
21
  } from '../util/index.js'
21
22
  import { VERSION } from '../version.js'
@@ -97,7 +98,7 @@ export interface InfoFacts {
97
98
  hits24h: number
98
99
  /** Of `hits24h`, the hits that restored outputs; the rest were up to date. */
99
100
  restored24h: number
100
- /** Tasks the retained history shows both passing and failing on unchanged inputs. */
101
+ /** Tasks the retained history shows failing on a key that had passed. */
101
102
  flakyTasks: FlakyTask[]
102
103
  lockfile: boolean
103
104
  /**
@@ -256,7 +257,7 @@ async function collectWorkspaceInfo(
256
257
  runs24h: stats.runCountLast24h,
257
258
  hits24h: stats.hitCountLast24h,
258
259
  restored24h: stats.restoredCountLast24h,
259
- // Same inputs, both outcomes — the history's definition of flaky, over
260
+ // A key that failed after it had passed — the history's definition of flaky, over
260
261
  // the 30 days it keeps. A run names its own findings in its footer;
261
262
  // this is the workspace's standing list.
262
263
  flakyTasks: flaky,
@@ -397,7 +398,8 @@ async function countLoadableTasks(
397
398
  : raw
398
399
  errors.push({
399
400
  path: relPosix(root, where),
400
- message,
401
+ // A config's own throw is its author's text, a secret too (L-11).
402
+ message: maskedLine(message),
401
403
  })
402
404
  }
403
405
  }),
@@ -16,7 +16,7 @@
16
16
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
17
17
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
18
18
  import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
19
- import { filterUpstreamHashes, keyedDeps } from './upstream.js'
19
+ import { filterUpstreamHashes, keyedDeps, keyedOutcome } from './upstream.js'
20
20
 
21
21
  export interface KeyExcludedArgs {
22
22
  /** The scheduled graph, after `excludeDependencies`. */
@@ -50,7 +50,7 @@ export async function keyExcludedDependencies(args: KeyExcludedArgs): Promise<vo
50
50
  // (item 737). The keys still resolve concurrently.
51
51
  const keys = new Map<string, Promise<string | undefined>>()
52
52
  const outcomes = (ids: readonly string[]): Promise<TaskOutcome[]> =>
53
- Promise.all(ids.map(async (id) => synthetic(nodeOf(id), await keys.get(id)!)))
53
+ Promise.all(ids.map(async (id) => keyedOutcome(nodeOf(id), await keys.get(id)!)))
54
54
  const derive = async (node: TaskNode): Promise<string | undefined> => {
55
55
  const upstream = await outcomes(fullDeps(node))
56
56
  return (isGroupTask(node) ? computeGroupKey : computeTaskHash)({
@@ -139,14 +139,3 @@ function foldsExcludedKey(node: TaskNode): boolean {
139
139
  .length > 0
140
140
  )
141
141
  }
142
-
143
- /** A dependency as a key reads it: only `node` and `hash` matter. */
144
- function synthetic(node: TaskNode, hash: string | undefined): TaskOutcome {
145
- return {
146
- node,
147
- status: 'success',
148
- exitCode: 0,
149
- durationMs: 0,
150
- ...(hash !== undefined ? { hash } : {}),
151
- }
152
- }
@@ -462,7 +462,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
462
462
  const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
463
463
  const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
464
464
  log.status(
465
- `vx: ${node.id} not ready after ${after}: waiting for a line matching /${readyWhen}/ (readyWhen)${unbounded}`,
465
+ `vx: ${node.id} not ready after ${after}: waiting for a line matching /${serverSecrets?.mask(readyWhen) ?? readyWhen}/ (readyWhen)${unbounded}`,
466
466
  )
467
467
  }, noticeMs)
468
468
  if (notice !== undefined) {
@@ -524,11 +524,10 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
524
524
  // exited before ready keeps its own exit code rather than a made-up 1.
525
525
  // One the readiness timeout is killing reports the signal's, as an
526
526
  // ordinary timeout does (X-24).
527
+ // A readiness timeout rejects once the group is gone (runner.ts), so a
528
+ // server that ignored the TERM holds no port into the next `vx watch`
529
+ // cycle, and a second signal here would cut a one-shot handler short.
527
530
  const ready = err instanceof PersistentReadyError ? err : undefined
528
- // The timer's SIGKILL is a grace away and the shell may die on the
529
- // TERM first: a server that ignores it held its port past run() into
530
- // the next `vx watch` cycle. Return once the group is gone.
531
- if (ready?.reason === 'timeout') await terminateChildren(() => [spawn.child])
532
531
  return {
533
532
  node,
534
533
  status: 'failed',
@@ -637,7 +636,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
637
636
  // `signal` stops a plugin executor on the run's stop AND on
638
637
  // `exec.timeout`: core cannot kill a process an executor spawned, so a
639
638
  // declared timeout meant nothing on one that kept its own clock (H-12).
640
- // The local executor keeps its own timer (it signals the process group).
639
+ // The local executor keeps its own timer from the spawn (it signals the
640
+ // process group), so its request carries no clock: armed here, it also
641
+ // timed the sandbox's wrap, and a wrap slower than the timeout aborted
642
+ // the request before the spawn — a task reported killed, never run.
641
643
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
642
644
  let timeoutFired = false
643
645
  // The attempt's listener on the run's stop signal, taken off once the
@@ -645,7 +647,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
645
647
  // grew to the task count, and each add scans it for a duplicate, ~100 ms
646
648
  // of a 1,000-task cold run.
647
649
  let unlistenStop: (() => void) | undefined
648
- function requestSignal(): AbortSignal {
650
+ function requestSignal(local: boolean): AbortSignal {
649
651
  const stop = new AbortController()
650
652
  const abort = (): void => stop.abort(args.stopSignal?.reason)
651
653
  unlistenStop?.()
@@ -656,9 +658,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
656
658
  run.addEventListener('abort', abort, { once: true })
657
659
  unlistenStop = () => run.removeEventListener('abort', abort)
658
660
  }
659
- if (effectiveTimeout !== undefined) {
660
- clearTimeout(timeoutTimer)
661
- timeoutFired = false
661
+ clearTimeout(timeoutTimer)
662
+ timeoutFired = false
663
+ if (effectiveTimeout !== undefined && !local) {
662
664
  timeoutTimer = setTimeout(() => {
663
665
  timeoutFired = true
664
666
  stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
@@ -808,7 +810,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
808
810
  }
809
811
  if (preProbed !== undefined) {
810
812
  if (preProbed.hit !== null) {
811
- const restored = await restoreOrMiss(preProbed.hit)
813
+ // A key taken before a task rewrote the lockfile names an install
814
+ // the tree no longer holds, and the lazy path below never probes
815
+ // one (X-124): demoted like a vanished hit, it runs dep-gated.
816
+ const restored = fingerprintMoved() ? null : await restoreOrMiss(preProbed.hit)
812
817
  if (restored !== null) return restored
813
818
  // The up-front probe's hit may be restoring AHEAD of this task's
814
819
  // deps, and a command run now would build from outputs they have
@@ -862,6 +867,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
862
867
  for (const c of captured)
863
868
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
864
869
  c.hash = MASKED + c.hash
870
+ // A runtime probe's row is named by its command, which a TS config may
871
+ // build from `process.env`: the run history stored it and `vx why`
872
+ // printed it (L-11).
873
+ const nameSecrets =
874
+ captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
875
+ if (nameSecrets !== null) {
876
+ // Two probes that differ only by a secret mask to one name, and a row
877
+ // is stored once per name: numbered, the second's change still shows.
878
+ const seen = new Set(captured.map((c) => `${c.kind}\0${c.name}`))
879
+ for (const c of captured) {
880
+ const masked = nameSecrets.mask(c.name)
881
+ if (masked === c.name) continue
882
+ let name = masked
883
+ for (let n = 2; seen.has(`${c.kind}\0${name}`); n++) name = `${masked} (${n})`
884
+ seen.add(`${c.kind}\0${name}`)
885
+ c.name = name
886
+ }
887
+ }
865
888
  const inputs: TaskInputs | undefined = described?.inputs
866
889
  const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
867
890
  // A plugin may keep the request past the run (the cache closed): it gets
@@ -917,6 +940,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
917
940
  async function runAttempt(): Promise<{
918
941
  result: ExecuteResult
919
942
  exitCode: number
943
+ withdrawn?: true
920
944
  }> {
921
945
  // A deferred task's outputs are deliberately NOT coming, so wiping the
922
946
  // tree would replace a stale build with nothing at all. The eligibility
@@ -952,8 +976,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
952
976
  }
953
977
  violations = []
954
978
  const endReq = span('miss: build request')
955
- const req = await buildRequest()
979
+ const req = await buildRequest(isLocalExecutor(args.executor))
956
980
  endReq()
981
+ // A sibling's failure under --continue=never can land while a retry
982
+ // cleans or builds its request, after the loop's own check: the retry
983
+ // is withdrawn and the attempt before it stands.
984
+ if (attempt > 1 && args.failFast?.aborted === true) {
985
+ clearTimeout(timeoutTimer)
986
+ unlistenStop?.()
987
+ unlistenStop = undefined
988
+ await sweepPlaceholders(placeholders)
989
+ return { result, exitCode: effectiveExitCode, withdrawn: true }
990
+ }
957
991
  // An executor that THROWS produces no captured output. Rethrown: the
958
992
  // scheduler classifies it and prints its one line into the task's own
959
993
  // stream (run()'s onError), where the frame reads it; a copy written
@@ -984,8 +1018,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
984
1018
  raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
985
1019
  } else {
986
1020
  log.status(`[vx] ${node.id}: ${why} — running it here`)
987
- clearTimeout(timeoutTimer)
988
- const local = await localExecutor().execute(await buildRequest())
1021
+ const local = await localExecutor().execute(await buildRequest(true))
989
1022
  assertExecuteResult('local', node.id, local)
990
1023
  return local
991
1024
  }
@@ -1016,11 +1049,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1016
1049
  stdout: maskCaptured(res.stdout, secrets),
1017
1050
  stderr: maskCaptured(res.stderr, secrets),
1018
1051
  }
1019
- // An executor that stopped on the timeout's abort exits non-zero; say
1020
- // why, so the frame, the retry line and `timedOut` read as a timeout.
1021
- if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
1022
- res = { ...res, timedOut: true }
1023
- }
1052
+ // An executor that stopped on the timeout's abort timed out, whatever
1053
+ // its exit: a child that traps the TERM and exits 0 left partial outputs
1054
+ // a pass would save (X-125), as the local runner's own `timedOut` says.
1055
+ // A local request carries no timer, so the runner alone decides there.
1056
+ if (timeoutFired && res.timedOut !== true) res = { ...res, timedOut: true }
1024
1057
  violations = [...res.violations]
1025
1058
  // A denial under a dependency the key does not answer for says only
1026
1059
  // ENOENT; the line beside it names the package and how to key it. Only
@@ -1167,10 +1200,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1167
1200
  for (;;) {
1168
1201
  attempt++
1169
1202
  const a = await runAttempt()
1170
- forgetUndeclaredWrites(args, writeReach)
1171
- if (writesFingerprint) args.fingerprintWatch?.wrote()
1172
1203
  result = a.result
1173
1204
  effectiveExitCode = a.exitCode
1205
+ if (a.withdrawn) {
1206
+ attempt--
1207
+ failedAttempts.pop()
1208
+ break
1209
+ }
1210
+ forgetUndeclaredWrites(args, writeReach)
1211
+ if (writesFingerprint) args.fingerprintWatch?.wrote()
1174
1212
  spentMs += result.durationMs
1175
1213
 
1176
1214
  // An attempt that ended in a shutdown never finished on its own terms —
@@ -1219,7 +1257,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1219
1257
  )
1220
1258
  }
1221
1259
 
1222
- async function buildRequest(): Promise<ExecuteRequest> {
1260
+ async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1223
1261
  const out = secrets && maskedEmitter(secrets, (t) => log.taskStdout(node, t))
1224
1262
  const err = secrets && maskedEmitter(secrets, (t) => log.taskStderr(node, t))
1225
1263
  flushMasked = () => {
@@ -1257,10 +1295,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1257
1295
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1258
1296
  ...(args.terminal === true ? { terminal: true as const } : {}),
1259
1297
  }
1260
- // The signal last: it arms `exec.timeout`, and the sandbox's arming
1298
+ // The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
1261
1299
  // below is vx's work, not the task's. Armed first, a 60 ms timeout
1262
1300
  // expired before the spawn and failed `echo` as timed out, unrun.
1263
- if (!userSandbox) return { ...base, signal: requestSignal() }
1301
+ if (!userSandbox) return { ...base, signal: requestSignal(local) }
1264
1302
  await args.armSandbox?.()
1265
1303
  const sb = await sandboxRequestFor(
1266
1304
  node,
@@ -1272,7 +1310,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1272
1310
  )
1273
1311
  placeholders = sb.placeholders
1274
1312
  withheld = sb.withheld
1275
- return { ...base, sandbox: sb.sandbox, signal: requestSignal() }
1313
+ return { ...base, sandbox: sb.sandbox, signal: requestSignal(local) }
1276
1314
  }
1277
1315
 
1278
1316
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
@@ -1283,12 +1321,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1283
1321
  // record (a row with no artifact) is exactly the corrupt-entry shape
1284
1322
  // `restoreOutputs` refuses, so writing none is the only clean answer.
1285
1323
  // The closure is what a later local consumer — or a later eager run —
1286
- // pulls the bytes with.
1324
+ // pulls the bytes with. Its fetch saves them under `hash` only past the
1325
+ // eager save's check: an input edited before the describe filed the
1326
+ // edit's bytes under the old key (X-123).
1287
1327
  const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
1288
1328
  if (materialize !== undefined) {
1329
+ const keyed = await keyStillTrue()
1289
1330
  args.deferred?.register(node.id, {
1290
1331
  materialize,
1291
- hash,
1332
+ ...(keyed ? { hash } : {}),
1292
1333
  entry: {
1293
1334
  taskId: node.id,
1294
1335
  command: storedCommand,
@@ -1470,7 +1511,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1470
1511
  ? {
1471
1512
  // vx's own notes ride with the lines and are no denial (B-20).
1472
1513
  sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
1473
- sandboxViolationLines: finalViolations.map((v) => v.line),
1514
+ // A denied path is the task's own spelling, a secret in it too.
1515
+ sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
1474
1516
  }
1475
1517
  : {}),
1476
1518
  }